Guilherme Silberfarb Costa commited on
Commit
82ec900
·
1 Parent(s): c578055

prototipo aba pesquisa

Browse files
.gitignore CHANGED
@@ -11,3 +11,6 @@ frontend/dist/
11
 
12
  # System
13
  .DS_Store
 
 
 
 
11
 
12
  # System
13
  .DS_Store
14
+
15
+ # Modelos locais da pesquisa (binários, não versionar)
16
+ backend/app/core/pesquisa/modelos_dai/*.dai
backend/app/api/pesquisa.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 PesquisaFiltros, gerar_mapa_modelos, listar_modelos
7
+
8
+
9
+ router = APIRouter(prefix="/api/pesquisa", tags=["pesquisa"])
10
+
11
+
12
+ def _split_csv(value: str | None) -> list[str]:
13
+ if not value:
14
+ return []
15
+ return [item.strip() for item in value.split(",") if item.strip()]
16
+
17
+
18
+ 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),
31
+ bairros: str | None = Query(None),
32
+ bairros_colunas: str | None = Query(None),
33
+ endereco: str | None = Query(None),
34
+ data_min: str | None = Query(None),
35
+ data_colunas: str | None = Query(None),
36
+ data_max: str | None = Query(None),
37
+ area_min: float | None = Query(None),
38
+ area_colunas: str | None = Query(None),
39
+ area_max: float | None = Query(None),
40
+ rh_min: float | None = Query(None),
41
+ rh_colunas: str | None = Query(None),
42
+ rh_max: float | None = Query(None),
43
+ aval_finalidade: str | None = Query(None),
44
+ aval_finalidade_colunas: str | None = Query(None),
45
+ aval_bairro: str | None = Query(None),
46
+ aval_bairro_colunas: str | None = Query(None),
47
+ aval_endereco: str | None = Query(None),
48
+ aval_data: str | None = Query(None),
49
+ aval_data_colunas: str | None = Query(None),
50
+ aval_area: float | None = Query(None),
51
+ aval_area_colunas: str | None = Query(None),
52
+ aval_area_privativa: float | None = Query(None),
53
+ aval_area_privativa_colunas: str | None = Query(None),
54
+ aval_area_total: float | None = Query(None),
55
+ aval_area_total_colunas: str | None = Query(None),
56
+ aval_rh: float | None = Query(None),
57
+ aval_rh_colunas: str | None = Query(None),
58
+ aval_valor_unitario: float | None = Query(None),
59
+ aval_valor_unitario_colunas: str | None = Query(None),
60
+ aval_valor_total: float | None = Query(None),
61
+ aval_valor_total_colunas: str | None = Query(None),
62
+ limite: int = Query(300, ge=1, le=2000),
63
+ ) -> dict:
64
+ filtros = PesquisaFiltros(
65
+ otica=otica,
66
+ nome=nome,
67
+ autor=autor,
68
+ finalidade=finalidade,
69
+ finalidade_colunas=_split_csv(finalidade_colunas),
70
+ bairro=bairro,
71
+ bairros=_split_csv(bairros),
72
+ bairros_colunas=_split_csv(bairros_colunas),
73
+ endereco=endereco,
74
+ data_min=data_min,
75
+ data_colunas=_split_csv(data_colunas),
76
+ data_max=data_max,
77
+ area_min=area_min,
78
+ area_colunas=_split_csv(area_colunas),
79
+ area_max=area_max,
80
+ rh_min=rh_min,
81
+ rh_colunas=_split_csv(rh_colunas),
82
+ rh_max=rh_max,
83
+ aval_finalidade=aval_finalidade,
84
+ aval_finalidade_colunas=_split_csv(aval_finalidade_colunas),
85
+ aval_bairro=aval_bairro,
86
+ aval_bairro_colunas=_split_csv(aval_bairro_colunas),
87
+ aval_endereco=aval_endereco,
88
+ aval_data=aval_data,
89
+ aval_data_colunas=_split_csv(aval_data_colunas),
90
+ aval_area=aval_area,
91
+ aval_area_colunas=_split_csv(aval_area_colunas),
92
+ aval_area_privativa=aval_area_privativa,
93
+ aval_area_privativa_colunas=_split_csv(aval_area_privativa_colunas),
94
+ aval_area_total=aval_area_total,
95
+ aval_area_total_colunas=_split_csv(aval_area_total_colunas),
96
+ aval_rh=aval_rh,
97
+ aval_rh_colunas=_split_csv(aval_rh_colunas),
98
+ aval_valor_unitario=aval_valor_unitario,
99
+ aval_valor_unitario_colunas=_split_csv(aval_valor_unitario_colunas),
100
+ aval_valor_total=aval_valor_total,
101
+ aval_valor_total_colunas=_split_csv(aval_valor_total_colunas),
102
+ )
103
+ return listar_modelos(filtros=filtros, limite=limite, somente_contexto=somente_contexto)
104
+
105
+
106
+ @router.post("/mapa-modelos")
107
+ def pesquisar_mapa_modelos(payload: MapaModelosPayload) -> dict:
108
+ return gerar_mapa_modelos(payload.modelos_ids)
backend/app/core/pesquisa/modelos_dai/.gitkeep ADDED
File without changes
backend/app/core/pesquisa/modelos_dai/README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pasta de Modelos da Aba Pesquisa
2
+
3
+ Coloque nesta pasta os arquivos `.dai` que devem aparecer na aba **Pesquisa**.
4
+
5
+ ## Estrutura
6
+
7
+ - `NOME_MODELO.dai`
8
+
9
+ Exemplo:
10
+
11
+ - `MOD_A_SALA_Z1_006C.dai`
12
+
13
+ ## Como os dados sao lidos
14
+
15
+ - Nome do modelo: nome do arquivo `.dai`
16
+ - Autor, equacao, R2 e dados estatisticos: lidos do proprio pacote `.dai`
17
+ - Finalidade e bairros: extraidos das colunas do `.dai` (com prioridade para aliases de `NME IMO-FINAL` e `NME BAI`)
18
+
19
+ Nao ha suporte a arquivo auxiliar `.meta.json` nesta pasta.
backend/app/main.py CHANGED
@@ -6,7 +6,7 @@ from fastapi import FastAPI
6
  from fastapi.middleware.cors import CORSMiddleware
7
  from fastapi.staticfiles import StaticFiles
8
 
9
- from app.api import elaboracao, health, session, visualizacao
10
 
11
 
12
  app = FastAPI(
@@ -27,6 +27,7 @@ app.include_router(health.router)
27
  app.include_router(session.router)
28
  app.include_router(elaboracao.router)
29
  app.include_router(visualizacao.router)
 
30
 
31
 
32
  def _mount_frontend_if_exists() -> None:
 
6
  from fastapi.middleware.cors import CORSMiddleware
7
  from fastapi.staticfiles import StaticFiles
8
 
9
+ from app.api import elaboracao, health, pesquisa, session, visualizacao
10
 
11
 
12
  app = FastAPI(
 
27
  app.include_router(session.router)
28
  app.include_router(elaboracao.router)
29
  app.include_router(visualizacao.router)
30
+ app.include_router(pesquisa.router)
31
 
32
 
33
  def _mount_frontend_if_exists() -> None:
backend/app/services/pesquisa_service.py ADDED
@@ -0,0 +1,1599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ import unicodedata
6
+ from dataclasses import dataclass
7
+ from datetime import date, datetime
8
+ from pathlib import Path
9
+ from threading import Lock
10
+ from typing import Any
11
+
12
+ import folium
13
+ import pandas as pd
14
+ from fastapi import HTTPException
15
+ from joblib import load
16
+
17
+ 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",
35
+ "RCOMD": "Residencia em condominio",
36
+ "TCOND": "Terreno em condominio",
37
+ "LCOM": "Loja",
38
+ "LOJA": "Loja",
39
+ "CCOM": "Casa comercial",
40
+ "DEP": "Deposito",
41
+ "DEPOS": "Deposito",
42
+ "RES": "Residencias isoladas / casas",
43
+ "SALA": "Salas comerciais",
44
+ "APTO": "Apartamentos residenciais",
45
+ "APART": "Apartamentos residenciais",
46
+ "AP": "Apartamentos residenciais",
47
+ "TERRENO": "Terrenos",
48
+ "TER": "Terrenos",
49
+ "EDIF": "Edificio",
50
+ "CASA": "Residencias isoladas / casas",
51
+ "GALPAO": "Galpao",
52
+ }
53
+
54
+ CAMPO_TEXTO_META_FONTES = {
55
+ "finalidade": [],
56
+ "bairros": [],
57
+ "aval_finalidade": [],
58
+ "aval_bairro": [],
59
+ }
60
+
61
+ CAMPO_TEXTO_ALIASES_COLUNA = {
62
+ "finalidade": FINALIDADE_ALIASES,
63
+ "bairros": BAIRRO_ALIASES,
64
+ "aval_finalidade": FINALIDADE_ALIASES,
65
+ "aval_bairro": BAIRRO_ALIASES,
66
+ }
67
+
68
+ CAMPO_FAIXA_META_FONTES = {
69
+ "data": [],
70
+ "area": [],
71
+ "rh": [],
72
+ "aval_data": [],
73
+ "aval_area": [],
74
+ "aval_area_privativa": [],
75
+ "aval_area_total": [],
76
+ "aval_rh": [],
77
+ "aval_valor_unitario": [],
78
+ "aval_valor_total": [],
79
+ }
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,
90
+ "aval_valor_unitario": VALOR_UNITARIO_ALIASES,
91
+ "aval_valor_total": VALOR_TOTAL_ALIASES,
92
+ }
93
+
94
+ FONTE_META_LABELS = {
95
+ "meta:nome_modelo": "Metadado: Nome do modelo",
96
+ "meta:arquivo": "Metadado: Arquivo",
97
+ "meta:autor": "Metadado: Autor/Elaborador",
98
+ "meta:finalidade": "Metadado: Finalidade",
99
+ "meta:tipo_imovel": "Metadado: Tipo",
100
+ "meta:finalidades": "Metadado: Finalidades",
101
+ "meta:bairros": "Metadado: Bairros",
102
+ "meta:endereco_referencia": "Metadado: Endereco de referencia",
103
+ "meta:faixa_data": "Metadado: Faixa de data",
104
+ "meta:faixa_area": "Metadado: Faixa de area",
105
+ "meta:faixa_rh": "Metadado: Faixa de RH",
106
+ "meta:faixa_area_privativa": "Metadado: Faixa de area privativa",
107
+ "meta:faixa_area_total": "Metadado: Faixa de area total",
108
+ "meta:faixa_valor_unitario": "Metadado: Faixa de valor unitario",
109
+ "meta:faixa_valor_total": "Metadado: Faixa de valor total",
110
+ }
111
+
112
+ MAX_COLUNAS_INDEXADAS = 120
113
+ MAX_VALORES_INDEXADOS_POR_COLUNA = 140
114
+ MAX_LINHAS_INDEXACAO = 5000
115
+
116
+ COMPATIBILIDADE_MAP = {
117
+ "area_privativa": AREA_PRIVATIVA_ALIASES,
118
+ "area_total": AREA_TOTAL_ALIASES,
119
+ "valor_unitario": VALOR_UNITARIO_ALIASES,
120
+ "valor_total": VALOR_TOTAL_ALIASES,
121
+ }
122
+
123
+ RANGE_CAMPOS = {
124
+ "area_privativa": AREA_PRIVATIVA_ALIASES,
125
+ "area_total": AREA_TOTAL_ALIASES,
126
+ "valor_unitario": VALOR_UNITARIO_ALIASES,
127
+ "valor_total": VALOR_TOTAL_ALIASES,
128
+ }
129
+
130
+ MAP_COLORS = [
131
+ "#1f77b4",
132
+ "#d62728",
133
+ "#2ca02c",
134
+ "#ff7f0e",
135
+ "#9467bd",
136
+ "#17becf",
137
+ "#8c564b",
138
+ "#e377c2",
139
+ "#bcbd22",
140
+ "#7f7f7f",
141
+ ]
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ 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
152
+ bairros: list[str] | None = None
153
+ bairros_colunas: list[str] | None = None
154
+ endereco: str | None = None
155
+ data_min: str | None = None
156
+ data_colunas: list[str] | None = None
157
+ data_max: str | None = None
158
+ area_min: float | None = None
159
+ area_colunas: list[str] | None = None
160
+ area_max: float | None = None
161
+ rh_min: float | None = None
162
+ rh_colunas: list[str] | None = None
163
+ rh_max: float | None = None
164
+ aval_finalidade: str | None = None
165
+ aval_finalidade_colunas: list[str] | None = None
166
+ aval_bairro: str | None = None
167
+ aval_bairro_colunas: list[str] | None = None
168
+ aval_endereco: str | None = None
169
+ aval_data: str | None = None
170
+ aval_data_colunas: list[str] | None = None
171
+ aval_area: float | None = None
172
+ aval_area_colunas: list[str] | None = None
173
+ aval_area_privativa: float | None = None
174
+ aval_area_privativa_colunas: list[str] | None = None
175
+ aval_area_total: float | None = None
176
+ aval_area_total_colunas: list[str] | None = None
177
+ aval_rh: float | None = None
178
+ aval_rh_colunas: list[str] | None = None
179
+ aval_valor_unitario: float | None = None
180
+ aval_valor_unitario_colunas: list[str] | None = None
181
+ aval_valor_total: float | None = None
182
+ aval_valor_total_colunas: list[str] | None = None
183
+
184
+
185
+ _CACHE_LOCK = Lock()
186
+ _CACHE: dict[str, dict[str, Any]] = {}
187
+
188
+
189
+ def ensure_modelos_dir() -> Path:
190
+ MODELOS_DIR.mkdir(parents=True, exist_ok=True)
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())
197
+
198
+ otica = _normalizar_otica(filtros.otica)
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(
207
+ {
208
+ "modelos": [],
209
+ "sugestoes": sugestoes,
210
+ "colunas_filtro": colunas_filtro,
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,
220
+ "bairros": filtros.bairros or [],
221
+ "bairros_colunas": filtros.bairros_colunas or [],
222
+ "endereco": filtros.endereco,
223
+ "data_min": filtros.data_min,
224
+ "data_colunas": filtros.data_colunas or [],
225
+ "data_max": filtros.data_max,
226
+ "area_min": filtros.area_min,
227
+ "area_colunas": filtros.area_colunas or [],
228
+ "area_max": filtros.area_max,
229
+ "rh_min": filtros.rh_min,
230
+ "rh_colunas": filtros.rh_colunas or [],
231
+ "rh_max": filtros.rh_max,
232
+ "otica": otica,
233
+ "aval_finalidade": filtros.aval_finalidade,
234
+ "aval_finalidade_colunas": filtros.aval_finalidade_colunas or [],
235
+ "aval_bairro": filtros.aval_bairro,
236
+ "aval_bairro_colunas": filtros.aval_bairro_colunas or [],
237
+ "aval_endereco": filtros.aval_endereco,
238
+ "aval_data": filtros.aval_data,
239
+ "aval_data_colunas": filtros.aval_data_colunas or [],
240
+ "aval_area": filtros.aval_area,
241
+ "aval_area_colunas": filtros.aval_area_colunas or [],
242
+ "aval_area_privativa": filtros.aval_area_privativa,
243
+ "aval_area_privativa_colunas": filtros.aval_area_privativa_colunas or [],
244
+ "aval_area_total": filtros.aval_area_total,
245
+ "aval_area_total_colunas": filtros.aval_area_total_colunas or [],
246
+ "aval_rh": filtros.aval_rh,
247
+ "aval_rh_colunas": filtros.aval_rh_colunas or [],
248
+ "aval_valor_unitario": filtros.aval_valor_unitario,
249
+ "aval_valor_unitario_colunas": filtros.aval_valor_unitario_colunas or [],
250
+ "aval_valor_total": filtros.aval_valor_total,
251
+ "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
252
+ },
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:
263
+ filtrados = filtrados[:limite]
264
+
265
+ modelos_publicos = [_modelo_publico(item) for item in filtrados]
266
+
267
+ return sanitize_value(
268
+ {
269
+ "modelos": modelos_publicos,
270
+ "sugestoes": sugestoes,
271
+ "colunas_filtro": colunas_filtro,
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,
281
+ "bairros": filtros.bairros or [],
282
+ "bairros_colunas": filtros.bairros_colunas or [],
283
+ "endereco": filtros.endereco,
284
+ "data_min": filtros.data_min,
285
+ "data_colunas": filtros.data_colunas or [],
286
+ "data_max": filtros.data_max,
287
+ "area_min": filtros.area_min,
288
+ "area_colunas": filtros.area_colunas or [],
289
+ "area_max": filtros.area_max,
290
+ "rh_min": filtros.rh_min,
291
+ "rh_colunas": filtros.rh_colunas or [],
292
+ "rh_max": filtros.rh_max,
293
+ "otica": otica,
294
+ "aval_finalidade": filtros.aval_finalidade,
295
+ "aval_finalidade_colunas": filtros.aval_finalidade_colunas or [],
296
+ "aval_bairro": filtros.aval_bairro,
297
+ "aval_bairro_colunas": filtros.aval_bairro_colunas or [],
298
+ "aval_endereco": filtros.aval_endereco,
299
+ "aval_data": filtros.aval_data,
300
+ "aval_data_colunas": filtros.aval_data_colunas or [],
301
+ "aval_area": filtros.aval_area,
302
+ "aval_area_colunas": filtros.aval_area_colunas or [],
303
+ "aval_area_privativa": filtros.aval_area_privativa,
304
+ "aval_area_privativa_colunas": filtros.aval_area_privativa_colunas or [],
305
+ "aval_area_total": filtros.aval_area_total,
306
+ "aval_area_total_colunas": filtros.aval_area_total_colunas or [],
307
+ "aval_rh": filtros.aval_rh,
308
+ "aval_rh_colunas": filtros.aval_rh_colunas or [],
309
+ "aval_valor_unitario": filtros.aval_valor_unitario,
310
+ "aval_valor_unitario_colunas": filtros.aval_valor_unitario_colunas or [],
311
+ "aval_valor_total": filtros.aval_valor_total,
312
+ "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
313
+ },
314
+ }
315
+ )
316
+
317
+
318
+ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 4500) -> dict[str, Any]:
319
+ ids = [str(item).strip() for item in (modelos_ids or []) if str(item).strip()]
320
+ if not ids:
321
+ raise HTTPException(status_code=400, detail="Selecione ao menos um modelo para gerar o mapa")
322
+
323
+ pasta = ensure_modelos_dir()
324
+ caminhos_por_id = {caminho.stem: caminho for caminho in pasta.glob("*.dai")}
325
+
326
+ selecionados: list[tuple[str, Path]] = []
327
+ vistos = set()
328
+ for modelo_id in ids:
329
+ if modelo_id in vistos:
330
+ continue
331
+ caminho = caminhos_por_id.get(modelo_id)
332
+ if caminho is None:
333
+ continue
334
+ vistos.add(modelo_id)
335
+ selecionados.append((modelo_id, caminho))
336
+
337
+ if not selecionados:
338
+ raise HTTPException(status_code=404, detail="Nenhum modelo selecionado foi encontrado na pasta de pesquisa")
339
+
340
+ modelos_plotados: list[dict[str, Any]] = []
341
+ bounds: list[list[float]] = []
342
+
343
+ for idx, (modelo_id, caminho) in enumerate(selecionados):
344
+ resumo = _carregar_resumo_com_cache(caminho)
345
+ df = _carregar_dataframe_modelo(caminho)
346
+ if df is None or df.empty:
347
+ continue
348
+
349
+ pontos = _coletar_pontos_modelo(df, limite_pontos_por_modelo)
350
+ if not pontos:
351
+ continue
352
+
353
+ cor = MAP_COLORS[idx % len(MAP_COLORS)]
354
+ nome = str(resumo.get("nome_modelo") or modelo_id)
355
+
356
+ modelos_plotados.append(
357
+ {
358
+ "id": modelo_id,
359
+ "nome": nome,
360
+ "cor": cor,
361
+ "total_pontos": len(pontos),
362
+ "pontos": pontos,
363
+ }
364
+ )
365
+ bounds.extend([[ponto["lat"], ponto["lon"]] for ponto in pontos])
366
+
367
+ if not modelos_plotados:
368
+ raise HTTPException(
369
+ status_code=400,
370
+ detail="Nao foi possivel gerar o mapa: os modelos selecionados nao possuem coordenadas validas",
371
+ )
372
+
373
+ centro_lat = sum(coord[0] for coord in bounds) / len(bounds)
374
+ centro_lon = sum(coord[1] for coord in bounds) / len(bounds)
375
+
376
+ mapa = folium.Map(
377
+ location=[centro_lat, centro_lon],
378
+ zoom_start=12,
379
+ control_scale=True,
380
+ tiles="CartoDB positron",
381
+ )
382
+
383
+ total_pontos = 0
384
+ for modelo in modelos_plotados:
385
+ total_pontos += int(modelo["total_pontos"])
386
+ camada = folium.FeatureGroup(name=f'{modelo["nome"]} ({modelo["total_pontos"]})', show=True)
387
+ for ponto in modelo["pontos"]:
388
+ folium.CircleMarker(
389
+ location=[ponto["lat"], ponto["lon"]],
390
+ radius=3,
391
+ color=modelo["cor"],
392
+ fill=True,
393
+ fill_color=modelo["cor"],
394
+ fill_opacity=0.72,
395
+ opacity=0.9,
396
+ weight=1,
397
+ tooltip=modelo["nome"],
398
+ ).add_to(camada)
399
+ camada.add_to(mapa)
400
+
401
+ folium.LayerControl(collapsed=False).add_to(mapa)
402
+ if bounds:
403
+ mapa.fit_bounds(bounds, padding=(20, 20))
404
+
405
+ return sanitize_value(
406
+ {
407
+ "mapa_html": mapa.get_root().render(),
408
+ "total_modelos_plotados": len(modelos_plotados),
409
+ "total_pontos": total_pontos,
410
+ "modelos_plotados": [
411
+ {
412
+ "id": modelo["id"],
413
+ "nome": modelo["nome"],
414
+ "cor": modelo["cor"],
415
+ "total_pontos": modelo["total_pontos"],
416
+ }
417
+ for modelo in modelos_plotados
418
+ ],
419
+ "status": f"Mapa gerado com {len(modelos_plotados)} modelo(s) e {total_pontos} ponto(s)",
420
+ }
421
+ )
422
+
423
+
424
+ def _carregar_resumo_com_cache(caminho_modelo: Path) -> dict[str, Any]:
425
+ assinatura = _assinatura_arquivos(caminho_modelo)
426
+ cache_key = str(caminho_modelo)
427
+
428
+ with _CACHE_LOCK:
429
+ cached = _CACHE.get(cache_key)
430
+ if cached and cached.get("assinatura") == assinatura:
431
+ return dict(cached["resumo"])
432
+
433
+ resumo = _construir_resumo_modelo(caminho_modelo)
434
+
435
+ with _CACHE_LOCK:
436
+ _CACHE[cache_key] = {
437
+ "assinatura": assinatura,
438
+ "resumo": resumo,
439
+ }
440
+
441
+ return dict(resumo)
442
+
443
+
444
+ def _assinatura_arquivos(caminho_modelo: Path) -> tuple[int, int]:
445
+ stat_modelo = caminho_modelo.stat()
446
+ return (stat_modelo.st_mtime_ns, stat_modelo.st_size)
447
+
448
+
449
+ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
450
+ resumo = {
451
+ "id": caminho_modelo.stem,
452
+ "arquivo": caminho_modelo.name,
453
+ "nome_modelo": caminho_modelo.stem,
454
+ "autor": None,
455
+ "finalidade": _inferir_finalidade_por_nome(caminho_modelo.stem),
456
+ "finalidades": [],
457
+ "tipo_imovel": _inferir_tipo_por_nome(caminho_modelo.stem),
458
+ "bairros": [],
459
+ "faixa_area": None,
460
+ "faixa_rh": None,
461
+ "faixa_data": None,
462
+ "total_dados": None,
463
+ "total_trabalhos": None,
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},
470
+ "variaveis_resumo": [],
471
+ "status": "ok",
472
+ "erro_leitura": None,
473
+ "fonte": {
474
+ "modelo": "dai",
475
+ },
476
+ "_texto_colunas_index": {},
477
+ "_faixa_colunas_index": {},
478
+ }
479
+
480
+ try:
481
+ pacote = load(caminho_modelo)
482
+ except Exception as exc:
483
+ resumo["status"] = "erro"
484
+ resumo["erro_leitura"] = f"Falha ao ler .dai: {exc}"
485
+ return resumo
486
+
487
+ if not isinstance(pacote, dict):
488
+ resumo["status"] = "erro"
489
+ resumo["erro_leitura"] = "Arquivo .dai invalido (conteudo nao e dict)."
490
+ return resumo
491
+
492
+ if "versao" not in pacote:
493
+ try:
494
+ pacote = _migrar_pacote_v1_para_v2(pacote)
495
+ except Exception:
496
+ pass
497
+
498
+ dados_pacote = pacote.get("dados") if isinstance(pacote.get("dados"), dict) else {}
499
+ estat_df = _to_dataframe(dados_pacote.get("estatisticas"))
500
+ df_modelo = _to_dataframe(dados_pacote.get("df_completo"))
501
+ if df_modelo is None:
502
+ df_modelo = _to_dataframe(dados_pacote.get("df"))
503
+
504
+ if resumo["autor"] is None:
505
+ resumo["autor"] = _autor_do_pacote(pacote)
506
+
507
+ if resumo["equacao"] is None:
508
+ resumo["equacao"] = _equacao_do_pacote(pacote)
509
+
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
+
516
+ if not resumo["finalidades"] and df_modelo is not None:
517
+ resumo["finalidades"] = _extrair_finalidades(df_modelo)
518
+
519
+ if resumo["finalidade"] is None:
520
+ resumo["finalidade"] = resumo["finalidades"][0] if resumo["finalidades"] else None
521
+
522
+ if not resumo["bairros"] and df_modelo is not None:
523
+ resumo["bairros"] = _extrair_bairros(df_modelo)
524
+
525
+ faixas_por_campo = {
526
+ chave: _extrair_faixa_por_alias(estat_df, aliases)
527
+ for chave, aliases in RANGE_CAMPOS.items()
528
+ }
529
+ resumo["faixas_por_campo"] = faixas_por_campo
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)
544
+ resumo["variaveis_resumo"] = _resumo_variaveis(estat_df)
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
+
551
+
552
+ def _autor_do_pacote(pacote: dict[str, Any]) -> str | None:
553
+ elaborador = pacote.get("elaborador")
554
+ if isinstance(elaborador, dict):
555
+ return _str_or_none(elaborador.get("nome_completo")) or _str_or_none(elaborador.get("nome"))
556
+ return None
557
+
558
+
559
+ def _equacao_do_pacote(pacote: dict[str, Any]) -> str | None:
560
+ modelo = pacote.get("modelo") if isinstance(pacote.get("modelo"), dict) else {}
561
+ diagnosticos = modelo.get("diagnosticos") if isinstance(modelo.get("diagnosticos"), dict) else {}
562
+ return _str_or_none(diagnosticos.get("equacao"))
563
+
564
+
565
+ def _r2_do_pacote(pacote: dict[str, Any]) -> float | None:
566
+ modelo = pacote.get("modelo") if isinstance(pacote.get("modelo"), dict) else {}
567
+ diagnosticos = modelo.get("diagnosticos") if isinstance(modelo.get("diagnosticos"), dict) else {}
568
+ gerais = diagnosticos.get("gerais") if isinstance(diagnosticos.get("gerais"), dict) else {}
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
575
+ if isinstance(value, pd.DataFrame):
576
+ return value.copy()
577
+ if isinstance(value, list):
578
+ try:
579
+ return pd.DataFrame(value)
580
+ except Exception:
581
+ return None
582
+ if isinstance(value, dict):
583
+ try:
584
+ return pd.DataFrame(value)
585
+ except Exception:
586
+ return None
587
+ return None
588
+
589
+
590
+ def _carregar_dataframe_modelo(caminho_modelo: Path) -> pd.DataFrame | None:
591
+ try:
592
+ pacote = load(caminho_modelo)
593
+ except Exception:
594
+ return None
595
+
596
+ if not isinstance(pacote, dict):
597
+ return None
598
+
599
+ if "versao" not in pacote:
600
+ try:
601
+ pacote = _migrar_pacote_v1_para_v2(pacote)
602
+ except Exception:
603
+ return None
604
+
605
+ dados = pacote.get("dados") if isinstance(pacote.get("dados"), dict) else {}
606
+ df_modelo = _to_dataframe(dados.get("df_completo"))
607
+ if df_modelo is None:
608
+ df_modelo = _to_dataframe(dados.get("df"))
609
+ return df_modelo
610
+
611
+
612
+ def _coletar_pontos_modelo(df_modelo: pd.DataFrame, limite_pontos: int) -> list[dict[str, float]]:
613
+ if df_modelo is None or df_modelo.empty:
614
+ return []
615
+
616
+ col_lat = _identificar_coluna_por_alias(df_modelo.columns, LAT_ALIASES)
617
+ col_lon = _identificar_coluna_por_alias(df_modelo.columns, LON_ALIASES)
618
+ if col_lat is None or col_lon is None:
619
+ return []
620
+
621
+ base = df_modelo[[col_lat, col_lon]].copy()
622
+ base[col_lat] = pd.to_numeric(base[col_lat], errors="coerce")
623
+ base[col_lon] = pd.to_numeric(base[col_lon], errors="coerce")
624
+ base = base.dropna(subset=[col_lat, col_lon])
625
+ if base.empty:
626
+ return []
627
+
628
+ base = base[(base[col_lat].between(-90, 90)) & (base[col_lon].between(-180, 180))]
629
+ if base.empty:
630
+ return []
631
+
632
+ if limite_pontos > 0 and len(base) > limite_pontos:
633
+ passo = max(1, math.ceil(len(base) / limite_pontos))
634
+ base = base.iloc[::passo].head(limite_pontos)
635
+
636
+ pontos: list[dict[str, float]] = []
637
+ for _, row in base.iterrows():
638
+ pontos.append({"lat": float(row[col_lat]), "lon": float(row[col_lon])})
639
+ return pontos
640
+
641
+
642
+ def _identificar_coluna_por_alias(colunas: Any, aliases: list[str]) -> str | None:
643
+ for coluna in colunas:
644
+ nome = str(coluna)
645
+ if _has_alias(nome, aliases):
646
+ return nome
647
+ return None
648
+
649
+
650
+ def _extrair_bairros(df: pd.DataFrame) -> list[str]:
651
+ candidatos = [col for col in df.columns if _has_alias(str(col), BAIRRO_ALIASES)]
652
+ bairros: set[str] = set()
653
+
654
+ for col in candidatos:
655
+ serie = df[col]
656
+ for valor in serie.dropna().head(5000):
657
+ texto = str(valor).strip()
658
+ if texto:
659
+ bairros.add(texto)
660
+ if len(bairros) >= 30:
661
+ break
662
+
663
+ return sorted(bairros)[:30]
664
+
665
+
666
+ def _extrair_finalidades(df: pd.DataFrame) -> list[str]:
667
+ candidatos = [col for col in df.columns if _has_alias(str(col), FINALIDADE_ALIASES)]
668
+ finalidades: list[str] = []
669
+ vistos = set()
670
+
671
+ for col in candidatos:
672
+ serie = df[col]
673
+ for valor in serie.dropna().head(5000):
674
+ texto = str(valor).strip()
675
+ chave = _normalize(texto)
676
+ if not texto or not chave or chave in vistos:
677
+ continue
678
+ vistos.add(chave)
679
+ finalidades.append(texto)
680
+ if len(finalidades) >= 20:
681
+ break
682
+ if len(finalidades) >= 20:
683
+ break
684
+
685
+ return finalidades
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
705
+
706
+ estat_indexado = estat_df.copy()
707
+ if "Variável" in estat_indexado.columns:
708
+ estat_indexado = estat_indexado.set_index("Variável")
709
+
710
+ if estat_indexado.empty:
711
+ return None
712
+
713
+ min_col = _buscar_coluna(estat_indexado.columns, ["minimo", "mínimo", "min"])
714
+ max_col = _buscar_coluna(estat_indexado.columns, ["maximo", "máximo", "max"])
715
+ if min_col is None or max_col is None:
716
+ return None
717
+
718
+ mins: list[Any] = []
719
+ maxs: list[Any] = []
720
+
721
+ for var, linha in estat_indexado.iterrows():
722
+ nome = str(var)
723
+ if not _has_alias(nome, aliases):
724
+ continue
725
+ min_val = linha.get(min_col)
726
+ max_val = linha.get(max_col)
727
+ if _is_empty(min_val) or _is_empty(max_val):
728
+ continue
729
+ mins.append(min_val)
730
+ maxs.append(max_val)
731
+
732
+ if not mins and not maxs:
733
+ return None
734
+
735
+ return {
736
+ "min": _min_value(mins),
737
+ "max": _max_value(maxs),
738
+ }
739
+
740
+
741
+ def _resumo_variaveis(estat_df: pd.DataFrame | None, limite: int = 12) -> list[dict[str, Any]]:
742
+ if estat_df is None or estat_df.empty:
743
+ return []
744
+
745
+ trabalho = estat_df.copy()
746
+ if "Variável" in trabalho.columns:
747
+ trabalho = trabalho.set_index("Variável")
748
+ if trabalho.empty:
749
+ return []
750
+
751
+ min_col = _buscar_coluna(trabalho.columns, ["minimo", "mínimo", "min"])
752
+ max_col = _buscar_coluna(trabalho.columns, ["maximo", "máximo", "max"])
753
+ if min_col is None or max_col is None:
754
+ return []
755
+
756
+ linhas: list[dict[str, Any]] = []
757
+ for var, linha in trabalho.iterrows():
758
+ min_val = linha.get(min_col)
759
+ max_val = linha.get(max_col)
760
+ if _is_empty(min_val) and _is_empty(max_val):
761
+ continue
762
+ linhas.append(
763
+ {
764
+ "variavel": str(var),
765
+ "min": sanitize_value(min_val),
766
+ "max": sanitize_value(max_val),
767
+ }
768
+ )
769
+ if len(linhas) >= limite:
770
+ break
771
+
772
+ return linhas
773
+
774
+
775
+ def _coletar_colunas_para_catalogo(estat_df: pd.DataFrame | None, df: pd.DataFrame | None) -> list[str]:
776
+ nomes: list[str] = []
777
+ if estat_df is not None and not estat_df.empty:
778
+ if "Variável" in estat_df.columns:
779
+ nomes.extend([str(v) for v in estat_df["Variável"].dropna().tolist()])
780
+ else:
781
+ nomes.extend([str(v) for v in estat_df.index.tolist()])
782
+ if df is not None and not df.empty:
783
+ nomes.extend([str(v) for v in df.columns.tolist()])
784
+
785
+ unicos = []
786
+ vistos = set()
787
+ for nome in nomes:
788
+ chave = _normalize(nome)
789
+ if not chave or chave in vistos:
790
+ continue
791
+ vistos.add(chave)
792
+ unicos.append(nome)
793
+ return unicos
794
+
795
+
796
+ def _mapear_compatibilidade(colunas: list[str]) -> dict[str, list[str]]:
797
+ out: dict[str, list[str]] = {}
798
+ for chave, aliases in COMPATIBILIDADE_MAP.items():
799
+ encontrados = [col for col in colunas if _has_alias(col, aliases)]
800
+ out[chave] = encontrados
801
+ return out
802
+
803
+
804
+ def _tem_colunas_mapa(df: pd.DataFrame | None) -> bool:
805
+ if df is None or df.empty:
806
+ return False
807
+ nomes = [str(col) for col in df.columns]
808
+ tem_lat = any(_has_alias(col, LAT_ALIASES) for col in nomes)
809
+ tem_lon = any(_has_alias(col, LON_ALIASES) for col in nomes)
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):
824
+ return False
825
+
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] = []
852
+
853
+ def registrar(campo: str, informado: Any, aceito: bool, detalhe: str) -> None:
854
+ check = {"campo": campo, "informado": sanitize_value(informado), "aceito": bool(aceito), "detalhe": detalhe}
855
+ checks.append(check)
856
+ if _is_provided(informado) and not aceito:
857
+ rejeicoes.append(f"{campo}: {detalhe}")
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
870
+ if _is_provided(endereco_info):
871
+ candidatos = [item.get("endereco_referencia"), ", ".join(item.get("bairros") or [])]
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
+
927
+ checks_informados = [check for check in checks if _is_provided(check.get("informado"))]
928
+ aceito = all(check.get("aceito") for check in checks_informados) if checks_informados else True
929
+
930
+ item["avaliando"] = {
931
+ "aceito": bool(aceito),
932
+ "checks": checks,
933
+ "motivos_rejeicao": rejeicoes,
934
+ "campos_informados": len(checks_informados),
935
+ }
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 {
956
+ "nomes_modelo": _lista_textos_unicos(nomes, limite),
957
+ "autores": _lista_textos_unicos(autores, limite),
958
+ "finalidades": _lista_textos_unicos(finalidades, limite),
959
+ "bairros": _lista_textos_unicos(bairros, limite),
960
+ "enderecos": _lista_textos_unicos(enderecos, limite),
961
+ }
962
+
963
+
964
+ def _modelo_publico(modelo: dict[str, Any]) -> dict[str, Any]:
965
+ return {chave: valor for chave, valor in modelo.items() if not str(chave).startswith("_")}
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()
1014
+ for value in values:
1015
+ text = str(value).strip()
1016
+ if not text or text in seen:
1017
+ continue
1018
+ seen.add(text)
1019
+ out.append(text)
1020
+ return out
1021
+
1022
+
1023
+ def _aceita_texto_com_colunas(modelo: dict[str, Any], consulta: str, campo: str, fontes_selecionadas: list[str] | None) -> bool:
1024
+ fontes = _resolver_fontes_campo(modelo, campo, fontes_selecionadas)
1025
+ candidatos = _valores_para_fontes(modelo, fontes)
1026
+ return _contains_any(candidatos, consulta)
1027
+
1028
+
1029
+ def _aceita_range_com_colunas(
1030
+ modelo: dict[str, Any],
1031
+ campo: str,
1032
+ fontes_selecionadas: list[str] | None,
1033
+ filtro_min: Any,
1034
+ filtro_max: Any,
1035
+ ) -> bool:
1036
+ if filtro_min is None and filtro_max is None:
1037
+ return True
1038
+ faixas = _faixas_para_campo(modelo, campo, fontes_selecionadas)
1039
+ if not faixas:
1040
+ return False
1041
+ return any(_range_overlaps(faixa, filtro_min, filtro_max) for faixa in faixas)
1042
+
1043
+
1044
+ def _aceita_valor_com_colunas(
1045
+ modelo: dict[str, Any],
1046
+ campo: str,
1047
+ fontes_selecionadas: list[str] | None,
1048
+ valor: Any,
1049
+ ) -> bool:
1050
+ if not _is_provided(valor):
1051
+ return True
1052
+ return _aceita_range_com_colunas(modelo, campo, fontes_selecionadas, valor, valor)
1053
+
1054
+
1055
+ def _faixa_resumo_com_colunas(modelo: dict[str, Any], campo: str, fontes_selecionadas: list[str] | None) -> dict[str, Any] | None:
1056
+ faixas = _faixas_para_campo(modelo, campo, fontes_selecionadas)
1057
+ return _combinar_faixas(faixas)
1058
+
1059
+
1060
+ def _faixas_para_campo(modelo: dict[str, Any], campo: str, fontes_selecionadas: list[str] | None) -> list[dict[str, Any]]:
1061
+ fontes = _resolver_fontes_faixa(modelo, campo, fontes_selecionadas)
1062
+ return _faixas_para_fontes(modelo, fontes)
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]:
1098
+ candidatos: list[str] = []
1099
+ indice_colunas = modelo.get("_texto_colunas_index") or {}
1100
+ if not isinstance(indice_colunas, dict):
1101
+ indice_colunas = {}
1102
+
1103
+ for fonte in fontes:
1104
+ if fonte.startswith("meta:"):
1105
+ candidatos.extend(_valores_meta(modelo, fonte))
1106
+ continue
1107
+ if fonte.startswith("col:"):
1108
+ col = fonte[4:]
1109
+ valores = indice_colunas.get(col) or []
1110
+ candidatos.extend([str(item) for item in valores if _str_or_none(item)])
1111
+
1112
+ return candidatos
1113
+
1114
+
1115
+ def _valores_meta(modelo: dict[str, Any], fonte: str) -> list[str]:
1116
+ if fonte == "meta:nome_modelo":
1117
+ return [str(modelo.get("nome_modelo") or "")]
1118
+ if fonte == "meta:arquivo":
1119
+ return [str(modelo.get("arquivo") or "")]
1120
+ if fonte == "meta:autor":
1121
+ return [str(modelo.get("autor") or "")]
1122
+ if fonte == "meta:finalidade":
1123
+ return [str(modelo.get("finalidade") or "")]
1124
+ if fonte == "meta:tipo_imovel":
1125
+ return [str(modelo.get("tipo_imovel") or "")]
1126
+ if fonte == "meta:finalidades":
1127
+ return [str(item) for item in (modelo.get("finalidades") or [])]
1128
+ if fonte == "meta:bairros":
1129
+ return [str(item) for item in (modelo.get("bairros") or [])]
1130
+ if fonte == "meta:endereco_referencia":
1131
+ return [str(modelo.get("endereco_referencia") or "")]
1132
+ return []
1133
+
1134
+
1135
+ def _faixas_para_fontes(modelo: dict[str, Any], fontes: list[str]) -> list[dict[str, Any]]:
1136
+ candidatos: list[dict[str, Any]] = []
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
1143
+ if fonte.startswith("meta:"):
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")):
1150
+ continue
1151
+ candidatos.append(faixa)
1152
+
1153
+ return candidatos
1154
+
1155
+
1156
+ def _faixa_meta(modelo: dict[str, Any], fonte: str) -> dict[str, Any] | None:
1157
+ if fonte == "meta:faixa_data":
1158
+ return modelo.get("faixa_data")
1159
+ if fonte == "meta:faixa_area":
1160
+ return modelo.get("faixa_area")
1161
+ if fonte == "meta:faixa_rh":
1162
+ return modelo.get("faixa_rh")
1163
+
1164
+ faixas_por_campo = modelo.get("faixas_por_campo") or {}
1165
+ if not isinstance(faixas_por_campo, dict):
1166
+ faixas_por_campo = {}
1167
+
1168
+ if fonte == "meta:faixa_area_privativa":
1169
+ return faixas_por_campo.get("area_privativa")
1170
+ if fonte == "meta:faixa_area_total":
1171
+ return faixas_por_campo.get("area_total")
1172
+ if fonte == "meta:faixa_valor_unitario":
1173
+ return faixas_por_campo.get("valor_unitario")
1174
+ if fonte == "meta:faixa_valor_total":
1175
+ return faixas_por_campo.get("valor_total")
1176
+ return None
1177
+
1178
+
1179
+ def _combinar_faixas(faixas: list[dict[str, Any]]) -> dict[str, Any] | None:
1180
+ kind: str | None = None
1181
+ min_vals: list[Any] = []
1182
+ max_vals: list[Any] = []
1183
+
1184
+ for faixa in faixas:
1185
+ cmp_min = _to_comparable(faixa.get("min"))
1186
+ cmp_max = _to_comparable(faixa.get("max"))
1187
+ faixa_kind = cmp_min[0] if cmp_min is not None else (cmp_max[0] if cmp_max is not None else None)
1188
+ if faixa_kind is None:
1189
+ continue
1190
+ if kind is None:
1191
+ kind = faixa_kind
1192
+ if faixa_kind != kind:
1193
+ continue
1194
+ if cmp_min is not None:
1195
+ min_vals.append(cmp_min[1])
1196
+ if cmp_max is not None:
1197
+ max_vals.append(cmp_max[1])
1198
+
1199
+ if not min_vals and not max_vals:
1200
+ return None
1201
+
1202
+ minimo = min(min_vals) if min_vals else None
1203
+ maximo = max(max_vals) if max_vals else None
1204
+ return {
1205
+ "min": _formatar_limite_faixa(minimo),
1206
+ "max": _formatar_limite_faixa(maximo),
1207
+ }
1208
+
1209
+
1210
+ def _formatar_limite_faixa(valor: Any) -> Any:
1211
+ if valor is None:
1212
+ return None
1213
+ if isinstance(valor, datetime):
1214
+ return valor.date().isoformat()
1215
+ return sanitize_value(valor)
1216
+
1217
+
1218
+ def _indexar_texto_colunas(df_modelo: pd.DataFrame | None) -> dict[str, list[str]]:
1219
+ if df_modelo is None or df_modelo.empty:
1220
+ return {}
1221
+
1222
+ indice: dict[str, list[str]] = {}
1223
+ colunas = [str(col) for col in df_modelo.columns[:MAX_COLUNAS_INDEXADAS]]
1224
+ base = df_modelo[colunas].head(MAX_LINHAS_INDEXACAO)
1225
+
1226
+ for coluna in colunas:
1227
+ serie = base[coluna]
1228
+ valores: list[str] = []
1229
+ vistos = set()
1230
+ for valor in serie.dropna().tolist():
1231
+ texto = _str_or_none(valor)
1232
+ if texto is None and isinstance(valor, (int, float)):
1233
+ texto = str(valor)
1234
+ if texto is None:
1235
+ continue
1236
+ chave = _normalize(texto)
1237
+ if not chave or chave in vistos:
1238
+ continue
1239
+ vistos.add(chave)
1240
+ valores.append(texto)
1241
+ if len(valores) >= MAX_VALORES_INDEXADOS_POR_COLUNA:
1242
+ break
1243
+ if valores:
1244
+ indice[coluna] = valores
1245
+
1246
+ return indice
1247
+
1248
+
1249
+ def _indexar_faixas_colunas(df_modelo: pd.DataFrame | None) -> dict[str, dict[str, Any]]:
1250
+ if df_modelo is None or df_modelo.empty:
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]:
1291
+ unicos: list[str] = []
1292
+ vistos = set()
1293
+ for valor in valores:
1294
+ texto = _str_or_none(valor)
1295
+ if not texto:
1296
+ continue
1297
+ chave = _normalize(texto)
1298
+ if not chave or chave in vistos:
1299
+ continue
1300
+ vistos.add(chave)
1301
+ unicos.append(texto)
1302
+ if len(unicos) >= limite:
1303
+ break
1304
+ return sorted(unicos, key=lambda item: item.lower())
1305
+
1306
+
1307
+ def _aceita_valor_na_faixa(faixa: dict[str, Any] | None, valor: Any) -> bool:
1308
+ if not _is_provided(valor):
1309
+ return True
1310
+ return _range_overlaps(faixa, valor, valor)
1311
+
1312
+
1313
+ def formatar_faixa(faixa: dict[str, Any] | None) -> str:
1314
+ if not faixa:
1315
+ return "nao disponivel"
1316
+ minimo = faixa.get("min")
1317
+ maximo = faixa.get("max")
1318
+ if _is_empty(minimo) and _is_empty(maximo):
1319
+ return "nao disponivel"
1320
+ if not _is_empty(minimo) and not _is_empty(maximo):
1321
+ return f"{minimo} a {maximo}"
1322
+ if not _is_empty(minimo):
1323
+ return f"a partir de {minimo}"
1324
+ return f"ate {maximo}"
1325
+
1326
+
1327
+ def _extrair_termos_bairro(filtros: PesquisaFiltros) -> list[str]:
1328
+ termos: list[str] = []
1329
+ if filtros.bairro:
1330
+ termos.extend(_split_terms(filtros.bairro))
1331
+ if filtros.bairros:
1332
+ for entrada in filtros.bairros:
1333
+ termos.extend(_split_terms(entrada))
1334
+
1335
+ limpos = []
1336
+ vistos = set()
1337
+ for termo in termos:
1338
+ chave = _normalize(termo)
1339
+ if not chave or chave in vistos:
1340
+ continue
1341
+ vistos.add(chave)
1342
+ limpos.append(termo)
1343
+ return limpos
1344
+
1345
+
1346
+ def _split_terms(texto: str) -> list[str]:
1347
+ if not texto:
1348
+ return []
1349
+ partes = re.split(r"[,;|]", texto)
1350
+ return [parte.strip() for parte in partes if parte.strip()]
1351
+
1352
+
1353
+ def _range_overlaps(model_range: dict[str, Any] | None, filtro_min: Any, filtro_max: Any) -> bool:
1354
+ if filtro_min is None and filtro_max is None:
1355
+ return True
1356
+
1357
+ if not model_range:
1358
+ return False
1359
+
1360
+ model_min_cmp = _to_comparable(model_range.get("min"))
1361
+ model_max_cmp = _to_comparable(model_range.get("max"))
1362
+ filtro_min_cmp = _to_comparable(filtro_min) if filtro_min is not None else None
1363
+ filtro_max_cmp = _to_comparable(filtro_max) if filtro_max is not None else None
1364
+
1365
+ if filtro_min_cmp is None and filtro_max_cmp is None:
1366
+ return True
1367
+
1368
+ kinds = {
1369
+ item[0]
1370
+ for item in [model_min_cmp, model_max_cmp, filtro_min_cmp, filtro_max_cmp]
1371
+ if item is not None
1372
+ }
1373
+
1374
+ if len(kinds) != 1:
1375
+ return False
1376
+
1377
+ model_min_val = model_min_cmp[1] if model_min_cmp is not None else None
1378
+ model_max_val = model_max_cmp[1] if model_max_cmp is not None else None
1379
+ filtro_min_val = filtro_min_cmp[1] if filtro_min_cmp is not None else None
1380
+ filtro_max_val = filtro_max_cmp[1] if filtro_max_cmp is not None else None
1381
+
1382
+ if model_min_val is None and model_max_val is None:
1383
+ return False
1384
+
1385
+ if model_min_val is None:
1386
+ model_min_val = model_max_val
1387
+ if model_max_val is None:
1388
+ model_max_val = model_min_val
1389
+
1390
+ if filtro_min_val is not None and model_max_val < filtro_min_val:
1391
+ return False
1392
+
1393
+ if filtro_max_val is not None and model_min_val > filtro_max_val:
1394
+ return False
1395
+
1396
+ return True
1397
+
1398
+
1399
+ def _to_comparable(value: Any) -> tuple[str, Any] | None:
1400
+ if value is None:
1401
+ return None
1402
+
1403
+ if isinstance(value, (int, float)):
1404
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
1405
+ return None
1406
+ return ("num", float(value))
1407
+
1408
+ if isinstance(value, pd.Timestamp):
1409
+ return ("dt", value.to_pydatetime())
1410
+
1411
+ if isinstance(value, datetime):
1412
+ return ("dt", value)
1413
+
1414
+ if isinstance(value, date):
1415
+ return ("dt", datetime(value.year, value.month, value.day))
1416
+
1417
+ texto = str(value).strip()
1418
+ if not texto:
1419
+ return None
1420
+
1421
+ numero = _to_float_or_none(texto)
1422
+ if numero is not None:
1423
+ return ("num", float(numero))
1424
+
1425
+ data_val = _parse_datetime(texto)
1426
+ if data_val is not None:
1427
+ return ("dt", data_val)
1428
+
1429
+ return None
1430
+
1431
+
1432
+ def _parse_datetime(texto: str) -> datetime | None:
1433
+ for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d", "%Y%m%d", "%Y"):
1434
+ try:
1435
+ return datetime.strptime(texto, fmt)
1436
+ except Exception:
1437
+ continue
1438
+ return None
1439
+
1440
+
1441
+ def _contains_any(candidatos: list[Any], consulta: str) -> bool:
1442
+ alvo = _normalize(consulta)
1443
+ if not alvo:
1444
+ return True
1445
+ for item in candidatos:
1446
+ if item is None:
1447
+ continue
1448
+ if alvo in _normalize(str(item)):
1449
+ return True
1450
+ return False
1451
+
1452
+
1453
+ def _merge_ranges(preferencial: dict[str, Any] | None, fallback: dict[str, Any] | None) -> dict[str, Any] | None:
1454
+ if not preferencial and not fallback:
1455
+ return None
1456
+ if not preferencial:
1457
+ return fallback
1458
+ if not fallback:
1459
+ return preferencial
1460
+
1461
+ min_final = preferencial.get("min") if not _is_empty(preferencial.get("min")) else fallback.get("min")
1462
+ max_final = preferencial.get("max") if not _is_empty(preferencial.get("max")) else fallback.get("max")
1463
+
1464
+ if _is_empty(min_final) and _is_empty(max_final):
1465
+ return None
1466
+
1467
+ return {"min": sanitize_value(min_final), "max": sanitize_value(max_final)}
1468
+
1469
+
1470
+ def _buscar_coluna(colunas: Any, aliases: list[str]) -> Any:
1471
+ for coluna in colunas:
1472
+ if _has_alias(str(coluna), aliases):
1473
+ return coluna
1474
+ return None
1475
+
1476
+
1477
+ def _has_alias(nome: str, aliases: list[str]) -> bool:
1478
+ nome_norm = _normalize(nome)
1479
+ if not nome_norm:
1480
+ return False
1481
+
1482
+ for alias in aliases:
1483
+ alias_norm = _normalize(alias)
1484
+ if alias_norm == nome_norm or alias_norm in nome_norm:
1485
+ return True
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()
1492
+ return re.sub(r"[^a-z0-9]+", "", ascii_text)
1493
+
1494
+
1495
+ def _min_value(values: list[Any]) -> Any:
1496
+ comparaveis = [_to_comparable(v) for v in values]
1497
+ comparaveis = [c for c in comparaveis if c is not None]
1498
+ if not comparaveis:
1499
+ return sanitize_value(values[0]) if values else None
1500
+ kind = comparaveis[0][0]
1501
+ valores = [c[1] for c in comparaveis if c[0] == kind]
1502
+ menor = min(valores)
1503
+ if isinstance(menor, datetime):
1504
+ return menor.date().isoformat()
1505
+ return sanitize_value(menor)
1506
+
1507
+
1508
+ def _max_value(values: list[Any]) -> Any:
1509
+ comparaveis = [_to_comparable(v) for v in values]
1510
+ comparaveis = [c for c in comparaveis if c is not None]
1511
+ if not comparaveis:
1512
+ return sanitize_value(values[0]) if values else None
1513
+ kind = comparaveis[0][0]
1514
+ valores = [c[1] for c in comparaveis if c[0] == kind]
1515
+ maior = max(valores)
1516
+ if isinstance(maior, datetime):
1517
+ return maior.date().isoformat()
1518
+ return sanitize_value(maior)
1519
+
1520
+
1521
+ def _inferir_finalidade_por_nome(nome_arquivo: str) -> str | None:
1522
+ nome_upper = nome_arquivo.upper()
1523
+ if re.search(r"(^|_)A(_|$)", nome_upper) or "ALUG" in nome_upper:
1524
+ return "Aluguel"
1525
+ if re.search(r"(^|_)V(_|$)", nome_upper) or "VENDA" in nome_upper:
1526
+ return "Venda"
1527
+ return None
1528
+
1529
+
1530
+ def _inferir_tipo_por_nome(nome_arquivo: str) -> str | None:
1531
+ nome_upper = nome_arquivo.upper()
1532
+ tokens_ordenados = sorted(TIPO_POR_TOKEN.items(), key=lambda item: len(item[0]), reverse=True)
1533
+ for token, tipo in tokens_ordenados:
1534
+ if _contains_tipo_token(nome_upper, token):
1535
+ return tipo
1536
+ return None
1537
+
1538
+
1539
+ def _contains_tipo_token(nome_upper: str, token: str) -> bool:
1540
+ padrao = rf"(^|[^A-Z]){re.escape(token)}([^A-Z]|$)"
1541
+ return re.search(padrao, nome_upper) is not None
1542
+
1543
+
1544
+ def _to_float_or_none(value: Any) -> float | None:
1545
+ if value is None:
1546
+ return None
1547
+
1548
+ if isinstance(value, bool):
1549
+ return None
1550
+
1551
+ if isinstance(value, (int, float)):
1552
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
1553
+ return None
1554
+ return float(value)
1555
+
1556
+ texto = str(value).strip()
1557
+ if not texto:
1558
+ return None
1559
+
1560
+ if "," in texto and "." in texto:
1561
+ if texto.rfind(",") > texto.rfind("."):
1562
+ texto = texto.replace(".", "").replace(",", ".")
1563
+ else:
1564
+ texto = texto.replace(",", "")
1565
+ else:
1566
+ texto = texto.replace(",", ".")
1567
+
1568
+ try:
1569
+ return float(texto)
1570
+ except Exception:
1571
+ return None
1572
+
1573
+
1574
+ def _str_or_none(value: Any) -> str | None:
1575
+ if value is None:
1576
+ return None
1577
+ texto = str(value).strip()
1578
+ return texto or None
1579
+
1580
+
1581
+ def _is_empty(value: Any) -> bool:
1582
+ if value is None:
1583
+ return True
1584
+ if isinstance(value, str) and not value.strip():
1585
+ return True
1586
+ try:
1587
+ if pd.isna(value):
1588
+ return True
1589
+ except Exception:
1590
+ pass
1591
+ return False
1592
+
1593
+
1594
+ def _is_provided(value: Any) -> bool:
1595
+ if value is None:
1596
+ return False
1597
+ if isinstance(value, str):
1598
+ return bool(value.strip())
1599
+ return True
frontend/src/App.jsx CHANGED
@@ -1,10 +1,11 @@
1
  import React, { useEffect, useState } from 'react'
2
  import { api } from './api'
3
  import ElaboracaoTab from './components/ElaboracaoTab'
 
4
  import VisualizacaoTab from './components/VisualizacaoTab'
5
 
6
  const TABS = [
7
- { key: 'Pesquisa', label: 'Pesquisa', hint: 'Módulo em desenvolvimento' },
8
  { key: 'Elaboração/Edição', label: 'Elaboração/Edição', hint: 'Fluxo completo de modelagem' },
9
  { key: 'Visualização/Avaliação', label: 'Visualização/Avaliação', hint: 'Leitura e avaliação de modelos .dai' },
10
  ]
@@ -58,18 +59,9 @@ export default function App() {
58
  {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
59
 
60
  {activeTab === 'Pesquisa' ? (
61
- <section className="workflow-section placeholder-section" style={{ '--section-order': 1 }}>
62
- <header className="section-head">
63
- <span className="section-index">1</span>
64
- <div className="section-title-wrap">
65
- <h3>Pesquisa</h3>
66
- <p>Este módulo segue em desenvolvimento no app original e foi mantido com o mesmo status.</p>
67
- </div>
68
- </header>
69
- <div className="section-body">
70
- <div className="empty-box">Aba disponível para expansão futura.</div>
71
- </div>
72
- </section>
73
  ) : null}
74
 
75
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
 
1
  import React, { useEffect, useState } from 'react'
2
  import { api } from './api'
3
  import ElaboracaoTab from './components/ElaboracaoTab'
4
+ import PesquisaTab from './components/PesquisaTab'
5
  import VisualizacaoTab from './components/VisualizacaoTab'
6
 
7
  const TABS = [
8
+ { key: 'Pesquisa', label: 'Pesquisa', hint: 'Busca inicial de modelos .dai' },
9
  { key: 'Elaboração/Edição', label: 'Elaboração/Edição', hint: 'Fluxo completo de modelagem' },
10
  { key: 'Visualização/Avaliação', label: 'Visualização/Avaliação', hint: 'Leitura e avaliação de modelos .dai' },
11
  ]
 
59
  {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
60
 
61
  {activeTab === 'Pesquisa' ? (
62
+ <div className="tab-pane">
63
+ <PesquisaTab />
64
+ </div>
 
 
 
 
 
 
 
 
 
65
  ) : null}
66
 
67
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
frontend/src/api.js CHANGED
@@ -59,6 +59,21 @@ export function downloadBlob(blob, fileName) {
59
  export const api = {
60
  createSession: () => postJson('/api/sessions', {}),
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  uploadElaboracaoFile(sessionId, file) {
63
  const form = new FormData()
64
  form.append('session_id', sessionId)
 
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]) => {
65
+ if (value === null || value === undefined) return
66
+ const text = String(value).trim()
67
+ if (!text) return
68
+ params.append(key, text)
69
+ })
70
+ const query = params.toString()
71
+ return getJson(query ? `/api/pesquisa/modelos?${query}` : '/api/pesquisa/modelos')
72
+ },
73
+ pesquisarMapaModelos(modelosIds = []) {
74
+ return postJson('/api/pesquisa/mapa-modelos', { modelos_ids: modelosIds })
75
+ },
76
+
77
  uploadElaboracaoFile(sessionId, file) {
78
  const form = new FormData()
79
  form.append('session_id', sessionId)
frontend/src/components/PesquisaTab.jsx ADDED
@@ -0,0 +1,1113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: '',
13
+ dataMax: '',
14
+ areaMin: '',
15
+ areaMax: '',
16
+ rhMin: '',
17
+ rhMax: '',
18
+ avalFinalidade: '',
19
+ avalBairro: '',
20
+ avalData: '',
21
+ avalArea: '',
22
+ avalAreaPrivativa: '',
23
+ avalAreaTotal: '',
24
+ avalRh: '',
25
+ avalValorUnitario: '',
26
+ avalValorTotal: '',
27
+ }
28
+
29
+ const RESULT_INITIAL = {
30
+ modelos: [],
31
+ sugestoes: {},
32
+ total_filtrado: 0,
33
+ total_geral: 0,
34
+ }
35
+
36
+ const CAMPOS_COLUNAS_FILTRO = [
37
+ 'finalidade',
38
+ 'bairros',
39
+ 'data',
40
+ 'area',
41
+ 'rh',
42
+ 'aval_finalidade',
43
+ 'aval_bairro',
44
+ 'aval_data',
45
+ 'aval_area',
46
+ 'aval_area_privativa',
47
+ 'aval_area_total',
48
+ 'aval_rh',
49
+ 'aval_valor_unitario',
50
+ 'aval_valor_total',
51
+ ]
52
+
53
+ const COLUNAS_FILTRO_INITIAL = {
54
+ finalidade: [],
55
+ bairros: [],
56
+ data: [],
57
+ area: [],
58
+ rh: [],
59
+ aval_finalidade: [],
60
+ aval_bairro: [],
61
+ aval_data: [],
62
+ aval_area: [],
63
+ aval_area_privativa: [],
64
+ aval_area_total: [],
65
+ aval_rh: [],
66
+ aval_valor_unitario: [],
67
+ aval_valor_total: [],
68
+ }
69
+
70
+ const TIPO_SIGLAS = {
71
+ RECOND: 'Residencia em condominio',
72
+ RCOMD: 'Residencia em condominio',
73
+ TCOND: 'Terreno em condominio',
74
+ SALA: 'Salas comerciais',
75
+ APTO: 'Apartamentos residenciais',
76
+ APART: 'Apartamentos residenciais',
77
+ AP: 'Apartamentos residenciais',
78
+ TERRENO: 'Terrenos',
79
+ TER: 'Terrenos',
80
+ EDIF: 'Edificio',
81
+ RES: 'Residencias isoladas / casas',
82
+ CASA: 'Residencias isoladas / casas',
83
+ LOJA: 'Loja',
84
+ LCOM: 'Loja',
85
+ DEP: 'Deposito',
86
+ DEPOS: 'Deposito',
87
+ CCOM: 'Casa comercial',
88
+ }
89
+
90
+ function formatRange(range) {
91
+ if (!range) return '-'
92
+ const min = range.min ?? null
93
+ const max = range.max ?? null
94
+ if (min === null && max === null) return '-'
95
+ if (min !== null && max !== null) return `${min} a ${max}`
96
+ if (min !== null) return `a partir de ${min}`
97
+ return `ate ${max}`
98
+ }
99
+
100
+ function formatCount(value) {
101
+ if (value === null || value === undefined || value === '') return '-'
102
+ if (typeof value === 'number') {
103
+ return new Intl.NumberFormat('pt-BR').format(value)
104
+ }
105
+ return String(value)
106
+ }
107
+
108
+ function normalizeTokenText(value) {
109
+ return String(value || '')
110
+ .normalize('NFD')
111
+ .replace(/[\u0300-\u036f]/g, '')
112
+ .toUpperCase()
113
+ }
114
+
115
+ function inferTipoPorNomeModelo(...nomes) {
116
+ const tokens = Object.keys(TIPO_SIGLAS).sort((a, b) => b.length - a.length)
117
+ for (const nome of nomes) {
118
+ const source = normalizeTokenText(nome)
119
+ if (!source) continue
120
+ for (const token of tokens) {
121
+ const re = new RegExp(`(^|[^A-Z])${token}([^A-Z]|$)`)
122
+ if (re.test(source)) {
123
+ return TIPO_SIGLAS[token]
124
+ }
125
+ }
126
+ }
127
+ return ''
128
+ }
129
+
130
+ function formatTipoImovel(modelo) {
131
+ const tipoPorNome = inferTipoPorNomeModelo(modelo?.nome_modelo, modelo?.arquivo)
132
+ if (tipoPorNome) return tipoPorNome
133
+
134
+ const text = String(modelo?.tipo_imovel || '').trim()
135
+ if (!text) return '-'
136
+
137
+ const mapped = TIPO_SIGLAS[normalizeTokenText(text)]
138
+ return mapped || text
139
+ }
140
+
141
+ function normalizeColunasConfig(rawConfig = {}) {
142
+ const out = {}
143
+ CAMPOS_COLUNAS_FILTRO.forEach((campo) => {
144
+ const config = rawConfig?.[campo] || {}
145
+ const disponiveis = []
146
+ const vistos = new Set()
147
+ ;(Array.isArray(config.disponiveis) ? config.disponiveis : []).forEach((item) => {
148
+ const id = typeof item === 'string' ? item : item?.id
149
+ const label = typeof item === 'string' ? item : item?.label || item?.id
150
+ const idText = String(id || '').trim()
151
+ if (!idText || vistos.has(idText)) return
152
+ vistos.add(idText)
153
+ disponiveis.push({ id: idText, label: String(label || idText) })
154
+ })
155
+
156
+ const padrao = []
157
+ ;(Array.isArray(config.padrao) ? config.padrao : []).forEach((item) => {
158
+ const idText = String(item || '').trim()
159
+ if (!idText || !vistos.has(idText) || padrao.includes(idText)) return
160
+ padrao.push(idText)
161
+ })
162
+
163
+ out[campo] = { disponiveis, padrao }
164
+ })
165
+ return out
166
+ }
167
+
168
+ function reconciliarColunasSelecionadas(atual, configNormalizada, camposEditados = {}) {
169
+ const next = { ...COLUNAS_FILTRO_INITIAL, ...atual }
170
+ CAMPOS_COLUNAS_FILTRO.forEach((campo) => {
171
+ const configCampo = configNormalizada[campo] || { disponiveis: [], padrao: [] }
172
+ const idsDisponiveis = new Set((configCampo.disponiveis || []).map((item) => item.id))
173
+ const selecionadasValidas = (next[campo] || []).filter((id) => idsDisponiveis.has(id))
174
+ if (camposEditados[campo]) {
175
+ next[campo] = selecionadasValidas
176
+ return
177
+ }
178
+ const padraoValido = (configCampo.padrao || []).filter((id) => idsDisponiveis.has(id))
179
+ next[campo] = padraoValido.length ? padraoValido : selecionadasValidas
180
+ })
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
+
209
+ return {
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
+ }
228
+
229
+ function toInputName(field) {
230
+ let hash = 0
231
+ for (let i = 0; i < field.length; i += 1) {
232
+ hash = (hash * 31 + field.charCodeAt(i)) % 1000000007
233
+ }
234
+ return `mesa_${Math.abs(hash)}`
235
+ }
236
+
237
+ function TextFieldInput({ field, ...props }) {
238
+ return (
239
+ <input
240
+ {...props}
241
+ data-field={field}
242
+ name={toInputName(field)}
243
+ autoComplete="off"
244
+ autoCorrect="off"
245
+ autoCapitalize="none"
246
+ spellCheck={false}
247
+ />
248
+ )
249
+ }
250
+
251
+ function NumberFieldInput({ field, ...props }) {
252
+ return (
253
+ <input
254
+ {...props}
255
+ type="number"
256
+ step="any"
257
+ inputMode="decimal"
258
+ data-field={field}
259
+ name={toInputName(field)}
260
+ autoComplete="off"
261
+ />
262
+ )
263
+ }
264
+
265
+ function DynamicFilterField({
266
+ label,
267
+ campoValor,
268
+ campoColunas,
269
+ configCampo,
270
+ selecionadas,
271
+ onAddColuna,
272
+ onRemoveColuna,
273
+ value,
274
+ onChange,
275
+ list,
276
+ placeholder,
277
+ inputKind = 'text',
278
+ }) {
279
+ const disponiveis = configCampo?.disponiveis || []
280
+ const selectedSet = new Set(selecionadas || [])
281
+ const opcoesAdicionar = disponiveis.filter((item) => !selectedSet.has(item.id))
282
+ const InputComponent = inputKind === 'number' ? NumberFieldInput : TextFieldInput
283
+
284
+ function findLabel(id) {
285
+ const match = disponiveis.find((item) => item.id === id)
286
+ return match?.label || id
287
+ }
288
+
289
+ return (
290
+ <label className="pesquisa-field pesquisa-field-wide">
291
+ {label ? <span>{label}</span> : null}
292
+ <div className="pesquisa-dynamic-filter-row">
293
+ <div className="pesquisa-colunas-box">
294
+ <div className="pesquisa-colunas-chip-list">
295
+ {(selecionadas || []).map((id) => (
296
+ <span key={`${campoColunas}-${id}`} className="pesquisa-coluna-chip">
297
+ <span>{findLabel(id)}</span>
298
+ <button type="button" className="pesquisa-coluna-remove" onClick={() => onRemoveColuna(campoColunas, id)} aria-label={`Remover coluna ${findLabel(id)}`}>
299
+ x
300
+ </button>
301
+ </span>
302
+ ))}
303
+ {!(selecionadas || []).length ? <span className="pesquisa-colunas-empty">Nenhuma coluna selecionada.</span> : null}
304
+ </div>
305
+ </div>
306
+
307
+ <select
308
+ className="pesquisa-colunas-add"
309
+ defaultValue=""
310
+ onChange={(event) => {
311
+ const selected = String(event.target.value || '').trim()
312
+ if (!selected) return
313
+ onAddColuna(campoColunas, selected)
314
+ event.target.value = ''
315
+ }}
316
+ >
317
+ <option value="">Adicionar coluna...</option>
318
+ {opcoesAdicionar.map((item) => (
319
+ <option key={`${campoColunas}-opt-${item.id}`} value={item.id}>{item.label}</option>
320
+ ))}
321
+ </select>
322
+
323
+ <InputComponent
324
+ list={list}
325
+ field={campoValor}
326
+ value={value}
327
+ onChange={onChange}
328
+ placeholder={placeholder}
329
+ />
330
+ </div>
331
+ </label>
332
+ )
333
+ }
334
+
335
+ function DynamicRangeFilterField({
336
+ label,
337
+ campoColunas,
338
+ configCampo,
339
+ selecionadas,
340
+ onAddColuna,
341
+ onRemoveColuna,
342
+ minLabel,
343
+ minField,
344
+ minValue,
345
+ maxLabel,
346
+ maxField,
347
+ maxValue,
348
+ onChange,
349
+ minPlaceholder,
350
+ maxPlaceholder,
351
+ inputKind = 'number',
352
+ }) {
353
+ const disponiveis = configCampo?.disponiveis || []
354
+ const selectedSet = new Set(selecionadas || [])
355
+ const opcoesAdicionar = disponiveis.filter((item) => !selectedSet.has(item.id))
356
+ const InputComponent = inputKind === 'number' ? NumberFieldInput : TextFieldInput
357
+
358
+ function findLabel(id) {
359
+ const match = disponiveis.find((item) => item.id === id)
360
+ return match?.label || id
361
+ }
362
+
363
+ return (
364
+ <div className="pesquisa-field pesquisa-field-wide">
365
+ {label ? <span>{label}</span> : null}
366
+ <div className="pesquisa-dynamic-filter-row pesquisa-dynamic-filter-row-range">
367
+ <div className="pesquisa-colunas-box">
368
+ <div className="pesquisa-colunas-chip-list">
369
+ {(selecionadas || []).map((id) => (
370
+ <span key={`${campoColunas}-${id}`} className="pesquisa-coluna-chip">
371
+ <span>{findLabel(id)}</span>
372
+ <button type="button" className="pesquisa-coluna-remove" onClick={() => onRemoveColuna(campoColunas, id)} aria-label={`Remover coluna ${findLabel(id)}`}>
373
+ x
374
+ </button>
375
+ </span>
376
+ ))}
377
+ {!(selecionadas || []).length ? <span className="pesquisa-colunas-empty">Nenhuma coluna selecionada.</span> : null}
378
+ </div>
379
+ </div>
380
+
381
+ <select
382
+ className="pesquisa-colunas-add"
383
+ defaultValue=""
384
+ onChange={(event) => {
385
+ const selected = String(event.target.value || '').trim()
386
+ if (!selected) return
387
+ onAddColuna(campoColunas, selected)
388
+ event.target.value = ''
389
+ }}
390
+ >
391
+ <option value="">Adicionar coluna...</option>
392
+ {opcoesAdicionar.map((item) => (
393
+ <option key={`${campoColunas}-opt-${item.id}`} value={item.id}>{item.label}</option>
394
+ ))}
395
+ </select>
396
+ </div>
397
+
398
+ <div className="pesquisa-range-values-row">
399
+ <label className="pesquisa-field">
400
+ {minLabel}
401
+ <InputComponent field={minField} value={minValue} onChange={onChange} placeholder={minPlaceholder} />
402
+ </label>
403
+ <label className="pesquisa-field">
404
+ {maxLabel}
405
+ <InputComponent field={maxField} value={maxValue} onChange={onChange} placeholder={maxPlaceholder} />
406
+ </label>
407
+ </div>
408
+ </div>
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)
430
+
431
+ const [selectedIds, setSelectedIds] = useState([])
432
+ const [detailModelId, setDetailModelId] = useState('')
433
+ const selectAllRef = useRef(null)
434
+ const [colunasConfig, setColunasConfig] = useState({})
435
+ const [colunasFiltro, setColunasFiltro] = useState(COLUNAS_FILTRO_INITIAL)
436
+ const [colunasEditadas, setColunasEditadas] = useState({})
437
+
438
+ const [mapaLoading, setMapaLoading] = useState(false)
439
+ const [mapaError, setMapaError] = useState('')
440
+ const [mapaStatus, setMapaStatus] = useState('')
441
+ const [mapaHtml, setMapaHtml] = useState('')
442
+ const [mapaLegendas, setMapaLegendas] = useState([])
443
+
444
+ const usandoOticaAvaliando = filters.otica === 'avaliando'
445
+ const sugestoes = result.sugestoes || {}
446
+ const resultIds = useMemo(() => (result.modelos || []).map((modelo) => modelo.id), [result.modelos])
447
+ const detalheModelo = useMemo(() => (result.modelos || []).find((modelo) => modelo.id === detailModelId) || null, [result.modelos, detailModelId])
448
+ const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
449
+ const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
450
+
451
+ async function buscarModelos(nextFilters = filters, nextColunasFiltro = colunasFiltro, nextColunasEditadas = colunasEditadas) {
452
+ setLoading(true)
453
+ setError('')
454
+ try {
455
+ const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextColunasFiltro))
456
+ const modelos = response.modelos || []
457
+ const idsNovos = new Set(modelos.map((item) => item.id))
458
+ const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
459
+
460
+ setResult({
461
+ ...RESULT_INITIAL,
462
+ ...response,
463
+ modelos,
464
+ sugestoes: response.sugestoes || {},
465
+ })
466
+ setColunasConfig(configNormalizada)
467
+ setColunasFiltro((current) => reconciliarColunasSelecionadas(current, configNormalizada, nextColunasEditadas))
468
+
469
+ setSelectedIds((current) => current.filter((id) => idsNovos.has(id)))
470
+
471
+ setMapaHtml('')
472
+ setMapaStatus('')
473
+ setMapaLegendas([])
474
+ setMapaError('')
475
+ setPesquisaInicializada(true)
476
+ } catch (err) {
477
+ setError(err.message)
478
+ } finally {
479
+ setLoading(false)
480
+ }
481
+ }
482
+
483
+ async function carregarContextoInicial() {
484
+ setLoading(true)
485
+ setError('')
486
+ try {
487
+ const response = await api.pesquisarModelos({ somente_contexto: true })
488
+ const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
489
+
490
+ setResult({
491
+ ...RESULT_INITIAL,
492
+ ...response,
493
+ modelos: [],
494
+ sugestoes: response.sugestoes || {},
495
+ })
496
+ setColunasConfig(configNormalizada)
497
+ setColunasFiltro((current) => reconciliarColunasSelecionadas(current, configNormalizada, {}))
498
+ setSelectedIds([])
499
+ setMapaHtml('')
500
+ setMapaStatus('')
501
+ setMapaLegendas([])
502
+ setMapaError('')
503
+ setPesquisaInicializada(false)
504
+ } catch (err) {
505
+ setError(err.message)
506
+ } finally {
507
+ setLoading(false)
508
+ }
509
+ }
510
+
511
+ useEffect(() => {
512
+ void carregarContextoInicial()
513
+ }, [])
514
+
515
+ useEffect(() => {
516
+ if (!selectAllRef.current) return
517
+ selectAllRef.current.indeterminate = algunsSelecionados && !todosSelecionados
518
+ }, [algunsSelecionados, todosSelecionados])
519
+
520
+ useEffect(() => {
521
+ if (!detailModelId) return
522
+ if (!detalheModelo) {
523
+ setDetailModelId('')
524
+ }
525
+ }, [detailModelId, detalheModelo])
526
+
527
+ useEffect(() => {
528
+ if (!detailModelId) return undefined
529
+ function onEsc(event) {
530
+ if (event.key === 'Escape') {
531
+ setDetailModelId('')
532
+ }
533
+ }
534
+ window.addEventListener('keydown', onEsc)
535
+ return () => window.removeEventListener('keydown', onEsc)
536
+ }, [detailModelId])
537
+
538
+ function onFieldChange(event) {
539
+ const { value, dataset, name } = event.target
540
+ const field = dataset.field || name
541
+ if (!field) return
542
+ setFilters((prev) => ({ ...prev, [field]: value }))
543
+ }
544
+
545
+ function onChangeOtica(otica) {
546
+ setFilters((prev) => ({ ...prev, otica }))
547
+ }
548
+
549
+ async function onLimparFiltros() {
550
+ setFilters(EMPTY_FILTERS)
551
+ setColunasEditadas({})
552
+ setColunasFiltro(COLUNAS_FILTRO_INITIAL)
553
+ await buscarModelos(EMPTY_FILTERS, COLUNAS_FILTRO_INITIAL, {})
554
+ }
555
+
556
+ function onToggleSelecionado(modelId) {
557
+ setSelectedIds((current) => {
558
+ if (current.includes(modelId)) {
559
+ return current.filter((id) => id !== modelId)
560
+ }
561
+ return [...current, modelId]
562
+ })
563
+ }
564
+
565
+ function onToggleSelecionarTodos() {
566
+ setSelectedIds((current) => {
567
+ if (todosSelecionados) {
568
+ const idsAtuais = new Set(resultIds)
569
+ return current.filter((id) => !idsAtuais.has(id))
570
+ }
571
+ const next = new Set(current)
572
+ resultIds.forEach((id) => next.add(id))
573
+ return Array.from(next)
574
+ })
575
+ }
576
+
577
+ function onAbrirDetalhes(modeloId) {
578
+ setDetailModelId(modeloId)
579
+ }
580
+
581
+ function onFecharDetalhes() {
582
+ setDetailModelId('')
583
+ }
584
+
585
+ function onAddColunaFiltro(campo, colunaId) {
586
+ setColunasFiltro((current) => {
587
+ const atual = current[campo] || []
588
+ if (atual.includes(colunaId)) return current
589
+ return { ...current, [campo]: [...atual, colunaId] }
590
+ })
591
+ setColunasEditadas((current) => ({ ...current, [campo]: true }))
592
+ }
593
+
594
+ function onRemoveColunaFiltro(campo, colunaId) {
595
+ setColunasFiltro((current) => ({
596
+ ...current,
597
+ [campo]: (current[campo] || []).filter((item) => item !== colunaId),
598
+ }))
599
+ setColunasEditadas((current) => ({ ...current, [campo]: true }))
600
+ }
601
+
602
+ async function onGerarMapaSelecionados() {
603
+ if (!selectedIds.length) {
604
+ setMapaError('Selecione ao menos um modelo para plotar no mapa.')
605
+ return
606
+ }
607
+
608
+ setMapaLoading(true)
609
+ setMapaError('')
610
+ try {
611
+ const response = await api.pesquisarMapaModelos(selectedIds)
612
+ setMapaHtml(response.mapa_html || '')
613
+ setMapaStatus(response.status || '')
614
+ setMapaLegendas(response.modelos_plotados || [])
615
+ } catch (err) {
616
+ setMapaError(err.message)
617
+ } finally {
618
+ setMapaLoading(false)
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"
628
+ className={`pesquisa-otica-btn${!usandoOticaAvaliando ? ' active' : ''}`}
629
+ role="tab"
630
+ id="pesquisa-otica-modelo"
631
+ aria-selected={!usandoOticaAvaliando}
632
+ aria-controls="pesquisa-panel-modelo"
633
+ tabIndex={!usandoOticaAvaliando ? 0 : -1}
634
+ onClick={() => onChangeOtica('modelo')}
635
+ >
636
+ Otica do modelo
637
+ </button>
638
+ <button
639
+ type="button"
640
+ className={`pesquisa-otica-btn${usandoOticaAvaliando ? ' active' : ''}`}
641
+ role="tab"
642
+ id="pesquisa-otica-avaliando"
643
+ aria-selected={usandoOticaAvaliando}
644
+ aria-controls="pesquisa-panel-avaliando"
645
+ tabIndex={usandoOticaAvaliando ? 0 : -1}
646
+ onClick={() => onChangeOtica('avaliando')}
647
+ >
648
+ Otica do avaliando
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
+
910
+ <datalist id="pesquisa-nomes-modelo">
911
+ {(sugestoes.nomes_modelo || []).map((item) => (
912
+ <option key={`nome-${item}`} value={item} />
913
+ ))}
914
+ </datalist>
915
+ <datalist id="pesquisa-autores">
916
+ {(sugestoes.autores || []).map((item) => (
917
+ <option key={`autor-${item}`} value={item} />
918
+ ))}
919
+ </datalist>
920
+ <datalist id="pesquisa-finalidades">
921
+ {(sugestoes.finalidades || []).map((item) => (
922
+ <option key={`finalidade-${item}`} value={item} />
923
+ ))}
924
+ </datalist>
925
+ <datalist id="pesquisa-bairros">
926
+ {(sugestoes.bairros || []).map((item) => (
927
+ <option key={`bairro-${item}`} value={item} />
928
+ ))}
929
+ </datalist>
930
+
931
+ <div className="row pesquisa-actions pesquisa-actions-primary">
932
+ <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
933
+ {loading ? 'Pesquisando...' : 'Pesquisar'}
934
+ </button>
935
+ <button type="button" onClick={() => void onLimparFiltros()} disabled={loading}>
936
+ Limpar filtros
937
+ </button>
938
+ </div>
939
+
940
+ {error ? <div className="error-line inline-error">{error}</div> : null}
941
+ </SectionBlock>
942
+
943
+ <SectionBlock
944
+ step="2"
945
+ title="Resultados"
946
+ subtitle={
947
+ usandoOticaAvaliando
948
+ ? 'Modelos aceitos para os parametros do avaliando informado.'
949
+ : 'Lista de modelos encontrados para os filtros atuais.'
950
+ }
951
+ >
952
+ <div className="pesquisa-results-toolbar">
953
+ <div className="pesquisa-summary-line">
954
+ <strong>{formatCount(result.total_filtrado)}</strong>{' '}
955
+ {usandoOticaAvaliando ? 'modelo(s) aceito(s)' : 'modelo(s) exibido(s)'} de <strong>{formatCount(result.total_geral)}</strong>.
956
+ </div>
957
+ {resultIds.length ? (
958
+ <label className="pesquisa-select-all">
959
+ <input ref={selectAllRef} type="checkbox" checked={todosSelecionados} onChange={onToggleSelecionarTodos} />
960
+ Selecionar todos os exibidos
961
+ </label>
962
+ ) : null}
963
+ </div>
964
+
965
+ {!result.modelos?.length ? (
966
+ <div className="empty-box">
967
+ {!pesquisaInicializada
968
+ ? 'Defina os filtros desejados e clique em Pesquisar.'
969
+ : usandoOticaAvaliando
970
+ ? 'Nenhum modelo aceitou os parametros do avaliando informado.'
971
+ : 'Nenhum modelo encontrado com os filtros atuais.'}
972
+ </div>
973
+ ) : (
974
+ <div className="pesquisa-card-grid">
975
+ {result.modelos.map((modelo) => {
976
+ const selecionado = selectedIds.includes(modelo.id)
977
+ return (
978
+ <article key={modelo.id} className={`pesquisa-card${selecionado ? ' is-selected' : ''}`}>
979
+ <div className="pesquisa-card-top">
980
+ <div className="pesquisa-card-head">
981
+ <div className="pesquisa-card-head-main">
982
+ <h4>{modelo.nome_modelo || modelo.arquivo}</h4>
983
+ <p>{modelo.arquivo}</p>
984
+ <div className="pesquisa-card-head-actions">
985
+ <label className="pesquisa-select-toggle">
986
+ <input
987
+ type="checkbox"
988
+ checked={selecionado}
989
+ onChange={() => onToggleSelecionado(modelo.id)}
990
+ />
991
+ Selecionar
992
+ </label>
993
+ <button type="button" className="btn-pesquisa-expand" onClick={() => onAbrirDetalhes(modelo.id)}>
994
+ Ver mais
995
+ </button>
996
+ </div>
997
+ </div>
998
+ </div>
999
+
1000
+ {usandoOticaAvaliando ? (
1001
+ <div className="pesquisa-card-status-row">
1002
+ <div className="status-pill done">Aceito para o avaliando ({modelo.avaliando?.campos_informados || 0} campo(s) validado(s))</div>
1003
+ </div>
1004
+ ) : null}
1005
+ <div className="pesquisa-card-body">
1006
+ <div className="pesquisa-card-dados-list">
1007
+ <div><strong>Finalidades no modelo:</strong> {(modelo.finalidades || []).length ? modelo.finalidades.join(', ') : '-'}</div>
1008
+ <div><strong>Tipo:</strong> {formatTipoImovel(modelo)}</div>
1009
+ <div><strong>Autor:</strong> {modelo.autor || '-'}</div>
1010
+ <div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
1011
+ <div><strong>Faixa area:</strong> {formatRange(modelo.faixa_area)}</div>
1012
+ <div><strong>Faixa RH:</strong> {formatRange(modelo.faixa_rh)}</div>
1013
+ <div><strong>Faixa data:</strong> {formatRange(modelo.faixa_data)}</div>
1014
+ <div><strong>Bairros:</strong> {(modelo.bairros || []).length ? (modelo.bairros || []).join(', ') : '-'}</div>
1015
+ </div>
1016
+ </div>
1017
+ </div>
1018
+
1019
+ {modelo.status !== 'ok' ? <div className="inline-error pesquisa-card-error">{modelo.erro_leitura || 'Falha ao ler modelo.'}</div> : null}
1020
+ </article>
1021
+ )
1022
+ })}
1023
+ </div>
1024
+ )}
1025
+ </SectionBlock>
1026
+
1027
+ <SectionBlock step="3" title="Mapa" subtitle="Plote os modelos selecionados com cores distintas e legenda.">
1028
+ <div className="pesquisa-summary-line">
1029
+ <strong>{formatCount(selectedIds.length)}</strong> modelo(s) selecionado(s) para plotagem.
1030
+ </div>
1031
+
1032
+ <div className="row pesquisa-actions">
1033
+ <button type="button" onClick={() => void onGerarMapaSelecionados()} disabled={mapaLoading || !selectedIds.length}>
1034
+ {mapaLoading ? 'Gerando mapa...' : 'Plotar mapa dos selecionados'}
1035
+ </button>
1036
+ </div>
1037
+
1038
+ {mapaStatus ? <div className="status-line">{mapaStatus}</div> : null}
1039
+ {mapaError ? <div className="error-line inline-error">{mapaError}</div> : null}
1040
+
1041
+ {mapaLegendas.length ? (
1042
+ <div className="pesquisa-legenda-grid">
1043
+ {mapaLegendas.map((item) => (
1044
+ <div key={item.id} className="pesquisa-legenda-item">
1045
+ <span className="pesquisa-legenda-color" style={{ backgroundColor: item.cor }} />
1046
+ <span>{item.nome} ({item.total_pontos})</span>
1047
+ </div>
1048
+ ))}
1049
+ </div>
1050
+ ) : null}
1051
+
1052
+ {mapaHtml ? <MapFrame html={mapaHtml} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
1053
+ </SectionBlock>
1054
+
1055
+ {detalheModelo ? (
1056
+ <div className="pesquisa-modal-backdrop" role="presentation" onClick={onFecharDetalhes}>
1057
+ <div className="pesquisa-modal" role="dialog" aria-modal="true" aria-labelledby={`pesquisa-modal-title-${detalheModelo.id}`} onClick={(event) => event.stopPropagation()}>
1058
+ <div className="pesquisa-modal-head">
1059
+ <div>
1060
+ <h4 id={`pesquisa-modal-title-${detalheModelo.id}`}>{detalheModelo.nome_modelo || detalheModelo.arquivo}</h4>
1061
+ <p>{detalheModelo.arquivo}</p>
1062
+ </div>
1063
+ <button type="button" className="pesquisa-modal-close" onClick={onFecharDetalhes}>
1064
+ Fechar
1065
+ </button>
1066
+ </div>
1067
+
1068
+ <div className="pesquisa-modal-body">
1069
+ <div className="pesquisa-card-kpis">
1070
+ <span><strong>Faixa area:</strong> {formatRange(detalheModelo.faixa_area)}</span>
1071
+ <span><strong>Faixa RH:</strong> {formatRange(detalheModelo.faixa_rh)}</span>
1072
+ <span><strong>Faixa data:</strong> {formatRange(detalheModelo.faixa_data)}</span>
1073
+ </div>
1074
+
1075
+ <div className="pesquisa-compare-block">
1076
+ <h5>Equacao</h5>
1077
+ <div className="equation-box">{detalheModelo.equacao || 'Nao disponivel no modelo.'}</div>
1078
+ </div>
1079
+
1080
+ <div className="pesquisa-compare-block">
1081
+ <h5>Faixas de variaveis (min/max)</h5>
1082
+ {detalheModelo.variaveis_resumo?.length ? (
1083
+ <div className="table-wrapper pesquisa-variaveis-table">
1084
+ <table>
1085
+ <thead>
1086
+ <tr>
1087
+ <th>Variavel</th>
1088
+ <th>Min</th>
1089
+ <th>Max</th>
1090
+ </tr>
1091
+ </thead>
1092
+ <tbody>
1093
+ {detalheModelo.variaveis_resumo.map((item) => (
1094
+ <tr key={`${detalheModelo.id}-${item.variavel}`}>
1095
+ <td>{item.variavel}</td>
1096
+ <td>{item.min ?? '-'}</td>
1097
+ <td>{item.max ?? '-'}</td>
1098
+ </tr>
1099
+ ))}
1100
+ </tbody>
1101
+ </table>
1102
+ </div>
1103
+ ) : (
1104
+ <div className="empty-box">Sem estatisticas suficientes para listar variaveis.</div>
1105
+ )}
1106
+ </div>
1107
+ </div>
1108
+ </div>
1109
+ </div>
1110
+ ) : null}
1111
+ </div>
1112
+ )
1113
+ }
frontend/src/styles.css CHANGED
@@ -392,6 +392,631 @@ textarea {
392
  border-style: solid;
393
  }
394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  .row {
396
  display: flex;
397
  align-items: center;
@@ -1882,6 +2507,10 @@ button.btn-upload-select {
1882
  grid-template-columns: 1.2fr 110px minmax(110px, 0.8fr) auto;
1883
  }
1884
 
 
 
 
 
1885
  .micro-msg-grid-codigo {
1886
  grid-template-columns: repeat(2, minmax(0, 1fr));
1887
  }
@@ -1933,6 +2562,56 @@ button.btn-upload-select {
1933
  grid-template-columns: 1fr;
1934
  }
1935
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1936
  .variavel-badge-line {
1937
  grid-template-columns: 1fr;
1938
  gap: 5px;
 
392
  border-style: solid;
393
  }
394
 
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
+
402
+ .pesquisa-filtros-groups.pesquisa-filtros-groups-stack {
403
+ grid-template-columns: 1fr;
404
+ }
405
+
406
+ .pesquisa-filtro-grupo {
407
+ border: 1px solid #c4d6e8;
408
+ border-radius: 12px;
409
+ background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
410
+ padding: 10px 11px 11px;
411
+ box-shadow:
412
+ 0 1px 0 rgba(255, 255, 255, 0.9),
413
+ inset 0 0 0 1px rgba(255, 255, 255, 0.74);
414
+ position: relative;
415
+ }
416
+
417
+ .pesquisa-filtro-grupo::before {
418
+ content: '';
419
+ position: absolute;
420
+ left: 0;
421
+ top: 0;
422
+ bottom: 0;
423
+ width: 4px;
424
+ border-radius: 12px 0 0 12px;
425
+ background: linear-gradient(180deg, #ff9a26 0%, #e67900 100%);
426
+ }
427
+
428
+ .pesquisa-filtro-grupo h5 {
429
+ margin: 0 0 9px;
430
+ padding-bottom: 6px;
431
+ border-bottom: 1px solid #d4e2ef;
432
+ color: #2f4b67;
433
+ font-size: 0.82rem;
434
+ font-family: 'Sora', sans-serif;
435
+ letter-spacing: 0.02em;
436
+ text-transform: uppercase;
437
+ }
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
+
446
+ .pesquisa-otica-switch {
447
+ display: flex;
448
+ align-items: flex-end;
449
+ gap: 4px;
450
+ margin-bottom: 18px;
451
+ border-bottom: 2px solid #c9d9e8;
452
+ padding-bottom: 0;
453
+ }
454
+
455
+ .pesquisa-otica-btn {
456
+ border: 1px solid #cfdbe7;
457
+ border-bottom-color: transparent;
458
+ border-radius: 9px 9px 0 0;
459
+ background: linear-gradient(180deg, #f3f7fb 0%, #eaf1f8 100%);
460
+ color: #6b7f93;
461
+ font-weight: 700;
462
+ padding: 9px 13px 8px;
463
+ box-shadow: none;
464
+ margin-bottom: -2px;
465
+ }
466
+
467
+ .pesquisa-otica-btn.active {
468
+ border-color: #cf6f00;
469
+ border-bottom-color: #cf6f00;
470
+ background: linear-gradient(180deg, #ff9a26 0%, #e67900 100%);
471
+ color: #ffffff;
472
+ box-shadow:
473
+ 0 1px 0 rgba(255, 255, 255, 0.25) inset,
474
+ 0 6px 14px rgba(230, 121, 0, 0.28);
475
+ }
476
+
477
+ button.pesquisa-otica-btn:hover {
478
+ transform: none;
479
+ box-shadow: none;
480
+ color: #445f78;
481
+ background: linear-gradient(180deg, #f7fbff 0%, #edf4fa 100%);
482
+ }
483
+
484
+ button.pesquisa-otica-btn.active:hover {
485
+ color: #ffffff;
486
+ background: linear-gradient(180deg, #ff9a26 0%, #e67900 100%);
487
+ box-shadow:
488
+ 0 1px 0 rgba(255, 255, 255, 0.25) inset,
489
+ 0 6px 14px rgba(230, 121, 0, 0.28);
490
+ }
491
+
492
+ .pesquisa-field {
493
+ display: grid;
494
+ gap: 7px;
495
+ font-size: 0.84rem;
496
+ align-content: start;
497
+ }
498
+
499
+ .pesquisa-field-wide {
500
+ grid-column: 1 / -1;
501
+ }
502
+
503
+ .pesquisa-field input {
504
+ width: 100%;
505
+ }
506
+
507
+ .pesquisa-field input::placeholder {
508
+ color: #b7c4d2;
509
+ opacity: 1;
510
+ }
511
+
512
+ .pesquisa-dynamic-filter-row {
513
+ display: grid;
514
+ grid-template-columns: minmax(0, 1.8fr) minmax(190px, 1fr) minmax(220px, 1.2fr);
515
+ gap: 10px;
516
+ align-items: start;
517
+ }
518
+
519
+ .pesquisa-dynamic-filter-row.pesquisa-dynamic-filter-row-range {
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;
526
+ border-radius: 10px;
527
+ background: #fff;
528
+ padding: 6px 8px;
529
+ }
530
+
531
+ .pesquisa-colunas-chip-list {
532
+ display: flex;
533
+ flex-wrap: wrap;
534
+ gap: 6px;
535
+ align-items: flex-start;
536
+ max-height: 110px;
537
+ overflow: auto;
538
+ }
539
+
540
+ .pesquisa-coluna-chip {
541
+ display: inline-flex;
542
+ align-items: center;
543
+ gap: 5px;
544
+ border: 1px solid #d8e4ef;
545
+ border-radius: 999px;
546
+ background: #f6faff;
547
+ color: #486179;
548
+ font-size: 0.74rem;
549
+ line-height: 1.2;
550
+ padding: 3px 8px;
551
+ }
552
+
553
+ button.pesquisa-coluna-remove {
554
+ border: none;
555
+ background: transparent;
556
+ color: #5f768c;
557
+ font-weight: 800;
558
+ padding: 0;
559
+ min-height: auto;
560
+ box-shadow: none;
561
+ line-height: 1;
562
+ }
563
+
564
+ button.pesquisa-coluna-remove:hover {
565
+ transform: none;
566
+ box-shadow: none;
567
+ color: #304b63;
568
+ }
569
+
570
+ .pesquisa-colunas-empty {
571
+ color: #7c90a4;
572
+ font-size: 0.75rem;
573
+ font-style: italic;
574
+ padding: 2px 0;
575
+ }
576
+
577
+ .pesquisa-colunas-add {
578
+ width: 100%;
579
+ min-height: 38px;
580
+ padding: 6px 8px;
581
+ font-size: 0.77rem;
582
+ background: #fbfdff;
583
+ }
584
+
585
+ .pesquisa-range-row {
586
+ grid-column: 1 / -1;
587
+ display: grid;
588
+ grid-template-columns: repeat(2, minmax(0, 1fr));
589
+ gap: 16px;
590
+ }
591
+
592
+ .pesquisa-range-values-row {
593
+ display: grid;
594
+ grid-template-columns: repeat(2, minmax(0, 1fr));
595
+ gap: 16px;
596
+ margin-top: 10px;
597
+ }
598
+
599
+ .pesquisa-range-row.pesquisa-range-row-three {
600
+ grid-template-columns: repeat(3, minmax(0, 1fr));
601
+ }
602
+
603
+ .pesquisa-actions {
604
+ margin-top: 2px;
605
+ margin-bottom: 10px;
606
+ }
607
+
608
+ .pesquisa-actions-primary {
609
+ margin-top: 22px;
610
+ margin-bottom: 14px;
611
+ }
612
+
613
+ .btn-pesquisa-expand {
614
+ --btn-bg-start: #f7fbff;
615
+ --btn-bg-end: #ecf3fa;
616
+ --btn-border: #c7d7e6;
617
+ --btn-shadow-soft: rgba(73, 102, 128, 0.08);
618
+ --btn-shadow-strong: rgba(73, 102, 128, 0.13);
619
+ color: #415c74;
620
+ }
621
+
622
+ .pesquisa-status {
623
+ margin-top: 2px;
624
+ }
625
+
626
+ .pesquisa-summary-line {
627
+ margin-bottom: 10px;
628
+ color: #4f657a;
629
+ }
630
+
631
+ .pesquisa-results-toolbar {
632
+ display: flex;
633
+ align-items: center;
634
+ justify-content: space-between;
635
+ flex-wrap: wrap;
636
+ gap: 10px;
637
+ margin-bottom: 10px;
638
+ }
639
+
640
+ .pesquisa-results-toolbar .pesquisa-summary-line {
641
+ margin: 0;
642
+ }
643
+
644
+ .pesquisa-select-all {
645
+ display: inline-flex;
646
+ align-items: center;
647
+ gap: 8px;
648
+ border: 1px solid #d9e4ef;
649
+ border-radius: 999px;
650
+ background: #f7fbff;
651
+ color: #3f566b;
652
+ font-size: 0.82rem;
653
+ font-weight: 700;
654
+ padding: 6px 10px;
655
+ white-space: nowrap;
656
+ }
657
+
658
+ .pesquisa-select-all input {
659
+ margin: 0;
660
+ }
661
+
662
+ .pesquisa-card-grid {
663
+ display: grid;
664
+ grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
665
+ gap: 12px;
666
+ min-width: 0;
667
+ align-items: stretch;
668
+ }
669
+
670
+ .pesquisa-card {
671
+ border: 1px solid #dbe7f2;
672
+ border-radius: 14px;
673
+ background: linear-gradient(180deg, #ffffff 0%, #fcfdff 100%);
674
+ padding: 12px;
675
+ display: grid;
676
+ gap: 10px;
677
+ min-width: 0;
678
+ height: 100%;
679
+ overflow: hidden;
680
+ box-shadow:
681
+ 0 6px 18px rgba(26, 43, 61, 0.06),
682
+ inset 0 0 0 1px rgba(255, 255, 255, 0.75);
683
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
684
+ }
685
+
686
+ .pesquisa-card:hover {
687
+ transform: translateY(-1px);
688
+ border-color: #c8dced;
689
+ box-shadow:
690
+ 0 10px 22px rgba(26, 43, 61, 0.08),
691
+ inset 0 0 0 1px rgba(255, 255, 255, 0.82);
692
+ }
693
+
694
+ .pesquisa-card.is-selected {
695
+ border-color: #ffbe77;
696
+ box-shadow:
697
+ 0 10px 24px rgba(255, 163, 63, 0.17),
698
+ 0 0 0 1px rgba(255, 163, 63, 0.28);
699
+ }
700
+
701
+ .pesquisa-card-top {
702
+ display: grid;
703
+ gap: 8px;
704
+ min-width: 0;
705
+ }
706
+
707
+ .pesquisa-card-head {
708
+ display: flex;
709
+ align-items: flex-start;
710
+ justify-content: space-between;
711
+ gap: 10px;
712
+ min-width: 0;
713
+ }
714
+
715
+ .pesquisa-card-head > div {
716
+ min-width: 0;
717
+ flex: 1 1 auto;
718
+ }
719
+
720
+ .pesquisa-card-head-main {
721
+ display: grid;
722
+ gap: 6px;
723
+ min-width: 0;
724
+ }
725
+
726
+ .pesquisa-card-head h4 {
727
+ margin: 0;
728
+ font-family: 'Sora', sans-serif;
729
+ color: #2e4358;
730
+ font-size: 0.93rem;
731
+ line-height: 1.32;
732
+ overflow-wrap: anywhere;
733
+ }
734
+
735
+ .pesquisa-card-head p {
736
+ margin: 3px 0 0;
737
+ color: #5f758b;
738
+ font-size: 0.8rem;
739
+ line-height: 1.35;
740
+ overflow-wrap: anywhere;
741
+ }
742
+
743
+ .btn-pesquisa-expand {
744
+ flex: 0 0 auto;
745
+ min-width: 84px;
746
+ padding: 6px 10px;
747
+ }
748
+
749
+ button.btn-pesquisa-expand:hover {
750
+ transform: none;
751
+ box-shadow: 0 4px 10px var(--btn-shadow-strong);
752
+ }
753
+
754
+ .pesquisa-card-head-actions {
755
+ display: flex;
756
+ flex-wrap: wrap;
757
+ align-items: center;
758
+ justify-content: flex-start;
759
+ gap: 8px;
760
+ min-width: 0;
761
+ margin-top: 2px;
762
+ }
763
+
764
+ .pesquisa-select-toggle {
765
+ display: inline-flex;
766
+ align-items: center;
767
+ gap: 7px;
768
+ border: 1px solid #d8e4ef;
769
+ border-radius: 9px;
770
+ background: #f5f9fd;
771
+ padding: 5px 8px;
772
+ width: fit-content;
773
+ max-width: 100%;
774
+ font-size: 0.8rem;
775
+ font-weight: 700;
776
+ color: #48627b;
777
+ }
778
+
779
+ .pesquisa-select-toggle input {
780
+ margin: 0;
781
+ }
782
+
783
+ .pesquisa-card-status-row .status-pill {
784
+ max-width: 100%;
785
+ white-space: normal;
786
+ text-transform: none;
787
+ letter-spacing: 0.01em;
788
+ font-size: 0.75rem;
789
+ padding: 5px 10px;
790
+ }
791
+
792
+ .pesquisa-card-body {
793
+ display: grid;
794
+ gap: 9px;
795
+ min-width: 0;
796
+ border-top: 1px solid #e7eef5;
797
+ padding-top: 9px;
798
+ }
799
+
800
+ .pesquisa-card-kpis {
801
+ display: grid;
802
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
803
+ gap: 7px 10px;
804
+ font-size: 0.83rem;
805
+ color: #41586f;
806
+ }
807
+
808
+ .pesquisa-card-kpis span {
809
+ min-width: 0;
810
+ overflow-wrap: anywhere;
811
+ }
812
+
813
+ .pesquisa-card-dados-list {
814
+ display: grid;
815
+ gap: 6px;
816
+ font-size: 0.82rem;
817
+ color: #41586f;
818
+ }
819
+
820
+ .pesquisa-card-dados-list div {
821
+ min-width: 0;
822
+ overflow-wrap: anywhere;
823
+ line-height: 1.34;
824
+ }
825
+
826
+ .pesquisa-card-bairros {
827
+ padding-top: 2px;
828
+ font-size: 0.82rem;
829
+ color: #4d647b;
830
+ min-width: 0;
831
+ overflow-wrap: anywhere;
832
+ }
833
+
834
+ .pesquisa-card-faixas {
835
+ display: grid;
836
+ gap: 4px;
837
+ padding-top: 2px;
838
+ font-size: 0.81rem;
839
+ color: #496178;
840
+ }
841
+
842
+ .pesquisa-card-faixas span {
843
+ min-width: 0;
844
+ overflow-wrap: anywhere;
845
+ }
846
+
847
+ .pesquisa-modal-backdrop {
848
+ position: fixed;
849
+ inset: 0;
850
+ z-index: 1400;
851
+ display: flex;
852
+ align-items: center;
853
+ justify-content: center;
854
+ padding: 20px;
855
+ background: rgba(19, 30, 43, 0.44);
856
+ backdrop-filter: blur(2px);
857
+ }
858
+
859
+ .pesquisa-modal {
860
+ width: min(980px, 100%);
861
+ max-height: 90vh;
862
+ overflow: auto;
863
+ border: 1px solid #d5e2ef;
864
+ border-radius: 16px;
865
+ background: #fff;
866
+ padding: 16px;
867
+ box-shadow: 0 20px 44px rgba(18, 31, 46, 0.28);
868
+ }
869
+
870
+ .pesquisa-modal-head {
871
+ display: flex;
872
+ align-items: flex-start;
873
+ justify-content: space-between;
874
+ gap: 10px;
875
+ }
876
+
877
+ .pesquisa-modal-head h4 {
878
+ margin: 0;
879
+ color: #2d4358;
880
+ font-family: 'Sora', sans-serif;
881
+ font-size: 1rem;
882
+ }
883
+
884
+ .pesquisa-modal-head p {
885
+ margin: 3px 0 0;
886
+ color: #5c7288;
887
+ font-size: 0.82rem;
888
+ }
889
+
890
+ .pesquisa-modal-close {
891
+ --btn-bg-start: #748292;
892
+ --btn-bg-end: #5f6d7d;
893
+ --btn-border: #4f5e6f;
894
+ --btn-shadow-soft: rgba(95, 109, 125, 0.18);
895
+ --btn-shadow-strong: rgba(95, 109, 125, 0.24);
896
+ min-width: 82px;
897
+ }
898
+
899
+ .pesquisa-modal-body {
900
+ display: grid;
901
+ gap: 12px;
902
+ margin-top: 12px;
903
+ }
904
+
905
+ .pesquisa-card-error {
906
+ margin-top: 2px;
907
+ }
908
+
909
+ .btn-pesquisa-compare {
910
+ min-width: 96px;
911
+ }
912
+
913
+ .pesquisa-compare-grid {
914
+ display: grid;
915
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
916
+ gap: 12px;
917
+ }
918
+
919
+ .pesquisa-compare-card {
920
+ border: 1px solid #d5e2ee;
921
+ border-radius: 12px;
922
+ background: #fff;
923
+ padding: 12px;
924
+ display: grid;
925
+ gap: 11px;
926
+ }
927
+
928
+ .pesquisa-compare-card h4 {
929
+ margin: 0;
930
+ font-family: 'Sora', sans-serif;
931
+ color: #2f4458;
932
+ font-size: 0.95rem;
933
+ }
934
+
935
+ .pesquisa-compare-meta {
936
+ display: grid;
937
+ gap: 5px;
938
+ font-size: 0.82rem;
939
+ color: #4d647b;
940
+ }
941
+
942
+ .pesquisa-compare-block h5 {
943
+ margin: 0 0 7px;
944
+ font-size: 0.85rem;
945
+ color: #344b60;
946
+ font-family: 'Sora', sans-serif;
947
+ }
948
+
949
+ .pesquisa-compat-grid {
950
+ display: grid;
951
+ gap: 7px;
952
+ }
953
+
954
+ .pesquisa-compat-row {
955
+ display: grid;
956
+ grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1fr);
957
+ gap: 7px;
958
+ border: 1px solid #e0e9f2;
959
+ border-radius: 9px;
960
+ padding: 6px 7px;
961
+ background: #fbfdff;
962
+ font-size: 0.81rem;
963
+ }
964
+
965
+ .pesquisa-compat-row span {
966
+ color: #587087;
967
+ }
968
+
969
+ .pesquisa-compat-row strong {
970
+ color: #2f465c;
971
+ word-break: break-word;
972
+ }
973
+
974
+ .pesquisa-variaveis-table {
975
+ max-height: 250px;
976
+ }
977
+
978
+ .table-wrapper.pesquisa-variaveis-table table {
979
+ min-width: 360px;
980
+ }
981
+
982
+ .table-wrapper.pesquisa-variaveis-table th,
983
+ .table-wrapper.pesquisa-variaveis-table td {
984
+ white-space: nowrap;
985
+ }
986
+
987
+ .table-wrapper.pesquisa-variaveis-table th:first-child,
988
+ .table-wrapper.pesquisa-variaveis-table td:first-child {
989
+ white-space: normal;
990
+ overflow-wrap: anywhere;
991
+ }
992
+
993
+ .pesquisa-legenda-grid {
994
+ display: flex;
995
+ flex-wrap: wrap;
996
+ gap: 8px;
997
+ margin: 10px 0 12px;
998
+ }
999
+
1000
+ .pesquisa-legenda-item {
1001
+ display: inline-flex;
1002
+ align-items: center;
1003
+ gap: 7px;
1004
+ border: 1px solid #dce6f1;
1005
+ border-radius: 999px;
1006
+ background: #fbfdff;
1007
+ padding: 5px 10px;
1008
+ color: #3f566b;
1009
+ font-size: 0.8rem;
1010
+ font-weight: 700;
1011
+ }
1012
+
1013
+ .pesquisa-legenda-color {
1014
+ width: 11px;
1015
+ height: 11px;
1016
+ border-radius: 50%;
1017
+ border: 1px solid rgba(24, 43, 62, 0.22);
1018
+ }
1019
+
1020
  .row {
1021
  display: flex;
1022
  align-items: center;
 
2507
  grid-template-columns: 1.2fr 110px minmax(110px, 0.8fr) auto;
2508
  }
2509
 
2510
+ .pesquisa-filtros-groups {
2511
+ grid-template-columns: 1fr;
2512
+ }
2513
+
2514
  .micro-msg-grid-codigo {
2515
  grid-template-columns: repeat(2, minmax(0, 1fr));
2516
  }
 
2562
  grid-template-columns: 1fr;
2563
  }
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,
2576
+ .pesquisa-range-row.pesquisa-range-row-three {
2577
+ grid-template-columns: 1fr;
2578
+ }
2579
+
2580
+ .pesquisa-otica-switch {
2581
+ overflow-x: auto;
2582
+ scrollbar-width: thin;
2583
+ }
2584
+
2585
+ .pesquisa-card-head {
2586
+ flex-direction: column;
2587
+ }
2588
+
2589
+ .pesquisa-card-head-actions {
2590
+ width: 100%;
2591
+ justify-content: flex-start;
2592
+ }
2593
+
2594
+ .pesquisa-results-toolbar {
2595
+ align-items: flex-start;
2596
+ }
2597
+
2598
+ .pesquisa-select-all {
2599
+ white-space: normal;
2600
+ }
2601
+
2602
+ .pesquisa-modal-backdrop {
2603
+ padding: 10px;
2604
+ }
2605
+
2606
+ .pesquisa-modal {
2607
+ max-height: 95vh;
2608
+ padding: 12px;
2609
+ }
2610
+
2611
+ .pesquisa-modal-head {
2612
+ flex-direction: column;
2613
+ }
2614
+
2615
  .variavel-badge-line {
2616
  grid-template-columns: 1fr;
2617
  gap: 5px;