Guilherme Silberfarb Costa commited on
Commit
c485ae0
·
1 Parent(s): 5c357b1

inclusao de mais avaliandos na pesquisa

Browse files
backend/app/api/pesquisa.py CHANGED
@@ -1,6 +1,8 @@
1
  from __future__ import annotations
2
 
3
- from fastapi import APIRouter, Query
 
 
4
  from pydantic import BaseModel
5
 
6
  from app.services.pesquisa_service import (
@@ -22,11 +24,36 @@ def _split_csv(value: str | None) -> list[str]:
22
  return [item.strip() for item in value.split(",") if item.strip()]
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  class MapaModelosPayload(BaseModel):
26
  modelos_ids: list[str]
27
  avaliando_lat: float | None = None
28
  avaliando_lon: float | None = None
 
29
  modo_exibicao: str | None = "pontos"
 
30
  trabalhos_tecnicos_modelos_modo: str | None = None
31
  trabalhos_tecnicos_proximidade_modo: str | None = None
32
  trabalhos_tecnicos_modo: str | None = None
@@ -102,8 +129,10 @@ def pesquisar_modelos(
102
  aval_valor_total_colunas: str | None = Query(None),
103
  aval_lat: float | None = Query(None),
104
  aval_lon: float | None = Query(None),
 
105
  limite: int = Query(300, ge=1, le=2000),
106
  ) -> dict:
 
107
  filtros = PesquisaFiltros(
108
  otica=otica,
109
  nome=nome,
@@ -148,6 +177,7 @@ def pesquisar_modelos(
148
  aval_valor_total_colunas=_split_csv(aval_valor_total_colunas),
149
  aval_lat=aval_lat,
150
  aval_lon=aval_lon,
 
151
  somente_versoes_atuais=somente_versoes_atuais,
152
  )
153
  return listar_modelos(filtros=filtros, limite=limite, somente_contexto=somente_contexto)
@@ -178,7 +208,9 @@ def pesquisar_mapa_modelos(payload: MapaModelosPayload) -> dict:
178
  payload.modelos_ids,
179
  avaliando_lat=payload.avaliando_lat,
180
  avaliando_lon=payload.avaliando_lon,
 
181
  modo_exibicao=payload.modo_exibicao,
 
182
  trabalhos_tecnicos_modelos_modo=modelos_modo,
183
  trabalhos_tecnicos_proximidade_modo=proximidade_modo,
184
  trabalhos_tecnicos_raio_m=payload.trabalhos_tecnicos_raio_m,
 
1
  from __future__ import annotations
2
 
3
+ import json
4
+
5
+ from fastapi import APIRouter, HTTPException, Query
6
  from pydantic import BaseModel
7
 
8
  from app.services.pesquisa_service import (
 
24
  return [item.strip() for item in value.split(",") if item.strip()]
25
 
26
 
27
+ def _parse_avaliandos_geo_json(raw: str | None) -> list[dict]:
28
+ if not raw:
29
+ return []
30
+ try:
31
+ data = json.loads(raw)
32
+ except Exception as exc:
33
+ raise HTTPException(status_code=400, detail="Lista de avaliandos geolocalizados invalida") from exc
34
+ if not isinstance(data, list):
35
+ raise HTTPException(status_code=400, detail="Lista de avaliandos geolocalizados invalida")
36
+ return [item for item in data if isinstance(item, dict)]
37
+
38
+
39
+ class AvaliandoGeoPayload(BaseModel):
40
+ id: str | None = None
41
+ label: str | None = None
42
+ lat: float | None = None
43
+ lon: float | None = None
44
+ logradouro: str | None = None
45
+ numero_usado: str | None = None
46
+ cdlog: int | None = None
47
+ origem: str | None = None
48
+
49
+
50
  class MapaModelosPayload(BaseModel):
51
  modelos_ids: list[str]
52
  avaliando_lat: float | None = None
53
  avaliando_lon: float | None = None
54
+ avaliandos: list[AvaliandoGeoPayload] | None = None
55
  modo_exibicao: str | None = "pontos"
56
+ criterio_espacial: str | None = None
57
  trabalhos_tecnicos_modelos_modo: str | None = None
58
  trabalhos_tecnicos_proximidade_modo: str | None = None
59
  trabalhos_tecnicos_modo: str | None = None
 
129
  aval_valor_total_colunas: str | None = Query(None),
130
  aval_lat: float | None = Query(None),
131
  aval_lon: float | None = Query(None),
132
+ avaliandos_geo_json: str | None = Query(None),
133
  limite: int = Query(300, ge=1, le=2000),
134
  ) -> dict:
135
+ avaliandos_geo = _parse_avaliandos_geo_json(avaliandos_geo_json)
136
  filtros = PesquisaFiltros(
137
  otica=otica,
138
  nome=nome,
 
177
  aval_valor_total_colunas=_split_csv(aval_valor_total_colunas),
178
  aval_lat=aval_lat,
179
  aval_lon=aval_lon,
180
+ avaliandos_geo=avaliandos_geo,
181
  somente_versoes_atuais=somente_versoes_atuais,
182
  )
183
  return listar_modelos(filtros=filtros, limite=limite, somente_contexto=somente_contexto)
 
208
  payload.modelos_ids,
209
  avaliando_lat=payload.avaliando_lat,
210
  avaliando_lon=payload.avaliando_lon,
211
+ avaliandos=[item.model_dump() for item in (payload.avaliandos or [])],
212
  modo_exibicao=payload.modo_exibicao,
213
+ criterio_espacial=payload.criterio_espacial,
214
  trabalhos_tecnicos_modelos_modo=modelos_modo,
215
  trabalhos_tecnicos_proximidade_modo=proximidade_modo,
216
  trabalhos_tecnicos_raio_m=payload.trabalhos_tecnicos_raio_m,
backend/app/api/visualizacao.py CHANGED
@@ -19,8 +19,13 @@ class SessionPayload(BaseModel):
19
  session_id: str
20
 
21
 
 
 
 
 
22
  class MapaPayload(SessionPayload):
23
  variavel_mapa: str | None = None
 
24
 
25
 
26
  class MapaPopupPayload(SessionPayload):
@@ -95,10 +100,11 @@ def repositorio_carregar(payload: RepositorioModeloPayload, request: Request) ->
95
 
96
 
97
  @router.post("/exibir")
98
- def exibir(payload: SessionPayload, request: Request) -> dict[str, Any]:
99
  session = session_store.get(payload.session_id)
100
  return visualizacao_service.exibir_modelo(
101
  session,
 
102
  api_base_url=str(request.base_url).rstrip("/"),
103
  popup_auth_token=getattr(request.state, "auth_token", None),
104
  )
@@ -116,6 +122,7 @@ def map_update(payload: MapaPayload, request: Request) -> dict[str, Any]:
116
  return visualizacao_service.atualizar_mapa(
117
  session,
118
  payload.variavel_mapa,
 
119
  api_base_url=str(request.base_url).rstrip("/"),
120
  popup_auth_token=getattr(request.state, "auth_token", None),
121
  )
 
19
  session_id: str
20
 
21
 
22
+ class ExibirPayload(SessionPayload):
23
+ trabalhos_tecnicos_modelos_modo: str | None = None
24
+
25
+
26
  class MapaPayload(SessionPayload):
27
  variavel_mapa: str | None = None
28
+ trabalhos_tecnicos_modelos_modo: str | None = None
29
 
30
 
31
  class MapaPopupPayload(SessionPayload):
 
100
 
101
 
102
  @router.post("/exibir")
103
+ def exibir(payload: ExibirPayload, request: Request) -> dict[str, Any]:
104
  session = session_store.get(payload.session_id)
105
  return visualizacao_service.exibir_modelo(
106
  session,
107
+ trabalhos_tecnicos_modelos_modo=payload.trabalhos_tecnicos_modelos_modo,
108
  api_base_url=str(request.base_url).rstrip("/"),
109
  popup_auth_token=getattr(request.state, "auth_token", None),
110
  )
 
122
  return visualizacao_service.atualizar_mapa(
123
  session,
124
  payload.variavel_mapa,
125
+ trabalhos_tecnicos_modelos_modo=payload.trabalhos_tecnicos_modelos_modo,
126
  api_base_url=str(request.base_url).rstrip("/"),
127
  popup_auth_token=getattr(request.state, "auth_token", None),
128
  )
backend/app/services/pesquisa_service.py CHANGED
@@ -162,6 +162,10 @@ TRABALHOS_TECNICOS_PROXIMIDADE_DESATIVADA = "sem_proximidade"
162
  TRABALHOS_TECNICOS_PROXIMIDADE_ATIVADA = "proximos_ao_avaliando"
163
  TRABALHOS_TECNICOS_RAIO_PADRAO_M = 1000
164
  TRABALHOS_TECNICOS_RAIO_MAX_M = 5000
 
 
 
 
165
  VERSAO_MODELO_RE = re.compile(r"^(?P<base>.*?)(?P<sufixo>[A-Za-z])$")
166
 
167
 
@@ -210,6 +214,7 @@ class PesquisaFiltros:
210
  aval_valor_total_colunas: list[str] | None = None
211
  aval_lat: float | None = None
212
  aval_lon: float | None = None
 
213
  somente_versoes_atuais: bool = True
214
 
215
 
@@ -284,6 +289,9 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
284
  admin_fontes = _carregar_fontes_admin(colunas_filtro)
285
  sugestoes = _extrair_sugestoes(todos, admin_fontes)
286
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(filtros_exec.aval_lat, filtros_exec.aval_lon)
 
 
 
287
  ids_versoes_antigas = _ids_versoes_antigas(todos) if filtros_exec.somente_versoes_atuais else set()
288
 
289
  if somente_contexto:
@@ -341,6 +349,7 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
341
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
342
  "aval_lat": aval_lat,
343
  "aval_lon": aval_lon,
 
344
  "somente_versoes_atuais": bool(filtros_exec.somente_versoes_atuais),
345
  },
346
  }
@@ -355,7 +364,16 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
355
  if ids_versoes_antigas:
356
  filtrados = [item for item in filtrados if str(item.get("id") or "") not in ids_versoes_antigas]
357
 
358
- if aval_lat is not None and aval_lon is not None:
 
 
 
 
 
 
 
 
 
359
  filtrados = [_anexar_distancia_modelo(item, aval_lat, aval_lon) for item in filtrados]
360
  filtrados.sort(
361
  key=lambda item: (
@@ -424,6 +442,7 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
424
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
425
  "aval_lat": aval_lat,
426
  "aval_lon": aval_lon,
 
427
  "somente_versoes_atuais": bool(filtros_exec.somente_versoes_atuais),
428
  },
429
  }
@@ -693,7 +712,9 @@ def gerar_mapa_modelos(
693
  limite_pontos_por_modelo: int = 0,
694
  avaliando_lat: float | None = None,
695
  avaliando_lon: float | None = None,
 
696
  modo_exibicao: str | None = "pontos",
 
697
  trabalhos_tecnicos_modelos_modo: str | None = TRABALHOS_TECNICOS_MODELOS_SELECIONADOS,
698
  trabalhos_tecnicos_proximidade_modo: str | None = TRABALHOS_TECNICOS_PROXIMIDADE_DESATIVADA,
699
  trabalhos_tecnicos_raio_m: int | float | None = TRABALHOS_TECNICOS_RAIO_PADRAO_M,
@@ -725,10 +746,19 @@ def gerar_mapa_modelos(
725
  trabalhos_tecnicos_proximidade_modo
726
  )
727
  trabalhos_tecnicos_raio_m_norm = _normalizar_raio_trabalhos_tecnicos_mapa(trabalhos_tecnicos_raio_m)
 
728
 
729
  modelos_plotados: list[dict[str, Any]] = []
730
  bounds: list[list[float]] = []
731
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
 
 
 
 
 
 
 
 
732
  chaves_modelos_selecionados: list[str] = []
733
  familias_versoes = (
734
  _montar_familias_versoes_modelos(list(caminhos_por_id.values()))
@@ -740,8 +770,11 @@ def gerar_mapa_modelos(
740
  if _modo_trabalhos_tecnicos_inclui_outras_versoes(trabalhos_tecnicos_modelos_modo_norm)
741
  else {}
742
  )
743
- if aval_lat is not None and aval_lon is not None:
744
- bounds.append([aval_lat, aval_lon])
 
 
 
745
 
746
  for idx, (modelo_id, caminho) in enumerate(selecionados):
747
  resumo = _carregar_resumo_com_cache(caminho)
@@ -756,7 +789,19 @@ def gerar_mapa_modelos(
756
  geometria = _carregar_geometria_modelo_com_cache(caminho)
757
  cor = MAP_COLORS[idx % len(MAP_COLORS)]
758
  nome = str(resumo.get("nome_modelo") or modelo_id)
759
- distancia_info = _calcular_distancia_geometria_cache(geometria, aval_lat, aval_lon)
 
 
 
 
 
 
 
 
 
 
 
 
760
  aliases_modelo = _resolver_aliases_modelo_para_trabalhos_tecnicos(
761
  modelo_id=modelo_id,
762
  caminho=caminho,
@@ -778,6 +823,8 @@ def gerar_mapa_modelos(
778
  "geometria": geometria,
779
  "distancia_km": distancia_info.get("distancia_km"),
780
  "distancia_label": distancia_info.get("distancia_label"),
 
 
781
  "avaliandos_tecnicos": avaliandos_tecnicos,
782
  }
783
  )
@@ -786,7 +833,8 @@ def gerar_mapa_modelos(
786
 
787
  avaliandos_tecnicos_proximos: list[dict[str, Any]] | None = None
788
  if (
789
- aval_lat is not None
 
790
  and aval_lon is not None
791
  and trabalhos_tecnicos_proximidade_modo_norm == TRABALHOS_TECNICOS_PROXIMIDADE_ATIVADA
792
  ):
@@ -815,8 +863,7 @@ def gerar_mapa_modelos(
815
  "pontos": _renderizar_mapa_modelos(
816
  modelos_plotados,
817
  bounds,
818
- aval_lat,
819
- aval_lon,
820
  "pontos",
821
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
822
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
@@ -824,8 +871,7 @@ def gerar_mapa_modelos(
824
  "cobertura": _renderizar_mapa_modelos(
825
  modelos_plotados,
826
  bounds,
827
- aval_lat,
828
- aval_lon,
829
  "cobertura",
830
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
831
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
@@ -869,6 +915,8 @@ def gerar_mapa_modelos(
869
  "total_pontos": modelo["total_pontos"],
870
  "distancia_km": modelo.get("distancia_km"),
871
  "distancia_label": modelo.get("distancia_label"),
 
 
872
  }
873
  for modelo in modelos_plotados
874
  ],
@@ -877,6 +925,8 @@ def gerar_mapa_modelos(
877
  "lon": aval_lon,
878
  "ativo": bool(aval_lat is not None and aval_lon is not None),
879
  },
 
 
880
  "modo_exibicao": modo_exibicao_norm,
881
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
882
  "trabalhos_tecnicos_proximidade_modo": trabalhos_tecnicos_proximidade_modo_norm,
@@ -885,7 +935,11 @@ def gerar_mapa_modelos(
885
  "total_trabalhos_tecnicos_proximos": total_trabalhos_proximos,
886
  "status": (
887
  f"Mapas de pontos e cobertura gerados com {len(modelos_plotados)} modelo(s) e {total_pontos} ponto(s)"
888
- + (" com avaliando destacado." if aval_lat is not None and aval_lon is not None else ".")
 
 
 
 
889
  + detalhe_trabalhos
890
  ),
891
  }
@@ -925,17 +979,34 @@ def _normalizar_raio_trabalhos_tecnicos_mapa(value: Any) -> int:
925
  return max(0, min(int(TRABALHOS_TECNICOS_RAIO_MAX_M), int(round(numero))))
926
 
927
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
928
  def _renderizar_mapa_modelos(
929
  modelos_plotados: list[dict[str, Any]],
930
  bounds: list[list[float]],
931
- aval_lat: float | None,
932
- aval_lon: float | None,
933
  modo_exibicao: str,
934
  avaliandos_tecnicos_proximos: list[dict[str, Any]] | None = None,
935
  trabalhos_tecnicos_raio_m: int | None = None,
936
  ) -> str:
937
  renderizar_pontos = modo_exibicao == "pontos"
938
  renderizar_cobertura = modo_exibicao == "cobertura"
 
 
 
939
  centro_lat = sum(coord[0] for coord in bounds) / len(bounds)
940
  centro_lon = sum(coord[1] for coord in bounds) / len(bounds)
941
 
@@ -948,7 +1019,8 @@ def _renderizar_mapa_modelos(
948
  folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
949
  folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
950
  add_bairros_layer(mapa, show=True)
951
- camada_avaliando = folium.FeatureGroup(name="Avaliando", show=True)
 
952
  camada_trabalhos_proximos = None
953
  if avaliandos_tecnicos_proximos is not None:
954
  label_raio = f" (ate {int(trabalhos_tecnicos_raio_m or 0)} m)" if trabalhos_tecnicos_raio_m is not None else ""
@@ -972,8 +1044,9 @@ def _renderizar_mapa_modelos(
972
 
973
  if renderizar_pontos:
974
  tooltip_modelo = nome_layer
975
- if modelo.get("distancia_label"):
976
- tooltip_modelo = f'{tooltip_modelo} • Distancia: {modelo["distancia_label"]}'
 
977
  for ponto in modelo["pontos"]:
978
  marcador = folium.CircleMarker(
979
  location=[ponto["lat"], ponto["lon"]],
@@ -1011,12 +1084,35 @@ def _renderizar_mapa_modelos(
1011
  add_trabalhos_tecnicos_markers(camada_trabalhos_proximos, avaliandos_tecnicos_proximos or [])
1012
  camada_trabalhos_proximos.add_to(mapa)
1013
 
1014
- if aval_lat is not None and aval_lon is not None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1015
  folium.Marker(
1016
- location=[aval_lat, aval_lon],
1017
- tooltip="Avaliando",
1018
- icon=folium.Icon(color="red", icon="home", prefix="glyphicon"),
1019
  ).add_to(camada_avaliando)
 
1020
  camada_avaliando.add_to(mapa)
1021
 
1022
  plugins.Fullscreen().add_to(mapa)
@@ -1796,6 +1892,136 @@ def _normalizar_coordenadas_avaliando(lat_raw: Any, lon_raw: Any) -> tuple[float
1796
  return float(lat), float(lon)
1797
 
1798
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1799
  def _anexar_distancia_modelo(modelo: dict[str, Any], aval_lat: float, aval_lon: float) -> dict[str, Any]:
1800
  item = dict(modelo)
1801
  caminho_texto = str(item.get("_caminho_modelo") or "").strip()
@@ -2130,8 +2356,9 @@ def _adicionar_geometria_modelo_no_mapa(
2130
  return
2131
 
2132
  tooltip = str(modelo.get("nome") or "").strip() or "Modelo"
2133
- if modelo.get("distancia_label"):
2134
- tooltip = f'{tooltip} • Distancia: {modelo["distancia_label"]}'
 
2135
 
2136
  geom_type = str(geometria.get("geom_type") or "")
2137
  cor = str(modelo.get("cor") or "#1f77b4")
 
162
  TRABALHOS_TECNICOS_PROXIMIDADE_ATIVADA = "proximos_ao_avaliando"
163
  TRABALHOS_TECNICOS_RAIO_PADRAO_M = 1000
164
  TRABALHOS_TECNICOS_RAIO_MAX_M = 5000
165
+ CRITERIO_ESPACIAL_MENOR_DISTANCIA = "menor_distancia"
166
+ CRITERIO_ESPACIAL_MEDIA_DISTANCIA = "media_distancia"
167
+ CRITERIO_ESPACIAL_MAIOR_DISTANCIA = "maior_distancia"
168
+ CRITERIO_ESPACIAL_PADRAO = CRITERIO_ESPACIAL_MAIOR_DISTANCIA
169
  VERSAO_MODELO_RE = re.compile(r"^(?P<base>.*?)(?P<sufixo>[A-Za-z])$")
170
 
171
 
 
214
  aval_valor_total_colunas: list[str] | None = None
215
  aval_lat: float | None = None
216
  aval_lon: float | None = None
217
+ avaliandos_geo: list[dict[str, Any]] | None = None
218
  somente_versoes_atuais: bool = True
219
 
220
 
 
289
  admin_fontes = _carregar_fontes_admin(colunas_filtro)
290
  sugestoes = _extrair_sugestoes(todos, admin_fontes)
291
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(filtros_exec.aval_lat, filtros_exec.aval_lon)
292
+ avaliandos_geo = _normalizar_avaliandos_geo(filtros_exec.avaliandos_geo)
293
+ if (aval_lat is None or aval_lon is None) and len(avaliandos_geo) == 1:
294
+ aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliandos_geo[0].get("lat"), avaliandos_geo[0].get("lon"))
295
  ids_versoes_antigas = _ids_versoes_antigas(todos) if filtros_exec.somente_versoes_atuais else set()
296
 
297
  if somente_contexto:
 
349
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
350
  "aval_lat": aval_lat,
351
  "aval_lon": aval_lon,
352
+ "avaliandos_geo": avaliandos_geo,
353
  "somente_versoes_atuais": bool(filtros_exec.somente_versoes_atuais),
354
  },
355
  }
 
364
  if ids_versoes_antigas:
365
  filtrados = [item for item in filtrados if str(item.get("id") or "") not in ids_versoes_antigas]
366
 
367
+ if len(avaliandos_geo) > 1:
368
+ filtrados = [_anexar_distancias_modelo_multiplos(item, avaliandos_geo) for item in filtrados]
369
+ filtrados.sort(
370
+ key=lambda item: (
371
+ item.get("distancia_resumo", {}).get("principal_distancia_km") is None,
372
+ float(item.get("distancia_resumo", {}).get("principal_distancia_km") or 0.0),
373
+ str(item.get("nome_modelo") or item.get("arquivo") or item.get("id") or "").lower(),
374
+ )
375
+ )
376
+ elif aval_lat is not None and aval_lon is not None:
377
  filtrados = [_anexar_distancia_modelo(item, aval_lat, aval_lon) for item in filtrados]
378
  filtrados.sort(
379
  key=lambda item: (
 
442
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
443
  "aval_lat": aval_lat,
444
  "aval_lon": aval_lon,
445
+ "avaliandos_geo": avaliandos_geo,
446
  "somente_versoes_atuais": bool(filtros_exec.somente_versoes_atuais),
447
  },
448
  }
 
712
  limite_pontos_por_modelo: int = 0,
713
  avaliando_lat: float | None = None,
714
  avaliando_lon: float | None = None,
715
+ avaliandos: list[dict[str, Any]] | None = None,
716
  modo_exibicao: str | None = "pontos",
717
+ criterio_espacial: str | None = CRITERIO_ESPACIAL_PADRAO,
718
  trabalhos_tecnicos_modelos_modo: str | None = TRABALHOS_TECNICOS_MODELOS_SELECIONADOS,
719
  trabalhos_tecnicos_proximidade_modo: str | None = TRABALHOS_TECNICOS_PROXIMIDADE_DESATIVADA,
720
  trabalhos_tecnicos_raio_m: int | float | None = TRABALHOS_TECNICOS_RAIO_PADRAO_M,
 
746
  trabalhos_tecnicos_proximidade_modo
747
  )
748
  trabalhos_tecnicos_raio_m_norm = _normalizar_raio_trabalhos_tecnicos_mapa(trabalhos_tecnicos_raio_m)
749
+ criterio_espacial_norm = _normalizar_criterio_espacial(criterio_espacial)
750
 
751
  modelos_plotados: list[dict[str, Any]] = []
752
  bounds: list[list[float]] = []
753
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
754
+ avaliandos_geo = _normalizar_avaliandos_geo(avaliandos)
755
+ if not avaliandos_geo and aval_lat is not None and aval_lon is not None:
756
+ avaliandos_geo = [{"id": "avaliando", "label": "Avaliando", "lat": aval_lat, "lon": aval_lon}]
757
+ avaliando_unico = avaliandos_geo[0] if len(avaliandos_geo) == 1 else None
758
+ if avaliando_unico is not None:
759
+ aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_unico.get("lat"), avaliando_unico.get("lon"))
760
+ else:
761
+ aval_lat, aval_lon = None, None
762
  chaves_modelos_selecionados: list[str] = []
763
  familias_versoes = (
764
  _montar_familias_versoes_modelos(list(caminhos_por_id.values()))
 
770
  if _modo_trabalhos_tecnicos_inclui_outras_versoes(trabalhos_tecnicos_modelos_modo_norm)
771
  else {}
772
  )
773
+ for avaliando in avaliandos_geo:
774
+ lat_item, lon_item = _normalizar_coordenadas_avaliando(avaliando.get("lat"), avaliando.get("lon"))
775
+ if lat_item is None or lon_item is None:
776
+ continue
777
+ bounds.append([lat_item, lon_item])
778
 
779
  for idx, (modelo_id, caminho) in enumerate(selecionados):
780
  resumo = _carregar_resumo_com_cache(caminho)
 
789
  geometria = _carregar_geometria_modelo_com_cache(caminho)
790
  cor = MAP_COLORS[idx % len(MAP_COLORS)]
791
  nome = str(resumo.get("nome_modelo") or modelo_id)
792
+ distancias_avaliandos, distancia_resumo = _calcular_distancias_avaliandos(
793
+ geometria,
794
+ avaliandos_geo,
795
+ criterio_espacial_norm,
796
+ )
797
+ distancia_info = (
798
+ {
799
+ "distancia_km": distancia_resumo.get("principal_distancia_km"),
800
+ "distancia_label": distancia_resumo.get("principal_distancia_label"),
801
+ }
802
+ if len(avaliandos_geo) > 1
803
+ else _calcular_distancia_geometria_cache(geometria, aval_lat, aval_lon)
804
+ )
805
  aliases_modelo = _resolver_aliases_modelo_para_trabalhos_tecnicos(
806
  modelo_id=modelo_id,
807
  caminho=caminho,
 
823
  "geometria": geometria,
824
  "distancia_km": distancia_info.get("distancia_km"),
825
  "distancia_label": distancia_info.get("distancia_label"),
826
+ "distancias_avaliandos": distancias_avaliandos,
827
+ "distancia_resumo": distancia_resumo,
828
  "avaliandos_tecnicos": avaliandos_tecnicos,
829
  }
830
  )
 
833
 
834
  avaliandos_tecnicos_proximos: list[dict[str, Any]] | None = None
835
  if (
836
+ avaliando_unico is not None
837
+ and aval_lat is not None
838
  and aval_lon is not None
839
  and trabalhos_tecnicos_proximidade_modo_norm == TRABALHOS_TECNICOS_PROXIMIDADE_ATIVADA
840
  ):
 
863
  "pontos": _renderizar_mapa_modelos(
864
  modelos_plotados,
865
  bounds,
866
+ avaliandos_geo,
 
867
  "pontos",
868
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
869
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
 
871
  "cobertura": _renderizar_mapa_modelos(
872
  modelos_plotados,
873
  bounds,
874
+ avaliandos_geo,
 
875
  "cobertura",
876
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
877
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
 
915
  "total_pontos": modelo["total_pontos"],
916
  "distancia_km": modelo.get("distancia_km"),
917
  "distancia_label": modelo.get("distancia_label"),
918
+ "distancia_resumo": modelo.get("distancia_resumo"),
919
+ "distancias_avaliandos": modelo.get("distancias_avaliandos"),
920
  }
921
  for modelo in modelos_plotados
922
  ],
 
925
  "lon": aval_lon,
926
  "ativo": bool(aval_lat is not None and aval_lon is not None),
927
  },
928
+ "avaliandos": avaliandos_geo,
929
+ "criterio_espacial": criterio_espacial_norm,
930
  "modo_exibicao": modo_exibicao_norm,
931
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
932
  "trabalhos_tecnicos_proximidade_modo": trabalhos_tecnicos_proximidade_modo_norm,
 
935
  "total_trabalhos_tecnicos_proximos": total_trabalhos_proximos,
936
  "status": (
937
  f"Mapas de pontos e cobertura gerados com {len(modelos_plotados)} modelo(s) e {total_pontos} ponto(s)"
938
+ + (
939
+ " com avaliando destacado."
940
+ if avaliando_unico is not None and aval_lat is not None and aval_lon is not None
941
+ else (f" com {len(avaliandos_geo)} avaliandos destacados." if len(avaliandos_geo) > 1 else ".")
942
+ )
943
  + detalhe_trabalhos
944
  ),
945
  }
 
979
  return max(0, min(int(TRABALHOS_TECNICOS_RAIO_MAX_M), int(round(numero))))
980
 
981
 
982
+ def _montar_tooltip_distancia_modelo(modelo: dict[str, Any]) -> str:
983
+ resumo = modelo.get("distancia_resumo") if isinstance(modelo.get("distancia_resumo"), dict) else {}
984
+ total_avaliandos = int(resumo.get("total_avaliandos") or 0)
985
+ if total_avaliandos > 1:
986
+ return (
987
+ "Resumo espacial"
988
+ f' • Maior: {resumo.get("maior_distancia_label") or "-"}'
989
+ f' • Media: {resumo.get("media_distancia_label") or "-"}'
990
+ f' • Menor: {resumo.get("menor_distancia_label") or "-"}'
991
+ )
992
+ if modelo.get("distancia_label"):
993
+ return f'Distancia: {modelo["distancia_label"]}'
994
+ return ""
995
+
996
+
997
  def _renderizar_mapa_modelos(
998
  modelos_plotados: list[dict[str, Any]],
999
  bounds: list[list[float]],
1000
+ avaliandos_geo: list[dict[str, Any]],
 
1001
  modo_exibicao: str,
1002
  avaliandos_tecnicos_proximos: list[dict[str, Any]] | None = None,
1003
  trabalhos_tecnicos_raio_m: int | None = None,
1004
  ) -> str:
1005
  renderizar_pontos = modo_exibicao == "pontos"
1006
  renderizar_cobertura = modo_exibicao == "cobertura"
1007
+ avaliando_unico = avaliandos_geo[0] if len(avaliandos_geo) == 1 else None
1008
+ aval_lat = avaliando_unico.get("lat") if avaliando_unico is not None else None
1009
+ aval_lon = avaliando_unico.get("lon") if avaliando_unico is not None else None
1010
  centro_lat = sum(coord[0] for coord in bounds) / len(bounds)
1011
  centro_lon = sum(coord[1] for coord in bounds) / len(bounds)
1012
 
 
1019
  folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
1020
  folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
1021
  add_bairros_layer(mapa, show=True)
1022
+ nome_camada_avaliando = "Avaliando" if len(avaliandos_geo) == 1 else "Avaliandos"
1023
+ camada_avaliando = folium.FeatureGroup(name=nome_camada_avaliando, show=True)
1024
  camada_trabalhos_proximos = None
1025
  if avaliandos_tecnicos_proximos is not None:
1026
  label_raio = f" (ate {int(trabalhos_tecnicos_raio_m or 0)} m)" if trabalhos_tecnicos_raio_m is not None else ""
 
1044
 
1045
  if renderizar_pontos:
1046
  tooltip_modelo = nome_layer
1047
+ tooltip_distancia = _montar_tooltip_distancia_modelo(modelo)
1048
+ if tooltip_distancia:
1049
+ tooltip_modelo = f"{tooltip_modelo} • {tooltip_distancia}"
1050
  for ponto in modelo["pontos"]:
1051
  marcador = folium.CircleMarker(
1052
  location=[ponto["lat"], ponto["lon"]],
 
1084
  add_trabalhos_tecnicos_markers(camada_trabalhos_proximos, avaliandos_tecnicos_proximos or [])
1085
  camada_trabalhos_proximos.add_to(mapa)
1086
 
1087
+ for idx, avaliando in enumerate(avaliandos_geo):
1088
+ lat_item, lon_item = _normalizar_coordenadas_avaliando(avaliando.get("lat"), avaliando.get("lon"))
1089
+ if lat_item is None or lon_item is None:
1090
+ continue
1091
+ label = str(avaliando.get("label") or f"A{idx + 1}").strip() or f"A{idx + 1}"
1092
+ endereco = str(avaliando.get("logradouro") or "").strip()
1093
+ numero_usado = str(avaliando.get("numero_usado") or "").strip()
1094
+ tooltip = label
1095
+ if endereco:
1096
+ tooltip = f"{tooltip} • {endereco}{f', {numero_usado}' if numero_usado else ''}"
1097
+ marker_icon = folium.DivIcon(
1098
+ html=(
1099
+ "<div style='display:flex;align-items:center;justify-content:center;"
1100
+ "width:28px;height:28px;border-radius:999px;background:#c62828;color:#fff;"
1101
+ "font-weight:800;font-size:12px;border:2px solid rgba(255,255,255,0.95);"
1102
+ "box-shadow:0 2px 6px rgba(0,0,0,0.18);'>"
1103
+ f"{escape(label)}"
1104
+ "</div>"
1105
+ ),
1106
+ icon_size=(28, 28),
1107
+ icon_anchor=(14, 14),
1108
+ class_name="mesa-avaliando-marker",
1109
+ )
1110
  folium.Marker(
1111
+ location=[lat_item, lon_item],
1112
+ tooltip=tooltip,
1113
+ icon=marker_icon,
1114
  ).add_to(camada_avaliando)
1115
+ if avaliandos_geo:
1116
  camada_avaliando.add_to(mapa)
1117
 
1118
  plugins.Fullscreen().add_to(mapa)
 
1892
  return float(lat), float(lon)
1893
 
1894
 
1895
+ def _normalizar_avaliandos_geo(raw: Any) -> list[dict[str, Any]]:
1896
+ saida: list[dict[str, Any]] = []
1897
+ for item in raw or []:
1898
+ if not isinstance(item, dict):
1899
+ continue
1900
+ lat, lon = _normalizar_coordenadas_avaliando(item.get("lat"), item.get("lon"))
1901
+ if lat is None or lon is None:
1902
+ continue
1903
+ indice = len(saida) + 1
1904
+ label = str(item.get("label") or f"A{indice}").strip() or f"A{indice}"
1905
+ avaliando_id = str(item.get("id") or label or f"avaliando-{indice}").strip() or f"avaliando-{indice}"
1906
+ logradouro = str(item.get("logradouro") or item.get("endereco") or "").strip() or None
1907
+ numero_usado_raw = item.get("numero_usado")
1908
+ if numero_usado_raw is None:
1909
+ numero_usado_raw = item.get("numero")
1910
+ numero_usado = None if _is_empty(numero_usado_raw) else str(numero_usado_raw).strip() or None
1911
+ origem = str(item.get("origem") or "").strip() or None
1912
+ cdlog = item.get("cdlog")
1913
+ saida.append(
1914
+ {
1915
+ "id": avaliando_id,
1916
+ "label": label,
1917
+ "lat": lat,
1918
+ "lon": lon,
1919
+ "logradouro": logradouro,
1920
+ "numero_usado": numero_usado,
1921
+ "cdlog": cdlog,
1922
+ "origem": origem,
1923
+ }
1924
+ )
1925
+ return saida
1926
+
1927
+
1928
+ def _normalizar_criterio_espacial(value: Any) -> str:
1929
+ criterio = str(value or "").strip().lower()
1930
+ if criterio == CRITERIO_ESPACIAL_MENOR_DISTANCIA:
1931
+ return CRITERIO_ESPACIAL_MENOR_DISTANCIA
1932
+ if criterio == CRITERIO_ESPACIAL_MEDIA_DISTANCIA:
1933
+ return CRITERIO_ESPACIAL_MEDIA_DISTANCIA
1934
+ return CRITERIO_ESPACIAL_PADRAO
1935
+
1936
+
1937
+ def _calcular_distancias_avaliandos(
1938
+ geometria: dict[str, Any] | None,
1939
+ avaliandos_geo: list[dict[str, Any]],
1940
+ criterio_espacial: str = CRITERIO_ESPACIAL_PADRAO,
1941
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
1942
+ criterio_norm = _normalizar_criterio_espacial(criterio_espacial)
1943
+ distancias: list[dict[str, Any]] = []
1944
+ for item in avaliandos_geo or []:
1945
+ distancia = _calcular_distancia_geometria_cache(geometria, item.get("lat"), item.get("lon"))
1946
+ distancias.append(
1947
+ {
1948
+ "id": item.get("id"),
1949
+ "label": item.get("label"),
1950
+ "lat": item.get("lat"),
1951
+ "lon": item.get("lon"),
1952
+ "logradouro": item.get("logradouro"),
1953
+ "numero_usado": item.get("numero_usado"),
1954
+ "cdlog": item.get("cdlog"),
1955
+ "origem": item.get("origem"),
1956
+ "distancia_km": distancia.get("distancia_km"),
1957
+ "distancia_label": distancia.get("distancia_label"),
1958
+ "avaliando_dentro_cobertura": distancia.get("avaliando_dentro_cobertura"),
1959
+ }
1960
+ )
1961
+ return distancias, _resumir_distancias_avaliandos(distancias, criterio_norm)
1962
+
1963
+
1964
+ def _resumir_distancias_avaliandos(
1965
+ distancias: list[dict[str, Any]],
1966
+ criterio_espacial: str = CRITERIO_ESPACIAL_PADRAO,
1967
+ ) -> dict[str, Any]:
1968
+ criterio_norm = _normalizar_criterio_espacial(criterio_espacial)
1969
+ valores_validos = [
1970
+ float(item["distancia_km"])
1971
+ for item in (distancias or [])
1972
+ if _to_float_or_none(item.get("distancia_km")) is not None
1973
+ ]
1974
+ menor = min(valores_validos) if valores_validos else None
1975
+ maior = max(valores_validos) if valores_validos else None
1976
+ media = (sum(valores_validos) / len(valores_validos)) if valores_validos else None
1977
+ total_dentro = sum(1 for item in (distancias or []) if item.get("avaliando_dentro_cobertura") is True)
1978
+ principal_km = {
1979
+ CRITERIO_ESPACIAL_MENOR_DISTANCIA: menor,
1980
+ CRITERIO_ESPACIAL_MEDIA_DISTANCIA: media,
1981
+ CRITERIO_ESPACIAL_MAIOR_DISTANCIA: maior,
1982
+ }.get(criterio_norm, maior)
1983
+ return {
1984
+ "criterio_principal": criterio_norm,
1985
+ "principal_distancia_km": principal_km,
1986
+ "principal_distancia_label": _formatar_distancia_km(principal_km),
1987
+ "menor_distancia_km": menor,
1988
+ "menor_distancia_label": _formatar_distancia_km(menor),
1989
+ "media_distancia_km": media,
1990
+ "media_distancia_label": _formatar_distancia_km(media),
1991
+ "maior_distancia_km": maior,
1992
+ "maior_distancia_label": _formatar_distancia_km(maior),
1993
+ "total_avaliandos": len(distancias or []),
1994
+ "total_com_distancia": len(valores_validos),
1995
+ "total_sem_distancia": max(0, len(distancias or []) - len(valores_validos)),
1996
+ "total_dentro_cobertura": total_dentro,
1997
+ }
1998
+
1999
+
2000
+ def _anexar_distancias_modelo_multiplos(
2001
+ modelo: dict[str, Any],
2002
+ avaliandos_geo: list[dict[str, Any]],
2003
+ criterio_espacial: str = CRITERIO_ESPACIAL_PADRAO,
2004
+ ) -> dict[str, Any]:
2005
+ item = dict(modelo)
2006
+ caminho_texto = str(item.get("_caminho_modelo") or "").strip()
2007
+ if not caminho_texto:
2008
+ item["distancias_avaliandos"] = []
2009
+ item["distancia_resumo"] = _resumir_distancias_avaliandos([], criterio_espacial)
2010
+ item["distancia_km"] = None
2011
+ item["distancia_label"] = "-"
2012
+ item["avaliando_dentro_cobertura"] = None
2013
+ return item
2014
+
2015
+ geometria = _carregar_geometria_modelo_com_cache(Path(caminho_texto))
2016
+ distancias, resumo = _calcular_distancias_avaliandos(geometria, avaliandos_geo, criterio_espacial)
2017
+ item["distancias_avaliandos"] = distancias
2018
+ item["distancia_resumo"] = resumo
2019
+ item["distancia_km"] = resumo.get("principal_distancia_km")
2020
+ item["distancia_label"] = resumo.get("principal_distancia_label")
2021
+ item["avaliando_dentro_cobertura"] = None
2022
+ return item
2023
+
2024
+
2025
  def _anexar_distancia_modelo(modelo: dict[str, Any], aval_lat: float, aval_lon: float) -> dict[str, Any]:
2026
  item = dict(modelo)
2027
  caminho_texto = str(item.get("_caminho_modelo") or "").strip()
 
2356
  return
2357
 
2358
  tooltip = str(modelo.get("nome") or "").strip() or "Modelo"
2359
+ tooltip_distancia = _montar_tooltip_distancia_modelo(modelo)
2360
+ if tooltip_distancia:
2361
+ tooltip = f"{tooltip} • {tooltip_distancia}"
2362
 
2363
  geom_type = str(geometria.get("geom_type") or "")
2364
  cor = str(modelo.get("cor") or "#1f77b4")
backend/app/services/visualizacao_service.py CHANGED
@@ -24,7 +24,7 @@ from app.core.elaboracao.core import (
24
  )
25
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
26
  from app.models.session import SessionState
27
- from app.services import model_repository, trabalhos_tecnicos_service
28
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
29
  from app.services.knn_avaliacao_service import estimar_valor_knn_avaliacao
30
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
@@ -34,6 +34,7 @@ CHAVES_ESPERADAS = ["versao", "dados", "transformacoes", "modelo"]
34
  BASE_COMPARACAO_SEM_BASE = "__none__"
35
  COORD_LAT_NAMES = {"lat", "latitude", "siat_latitude"}
36
  COORD_LON_NAMES = {"lon", "long", "longitude", "siat_longitude"}
 
37
 
38
 
39
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
@@ -500,6 +501,107 @@ def _aliases_modelo_visualizacao(session: SessionState) -> list[str]:
500
  return unicos
501
 
502
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  def _equacoes_do_modelo(pacote: dict[str, Any], info: dict[str, Any]) -> dict[str, Any]:
504
  diagnosticos = pacote.get("modelo", {}).get("diagnosticos", {}) if isinstance(pacote.get("modelo"), dict) else {}
505
  return build_equacoes_payload(
@@ -535,7 +637,12 @@ def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
535
  }
536
 
537
 
538
- def exibir_modelo(session: SessionState, api_base_url: str | None = None, popup_auth_token: str | None = None) -> dict[str, Any]:
 
 
 
 
 
539
  pacote = session.pacote_visualizacao
540
  if pacote is None:
541
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
@@ -571,9 +678,10 @@ def exibir_modelo(session: SessionState, api_base_url: str | None = None, popup_
571
 
572
  info = _extrair_modelo_info(pacote)
573
  equacoes = _equacoes_do_modelo(pacote, info)
574
- aliases_modelo = _aliases_modelo_visualizacao(session)
575
- avaliandos_tecnicos = trabalhos_tecnicos_service.listar_avaliandos_por_modelo(aliases_modelo)
576
- trabalhos_tecnicos = _resumir_trabalhos_tecnicos(avaliandos_tecnicos)
 
577
  popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
578
  mapa_html = viz_app.criar_mapa(
579
  dados,
@@ -612,10 +720,17 @@ def exibir_modelo(session: SessionState, api_base_url: str | None = None, popup_
612
  "meta_modelo": sanitize_value(info),
613
  "equacoes": sanitize_value(equacoes),
614
  "trabalhos_tecnicos": trabalhos_tecnicos,
 
615
  }
616
 
617
 
618
- def atualizar_mapa(session: SessionState, variavel_mapa: str | None, api_base_url: str | None = None, popup_auth_token: str | None = None) -> dict[str, Any]:
 
 
 
 
 
 
619
  pacote = session.pacote_visualizacao
620
  dados = session.dados_visualizacao
621
  if pacote is None or dados is None or dados.empty:
@@ -626,8 +741,9 @@ def atualizar_mapa(session: SessionState, variavel_mapa: str | None, api_base_ur
626
  if variavel_mapa and variavel_mapa != "Visualização Padrão":
627
  tamanho_col = variavel_mapa
628
 
629
- avaliandos_tecnicos = trabalhos_tecnicos_service.listar_avaliandos_por_modelo(
630
- _aliases_modelo_visualizacao(session)
 
631
  )
632
  popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
633
  mapa_html = viz_app.criar_mapa(
@@ -639,7 +755,11 @@ def atualizar_mapa(session: SessionState, variavel_mapa: str | None, api_base_ur
639
  popup_auth_token=popup_auth_token,
640
  avaliandos_tecnicos=avaliandos_tecnicos,
641
  )
642
- return {"mapa_html": mapa_html}
 
 
 
 
643
 
644
 
645
  def carregar_popup_ponto_mapa(session: SessionState, row_id: int) -> dict[str, Any]:
 
24
  )
25
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
26
  from app.models.session import SessionState
27
+ from app.services import model_repository, pesquisa_service, trabalhos_tecnicos_service
28
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
29
  from app.services.knn_avaliacao_service import estimar_valor_knn_avaliacao
30
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
 
34
  BASE_COMPARACAO_SEM_BASE = "__none__"
35
  COORD_LAT_NAMES = {"lat", "latitude", "siat_latitude"}
36
  COORD_LON_NAMES = {"lon", "long", "longitude", "siat_longitude"}
37
+ TRABALHOS_TECNICOS_MODELOS_MODO_PADRAO = pesquisa_service.TRABALHOS_TECNICOS_MODELOS_SELECIONADOS_E_OUTRAS_VERSOES
38
 
39
 
40
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
 
501
  return unicos
502
 
503
 
504
+ def _normalizar_modo_trabalhos_tecnicos_modelos_visualizacao(value: Any) -> str:
505
+ modo = value
506
+ if not str(modo or "").strip():
507
+ modo = TRABALHOS_TECNICOS_MODELOS_MODO_PADRAO
508
+ return pesquisa_service._normalizar_modo_trabalhos_tecnicos_modelos_mapa(modo)
509
+
510
+
511
+ def _dedupe_aliases(values: list[Any]) -> list[str]:
512
+ vistos: set[str] = set()
513
+ aliases: list[str] = []
514
+ for value in values:
515
+ texto = str(value or "").strip()
516
+ chave = texto.casefold()
517
+ if not texto or chave in vistos:
518
+ continue
519
+ vistos.add(chave)
520
+ aliases.append(texto)
521
+ return aliases
522
+
523
+
524
+ def _resolver_referencias_modelo_visualizacao(session: SessionState) -> tuple[str, Path, str]:
525
+ caminho_raw = str(session.uploaded_file_path or session.uploaded_filename or "").strip()
526
+ caminho = Path(caminho_raw or "modelo.dai")
527
+ modelo_id = caminho.stem.strip() or "modelo"
528
+ nome_modelo = modelo_id
529
+
530
+ try:
531
+ payload = model_repository.list_repository_models()
532
+ except Exception:
533
+ payload = {}
534
+
535
+ modelos = payload.get("modelos") if isinstance(payload, dict) else []
536
+ for item in modelos if isinstance(modelos, list) else []:
537
+ if not isinstance(item, dict):
538
+ continue
539
+ item_id = str(item.get("id") or "").strip()
540
+ item_arquivo = str(item.get("arquivo") or "").strip()
541
+ item_nome = str(item.get("nome_modelo") or "").strip()
542
+ candidatos = {
543
+ caminho.name.casefold(),
544
+ modelo_id.casefold(),
545
+ str(session.uploaded_filename or "").strip().casefold(),
546
+ str(session.uploaded_file_path or "").strip().casefold(),
547
+ }
548
+ if item_id and item_id.casefold() in candidatos or item_arquivo and item_arquivo.casefold() in candidatos:
549
+ modelo_id = item_id or modelo_id
550
+ nome_modelo = item_nome or nome_modelo
551
+ caminho = Path(item_arquivo or caminho.name)
552
+ break
553
+
554
+ return modelo_id, caminho, nome_modelo
555
+
556
+
557
+ def _aliases_modelo_visualizacao_para_trabalhos_tecnicos(
558
+ session: SessionState,
559
+ trabalhos_tecnicos_modelos_modo: Any = None,
560
+ ) -> tuple[list[str], str]:
561
+ aliases_base = _aliases_modelo_visualizacao(session)
562
+ modo_norm = _normalizar_modo_trabalhos_tecnicos_modelos_visualizacao(trabalhos_tecnicos_modelos_modo)
563
+ incluir_outras_versoes = pesquisa_service._modo_trabalhos_tecnicos_inclui_outras_versoes(modo_norm)
564
+
565
+ if not incluir_outras_versoes:
566
+ return aliases_base, modo_norm
567
+
568
+ modelo_id, caminho, nome_modelo = _resolver_referencias_modelo_visualizacao(session)
569
+
570
+ try:
571
+ familias_versoes = pesquisa_service._montar_familias_versoes_modelos(
572
+ list(pesquisa_service.ensure_modelos_dir().glob("*.dai"))
573
+ )
574
+ except Exception:
575
+ familias_versoes = {}
576
+
577
+ try:
578
+ familias_trabalhos_tecnicos = pesquisa_service._normalizar_familias_trabalhos_tecnicos_modelos()
579
+ except Exception:
580
+ familias_trabalhos_tecnicos = {}
581
+
582
+ aliases = pesquisa_service._resolver_aliases_modelo_para_trabalhos_tecnicos(
583
+ modelo_id=modelo_id,
584
+ caminho=caminho,
585
+ nome_modelo=nome_modelo,
586
+ incluir_outras_versoes=True,
587
+ familias_versoes=familias_versoes,
588
+ familias_trabalhos_tecnicos=familias_trabalhos_tecnicos,
589
+ )
590
+ return _dedupe_aliases([*aliases, *aliases_base]), modo_norm
591
+
592
+
593
+ def _carregar_trabalhos_tecnicos_visualizacao(
594
+ session: SessionState,
595
+ trabalhos_tecnicos_modelos_modo: Any = None,
596
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]:
597
+ aliases_modelo, modo_norm = _aliases_modelo_visualizacao_para_trabalhos_tecnicos(
598
+ session,
599
+ trabalhos_tecnicos_modelos_modo,
600
+ )
601
+ avaliandos_tecnicos = trabalhos_tecnicos_service.listar_avaliandos_por_modelo(aliases_modelo)
602
+ return avaliandos_tecnicos, _resumir_trabalhos_tecnicos(avaliandos_tecnicos), modo_norm
603
+
604
+
605
  def _equacoes_do_modelo(pacote: dict[str, Any], info: dict[str, Any]) -> dict[str, Any]:
606
  diagnosticos = pacote.get("modelo", {}).get("diagnosticos", {}) if isinstance(pacote.get("modelo"), dict) else {}
607
  return build_equacoes_payload(
 
637
  }
638
 
639
 
640
+ def exibir_modelo(
641
+ session: SessionState,
642
+ trabalhos_tecnicos_modelos_modo: Any = None,
643
+ api_base_url: str | None = None,
644
+ popup_auth_token: str | None = None,
645
+ ) -> dict[str, Any]:
646
  pacote = session.pacote_visualizacao
647
  if pacote is None:
648
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
 
678
 
679
  info = _extrair_modelo_info(pacote)
680
  equacoes = _equacoes_do_modelo(pacote, info)
681
+ avaliandos_tecnicos, trabalhos_tecnicos, trabalhos_tecnicos_modelos_modo_norm = _carregar_trabalhos_tecnicos_visualizacao(
682
+ session,
683
+ trabalhos_tecnicos_modelos_modo,
684
+ )
685
  popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
686
  mapa_html = viz_app.criar_mapa(
687
  dados,
 
720
  "meta_modelo": sanitize_value(info),
721
  "equacoes": sanitize_value(equacoes),
722
  "trabalhos_tecnicos": trabalhos_tecnicos,
723
+ "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
724
  }
725
 
726
 
727
+ def atualizar_mapa(
728
+ session: SessionState,
729
+ variavel_mapa: str | None,
730
+ trabalhos_tecnicos_modelos_modo: Any = None,
731
+ api_base_url: str | None = None,
732
+ popup_auth_token: str | None = None,
733
+ ) -> dict[str, Any]:
734
  pacote = session.pacote_visualizacao
735
  dados = session.dados_visualizacao
736
  if pacote is None or dados is None or dados.empty:
 
741
  if variavel_mapa and variavel_mapa != "Visualização Padrão":
742
  tamanho_col = variavel_mapa
743
 
744
+ avaliandos_tecnicos, trabalhos_tecnicos, trabalhos_tecnicos_modelos_modo_norm = _carregar_trabalhos_tecnicos_visualizacao(
745
+ session,
746
+ trabalhos_tecnicos_modelos_modo,
747
  )
748
  popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
749
  mapa_html = viz_app.criar_mapa(
 
755
  popup_auth_token=popup_auth_token,
756
  avaliandos_tecnicos=avaliandos_tecnicos,
757
  )
758
+ return {
759
+ "mapa_html": mapa_html,
760
+ "trabalhos_tecnicos": trabalhos_tecnicos,
761
+ "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
762
+ }
763
 
764
 
765
  def carregar_popup_ponto_mapa(session: SessionState, row_id: int) -> dict[str, Any]:
frontend/src/api.js CHANGED
@@ -181,15 +181,20 @@ export const api = {
181
  modelosIds = [],
182
  avaliando = null,
183
  modoExibicao = 'pontos',
 
184
  trabalhosTecnicosModelosModo = 'selecionados',
185
  trabalhosTecnicosProximidadeModo = 'sem_proximidade',
186
  trabalhosTecnicosRaioM = 1000,
187
  ) {
 
 
188
  return postJson('/api/pesquisa/mapa-modelos', {
189
  modelos_ids: modelosIds,
190
- avaliando_lat: avaliando?.lat ?? null,
191
- avaliando_lon: avaliando?.lon ?? null,
 
192
  modo_exibicao: modoExibicao,
 
193
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
194
  trabalhos_tecnicos_proximidade_modo: trabalhosTecnicosProximidadeModo,
195
  trabalhos_tecnicos_raio_m: trabalhosTecnicosRaioM,
@@ -334,9 +339,16 @@ export const api = {
334
  },
335
  visualizacaoRepositorioModelos: () => getJson('/api/visualizacao/repositorio-modelos'),
336
  visualizacaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/visualizacao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
337
- exibirVisualizacao: (sessionId) => postJson('/api/visualizacao/exibir', { session_id: sessionId }),
 
 
 
338
  evaluationContextViz: (sessionId) => postJson('/api/visualizacao/evaluation/context', { session_id: sessionId }),
339
- updateVisualizacaoMap: (sessionId, variavelMapa) => postJson('/api/visualizacao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
 
 
 
 
340
  evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
341
  evaluationCalculateViz: (sessionId, valoresX, indiceBase, avaliando = null) => postJson('/api/visualizacao/evaluation/calculate', {
342
  session_id: sessionId,
 
181
  modelosIds = [],
182
  avaliando = null,
183
  modoExibicao = 'pontos',
184
+ criterioEspacial = 'maior_distancia',
185
  trabalhosTecnicosModelosModo = 'selecionados',
186
  trabalhosTecnicosProximidadeModo = 'sem_proximidade',
187
  trabalhosTecnicosRaioM = 1000,
188
  ) {
189
+ const avaliandos = Array.isArray(avaliando) ? avaliando : []
190
+ const avaliandoUnico = !avaliandos.length && avaliando && typeof avaliando === 'object' ? avaliando : null
191
  return postJson('/api/pesquisa/mapa-modelos', {
192
  modelos_ids: modelosIds,
193
+ avaliando_lat: avaliandoUnico?.lat ?? null,
194
+ avaliando_lon: avaliandoUnico?.lon ?? null,
195
+ avaliandos: avaliandos.length ? avaliandos : null,
196
  modo_exibicao: modoExibicao,
197
+ criterio_espacial: criterioEspacial,
198
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
199
  trabalhos_tecnicos_proximidade_modo: trabalhosTecnicosProximidadeModo,
200
  trabalhos_tecnicos_raio_m: trabalhosTecnicosRaioM,
 
339
  },
340
  visualizacaoRepositorioModelos: () => getJson('/api/visualizacao/repositorio-modelos'),
341
  visualizacaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/visualizacao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
342
+ exibirVisualizacao: (sessionId, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/exibir', {
343
+ session_id: sessionId,
344
+ trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
345
+ }),
346
  evaluationContextViz: (sessionId) => postJson('/api/visualizacao/evaluation/context', { session_id: sessionId }),
347
+ updateVisualizacaoMap: (sessionId, variavelMapa, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/map/update', {
348
+ session_id: sessionId,
349
+ variavel_mapa: variavelMapa,
350
+ trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
351
+ }),
352
  evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
353
  evaluationCalculateViz: (sessionId, valoresX, indiceBase, avaliando = null) => postJson('/api/visualizacao/evaluation/calculate', {
354
  session_id: sessionId,
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -1415,35 +1415,40 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
1415
  {avaliandoLocalizacaoError ? <div className="error-line inline-error">{avaliandoLocalizacaoError}</div> : null}
1416
 
1417
  {avaliandoLocalizacaoAtiva ? (
1418
- <div className="pesquisa-localizacao-summary">
1419
- {avaliandoLocalizacaoResolvida?.logradouro ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1420
  <div className="pesquisa-localizacao-summary-row">
1421
- <span className="pesquisa-localizacao-summary-label">Endereço</span>
1422
- <span className="pesquisa-localizacao-summary-value">
1423
- {avaliandoLocalizacaoResolvida.logradouro}
1424
- {avaliandoLocalizacaoResolvida?.numero_usado ? `, ${avaliandoLocalizacaoResolvida.numero_usado}` : ''}
1425
- </span>
1426
  </div>
1427
- ) : null}
1428
- {avaliandoLocalizacaoResolvida?.cdlog ? (
1429
  <div className="pesquisa-localizacao-summary-row">
1430
- <span className="pesquisa-localizacao-summary-label">CDLOG</span>
1431
- <span className="pesquisa-localizacao-summary-value">{avaliandoLocalizacaoResolvida.cdlog}</span>
 
 
 
 
 
 
1432
  </div>
1433
- ) : null}
1434
- <div className="pesquisa-localizacao-summary-row">
1435
- <span className="pesquisa-localizacao-summary-label">Latitude</span>
1436
- <span className="pesquisa-localizacao-summary-value">{Number(avaliandoLocalizacaoResolvida.lat).toFixed(6)}</span>
1437
- </div>
1438
- <div className="pesquisa-localizacao-summary-row">
1439
- <span className="pesquisa-localizacao-summary-label">Longitude</span>
1440
- <span className="pesquisa-localizacao-summary-value">{Number(avaliandoLocalizacaoResolvida.lon).toFixed(6)}</span>
1441
- </div>
1442
- <div className="pesquisa-localizacao-summary-row">
1443
- <span className="pesquisa-localizacao-summary-label">Origem</span>
1444
- <span className="pesquisa-localizacao-summary-value">
1445
- {avaliandoLocalizacaoResolvida?.origem === 'eixos' ? 'Eixos de logradouro' : 'Coordenadas informadas'}
1446
- </span>
1447
  </div>
1448
  </div>
1449
  ) : null}
 
1415
  {avaliandoLocalizacaoError ? <div className="error-line inline-error">{avaliandoLocalizacaoError}</div> : null}
1416
 
1417
  {avaliandoLocalizacaoAtiva ? (
1418
+ <div className="pesquisa-localizacao-registered">
1419
+ <div className="pesquisa-localizacao-registered-copy">
1420
+ <strong>Geolocalização registrada</strong>
1421
+ </div>
1422
+ <div className="pesquisa-localizacao-summary">
1423
+ {avaliandoLocalizacaoResolvida?.logradouro ? (
1424
+ <div className="pesquisa-localizacao-summary-row">
1425
+ <span className="pesquisa-localizacao-summary-label">Endereço</span>
1426
+ <span className="pesquisa-localizacao-summary-value">
1427
+ {avaliandoLocalizacaoResolvida.logradouro}
1428
+ {avaliandoLocalizacaoResolvida?.numero_usado ? `, ${avaliandoLocalizacaoResolvida.numero_usado}` : ''}
1429
+ </span>
1430
+ </div>
1431
+ ) : null}
1432
+ {avaliandoLocalizacaoResolvida?.cdlog ? (
1433
+ <div className="pesquisa-localizacao-summary-row">
1434
+ <span className="pesquisa-localizacao-summary-label">CDLOG</span>
1435
+ <span className="pesquisa-localizacao-summary-value">{avaliandoLocalizacaoResolvida.cdlog}</span>
1436
+ </div>
1437
+ ) : null}
1438
  <div className="pesquisa-localizacao-summary-row">
1439
+ <span className="pesquisa-localizacao-summary-label">Latitude</span>
1440
+ <span className="pesquisa-localizacao-summary-value">{Number(avaliandoLocalizacaoResolvida.lat).toFixed(6)}</span>
 
 
 
1441
  </div>
 
 
1442
  <div className="pesquisa-localizacao-summary-row">
1443
+ <span className="pesquisa-localizacao-summary-label">Longitude</span>
1444
+ <span className="pesquisa-localizacao-summary-value">{Number(avaliandoLocalizacaoResolvida.lon).toFixed(6)}</span>
1445
+ </div>
1446
+ <div className="pesquisa-localizacao-summary-row">
1447
+ <span className="pesquisa-localizacao-summary-label">Origem</span>
1448
+ <span className="pesquisa-localizacao-summary-value">
1449
+ {avaliandoLocalizacaoResolvida?.origem === 'eixos' ? 'Eixos de logradouro' : 'Coordenadas informadas'}
1450
+ </span>
1451
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1452
  </div>
1453
  </div>
1454
  ) : null}
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -18,7 +18,7 @@ const EMPTY_FILTERS = {
18
  negociacaoModelo: '',
19
  dataMin: '',
20
  dataMax: '',
21
- versionamentoModelos: 'atuais',
22
  avalFinalidade: '',
23
  avalZona: '',
24
  avalBairro: '',
@@ -34,6 +34,12 @@ const EMPTY_LOCATION_INPUTS = {
34
  cdlog: '',
35
  }
36
 
 
 
 
 
 
 
37
  const RESULT_INITIAL = {
38
  modelos: [],
39
  sugestoes: {},
@@ -52,6 +58,7 @@ const PESQUISA_INNER_TABS = [
52
  { key: 'obs_calc', label: 'Obs x Calc' },
53
  { key: 'graficos', label: 'Gráficos' },
54
  ]
 
55
 
56
  const TIPO_SIGLAS = {
57
  RECOND: 'Residencia em condominio',
@@ -97,6 +104,82 @@ function formatDistanceKm(value) {
97
  return `${number.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} km`
98
  }
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  function formatDateBrIfIso(value) {
101
  const text = String(value ?? '').trim()
102
  if (!text) return '-'
@@ -202,6 +285,58 @@ function buildVariablesContent(text) {
202
  )
203
  }
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  function CompactHoverList({
206
  label,
207
  buttonLabel,
@@ -487,7 +622,7 @@ function formatTipoImovel(modelo) {
487
  return mapped || text
488
  }
489
 
490
- function buildApiFilters(filters, localizacaoResolvida = null) {
491
  const payload = {
492
  otica: 'avaliando',
493
  nome: filters.nomeModelo,
@@ -502,11 +637,12 @@ function buildApiFilters(filters, localizacaoResolvida = null) {
502
  aval_area: filters.avalArea,
503
  aval_rh: filters.avalRh,
504
  }
505
- const lat = Number(localizacaoResolvida?.lat)
506
- const lon = Number(localizacaoResolvida?.lon)
507
- if (Number.isFinite(lat) && Number.isFinite(lon)) {
508
- payload.aval_lat = lat
509
- payload.aval_lon = lon
 
510
  }
511
  return payload
512
  }
@@ -519,16 +655,33 @@ function toInputName(field) {
519
  return `mesa_${Math.abs(hash)}`
520
  }
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  function TextFieldInput({ field, ...props }) {
523
  return (
524
  <input
525
  {...props}
526
  data-field={field}
527
- name={toInputName(field)}
528
- autoComplete="off"
529
- autoCorrect="off"
530
- autoCapitalize="none"
531
- spellCheck={false}
532
  />
533
  )
534
  }
@@ -541,8 +694,7 @@ function NumberFieldInput({ field, ...props }) {
541
  step="any"
542
  inputMode="decimal"
543
  data-field={field}
544
- name={toInputName(field)}
545
- autoComplete="off"
546
  />
547
  )
548
  }
@@ -553,8 +705,7 @@ function DateFieldInput({ field, ...props }) {
553
  {...props}
554
  type="date"
555
  data-field={field}
556
- name={toInputName(field)}
557
- autoComplete="off"
558
  />
559
  )
560
  }
@@ -820,13 +971,16 @@ export default function PesquisaTab({
820
  const [result, setResult] = useState(RESULT_INITIAL)
821
  const [localizacaoModo, setLocalizacaoModo] = useState('endereco')
822
  const [localizacaoInputs, setLocalizacaoInputs] = useState(EMPTY_LOCATION_INPUTS)
823
- const [localizacaoResolvida, setLocalizacaoResolvida] = useState(null)
 
824
  const [localizacaoLoading, setLocalizacaoLoading] = useState(false)
825
  const [localizacaoError, setLocalizacaoError] = useState('')
826
  const [localizacaoStatus, setLocalizacaoStatus] = useState('')
 
827
 
828
  const [selectedIds, setSelectedIds] = useState([])
829
  const selectAllRef = useRef(null)
 
830
 
831
  const [mapaLoading, setMapaLoading] = useState(false)
832
  const [mapaError, setMapaError] = useState('')
@@ -853,6 +1007,7 @@ export default function PesquisaTab({
853
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
854
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
855
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
 
856
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
857
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
858
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
@@ -869,11 +1024,26 @@ export default function PesquisaTab({
869
  .sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })),
870
  [sugestoes.tipos_modelo],
871
  )
872
- const resultIds = useMemo(() => (result.modelos || []).map((modelo) => modelo.id), [result.modelos])
873
  const modoModeloAberto = Boolean(modeloAbertoMeta)
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
875
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
876
- const localizacaoAtiva = Number.isFinite(Number(localizacaoResolvida?.lat)) && Number.isFinite(Number(localizacaoResolvida?.lon))
 
 
 
877
  const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
878
  const mapaFoiGerado = Boolean(mapaHtmls.pontos || mapaHtmls.cobertura)
879
 
@@ -916,7 +1086,7 @@ export default function PesquisaTab({
916
  ? 'selecionados_e_outras_versoes'
917
  : String(modelosModoBruto || 'selecionados')
918
  const proximidadeModoBruto = overrides.trabalhosTecnicosProximidadeModo ?? mapaTrabalhosTecnicosProximidadeModo
919
- const proximidadeModo = localizacaoAtiva ? String(proximidadeModoBruto || 'sem_proximidade') : 'sem_proximidade'
920
  const raioNumero = Number(overrides.trabalhosTecnicosRaio ?? mapaTrabalhosTecnicosRaio)
921
  const raio = Number.isFinite(raioNumero)
922
  ? Math.max(0, Math.min(5000, Math.round(raioNumero)))
@@ -926,9 +1096,10 @@ export default function PesquisaTab({
926
 
927
  function buildMapaTrabalhosTecnicosConfigKey(config) {
928
  return JSON.stringify({
929
- localizacaoAtiva,
930
  modelosModo: config.modelosModo,
931
  proximidadeModo: config.proximidadeModo,
 
932
  raio: config.proximidadeModo === 'proximos_ao_avaliando' ? config.raio : null,
933
  })
934
  }
@@ -951,8 +1122,9 @@ export default function PesquisaTab({
951
  try {
952
  const response = await api.pesquisarMapaModelos(
953
  idsValidos,
954
- localizacaoResolvida,
955
  modoExibicaoSolicitado,
 
956
  trabalhosTecnicosConfig.modelosModo,
957
  trabalhosTecnicosConfig.proximidadeModo,
958
  trabalhosTecnicosConfig.raio,
@@ -970,11 +1142,11 @@ export default function PesquisaTab({
970
  }
971
  }
972
 
973
- async function buscarModelos(nextFilters = filters, nextLocalizacao = localizacaoResolvida) {
974
  setLoading(true)
975
  setError('')
976
  try {
977
- const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextLocalizacao))
978
  const modelos = response.modelos || []
979
  const idsNovos = new Set(modelos.map((item) => item.id))
980
 
@@ -1058,11 +1230,14 @@ export default function PesquisaTab({
1058
  }
1059
  }, [
1060
  localizacaoAtiva,
 
1061
  mapaFoiGerado,
1062
  mapaLoading,
1063
  mapaTrabalhosTecnicosModelosModo,
1064
  mapaTrabalhosTecnicosProximidadeModo,
1065
  mapaTrabalhosTecnicosRaio,
 
 
1066
  ])
1067
 
1068
  function onFieldChange(event) {
@@ -1082,20 +1257,11 @@ export default function PesquisaTab({
1082
  function atualizarCampoLocalizacao(field, value) {
1083
  if (!field) return
1084
  setLocalizacaoInputs((prev) => ({ ...prev, [field]: value }))
1085
- if (localizacaoResolvida) {
1086
- setLocalizacaoResolvida(null)
1087
- }
1088
  setLocalizacaoError('')
1089
- setLocalizacaoStatus('')
1090
- resetMapaPesquisa()
1091
  }
1092
 
1093
  async function onLimparFiltros() {
1094
  setFilters(EMPTY_FILTERS)
1095
- setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1096
- setLocalizacaoResolvida(null)
1097
- setLocalizacaoError('')
1098
- setLocalizacaoStatus('')
1099
  await carregarContextoInicial()
1100
  }
1101
 
@@ -1142,6 +1308,7 @@ export default function PesquisaTab({
1142
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
1143
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
1144
  setModeloAbertoMapaVar('Visualização Padrão')
 
1145
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1146
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
1147
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
@@ -1159,7 +1326,7 @@ export default function PesquisaTab({
1159
  setModeloAbertoError('')
1160
  try {
1161
  await api.visualizacaoRepositorioCarregar(sessionId, modelo.id)
1162
- const resp = await api.exibirVisualizacao(sessionId)
1163
  preencherModeloAberto(resp)
1164
  setModeloAbertoActiveTab('mapa')
1165
  setModeloAbertoMeta({
@@ -1184,12 +1351,30 @@ export default function PesquisaTab({
1184
  scrollParaResultadosNoTopo()
1185
  }
1186
 
 
 
 
 
 
 
 
 
 
 
 
1187
  async function onModeloAbertoMapChange(nextVar) {
1188
  setModeloAbertoMapaVar(nextVar)
1189
- if (!sessionId) return
1190
  try {
1191
- const resp = await api.updateVisualizacaoMap(sessionId, nextVar)
1192
- setModeloAbertoMapaHtml(resp?.mapa_html || '')
 
 
 
 
 
 
 
 
1193
  } catch (err) {
1194
  setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
1195
  }
@@ -1220,12 +1405,41 @@ export default function PesquisaTab({
1220
 
1221
  async function onAdminConfigSalva() {
1222
  if (pesquisaInicializada) {
1223
- await buscarModelos(filters, localizacaoResolvida)
1224
  return
1225
  }
1226
  await carregarContextoInicial()
1227
  }
1228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1229
  async function onResolverLocalizacao() {
1230
  setLocalizacaoError('')
1231
  setLocalizacaoStatus('')
@@ -1264,15 +1478,23 @@ export default function PesquisaTab({
1264
  lat: Number(response?.lat),
1265
  lon: Number(response?.lon),
1266
  }
1267
- setLocalizacaoResolvida(resolvida)
1268
- setLocalizacaoStatus(response?.status || 'Localização do avaliando definida.')
1269
- resetMapaPesquisa()
1270
- if (pesquisaInicializada) {
1271
- await buscarModelos(filters, resolvida)
1272
- }
 
 
 
 
 
 
 
 
 
1273
  } catch (err) {
1274
  setLocalizacaoError(err.message || 'Falha ao localizar o avaliando.')
1275
- setLocalizacaoResolvida(null)
1276
  } finally {
1277
  setLocalizacaoLoading(false)
1278
  }
@@ -1280,13 +1502,22 @@ export default function PesquisaTab({
1280
 
1281
  async function onLimparLocalizacao() {
1282
  setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1283
- setLocalizacaoResolvida(null)
 
1284
  setLocalizacaoError('')
1285
  setLocalizacaoStatus('')
1286
- resetMapaPesquisa()
1287
- if (pesquisaInicializada) {
1288
- await buscarModelos(filters, null)
 
 
 
 
 
 
 
1289
  }
 
1290
  }
1291
 
1292
  if (modoModeloAberto) {
@@ -1324,13 +1555,32 @@ export default function PesquisaTab({
1324
  <div className="inner-tab-panel">
1325
  {modeloAbertoActiveTab === 'mapa' ? (
1326
  <>
1327
- <div className="row compact visualizacao-mapa-controls">
1328
- <label>Variável no mapa</label>
1329
- <select value={modeloAbertoMapaVar} onChange={(event) => void onModeloAbertoMapChange(event.target.value)}>
1330
- {modeloAbertoMapaChoices.map((choice) => (
1331
- <option key={`modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
1332
- ))}
1333
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1334
  </div>
1335
  <MapFrame html={modeloAbertoMapaHtml} />
1336
  </>
@@ -1394,8 +1644,195 @@ export default function PesquisaTab({
1394
  <div className="tab-content">
1395
  <SectionBlock
1396
  step="1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1397
  title="Filtros de Pesquisa"
1398
- subtitle="Informe os dados do avaliando. Todos os filtros são cumulativos."
1399
  aside={(
1400
  <button
1401
  type="button"
@@ -1428,10 +1865,9 @@ export default function PesquisaTab({
1428
  Tipo do modelo
1429
  <select
1430
  data-field="negociacaoModelo"
1431
- name={toInputName('negociacaoModelo')}
1432
  value={filters.negociacaoModelo}
1433
  onChange={onFieldChange}
1434
- autoComplete="off"
1435
  >
1436
  <option value="">Indiferente</option>
1437
  <option value="aluguel">Aluguel</option>
@@ -1442,10 +1878,9 @@ export default function PesquisaTab({
1442
  Finalidade genérica
1443
  <select
1444
  data-field="tipoModelo"
1445
- name={toInputName('tipoModelo')}
1446
  value={filters.tipoModelo}
1447
  onChange={onFieldChange}
1448
- autoComplete="off"
1449
  >
1450
  <option value="">Todos</option>
1451
  {opcoesTipoModelo.map((tipo) => (
@@ -1523,161 +1958,16 @@ export default function PesquisaTab({
1523
  Versionamento dos modelos
1524
  <select
1525
  data-field="versionamentoModelos"
1526
- name={toInputName('versionamentoModelos')}
1527
  value={filters.versionamentoModelos}
1528
  onChange={onFieldChange}
1529
- autoComplete="off"
1530
  >
 
1531
  <option value="atuais">Exibir somente versões mais atuais</option>
1532
- <option value="incluir_antigos">Incluir também modelos substituídos</option>
1533
  </select>
1534
  </label>
1535
  </div>
1536
 
1537
- <div className="pesquisa-field-pair pesquisa-localizacao-group">
1538
- <span className="pesquisa-field-pair-title">Localização do avaliando (opcional)</span>
1539
- <div className="pesquisa-localizacao-optional-hint">
1540
- O preenchimento deste grupo não é obrigatório. Se você não informar localização, a pesquisa continua normal e apenas a distância espacial dos modelos não será calculada.
1541
- </div>
1542
-
1543
- {localizacaoModo === 'coords' ? (
1544
- <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-coords">
1545
- <label className="pesquisa-field">
1546
- Forma de localização
1547
- <select
1548
- data-field="localizacaoModo"
1549
- name={toInputName('localizacaoModo')}
1550
- value={localizacaoModo}
1551
- onChange={(event) => setLocalizacaoModo(event.target.value)}
1552
- autoComplete="off"
1553
- >
1554
- <option value="endereco">Endereço</option>
1555
- <option value="coords">Coordenadas</option>
1556
- </select>
1557
- </label>
1558
- <label className="pesquisa-field">
1559
- Latitude
1560
- <NumberFieldInput field="latitude" value={localizacaoInputs.latitude} onChange={onLocalizacaoFieldChange} placeholder="-30.000000" />
1561
- </label>
1562
- <label className="pesquisa-field">
1563
- Longitude
1564
- <NumberFieldInput field="longitude" value={localizacaoInputs.longitude} onChange={onLocalizacaoFieldChange} placeholder="-51.000000" />
1565
- </label>
1566
- <div className="pesquisa-localizacao-actions-inline">
1567
- <button
1568
- type="button"
1569
- className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1570
- onClick={() => void onResolverLocalizacao()}
1571
- disabled={localizacaoLoading}
1572
- >
1573
- {localizacaoLoading ? 'Buscando...' : 'Buscar'}
1574
- </button>
1575
- <button
1576
- type="button"
1577
- className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1578
- onClick={() => void onLimparLocalizacao()}
1579
- disabled={localizacaoLoading}
1580
- >
1581
- Limpar
1582
- </button>
1583
- </div>
1584
- </div>
1585
- ) : (
1586
- <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-endereco">
1587
- <label className="pesquisa-field">
1588
- Forma de localização
1589
- <select
1590
- data-field="localizacaoModo"
1591
- name={toInputName('localizacaoModo')}
1592
- value={localizacaoModo}
1593
- onChange={(event) => setLocalizacaoModo(event.target.value)}
1594
- autoComplete="off"
1595
- >
1596
- <option value="endereco">Endereço</option>
1597
- <option value="coords">Coordenadas</option>
1598
- </select>
1599
- </label>
1600
- <label className="pesquisa-field">
1601
- CDLOG
1602
- <NumberFieldInput field="cdlog" value={localizacaoInputs.cdlog} onChange={onLocalizacaoFieldChange} placeholder="Opcional" />
1603
- </label>
1604
- <label className="pesquisa-field pesquisa-localizacao-logradouro-field">
1605
- Logradouro
1606
- <SinglePillAutocomplete
1607
- value={localizacaoInputs.logradouro}
1608
- onChange={(nextValue) => atualizarCampoLocalizacao('logradouro', nextValue)}
1609
- options={sugestoes.logradouros_eixos || []}
1610
- placeholder="Digite ou selecione um logradouro dos eixos"
1611
- panelTitle="Logradouros dos eixos"
1612
- emptyMessage="Nenhum logradouro encontrado nos eixos."
1613
- loading={loading && !sugestoesInicializadas}
1614
- inputName={toInputName('logradouroEixosPesquisa')}
1615
- inputAutoComplete="new-password"
1616
- />
1617
- </label>
1618
- <label className="pesquisa-field">
1619
- Número
1620
- <NumberFieldInput field="numero" value={localizacaoInputs.numero} onChange={onLocalizacaoFieldChange} placeholder="0" />
1621
- </label>
1622
- <div className="pesquisa-localizacao-actions-inline">
1623
- <button
1624
- type="button"
1625
- className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1626
- onClick={() => void onResolverLocalizacao()}
1627
- disabled={localizacaoLoading}
1628
- >
1629
- {localizacaoLoading ? 'Buscando...' : 'Buscar'}
1630
- </button>
1631
- <button
1632
- type="button"
1633
- className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1634
- onClick={() => void onLimparLocalizacao()}
1635
- disabled={localizacaoLoading}
1636
- >
1637
- Limpar
1638
- </button>
1639
- </div>
1640
- </div>
1641
- )}
1642
-
1643
- {localizacaoStatus && !localizacaoAtiva ? <div className="status-line">{localizacaoStatus}</div> : null}
1644
- {localizacaoError ? <div className="error-line inline-error">{localizacaoError}</div> : null}
1645
-
1646
- {localizacaoAtiva ? (
1647
- <div className="pesquisa-localizacao-summary">
1648
- {localizacaoResolvida?.logradouro ? (
1649
- <div className="pesquisa-localizacao-summary-row">
1650
- <span className="pesquisa-localizacao-summary-label">Endereço</span>
1651
- <span className="pesquisa-localizacao-summary-value">
1652
- {localizacaoResolvida.logradouro}
1653
- {localizacaoResolvida?.numero_usado ? `, ${localizacaoResolvida.numero_usado}` : ''}
1654
- </span>
1655
- </div>
1656
- ) : null}
1657
- {localizacaoResolvida?.cdlog ? (
1658
- <div className="pesquisa-localizacao-summary-row">
1659
- <span className="pesquisa-localizacao-summary-label">CDLOG</span>
1660
- <span className="pesquisa-localizacao-summary-value">{localizacaoResolvida.cdlog}</span>
1661
- </div>
1662
- ) : null}
1663
- <div className="pesquisa-localizacao-summary-row">
1664
- <span className="pesquisa-localizacao-summary-label">Latitude</span>
1665
- <span className="pesquisa-localizacao-summary-value">{Number(localizacaoResolvida.lat).toFixed(6)}</span>
1666
- </div>
1667
- <div className="pesquisa-localizacao-summary-row">
1668
- <span className="pesquisa-localizacao-summary-label">Longitude</span>
1669
- <span className="pesquisa-localizacao-summary-value">{Number(localizacaoResolvida.lon).toFixed(6)}</span>
1670
- </div>
1671
- <div className="pesquisa-localizacao-summary-row">
1672
- <span className="pesquisa-localizacao-summary-label">Origem</span>
1673
- <span className="pesquisa-localizacao-summary-value">
1674
- {localizacaoResolvida?.origem === 'eixos' ? 'Eixos de logradouro' : 'Coordenadas informadas'}
1675
- </span>
1676
- </div>
1677
- </div>
1678
- ) : null}
1679
- </div>
1680
-
1681
  <div className="row pesquisa-actions pesquisa-actions-primary">
1682
  <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
1683
  {loading ? 'Pesquisando...' : 'Pesquisar'}
@@ -1692,7 +1982,7 @@ export default function PesquisaTab({
1692
 
1693
  <div ref={sectionResultadosRef}>
1694
  <SectionBlock
1695
- step="2"
1696
  title="Resultados"
1697
  subtitle="Modelos aceitos para os parametros do avaliando informado."
1698
  >
@@ -1701,6 +1991,20 @@ export default function PesquisaTab({
1701
  <strong>{formatCount(result.total_filtrado)}</strong>{' '}
1702
  modelo(s) aceito(s) de <strong>{formatCount(result.total_geral)}</strong>.
1703
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1704
  {resultIds.length ? (
1705
  <label className="pesquisa-select-all">
1706
  <input ref={selectAllRef} type="checkbox" checked={todosSelecionados} onChange={onToggleSelecionarTodos} />
@@ -1709,7 +2013,7 @@ export default function PesquisaTab({
1709
  ) : null}
1710
  </div>
1711
 
1712
- {!result.modelos?.length ? (
1713
  <div className="empty-box">
1714
  {!pesquisaInicializada
1715
  ? 'Defina os filtros desejados e clique em Pesquisar.'
@@ -1717,13 +2021,15 @@ export default function PesquisaTab({
1717
  </div>
1718
  ) : (
1719
  <div className="pesquisa-card-grid">
1720
- {result.modelos.map((modelo) => {
1721
  const selecionado = selectedIds.includes(modelo.id)
1722
  const faixaDataRecency = getFaixaDataRecencyInfo(modelo.faixa_data)
1723
  const finalidadesText = uppercaseListText(modelo.finalidades || [])
1724
  const bairrosText = uppercaseListText(modelo.bairros || [])
1725
  const variaveisText = buildVariablesDisplay(modelo)
1726
  const observacaoText = String(modelo.observacao_modelo || '').trim()
 
 
1727
  const cardClassName = [
1728
  'pesquisa-card',
1729
  selecionado ? 'is-selected' : '',
@@ -1751,7 +2057,15 @@ export default function PesquisaTab({
1751
  </div>
1752
  <div className="pesquisa-card-body">
1753
  <div className="pesquisa-card-dados-list">
1754
- <div><strong>Distância:</strong> {String(modelo.distancia_label || '').trim() || formatDistanceKm(modelo.distancia_km)}</div>
 
 
 
 
 
 
 
 
1755
  <div><strong>Tipo:</strong> {formatTipoImovel(modelo)}</div>
1756
  <div><strong>Autor:</strong> {modelo.autor || '-'}</div>
1757
  <div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
@@ -1809,17 +2123,20 @@ export default function PesquisaTab({
1809
 
1810
  <div ref={sectionMapaRef}>
1811
  <SectionBlock
1812
- step="3"
1813
  title="Mapa"
1814
  subtitle={localizacaoAtiva
1815
- ? 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos, com o avaliando destacado.'
 
 
1816
  : 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos.'}
1817
  >
1818
  <div className="pesquisa-summary-line">
1819
  <strong>{formatCount(selectedIds.length)}</strong> modelo(s) selecionado(s) para plotagem.
1820
  </div>
1821
  <div className="pesquisa-summary-line">
1822
- <strong>Localização do avaliando:</strong> {localizacaoAtiva ? 'ativa' : 'não definida'}
 
1823
  </div>
1824
 
1825
  <div className="row pesquisa-actions pesquisa-mapa-actions">
@@ -1835,9 +2152,9 @@ export default function PesquisaTab({
1835
  <label className="pesquisa-field pesquisa-mapa-modo-field">
1836
  Exibição do mapa
1837
  <select
 
1838
  value={mapaModoExibicao}
1839
  onChange={(event) => setMapaModoExibicao(event.target.value)}
1840
- autoComplete="off"
1841
  >
1842
  <option value="pontos">Pontos representando dados de mercado</option>
1843
  <option value="cobertura">Cobertura dos modelos</option>
@@ -1847,11 +2164,11 @@ export default function PesquisaTab({
1847
  <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
1848
  Exibição dos trabalhos técnicos
1849
  <select
 
1850
  value={mapaTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
1851
  ? 'selecionados_e_outras_versoes'
1852
  : mapaTrabalhosTecnicosModelosModo}
1853
  onChange={(event) => setMapaTrabalhosTecnicosModelosModo(event.target.value)}
1854
- autoComplete="off"
1855
  disabled={mapaLoading || !selectedIds.length}
1856
  >
1857
  <option value="selecionados">Somente dos modelos selecionados</option>
@@ -1859,22 +2176,22 @@ export default function PesquisaTab({
1859
  </select>
1860
  </label>
1861
 
1862
- {localizacaoAtiva ? (
1863
  <label className="pesquisa-field pesquisa-mapa-proximidade-field">
1864
  Trabalhos técnicos adicionais por raio
1865
  <select
 
1866
  value={mapaTrabalhosTecnicosProximidadeModo}
1867
  onChange={(event) => setMapaTrabalhosTecnicosProximidadeModo(event.target.value)}
1868
- autoComplete="off"
1869
- disabled={mapaLoading || !selectedIds.length}
1870
- >
1871
  <option value="sem_proximidade">Não mostrar</option>
1872
  <option value="proximos_ao_avaliando">Mostrar próximos do avaliando</option>
1873
  </select>
1874
  </label>
1875
  ) : null}
1876
 
1877
- {localizacaoAtiva && mapaTrabalhosTecnicosProximidadeModo === 'proximos_ao_avaliando' ? (
1878
  <label className="pesquisa-field pesquisa-mapa-raio-field">
1879
  Raio do avaliando (m)
1880
  <div className="pesquisa-mapa-raio-control">
 
18
  negociacaoModelo: '',
19
  dataMin: '',
20
  dataMax: '',
21
+ versionamentoModelos: 'incluir_antigos',
22
  avalFinalidade: '',
23
  avalZona: '',
24
  avalBairro: '',
 
34
  cdlog: '',
35
  }
36
 
37
+ const CRITERIO_ESPACIAL_OPTIONS = [
38
+ { value: 'maior_distancia', label: 'Maior distância' },
39
+ { value: 'media_distancia', label: 'Média distância' },
40
+ { value: 'menor_distancia', label: 'Menor distância' },
41
+ ]
42
+
43
  const RESULT_INITIAL = {
44
  modelos: [],
45
  sugestoes: {},
 
58
  { key: 'obs_calc', label: 'Obs x Calc' },
59
  { key: 'graficos', label: 'Gráficos' },
60
  ]
61
+ const MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO = 'selecionados_e_outras_versoes'
62
 
63
  const TIPO_SIGLAS = {
64
  RECOND: 'Residencia em condominio',
 
104
  return `${number.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} km`
105
  }
106
 
107
+ function formatAvaliandoGeoLabel(index) {
108
+ return `A${index + 1}`
109
+ }
110
+
111
+ function buildAvaliandosGeoPayload(entries = []) {
112
+ return (entries || []).map((item, index) => ({
113
+ id: String(item?.id || `avaliando-${index + 1}`),
114
+ label: formatAvaliandoGeoLabel(index),
115
+ lat: Number(item?.lat),
116
+ lon: Number(item?.lon),
117
+ logradouro: item?.logradouro || null,
118
+ numero_usado: item?.numero_usado || null,
119
+ cdlog: item?.cdlog ?? null,
120
+ origem: item?.origem || null,
121
+ })).filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lon))
122
+ }
123
+
124
+ function getResumoEspacialValor(resumo, criterio) {
125
+ if (!resumo || typeof resumo !== 'object') return null
126
+ if (criterio === 'menor_distancia') return Number(resumo.menor_distancia_km)
127
+ if (criterio === 'media_distancia') return Number(resumo.media_distancia_km)
128
+ return Number(resumo.maior_distancia_km)
129
+ }
130
+
131
+ function sortModelosBySpatialCriterion(modelos = [], criterio = 'maior_distancia') {
132
+ if (!Array.isArray(modelos)) return []
133
+ return [...modelos].sort((a, b) => {
134
+ const aValor = getResumoEspacialValor(a?.distancia_resumo, criterio)
135
+ const bValor = getResumoEspacialValor(b?.distancia_resumo, criterio)
136
+ const aSem = !Number.isFinite(aValor)
137
+ const bSem = !Number.isFinite(bValor)
138
+ if (aSem !== bSem) return aSem ? 1 : -1
139
+ if (!aSem && !bSem && aValor !== bValor) return aValor - bValor
140
+ const aNome = String(a?.nome_modelo || a?.arquivo || a?.id || '').toLowerCase()
141
+ const bNome = String(b?.nome_modelo || b?.arquivo || b?.id || '').toLowerCase()
142
+ return aNome.localeCompare(bNome, 'pt-BR')
143
+ })
144
+ }
145
+
146
+ function formatResumoEspacial(resumo) {
147
+ if (!resumo || typeof resumo !== 'object') return '-'
148
+ return `Maior ${String(resumo.maior_distancia_label || '-')} • Média ${String(resumo.media_distancia_label || '-')} • Menor ${String(resumo.menor_distancia_label || '-')}`
149
+ }
150
+
151
+ function formatResumoEspacialCobertura(resumo) {
152
+ if (!resumo || typeof resumo !== 'object') return ''
153
+ const total = Number(resumo.total_avaliandos || 0)
154
+ const comDistancia = Number(resumo.total_com_distancia || 0)
155
+ if (!total) return ''
156
+ return `${comDistancia}/${total} com distância calculada`
157
+ }
158
+
159
+ function formatDistanciasPorAvaliando(distancias = []) {
160
+ if (!Array.isArray(distancias) || !distancias.length) return '-'
161
+ return distancias.map((item, index) => {
162
+ const label = String(item?.label || formatAvaliandoGeoLabel(index)).trim() || formatAvaliandoGeoLabel(index)
163
+ const distancia = String(item?.distancia_label || '').trim() || formatDistanceKm(item?.distancia_km)
164
+ return `${label}: ${distancia}`
165
+ }).join(' • ')
166
+ }
167
+
168
+ function formatLocalizacaoOrigemLabel(localizacao) {
169
+ return localizacao?.origem === 'eixos'
170
+ ? 'Eixos de logradouro'
171
+ : 'Coordenadas informadas'
172
+ }
173
+
174
+ function createLocalizacaoEntry(resolvida, id) {
175
+ return {
176
+ ...resolvida,
177
+ id,
178
+ lat: Number(resolvida?.lat),
179
+ lon: Number(resolvida?.lon),
180
+ }
181
+ }
182
+
183
  function formatDateBrIfIso(value) {
184
  const text = String(value ?? '').trim()
185
  if (!text) return '-'
 
285
  )
286
  }
287
 
288
+ function LocalizacaoResumoCard({
289
+ title,
290
+ subtitle = '',
291
+ localizacao = null,
292
+ actions = null,
293
+ className = '',
294
+ }) {
295
+ if (!localizacao) return null
296
+ const classes = ['pesquisa-localizacao-registered', className].filter(Boolean).join(' ')
297
+ return (
298
+ <div className={classes}>
299
+ <div className="pesquisa-localizacao-registered-head">
300
+ <div className="pesquisa-localizacao-registered-copy">
301
+ <strong>{title}</strong>
302
+ {subtitle ? <span>{subtitle}</span> : null}
303
+ </div>
304
+ {actions}
305
+ </div>
306
+
307
+ <div className="pesquisa-localizacao-summary">
308
+ {localizacao?.logradouro ? (
309
+ <div className="pesquisa-localizacao-summary-row">
310
+ <span className="pesquisa-localizacao-summary-label">Endereço</span>
311
+ <span className="pesquisa-localizacao-summary-value">
312
+ {localizacao.logradouro}
313
+ {localizacao?.numero_usado ? `, ${localizacao.numero_usado}` : ''}
314
+ </span>
315
+ </div>
316
+ ) : null}
317
+ {localizacao?.cdlog ? (
318
+ <div className="pesquisa-localizacao-summary-row">
319
+ <span className="pesquisa-localizacao-summary-label">CDLOG</span>
320
+ <span className="pesquisa-localizacao-summary-value">{localizacao.cdlog}</span>
321
+ </div>
322
+ ) : null}
323
+ <div className="pesquisa-localizacao-summary-row">
324
+ <span className="pesquisa-localizacao-summary-label">Latitude</span>
325
+ <span className="pesquisa-localizacao-summary-value">{Number(localizacao.lat).toFixed(6)}</span>
326
+ </div>
327
+ <div className="pesquisa-localizacao-summary-row">
328
+ <span className="pesquisa-localizacao-summary-label">Longitude</span>
329
+ <span className="pesquisa-localizacao-summary-value">{Number(localizacao.lon).toFixed(6)}</span>
330
+ </div>
331
+ <div className="pesquisa-localizacao-summary-row">
332
+ <span className="pesquisa-localizacao-summary-label">Origem</span>
333
+ <span className="pesquisa-localizacao-summary-value">{formatLocalizacaoOrigemLabel(localizacao)}</span>
334
+ </div>
335
+ </div>
336
+ </div>
337
+ )
338
+ }
339
+
340
  function CompactHoverList({
341
  label,
342
  buttonLabel,
 
622
  return mapped || text
623
  }
624
 
625
+ function buildApiFilters(filters, avaliandosGeolocalizados = []) {
626
  const payload = {
627
  otica: 'avaliando',
628
  nome: filters.nomeModelo,
 
637
  aval_area: filters.avalArea,
638
  aval_rh: filters.avalRh,
639
  }
640
+ const avaliandosPayload = buildAvaliandosGeoPayload(avaliandosGeolocalizados)
641
+ if (avaliandosPayload.length > 1) {
642
+ payload.avaliandos_geo_json = JSON.stringify(avaliandosPayload)
643
+ } else if (avaliandosPayload.length === 1) {
644
+ payload.aval_lat = avaliandosPayload[0].lat
645
+ payload.aval_lon = avaliandosPayload[0].lon
646
  }
647
  return payload
648
  }
 
655
  return `mesa_${Math.abs(hash)}`
656
  }
657
 
658
+ function buildInputAutofillProps(field) {
659
+ return {
660
+ name: toInputName(field),
661
+ autoComplete: 'new-password',
662
+ autoCorrect: 'off',
663
+ autoCapitalize: 'none',
664
+ spellCheck: false,
665
+ 'data-lpignore': 'true',
666
+ 'data-1p-ignore': 'true',
667
+ }
668
+ }
669
+
670
+ function buildSelectAutofillProps(field) {
671
+ return {
672
+ name: toInputName(field),
673
+ autoComplete: 'new-password',
674
+ 'data-lpignore': 'true',
675
+ 'data-1p-ignore': 'true',
676
+ }
677
+ }
678
+
679
  function TextFieldInput({ field, ...props }) {
680
  return (
681
  <input
682
  {...props}
683
  data-field={field}
684
+ {...buildInputAutofillProps(field)}
 
 
 
 
685
  />
686
  )
687
  }
 
694
  step="any"
695
  inputMode="decimal"
696
  data-field={field}
697
+ {...buildInputAutofillProps(field)}
 
698
  />
699
  )
700
  }
 
705
  {...props}
706
  type="date"
707
  data-field={field}
708
+ {...buildInputAutofillProps(field)}
 
709
  />
710
  )
711
  }
 
971
  const [result, setResult] = useState(RESULT_INITIAL)
972
  const [localizacaoModo, setLocalizacaoModo] = useState('endereco')
973
  const [localizacaoInputs, setLocalizacaoInputs] = useState(EMPTY_LOCATION_INPUTS)
974
+ const [avaliandosGeolocalizados, setAvaliandosGeolocalizados] = useState([])
975
+ const [localizacaoEditorAberto, setLocalizacaoEditorAberto] = useState(false)
976
  const [localizacaoLoading, setLocalizacaoLoading] = useState(false)
977
  const [localizacaoError, setLocalizacaoError] = useState('')
978
  const [localizacaoStatus, setLocalizacaoStatus] = useState('')
979
+ const [criterioEspacial, setCriterioEspacial] = useState('maior_distancia')
980
 
981
  const [selectedIds, setSelectedIds] = useState([])
982
  const selectAllRef = useRef(null)
983
+ const localizacaoIdCounterRef = useRef(1)
984
 
985
  const [mapaLoading, setMapaLoading] = useState(false)
986
  const [mapaError, setMapaError] = useState('')
 
1007
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
1008
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
1009
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
1010
+ const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1011
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
1012
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
1013
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
 
1024
  .sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })),
1025
  [sugestoes.tipos_modelo],
1026
  )
 
1027
  const modoModeloAberto = Boolean(modeloAbertoMeta)
1028
+ const avaliandosGeoPayload = useMemo(
1029
+ () => buildAvaliandosGeoPayload(avaliandosGeolocalizados),
1030
+ [avaliandosGeolocalizados],
1031
+ )
1032
+ const localizacaoAtiva = avaliandosGeoPayload.length > 0
1033
+ const localizacaoMultipla = avaliandosGeoPayload.length > 1
1034
+ const avaliandoUnicoAtivo = avaliandosGeoPayload.length === 1
1035
+ const avaliandoPrincipal = avaliandosGeolocalizados.length === 1 ? avaliandosGeolocalizados[0] : null
1036
+ const modelosOrdenados = useMemo(
1037
+ () => (localizacaoMultipla ? sortModelosBySpatialCriterion(result.modelos || [], criterioEspacial) : (result.modelos || [])),
1038
+ [criterioEspacial, localizacaoMultipla, result.modelos],
1039
+ )
1040
+ const resultIds = useMemo(() => modelosOrdenados.map((modelo) => modelo.id), [modelosOrdenados])
1041
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
1042
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
1043
+ const localizacaoRegistradaMensagem = localizacaoMultipla
1044
+ ? `${formatCount(avaliandosGeoPayload.length)} avaliandos foram geolocalizados e serão usados na distância espacial dos modelos.`
1045
+ : (String(localizacaoStatus || '').trim()
1046
+ || 'A geolocalização do avaliando foi registrada e será usada na distância espacial dos modelos.')
1047
  const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
1048
  const mapaFoiGerado = Boolean(mapaHtmls.pontos || mapaHtmls.cobertura)
1049
 
 
1086
  ? 'selecionados_e_outras_versoes'
1087
  : String(modelosModoBruto || 'selecionados')
1088
  const proximidadeModoBruto = overrides.trabalhosTecnicosProximidadeModo ?? mapaTrabalhosTecnicosProximidadeModo
1089
+ const proximidadeModo = avaliandoUnicoAtivo ? String(proximidadeModoBruto || 'sem_proximidade') : 'sem_proximidade'
1090
  const raioNumero = Number(overrides.trabalhosTecnicosRaio ?? mapaTrabalhosTecnicosRaio)
1091
  const raio = Number.isFinite(raioNumero)
1092
  ? Math.max(0, Math.min(5000, Math.round(raioNumero)))
 
1096
 
1097
  function buildMapaTrabalhosTecnicosConfigKey(config) {
1098
  return JSON.stringify({
1099
+ totalAvaliandos: avaliandosGeoPayload.length,
1100
  modelosModo: config.modelosModo,
1101
  proximidadeModo: config.proximidadeModo,
1102
+ criterioEspacial: localizacaoMultipla ? criterioEspacial : null,
1103
  raio: config.proximidadeModo === 'proximos_ao_avaliando' ? config.raio : null,
1104
  })
1105
  }
 
1122
  try {
1123
  const response = await api.pesquisarMapaModelos(
1124
  idsValidos,
1125
+ localizacaoMultipla ? avaliandosGeoPayload : avaliandoPrincipal,
1126
  modoExibicaoSolicitado,
1127
+ criterioEspacial,
1128
  trabalhosTecnicosConfig.modelosModo,
1129
  trabalhosTecnicosConfig.proximidadeModo,
1130
  trabalhosTecnicosConfig.raio,
 
1142
  }
1143
  }
1144
 
1145
+ async function buscarModelos(nextFilters = filters, nextAvaliandos = avaliandosGeolocalizados) {
1146
  setLoading(true)
1147
  setError('')
1148
  try {
1149
+ const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextAvaliandos))
1150
  const modelos = response.modelos || []
1151
  const idsNovos = new Set(modelos.map((item) => item.id))
1152
 
 
1230
  }
1231
  }, [
1232
  localizacaoAtiva,
1233
+ localizacaoMultipla,
1234
  mapaFoiGerado,
1235
  mapaLoading,
1236
  mapaTrabalhosTecnicosModelosModo,
1237
  mapaTrabalhosTecnicosProximidadeModo,
1238
  mapaTrabalhosTecnicosRaio,
1239
+ avaliandosGeoPayload.length,
1240
+ criterioEspacial,
1241
  ])
1242
 
1243
  function onFieldChange(event) {
 
1257
  function atualizarCampoLocalizacao(field, value) {
1258
  if (!field) return
1259
  setLocalizacaoInputs((prev) => ({ ...prev, [field]: value }))
 
 
 
1260
  setLocalizacaoError('')
 
 
1261
  }
1262
 
1263
  async function onLimparFiltros() {
1264
  setFilters(EMPTY_FILTERS)
 
 
 
 
1265
  await carregarContextoInicial()
1266
  }
1267
 
 
1308
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
1309
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
1310
  setModeloAbertoMapaVar('Visualização Padrão')
1311
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1312
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1313
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
1314
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
 
1326
  setModeloAbertoError('')
1327
  try {
1328
  await api.visualizacaoRepositorioCarregar(sessionId, modelo.id)
1329
+ const resp = await api.exibirVisualizacao(sessionId, MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1330
  preencherModeloAberto(resp)
1331
  setModeloAbertoActiveTab('mapa')
1332
  setModeloAbertoMeta({
 
1351
  scrollParaResultadosNoTopo()
1352
  }
1353
 
1354
+ async function atualizarMapaModeloAberto(
1355
+ nextVar = modeloAbertoMapaVar,
1356
+ nextTrabalhosTecnicosModo = modeloAbertoTrabalhosTecnicosModelosModo,
1357
+ ) {
1358
+ if (!sessionId) return
1359
+ const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
1360
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
1361
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1362
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
1363
+ }
1364
+
1365
  async function onModeloAbertoMapChange(nextVar) {
1366
  setModeloAbertoMapaVar(nextVar)
 
1367
  try {
1368
+ await atualizarMapaModeloAberto(nextVar, modeloAbertoTrabalhosTecnicosModelosModo)
1369
+ } catch (err) {
1370
+ setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
1371
+ }
1372
+ }
1373
+
1374
+ async function onModeloAbertoTrabalhosTecnicosModeChange(nextMode) {
1375
+ setModeloAbertoTrabalhosTecnicosModelosModo(nextMode)
1376
+ try {
1377
+ await atualizarMapaModeloAberto(modeloAbertoMapaVar, nextMode)
1378
  } catch (err) {
1379
  setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
1380
  }
 
1405
 
1406
  async function onAdminConfigSalva() {
1407
  if (pesquisaInicializada) {
1408
+ await buscarModelos(filters, avaliandosGeolocalizados)
1409
  return
1410
  }
1411
  await carregarContextoInicial()
1412
  }
1413
 
1414
+ function onAdicionarOutroAvaliando() {
1415
+ setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1416
+ setLocalizacaoModo('endereco')
1417
+ setLocalizacaoError('')
1418
+ setLocalizacaoStatus('')
1419
+ setLocalizacaoEditorAberto(true)
1420
+ }
1421
+
1422
+ function onCancelarInclusaoAvaliando() {
1423
+ setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1424
+ setLocalizacaoModo('endereco')
1425
+ setLocalizacaoError('')
1426
+ setLocalizacaoEditorAberto(false)
1427
+ }
1428
+
1429
+ function onLimparFormularioLocalizacao() {
1430
+ setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1431
+ setLocalizacaoModo('endereco')
1432
+ setLocalizacaoError('')
1433
+ setLocalizacaoStatus('')
1434
+ }
1435
+
1436
+ async function atualizarPesquisaAposGeolocalizacao(nextEntries) {
1437
+ resetMapaPesquisa()
1438
+ if (pesquisaInicializada) {
1439
+ await buscarModelos(filters, nextEntries)
1440
+ }
1441
+ }
1442
+
1443
  async function onResolverLocalizacao() {
1444
  setLocalizacaoError('')
1445
  setLocalizacaoStatus('')
 
1478
  lat: Number(response?.lat),
1479
  lon: Number(response?.lon),
1480
  }
1481
+ const nextEntry = createLocalizacaoEntry(
1482
+ resolvida,
1483
+ `avaliando-${localizacaoIdCounterRef.current}`,
1484
+ )
1485
+ localizacaoIdCounterRef.current += 1
1486
+ const nextEntries = [...avaliandosGeolocalizados, nextEntry]
1487
+ setAvaliandosGeolocalizados(nextEntries)
1488
+ setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1489
+ setLocalizacaoEditorAberto(false)
1490
+ setLocalizacaoStatus(
1491
+ nextEntries.length > 1
1492
+ ? `${nextEntries.length} avaliandos geolocalizados com sucesso.`
1493
+ : (response?.status || 'Geolocalização do avaliando registrada.')
1494
+ )
1495
+ await atualizarPesquisaAposGeolocalizacao(nextEntries)
1496
  } catch (err) {
1497
  setLocalizacaoError(err.message || 'Falha ao localizar o avaliando.')
 
1498
  } finally {
1499
  setLocalizacaoLoading(false)
1500
  }
 
1502
 
1503
  async function onLimparLocalizacao() {
1504
  setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1505
+ setAvaliandosGeolocalizados([])
1506
+ setLocalizacaoEditorAberto(false)
1507
  setLocalizacaoError('')
1508
  setLocalizacaoStatus('')
1509
+ await atualizarPesquisaAposGeolocalizacao([])
1510
+ }
1511
+
1512
+ async function onRemoverAvaliandoLocalizacao(id) {
1513
+ const nextEntries = avaliandosGeolocalizados.filter((item) => item.id !== id)
1514
+ setAvaliandosGeolocalizados(nextEntries)
1515
+ setLocalizacaoError('')
1516
+ setLocalizacaoStatus('')
1517
+ if (!nextEntries.length) {
1518
+ setLocalizacaoEditorAberto(false)
1519
  }
1520
+ await atualizarPesquisaAposGeolocalizacao(nextEntries)
1521
  }
1522
 
1523
  if (modoModeloAberto) {
 
1555
  <div className="inner-tab-panel">
1556
  {modeloAbertoActiveTab === 'mapa' ? (
1557
  <>
1558
+ <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
1559
+ <label className="pesquisa-field pesquisa-mapa-modo-field">
1560
+ Variável no mapa
1561
+ <select
1562
+ {...buildSelectAutofillProps('modeloAbertoMapaVar')}
1563
+ value={modeloAbertoMapaVar}
1564
+ onChange={(event) => void onModeloAbertoMapChange(event.target.value)}
1565
+ >
1566
+ {modeloAbertoMapaChoices.map((choice) => (
1567
+ <option key={`modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
1568
+ ))}
1569
+ </select>
1570
+ </label>
1571
+ <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
1572
+ Exibição dos trabalhos técnicos
1573
+ <select
1574
+ {...buildSelectAutofillProps('modeloAbertoTrabalhosTecnicosModelosModo')}
1575
+ value={modeloAbertoTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
1576
+ ? MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO
1577
+ : modeloAbertoTrabalhosTecnicosModelosModo}
1578
+ onChange={(event) => void onModeloAbertoTrabalhosTecnicosModeChange(event.target.value)}
1579
+ >
1580
+ <option value="selecionados">Somente deste modelo</option>
1581
+ <option value="selecionados_e_outras_versoes">Incluir demais versões do modelo</option>
1582
+ </select>
1583
+ </label>
1584
  </div>
1585
  <MapFrame html={modeloAbertoMapaHtml} />
1586
  </>
 
1644
  <div className="tab-content">
1645
  <SectionBlock
1646
  step="1"
1647
+ title="Geolocalização do Avaliando"
1648
+ subtitle="Registre um endereço ou coordenadas do avaliando. O preenchimento desta seção não é obrigatório. Se você não informar localização, a pesquisa continua normal, a distância espacial dos modelos não será calculada e o avaliando não aparecerá no mapa gerado."
1649
+ >
1650
+ <div className="pesquisa-localizacao-section">
1651
+ {avaliandoPrincipal ? (
1652
+ <LocalizacaoResumoCard
1653
+ title="Geolocalização registrada"
1654
+ subtitle={localizacaoRegistradaMensagem}
1655
+ localizacao={avaliandoPrincipal}
1656
+ actions={(
1657
+ <div className="pesquisa-localizacao-registered-actions">
1658
+ <button
1659
+ type="button"
1660
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1661
+ onClick={localizacaoEditorAberto ? onCancelarInclusaoAvaliando : onAdicionarOutroAvaliando}
1662
+ disabled={localizacaoLoading || loading}
1663
+ >
1664
+ {localizacaoEditorAberto ? 'Cancelar inclusão' : 'Adicionar outro avaliando'}
1665
+ </button>
1666
+ <button
1667
+ type="button"
1668
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset pesquisa-localizacao-restart-btn"
1669
+ onClick={() => void onLimparLocalizacao()}
1670
+ disabled={localizacaoLoading || loading}
1671
+ >
1672
+ Reiniciar geolocalização
1673
+ </button>
1674
+ </div>
1675
+ )}
1676
+ />
1677
+ ) : null}
1678
+
1679
+ {localizacaoMultipla ? (
1680
+ <div className="pesquisa-localizacao-multiple-block">
1681
+ <div className="pesquisa-localizacao-registered-head pesquisa-localizacao-multi-head">
1682
+ <div className="pesquisa-localizacao-registered-copy">
1683
+ <strong>Geolocalizações registradas</strong>
1684
+ <span>{localizacaoRegistradaMensagem}</span>
1685
+ </div>
1686
+ <div className="pesquisa-localizacao-registered-actions">
1687
+ <button
1688
+ type="button"
1689
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1690
+ onClick={localizacaoEditorAberto ? onCancelarInclusaoAvaliando : onAdicionarOutroAvaliando}
1691
+ disabled={localizacaoLoading || loading}
1692
+ >
1693
+ {localizacaoEditorAberto ? 'Cancelar inclusão' : 'Adicionar outro avaliando'}
1694
+ </button>
1695
+ <button
1696
+ type="button"
1697
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1698
+ onClick={() => void onLimparLocalizacao()}
1699
+ disabled={localizacaoLoading || loading}
1700
+ >
1701
+ Reiniciar geolocalizações
1702
+ </button>
1703
+ </div>
1704
+ </div>
1705
+
1706
+ <div className="pesquisa-localizacao-multi-grid">
1707
+ {avaliandosGeolocalizados.map((item, index) => (
1708
+ <LocalizacaoResumoCard
1709
+ key={item.id}
1710
+ title={`Avaliando ${formatAvaliandoGeoLabel(index)}`}
1711
+ localizacao={item}
1712
+ className="pesquisa-localizacao-multi-card"
1713
+ actions={(
1714
+ <button
1715
+ type="button"
1716
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1717
+ onClick={() => void onRemoverAvaliandoLocalizacao(item.id)}
1718
+ disabled={localizacaoLoading || loading}
1719
+ >
1720
+ Remover
1721
+ </button>
1722
+ )}
1723
+ />
1724
+ ))}
1725
+ </div>
1726
+ </div>
1727
+ ) : null}
1728
+
1729
+ {!localizacaoAtiva || localizacaoEditorAberto ? (localizacaoModo === 'coords' ? (
1730
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-coords">
1731
+ <label className="pesquisa-field">
1732
+ Forma de localização
1733
+ <select
1734
+ data-field="localizacaoModo"
1735
+ {...buildSelectAutofillProps('localizacaoModo')}
1736
+ value={localizacaoModo}
1737
+ onChange={(event) => setLocalizacaoModo(event.target.value)}
1738
+ >
1739
+ <option value="endereco">Endereço</option>
1740
+ <option value="coords">Coordenadas</option>
1741
+ </select>
1742
+ </label>
1743
+ <label className="pesquisa-field">
1744
+ Latitude
1745
+ <NumberFieldInput field="latitude" value={localizacaoInputs.latitude} onChange={onLocalizacaoFieldChange} placeholder="-30.000000" />
1746
+ </label>
1747
+ <label className="pesquisa-field">
1748
+ Longitude
1749
+ <NumberFieldInput field="longitude" value={localizacaoInputs.longitude} onChange={onLocalizacaoFieldChange} placeholder="-51.000000" />
1750
+ </label>
1751
+ <div className="pesquisa-localizacao-actions-inline">
1752
+ <button
1753
+ type="button"
1754
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1755
+ onClick={() => void onResolverLocalizacao()}
1756
+ disabled={localizacaoLoading}
1757
+ >
1758
+ {localizacaoLoading ? 'Buscando...' : 'Buscar'}
1759
+ </button>
1760
+ <button
1761
+ type="button"
1762
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1763
+ onClick={onLimparFormularioLocalizacao}
1764
+ disabled={localizacaoLoading}
1765
+ >
1766
+ Limpar
1767
+ </button>
1768
+ </div>
1769
+ </div>
1770
+ ) : (
1771
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-endereco">
1772
+ <label className="pesquisa-field">
1773
+ Forma de localização
1774
+ <select
1775
+ data-field="localizacaoModo"
1776
+ {...buildSelectAutofillProps('localizacaoModo')}
1777
+ value={localizacaoModo}
1778
+ onChange={(event) => setLocalizacaoModo(event.target.value)}
1779
+ >
1780
+ <option value="endereco">Endereço</option>
1781
+ <option value="coords">Coordenadas</option>
1782
+ </select>
1783
+ </label>
1784
+ <label className="pesquisa-field">
1785
+ CDLOG
1786
+ <NumberFieldInput field="cdlog" value={localizacaoInputs.cdlog} onChange={onLocalizacaoFieldChange} placeholder="Opcional" />
1787
+ </label>
1788
+ <label className="pesquisa-field pesquisa-localizacao-logradouro-field">
1789
+ Logradouro
1790
+ <SinglePillAutocomplete
1791
+ value={localizacaoInputs.logradouro}
1792
+ onChange={(nextValue) => atualizarCampoLocalizacao('logradouro', nextValue)}
1793
+ options={sugestoes.logradouros_eixos || []}
1794
+ placeholder="Digite ou selecione um logradouro dos eixos"
1795
+ panelTitle="Logradouros dos eixos"
1796
+ emptyMessage="Nenhum logradouro encontrado nos eixos."
1797
+ loading={loading && !sugestoesInicializadas}
1798
+ inputName={toInputName('logradouroEixosPesquisa')}
1799
+ inputAutoComplete="new-password"
1800
+ />
1801
+ </label>
1802
+ <label className="pesquisa-field">
1803
+ Número
1804
+ <NumberFieldInput field="numero" value={localizacaoInputs.numero} onChange={onLocalizacaoFieldChange} placeholder="0" />
1805
+ </label>
1806
+ <div className="pesquisa-localizacao-actions-inline">
1807
+ <button
1808
+ type="button"
1809
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1810
+ onClick={() => void onResolverLocalizacao()}
1811
+ disabled={localizacaoLoading}
1812
+ >
1813
+ {localizacaoLoading ? 'Buscando...' : 'Buscar'}
1814
+ </button>
1815
+ <button
1816
+ type="button"
1817
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1818
+ onClick={onLimparFormularioLocalizacao}
1819
+ disabled={localizacaoLoading}
1820
+ >
1821
+ Limpar
1822
+ </button>
1823
+ </div>
1824
+ </div>
1825
+ )) : null}
1826
+
1827
+ {localizacaoStatus && !localizacaoAtiva ? <div className="status-line">{localizacaoStatus}</div> : null}
1828
+ {localizacaoError ? <div className="error-line inline-error">{localizacaoError}</div> : null}
1829
+ </div>
1830
+ </SectionBlock>
1831
+
1832
+ <SectionBlock
1833
+ step="2"
1834
  title="Filtros de Pesquisa"
1835
+ subtitle="Informe os dados do avaliando para filtrar os modelos. Todos os filtros são cumulativos."
1836
  aside={(
1837
  <button
1838
  type="button"
 
1865
  Tipo do modelo
1866
  <select
1867
  data-field="negociacaoModelo"
1868
+ {...buildSelectAutofillProps('negociacaoModelo')}
1869
  value={filters.negociacaoModelo}
1870
  onChange={onFieldChange}
 
1871
  >
1872
  <option value="">Indiferente</option>
1873
  <option value="aluguel">Aluguel</option>
 
1878
  Finalidade genérica
1879
  <select
1880
  data-field="tipoModelo"
1881
+ {...buildSelectAutofillProps('tipoModelo')}
1882
  value={filters.tipoModelo}
1883
  onChange={onFieldChange}
 
1884
  >
1885
  <option value="">Todos</option>
1886
  {opcoesTipoModelo.map((tipo) => (
 
1958
  Versionamento dos modelos
1959
  <select
1960
  data-field="versionamentoModelos"
1961
+ {...buildSelectAutofillProps('versionamentoModelos')}
1962
  value={filters.versionamentoModelos}
1963
  onChange={onFieldChange}
 
1964
  >
1965
+ <option value="incluir_antigos">Exibir todos os modelos</option>
1966
  <option value="atuais">Exibir somente versões mais atuais</option>
 
1967
  </select>
1968
  </label>
1969
  </div>
1970
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1971
  <div className="row pesquisa-actions pesquisa-actions-primary">
1972
  <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
1973
  {loading ? 'Pesquisando...' : 'Pesquisar'}
 
1982
 
1983
  <div ref={sectionResultadosRef}>
1984
  <SectionBlock
1985
+ step="3"
1986
  title="Resultados"
1987
  subtitle="Modelos aceitos para os parametros do avaliando informado."
1988
  >
 
1991
  <strong>{formatCount(result.total_filtrado)}</strong>{' '}
1992
  modelo(s) aceito(s) de <strong>{formatCount(result.total_geral)}</strong>.
1993
  </div>
1994
+ {localizacaoMultipla ? (
1995
+ <label className="pesquisa-results-criterio">
1996
+ Critério espacial
1997
+ <select
1998
+ {...buildSelectAutofillProps('criterioEspacial')}
1999
+ value={criterioEspacial}
2000
+ onChange={(event) => setCriterioEspacial(event.target.value)}
2001
+ >
2002
+ {CRITERIO_ESPACIAL_OPTIONS.map((option) => (
2003
+ <option key={`criterio-espacial-${option.value}`} value={option.value}>{option.label}</option>
2004
+ ))}
2005
+ </select>
2006
+ </label>
2007
+ ) : null}
2008
  {resultIds.length ? (
2009
  <label className="pesquisa-select-all">
2010
  <input ref={selectAllRef} type="checkbox" checked={todosSelecionados} onChange={onToggleSelecionarTodos} />
 
2013
  ) : null}
2014
  </div>
2015
 
2016
+ {!modelosOrdenados.length ? (
2017
  <div className="empty-box">
2018
  {!pesquisaInicializada
2019
  ? 'Defina os filtros desejados e clique em Pesquisar.'
 
2021
  </div>
2022
  ) : (
2023
  <div className="pesquisa-card-grid">
2024
+ {modelosOrdenados.map((modelo) => {
2025
  const selecionado = selectedIds.includes(modelo.id)
2026
  const faixaDataRecency = getFaixaDataRecencyInfo(modelo.faixa_data)
2027
  const finalidadesText = uppercaseListText(modelo.finalidades || [])
2028
  const bairrosText = uppercaseListText(modelo.bairros || [])
2029
  const variaveisText = buildVariablesDisplay(modelo)
2030
  const observacaoText = String(modelo.observacao_modelo || '').trim()
2031
+ const resumoEspacial = modelo?.distancia_resumo || null
2032
+ const coberturaEspacial = formatResumoEspacialCobertura(resumoEspacial)
2033
  const cardClassName = [
2034
  'pesquisa-card',
2035
  selecionado ? 'is-selected' : '',
 
2057
  </div>
2058
  <div className="pesquisa-card-body">
2059
  <div className="pesquisa-card-dados-list">
2060
+ {localizacaoMultipla ? (
2061
+ <>
2062
+ <div><strong>Resumo espacial:</strong> {formatResumoEspacial(resumoEspacial)}</div>
2063
+ <div><strong>Distâncias por avaliando:</strong> {formatDistanciasPorAvaliando(modelo?.distancias_avaliandos || [])}</div>
2064
+ {coberturaEspacial ? <div><strong>Cobertura espacial:</strong> {coberturaEspacial}</div> : null}
2065
+ </>
2066
+ ) : (
2067
+ <div><strong>Distância:</strong> {String(modelo.distancia_label || '').trim() || formatDistanceKm(modelo.distancia_km)}</div>
2068
+ )}
2069
  <div><strong>Tipo:</strong> {formatTipoImovel(modelo)}</div>
2070
  <div><strong>Autor:</strong> {modelo.autor || '-'}</div>
2071
  <div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
 
2123
 
2124
  <div ref={sectionMapaRef}>
2125
  <SectionBlock
2126
+ step="4"
2127
  title="Mapa"
2128
  subtitle={localizacaoAtiva
2129
+ ? (localizacaoMultipla
2130
+ ? 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos, com os avaliandos destacados.'
2131
+ : 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos, com o avaliando destacado.')
2132
  : 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos.'}
2133
  >
2134
  <div className="pesquisa-summary-line">
2135
  <strong>{formatCount(selectedIds.length)}</strong> modelo(s) selecionado(s) para plotagem.
2136
  </div>
2137
  <div className="pesquisa-summary-line">
2138
+ <strong>{localizacaoMultipla ? 'Avaliandos geolocalizados:' : 'Localização do avaliando:'}</strong>{' '}
2139
+ {localizacaoAtiva ? (localizacaoMultipla ? formatCount(avaliandosGeoPayload.length) : 'ativa') : 'não definida'}
2140
  </div>
2141
 
2142
  <div className="row pesquisa-actions pesquisa-mapa-actions">
 
2152
  <label className="pesquisa-field pesquisa-mapa-modo-field">
2153
  Exibição do mapa
2154
  <select
2155
+ {...buildSelectAutofillProps('mapaModoExibicao')}
2156
  value={mapaModoExibicao}
2157
  onChange={(event) => setMapaModoExibicao(event.target.value)}
 
2158
  >
2159
  <option value="pontos">Pontos representando dados de mercado</option>
2160
  <option value="cobertura">Cobertura dos modelos</option>
 
2164
  <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
2165
  Exibição dos trabalhos técnicos
2166
  <select
2167
+ {...buildSelectAutofillProps('mapaTrabalhosTecnicosModelosModo')}
2168
  value={mapaTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
2169
  ? 'selecionados_e_outras_versoes'
2170
  : mapaTrabalhosTecnicosModelosModo}
2171
  onChange={(event) => setMapaTrabalhosTecnicosModelosModo(event.target.value)}
 
2172
  disabled={mapaLoading || !selectedIds.length}
2173
  >
2174
  <option value="selecionados">Somente dos modelos selecionados</option>
 
2176
  </select>
2177
  </label>
2178
 
2179
+ {avaliandoUnicoAtivo ? (
2180
  <label className="pesquisa-field pesquisa-mapa-proximidade-field">
2181
  Trabalhos técnicos adicionais por raio
2182
  <select
2183
+ {...buildSelectAutofillProps('mapaTrabalhosTecnicosProximidadeModo')}
2184
  value={mapaTrabalhosTecnicosProximidadeModo}
2185
  onChange={(event) => setMapaTrabalhosTecnicosProximidadeModo(event.target.value)}
2186
+ disabled={mapaLoading || !selectedIds.length}
2187
+ >
 
2188
  <option value="sem_proximidade">Não mostrar</option>
2189
  <option value="proximos_ao_avaliando">Mostrar próximos do avaliando</option>
2190
  </select>
2191
  </label>
2192
  ) : null}
2193
 
2194
+ {avaliandoUnicoAtivo && mapaTrabalhosTecnicosProximidadeModo === 'proximos_ao_avaliando' ? (
2195
  <label className="pesquisa-field pesquisa-mapa-raio-field">
2196
  Raio do avaliando (m)
2197
  <div className="pesquisa-mapa-raio-control">
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -22,6 +22,7 @@ const REPO_INNER_TABS = [
22
  { key: 'obs_calc', label: 'Obs x Calc' },
23
  { key: 'graficos', label: 'Gráficos' },
24
  ]
 
25
 
26
  function formatarFonte(fonte) {
27
  if (!fonte || typeof fonte !== 'object') return 'Fonte não informada'
@@ -69,6 +70,7 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
69
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
70
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
71
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
 
72
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
73
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
74
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
@@ -222,6 +224,7 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
222
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
223
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
224
  setModeloAbertoMapaVar('Visualização Padrão')
 
225
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
226
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
227
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
@@ -239,7 +242,7 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
239
  setModeloAbertoError('')
240
  try {
241
  await api.visualizacaoRepositorioCarregar(sessionId, String(item?.id || ''))
242
- const resp = await api.exibirVisualizacao(sessionId)
243
  preencherModeloAberto(resp)
244
  setModeloAbertoActiveTab('mapa')
245
  setModeloAbertoMeta({
@@ -260,12 +263,30 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
260
  setModeloAbertoActiveTab('mapa')
261
  }
262
 
 
 
 
 
 
 
 
 
 
 
 
263
  async function onModeloAbertoMapChange(nextVar) {
264
  setModeloAbertoMapaVar(nextVar)
265
- if (!sessionId) return
266
  try {
267
- const resp = await api.updateVisualizacaoMap(sessionId, nextVar)
268
- setModeloAbertoMapaHtml(resp?.mapa_html || '')
 
 
 
 
 
 
 
 
269
  } catch (err) {
270
  setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
271
  }
@@ -315,13 +336,28 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
315
  <div className="inner-tab-panel">
316
  {modeloAbertoActiveTab === 'mapa' ? (
317
  <>
318
- <div className="row compact visualizacao-mapa-controls">
319
- <label>Variável no mapa</label>
320
- <select value={modeloAbertoMapaVar} onChange={(event) => void onModeloAbertoMapChange(event.target.value)}>
321
- {modeloAbertoMapaChoices.map((choice) => (
322
- <option key={`repo-modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
323
- ))}
324
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  </div>
326
  <MapFrame html={modeloAbertoMapaHtml} />
327
  </>
 
22
  { key: 'obs_calc', label: 'Obs x Calc' },
23
  { key: 'graficos', label: 'Gráficos' },
24
  ]
25
+ const MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO = 'selecionados_e_outras_versoes'
26
 
27
  function formatarFonte(fonte) {
28
  if (!fonte || typeof fonte !== 'object') return 'Fonte não informada'
 
70
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
71
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
72
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
73
+ const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
74
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
75
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
76
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
 
224
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
225
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
226
  setModeloAbertoMapaVar('Visualização Padrão')
227
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
228
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
229
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
230
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
 
242
  setModeloAbertoError('')
243
  try {
244
  await api.visualizacaoRepositorioCarregar(sessionId, String(item?.id || ''))
245
+ const resp = await api.exibirVisualizacao(sessionId, MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
246
  preencherModeloAberto(resp)
247
  setModeloAbertoActiveTab('mapa')
248
  setModeloAbertoMeta({
 
263
  setModeloAbertoActiveTab('mapa')
264
  }
265
 
266
+ async function atualizarMapaModeloAberto(
267
+ nextVar = modeloAbertoMapaVar,
268
+ nextTrabalhosTecnicosModo = modeloAbertoTrabalhosTecnicosModelosModo,
269
+ ) {
270
+ if (!sessionId) return
271
+ const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
272
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
273
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
274
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
275
+ }
276
+
277
  async function onModeloAbertoMapChange(nextVar) {
278
  setModeloAbertoMapaVar(nextVar)
 
279
  try {
280
+ await atualizarMapaModeloAberto(nextVar, modeloAbertoTrabalhosTecnicosModelosModo)
281
+ } catch (err) {
282
+ setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
283
+ }
284
+ }
285
+
286
+ async function onModeloAbertoTrabalhosTecnicosModeChange(nextMode) {
287
+ setModeloAbertoTrabalhosTecnicosModelosModo(nextMode)
288
+ try {
289
+ await atualizarMapaModeloAberto(modeloAbertoMapaVar, nextMode)
290
  } catch (err) {
291
  setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
292
  }
 
336
  <div className="inner-tab-panel">
337
  {modeloAbertoActiveTab === 'mapa' ? (
338
  <>
339
+ <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
340
+ <label className="pesquisa-field pesquisa-mapa-modo-field">
341
+ Variável no mapa
342
+ <select value={modeloAbertoMapaVar} onChange={(event) => void onModeloAbertoMapChange(event.target.value)}>
343
+ {modeloAbertoMapaChoices.map((choice) => (
344
+ <option key={`repo-modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
345
+ ))}
346
+ </select>
347
+ </label>
348
+ <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
349
+ Exibição dos trabalhos técnicos
350
+ <select
351
+ value={modeloAbertoTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
352
+ ? MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO
353
+ : modeloAbertoTrabalhosTecnicosModelosModo}
354
+ onChange={(event) => void onModeloAbertoTrabalhosTecnicosModeChange(event.target.value)}
355
+ autoComplete="off"
356
+ >
357
+ <option value="selecionados">Somente deste modelo</option>
358
+ <option value="selecionados_e_outras_versoes">Incluir demais versões do modelo</option>
359
+ </select>
360
+ </label>
361
  </div>
362
  <MapFrame html={modeloAbertoMapaHtml} />
363
  </>
frontend/src/components/SinglePillAutocomplete.jsx CHANGED
@@ -1,4 +1,5 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react'
 
2
 
3
  function normalizeSearchText(value) {
4
  return String(value || '')
@@ -52,13 +53,17 @@ export default function SinglePillAutocomplete({
52
  disabled = false,
53
  onOpenChange = null,
54
  inputName = '',
55
- inputAutoComplete = 'off',
56
  }) {
57
  const rootRef = useRef(null)
58
  const inputRef = useRef(null)
 
59
  const [query, setQuery] = useState('')
60
  const [open, setOpen] = useState(false)
61
  const [activeIndex, setActiveIndex] = useState(-1)
 
 
 
62
 
63
  const selectedValue = String(value || '')
64
  const normalizedOptions = useMemo(() => {
@@ -97,8 +102,9 @@ export default function SinglePillAutocomplete({
97
  useEffect(() => {
98
  if (!open) return undefined
99
  function onDocumentMouseDown(event) {
100
- if (!rootRef.current) return
101
- if (!rootRef.current.contains(event.target)) setOpen(false)
 
102
  }
103
  document.addEventListener('mousedown', onDocumentMouseDown)
104
  return () => document.removeEventListener('mousedown', onDocumentMouseDown)
@@ -121,6 +127,61 @@ export default function SinglePillAutocomplete({
121
  if (activeIndex >= filteredOptions.length) setActiveIndex(filteredOptions.length - 1)
122
  }, [activeIndex, filteredOptions, open])
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  function emitChange(nextValue) {
125
  if (typeof onChange === 'function') onChange(String(nextValue || ''))
126
  }
@@ -227,7 +288,7 @@ export default function SinglePillAutocomplete({
227
  ref={inputRef}
228
  type="text"
229
  className="chip-autocomplete-single-input"
230
- name={inputName || undefined}
231
  value={query}
232
  onChange={onInputChange}
233
  onFocus={onInputFocus}
@@ -243,36 +304,44 @@ export default function SinglePillAutocomplete({
243
  />
244
  </div>
245
 
246
- {open && !disabled ? (
247
- <div className="chip-autocomplete-panel" role="listbox">
248
- {panelTitle ? <div className="chip-autocomplete-panel-head">{panelTitle}</div> : null}
249
- {loading ? (
250
- <div className="chip-autocomplete-empty">Carregando lista...</div>
251
- ) : filteredOptions.length ? (
252
- <div className="chip-autocomplete-chip-wrap">
253
- {filteredOptions.map((item, idx) => (
254
- <button
255
- type="button"
256
- key={`single-chip-${item.value}-${idx}`}
257
- className={`chip-autocomplete-chip${idx === activeIndex ? ' is-active' : ''}`}
258
- onMouseDown={(event) => {
259
- event.preventDefault()
260
- event.stopPropagation()
261
- selectOption(item)
262
- }}
263
- title={item.secondary ? `${item.label} | ${item.secondary}` : item.label}
264
- >
265
- {item.label}
266
- </button>
267
- ))}
268
- </div>
269
- ) : (
270
- <div className="chip-autocomplete-empty">
271
- {emptyMessage}
272
- </div>
273
- )}
274
- </div>
275
- ) : null}
 
 
 
 
 
 
 
 
276
  </div>
277
  )
278
  }
 
1
+ import React, { useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
 
4
  function normalizeSearchText(value) {
5
  return String(value || '')
 
53
  disabled = false,
54
  onOpenChange = null,
55
  inputName = '',
56
+ inputAutoComplete = 'new-password',
57
  }) {
58
  const rootRef = useRef(null)
59
  const inputRef = useRef(null)
60
+ const panelRef = useRef(null)
61
  const [query, setQuery] = useState('')
62
  const [open, setOpen] = useState(false)
63
  const [activeIndex, setActiveIndex] = useState(-1)
64
+ const [panelStyle, setPanelStyle] = useState({})
65
+ const generatedInputId = useId()
66
+ const resolvedInputName = inputName || `mesa_single_${String(generatedInputId).replace(/[^a-zA-Z0-9_-]/g, '')}`
67
 
68
  const selectedValue = String(value || '')
69
  const normalizedOptions = useMemo(() => {
 
102
  useEffect(() => {
103
  if (!open) return undefined
104
  function onDocumentMouseDown(event) {
105
+ const target = event.target
106
+ if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) return
107
+ setOpen(false)
108
  }
109
  document.addEventListener('mousedown', onDocumentMouseDown)
110
  return () => document.removeEventListener('mousedown', onDocumentMouseDown)
 
127
  if (activeIndex >= filteredOptions.length) setActiveIndex(filteredOptions.length - 1)
128
  }, [activeIndex, filteredOptions, open])
129
 
130
+ function updatePanelPosition() {
131
+ if (!open || !rootRef.current || !panelRef.current || typeof window === 'undefined') return
132
+
133
+ const bounds = rootRef.current.getBoundingClientRect()
134
+ const panel = panelRef.current
135
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0
136
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
137
+ const padding = 12
138
+ const gap = 6
139
+
140
+ function clamp(value, min, max) {
141
+ if (max < min) return min
142
+ return Math.min(Math.max(value, min), max)
143
+ }
144
+
145
+ const width = clamp(bounds.width, 220, Math.max(220, viewportWidth - (padding * 2)))
146
+ const availableBelow = Math.max(120, viewportHeight - bounds.bottom - gap - padding)
147
+ const availableAbove = Math.max(120, bounds.top - gap - padding)
148
+ const shouldOpenBelow = availableBelow >= 180 || availableBelow >= availableAbove
149
+ const maxHeight = shouldOpenBelow ? availableBelow : availableAbove
150
+ const desiredHeight = Math.min(panel.scrollHeight || 220, maxHeight)
151
+ const left = clamp(bounds.left, padding, viewportWidth - padding - width)
152
+ const top = shouldOpenBelow
153
+ ? clamp(bounds.bottom + gap, padding, viewportHeight - padding - desiredHeight)
154
+ : clamp(bounds.top - gap - desiredHeight, padding, viewportHeight - padding - desiredHeight)
155
+
156
+ setPanelStyle({
157
+ left: `${left}px`,
158
+ top: `${top}px`,
159
+ width: `${width}px`,
160
+ maxHeight: `${Math.max(120, maxHeight)}px`,
161
+ })
162
+ }
163
+
164
+ useEffect(() => {
165
+ if (!open) return undefined
166
+
167
+ const rafId = window.requestAnimationFrame(() => {
168
+ updatePanelPosition()
169
+ })
170
+
171
+ function handleViewportChange() {
172
+ updatePanelPosition()
173
+ }
174
+
175
+ window.addEventListener('resize', handleViewportChange)
176
+ window.addEventListener('scroll', handleViewportChange, true)
177
+
178
+ return () => {
179
+ window.cancelAnimationFrame(rafId)
180
+ window.removeEventListener('resize', handleViewportChange)
181
+ window.removeEventListener('scroll', handleViewportChange, true)
182
+ }
183
+ }, [open, filteredOptions.length, loading, query, selectedValue])
184
+
185
  function emitChange(nextValue) {
186
  if (typeof onChange === 'function') onChange(String(nextValue || ''))
187
  }
 
288
  ref={inputRef}
289
  type="text"
290
  className="chip-autocomplete-single-input"
291
+ name={resolvedInputName}
292
  value={query}
293
  onChange={onInputChange}
294
  onFocus={onInputFocus}
 
304
  />
305
  </div>
306
 
307
+ {open && !disabled && typeof document !== 'undefined'
308
+ ? createPortal(
309
+ <div
310
+ ref={panelRef}
311
+ className="chip-autocomplete-panel is-floating"
312
+ style={{ ...panelStyle, visibility: panelStyle.left ? 'visible' : 'hidden' }}
313
+ role="listbox"
314
+ >
315
+ {panelTitle ? <div className="chip-autocomplete-panel-head">{panelTitle}</div> : null}
316
+ {loading ? (
317
+ <div className="chip-autocomplete-empty">Carregando lista...</div>
318
+ ) : filteredOptions.length ? (
319
+ <div className="chip-autocomplete-chip-wrap">
320
+ {filteredOptions.map((item, idx) => (
321
+ <button
322
+ type="button"
323
+ key={`single-chip-${item.value}-${idx}`}
324
+ className={`chip-autocomplete-chip${idx === activeIndex ? ' is-active' : ''}`}
325
+ onMouseDown={(event) => {
326
+ event.preventDefault()
327
+ event.stopPropagation()
328
+ selectOption(item)
329
+ }}
330
+ title={item.secondary ? `${item.label} | ${item.secondary}` : item.label}
331
+ >
332
+ {item.label}
333
+ </button>
334
+ ))}
335
+ </div>
336
+ ) : (
337
+ <div className="chip-autocomplete-empty">
338
+ {emptyMessage}
339
+ </div>
340
+ )}
341
+ </div>,
342
+ document.body,
343
+ )
344
+ : null}
345
  </div>
346
  )
347
  }
frontend/src/components/VisualizacaoTab.jsx CHANGED
@@ -21,6 +21,7 @@ const INNER_TABS = [
21
  { key: 'avaliacao_massa', label: 'Avaliação em Massa' },
22
  ]
23
  const BASE_COMPARACAO_SEM_BASE = '__none__'
 
24
 
25
  export default function VisualizacaoTab({ sessionId }) {
26
  const [loading, setLoading] = useState(false)
@@ -54,6 +55,7 @@ export default function VisualizacaoTab({ sessionId }) {
54
  const [mapaHtml, setMapaHtml] = useState('')
55
  const [mapaChoices, setMapaChoices] = useState(['Visualização Padrão'])
56
  const [mapaVar, setMapaVar] = useState('Visualização Padrão')
 
57
 
58
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
59
  const valoresAvaliacaoRef = useRef({})
@@ -95,6 +97,7 @@ export default function VisualizacaoTab({ sessionId }) {
95
  setMapaHtml('')
96
  setMapaChoices(['Visualização Padrão'])
97
  setMapaVar('Visualização Padrão')
 
98
 
99
  setCamposAvaliacao([])
100
  valoresAvaliacaoRef.current = {}
@@ -126,6 +129,7 @@ export default function VisualizacaoTab({ sessionId }) {
126
  setMapaHtml(resp.mapa_html || '')
127
  setMapaChoices(resp.mapa_choices || ['Visualização Padrão'])
128
  setMapaVar('Visualização Padrão')
 
129
 
130
  setCamposAvaliacao(resp.campos_avaliacao || [])
131
  const values = {}
@@ -229,7 +233,7 @@ export default function VisualizacaoTab({ sessionId }) {
229
  setStatus(uploadResp.status || '')
230
  setBadgeHtml(uploadResp.badge_html || '')
231
 
232
- const exibirResp = await api.exibirVisualizacao(sessionId)
233
  applyExibicaoResponse(exibirResp)
234
  })
235
  }
@@ -243,7 +247,7 @@ export default function VisualizacaoTab({ sessionId }) {
243
  setStatus(uploadResp.status || '')
244
  setBadgeHtml(uploadResp.badge_html || '')
245
 
246
- const exibirResp = await api.exibirVisualizacao(sessionId)
247
  applyExibicaoResponse(exibirResp)
248
  setUploadedFile(null)
249
  })
@@ -282,12 +286,27 @@ export default function VisualizacaoTab({ sessionId }) {
282
  void onUploadModel(file)
283
  }
284
 
 
 
 
 
 
 
 
 
 
 
285
  async function onMapChange(value) {
286
  setMapaVar(value)
287
- if (!sessionId) return
288
  await withBusy(async () => {
289
- const resp = await api.updateVisualizacaoMap(sessionId, value)
290
- setMapaHtml(resp.mapa_html || '')
 
 
 
 
 
 
291
  })
292
  }
293
 
@@ -510,13 +529,28 @@ export default function VisualizacaoTab({ sessionId }) {
510
  <div className="inner-tab-panel">
511
  {activeInnerTab === 'mapa' ? (
512
  <>
513
- <div className="row compact visualizacao-mapa-controls">
514
- <label>Variável no mapa</label>
515
- <select value={mapaVar} onChange={(e) => onMapChange(e.target.value)}>
516
- {mapaChoices.map((choice) => (
517
- <option key={choice} value={choice}>{choice}</option>
518
- ))}
519
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  </div>
521
  <MapFrame html={mapaHtml} />
522
  </>
 
21
  { key: 'avaliacao_massa', label: 'Avaliação em Massa' },
22
  ]
23
  const BASE_COMPARACAO_SEM_BASE = '__none__'
24
+ const TRABALHOS_TECNICOS_MODELOS_PADRAO = 'selecionados_e_outras_versoes'
25
 
26
  export default function VisualizacaoTab({ sessionId }) {
27
  const [loading, setLoading] = useState(false)
 
55
  const [mapaHtml, setMapaHtml] = useState('')
56
  const [mapaChoices, setMapaChoices] = useState(['Visualização Padrão'])
57
  const [mapaVar, setMapaVar] = useState('Visualização Padrão')
58
+ const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState(TRABALHOS_TECNICOS_MODELOS_PADRAO)
59
 
60
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
61
  const valoresAvaliacaoRef = useRef({})
 
97
  setMapaHtml('')
98
  setMapaChoices(['Visualização Padrão'])
99
  setMapaVar('Visualização Padrão')
100
+ setMapaTrabalhosTecnicosModelosModo(TRABALHOS_TECNICOS_MODELOS_PADRAO)
101
 
102
  setCamposAvaliacao([])
103
  valoresAvaliacaoRef.current = {}
 
129
  setMapaHtml(resp.mapa_html || '')
130
  setMapaChoices(resp.mapa_choices || ['Visualização Padrão'])
131
  setMapaVar('Visualização Padrão')
132
+ setMapaTrabalhosTecnicosModelosModo(resp.trabalhos_tecnicos_modelos_modo || TRABALHOS_TECNICOS_MODELOS_PADRAO)
133
 
134
  setCamposAvaliacao(resp.campos_avaliacao || [])
135
  const values = {}
 
233
  setStatus(uploadResp.status || '')
234
  setBadgeHtml(uploadResp.badge_html || '')
235
 
236
+ const exibirResp = await api.exibirVisualizacao(sessionId, TRABALHOS_TECNICOS_MODELOS_PADRAO)
237
  applyExibicaoResponse(exibirResp)
238
  })
239
  }
 
247
  setStatus(uploadResp.status || '')
248
  setBadgeHtml(uploadResp.badge_html || '')
249
 
250
+ const exibirResp = await api.exibirVisualizacao(sessionId, TRABALHOS_TECNICOS_MODELOS_PADRAO)
251
  applyExibicaoResponse(exibirResp)
252
  setUploadedFile(null)
253
  })
 
286
  void onUploadModel(file)
287
  }
288
 
289
+ async function atualizarMapa(
290
+ variavelMapa = mapaVar,
291
+ trabalhosTecnicosModelosModo = mapaTrabalhosTecnicosModelosModo,
292
+ ) {
293
+ if (!sessionId) return
294
+ const resp = await api.updateVisualizacaoMap(sessionId, variavelMapa, trabalhosTecnicosModelosModo)
295
+ setMapaHtml(resp.mapa_html || '')
296
+ setMapaTrabalhosTecnicosModelosModo(resp.trabalhos_tecnicos_modelos_modo || trabalhosTecnicosModelosModo)
297
+ }
298
+
299
  async function onMapChange(value) {
300
  setMapaVar(value)
 
301
  await withBusy(async () => {
302
+ await atualizarMapa(value, mapaTrabalhosTecnicosModelosModo)
303
+ })
304
+ }
305
+
306
+ async function onMapTrabalhosTecnicosModeChange(value) {
307
+ setMapaTrabalhosTecnicosModelosModo(value)
308
+ await withBusy(async () => {
309
+ await atualizarMapa(mapaVar, value)
310
  })
311
  }
312
 
 
529
  <div className="inner-tab-panel">
530
  {activeInnerTab === 'mapa' ? (
531
  <>
532
+ <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
533
+ <label className="pesquisa-field pesquisa-mapa-modo-field">
534
+ Variável no mapa
535
+ <select value={mapaVar} onChange={(e) => onMapChange(e.target.value)}>
536
+ {mapaChoices.map((choice) => (
537
+ <option key={choice} value={choice}>{choice}</option>
538
+ ))}
539
+ </select>
540
+ </label>
541
+ <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
542
+ Exibição dos trabalhos técnicos
543
+ <select
544
+ value={mapaTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
545
+ ? TRABALHOS_TECNICOS_MODELOS_PADRAO
546
+ : mapaTrabalhosTecnicosModelosModo}
547
+ onChange={(event) => void onMapTrabalhosTecnicosModeChange(event.target.value)}
548
+ autoComplete="off"
549
+ >
550
+ <option value="selecionados">Somente deste modelo</option>
551
+ <option value="selecionados_e_outras_versoes">Incluir demais versões do modelo</option>
552
+ </select>
553
+ </label>
554
  </div>
555
  <MapFrame html={mapaHtml} />
556
  </>
frontend/src/styles.css CHANGED
@@ -2321,7 +2321,18 @@ button.pesquisa-otica-btn.active:hover {
2321
  overflow: auto;
2322
  }
2323
 
2324
- .workflow-section[data-section-step="1"] .chip-autocomplete.is-open .chip-autocomplete-panel {
 
 
 
 
 
 
 
 
 
 
 
2325
  position: absolute;
2326
  top: calc(100% + 6px);
2327
  left: 0;
@@ -2562,7 +2573,9 @@ button.pesquisa-otica-btn.active:hover {
2562
  height: var(--pesquisa-localizacao-control-height);
2563
  }
2564
 
2565
- .pesquisa-localizacao-group {
 
 
2566
  margin-top: 2px;
2567
  }
2568
 
@@ -2573,6 +2586,62 @@ button.pesquisa-otica-btn.active:hover {
2573
  line-height: 1.45;
2574
  }
2575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2576
  .pesquisa-localizacao-action {
2577
  min-width: 86px;
2578
  min-height: var(--pesquisa-localizacao-control-height);
@@ -2590,15 +2659,21 @@ button.pesquisa-otica-btn.active:hover {
2590
  }
2591
 
2592
  .pesquisa-localizacao-action-ok {
2593
- background: #2e8b57;
2594
- border-color: #2e8b57;
 
 
 
2595
  color: #ffffff;
2596
  }
2597
 
2598
  .pesquisa-localizacao-action-reset {
2599
- background: #edf1f5;
2600
- border-color: #cfd8e2;
2601
- color: #647588;
 
 
 
2602
  }
2603
 
2604
  .pesquisa-localizacao-actions-inline {
@@ -2612,6 +2687,10 @@ button.pesquisa-otica-btn.active:hover {
2612
  margin-top: 27px;
2613
  }
2614
 
 
 
 
 
2615
  .pesquisa-localizacao-summary {
2616
  grid-column: 1 / -1;
2617
  display: grid;
@@ -2903,6 +2982,19 @@ button.pesquisa-coluna-remove:hover {
2903
  margin: 0;
2904
  }
2905
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2906
  .pesquisa-select-all {
2907
  display: inline-flex;
2908
  align-items: center;
@@ -6572,10 +6664,30 @@ button.btn-download-subtle {
6572
  grid-template-columns: 1fr;
6573
  }
6574
 
 
 
 
 
 
 
 
 
 
 
6575
  .pesquisa-localizacao-summary-row {
6576
  grid-template-columns: 1fr;
6577
  }
6578
 
 
 
 
 
 
 
 
 
 
 
6579
  .pesquisa-avaliando-bottom-grid {
6580
  grid-template-columns: 1fr;
6581
  }
@@ -6628,6 +6740,10 @@ button.btn-download-subtle {
6628
  align-items: flex-start;
6629
  }
6630
 
 
 
 
 
6631
  .pesquisa-select-all {
6632
  white-space: normal;
6633
  }
 
2321
  overflow: auto;
2322
  }
2323
 
2324
+ .chip-autocomplete-panel.is-floating {
2325
+ position: fixed;
2326
+ left: 0;
2327
+ right: auto;
2328
+ top: 0;
2329
+ z-index: 3600;
2330
+ overflow-x: hidden;
2331
+ overflow-y: auto;
2332
+ }
2333
+
2334
+ .workflow-section[data-section-step="1"] .chip-autocomplete.is-open .chip-autocomplete-panel,
2335
+ .workflow-section[data-section-step="2"] .chip-autocomplete.is-open .chip-autocomplete-panel {
2336
  position: absolute;
2337
  top: calc(100% + 6px);
2338
  left: 0;
 
2573
  height: var(--pesquisa-localizacao-control-height);
2574
  }
2575
 
2576
+ .pesquisa-localizacao-section {
2577
+ display: grid;
2578
+ gap: 14px;
2579
  margin-top: 2px;
2580
  }
2581
 
 
2586
  line-height: 1.45;
2587
  }
2588
 
2589
+ .pesquisa-localizacao-registered {
2590
+ grid-column: 1 / -1;
2591
+ display: grid;
2592
+ gap: 14px;
2593
+ }
2594
+
2595
+ .pesquisa-localizacao-registered-head {
2596
+ display: flex;
2597
+ align-items: flex-start;
2598
+ justify-content: space-between;
2599
+ gap: 14px;
2600
+ padding: 14px 15px;
2601
+ border: 1px solid #cfe3d4;
2602
+ border-radius: 12px;
2603
+ background: linear-gradient(180deg, #f5fcf6 0%, #eef8f0 100%);
2604
+ }
2605
+
2606
+ .pesquisa-localizacao-registered-copy {
2607
+ display: grid;
2608
+ gap: 4px;
2609
+ }
2610
+
2611
+ .pesquisa-localizacao-registered-copy strong {
2612
+ font-size: 0.92rem;
2613
+ color: #215a37;
2614
+ }
2615
+
2616
+ .pesquisa-localizacao-registered-copy span {
2617
+ font-size: 0.82rem;
2618
+ color: #496a56;
2619
+ line-height: 1.45;
2620
+ }
2621
+
2622
+ .pesquisa-localizacao-registered-actions {
2623
+ display: inline-flex;
2624
+ align-items: center;
2625
+ justify-content: flex-end;
2626
+ gap: 8px;
2627
+ flex-wrap: wrap;
2628
+ }
2629
+
2630
+ .pesquisa-localizacao-multiple-block {
2631
+ display: grid;
2632
+ gap: 14px;
2633
+ }
2634
+
2635
+ .pesquisa-localizacao-multi-grid {
2636
+ display: grid;
2637
+ grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
2638
+ gap: 12px;
2639
+ }
2640
+
2641
+ .pesquisa-localizacao-multi-card {
2642
+ height: 100%;
2643
+ }
2644
+
2645
  .pesquisa-localizacao-action {
2646
  min-width: 86px;
2647
  min-height: var(--pesquisa-localizacao-control-height);
 
2659
  }
2660
 
2661
  .pesquisa-localizacao-action-ok {
2662
+ --btn-bg-start: #ff8c00;
2663
+ --btn-bg-end: #e67e00;
2664
+ --btn-border: #cc6f00;
2665
+ background: linear-gradient(180deg, var(--btn-bg-start) 0%, var(--btn-bg-end) 100%);
2666
+ border-color: var(--btn-border);
2667
  color: #ffffff;
2668
  }
2669
 
2670
  .pesquisa-localizacao-action-reset {
2671
+ --btn-bg-start: #2f80cf;
2672
+ --btn-bg-end: #2368af;
2673
+ --btn-border: #1f5f9f;
2674
+ background: linear-gradient(180deg, var(--btn-bg-start) 0%, var(--btn-bg-end) 100%);
2675
+ border-color: var(--btn-border);
2676
+ color: #ffffff;
2677
  }
2678
 
2679
  .pesquisa-localizacao-actions-inline {
 
2687
  margin-top: 27px;
2688
  }
2689
 
2690
+ .pesquisa-localizacao-restart-btn {
2691
+ min-width: 220px;
2692
+ }
2693
+
2694
  .pesquisa-localizacao-summary {
2695
  grid-column: 1 / -1;
2696
  display: grid;
 
2982
  margin: 0;
2983
  }
2984
 
2985
+ .pesquisa-results-criterio {
2986
+ display: grid;
2987
+ gap: 6px;
2988
+ min-width: 220px;
2989
+ color: #4f657a;
2990
+ font-size: 0.79rem;
2991
+ font-weight: 700;
2992
+ }
2993
+
2994
+ .pesquisa-results-criterio select {
2995
+ min-height: 34px;
2996
+ }
2997
+
2998
  .pesquisa-select-all {
2999
  display: inline-flex;
3000
  align-items: center;
 
6664
  grid-template-columns: 1fr;
6665
  }
6666
 
6667
+ .pesquisa-localizacao-registered-head {
6668
+ flex-direction: column;
6669
+ align-items: stretch;
6670
+ }
6671
+
6672
+ .pesquisa-localizacao-restart-btn {
6673
+ min-width: 0;
6674
+ width: 100%;
6675
+ }
6676
+
6677
  .pesquisa-localizacao-summary-row {
6678
  grid-template-columns: 1fr;
6679
  }
6680
 
6681
+ .pesquisa-localizacao-registered-actions {
6682
+ width: 100%;
6683
+ flex-direction: column;
6684
+ align-items: stretch;
6685
+ }
6686
+
6687
+ .pesquisa-localizacao-multi-grid {
6688
+ grid-template-columns: 1fr;
6689
+ }
6690
+
6691
  .pesquisa-avaliando-bottom-grid {
6692
  grid-template-columns: 1fr;
6693
  }
 
6740
  align-items: flex-start;
6741
  }
6742
 
6743
+ .pesquisa-results-criterio {
6744
+ width: 100%;
6745
+ }
6746
+
6747
  .pesquisa-select-all {
6748
  white-space: normal;
6749
  }