Guilherme Silberfarb Costa commited on
Commit
d2635d4
·
1 Parent(s): 4a39261

diversas atualizacoes na aba pesquisas

Browse files
backend/app/api/pesquisa.py CHANGED
@@ -3,7 +3,13 @@ from __future__ import annotations
3
  from fastapi import APIRouter, Query
4
  from pydantic import BaseModel
5
 
6
- from app.services.pesquisa_service import PesquisaFiltros, gerar_mapa_modelos, listar_modelos
 
 
 
 
 
 
7
 
8
 
9
  router = APIRouter(prefix="/api/pesquisa", tags=["pesquisa"])
@@ -19,12 +25,27 @@ class MapaModelosPayload(BaseModel):
19
  modelos_ids: list[str]
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  @router.get("/modelos")
23
  def pesquisar_modelos(
24
  somente_contexto: bool = Query(False),
25
  otica: str = Query("modelo"),
26
  nome: str | None = Query(None),
27
  autor: str | None = Query(None),
 
28
  finalidade: str | None = Query(None),
29
  finalidade_colunas: str | None = Query(None),
30
  bairro: str | None = Query(None),
@@ -65,6 +86,7 @@ def pesquisar_modelos(
65
  otica=otica,
66
  nome=nome,
67
  autor=autor,
 
68
  finalidade=finalidade,
69
  finalidade_colunas=_split_csv(finalidade_colunas),
70
  bairro=bairro,
 
3
  from fastapi import APIRouter, Query
4
  from pydantic import BaseModel
5
 
6
+ from app.services.pesquisa_service import (
7
+ PesquisaFiltros,
8
+ gerar_mapa_modelos,
9
+ listar_modelos,
10
+ obter_admin_config_pesquisa,
11
+ salvar_admin_config_pesquisa,
12
+ )
13
 
14
 
15
  router = APIRouter(prefix="/api/pesquisa", tags=["pesquisa"])
 
25
  modelos_ids: list[str]
26
 
27
 
28
+ class PesquisaAdminConfigPayload(BaseModel):
29
+ campos: dict[str, list[str]] = {}
30
+
31
+
32
+ @router.get("/admin-config")
33
+ def pesquisar_admin_config() -> dict:
34
+ return obter_admin_config_pesquisa()
35
+
36
+
37
+ @router.post("/admin-config")
38
+ def pesquisar_admin_config_salvar(payload: PesquisaAdminConfigPayload) -> dict:
39
+ return salvar_admin_config_pesquisa(payload.campos)
40
+
41
+
42
  @router.get("/modelos")
43
  def pesquisar_modelos(
44
  somente_contexto: bool = Query(False),
45
  otica: str = Query("modelo"),
46
  nome: str | None = Query(None),
47
  autor: str | None = Query(None),
48
+ contem_app: str | None = Query(None),
49
  finalidade: str | None = Query(None),
50
  finalidade_colunas: str | None = Query(None),
51
  bairro: str | None = Query(None),
 
86
  otica=otica,
87
  nome=nome,
88
  autor=autor,
89
+ contem_app=contem_app,
90
  finalidade=finalidade,
91
  finalidade_colunas=_split_csv(finalidade_colunas),
92
  bairro=bairro,
backend/app/services/pesquisa_service.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import math
4
  import re
5
  import unicodedata
@@ -18,17 +19,20 @@ from app.core.elaboracao.core import _migrar_pacote_v1_para_v2
18
  from app.services.serializers import sanitize_value
19
 
20
  MODELOS_DIR = Path(__file__).resolve().parent.parent / "core" / "pesquisa" / "modelos_dai"
 
21
 
22
- AREA_PRIVATIVA_ALIASES = ["APRIV", "ACOPRIV", "AREAPRIV", "AREA_PRIVATIVA", "AREA PRIVATIVA"]
23
- AREA_TOTAL_ALIASES = ["ATOTAL", "ATOT", "AREA_TOTAL", "AREA TOTAL", "AREA"]
24
- VALOR_UNITARIO_ALIASES = ["VU", "VUNIT", "VULOC", "VUAPRIV", "VALOR_UNITARIO", "VALOR UNITARIO"]
25
- VALOR_TOTAL_ALIASES = ["VLOC", "VTOT", "VALOR_TOTAL", "VALOR TOTAL"]
 
26
  RH_ALIASES = ["RH", "FATOR_RH", "FATOR RH", "RENDA_HABITACIONAL"]
27
- DATA_ALIASES = ["DATA", "DT", "ANO", "DATA_AVALIACAO", "DATA AVALIACAO", "COMPETENCIA"]
28
  BAIRRO_ALIASES = ["BAIRRO", "BAIRROS", "NOME_BAIRRO", "BAIRRO_NOME", "NME BAI", "NME_BAI"]
29
  FINALIDADE_ALIASES = ["NME IMO-FINAL", "NME_IMO_FINAL", "NME IMO FINAL", "FINALIDADE", "TIPO_IMOVEL", "TIPO IMOVEL"]
30
  LAT_ALIASES = ["LAT", "LATITUDE", "SIAT_LATITUDE"]
31
  LON_ALIASES = ["LON", "LONG", "LONGITUDE", "SIAT_LONGITUDE"]
 
32
 
33
  TIPO_POR_TOKEN = {
34
  "RECOND": "Residencia em condominio",
@@ -80,10 +84,13 @@ CAMPO_FAIXA_META_FONTES = {
80
 
81
  CAMPO_FAIXA_ALIASES_COLUNA = {
82
  "data": DATA_ALIASES,
83
- "area": AREA_PRIVATIVA_ALIASES + AREA_TOTAL_ALIASES,
84
- "rh": RH_ALIASES,
85
  "aval_data": DATA_ALIASES,
86
- "aval_area": AREA_PRIVATIVA_ALIASES + AREA_TOTAL_ALIASES,
 
 
 
 
 
87
  "aval_area_privativa": AREA_PRIVATIVA_ALIASES,
88
  "aval_area_total": AREA_TOTAL_ALIASES,
89
  "aval_rh": RH_ALIASES,
@@ -146,6 +153,7 @@ class PesquisaFiltros:
146
  otica: str = "modelo"
147
  nome: str | None = None
148
  autor: str | None = None
 
149
  finalidade: str | None = None
150
  finalidade_colunas: list[str] | None = None
151
  bairro: str | None = None
@@ -184,6 +192,7 @@ class PesquisaFiltros:
184
 
185
  _CACHE_LOCK = Lock()
186
  _CACHE: dict[str, dict[str, Any]] = {}
 
187
 
188
 
189
  def ensure_modelos_dir() -> Path:
@@ -191,6 +200,38 @@ def ensure_modelos_dir() -> Path:
191
  return MODELOS_DIR
192
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_contexto: bool = False) -> dict[str, Any]:
195
  pasta = ensure_modelos_dir()
196
  modelos = sorted(pasta.glob("*.dai"), key=lambda item: item.name.lower())
@@ -199,8 +240,9 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
199
  filtros_exec = PesquisaFiltros(**{**filtros.__dict__, "otica": otica})
200
 
201
  todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
202
- sugestoes = _extrair_sugestoes(todos)
203
  colunas_filtro = _montar_config_colunas_filtro(todos)
 
 
204
 
205
  if somente_contexto:
206
  return sanitize_value(
@@ -211,9 +253,11 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
211
  "total_filtrado": 0,
212
  "total_geral": len(todos),
213
  "modelos_dir": str(pasta),
 
214
  "filtros_aplicados": {
215
  "nome": filtros.nome,
216
  "autor": filtros.autor,
 
217
  "finalidade": filtros.finalidade,
218
  "finalidade_colunas": filtros.finalidade_colunas or [],
219
  "bairro": filtros.bairro,
@@ -253,10 +297,10 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
253
  }
254
  )
255
 
256
- filtrados = [item for item in todos if _aceita_filtros(item, filtros_exec)]
257
 
258
  if otica == "avaliando":
259
- filtrados = [_anexar_avaliando_info(item, filtros_exec) for item in filtrados]
260
  filtrados = [item for item in filtrados if item.get("avaliando", {}).get("aceito")]
261
 
262
  if limite and limite > 0:
@@ -272,9 +316,11 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
272
  "total_filtrado": len(filtrados),
273
  "total_geral": len(todos),
274
  "modelos_dir": str(pasta),
 
275
  "filtros_aplicados": {
276
  "nome": filtros.nome,
277
  "autor": filtros.autor,
 
278
  "finalidade": filtros.finalidade,
279
  "finalidade_colunas": filtros.finalidade_colunas or [],
280
  "bairro": filtros.bairro,
@@ -464,6 +510,7 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
464
  "endereco_referencia": None,
465
  "equacao": None,
466
  "r2": None,
 
467
  "mapa_disponivel": False,
468
  "compatibilidade_campos": {chave: [] for chave in COMPATIBILIDADE_MAP},
469
  "faixas_por_campo": {chave: None for chave in RANGE_CAMPOS},
@@ -473,8 +520,10 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
473
  "fonte": {
474
  "modelo": "dai",
475
  },
 
476
  "_texto_colunas_index": {},
477
  "_faixa_colunas_index": {},
 
478
  }
479
 
480
  try:
@@ -510,6 +559,9 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
510
  if resumo["r2"] is None:
511
  resumo["r2"] = _r2_do_pacote(pacote)
512
 
 
 
 
513
  if resumo["total_dados"] is None and df_modelo is not None:
514
  resumo["total_dados"] = int(len(df_modelo))
515
 
@@ -530,14 +582,11 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
530
 
531
  resumo["faixa_area"] = _merge_ranges(
532
  resumo["faixa_area"],
533
- _extrair_faixa_por_alias(estat_df, AREA_PRIVATIVA_ALIASES + AREA_TOTAL_ALIASES),
534
  )
535
  resumo["faixa_rh"] = _merge_ranges(resumo["faixa_rh"], _extrair_faixa_por_alias(estat_df, RH_ALIASES))
536
 
537
- faixa_data_estat = _extrair_faixa_por_alias(estat_df, DATA_ALIASES)
538
- if faixa_data_estat is None and df_modelo is not None:
539
- faixa_data_estat = _extrair_faixa_data_dataframe(df_modelo)
540
- resumo["faixa_data"] = _merge_ranges(resumo["faixa_data"], faixa_data_estat)
541
 
542
  colunas_catalogo = _coletar_colunas_para_catalogo(estat_df, df_modelo)
543
  resumo["compatibilidade_campos"] = _mapear_compatibilidade(colunas_catalogo)
@@ -545,6 +594,7 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
545
  resumo["mapa_disponivel"] = _tem_colunas_mapa(df_modelo)
546
  resumo["_texto_colunas_index"] = _indexar_texto_colunas(df_modelo)
547
  resumo["_faixa_colunas_index"] = _indexar_faixas_colunas(df_modelo)
 
548
 
549
  return resumo
550
 
@@ -569,6 +619,44 @@ def _r2_do_pacote(pacote: dict[str, Any]) -> float | None:
569
  return _to_float_or_none(gerais.get("r2"))
570
 
571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
573
  if value is None:
574
  return None
@@ -686,19 +774,69 @@ def _extrair_finalidades(df: pd.DataFrame) -> list[str]:
686
 
687
 
688
  def _extrair_faixa_data_dataframe(df: pd.DataFrame) -> dict[str, Any] | None:
689
- candidatos = [col for col in df.columns if _has_alias(str(col), DATA_ALIASES)]
690
- for col in candidatos:
691
- serie = pd.to_datetime(df[col], errors="coerce", dayfirst=True)
692
- serie = serie.dropna()
693
- if serie.empty:
694
- continue
695
- return {
696
- "min": serie.min().date().isoformat(),
697
- "max": serie.max().date().isoformat(),
698
- }
699
  return None
700
 
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  def _extrair_faixa_por_alias(estat_df: pd.DataFrame | None, aliases: list[str]) -> dict[str, Any] | None:
703
  if estat_df is None or estat_df.empty:
704
  return None
@@ -810,14 +948,20 @@ def _tem_colunas_mapa(df: pd.DataFrame | None) -> bool:
810
  return tem_lat and tem_lon
811
 
812
 
813
- def _aceita_filtros(modelo: dict[str, Any], filtros: PesquisaFiltros) -> bool:
 
 
814
  if filtros.nome and not _contains_any([modelo.get("nome_modelo"), modelo.get("arquivo")], filtros.nome):
815
  return False
816
 
817
  if filtros.autor and not _contains_any([modelo.get("autor")], filtros.autor):
818
  return False
819
 
820
- if filtros.finalidade and not _aceita_texto_com_colunas(modelo, filtros.finalidade, "finalidade", filtros.finalidade_colunas):
 
 
 
 
821
  return False
822
 
823
  if filtros.endereco and not _contains_any([modelo.get("endereco_referencia"), ", ".join(modelo.get("bairros") or [])], filtros.endereco):
@@ -826,26 +970,42 @@ def _aceita_filtros(modelo: dict[str, Any], filtros: PesquisaFiltros) -> bool:
826
  termos_bairro = _extrair_termos_bairro(filtros)
827
  if termos_bairro:
828
  for termo in termos_bairro:
829
- if not _aceita_texto_com_colunas(modelo, termo, "bairros", filtros.bairros_colunas):
830
  return False
831
 
832
- if not _aceita_range_com_colunas(modelo, "area", filtros.area_colunas, filtros.area_min, filtros.area_max):
833
  return False
834
 
835
- if not _aceita_range_com_colunas(modelo, "rh", filtros.rh_colunas, filtros.rh_min, filtros.rh_max):
836
  return False
837
 
838
- if not _aceita_range_com_colunas(modelo, "data", filtros.data_colunas, filtros.data_min, filtros.data_max):
839
  return False
840
 
841
  return True
842
 
843
 
 
 
 
 
 
 
 
 
 
 
 
844
  def _normalizar_otica(value: str | None) -> str:
845
  return "avaliando" if _normalize(value or "") == "avaliando" else "modelo"
846
 
847
 
848
- def _anexar_avaliando_info(modelo: dict[str, Any], filtros: PesquisaFiltros) -> dict[str, Any]:
 
 
 
 
 
849
  item = dict(modelo)
850
  checks: list[dict[str, Any]] = []
851
  rejeicoes: list[str] = []
@@ -858,12 +1018,12 @@ def _anexar_avaliando_info(modelo: dict[str, Any], filtros: PesquisaFiltros) ->
858
 
859
  finalidade_info = filtros.aval_finalidade
860
  if _is_provided(finalidade_info):
861
- aceito = _aceita_texto_com_colunas(item, str(finalidade_info), "aval_finalidade", filtros.aval_finalidade_colunas)
862
  registrar("finalidade", finalidade_info, aceito, "nao encontrada no modelo")
863
 
864
  bairro_info = filtros.aval_bairro
865
  if _is_provided(bairro_info):
866
- aceito = _aceita_texto_com_colunas(item, str(bairro_info), "aval_bairro", filtros.aval_bairro_colunas)
867
  registrar("bairro", bairro_info, aceito, "bairro fora da cobertura do modelo")
868
 
869
  endereco_info = filtros.aval_endereco
@@ -872,55 +1032,55 @@ def _anexar_avaliando_info(modelo: dict[str, Any], filtros: PesquisaFiltros) ->
872
  aceito = _contains_any(candidatos, str(endereco_info))
873
  registrar("endereco", endereco_info, aceito, "sem correspondencia textual no modelo")
874
 
875
- faixa_data_ref = _faixa_resumo_com_colunas(item, "aval_data", filtros.aval_data_colunas)
876
  registrar(
877
  "data",
878
  filtros.aval_data,
879
- _aceita_valor_com_colunas(item, "aval_data", filtros.aval_data_colunas, filtros.aval_data),
880
  f"fora da faixa {formatar_faixa(faixa_data_ref)}",
881
  )
882
- faixa_rh_ref = _faixa_resumo_com_colunas(item, "aval_rh", filtros.aval_rh_colunas)
883
  registrar(
884
  "rh",
885
  filtros.aval_rh,
886
- _aceita_valor_com_colunas(item, "aval_rh", filtros.aval_rh_colunas, filtros.aval_rh),
887
  f"fora da faixa {formatar_faixa(faixa_rh_ref)}",
888
  )
889
- faixa_area_ref = _faixa_resumo_com_colunas(item, "aval_area", filtros.aval_area_colunas)
890
  registrar(
891
  "area",
892
  filtros.aval_area,
893
- _aceita_valor_com_colunas(item, "aval_area", filtros.aval_area_colunas, filtros.aval_area),
894
  f"fora da faixa {formatar_faixa(faixa_area_ref)}",
895
  )
896
 
897
- faixa_area_priv = _faixa_resumo_com_colunas(item, "aval_area_privativa", filtros.aval_area_privativa_colunas)
898
- faixa_area_total = _faixa_resumo_com_colunas(item, "aval_area_total", filtros.aval_area_total_colunas)
899
- faixa_valor_unit = _faixa_resumo_com_colunas(item, "aval_valor_unitario", filtros.aval_valor_unitario_colunas)
900
- faixa_valor_tot = _faixa_resumo_com_colunas(item, "aval_valor_total", filtros.aval_valor_total_colunas)
901
 
902
  registrar(
903
  "area_privativa",
904
  filtros.aval_area_privativa,
905
- _aceita_valor_com_colunas(item, "aval_area_privativa", filtros.aval_area_privativa_colunas, filtros.aval_area_privativa),
906
  f"fora da faixa {formatar_faixa(faixa_area_priv)}",
907
  )
908
  registrar(
909
  "area_total",
910
  filtros.aval_area_total,
911
- _aceita_valor_com_colunas(item, "aval_area_total", filtros.aval_area_total_colunas, filtros.aval_area_total),
912
  f"fora da faixa {formatar_faixa(faixa_area_total)}",
913
  )
914
  registrar(
915
  "valor_unitario",
916
  filtros.aval_valor_unitario,
917
- _aceita_valor_com_colunas(item, "aval_valor_unitario", filtros.aval_valor_unitario_colunas, filtros.aval_valor_unitario),
918
  f"fora da faixa {formatar_faixa(faixa_valor_unit)}",
919
  )
920
  registrar(
921
  "valor_total",
922
  filtros.aval_valor_total,
923
- _aceita_valor_com_colunas(item, "aval_valor_total", filtros.aval_valor_total_colunas, filtros.aval_valor_total),
924
  f"fora da faixa {formatar_faixa(faixa_valor_tot)}",
925
  )
926
 
@@ -936,20 +1096,34 @@ def _anexar_avaliando_info(modelo: dict[str, Any], filtros: PesquisaFiltros) ->
936
  return item
937
 
938
 
939
- def _extrair_sugestoes(modelos: list[dict[str, Any]], limite: int = 200) -> dict[str, list[str]]:
 
 
 
 
 
940
  nomes: list[str] = []
941
  autores: list[str] = []
942
  finalidades: list[str] = []
943
  bairros: list[str] = []
944
  enderecos: list[str] = []
945
 
 
 
 
946
  for modelo in modelos:
947
  nomes.append(str(modelo.get("nome_modelo") or ""))
948
  nomes.append(str(modelo.get("arquivo") or ""))
949
  autores.append(str(modelo.get("autor") or ""))
950
- finalidades.append(str(modelo.get("finalidade") or ""))
951
- finalidades.extend([str(item) for item in (modelo.get("finalidades") or [])])
952
- bairros.extend([str(item) for item in (modelo.get("bairros") or [])])
 
 
 
 
 
 
953
  enderecos.append(str(modelo.get("endereco_referencia") or ""))
954
 
955
  return {
@@ -966,48 +1140,143 @@ def _modelo_publico(modelo: dict[str, Any]) -> dict[str, Any]:
966
 
967
 
968
  def _montar_config_colunas_filtro(modelos: list[dict[str, Any]]) -> dict[str, Any]:
969
- todas_colunas_texto = set()
970
- todas_colunas_faixa = set()
971
- for modelo in modelos:
972
- indice_texto = modelo.get("_texto_colunas_index") or {}
973
- if isinstance(indice_texto, dict):
974
- todas_colunas_texto.update([str(col) for col in indice_texto.keys()])
975
- indice_faixa = modelo.get("_faixa_colunas_index") or {}
976
- if isinstance(indice_faixa, dict):
977
- todas_colunas_faixa.update([str(col) for col in indice_faixa.keys()])
978
-
979
- colunas_texto_ordenadas = sorted(todas_colunas_texto, key=lambda item: item.lower())
980
- colunas_faixa_ordenadas = sorted(todas_colunas_faixa, key=lambda item: item.lower())
981
  config: dict[str, Any] = {}
982
 
983
  campos = list(CAMPO_TEXTO_META_FONTES.keys()) + [campo for campo in CAMPO_FAIXA_META_FONTES.keys() if campo not in CAMPO_TEXTO_META_FONTES]
984
 
985
  for campo in campos:
986
- if campo in CAMPO_TEXTO_META_FONTES:
987
- meta_fontes = list(CAMPO_TEXTO_META_FONTES.get(campo, []))
988
- aliases = CAMPO_TEXTO_ALIASES_COLUNA.get(campo, [])
989
- colunas_ordenadas = colunas_texto_ordenadas
990
- else:
991
- meta_fontes = list(CAMPO_FAIXA_META_FONTES.get(campo, []))
992
- aliases = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
993
- colunas_ordenadas = colunas_faixa_ordenadas
994
-
995
- disponiveis = [{"id": fonte, "label": FONTE_META_LABELS.get(fonte, fonte)} for fonte in meta_fontes]
996
- disponiveis.extend([{"id": f"col:{col}", "label": col} for col in colunas_ordenadas])
997
-
998
- padrao = list(meta_fontes)
999
- for col in colunas_ordenadas:
1000
- if _has_alias(col, aliases):
1001
- padrao.append(f"col:{col}")
1002
-
1003
  config[campo] = {
1004
- "disponiveis": disponiveis,
1005
- "padrao": _dedupe_strings(padrao),
1006
  }
1007
 
1008
  return config
1009
 
1010
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  def _dedupe_strings(values: list[str]) -> list[str]:
1012
  out: list[str] = []
1013
  seen = set()
@@ -1063,35 +1332,21 @@ def _faixas_para_campo(modelo: dict[str, Any], campo: str, fontes_selecionadas:
1063
 
1064
 
1065
  def _resolver_fontes_campo(modelo: dict[str, Any], campo: str, fontes_selecionadas: list[str] | None) -> list[str]:
1066
- base = _dedupe_strings([str(item) for item in (fontes_selecionadas or [])])
1067
- if base:
1068
- return base
1069
-
1070
- fontes = list(CAMPO_TEXTO_META_FONTES.get(campo, []))
1071
- aliases = CAMPO_TEXTO_ALIASES_COLUNA.get(campo, [])
1072
- indice = modelo.get("_texto_colunas_index") or {}
1073
- if isinstance(indice, dict):
1074
- for col in indice.keys():
1075
- col_text = str(col)
1076
- if _has_alias(col_text, aliases):
1077
- fontes.append(f"col:{col_text}")
1078
- return _dedupe_strings(fontes)
1079
 
1080
 
1081
  def _resolver_fontes_faixa(modelo: dict[str, Any], campo: str, fontes_selecionadas: list[str] | None) -> list[str]:
1082
- base = _dedupe_strings([str(item) for item in (fontes_selecionadas or [])])
1083
- if base:
1084
- return base
1085
-
1086
- fontes = list(CAMPO_FAIXA_META_FONTES.get(campo, []))
1087
- aliases = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
1088
- indice = modelo.get("_faixa_colunas_index") or {}
1089
- if isinstance(indice, dict):
1090
- for col in indice.keys():
1091
- col_text = str(col)
1092
- if _has_alias(col_text, aliases):
1093
- fontes.append(f"col:{col_text}")
1094
- return _dedupe_strings(fontes)
1095
 
1096
 
1097
  def _valores_para_fontes(modelo: dict[str, Any], fontes: list[str]) -> list[str]:
@@ -1137,6 +1392,9 @@ def _faixas_para_fontes(modelo: dict[str, Any], fontes: list[str]) -> list[dict[
1137
  indice_colunas = modelo.get("_faixa_colunas_index") or {}
1138
  if not isinstance(indice_colunas, dict):
1139
  indice_colunas = {}
 
 
 
1140
 
1141
  for fonte in fontes:
1142
  faixa: dict[str, Any] | None = None
@@ -1144,6 +1402,8 @@ def _faixas_para_fontes(modelo: dict[str, Any], fontes: list[str]) -> list[dict[
1144
  faixa = _faixa_meta(modelo, fonte)
1145
  elif fonte.startswith("col:"):
1146
  faixa = indice_colunas.get(fonte[4:])
 
 
1147
  if not isinstance(faixa, dict):
1148
  continue
1149
  if _is_empty(faixa.get("min")) and _is_empty(faixa.get("max")):
@@ -1251,40 +1511,52 @@ def _indexar_faixas_colunas(df_modelo: pd.DataFrame | None) -> dict[str, dict[st
1251
  return {}
1252
 
1253
  indice: dict[str, dict[str, Any]] = {}
1254
- colunas = [str(col) for col in df_modelo.columns[:MAX_COLUNAS_INDEXADAS]]
1255
- base = df_modelo[colunas].head(MAX_LINHAS_INDEXACAO)
 
1256
 
 
1257
  for coluna in colunas:
1258
- faixa = _extrair_faixa_serie(base[coluna])
1259
  if faixa is not None:
1260
  indice[coluna] = faixa
1261
 
1262
  return indice
1263
 
1264
 
1265
- def _extrair_faixa_serie(serie: pd.Series) -> dict[str, Any] | None:
1266
- serie_limpa = serie.dropna()
1267
- if serie_limpa.empty:
1268
- return None
1269
 
1270
- total = len(serie_limpa)
1271
- minimo_amostras = min(3, total)
 
 
 
1272
 
1273
- serie_num = pd.to_numeric(serie_limpa, errors="coerce").dropna()
1274
- if len(serie_num) >= minimo_amostras and (len(serie_num) / total) >= 0.6:
1275
- return {
1276
- "min": sanitize_value(serie_num.min()),
1277
- "max": sanitize_value(serie_num.max()),
1278
- }
 
 
 
 
 
 
1279
 
1280
- serie_data = pd.to_datetime(serie_limpa, errors="coerce", dayfirst=True).dropna()
1281
- if len(serie_data) >= minimo_amostras and (len(serie_data) / total) >= 0.6:
1282
- return {
1283
- "min": serie_data.min().date().isoformat(),
1284
- "max": serie_data.max().date().isoformat(),
 
 
1285
  }
1286
 
1287
- return None
1288
 
1289
 
1290
  def _lista_textos_unicos(valores: list[str], limite: int) -> list[str]:
@@ -1486,6 +1758,13 @@ def _has_alias(nome: str, aliases: list[str]) -> bool:
1486
  return False
1487
 
1488
 
 
 
 
 
 
 
 
1489
  def _normalize(value: str) -> str:
1490
  ascii_text = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii")
1491
  ascii_text = ascii_text.lower().strip()
 
1
  from __future__ import annotations
2
 
3
+ import json
4
  import math
5
  import re
6
  import unicodedata
 
19
  from app.services.serializers import sanitize_value
20
 
21
  MODELOS_DIR = Path(__file__).resolve().parent.parent / "core" / "pesquisa" / "modelos_dai"
22
+ ADMIN_CONFIG_PATH = Path(__file__).resolve().parent.parent / "core" / "pesquisa" / "pesquisa_admin_config.json"
23
 
24
+ AREA_PRIVATIVA_ALIASES = ["APRIV", "APRIVEQ", "ATPRIV", "ACOPRIV", "AREAPRIV", "AREA_PRIVATIVA", "AREA PRIVATIVA"]
25
+ AREA_TOTAL_ALIASES = ["ATTOTAL", "ATOTAL", "ATOT", "AREA_TOTAL", "AREA TOTAL", "AREA"]
26
+ AREA_GERAL_ALIASES = AREA_PRIVATIVA_ALIASES + AREA_TOTAL_ALIASES + ["ACONST", "ALOC"]
27
+ VALOR_UNITARIO_ALIASES = ["VU", "VUNIT", "VULOC", "VUAPRIV", "VUNIPRIV", "VUACONST", "VALOR_UNITARIO", "VALOR UNITARIO"]
28
+ VALOR_TOTAL_ALIASES = ["VLOC", "VTOT", "VTOTAL", "VALOR_TOTAL", "VALOR TOTAL"]
29
  RH_ALIASES = ["RH", "FATOR_RH", "FATOR RH", "RENDA_HABITACIONAL"]
30
+ DATA_ALIASES = ["DATA", "DT", "RDATA", "AVALDT", "DT ENC", "DT EST", "DT REG", "DATA_AVALIACAO", "DATA AVALIACAO", "COMPETENCIA"]
31
  BAIRRO_ALIASES = ["BAIRRO", "BAIRROS", "NOME_BAIRRO", "BAIRRO_NOME", "NME BAI", "NME_BAI"]
32
  FINALIDADE_ALIASES = ["NME IMO-FINAL", "NME_IMO_FINAL", "NME IMO FINAL", "FINALIDADE", "TIPO_IMOVEL", "TIPO IMOVEL"]
33
  LAT_ALIASES = ["LAT", "LATITUDE", "SIAT_LATITUDE"]
34
  LON_ALIASES = ["LON", "LONG", "LONGITUDE", "SIAT_LONGITUDE"]
35
+ APP_ALIASES = ["% APP", "%APP"]
36
 
37
  TIPO_POR_TOKEN = {
38
  "RECOND": "Residencia em condominio",
 
84
 
85
  CAMPO_FAIXA_ALIASES_COLUNA = {
86
  "data": DATA_ALIASES,
 
 
87
  "aval_data": DATA_ALIASES,
88
+ }
89
+
90
+ CAMPO_FAIXA_ALIASES_VARIAVEL = {
91
+ "area": AREA_GERAL_ALIASES,
92
+ "rh": RH_ALIASES,
93
+ "aval_area": AREA_GERAL_ALIASES,
94
  "aval_area_privativa": AREA_PRIVATIVA_ALIASES,
95
  "aval_area_total": AREA_TOTAL_ALIASES,
96
  "aval_rh": RH_ALIASES,
 
153
  otica: str = "modelo"
154
  nome: str | None = None
155
  autor: str | None = None
156
+ contem_app: str | None = None
157
  finalidade: str | None = None
158
  finalidade_colunas: list[str] | None = None
159
  bairro: str | None = None
 
192
 
193
  _CACHE_LOCK = Lock()
194
  _CACHE: dict[str, dict[str, Any]] = {}
195
+ _ADMIN_CONFIG_LOCK = Lock()
196
 
197
 
198
  def ensure_modelos_dir() -> Path:
 
200
  return MODELOS_DIR
201
 
202
 
203
+ def obter_admin_config_pesquisa() -> dict[str, Any]:
204
+ pasta = ensure_modelos_dir()
205
+ modelos = sorted(pasta.glob("*.dai"), key=lambda item: item.name.lower())
206
+ todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
207
+ colunas_filtro = _montar_config_colunas_filtro(todos)
208
+ admin_fontes = _carregar_fontes_admin(colunas_filtro)
209
+ return sanitize_value(
210
+ {
211
+ "colunas_filtro": colunas_filtro,
212
+ "admin_fontes": admin_fontes,
213
+ "total_modelos": len(todos),
214
+ }
215
+ )
216
+
217
+
218
+ def salvar_admin_config_pesquisa(campos: dict[str, list[str]] | None) -> dict[str, Any]:
219
+ pasta = ensure_modelos_dir()
220
+ modelos = sorted(pasta.glob("*.dai"), key=lambda item: item.name.lower())
221
+ todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
222
+ colunas_filtro = _montar_config_colunas_filtro(todos)
223
+ admin_fontes = _normalizar_fontes_admin(campos or {}, colunas_filtro)
224
+ _persistir_fontes_admin(admin_fontes)
225
+ return sanitize_value(
226
+ {
227
+ "colunas_filtro": colunas_filtro,
228
+ "admin_fontes": admin_fontes,
229
+ "total_modelos": len(todos),
230
+ "status": "Configuracao de busca salva com sucesso.",
231
+ }
232
+ )
233
+
234
+
235
  def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_contexto: bool = False) -> dict[str, Any]:
236
  pasta = ensure_modelos_dir()
237
  modelos = sorted(pasta.glob("*.dai"), key=lambda item: item.name.lower())
 
240
  filtros_exec = PesquisaFiltros(**{**filtros.__dict__, "otica": otica})
241
 
242
  todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
 
243
  colunas_filtro = _montar_config_colunas_filtro(todos)
244
+ admin_fontes = _carregar_fontes_admin(colunas_filtro)
245
+ sugestoes = _extrair_sugestoes(todos, admin_fontes)
246
 
247
  if somente_contexto:
248
  return sanitize_value(
 
253
  "total_filtrado": 0,
254
  "total_geral": len(todos),
255
  "modelos_dir": str(pasta),
256
+ "admin_fontes": admin_fontes,
257
  "filtros_aplicados": {
258
  "nome": filtros.nome,
259
  "autor": filtros.autor,
260
+ "contem_app": filtros.contem_app,
261
  "finalidade": filtros.finalidade,
262
  "finalidade_colunas": filtros.finalidade_colunas or [],
263
  "bairro": filtros.bairro,
 
297
  }
298
  )
299
 
300
+ filtrados = [item for item in todos if _aceita_filtros(item, filtros_exec, admin_fontes)]
301
 
302
  if otica == "avaliando":
303
+ filtrados = [_anexar_avaliando_info(item, filtros_exec, admin_fontes) for item in filtrados]
304
  filtrados = [item for item in filtrados if item.get("avaliando", {}).get("aceito")]
305
 
306
  if limite and limite > 0:
 
316
  "total_filtrado": len(filtrados),
317
  "total_geral": len(todos),
318
  "modelos_dir": str(pasta),
319
+ "admin_fontes": admin_fontes,
320
  "filtros_aplicados": {
321
  "nome": filtros.nome,
322
  "autor": filtros.autor,
323
+ "contem_app": filtros.contem_app,
324
  "finalidade": filtros.finalidade,
325
  "finalidade_colunas": filtros.finalidade_colunas or [],
326
  "bairro": filtros.bairro,
 
510
  "endereco_referencia": None,
511
  "equacao": None,
512
  "r2": None,
513
+ "tem_app": False,
514
  "mapa_disponivel": False,
515
  "compatibilidade_campos": {chave: [] for chave in COMPATIBILIDADE_MAP},
516
  "faixas_por_campo": {chave: None for chave in RANGE_CAMPOS},
 
520
  "fonte": {
521
  "modelo": "dai",
522
  },
523
+ "_variaveis_modelo": [],
524
  "_texto_colunas_index": {},
525
  "_faixa_colunas_index": {},
526
+ "_faixa_variaveis_index": {},
527
  }
528
 
529
  try:
 
559
  if resumo["r2"] is None:
560
  resumo["r2"] = _r2_do_pacote(pacote)
561
 
562
+ resumo["_variaveis_modelo"] = _variaveis_do_modelo(pacote)
563
+ resumo["tem_app"] = _modelo_contem_variavel(resumo, APP_ALIASES)
564
+
565
  if resumo["total_dados"] is None and df_modelo is not None:
566
  resumo["total_dados"] = int(len(df_modelo))
567
 
 
582
 
583
  resumo["faixa_area"] = _merge_ranges(
584
  resumo["faixa_area"],
585
+ _extrair_faixa_por_alias(estat_df, AREA_GERAL_ALIASES),
586
  )
587
  resumo["faixa_rh"] = _merge_ranges(resumo["faixa_rh"], _extrair_faixa_por_alias(estat_df, RH_ALIASES))
588
 
589
+ resumo["faixa_data"] = _merge_ranges(resumo["faixa_data"], _extrair_faixa_data_dataframe(df_modelo))
 
 
 
590
 
591
  colunas_catalogo = _coletar_colunas_para_catalogo(estat_df, df_modelo)
592
  resumo["compatibilidade_campos"] = _mapear_compatibilidade(colunas_catalogo)
 
594
  resumo["mapa_disponivel"] = _tem_colunas_mapa(df_modelo)
595
  resumo["_texto_colunas_index"] = _indexar_texto_colunas(df_modelo)
596
  resumo["_faixa_colunas_index"] = _indexar_faixas_colunas(df_modelo)
597
+ resumo["_faixa_variaveis_index"] = _indexar_faixas_variaveis(estat_df, resumo["_variaveis_modelo"])
598
 
599
  return resumo
600
 
 
619
  return _to_float_or_none(gerais.get("r2"))
620
 
621
 
622
+ def _variaveis_do_modelo(pacote: dict[str, Any]) -> list[str]:
623
+ variaveis: list[str] = []
624
+
625
+ modelo = pacote.get("modelo") if isinstance(pacote.get("modelo"), dict) else {}
626
+ coeficientes = modelo.get("coeficientes") if isinstance(modelo, dict) else None
627
+ if isinstance(coeficientes, pd.DataFrame):
628
+ variaveis.extend([str(item) for item in coeficientes.index.tolist()])
629
+ elif isinstance(coeficientes, pd.Series):
630
+ variaveis.extend([str(item) for item in coeficientes.index.tolist()])
631
+ elif isinstance(coeficientes, dict):
632
+ variaveis.extend([str(item) for item in coeficientes.keys()])
633
+
634
+ transformacoes = pacote.get("transformacoes") if isinstance(pacote.get("transformacoes"), dict) else {}
635
+ x_transformado = transformacoes.get("X")
636
+ y_transformado = transformacoes.get("y")
637
+ if isinstance(x_transformado, pd.DataFrame):
638
+ variaveis.extend([str(item) for item in x_transformado.columns.tolist()])
639
+ if isinstance(y_transformado, pd.Series):
640
+ nome_y = _str_or_none(y_transformado.name)
641
+ if nome_y:
642
+ variaveis.append(nome_y)
643
+
644
+ equacao = _equacao_do_pacote(pacote)
645
+ if isinstance(equacao, str) and "=" in equacao:
646
+ variaveis.append(equacao.split("=", 1)[0].strip())
647
+
648
+ limpas = [item for item in variaveis if _str_or_none(item) and _normalize(item) != "const"]
649
+ return _dedupe_strings(limpas)
650
+
651
+
652
+ def _modelo_contem_variavel(modelo: dict[str, Any], aliases: list[str]) -> bool:
653
+ variaveis = list(modelo.get("_variaveis_modelo") or [])
654
+ indice_faixa = modelo.get("_faixa_variaveis_index") or {}
655
+ if isinstance(indice_faixa, dict):
656
+ variaveis.extend([str(chave) for chave in indice_faixa.keys()])
657
+ return any(_has_alias_exato(str(variavel), aliases) for variavel in variaveis)
658
+
659
+
660
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
661
  if value is None:
662
  return None
 
774
 
775
 
776
  def _extrair_faixa_data_dataframe(df: pd.DataFrame) -> dict[str, Any] | None:
777
+ for col in _colunas_data_reais(df):
778
+ faixa = _extrair_faixa_data_real_serie(df[col])
779
+ if faixa is not None:
780
+ return faixa
 
 
 
 
 
 
781
  return None
782
 
783
 
784
+ def _colunas_data_reais(df: pd.DataFrame | None) -> list[str]:
785
+ if df is None or df.empty:
786
+ return []
787
+ candidatos = [str(col) for col in df.columns if _has_alias(str(col), DATA_ALIASES)]
788
+ return [col for col in candidatos if not _parece_coluna_apenas_ano(col)]
789
+
790
+
791
+ def _parece_coluna_apenas_ano(nome_coluna: str) -> bool:
792
+ nome_norm = _normalize(nome_coluna)
793
+ if not nome_norm:
794
+ return False
795
+ tokens_ano = [
796
+ "ano",
797
+ "anodado",
798
+ "anoconstr",
799
+ "anobconstrucao",
800
+ "anocconstrucao",
801
+ "imoanomaior",
802
+ "pmianomaior",
803
+ ]
804
+ return any(token in nome_norm for token in tokens_ano)
805
+
806
+
807
+ def _extrair_faixa_data_real_serie(serie: pd.Series) -> dict[str, Any] | None:
808
+ serie_limpa = serie.dropna()
809
+ if serie_limpa.empty:
810
+ return None
811
+
812
+ bruto = serie_limpa.astype(str).str.strip()
813
+ bruto = bruto[bruto != ""]
814
+ if bruto.empty:
815
+ return None
816
+
817
+ # Rejeita colunas que sejam basicamente ano puro (YYYY), pois nao suportam
818
+ # intervalo de datas reais (dia/mes/ano).
819
+ proporcao_ano_puro = float(bruto.str.fullmatch(r"\d{4}").mean())
820
+ if proporcao_ano_puro >= 0.8:
821
+ return None
822
+
823
+ try:
824
+ serie_data = pd.to_datetime(bruto, errors="coerce", dayfirst=True, format="mixed").dropna()
825
+ except TypeError:
826
+ serie_data = pd.to_datetime(bruto, errors="coerce", dayfirst=True).dropna()
827
+ total = len(bruto)
828
+ minimo_amostras = min(3, total)
829
+ if len(serie_data) < minimo_amostras:
830
+ return None
831
+ if total > 0 and (len(serie_data) / total) < 0.6:
832
+ return None
833
+
834
+ return {
835
+ "min": serie_data.min().date().isoformat(),
836
+ "max": serie_data.max().date().isoformat(),
837
+ }
838
+
839
+
840
  def _extrair_faixa_por_alias(estat_df: pd.DataFrame | None, aliases: list[str]) -> dict[str, Any] | None:
841
  if estat_df is None or estat_df.empty:
842
  return None
 
948
  return tem_lat and tem_lon
949
 
950
 
951
+ def _aceita_filtros(modelo: dict[str, Any], filtros: PesquisaFiltros, fontes_admin: dict[str, list[str]] | None = None) -> bool:
952
+ fontes_admin = fontes_admin or {}
953
+
954
  if filtros.nome and not _contains_any([modelo.get("nome_modelo"), modelo.get("arquivo")], filtros.nome):
955
  return False
956
 
957
  if filtros.autor and not _contains_any([modelo.get("autor")], filtros.autor):
958
  return False
959
 
960
+ app_flag = _normalizar_contem_app(filtros.contem_app)
961
+ if app_flag is not None and _modelo_contem_variavel(modelo, APP_ALIASES) != app_flag:
962
+ return False
963
+
964
+ if filtros.finalidade and not _aceita_texto_com_colunas(modelo, filtros.finalidade, "finalidade", fontes_admin.get("finalidade")):
965
  return False
966
 
967
  if filtros.endereco and not _contains_any([modelo.get("endereco_referencia"), ", ".join(modelo.get("bairros") or [])], filtros.endereco):
 
970
  termos_bairro = _extrair_termos_bairro(filtros)
971
  if termos_bairro:
972
  for termo in termos_bairro:
973
+ if not _aceita_texto_com_colunas(modelo, termo, "bairros", fontes_admin.get("bairros")):
974
  return False
975
 
976
+ if not _aceita_range_com_colunas(modelo, "area", fontes_admin.get("area"), filtros.area_min, filtros.area_max):
977
  return False
978
 
979
+ if not _aceita_range_com_colunas(modelo, "rh", fontes_admin.get("rh"), filtros.rh_min, filtros.rh_max):
980
  return False
981
 
982
+ if not _aceita_range_com_colunas(modelo, "data", fontes_admin.get("data"), filtros.data_min, filtros.data_max):
983
  return False
984
 
985
  return True
986
 
987
 
988
+ def _normalizar_contem_app(value: str | None) -> bool | None:
989
+ chave = _normalize(value or "")
990
+ if not chave:
991
+ return None
992
+ if chave in {"sim", "true", "1", "com", "contem"}:
993
+ return True
994
+ if chave in {"nao", "false", "0", "sem"}:
995
+ return False
996
+ return None
997
+
998
+
999
  def _normalizar_otica(value: str | None) -> str:
1000
  return "avaliando" if _normalize(value or "") == "avaliando" else "modelo"
1001
 
1002
 
1003
+ def _anexar_avaliando_info(
1004
+ modelo: dict[str, Any],
1005
+ filtros: PesquisaFiltros,
1006
+ fontes_admin: dict[str, list[str]] | None = None,
1007
+ ) -> dict[str, Any]:
1008
+ fontes_admin = fontes_admin or {}
1009
  item = dict(modelo)
1010
  checks: list[dict[str, Any]] = []
1011
  rejeicoes: list[str] = []
 
1018
 
1019
  finalidade_info = filtros.aval_finalidade
1020
  if _is_provided(finalidade_info):
1021
+ aceito = _aceita_texto_com_colunas(item, str(finalidade_info), "aval_finalidade", fontes_admin.get("aval_finalidade"))
1022
  registrar("finalidade", finalidade_info, aceito, "nao encontrada no modelo")
1023
 
1024
  bairro_info = filtros.aval_bairro
1025
  if _is_provided(bairro_info):
1026
+ aceito = _aceita_texto_com_colunas(item, str(bairro_info), "aval_bairro", fontes_admin.get("aval_bairro"))
1027
  registrar("bairro", bairro_info, aceito, "bairro fora da cobertura do modelo")
1028
 
1029
  endereco_info = filtros.aval_endereco
 
1032
  aceito = _contains_any(candidatos, str(endereco_info))
1033
  registrar("endereco", endereco_info, aceito, "sem correspondencia textual no modelo")
1034
 
1035
+ faixa_data_ref = _faixa_resumo_com_colunas(item, "aval_data", fontes_admin.get("aval_data"))
1036
  registrar(
1037
  "data",
1038
  filtros.aval_data,
1039
+ _aceita_valor_com_colunas(item, "aval_data", fontes_admin.get("aval_data"), filtros.aval_data),
1040
  f"fora da faixa {formatar_faixa(faixa_data_ref)}",
1041
  )
1042
+ faixa_rh_ref = _faixa_resumo_com_colunas(item, "aval_rh", fontes_admin.get("aval_rh"))
1043
  registrar(
1044
  "rh",
1045
  filtros.aval_rh,
1046
+ _aceita_valor_com_colunas(item, "aval_rh", fontes_admin.get("aval_rh"), filtros.aval_rh),
1047
  f"fora da faixa {formatar_faixa(faixa_rh_ref)}",
1048
  )
1049
+ faixa_area_ref = _faixa_resumo_com_colunas(item, "aval_area", fontes_admin.get("aval_area"))
1050
  registrar(
1051
  "area",
1052
  filtros.aval_area,
1053
+ _aceita_valor_com_colunas(item, "aval_area", fontes_admin.get("aval_area"), filtros.aval_area),
1054
  f"fora da faixa {formatar_faixa(faixa_area_ref)}",
1055
  )
1056
 
1057
+ faixa_area_priv = _faixa_resumo_com_colunas(item, "aval_area_privativa", fontes_admin.get("aval_area_privativa"))
1058
+ faixa_area_total = _faixa_resumo_com_colunas(item, "aval_area_total", fontes_admin.get("aval_area_total"))
1059
+ faixa_valor_unit = _faixa_resumo_com_colunas(item, "aval_valor_unitario", fontes_admin.get("aval_valor_unitario"))
1060
+ faixa_valor_tot = _faixa_resumo_com_colunas(item, "aval_valor_total", fontes_admin.get("aval_valor_total"))
1061
 
1062
  registrar(
1063
  "area_privativa",
1064
  filtros.aval_area_privativa,
1065
+ _aceita_valor_com_colunas(item, "aval_area_privativa", fontes_admin.get("aval_area_privativa"), filtros.aval_area_privativa),
1066
  f"fora da faixa {formatar_faixa(faixa_area_priv)}",
1067
  )
1068
  registrar(
1069
  "area_total",
1070
  filtros.aval_area_total,
1071
+ _aceita_valor_com_colunas(item, "aval_area_total", fontes_admin.get("aval_area_total"), filtros.aval_area_total),
1072
  f"fora da faixa {formatar_faixa(faixa_area_total)}",
1073
  )
1074
  registrar(
1075
  "valor_unitario",
1076
  filtros.aval_valor_unitario,
1077
+ _aceita_valor_com_colunas(item, "aval_valor_unitario", fontes_admin.get("aval_valor_unitario"), filtros.aval_valor_unitario),
1078
  f"fora da faixa {formatar_faixa(faixa_valor_unit)}",
1079
  )
1080
  registrar(
1081
  "valor_total",
1082
  filtros.aval_valor_total,
1083
+ _aceita_valor_com_colunas(item, "aval_valor_total", fontes_admin.get("aval_valor_total"), filtros.aval_valor_total),
1084
  f"fora da faixa {formatar_faixa(faixa_valor_tot)}",
1085
  )
1086
 
 
1096
  return item
1097
 
1098
 
1099
+ def _extrair_sugestoes(
1100
+ modelos: list[dict[str, Any]],
1101
+ fontes_admin: dict[str, list[str]] | None = None,
1102
+ limite: int = 200,
1103
+ ) -> dict[str, list[str]]:
1104
+ fontes_admin = fontes_admin or {}
1105
  nomes: list[str] = []
1106
  autores: list[str] = []
1107
  finalidades: list[str] = []
1108
  bairros: list[str] = []
1109
  enderecos: list[str] = []
1110
 
1111
+ fontes_finalidade = _dedupe_strings((fontes_admin.get("finalidade") or []) + (fontes_admin.get("aval_finalidade") or []))
1112
+ fontes_bairro = _dedupe_strings((fontes_admin.get("bairros") or []) + (fontes_admin.get("aval_bairro") or []))
1113
+
1114
  for modelo in modelos:
1115
  nomes.append(str(modelo.get("nome_modelo") or ""))
1116
  nomes.append(str(modelo.get("arquivo") or ""))
1117
  autores.append(str(modelo.get("autor") or ""))
1118
+ if fontes_finalidade:
1119
+ finalidades.extend(_valores_para_fontes(modelo, fontes_finalidade))
1120
+ else:
1121
+ finalidades.append(str(modelo.get("finalidade") or ""))
1122
+ finalidades.extend([str(item) for item in (modelo.get("finalidades") or [])])
1123
+ if fontes_bairro:
1124
+ bairros.extend(_valores_para_fontes(modelo, fontes_bairro))
1125
+ else:
1126
+ bairros.extend([str(item) for item in (modelo.get("bairros") or [])])
1127
  enderecos.append(str(modelo.get("endereco_referencia") or ""))
1128
 
1129
  return {
 
1140
 
1141
 
1142
  def _montar_config_colunas_filtro(modelos: list[dict[str, Any]]) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
1143
  config: dict[str, Any] = {}
1144
 
1145
  campos = list(CAMPO_TEXTO_META_FONTES.keys()) + [campo for campo in CAMPO_FAIXA_META_FONTES.keys() if campo not in CAMPO_TEXTO_META_FONTES]
1146
 
1147
  for campo in campos:
1148
+ fontes_campo = set()
1149
+ fontes_padrao = set()
1150
+ for modelo in modelos:
1151
+ if campo in CAMPO_TEXTO_META_FONTES:
1152
+ fontes_campo.update(_fontes_texto_disponiveis(modelo, campo))
1153
+ fontes_padrao.update(_fontes_texto_padrao(modelo, campo))
1154
+ else:
1155
+ fontes_campo.update(_fontes_faixa_disponiveis(modelo, campo))
1156
+ fontes_padrao.update(_fontes_faixa_padrao(modelo, campo))
1157
+
1158
+ fontes = sorted(fontes_campo, key=lambda item: _rotulo_fonte(item).lower())
1159
+ padrao = [item for item in sorted(fontes_padrao, key=lambda item: _rotulo_fonte(item).lower()) if item in set(fontes)]
1160
+ if not padrao:
1161
+ padrao = list(fontes)
 
 
 
1162
  config[campo] = {
1163
+ "disponiveis": [{"id": fonte, "label": _rotulo_fonte(fonte)} for fonte in fontes],
1164
+ "padrao": padrao,
1165
  }
1166
 
1167
  return config
1168
 
1169
 
1170
+ def _carregar_fontes_admin(colunas_filtro: dict[str, Any]) -> dict[str, list[str]]:
1171
+ raw: dict[str, Any] = {}
1172
+ with _ADMIN_CONFIG_LOCK:
1173
+ if ADMIN_CONFIG_PATH.exists():
1174
+ try:
1175
+ raw_file = json.loads(ADMIN_CONFIG_PATH.read_text(encoding="utf-8"))
1176
+ if isinstance(raw_file, dict):
1177
+ raw = raw_file
1178
+ except Exception:
1179
+ raw = {}
1180
+ return _normalizar_fontes_admin(raw, colunas_filtro)
1181
+
1182
+
1183
+ def _persistir_fontes_admin(fontes_admin: dict[str, list[str]]) -> None:
1184
+ ADMIN_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
1185
+ payload = {campo: _dedupe_strings(valores) for campo, valores in (fontes_admin or {}).items()}
1186
+ with _ADMIN_CONFIG_LOCK:
1187
+ ADMIN_CONFIG_PATH.write_text(
1188
+ json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True),
1189
+ encoding="utf-8",
1190
+ )
1191
+
1192
+
1193
+ def _normalizar_fontes_admin(raw: dict[str, Any], colunas_filtro: dict[str, Any]) -> dict[str, list[str]]:
1194
+ saida: dict[str, list[str]] = {}
1195
+ for campo, config in (colunas_filtro or {}).items():
1196
+ disponiveis = [str(item.get("id")) for item in (config.get("disponiveis") or []) if isinstance(item, dict) and item.get("id")]
1197
+ padrao = [str(item) for item in (config.get("padrao") or []) if str(item).strip()]
1198
+ escolhidas_raw = raw.get(campo) if isinstance(raw, dict) else None
1199
+ escolhidas = [str(item) for item in (escolhidas_raw or []) if str(item).strip()]
1200
+
1201
+ set_disponiveis = set(disponiveis)
1202
+ escolhidas_validas = [item for item in _dedupe_strings(escolhidas) if item in set_disponiveis]
1203
+ if not escolhidas_validas:
1204
+ escolhidas_validas = [item for item in _dedupe_strings(padrao) if item in set_disponiveis]
1205
+ if not escolhidas_validas and disponiveis:
1206
+ escolhidas_validas = [disponiveis[0]]
1207
+
1208
+ saida[campo] = escolhidas_validas
1209
+ return saida
1210
+
1211
+
1212
+ def _rotulo_fonte(fonte: str) -> str:
1213
+ if fonte.startswith("col:"):
1214
+ return fonte[4:]
1215
+ if fonte.startswith("var:"):
1216
+ return f"Variavel: {fonte[4:]}"
1217
+ return FONTE_META_LABELS.get(fonte, fonte)
1218
+
1219
+
1220
+ def _fontes_texto_disponiveis(modelo: dict[str, Any], campo: str) -> list[str]:
1221
+ aliases = CAMPO_TEXTO_ALIASES_COLUNA.get(campo, [])
1222
+ indice = modelo.get("_texto_colunas_index") or {}
1223
+ if not isinstance(indice, dict):
1224
+ return []
1225
+
1226
+ fontes: list[str] = []
1227
+ for col in indice.keys():
1228
+ col_text = str(col)
1229
+ if _has_alias(col_text, aliases):
1230
+ fontes.append(f"col:{col_text}")
1231
+ return _dedupe_strings(fontes)
1232
+
1233
+
1234
+ def _fontes_texto_padrao(modelo: dict[str, Any], campo: str) -> list[str]:
1235
+ return _fontes_texto_disponiveis(modelo, campo)
1236
+
1237
+
1238
+ def _fontes_faixa_disponiveis(modelo: dict[str, Any], campo: str) -> list[str]:
1239
+ fontes: list[str] = []
1240
+
1241
+ aliases_coluna = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
1242
+ indice_colunas = modelo.get("_faixa_colunas_index") or {}
1243
+ if isinstance(indice_colunas, dict):
1244
+ for col in indice_colunas.keys():
1245
+ col_text = str(col)
1246
+ if _has_alias(col_text, aliases_coluna):
1247
+ fontes.append(f"col:{col_text}")
1248
+
1249
+ permite_variaveis = campo in CAMPO_FAIXA_ALIASES_VARIAVEL
1250
+ indice_variaveis = modelo.get("_faixa_variaveis_index") or {}
1251
+ if permite_variaveis and isinstance(indice_variaveis, dict):
1252
+ for var in indice_variaveis.keys():
1253
+ fontes.append(f"var:{str(var)}")
1254
+
1255
+ return _dedupe_strings(fontes)
1256
+
1257
+
1258
+ def _fontes_faixa_padrao(modelo: dict[str, Any], campo: str) -> list[str]:
1259
+ fontes: list[str] = []
1260
+
1261
+ aliases_coluna = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
1262
+ indice_colunas = modelo.get("_faixa_colunas_index") or {}
1263
+ if isinstance(indice_colunas, dict):
1264
+ for col in indice_colunas.keys():
1265
+ col_text = str(col)
1266
+ if _has_alias(col_text, aliases_coluna):
1267
+ fontes.append(f"col:{col_text}")
1268
+
1269
+ aliases_variavel = CAMPO_FAIXA_ALIASES_VARIAVEL.get(campo, [])
1270
+ indice_variaveis = modelo.get("_faixa_variaveis_index") or {}
1271
+ if isinstance(indice_variaveis, dict):
1272
+ for var in indice_variaveis.keys():
1273
+ var_text = str(var)
1274
+ if _has_alias_exato(var_text, aliases_variavel):
1275
+ fontes.append(f"var:{var_text}")
1276
+
1277
+ return _dedupe_strings(fontes)
1278
+
1279
+
1280
  def _dedupe_strings(values: list[str]) -> list[str]:
1281
  out: list[str] = []
1282
  seen = set()
 
1332
 
1333
 
1334
  def _resolver_fontes_campo(modelo: dict[str, Any], campo: str, fontes_selecionadas: list[str] | None) -> list[str]:
1335
+ disponiveis = _fontes_texto_disponiveis(modelo, campo)
1336
+ selecionadas = _dedupe_strings([str(item) for item in (fontes_selecionadas or [])])
1337
+ if selecionadas:
1338
+ set_disponiveis = set(disponiveis)
1339
+ return [item for item in selecionadas if item in set_disponiveis]
1340
+ return disponiveis
 
 
 
 
 
 
 
1341
 
1342
 
1343
  def _resolver_fontes_faixa(modelo: dict[str, Any], campo: str, fontes_selecionadas: list[str] | None) -> list[str]:
1344
+ disponiveis = _fontes_faixa_disponiveis(modelo, campo)
1345
+ selecionadas = _dedupe_strings([str(item) for item in (fontes_selecionadas or [])])
1346
+ if selecionadas:
1347
+ set_disponiveis = set(disponiveis)
1348
+ return [item for item in selecionadas if item in set_disponiveis]
1349
+ return disponiveis
 
 
 
 
 
 
 
1350
 
1351
 
1352
  def _valores_para_fontes(modelo: dict[str, Any], fontes: list[str]) -> list[str]:
 
1392
  indice_colunas = modelo.get("_faixa_colunas_index") or {}
1393
  if not isinstance(indice_colunas, dict):
1394
  indice_colunas = {}
1395
+ indice_variaveis = modelo.get("_faixa_variaveis_index") or {}
1396
+ if not isinstance(indice_variaveis, dict):
1397
+ indice_variaveis = {}
1398
 
1399
  for fonte in fontes:
1400
  faixa: dict[str, Any] | None = None
 
1402
  faixa = _faixa_meta(modelo, fonte)
1403
  elif fonte.startswith("col:"):
1404
  faixa = indice_colunas.get(fonte[4:])
1405
+ elif fonte.startswith("var:"):
1406
+ faixa = indice_variaveis.get(fonte[4:])
1407
  if not isinstance(faixa, dict):
1408
  continue
1409
  if _is_empty(faixa.get("min")) and _is_empty(faixa.get("max")):
 
1511
  return {}
1512
 
1513
  indice: dict[str, dict[str, Any]] = {}
1514
+ colunas = _colunas_data_reais(df_modelo)[:MAX_COLUNAS_INDEXADAS]
1515
+ if not colunas:
1516
+ return indice
1517
 
1518
+ base = df_modelo[colunas].head(MAX_LINHAS_INDEXACAO)
1519
  for coluna in colunas:
1520
+ faixa = _extrair_faixa_data_real_serie(base[coluna])
1521
  if faixa is not None:
1522
  indice[coluna] = faixa
1523
 
1524
  return indice
1525
 
1526
 
1527
+ def _indexar_faixas_variaveis(estat_df: pd.DataFrame | None, variaveis_modelo: list[str] | None) -> dict[str, dict[str, Any]]:
1528
+ if estat_df is None or estat_df.empty:
1529
+ return {}
 
1530
 
1531
+ trabalho = estat_df.copy()
1532
+ if "Variável" in trabalho.columns:
1533
+ trabalho = trabalho.set_index("Variável")
1534
+ if trabalho.empty:
1535
+ return {}
1536
 
1537
+ min_col = _buscar_coluna(trabalho.columns, ["minimo", "mínimo", "min"])
1538
+ max_col = _buscar_coluna(trabalho.columns, ["maximo", "máximo", "max"])
1539
+ if min_col is None or max_col is None:
1540
+ return {}
1541
+
1542
+ variaveis_norm = {_normalize(item) for item in (variaveis_modelo or []) if _str_or_none(item)}
1543
+ indice: dict[str, dict[str, Any]] = {}
1544
+
1545
+ for variavel, linha in trabalho.iterrows():
1546
+ nome = str(variavel)
1547
+ if variaveis_norm and _normalize(nome) not in variaveis_norm:
1548
+ continue
1549
 
1550
+ min_val = sanitize_value(linha.get(min_col))
1551
+ max_val = sanitize_value(linha.get(max_col))
1552
+ if _is_empty(min_val) and _is_empty(max_val):
1553
+ continue
1554
+ indice[nome] = {
1555
+ "min": None if _is_empty(min_val) else min_val,
1556
+ "max": None if _is_empty(max_val) else max_val,
1557
  }
1558
 
1559
+ return indice
1560
 
1561
 
1562
  def _lista_textos_unicos(valores: list[str], limite: int) -> list[str]:
 
1758
  return False
1759
 
1760
 
1761
+ def _has_alias_exato(nome: str, aliases: list[str]) -> bool:
1762
+ nome_norm = _normalize(nome)
1763
+ if not nome_norm:
1764
+ return False
1765
+ return any(_normalize(alias) == nome_norm for alias in aliases)
1766
+
1767
+
1768
  def _normalize(value: str) -> str:
1769
  ascii_text = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii")
1770
  ascii_text = ascii_text.lower().strip()
frontend/src/api.js CHANGED
@@ -59,6 +59,9 @@ export function downloadBlob(blob, fileName) {
59
  export const api = {
60
  createSession: () => postJson('/api/sessions', {}),
61
 
 
 
 
62
  pesquisarModelos(filtros = {}) {
63
  const params = new URLSearchParams()
64
  Object.entries(filtros).forEach(([key, value]) => {
 
59
  export const api = {
60
  createSession: () => postJson('/api/sessions', {}),
61
 
62
+ pesquisaAdminConfig: () => getJson('/api/pesquisa/admin-config'),
63
+ pesquisaAdminConfigSalvar: (campos = {}) => postJson('/api/pesquisa/admin-config', { campos }),
64
+
65
  pesquisarModelos(filtros = {}) {
66
  const params = new URLSearchParams()
67
  Object.entries(filtros).forEach(([key, value]) => {
frontend/src/components/PesquisaAdminConfigPanel.jsx ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { api } from '../api'
3
+
4
+ const CAMPOS_GRUPOS = [
5
+ {
6
+ titulo: 'Otica do modelo',
7
+ campos: ['finalidade', 'bairros', 'data', 'area', 'rh'],
8
+ },
9
+ {
10
+ titulo: 'Otica do avaliando',
11
+ campos: [
12
+ 'aval_finalidade',
13
+ 'aval_bairro',
14
+ 'aval_data',
15
+ 'aval_area',
16
+ 'aval_rh',
17
+ ],
18
+ },
19
+ ]
20
+
21
+ const CAMPO_LABELS = {
22
+ finalidade: 'Finalidade',
23
+ bairros: 'Bairro',
24
+ data: 'Data',
25
+ area: 'Area',
26
+ rh: 'RH',
27
+ aval_finalidade: 'Finalidade (avaliando)',
28
+ aval_bairro: 'Bairro (avaliando)',
29
+ aval_data: 'Data (avaliando)',
30
+ aval_area: 'Area (avaliando)',
31
+ aval_rh: 'RH (avaliando)',
32
+ }
33
+
34
+ function normalizeColunasConfig(rawConfig = {}) {
35
+ const out = {}
36
+ Object.entries(rawConfig || {}).forEach(([campo, config]) => {
37
+ const disponiveis = []
38
+ const vistos = new Set()
39
+ ;(Array.isArray(config?.disponiveis) ? config.disponiveis : []).forEach((item) => {
40
+ const id = typeof item === 'string' ? item : item?.id
41
+ const label = typeof item === 'string' ? item : item?.label || item?.id
42
+ const idText = String(id || '').trim()
43
+ if (!idText || vistos.has(idText)) return
44
+ vistos.add(idText)
45
+ disponiveis.push({ id: idText, label: String(label || idText) })
46
+ })
47
+ const padrao = []
48
+ ;(Array.isArray(config?.padrao) ? config.padrao : []).forEach((item) => {
49
+ const idText = String(item || '').trim()
50
+ if (!idText || !vistos.has(idText) || padrao.includes(idText)) return
51
+ padrao.push(idText)
52
+ })
53
+ out[campo] = { disponiveis, padrao }
54
+ })
55
+ return out
56
+ }
57
+
58
+ function normalizeSelecionadas(raw = {}, configNormalizada = {}) {
59
+ const out = {}
60
+ Object.keys(configNormalizada || {}).forEach((campo) => {
61
+ const disponiveis = new Set((configNormalizada[campo]?.disponiveis || []).map((item) => item.id))
62
+ const preferidas = Array.isArray(raw?.[campo]) ? raw[campo] : []
63
+ const validas = preferidas.map((item) => String(item || '').trim()).filter((item) => item && disponiveis.has(item))
64
+ if (validas.length) {
65
+ out[campo] = Array.from(new Set(validas))
66
+ return
67
+ }
68
+ const padrao = (configNormalizada[campo]?.padrao || []).filter((item) => disponiveis.has(item))
69
+ out[campo] = Array.from(new Set(padrao))
70
+ })
71
+ return out
72
+ }
73
+
74
+ function serializarCampos(campos = {}) {
75
+ const normalizado = {}
76
+ Object.keys(campos || {}).sort().forEach((campo) => {
77
+ normalizado[campo] = [...(campos[campo] || [])]
78
+ })
79
+ return JSON.stringify(normalizado)
80
+ }
81
+
82
+ export default function PesquisaAdminConfigPanel({ onSaved }) {
83
+ const [loading, setLoading] = useState(false)
84
+ const [saving, setSaving] = useState(false)
85
+ const [error, setError] = useState('')
86
+ const [status, setStatus] = useState('')
87
+ const [colunasConfig, setColunasConfig] = useState({})
88
+ const [selecionadas, setSelecionadas] = useState({})
89
+ const [baseline, setBaseline] = useState('{}')
90
+
91
+ const dirty = useMemo(() => serializarCampos(selecionadas) !== baseline, [selecionadas, baseline])
92
+
93
+ async function carregar() {
94
+ setLoading(true)
95
+ setError('')
96
+ setStatus('')
97
+ try {
98
+ const response = await api.pesquisaAdminConfig()
99
+ const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
100
+ const selecionadasNormalizadas = normalizeSelecionadas(response.admin_fontes || {}, configNormalizada)
101
+ const base = serializarCampos(selecionadasNormalizadas)
102
+ setColunasConfig(configNormalizada)
103
+ setSelecionadas(selecionadasNormalizadas)
104
+ setBaseline(base)
105
+ } catch (err) {
106
+ setError(err.message)
107
+ } finally {
108
+ setLoading(false)
109
+ }
110
+ }
111
+
112
+ useEffect(() => {
113
+ void carregar()
114
+ }, [])
115
+
116
+ function findLabel(campo, id) {
117
+ const match = (colunasConfig[campo]?.disponiveis || []).find((item) => item.id === id)
118
+ return match?.label || id
119
+ }
120
+
121
+ function onAdd(campo, value) {
122
+ const id = String(value || '').trim()
123
+ if (!id) return
124
+ setSelecionadas((current) => {
125
+ const atual = current[campo] || []
126
+ if (atual.includes(id)) return current
127
+ return { ...current, [campo]: [...atual, id] }
128
+ })
129
+ }
130
+
131
+ function onRemove(campo, id) {
132
+ setSelecionadas((current) => ({
133
+ ...current,
134
+ [campo]: (current[campo] || []).filter((item) => item !== id),
135
+ }))
136
+ }
137
+
138
+ function onRestaurarPadrao(campo) {
139
+ const padrao = colunasConfig[campo]?.padrao || []
140
+ setSelecionadas((current) => ({ ...current, [campo]: [...padrao] }))
141
+ }
142
+
143
+ async function onSalvar() {
144
+ setSaving(true)
145
+ setError('')
146
+ setStatus('')
147
+ try {
148
+ const response = await api.pesquisaAdminConfigSalvar(selecionadas)
149
+ const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
150
+ const selecionadasNormalizadas = normalizeSelecionadas(response.admin_fontes || {}, configNormalizada)
151
+ const base = serializarCampos(selecionadasNormalizadas)
152
+ setColunasConfig(configNormalizada)
153
+ setSelecionadas(selecionadasNormalizadas)
154
+ setBaseline(base)
155
+ setStatus(response.status || 'Configuracao salva.')
156
+ if (onSaved) onSaved()
157
+ } catch (err) {
158
+ setError(err.message)
159
+ } finally {
160
+ setSaving(false)
161
+ }
162
+ }
163
+
164
+ function renderCampo(campo) {
165
+ const configCampo = colunasConfig[campo] || { disponiveis: [], padrao: [] }
166
+ const selecionadasCampo = selecionadas[campo] || []
167
+ const selectedSet = new Set(selecionadasCampo)
168
+ const opcoesAdicionar = (configCampo.disponiveis || []).filter((item) => !selectedSet.has(item.id))
169
+
170
+ return (
171
+ <div key={campo} className="pesquisa-admin-field">
172
+ <div className="pesquisa-admin-field-head">
173
+ <strong>{CAMPO_LABELS[campo] || campo}</strong>
174
+ <button type="button" className="btn-pesquisa-expand" onClick={() => onRestaurarPadrao(campo)}>
175
+ Restaurar padrao
176
+ </button>
177
+ </div>
178
+
179
+ <div className="pesquisa-dynamic-filter-row pesquisa-admin-row">
180
+ <div className="pesquisa-colunas-box">
181
+ <div className="pesquisa-colunas-chip-list">
182
+ {selecionadasCampo.map((id) => (
183
+ <span key={`${campo}-${id}`} className="pesquisa-coluna-chip">
184
+ <span>{findLabel(campo, id)}</span>
185
+ <button type="button" className="pesquisa-coluna-remove" onClick={() => onRemove(campo, id)} aria-label={`Remover fonte ${findLabel(campo, id)}`}>
186
+ x
187
+ </button>
188
+ </span>
189
+ ))}
190
+ {!selecionadasCampo.length ? <span className="pesquisa-colunas-empty">Nenhuma fonte selecionada.</span> : null}
191
+ </div>
192
+ </div>
193
+
194
+ <select
195
+ className="pesquisa-colunas-add"
196
+ defaultValue=""
197
+ onChange={(event) => {
198
+ const selected = String(event.target.value || '').trim()
199
+ if (!selected) return
200
+ onAdd(campo, selected)
201
+ event.target.value = ''
202
+ }}
203
+ >
204
+ <option value="">Adicionar fonte...</option>
205
+ {opcoesAdicionar.map((item) => (
206
+ <option key={`${campo}-opt-${item.id}`} value={item.id}>{item.label}</option>
207
+ ))}
208
+ </select>
209
+ </div>
210
+ </div>
211
+ )
212
+ }
213
+
214
+ return (
215
+ <div className="pesquisa-admin-panel">
216
+ <div className="row pesquisa-actions pesquisa-actions-primary">
217
+ <button type="button" onClick={() => void onSalvar()} disabled={saving || loading || !dirty}>
218
+ {saving ? 'Salvando...' : 'Salvar configuracao'}
219
+ </button>
220
+ <button type="button" onClick={() => void carregar()} disabled={saving || loading}>
221
+ Recarregar
222
+ </button>
223
+ </div>
224
+
225
+ {status ? <div className="status-line">{status}</div> : null}
226
+ {error ? <div className="error-line inline-error">{error}</div> : null}
227
+
228
+ {CAMPOS_GRUPOS.map((grupo) => (
229
+ <section key={grupo.titulo} className="pesquisa-filtro-grupo">
230
+ <h5>{grupo.titulo}</h5>
231
+ <div className="pesquisa-admin-fields">
232
+ {grupo.campos.map((campo) => renderCampo(campo))}
233
+ </div>
234
+ </section>
235
+ ))}
236
+ </div>
237
+ )
238
+ }
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -1,12 +1,14 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api } from '../api'
3
  import MapFrame from './MapFrame'
 
4
  import SectionBlock from './SectionBlock'
5
 
6
  const EMPTY_FILTERS = {
7
  otica: 'modelo',
8
  nome: '',
9
  autor: '',
 
10
  finalidade: '',
11
  bairros: '',
12
  dataMin: '',
@@ -19,11 +21,7 @@ const EMPTY_FILTERS = {
19
  avalBairro: '',
20
  avalData: '',
21
  avalArea: '',
22
- avalAreaPrivativa: '',
23
- avalAreaTotal: '',
24
  avalRh: '',
25
- avalValorUnitario: '',
26
- avalValorTotal: '',
27
  }
28
 
29
  const RESULT_INITIAL = {
@@ -181,28 +179,16 @@ function reconciliarColunasSelecionadas(atual, configNormalizada, camposEditados
181
  return next
182
  }
183
 
184
- function buildApiFilters(filters, colunasFiltro = COLUNAS_FILTRO_INITIAL) {
185
  if (filters.otica === 'avaliando') {
186
  return {
187
  otica: filters.otica,
 
188
  aval_finalidade: filters.avalFinalidade,
189
- aval_finalidade_colunas: (colunasFiltro.aval_finalidade || []).join(','),
190
  aval_bairro: filters.avalBairro,
191
- aval_bairro_colunas: (colunasFiltro.aval_bairro || []).join(','),
192
  aval_data: filters.avalData,
193
- aval_data_colunas: (colunasFiltro.aval_data || []).join(','),
194
  aval_area: filters.avalArea,
195
- aval_area_colunas: (colunasFiltro.aval_area || []).join(','),
196
- aval_area_privativa: filters.avalAreaPrivativa,
197
- aval_area_privativa_colunas: (colunasFiltro.aval_area_privativa || []).join(','),
198
- aval_area_total: filters.avalAreaTotal,
199
- aval_area_total_colunas: (colunasFiltro.aval_area_total || []).join(','),
200
  aval_rh: filters.avalRh,
201
- aval_rh_colunas: (colunasFiltro.aval_rh || []).join(','),
202
- aval_valor_unitario: filters.avalValorUnitario,
203
- aval_valor_unitario_colunas: (colunasFiltro.aval_valor_unitario || []).join(','),
204
- aval_valor_total: filters.avalValorTotal,
205
- aval_valor_total_colunas: (colunasFiltro.aval_valor_total || []).join(','),
206
  }
207
  }
208
 
@@ -210,18 +196,14 @@ function buildApiFilters(filters, colunasFiltro = COLUNAS_FILTRO_INITIAL) {
210
  otica: filters.otica,
211
  nome: filters.nome,
212
  autor: filters.autor,
 
213
  finalidade: filters.finalidade,
214
- finalidade_colunas: (colunasFiltro.finalidade || []).join(','),
215
  bairros: filters.bairros,
216
- bairros_colunas: (colunasFiltro.bairros || []).join(','),
217
  data_min: filters.dataMin,
218
- data_colunas: (colunasFiltro.data || []).join(','),
219
  data_max: filters.dataMax,
220
  area_min: filters.areaMin,
221
- area_colunas: (colunasFiltro.area || []).join(','),
222
  area_max: filters.areaMax,
223
  rh_min: filters.rhMin,
224
- rh_colunas: (colunasFiltro.rh || []).join(','),
225
  rh_max: filters.rhMax,
226
  }
227
  }
@@ -409,21 +391,11 @@ function DynamicRangeFilterField({
409
  )
410
  }
411
 
412
- function FiltroGroup({ title, children }) {
413
- return (
414
- <section className="pesquisa-filtro-grupo">
415
- <h5>{title}</h5>
416
- <div className="pesquisa-filtros-grid">
417
- {children}
418
- </div>
419
- </section>
420
- )
421
- }
422
-
423
  export default function PesquisaTab() {
424
  const [loading, setLoading] = useState(false)
425
  const [error, setError] = useState('')
426
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
 
427
 
428
  const [filters, setFilters] = useState(EMPTY_FILTERS)
429
  const [result, setResult] = useState(RESULT_INITIAL)
@@ -619,9 +591,35 @@ export default function PesquisaTab() {
619
  }
620
  }
621
 
 
 
 
 
 
 
 
 
622
  return (
623
  <div className="tab-content">
624
- <SectionBlock step="1" title="Filtros de Pesquisa" subtitle="Use a otica do modelo ou do avaliando. Todos os filtros sao cumulativos.">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  <div className="pesquisa-otica-switch" role="tablist" aria-label="Otica de pesquisa">
626
  <button
627
  type="button"
@@ -649,261 +647,149 @@ export default function PesquisaTab() {
649
  </button>
650
  </div>
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  {usandoOticaAvaliando ? (
653
- <div id="pesquisa-panel-avaliando" role="tabpanel" aria-labelledby="pesquisa-otica-avaliando" className="pesquisa-filtros-groups">
654
- <FiltroGroup title="Finalidade">
655
- <DynamicFilterField
656
- label="Finalidade do imovel"
657
- campoValor="avalFinalidade"
658
- campoColunas="aval_finalidade"
659
- configCampo={colunasConfig.aval_finalidade}
660
- selecionadas={colunasFiltro.aval_finalidade}
661
- onAddColuna={onAddColunaFiltro}
662
- onRemoveColuna={onRemoveColunaFiltro}
 
663
  value={filters.avalFinalidade}
664
  onChange={onFieldChange}
665
- list="pesquisa-finalidades"
666
  placeholder="Ex: Apartamento"
667
  />
668
- </FiltroGroup>
669
-
670
- <FiltroGroup title="Bairro">
671
- <DynamicFilterField
672
- label="Bairro do imovel"
673
- campoValor="avalBairro"
674
- campoColunas="aval_bairro"
675
- configCampo={colunasConfig.aval_bairro}
676
- selecionadas={colunasFiltro.aval_bairro}
677
- onAddColuna={onAddColunaFiltro}
678
- onRemoveColuna={onRemoveColunaFiltro}
679
  value={filters.avalBairro}
680
  onChange={onFieldChange}
681
- list="pesquisa-bairros"
682
  placeholder="Ex: Centro"
683
  />
684
- </FiltroGroup>
685
-
686
- <FiltroGroup title="Datas">
687
- <DynamicFilterField
688
- label="Data de referencia"
689
- campoValor="avalData"
690
- campoColunas="aval_data"
691
- configCampo={colunasConfig.aval_data}
692
- selecionadas={colunasFiltro.aval_data}
693
- onAddColuna={onAddColunaFiltro}
694
- onRemoveColuna={onRemoveColunaFiltro}
695
- value={filters.avalData}
696
- onChange={onFieldChange}
697
- placeholder="2025-02-27"
698
- />
699
- </FiltroGroup>
700
-
701
- <FiltroGroup title="Areas">
702
- <DynamicFilterField
703
- label="Area (generica)"
704
- campoValor="avalArea"
705
- campoColunas="aval_area"
706
- configCampo={colunasConfig.aval_area}
707
- selecionadas={colunasFiltro.aval_area}
708
- onAddColuna={onAddColunaFiltro}
709
- onRemoveColuna={onRemoveColunaFiltro}
710
- value={filters.avalArea}
711
- onChange={onFieldChange}
712
- placeholder="0"
713
- inputKind="number"
714
- />
715
- <DynamicFilterField
716
- label="Area privativa"
717
- campoValor="avalAreaPrivativa"
718
- campoColunas="aval_area_privativa"
719
- configCampo={colunasConfig.aval_area_privativa}
720
- selecionadas={colunasFiltro.aval_area_privativa}
721
- onAddColuna={onAddColunaFiltro}
722
- onRemoveColuna={onRemoveColunaFiltro}
723
- value={filters.avalAreaPrivativa}
724
- onChange={onFieldChange}
725
- placeholder="0"
726
- inputKind="number"
727
- />
728
- <DynamicFilterField
729
- label="Area total"
730
- campoValor="avalAreaTotal"
731
- campoColunas="aval_area_total"
732
- configCampo={colunasConfig.aval_area_total}
733
- selecionadas={colunasFiltro.aval_area_total}
734
- onAddColuna={onAddColunaFiltro}
735
- onRemoveColuna={onRemoveColunaFiltro}
736
- value={filters.avalAreaTotal}
737
- onChange={onFieldChange}
738
- placeholder="0"
739
- inputKind="number"
740
- />
741
- </FiltroGroup>
742
-
743
- <FiltroGroup title="RH">
744
- <DynamicFilterField
745
- label="RH do imovel"
746
- campoValor="avalRh"
747
- campoColunas="aval_rh"
748
- configCampo={colunasConfig.aval_rh}
749
- selecionadas={colunasFiltro.aval_rh}
750
- onAddColuna={onAddColunaFiltro}
751
- onRemoveColuna={onRemoveColunaFiltro}
752
- value={filters.avalRh}
753
- onChange={onFieldChange}
754
- placeholder="0"
755
- inputKind="number"
756
- />
757
- </FiltroGroup>
758
-
759
- <FiltroGroup title="Valores">
760
- <DynamicFilterField
761
- label="Valor unitario"
762
- campoValor="avalValorUnitario"
763
- campoColunas="aval_valor_unitario"
764
- configCampo={colunasConfig.aval_valor_unitario}
765
- selecionadas={colunasFiltro.aval_valor_unitario}
766
- onAddColuna={onAddColunaFiltro}
767
- onRemoveColuna={onRemoveColunaFiltro}
768
- value={filters.avalValorUnitario}
769
- onChange={onFieldChange}
770
- placeholder="0"
771
- inputKind="number"
772
- />
773
- <DynamicFilterField
774
- label="Valor total"
775
- campoValor="avalValorTotal"
776
- campoColunas="aval_valor_total"
777
- configCampo={colunasConfig.aval_valor_total}
778
- selecionadas={colunasFiltro.aval_valor_total}
779
- onAddColuna={onAddColunaFiltro}
780
- onRemoveColuna={onRemoveColunaFiltro}
781
- value={filters.avalValorTotal}
782
- onChange={onFieldChange}
783
- placeholder="0"
784
- inputKind="number"
785
- />
786
- </FiltroGroup>
787
- </div>
788
- ) : (
789
- <div id="pesquisa-panel-modelo" role="tabpanel" aria-labelledby="pesquisa-otica-modelo" className="pesquisa-filtros-groups pesquisa-filtros-groups-stack">
790
- <FiltroGroup title="Modelo">
791
  <label className="pesquisa-field">
792
- Nome do modelo
793
- <TextFieldInput
794
- list="pesquisa-nomes-modelo"
795
- field="nome"
796
- value={filters.nome}
797
- onChange={onFieldChange}
798
- placeholder="Ex: MOD_A_SALA_Z1"
799
- />
800
  </label>
801
  <label className="pesquisa-field">
802
- Autor
803
- <TextFieldInput
804
- list="pesquisa-autores"
805
- field="autor"
806
- value={filters.autor}
807
- onChange={onFieldChange}
808
- placeholder="Nome do avaliador"
809
- />
810
  </label>
811
- </FiltroGroup>
812
-
813
- <FiltroGroup title="Finalidade">
814
- <DynamicFilterField
815
- label=""
816
- campoValor="finalidade"
817
- campoColunas="finalidade"
818
- configCampo={colunasConfig.finalidade}
819
- selecionadas={colunasFiltro.finalidade}
820
- onAddColuna={onAddColunaFiltro}
821
- onRemoveColuna={onRemoveColunaFiltro}
822
- value={filters.finalidade}
823
- onChange={onFieldChange}
824
- list="pesquisa-finalidades"
825
- placeholder="Apartamento, sala, deposito..."
826
- />
827
- </FiltroGroup>
828
-
829
- <FiltroGroup title="Bairro">
830
- <DynamicFilterField
831
- label=""
832
- campoValor="bairros"
833
- campoColunas="bairros"
834
- configCampo={colunasConfig.bairros}
835
- selecionadas={colunasFiltro.bairros}
836
- onAddColuna={onAddColunaFiltro}
837
- onRemoveColuna={onRemoveColunaFiltro}
838
- value={filters.bairros}
839
  onChange={onFieldChange}
840
- list="pesquisa-bairros"
841
- placeholder="Centro, Moinhos de Vento"
842
  />
843
- </FiltroGroup>
844
-
845
- <FiltroGroup title="Datas">
846
- <DynamicRangeFilterField
847
- label=""
848
- campoColunas="data"
849
- configCampo={colunasConfig.data}
850
- selecionadas={colunasFiltro.data}
851
- onAddColuna={onAddColunaFiltro}
852
- onRemoveColuna={onRemoveColunaFiltro}
853
- minLabel="Data minima"
854
- minField="dataMin"
855
- minValue={filters.dataMin}
856
- maxLabel="Data maxima"
857
- maxField="dataMax"
858
- maxValue={filters.dataMax}
859
  onChange={onFieldChange}
860
- minPlaceholder="2022 ou 2022-01-01"
861
- maxPlaceholder="2025 ou 2025-12-31"
862
- inputKind="text"
863
  />
864
- </FiltroGroup>
865
-
866
- <FiltroGroup title="Areas">
867
- <DynamicRangeFilterField
868
- label=""
869
- campoColunas="area"
870
- configCampo={colunasConfig.area}
871
- selecionadas={colunasFiltro.area}
872
- onAddColuna={onAddColunaFiltro}
873
- onRemoveColuna={onRemoveColunaFiltro}
874
- minLabel="Area minima"
875
- minField="areaMin"
876
- minValue={filters.areaMin}
877
- maxLabel="Area maxima"
878
- maxField="areaMax"
879
- maxValue={filters.areaMax}
880
  onChange={onFieldChange}
881
- minPlaceholder="0"
882
- maxPlaceholder="0"
883
- inputKind="number"
884
  />
885
- </FiltroGroup>
886
-
887
- <FiltroGroup title="RH">
888
- <DynamicRangeFilterField
889
- label=""
890
- campoColunas="rh"
891
- configCampo={colunasConfig.rh}
892
- selecionadas={colunasFiltro.rh}
893
- onAddColuna={onAddColunaFiltro}
894
- onRemoveColuna={onRemoveColunaFiltro}
895
- minLabel="RH minimo"
896
- minField="rhMin"
897
- minValue={filters.rhMin}
898
- maxLabel="RH maximo"
899
- maxField="rhMax"
900
- maxValue={filters.rhMax}
901
  onChange={onFieldChange}
902
- minPlaceholder="0"
903
- maxPlaceholder="1"
904
- inputKind="number"
905
  />
906
- </FiltroGroup>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
907
  </div>
908
  )}
909
 
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api } from '../api'
3
  import MapFrame from './MapFrame'
4
+ import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
5
  import SectionBlock from './SectionBlock'
6
 
7
  const EMPTY_FILTERS = {
8
  otica: 'modelo',
9
  nome: '',
10
  autor: '',
11
+ contemApp: '',
12
  finalidade: '',
13
  bairros: '',
14
  dataMin: '',
 
21
  avalBairro: '',
22
  avalData: '',
23
  avalArea: '',
 
 
24
  avalRh: '',
 
 
25
  }
26
 
27
  const RESULT_INITIAL = {
 
179
  return next
180
  }
181
 
182
+ function buildApiFilters(filters) {
183
  if (filters.otica === 'avaliando') {
184
  return {
185
  otica: filters.otica,
186
+ contem_app: filters.contemApp,
187
  aval_finalidade: filters.avalFinalidade,
 
188
  aval_bairro: filters.avalBairro,
 
189
  aval_data: filters.avalData,
 
190
  aval_area: filters.avalArea,
 
 
 
 
 
191
  aval_rh: filters.avalRh,
 
 
 
 
 
192
  }
193
  }
194
 
 
196
  otica: filters.otica,
197
  nome: filters.nome,
198
  autor: filters.autor,
199
+ contem_app: filters.contemApp,
200
  finalidade: filters.finalidade,
 
201
  bairros: filters.bairros,
 
202
  data_min: filters.dataMin,
 
203
  data_max: filters.dataMax,
204
  area_min: filters.areaMin,
 
205
  area_max: filters.areaMax,
206
  rh_min: filters.rhMin,
 
207
  rh_max: filters.rhMax,
208
  }
209
  }
 
391
  )
392
  }
393
 
 
 
 
 
 
 
 
 
 
 
 
394
  export default function PesquisaTab() {
395
  const [loading, setLoading] = useState(false)
396
  const [error, setError] = useState('')
397
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
398
+ const [mostrarAdminConfig, setMostrarAdminConfig] = useState(false)
399
 
400
  const [filters, setFilters] = useState(EMPTY_FILTERS)
401
  const [result, setResult] = useState(RESULT_INITIAL)
 
591
  }
592
  }
593
 
594
+ async function onAdminConfigSalva() {
595
+ if (pesquisaInicializada) {
596
+ await buscarModelos()
597
+ return
598
+ }
599
+ await carregarContextoInicial()
600
+ }
601
+
602
  return (
603
  <div className="tab-content">
604
+ <SectionBlock
605
+ step="1"
606
+ title="Filtros de Pesquisa"
607
+ subtitle="Use a otica do modelo ou do avaliando. Todos os filtros sao cumulativos."
608
+ aside={(
609
+ <button
610
+ type="button"
611
+ className={`pesquisa-admin-toggle${mostrarAdminConfig ? ' active' : ''}`}
612
+ onClick={() => setMostrarAdminConfig((current) => !current)}
613
+ >
614
+ <span className="pesquisa-admin-toggle-icon" aria-hidden="true">⚙</span>
615
+ <span>Configure as fontes dos campos</span>
616
+ </button>
617
+ )}
618
+ >
619
+ {mostrarAdminConfig ? (
620
+ <PesquisaAdminConfigPanel onSaved={() => void onAdminConfigSalva()} />
621
+ ) : null}
622
+
623
  <div className="pesquisa-otica-switch" role="tablist" aria-label="Otica de pesquisa">
624
  <button
625
  type="button"
 
647
  </button>
648
  </div>
649
 
650
+ <div className="pesquisa-fields-grid pesquisa-fields-grid-single">
651
+ <label className="pesquisa-field">
652
+ Contem variavel APP (% APP)
653
+ <select
654
+ data-field="contemApp"
655
+ name={toInputName('contemApp')}
656
+ value={filters.contemApp}
657
+ onChange={onFieldChange}
658
+ autoComplete="off"
659
+ >
660
+ <option value="">Indiferente</option>
661
+ <option value="sim">Sim</option>
662
+ <option value="nao">Nao</option>
663
+ </select>
664
+ </label>
665
+ </div>
666
+
667
  {usandoOticaAvaliando ? (
668
+ <div
669
+ id="pesquisa-panel-avaliando"
670
+ role="tabpanel"
671
+ aria-labelledby="pesquisa-otica-avaliando"
672
+ className="pesquisa-fields-grid pesquisa-avaliando-grid"
673
+ >
674
+ <label className="pesquisa-field">
675
+ Finalidade do imovel
676
+ <TextFieldInput
677
+ list="pesquisa-finalidades"
678
+ field="avalFinalidade"
679
  value={filters.avalFinalidade}
680
  onChange={onFieldChange}
 
681
  placeholder="Ex: Apartamento"
682
  />
683
+ </label>
684
+
685
+ <label className="pesquisa-field">
686
+ Bairro do imovel
687
+ <TextFieldInput
688
+ list="pesquisa-bairros"
689
+ field="avalBairro"
 
 
 
 
690
  value={filters.avalBairro}
691
  onChange={onFieldChange}
 
692
  placeholder="Ex: Centro"
693
  />
694
+ </label>
695
+
696
+ <div className="pesquisa-avaliando-inline">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  <label className="pesquisa-field">
698
+ Data do imovel
699
+ <TextFieldInput field="avalData" value={filters.avalData} onChange={onFieldChange} placeholder="2025-02-27" />
 
 
 
 
 
 
700
  </label>
701
  <label className="pesquisa-field">
702
+ Area do imovel
703
+ <NumberFieldInput field="avalArea" value={filters.avalArea} onChange={onFieldChange} placeholder="0" />
 
 
 
 
 
 
704
  </label>
705
+ <label className="pesquisa-field">
706
+ RH do imovel
707
+ <NumberFieldInput field="avalRh" value={filters.avalRh} onChange={onFieldChange} placeholder="0" />
708
+ </label>
709
+ </div>
710
+ </div>
711
+ ) : (
712
+ <div id="pesquisa-panel-modelo" role="tabpanel" aria-labelledby="pesquisa-otica-modelo" className="pesquisa-fields-grid">
713
+ <label className="pesquisa-field">
714
+ Nome do modelo
715
+ <TextFieldInput
716
+ list="pesquisa-nomes-modelo"
717
+ field="nome"
718
+ value={filters.nome}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
  onChange={onFieldChange}
720
+ placeholder="Ex: MOD_A_SALA_Z1"
 
721
  />
722
+ </label>
723
+ <label className="pesquisa-field">
724
+ Autor
725
+ <TextFieldInput
726
+ list="pesquisa-autores"
727
+ field="autor"
728
+ value={filters.autor}
 
 
 
 
 
 
 
 
 
729
  onChange={onFieldChange}
730
+ placeholder="Nome do avaliador"
 
 
731
  />
732
+ </label>
733
+
734
+ <label className="pesquisa-field">
735
+ Finalidade
736
+ <TextFieldInput
737
+ list="pesquisa-finalidades"
738
+ field="finalidade"
739
+ value={filters.finalidade}
 
 
 
 
 
 
 
 
740
  onChange={onFieldChange}
741
+ placeholder="Apartamento, sala, deposito..."
 
 
742
  />
743
+ </label>
744
+
745
+ <label className="pesquisa-field">
746
+ Bairro
747
+ <TextFieldInput
748
+ list="pesquisa-bairros"
749
+ field="bairros"
750
+ value={filters.bairros}
 
 
 
 
 
 
 
 
751
  onChange={onFieldChange}
752
+ placeholder="Centro, Moinhos de Vento"
 
 
753
  />
754
+ </label>
755
+
756
+ <div className="pesquisa-inline-trio">
757
+ <div className="pesquisa-field-pair pesquisa-field-pair-inline">
758
+ <span className="pesquisa-field-pair-title">Data</span>
759
+ <label className="pesquisa-field">
760
+ Minima
761
+ <TextFieldInput field="dataMin" value={filters.dataMin} onChange={onFieldChange} placeholder="2022-01-01" />
762
+ </label>
763
+ <label className="pesquisa-field">
764
+ Maxima
765
+ <TextFieldInput field="dataMax" value={filters.dataMax} onChange={onFieldChange} placeholder="2025-12-31" />
766
+ </label>
767
+ </div>
768
+
769
+ <div className="pesquisa-field-pair pesquisa-field-pair-inline">
770
+ <span className="pesquisa-field-pair-title">Area</span>
771
+ <label className="pesquisa-field">
772
+ Minima
773
+ <NumberFieldInput field="areaMin" value={filters.areaMin} onChange={onFieldChange} placeholder="0" />
774
+ </label>
775
+ <label className="pesquisa-field">
776
+ Maxima
777
+ <NumberFieldInput field="areaMax" value={filters.areaMax} onChange={onFieldChange} placeholder="0" />
778
+ </label>
779
+ </div>
780
+
781
+ <div className="pesquisa-field-pair pesquisa-field-pair-inline">
782
+ <span className="pesquisa-field-pair-title">RH</span>
783
+ <label className="pesquisa-field">
784
+ Minimo
785
+ <NumberFieldInput field="rhMin" value={filters.rhMin} onChange={onFieldChange} placeholder="0" />
786
+ </label>
787
+ <label className="pesquisa-field">
788
+ Maximo
789
+ <NumberFieldInput field="rhMax" value={filters.rhMax} onChange={onFieldChange} placeholder="1" />
790
+ </label>
791
+ </div>
792
+ </div>
793
  </div>
794
  )}
795
 
frontend/src/styles.css CHANGED
@@ -310,6 +310,39 @@ textarea {
310
  margin-left: auto;
311
  }
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  .section-body {
314
  padding: 18px;
315
  min-width: 0;
@@ -395,7 +428,7 @@ textarea {
395
  .pesquisa-filtros-groups {
396
  display: grid;
397
  grid-template-columns: repeat(2, minmax(0, 1fr));
398
- gap: 22px;
399
  margin-bottom: 12px;
400
  }
401
 
@@ -438,8 +471,8 @@ textarea {
438
 
439
  .pesquisa-filtros-grid {
440
  display: grid;
441
- grid-template-columns: repeat(2, minmax(0, 1fr));
442
- gap: 14px;
443
  margin-bottom: 0;
444
  }
445
 
@@ -504,6 +537,94 @@ button.pesquisa-otica-btn.active:hover {
504
  width: 100%;
505
  }
506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
  .pesquisa-field input::placeholder {
508
  color: #b7c4d2;
509
  opacity: 1;
@@ -520,6 +641,27 @@ button.pesquisa-otica-btn.active:hover {
520
  grid-template-columns: minmax(0, 1.8fr) minmax(190px, 1fr);
521
  }
522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  .pesquisa-colunas-box {
524
  min-height: 38px;
525
  border: 1px solid #cfdbe7;
@@ -2564,12 +2706,27 @@ button.btn-upload-select {
2564
 
2565
  .pesquisa-filtros-groups,
2566
  .pesquisa-filtros-grid,
 
2567
  .pesquisa-card-grid,
2568
  .pesquisa-compare-grid,
2569
  .pesquisa-compat-row {
2570
  grid-template-columns: 1fr;
2571
  }
2572
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2573
  .pesquisa-dynamic-filter-row,
2574
  .pesquisa-range-values-row,
2575
  .pesquisa-range-row,
 
310
  margin-left: auto;
311
  }
312
 
313
+ .pesquisa-admin-toggle {
314
+ display: inline-flex;
315
+ align-items: center;
316
+ gap: 8px;
317
+ border: 1px solid #c7d8e8;
318
+ border-radius: 10px;
319
+ background: linear-gradient(180deg, #f7fbff 0%, #eef5fb 100%);
320
+ color: #3e5a74;
321
+ font-weight: 700;
322
+ padding: 8px 11px;
323
+ box-shadow: none;
324
+ }
325
+
326
+ .pesquisa-admin-toggle .pesquisa-admin-toggle-icon {
327
+ font-size: 0.95rem;
328
+ line-height: 1;
329
+ }
330
+
331
+ .pesquisa-admin-toggle.active {
332
+ border-color: #cf6f00;
333
+ background: linear-gradient(180deg, #ff9a26 0%, #e67900 100%);
334
+ color: #ffffff;
335
+ }
336
+
337
+ .pesquisa-admin-toggle:hover {
338
+ transform: none;
339
+ box-shadow: none;
340
+ }
341
+
342
+ .pesquisa-admin-panel {
343
+ margin-bottom: 16px;
344
+ }
345
+
346
  .section-body {
347
  padding: 18px;
348
  min-width: 0;
 
428
  .pesquisa-filtros-groups {
429
  display: grid;
430
  grid-template-columns: repeat(2, minmax(0, 1fr));
431
+ gap: 18px;
432
  margin-bottom: 12px;
433
  }
434
 
 
471
 
472
  .pesquisa-filtros-grid {
473
  display: grid;
474
+ grid-template-columns: repeat(3, minmax(0, 1fr));
475
+ gap: 12px;
476
  margin-bottom: 0;
477
  }
478
 
 
537
  width: 100%;
538
  }
539
 
540
+ .pesquisa-filtros-groups .pesquisa-field input,
541
+ .pesquisa-filtros-groups .pesquisa-field select {
542
+ width: min(100%, 255px);
543
+ }
544
+
545
+ .pesquisa-fields-grid {
546
+ display: grid;
547
+ grid-template-columns: repeat(2, minmax(0, 1fr));
548
+ gap: 12px 14px;
549
+ margin-bottom: 14px;
550
+ align-items: start;
551
+ }
552
+
553
+ .pesquisa-fields-grid-single {
554
+ grid-template-columns: minmax(240px, 420px);
555
+ }
556
+
557
+ .pesquisa-fields-grid .pesquisa-field input,
558
+ .pesquisa-fields-grid .pesquisa-field select {
559
+ width: 100%;
560
+ }
561
+
562
+ .pesquisa-field-pair {
563
+ grid-column: 1 / -1;
564
+ display: grid;
565
+ grid-template-columns: repeat(2, minmax(0, 1fr));
566
+ gap: 10px 12px;
567
+ padding: 11px 12px;
568
+ border: 1px solid #d2deea;
569
+ border-radius: 12px;
570
+ background: linear-gradient(180deg, #ffffff 0%, #f9fcff 100%);
571
+ }
572
+
573
+ .pesquisa-field-pair-triple {
574
+ grid-template-columns: repeat(3, minmax(0, 1fr));
575
+ grid-column: 1 / -1;
576
+ }
577
+
578
+ .pesquisa-field-pair-title {
579
+ grid-column: 1 / -1;
580
+ font-size: 0.75rem;
581
+ font-weight: 700;
582
+ color: #3a5874;
583
+ letter-spacing: 0.02em;
584
+ text-transform: uppercase;
585
+ }
586
+
587
+ .pesquisa-inline-trio {
588
+ grid-column: 1 / -1;
589
+ display: grid;
590
+ grid-template-columns: repeat(3, minmax(0, 1fr));
591
+ gap: 12px;
592
+ align-items: stretch;
593
+ }
594
+
595
+ .pesquisa-field-pair-inline {
596
+ grid-column: auto;
597
+ padding: 10px 11px;
598
+ height: 100%;
599
+ }
600
+
601
+ .pesquisa-field-pair-inline .pesquisa-field input,
602
+ .pesquisa-field-pair-inline .pesquisa-field select {
603
+ max-width: none;
604
+ }
605
+
606
+ .pesquisa-avaliando-grid {
607
+ align-items: end;
608
+ }
609
+
610
+ .pesquisa-avaliando-inline {
611
+ grid-column: 1 / -1;
612
+ display: grid;
613
+ grid-template-columns: repeat(3, minmax(0, 1fr));
614
+ gap: 12px 14px;
615
+ }
616
+
617
+ .pesquisa-fields-grid .pesquisa-field {
618
+ min-width: 0;
619
+ }
620
+
621
+ .pesquisa-fields-grid .pesquisa-field input,
622
+ .pesquisa-fields-grid .pesquisa-field select,
623
+ .pesquisa-field-pair .pesquisa-field input,
624
+ .pesquisa-field-pair .pesquisa-field select {
625
+ min-height: 34px;
626
+ }
627
+
628
  .pesquisa-field input::placeholder {
629
  color: #b7c4d2;
630
  opacity: 1;
 
641
  grid-template-columns: minmax(0, 1.8fr) minmax(190px, 1fr);
642
  }
643
 
644
+ .pesquisa-admin-fields {
645
+ display: grid;
646
+ gap: 14px;
647
+ }
648
+
649
+ .pesquisa-admin-field {
650
+ display: grid;
651
+ gap: 8px;
652
+ }
653
+
654
+ .pesquisa-admin-field-head {
655
+ display: flex;
656
+ align-items: center;
657
+ justify-content: space-between;
658
+ gap: 12px;
659
+ }
660
+
661
+ .pesquisa-admin-row {
662
+ grid-template-columns: minmax(0, 1.8fr) minmax(220px, 1fr);
663
+ }
664
+
665
  .pesquisa-colunas-box {
666
  min-height: 38px;
667
  border: 1px solid #cfdbe7;
 
2706
 
2707
  .pesquisa-filtros-groups,
2708
  .pesquisa-filtros-grid,
2709
+ .pesquisa-fields-grid,
2710
  .pesquisa-card-grid,
2711
  .pesquisa-compare-grid,
2712
  .pesquisa-compat-row {
2713
  grid-template-columns: 1fr;
2714
  }
2715
 
2716
+ .pesquisa-field-pair,
2717
+ .pesquisa-field-pair.pesquisa-field-pair-triple {
2718
+ grid-template-columns: 1fr;
2719
+ grid-column: 1 / -1;
2720
+ }
2721
+
2722
+ .pesquisa-inline-trio {
2723
+ grid-template-columns: 1fr;
2724
+ }
2725
+
2726
+ .pesquisa-avaliando-inline {
2727
+ grid-template-columns: 1fr;
2728
+ }
2729
+
2730
  .pesquisa-dynamic-filter-row,
2731
  .pesquisa-range-values-row,
2732
  .pesquisa-range-row,