Guilherme Silberfarb Costa commited on
Commit
c4b0839
·
1 Parent(s): ca6595d

alteracoes generalizadas

Browse files
backend/app/api/pesquisa.py CHANGED
@@ -47,6 +47,7 @@ def pesquisar_modelos(
47
  autor: str | None = Query(None),
48
  contem_app: str | None = Query(None),
49
  tipo_modelo: str | None = Query(None),
 
50
  finalidade: str | None = Query(None),
51
  finalidade_colunas: str | None = Query(None),
52
  bairro: str | None = Query(None),
@@ -89,6 +90,7 @@ def pesquisar_modelos(
89
  autor=autor,
90
  contem_app=contem_app,
91
  tipo_modelo=tipo_modelo,
 
92
  finalidade=finalidade,
93
  finalidade_colunas=_split_csv(finalidade_colunas),
94
  bairro=bairro,
 
47
  autor: str | None = Query(None),
48
  contem_app: str | None = Query(None),
49
  tipo_modelo: str | None = Query(None),
50
+ negociacao_modelo: str | None = Query(None),
51
  finalidade: str | None = Query(None),
52
  finalidade_colunas: str | None = Query(None),
53
  bairro: str | None = Query(None),
 
90
  autor=autor,
91
  contem_app=contem_app,
92
  tipo_modelo=tipo_modelo,
93
+ negociacao_modelo=negociacao_modelo,
94
  finalidade=finalidade,
95
  finalidade_colunas=_split_csv(finalidade_colunas),
96
  bairro=bairro,
backend/app/core/elaboracao/core.py CHANGED
@@ -6,6 +6,7 @@ Contém: carregamento de dados, estatísticas, transformações, modelo OLS, dia
6
 
7
  import os
8
  import re
 
9
  import pandas as pd
10
  # Desabilita StringDtype para compatibilidade entre versões do pandas
11
  pd.set_option('future.infer_string', False)
@@ -203,8 +204,51 @@ def _normalizar_data_iso(valor):
203
  """Converte valores de data para ISO (YYYY-MM-DD), retornando None se inválido."""
204
  if valor is None:
205
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  try:
207
- dt = pd.to_datetime(valor, errors="coerce", dayfirst=True)
208
  except Exception:
209
  return None
210
  if pd.isna(dt):
 
6
 
7
  import os
8
  import re
9
+ from datetime import datetime
10
  import pandas as pd
11
  # Desabilita StringDtype para compatibilidade entre versões do pandas
12
  pd.set_option('future.infer_string', False)
 
204
  """Converte valores de data para ISO (YYYY-MM-DD), retornando None se inválido."""
205
  if valor is None:
206
  return None
207
+
208
+ if isinstance(valor, pd.Timestamp):
209
+ if pd.isna(valor):
210
+ return None
211
+ return valor.date().isoformat()
212
+
213
+ texto = str(valor).strip()
214
+ if not texto:
215
+ return None
216
+
217
+ match_iso = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$", texto)
218
+ if match_iso:
219
+ try:
220
+ return datetime(
221
+ int(match_iso.group(1)),
222
+ int(match_iso.group(2)),
223
+ int(match_iso.group(3)),
224
+ ).date().isoformat()
225
+ except Exception:
226
+ return None
227
+
228
+ match_iso_slash = re.match(r"^(\d{4})/(\d{2})/(\d{2})(?:[T\s].*)?$", texto)
229
+ if match_iso_slash:
230
+ try:
231
+ return datetime(
232
+ int(match_iso_slash.group(1)),
233
+ int(match_iso_slash.group(2)),
234
+ int(match_iso_slash.group(3)),
235
+ ).date().isoformat()
236
+ except Exception:
237
+ return None
238
+
239
+ match_br = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:[T\s].*)?$", texto)
240
+ if match_br:
241
+ try:
242
+ return datetime(
243
+ int(match_br.group(3)),
244
+ int(match_br.group(2)),
245
+ int(match_br.group(1)),
246
+ ).date().isoformat()
247
+ except Exception:
248
+ return None
249
+
250
  try:
251
+ dt = pd.to_datetime(texto, errors="coerce", dayfirst=True)
252
  except Exception:
253
  return None
254
  if pd.isna(dt):
backend/app/core/visualizacao/app.py CHANGED
@@ -10,6 +10,7 @@ from joblib import load
10
  import os
11
  import re
12
  import traceback
 
13
 
14
 
15
  # Importações para gráficos (trazidas de graficos.py)
@@ -1239,9 +1240,33 @@ def _formatar_badge_completo(pacote, nome_modelo=""):
1239
 
1240
  def _data_br(value):
1241
  texto = str(value or "").strip()
1242
- match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", texto)
1243
- if match:
1244
- return f"{match.group(3)}/{match.group(2)}/{match.group(1)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1245
  return texto
1246
 
1247
  model_name = str(nome_modelo or "").strip() or "-"
 
10
  import os
11
  import re
12
  import traceback
13
+ from datetime import datetime
14
 
15
 
16
  # Importações para gráficos (trazidas de graficos.py)
 
1240
 
1241
  def _data_br(value):
1242
  texto = str(value or "").strip()
1243
+ if not texto:
1244
+ return ""
1245
+
1246
+ match_iso = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$", texto)
1247
+ if match_iso:
1248
+ try:
1249
+ dt = datetime(int(match_iso.group(1)), int(match_iso.group(2)), int(match_iso.group(3)))
1250
+ return dt.strftime("%d/%m/%Y")
1251
+ except Exception:
1252
+ return texto
1253
+
1254
+ match_iso_slash = re.match(r"^(\d{4})/(\d{2})/(\d{2})(?:[T\s].*)?$", texto)
1255
+ if match_iso_slash:
1256
+ try:
1257
+ dt = datetime(int(match_iso_slash.group(1)), int(match_iso_slash.group(2)), int(match_iso_slash.group(3)))
1258
+ return dt.strftime("%d/%m/%Y")
1259
+ except Exception:
1260
+ return texto
1261
+
1262
+ match_br = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:[T\s].*)?$", texto)
1263
+ if match_br:
1264
+ try:
1265
+ dt = datetime(int(match_br.group(3)), int(match_br.group(2)), int(match_br.group(1)))
1266
+ return dt.strftime("%d/%m/%Y")
1267
+ except Exception:
1268
+ return texto
1269
+
1270
  return texto
1271
 
1272
  model_name = str(nome_modelo or "").strip() or "-"
backend/app/services/elaboracao_service.py CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
 
 
5
  from dataclasses import asdict
6
  from pathlib import Path
7
  from typing import Any
@@ -48,6 +50,77 @@ _AVALIADORES_PATH = Path(__file__).resolve().parent.parent / "core" / "elaboraca
48
  _AVALIADORES_CACHE: list[dict[str, Any]] | None = None
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  def list_avaliadores() -> list[dict[str, Any]]:
52
  global _AVALIADORES_CACHE
53
  if _AVALIADORES_CACHE is not None:
@@ -212,12 +285,12 @@ def _normalizar_periodo_dados_mercado(periodo: dict[str, Any] | None) -> dict[st
212
  if not isinstance(periodo, dict):
213
  return {"coluna_data": None, "data_inicial": None, "data_final": None}
214
  coluna_data = str(periodo.get("coluna_data") or "").strip() or None
215
- inicio = pd.to_datetime(periodo.get("data_inicial"), errors="coerce", dayfirst=True)
216
- fim = pd.to_datetime(periodo.get("data_final"), errors="coerce", dayfirst=True)
217
  return {
218
  "coluna_data": coluna_data,
219
- "data_inicial": None if pd.isna(inicio) else inicio.date().isoformat(),
220
- "data_final": None if pd.isna(fim) else fim.date().isoformat(),
221
  }
222
 
223
 
@@ -271,7 +344,7 @@ def _converter_coluna_para_datas(
271
  )
272
  datas = pd.to_datetime(serie_num, unit="D", origin="1899-12-30", errors="coerce")
273
  else:
274
- datas = pd.to_datetime(serie_base, errors="coerce", dayfirst=True)
275
 
276
  datas_validas = datas[mascara_preenchida].dropna()
277
  proporcao = len(datas_validas) / total_preenchido if total_preenchido else 0.0
 
2
 
3
  import json
4
  import os
5
+ import re
6
+ from datetime import datetime
7
  from dataclasses import asdict
8
  from pathlib import Path
9
  from typing import Any
 
50
  _AVALIADORES_CACHE: list[dict[str, Any]] | None = None
51
 
52
 
53
+ def _parse_data_iso_segura(value: Any) -> str | None:
54
+ if value is None:
55
+ return None
56
+ if isinstance(value, pd.Timestamp):
57
+ if pd.isna(value):
58
+ return None
59
+ return value.date().isoformat()
60
+
61
+ text = str(value).strip()
62
+ if not text:
63
+ return None
64
+
65
+ iso_match = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$", text)
66
+ if iso_match:
67
+ try:
68
+ return datetime(
69
+ int(iso_match.group(1)),
70
+ int(iso_match.group(2)),
71
+ int(iso_match.group(3)),
72
+ ).date().isoformat()
73
+ except Exception:
74
+ return None
75
+
76
+ iso_slash_match = re.match(r"^(\d{4})/(\d{2})/(\d{2})(?:[T\s].*)?$", text)
77
+ if iso_slash_match:
78
+ try:
79
+ return datetime(
80
+ int(iso_slash_match.group(1)),
81
+ int(iso_slash_match.group(2)),
82
+ int(iso_slash_match.group(3)),
83
+ ).date().isoformat()
84
+ except Exception:
85
+ return None
86
+
87
+ br_match = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:[T\s].*)?$", text)
88
+ if br_match:
89
+ try:
90
+ return datetime(
91
+ int(br_match.group(3)),
92
+ int(br_match.group(2)),
93
+ int(br_match.group(1)),
94
+ ).date().isoformat()
95
+ except Exception:
96
+ return None
97
+
98
+ parsed = pd.to_datetime(text, errors="coerce", dayfirst=True)
99
+ if pd.isna(parsed):
100
+ return None
101
+ return parsed.date().isoformat()
102
+
103
+
104
+ def _parse_serie_datas_texto_segura(serie_texto: pd.Series) -> pd.Series:
105
+ preenchidos = serie_texto.dropna().astype(str).str.strip()
106
+ if preenchidos.empty:
107
+ return pd.to_datetime(serie_texto, errors="coerce")
108
+
109
+ if preenchidos.str.match(r"^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$").all():
110
+ base = serie_texto.astype(str).str.slice(0, 10)
111
+ return pd.to_datetime(base, format="%Y-%m-%d", errors="coerce")
112
+
113
+ if preenchidos.str.match(r"^\d{4}/\d{2}/\d{2}(?:[T\s].*)?$").all():
114
+ base = serie_texto.astype(str).str.slice(0, 10)
115
+ return pd.to_datetime(base, format="%Y/%m/%d", errors="coerce")
116
+
117
+ if preenchidos.str.match(r"^\d{2}/\d{2}/\d{4}(?:[T\s].*)?$").all():
118
+ base = serie_texto.astype(str).str.slice(0, 10)
119
+ return pd.to_datetime(base, format="%d/%m/%Y", errors="coerce")
120
+
121
+ return pd.to_datetime(serie_texto, errors="coerce", dayfirst=True)
122
+
123
+
124
  def list_avaliadores() -> list[dict[str, Any]]:
125
  global _AVALIADORES_CACHE
126
  if _AVALIADORES_CACHE is not None:
 
285
  if not isinstance(periodo, dict):
286
  return {"coluna_data": None, "data_inicial": None, "data_final": None}
287
  coluna_data = str(periodo.get("coluna_data") or "").strip() or None
288
+ inicio = _parse_data_iso_segura(periodo.get("data_inicial"))
289
+ fim = _parse_data_iso_segura(periodo.get("data_final"))
290
  return {
291
  "coluna_data": coluna_data,
292
+ "data_inicial": inicio,
293
+ "data_final": fim,
294
  }
295
 
296
 
 
344
  )
345
  datas = pd.to_datetime(serie_num, unit="D", origin="1899-12-30", errors="coerce")
346
  else:
347
+ datas = _parse_serie_datas_texto_segura(serie_base)
348
 
349
  datas_validas = datas[mascara_preenchida].dropna()
350
  proporcao = len(datas_validas) / total_preenchido if total_preenchido else 0.0
backend/app/services/pesquisa_service.py CHANGED
@@ -1,6 +1,5 @@
1
  from __future__ import annotations
2
 
3
- import json
4
  import math
5
  import re
6
  import unicodedata
@@ -20,8 +19,6 @@ from app.core.elaboracao.core import _migrar_pacote_v1_para_v2
20
  from app.services import model_repository
21
  from app.services.serializers import sanitize_value
22
 
23
- ADMIN_CONFIG_PATH = Path(__file__).resolve().parent.parent / "core" / "pesquisa" / "pesquisa_admin_config.json"
24
-
25
  AREA_PRIVATIVA_ALIASES = ["APRIV", "APRIVEQ", "ATPRIV", "ACOPRIV", "AREAPRIV", "AREA_PRIVATIVA", "AREA PRIVATIVA"]
26
  AREA_TOTAL_ALIASES = ["ATTOTAL", "ATOTAL", "ATOT", "AREA_TOTAL", "AREA TOTAL", "AREA"]
27
  AREA_GERAL_ALIASES = AREA_PRIVATIVA_ALIASES + AREA_TOTAL_ALIASES + ["ACONST", "ALOC"]
@@ -153,6 +150,7 @@ class PesquisaFiltros:
153
  autor: str | None = None
154
  contem_app: str | None = None
155
  tipo_modelo: str | None = None
 
156
  finalidade: str | None = None
157
  finalidade_colunas: list[str] | None = None
158
  bairro: str | None = None
@@ -193,6 +191,7 @@ _CACHE_LOCK = Lock()
193
  _CACHE: dict[str, dict[str, Any]] = {}
194
  _ADMIN_CONFIG_LOCK = Lock()
195
  _CACHE_SOURCE_SIGNATURE: str | None = None
 
196
 
197
 
198
  def _resolver_repositorio_modelos() -> model_repository.ModelRepositoryResolution:
@@ -233,13 +232,13 @@ def salvar_admin_config_pesquisa(campos: dict[str, list[str]] | None) -> dict[st
233
  todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
234
  colunas_filtro = _montar_config_colunas_filtro(todos)
235
  admin_fontes = _normalizar_fontes_admin(campos or {}, colunas_filtro)
236
- _persistir_fontes_admin(admin_fontes)
237
  return sanitize_value(
238
  {
239
  "colunas_filtro": colunas_filtro,
240
  "admin_fontes": admin_fontes,
241
  "total_modelos": len(todos),
242
- "status": "Configuracao de busca salva com sucesso.",
243
  "fonte_modelos": resolved.as_payload(),
244
  }
245
  )
@@ -274,6 +273,7 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
274
  "autor": filtros.autor,
275
  "contem_app": filtros.contem_app,
276
  "tipo_modelo": filtros.tipo_modelo,
 
277
  "finalidade": filtros.finalidade,
278
  "finalidade_colunas": filtros.finalidade_colunas or [],
279
  "bairro": filtros.bairro,
@@ -339,6 +339,7 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
339
  "autor": filtros.autor,
340
  "contem_app": filtros.contem_app,
341
  "tipo_modelo": filtros.tipo_modelo,
 
342
  "finalidade": filtros.finalidade,
343
  "finalidade_colunas": filtros.finalidade_colunas or [],
344
  "bairro": filtros.bairro,
@@ -651,7 +652,45 @@ def _faixa_data_do_pacote(pacote: dict[str, Any]) -> dict[str, Any] | None:
651
  def _data_iso_or_none(value: Any) -> str | None:
652
  if _is_empty(value):
653
  return None
654
- parsed = pd.to_datetime(value, errors="coerce", dayfirst=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
  if pd.isna(parsed):
656
  return None
657
  return parsed.date().isoformat()
@@ -857,10 +896,17 @@ def _extrair_faixa_data_real_serie(serie: pd.Series) -> dict[str, Any] | None:
857
  if proporcao_ano_puro >= 0.8:
858
  return None
859
 
860
- try:
861
- serie_data = pd.to_datetime(bruto, errors="coerce", dayfirst=True, format="mixed").dropna()
862
- except TypeError:
863
- serie_data = pd.to_datetime(bruto, errors="coerce", dayfirst=True).dropna()
 
 
 
 
 
 
 
864
  total = len(bruto)
865
  minimo_amostras = min(3, total)
866
  if len(serie_data) < minimo_amostras:
@@ -1000,6 +1046,10 @@ def _aceita_filtros(modelo: dict[str, Any], filtros: PesquisaFiltros, fontes_adm
1000
  if filtros.tipo_modelo and not _contains_any([_tipo_modelo_modelo(modelo)], filtros.tipo_modelo):
1001
  return False
1002
 
 
 
 
 
1003
  if filtros.finalidade and not _aceita_texto_com_colunas(modelo, filtros.finalidade, "finalidade", fontes_admin.get("finalidade")):
1004
  return False
1005
 
@@ -1041,6 +1091,17 @@ def _normalizar_contem_app(value: str | None) -> bool | None:
1041
  return None
1042
 
1043
 
 
 
 
 
 
 
 
 
 
 
 
1044
  def _normalizar_otica(value: str | None) -> str:
1045
  return "avaliando"
1046
 
@@ -1063,13 +1124,23 @@ def _anexar_avaliando_info(
1063
 
1064
  finalidade_info = filtros.aval_finalidade
1065
  if _is_provided(finalidade_info):
1066
- aceito = _aceita_texto_com_colunas(item, str(finalidade_info), "aval_finalidade", fontes_admin.get("aval_finalidade"))
1067
- registrar("finalidade", finalidade_info, aceito, "nao encontrada no modelo")
 
 
 
 
 
1068
 
1069
  bairro_info = filtros.aval_bairro
1070
  if _is_provided(bairro_info):
1071
- aceito = _aceita_texto_com_colunas(item, str(bairro_info), "aval_bairro", fontes_admin.get("aval_bairro"))
1072
- registrar("bairro", bairro_info, aceito, "bairro fora da cobertura do modelo")
 
 
 
 
 
1073
 
1074
  endereco_info = filtros.aval_endereco
1075
  if _is_provided(endereco_info):
@@ -1222,26 +1293,16 @@ def _montar_config_colunas_filtro(modelos: list[dict[str, Any]]) -> dict[str, An
1222
 
1223
 
1224
  def _carregar_fontes_admin(colunas_filtro: dict[str, Any]) -> dict[str, list[str]]:
1225
- raw: dict[str, Any] = {}
1226
  with _ADMIN_CONFIG_LOCK:
1227
- if ADMIN_CONFIG_PATH.exists():
1228
- try:
1229
- raw_file = json.loads(ADMIN_CONFIG_PATH.read_text(encoding="utf-8"))
1230
- if isinstance(raw_file, dict):
1231
- raw = raw_file
1232
- except Exception:
1233
- raw = {}
1234
  return _normalizar_fontes_admin(raw, colunas_filtro)
1235
 
1236
 
1237
- def _persistir_fontes_admin(fontes_admin: dict[str, list[str]]) -> None:
1238
- ADMIN_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
1239
  payload = {campo: _dedupe_strings(valores) for campo, valores in (fontes_admin or {}).items()}
 
1240
  with _ADMIN_CONFIG_LOCK:
1241
- ADMIN_CONFIG_PATH.write_text(
1242
- json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True),
1243
- encoding="utf-8",
1244
- )
1245
 
1246
 
1247
  def _normalizar_fontes_admin(raw: dict[str, Any], colunas_filtro: dict[str, Any]) -> dict[str, list[str]]:
@@ -1697,6 +1758,16 @@ def _tipo_modelo_modelo(modelo: dict[str, Any]) -> str | None:
1697
  return _inferir_tipo_por_nome(nome_referencia)
1698
 
1699
 
 
 
 
 
 
 
 
 
 
 
1700
  def _extrair_termos_bairro(filtros: PesquisaFiltros) -> list[str]:
1701
  termos: list[str] = []
1702
  if filtros.bairro:
 
1
  from __future__ import annotations
2
 
 
3
  import math
4
  import re
5
  import unicodedata
 
19
  from app.services import model_repository
20
  from app.services.serializers import sanitize_value
21
 
 
 
22
  AREA_PRIVATIVA_ALIASES = ["APRIV", "APRIVEQ", "ATPRIV", "ACOPRIV", "AREAPRIV", "AREA_PRIVATIVA", "AREA PRIVATIVA"]
23
  AREA_TOTAL_ALIASES = ["ATTOTAL", "ATOTAL", "ATOT", "AREA_TOTAL", "AREA TOTAL", "AREA"]
24
  AREA_GERAL_ALIASES = AREA_PRIVATIVA_ALIASES + AREA_TOTAL_ALIASES + ["ACONST", "ALOC"]
 
150
  autor: str | None = None
151
  contem_app: str | None = None
152
  tipo_modelo: str | None = None
153
+ negociacao_modelo: str | None = None
154
  finalidade: str | None = None
155
  finalidade_colunas: list[str] | None = None
156
  bairro: str | None = None
 
191
  _CACHE: dict[str, dict[str, Any]] = {}
192
  _ADMIN_CONFIG_LOCK = Lock()
193
  _CACHE_SOURCE_SIGNATURE: str | None = None
194
+ _ADMIN_FONTES_SESSION: dict[str, list[str]] = {}
195
 
196
 
197
  def _resolver_repositorio_modelos() -> model_repository.ModelRepositoryResolution:
 
232
  todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
233
  colunas_filtro = _montar_config_colunas_filtro(todos)
234
  admin_fontes = _normalizar_fontes_admin(campos or {}, colunas_filtro)
235
+ _salvar_fontes_admin_sessao(admin_fontes)
236
  return sanitize_value(
237
  {
238
  "colunas_filtro": colunas_filtro,
239
  "admin_fontes": admin_fontes,
240
  "total_modelos": len(todos),
241
+ "status": "Configuracao de busca aplicada para a sessao atual.",
242
  "fonte_modelos": resolved.as_payload(),
243
  }
244
  )
 
273
  "autor": filtros.autor,
274
  "contem_app": filtros.contem_app,
275
  "tipo_modelo": filtros.tipo_modelo,
276
+ "negociacao_modelo": filtros.negociacao_modelo,
277
  "finalidade": filtros.finalidade,
278
  "finalidade_colunas": filtros.finalidade_colunas or [],
279
  "bairro": filtros.bairro,
 
339
  "autor": filtros.autor,
340
  "contem_app": filtros.contem_app,
341
  "tipo_modelo": filtros.tipo_modelo,
342
+ "negociacao_modelo": filtros.negociacao_modelo,
343
  "finalidade": filtros.finalidade,
344
  "finalidade_colunas": filtros.finalidade_colunas or [],
345
  "bairro": filtros.bairro,
 
652
  def _data_iso_or_none(value: Any) -> str | None:
653
  if _is_empty(value):
654
  return None
655
+
656
+ text = str(value).strip()
657
+ if not text:
658
+ return None
659
+
660
+ iso_match = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$", text)
661
+ if iso_match:
662
+ try:
663
+ return datetime(
664
+ int(iso_match.group(1)),
665
+ int(iso_match.group(2)),
666
+ int(iso_match.group(3)),
667
+ ).date().isoformat()
668
+ except Exception:
669
+ return None
670
+
671
+ iso_slash_match = re.match(r"^(\d{4})/(\d{2})/(\d{2})(?:[T\s].*)?$", text)
672
+ if iso_slash_match:
673
+ try:
674
+ return datetime(
675
+ int(iso_slash_match.group(1)),
676
+ int(iso_slash_match.group(2)),
677
+ int(iso_slash_match.group(3)),
678
+ ).date().isoformat()
679
+ except Exception:
680
+ return None
681
+
682
+ br_match = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:[T\s].*)?$", text)
683
+ if br_match:
684
+ try:
685
+ return datetime(
686
+ int(br_match.group(3)),
687
+ int(br_match.group(2)),
688
+ int(br_match.group(1)),
689
+ ).date().isoformat()
690
+ except Exception:
691
+ return None
692
+
693
+ parsed = pd.to_datetime(text, errors="coerce", dayfirst=True)
694
  if pd.isna(parsed):
695
  return None
696
  return parsed.date().isoformat()
 
896
  if proporcao_ano_puro >= 0.8:
897
  return None
898
 
899
+ if bruto.str.match(r"^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$").all():
900
+ serie_data = pd.to_datetime(bruto.str.slice(0, 10), errors="coerce", format="%Y-%m-%d").dropna()
901
+ elif bruto.str.match(r"^\d{4}/\d{2}/\d{2}(?:[T\s].*)?$").all():
902
+ serie_data = pd.to_datetime(bruto.str.slice(0, 10), errors="coerce", format="%Y/%m/%d").dropna()
903
+ elif bruto.str.match(r"^\d{2}/\d{2}/\d{4}(?:[T\s].*)?$").all():
904
+ serie_data = pd.to_datetime(bruto.str.slice(0, 10), errors="coerce", format="%d/%m/%Y").dropna()
905
+ else:
906
+ try:
907
+ serie_data = pd.to_datetime(bruto, errors="coerce", dayfirst=True, format="mixed").dropna()
908
+ except TypeError:
909
+ serie_data = pd.to_datetime(bruto, errors="coerce", dayfirst=True).dropna()
910
  total = len(bruto)
911
  minimo_amostras = min(3, total)
912
  if len(serie_data) < minimo_amostras:
 
1046
  if filtros.tipo_modelo and not _contains_any([_tipo_modelo_modelo(modelo)], filtros.tipo_modelo):
1047
  return False
1048
 
1049
+ negociacao_modelo = _normalizar_negociacao_modelo(filtros.negociacao_modelo)
1050
+ if negociacao_modelo and _negociacao_modelo_modelo(modelo) != negociacao_modelo:
1051
+ return False
1052
+
1053
  if filtros.finalidade and not _aceita_texto_com_colunas(modelo, filtros.finalidade, "finalidade", fontes_admin.get("finalidade")):
1054
  return False
1055
 
 
1091
  return None
1092
 
1093
 
1094
+ def _normalizar_negociacao_modelo(value: str | None) -> str | None:
1095
+ chave = _normalize(value or "")
1096
+ if not chave:
1097
+ return None
1098
+ if chave in {"aluguel", "locacao", "a"}:
1099
+ return "aluguel"
1100
+ if chave in {"venda", "v"}:
1101
+ return "venda"
1102
+ return None
1103
+
1104
+
1105
  def _normalizar_otica(value: str | None) -> str:
1106
  return "avaliando"
1107
 
 
1124
 
1125
  finalidade_info = filtros.aval_finalidade
1126
  if _is_provided(finalidade_info):
1127
+ termos_finalidade = _split_terms(str(finalidade_info))
1128
+ aceito = any(
1129
+ _aceita_texto_com_colunas(item, termo, "aval_finalidade", fontes_admin.get("aval_finalidade"))
1130
+ for termo in termos_finalidade
1131
+ ) if termos_finalidade else False
1132
+ detalhe = "nenhuma das finalidades informadas foi encontrada no modelo" if len(termos_finalidade) > 1 else "nao encontrada no modelo"
1133
+ registrar("finalidade", finalidade_info, aceito, detalhe)
1134
 
1135
  bairro_info = filtros.aval_bairro
1136
  if _is_provided(bairro_info):
1137
+ termos_bairro = _split_terms(str(bairro_info))
1138
+ aceito = any(
1139
+ _aceita_texto_com_colunas(item, termo, "aval_bairro", fontes_admin.get("aval_bairro"))
1140
+ for termo in termos_bairro
1141
+ ) if termos_bairro else False
1142
+ detalhe = "nenhum dos bairros informados esta na cobertura do modelo" if len(termos_bairro) > 1 else "bairro fora da cobertura do modelo"
1143
+ registrar("bairro", bairro_info, aceito, detalhe)
1144
 
1145
  endereco_info = filtros.aval_endereco
1146
  if _is_provided(endereco_info):
 
1293
 
1294
 
1295
  def _carregar_fontes_admin(colunas_filtro: dict[str, Any]) -> dict[str, list[str]]:
 
1296
  with _ADMIN_CONFIG_LOCK:
1297
+ raw = dict(_ADMIN_FONTES_SESSION or {})
 
 
 
 
 
 
1298
  return _normalizar_fontes_admin(raw, colunas_filtro)
1299
 
1300
 
1301
+ def _salvar_fontes_admin_sessao(fontes_admin: dict[str, list[str]]) -> None:
 
1302
  payload = {campo: _dedupe_strings(valores) for campo, valores in (fontes_admin or {}).items()}
1303
+ global _ADMIN_FONTES_SESSION
1304
  with _ADMIN_CONFIG_LOCK:
1305
+ _ADMIN_FONTES_SESSION = payload
 
 
 
1306
 
1307
 
1308
  def _normalizar_fontes_admin(raw: dict[str, Any], colunas_filtro: dict[str, Any]) -> dict[str, list[str]]:
 
1758
  return _inferir_tipo_por_nome(nome_referencia)
1759
 
1760
 
1761
+ def _negociacao_modelo_modelo(modelo: dict[str, Any]) -> str | None:
1762
+ nome_referencia = _str_or_none(modelo.get("arquivo")) or _str_or_none(modelo.get("nome_modelo")) or ""
1763
+ nome_upper = nome_referencia.upper()
1764
+ if re.search(r"(^|_)A(_|$)", nome_upper):
1765
+ return "aluguel"
1766
+ if re.search(r"(^|_)V(_|$)", nome_upper):
1767
+ return "venda"
1768
+ return None
1769
+
1770
+
1771
  def _extrair_termos_bairro(filtros: PesquisaFiltros) -> list[str]:
1772
  termos: list[str] = []
1773
  if filtros.bairro:
backend/app/services/visualizacao_service.py CHANGED
@@ -136,7 +136,7 @@ def exibir_modelo(session: SessionState) -> dict[str, Any]:
136
  mask = tab_coef["Variável"].astype(str).str.lower().isin(["intercept", "const", "(intercept)"])
137
  if mask.any():
138
  tab_coef = pd.concat([tab_coef[mask], tab_coef[~mask]], ignore_index=True)
139
- tab_coef = tab_coef.round(2)
140
 
141
  tab_obs_calc = pacote["modelo"]["obs_calc"].reset_index().round(2)
142
 
@@ -169,7 +169,7 @@ def exibir_modelo(session: SessionState) -> dict[str, Any]:
169
  "escalas_html": escalas_html,
170
  "dados_transformados": dataframe_to_payload(df_xy, decimals=2),
171
  "resumo_html": resumo_html,
172
- "coeficientes": dataframe_to_payload(tab_coef, decimals=2),
173
  "obs_calc": dataframe_to_payload(tab_obs_calc, decimals=2),
174
  "grafico_obs_calc": figure_to_payload(figs.get("obs_calc")),
175
  "grafico_residuos": figure_to_payload(figs.get("residuos")),
 
136
  mask = tab_coef["Variável"].astype(str).str.lower().isin(["intercept", "const", "(intercept)"])
137
  if mask.any():
138
  tab_coef = pd.concat([tab_coef[mask], tab_coef[~mask]], ignore_index=True)
139
+ tab_coef = tab_coef.round(4)
140
 
141
  tab_obs_calc = pacote["modelo"]["obs_calc"].reset_index().round(2)
142
 
 
169
  "escalas_html": escalas_html,
170
  "dados_transformados": dataframe_to_payload(df_xy, decimals=2),
171
  "resumo_html": resumo_html,
172
+ "coeficientes": dataframe_to_payload(tab_coef, decimals=4),
173
  "obs_calc": dataframe_to_payload(tab_obs_calc, decimals=2),
174
  "grafico_obs_calc": figure_to_payload(figs.get("obs_calc")),
175
  "grafico_residuos": figure_to_payload(figs.get("residuos")),
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -27,6 +27,7 @@ const GRAU_LABEL_CURTO = {
27
  2: 'Grau II',
28
  3: 'Grau III',
29
  }
 
30
 
31
  function grauBadgeClass(value) {
32
  const grau = Number(value)
@@ -3696,7 +3697,9 @@ export default function ElaboracaoTab({ sessionId }) {
3696
  </div>
3697
  <div className="outlier-actions-row">
3698
  <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
3699
- <button type="button" className="btn-filtro-recursivo" onClick={onApplyOutlierFiltersRecursive} disabled={loading}>Aplicar com recursividade</button>
 
 
3700
  <button type="button" className="btn-filtro-add" onClick={onAddFiltro} disabled={loading}>Adicionar filtro</button>
3701
  </div>
3702
  </div>
@@ -3707,7 +3710,12 @@ export default function ElaboracaoTab({ sessionId }) {
3707
  <div className="outlier-inputs-grid">
3708
  <div className="outlier-input-card">
3709
  <label>A excluir</label>
3710
- <input type="text" value={outliersTexto} onChange={(e) => setOutliersTexto(e.target.value)} placeholder="ex: 5, 12, 30" />
 
 
 
 
 
3711
  </div>
3712
  <div className="outlier-input-card">
3713
  <label>A reincluir</label>
 
27
  2: 'Grau II',
28
  3: 'Grau III',
29
  }
30
+ const OUTLIER_RECURSIVO_TOOLTIP = 'Aplicar com recursividade executa os mesmos filtros em ciclos sucessivos: nos bastidores, simula a exclusão dos índices encontrados, recalcula o ajuste do modelo e as métricas de outlier e reaplica os filtros, repetindo até não surgir nenhum índice novo. Para você, o resultado prático é que o campo "A excluir" é preenchido automaticamente com o conjunto total de índices encontrados nessa simulação recursiva.'
31
 
32
  function grauBadgeClass(value) {
33
  const grau = Number(value)
 
3697
  </div>
3698
  <div className="outlier-actions-row">
3699
  <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
3700
+ <span className="btn-filtro-recursivo-wrap" data-tooltip={OUTLIER_RECURSIVO_TOOLTIP}>
3701
+ <button type="button" className="btn-filtro-recursivo" onClick={onApplyOutlierFiltersRecursive} disabled={loading}>Aplicar com recursividade</button>
3702
+ </span>
3703
  <button type="button" className="btn-filtro-add" onClick={onAddFiltro} disabled={loading}>Adicionar filtro</button>
3704
  </div>
3705
  </div>
 
3710
  <div className="outlier-inputs-grid">
3711
  <div className="outlier-input-card">
3712
  <label>A excluir</label>
3713
+ <textarea
3714
+ rows={2}
3715
+ value={outliersTexto}
3716
+ onChange={(e) => setOutliersTexto(e.target.value)}
3717
+ placeholder="ex: 5, 12, 30"
3718
+ />
3719
  </div>
3720
  <div className="outlier-input-card">
3721
  <label>A reincluir</label>
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -7,6 +7,7 @@ import SectionBlock from './SectionBlock'
7
  const EMPTY_FILTERS = {
8
  contemApp: '',
9
  tipoModelo: '',
 
10
  dataMin: '',
11
  dataMax: '',
12
  avalFinalidade: '',
@@ -75,6 +76,37 @@ function normalizeTokenText(value) {
75
  .toUpperCase()
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  function inferTipoPorNomeModelo(...nomes) {
79
  const tokens = Object.keys(TIPO_SIGLAS).sort((a, b) => b.length - a.length)
80
  for (const nome of nomes) {
@@ -106,6 +138,7 @@ function buildApiFilters(filters) {
106
  otica: 'avaliando',
107
  contem_app: filters.contemApp,
108
  tipo_modelo: filters.tipoModelo,
 
109
  aval_finalidade: filters.avalFinalidade,
110
  aval_bairro: filters.avalBairro,
111
  data_min: filters.dataMin,
@@ -163,6 +196,245 @@ function DateFieldInput({ field, ...props }) {
163
  )
164
  }
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  export default function PesquisaTab() {
167
  const [loading, setLoading] = useState(false)
168
  const [error, setError] = useState('')
@@ -380,7 +652,7 @@ export default function PesquisaTab() {
380
  </select>
381
  </label>
382
  <label className="pesquisa-field">
383
- Tipo do modelo
384
  <select
385
  data-field="tipoModelo"
386
  name={toInputName('tipoModelo')}
@@ -395,25 +667,30 @@ export default function PesquisaTab() {
395
  </select>
396
  </label>
397
  <label className="pesquisa-field">
398
- Finalidade do imovel
399
- <TextFieldInput
400
- list="pesquisa-finalidades"
401
  field="avalFinalidade"
402
  value={filters.avalFinalidade}
403
  onChange={onFieldChange}
404
- placeholder="Ex: Apartamento"
 
 
405
  />
406
  </label>
407
 
408
  <label className="pesquisa-field">
409
- Bairro do imovel
410
- <TextFieldInput
411
- list="pesquisa-bairros"
412
- field="avalBairro"
413
- value={filters.avalBairro}
414
  onChange={onFieldChange}
415
- placeholder="Ex: Centro"
416
- />
 
 
 
 
417
  </label>
418
  </div>
419
 
@@ -431,28 +708,30 @@ export default function PesquisaTab() {
431
  </div>
432
 
433
  <div className="pesquisa-avaliando-stack pesquisa-avaliando-bottom-stack">
434
- <label className="pesquisa-field">
435
- Area do imovel
436
- <NumberFieldInput field="avalArea" value={filters.avalArea} onChange={onFieldChange} placeholder="0" />
437
- </label>
438
- <label className="pesquisa-field">
439
- RH do imovel
440
- <NumberFieldInput field="avalRh" value={filters.avalRh} onChange={onFieldChange} placeholder="0" />
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  </label>
442
  </div>
443
  </div>
444
 
445
- <datalist id="pesquisa-finalidades">
446
- {(sugestoes.finalidades || []).map((item) => (
447
- <option key={`finalidade-${item}`} value={item} />
448
- ))}
449
- </datalist>
450
- <datalist id="pesquisa-bairros">
451
- {(sugestoes.bairros || []).map((item) => (
452
- <option key={`bairro-${item}`} value={item} />
453
- ))}
454
- </datalist>
455
-
456
  <div className="row pesquisa-actions pesquisa-actions-primary">
457
  <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
458
  {loading ? 'Pesquisando...' : 'Pesquisar'}
 
7
  const EMPTY_FILTERS = {
8
  contemApp: '',
9
  tipoModelo: '',
10
+ negociacaoModelo: '',
11
  dataMin: '',
12
  dataMax: '',
13
  avalFinalidade: '',
 
76
  .toUpperCase()
77
  }
78
 
79
+ function normalizeSearchText(value) {
80
+ return String(value || '')
81
+ .normalize('NFD')
82
+ .replace(/[\u0300-\u036f]/g, '')
83
+ .toLowerCase()
84
+ .trim()
85
+ }
86
+
87
+ function splitMultiTerms(value) {
88
+ const text = String(value || '').trim()
89
+ const parts = text.includes('||')
90
+ ? text.split(/\s*\|\|\s*/)
91
+ : text.split(/[;|]/)
92
+ const raw = parts
93
+ .map((item) => String(item || '').trim())
94
+ .filter(Boolean)
95
+ const seen = new Set()
96
+ const unique = []
97
+ raw.forEach((item) => {
98
+ const key = normalizeSearchText(item)
99
+ if (!key || seen.has(key)) return
100
+ seen.add(key)
101
+ unique.push(item)
102
+ })
103
+ return unique
104
+ }
105
+
106
+ function joinMultiTerms(values) {
107
+ return (values || []).map((item) => String(item || '').trim()).filter(Boolean).join(' || ')
108
+ }
109
+
110
  function inferTipoPorNomeModelo(...nomes) {
111
  const tokens = Object.keys(TIPO_SIGLAS).sort((a, b) => b.length - a.length)
112
  for (const nome of nomes) {
 
138
  otica: 'avaliando',
139
  contem_app: filters.contemApp,
140
  tipo_modelo: filters.tipoModelo,
141
+ negociacao_modelo: filters.negociacaoModelo,
142
  aval_finalidade: filters.avalFinalidade,
143
  aval_bairro: filters.avalBairro,
144
  data_min: filters.dataMin,
 
196
  )
197
  }
198
 
199
+ function ChipAutocompleteInput({
200
+ field,
201
+ value,
202
+ onChange,
203
+ placeholder,
204
+ suggestions = [],
205
+ panelTitle = 'Sugestoes',
206
+ }) {
207
+ const rootRef = useRef(null)
208
+ const [query, setQuery] = useState('')
209
+ const [open, setOpen] = useState(false)
210
+ const [activeIndex, setActiveIndex] = useState(-1)
211
+ const selectedValues = useMemo(() => splitMultiTerms(value), [value])
212
+ const selectedKeys = useMemo(() => new Set(selectedValues.map((item) => normalizeSearchText(item))), [selectedValues])
213
+ const queryNormalized = normalizeSearchText(query)
214
+
215
+ useEffect(() => {
216
+ if (!value) setQuery('')
217
+ }, [value])
218
+
219
+ const filteredSuggestions = useMemo(() => {
220
+ const unique = []
221
+ const seen = new Set()
222
+
223
+ ;(suggestions || []).forEach((item) => {
224
+ const text = String(item || '').trim()
225
+ if (!text) return
226
+ const key = normalizeSearchText(text)
227
+ if (!key || seen.has(key) || selectedKeys.has(key)) return
228
+ seen.add(key)
229
+ unique.push(text)
230
+ })
231
+
232
+ if (!queryNormalized) return unique.slice(0, 120)
233
+ return unique
234
+ .filter((item) => normalizeSearchText(item).includes(queryNormalized))
235
+ .slice(0, 120)
236
+ }, [suggestions, queryNormalized, selectedKeys])
237
+
238
+ useEffect(() => {
239
+ if (!open) return undefined
240
+ function onDocumentMouseDown(event) {
241
+ if (!rootRef.current) return
242
+ if (!rootRef.current.contains(event.target)) {
243
+ setOpen(false)
244
+ }
245
+ }
246
+ document.addEventListener('mousedown', onDocumentMouseDown)
247
+ return () => document.removeEventListener('mousedown', onDocumentMouseDown)
248
+ }, [open])
249
+
250
+ useEffect(() => {
251
+ if (!open || !filteredSuggestions.length) {
252
+ setActiveIndex(-1)
253
+ return
254
+ }
255
+ setActiveIndex(-1)
256
+ }, [filteredSuggestions, open])
257
+
258
+ function emitValue(nextValue) {
259
+ onChange({
260
+ target: {
261
+ value: nextValue,
262
+ dataset: { field },
263
+ name: toInputName(field),
264
+ },
265
+ })
266
+ }
267
+
268
+ function setSelectedValues(nextSelected) {
269
+ emitValue(joinMultiTerms(nextSelected))
270
+ }
271
+
272
+ function addValue(nextValue) {
273
+ const text = String(nextValue || '').trim()
274
+ if (!text) return
275
+ const key = normalizeSearchText(text)
276
+ if (!key || selectedKeys.has(key)) {
277
+ setQuery('')
278
+ return
279
+ }
280
+ setSelectedValues([...selectedValues, text])
281
+ setQuery('')
282
+ setOpen(true)
283
+ setActiveIndex(-1)
284
+ }
285
+
286
+ function removeValue(nextValue) {
287
+ const target = String(nextValue || '').trim()
288
+ if (!target) return
289
+
290
+ const exactIndex = selectedValues.findIndex((item) => String(item || '').trim() === target)
291
+ if (exactIndex >= 0) {
292
+ const nextSelected = [...selectedValues]
293
+ nextSelected.splice(exactIndex, 1)
294
+ setSelectedValues(nextSelected)
295
+ return
296
+ }
297
+
298
+ const key = normalizeSearchText(target)
299
+ const normalizedIndex = selectedValues.findIndex((item) => normalizeSearchText(item) === key)
300
+ if (normalizedIndex < 0) return
301
+ const nextSelected = [...selectedValues]
302
+ nextSelected.splice(normalizedIndex, 1)
303
+ setSelectedValues(nextSelected)
304
+ }
305
+
306
+ function onInputChange(event) {
307
+ setQuery(event.target.value)
308
+ setOpen(true)
309
+ setActiveIndex(-1)
310
+ }
311
+
312
+ function onInputKeyDown(event) {
313
+ if (event.key === 'Escape') {
314
+ setOpen(false)
315
+ return
316
+ }
317
+
318
+ if (!filteredSuggestions.length) return
319
+
320
+ if (event.key === 'ArrowDown') {
321
+ event.preventDefault()
322
+ setOpen(true)
323
+ setActiveIndex((prev) => (prev < 0 ? 0 : (prev + 1) % filteredSuggestions.length))
324
+ return
325
+ }
326
+
327
+ if (event.key === 'ArrowUp') {
328
+ event.preventDefault()
329
+ setOpen(true)
330
+ setActiveIndex((prev) => {
331
+ if (prev < 0) return filteredSuggestions.length - 1
332
+ return (prev - 1 + filteredSuggestions.length) % filteredSuggestions.length
333
+ })
334
+ return
335
+ }
336
+
337
+ if (event.key === 'Enter' && open && activeIndex >= 0) {
338
+ event.preventDefault()
339
+ addValue(filteredSuggestions[activeIndex])
340
+ return
341
+ }
342
+
343
+ if (event.key === 'Enter') {
344
+ const next = String(query || '').trim()
345
+ if (next) {
346
+ event.preventDefault()
347
+ addValue(next)
348
+ }
349
+ return
350
+ }
351
+
352
+ if (event.key === ',' || event.key === ';' || event.key === '|') {
353
+ const next = String(query || '').trim()
354
+ if (next) {
355
+ event.preventDefault()
356
+ addValue(next)
357
+ }
358
+ return
359
+ }
360
+
361
+ if (event.key === 'Backspace' && !query && selectedValues.length) {
362
+ const nextSelected = selectedValues.slice(0, -1)
363
+ setSelectedValues(nextSelected)
364
+ }
365
+ }
366
+
367
+ return (
368
+ <div className={`chip-autocomplete${open ? ' is-open' : ''}`} ref={rootRef}>
369
+ {selectedValues.length ? (
370
+ <div className="chip-autocomplete-selected-wrap">
371
+ {selectedValues.map((item) => (
372
+ <span key={`${field}-selected-${item}`} className="chip-autocomplete-selected">
373
+ <span>{item}</span>
374
+ <button
375
+ type="button"
376
+ className="chip-autocomplete-selected-remove"
377
+ onMouseDown={(event) => {
378
+ event.preventDefault()
379
+ event.stopPropagation()
380
+ removeValue(item)
381
+ }}
382
+ onClick={(event) => {
383
+ event.preventDefault()
384
+ event.stopPropagation()
385
+ }}
386
+ aria-label={`Remover ${item}`}
387
+ >
388
+ ×
389
+ </button>
390
+ </span>
391
+ ))}
392
+ </div>
393
+ ) : null}
394
+
395
+ <TextFieldInput
396
+ field={field}
397
+ value={query}
398
+ onChange={onInputChange}
399
+ onFocus={() => {
400
+ setOpen(true)
401
+ setActiveIndex(-1)
402
+ }}
403
+ onKeyDown={onInputKeyDown}
404
+ placeholder={placeholder}
405
+ />
406
+
407
+ {open ? (
408
+ <div className="chip-autocomplete-panel" role="listbox">
409
+ <div className="chip-autocomplete-panel-head">{panelTitle}</div>
410
+ {filteredSuggestions.length ? (
411
+ <div className="chip-autocomplete-chip-wrap">
412
+ {filteredSuggestions.map((item, idx) => (
413
+ <button
414
+ type="button"
415
+ key={`${field}-chip-${item}-${idx}`}
416
+ className={`chip-autocomplete-chip${idx === activeIndex ? ' is-active' : ''}`}
417
+ onMouseDown={(event) => {
418
+ event.preventDefault()
419
+ event.stopPropagation()
420
+ addValue(item)
421
+ }}
422
+ >
423
+ {item}
424
+ </button>
425
+ ))}
426
+ </div>
427
+ ) : (
428
+ <div className="chip-autocomplete-empty">
429
+ Nenhuma sugestao encontrada.
430
+ </div>
431
+ )}
432
+ </div>
433
+ ) : null}
434
+ </div>
435
+ )
436
+ }
437
+
438
  export default function PesquisaTab() {
439
  const [loading, setLoading] = useState(false)
440
  const [error, setError] = useState('')
 
652
  </select>
653
  </label>
654
  <label className="pesquisa-field">
655
+ Finalidade Generica
656
  <select
657
  data-field="tipoModelo"
658
  name={toInputName('tipoModelo')}
 
667
  </select>
668
  </label>
669
  <label className="pesquisa-field">
670
+ Finalidade Cadastral
671
+ <ChipAutocompleteInput
 
672
  field="avalFinalidade"
673
  value={filters.avalFinalidade}
674
  onChange={onFieldChange}
675
+ placeholder="Digite e pressione Enter"
676
+ suggestions={sugestoes.finalidades || []}
677
+ panelTitle="Finalidades sugeridas"
678
  />
679
  </label>
680
 
681
  <label className="pesquisa-field">
682
+ Tipo do Modelo
683
+ <select
684
+ data-field="negociacaoModelo"
685
+ name={toInputName('negociacaoModelo')}
686
+ value={filters.negociacaoModelo}
687
  onChange={onFieldChange}
688
+ autoComplete="off"
689
+ >
690
+ <option value="">Indiferente</option>
691
+ <option value="aluguel">Aluguel</option>
692
+ <option value="venda">Venda</option>
693
+ </select>
694
  </label>
695
  </div>
696
 
 
708
  </div>
709
 
710
  <div className="pesquisa-avaliando-stack pesquisa-avaliando-bottom-stack">
711
+ <div className="pesquisa-area-rh-grid">
712
+ <label className="pesquisa-field">
713
+ Area do imovel
714
+ <NumberFieldInput field="avalArea" value={filters.avalArea} onChange={onFieldChange} placeholder="0" />
715
+ </label>
716
+ <label className="pesquisa-field">
717
+ RH do imovel
718
+ <NumberFieldInput field="avalRh" value={filters.avalRh} onChange={onFieldChange} placeholder="0" />
719
+ </label>
720
+ </div>
721
+ <label className="pesquisa-field pesquisa-bairro-bottom-field">
722
+ Bairro do imovel
723
+ <ChipAutocompleteInput
724
+ field="avalBairro"
725
+ value={filters.avalBairro}
726
+ onChange={onFieldChange}
727
+ placeholder="Digite e pressione Enter"
728
+ suggestions={sugestoes.bairros || []}
729
+ panelTitle="Bairros sugeridos"
730
+ />
731
  </label>
732
  </div>
733
  </div>
734
 
 
 
 
 
 
 
 
 
 
 
 
735
  <div className="row pesquisa-actions pesquisa-actions-primary">
736
  <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
737
  {loading ? 'Pesquisando...' : 'Pesquisar'}
frontend/src/styles.css CHANGED
@@ -911,6 +911,119 @@ button.pesquisa-otica-btn.active:hover {
911
  width: 100%;
912
  }
913
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
914
  .pesquisa-filtros-groups .pesquisa-field input,
915
  .pesquisa-filtros-groups .pesquisa-field select {
916
  width: min(100%, 255px);
@@ -997,6 +1110,13 @@ button.pesquisa-otica-btn.active:hover {
997
  align-items: start;
998
  }
999
 
 
 
 
 
 
 
 
1000
  .pesquisa-avaliando-periodo-pair {
1001
  margin: 0;
1002
  }
@@ -1005,6 +1125,10 @@ button.pesquisa-otica-btn.active:hover {
1005
  gap: 12px;
1006
  }
1007
 
 
 
 
 
1008
  .pesquisa-avaliando-bottom-grid .pesquisa-field-pair {
1009
  grid-column: auto;
1010
  margin: 0;
@@ -2864,7 +2988,7 @@ button.btn-upload-select {
2864
 
2865
  .outlier-inputs-grid {
2866
  display: grid;
2867
- grid-template-columns: repeat(2, minmax(0, 1fr));
2868
  gap: 12px;
2869
  }
2870
 
@@ -2884,6 +3008,13 @@ button.btn-upload-select {
2884
  width: 100%;
2885
  }
2886
 
 
 
 
 
 
 
 
2887
  .btn-reiniciar-iteracao {
2888
  --btn-bg-start: #ff8c00;
2889
  --btn-bg-end: #e67e00;
@@ -2973,11 +3104,68 @@ button.btn-upload-select {
2973
  }
2974
 
2975
  .outlier-actions-row button.btn-filtro-recursivo {
2976
- --btn-bg-start: #8f6bd9;
2977
- --btn-bg-end: #7552c3;
2978
- --btn-border: #6848b4;
2979
- --btn-shadow-soft: rgba(117, 82, 195, 0.2);
2980
- --btn-shadow-strong: rgba(117, 82, 195, 0.28);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2981
  }
2982
 
2983
  .resumo-outliers-box {
@@ -3758,6 +3946,10 @@ button.btn-download-subtle {
3758
  grid-template-columns: 1fr;
3759
  }
3760
 
 
 
 
 
3761
  .pesquisa-dynamic-filter-row,
3762
  .pesquisa-range-values-row,
3763
  .pesquisa-range-row,
 
911
  width: 100%;
912
  }
913
 
914
+ .chip-autocomplete {
915
+ position: relative;
916
+ }
917
+
918
+ .chip-autocomplete-selected-wrap {
919
+ display: flex;
920
+ flex-wrap: wrap;
921
+ gap: 6px;
922
+ margin: 0 0 7px;
923
+ }
924
+
925
+ .chip-autocomplete-selected {
926
+ display: inline-flex;
927
+ align-items: center;
928
+ flex: 0 0 auto;
929
+ gap: 6px;
930
+ border: 1px solid #b7cee6;
931
+ border-radius: 999px;
932
+ background: #eaf3fc;
933
+ color: #2f4d69;
934
+ font-size: 0.77rem;
935
+ font-weight: 700;
936
+ line-height: 1.1;
937
+ padding: 4px 8px;
938
+ }
939
+
940
+ .chip-autocomplete-selected-remove {
941
+ display: inline-flex;
942
+ align-items: center;
943
+ justify-content: center;
944
+ flex: 0 0 16px;
945
+ width: 16px;
946
+ height: 16px;
947
+ border: none;
948
+ background: transparent;
949
+ color: #365776;
950
+ font-size: 0.9rem;
951
+ font-weight: 700;
952
+ line-height: 1;
953
+ padding: 0;
954
+ margin: 0;
955
+ border-radius: 999px;
956
+ box-shadow: none;
957
+ transform: none;
958
+ cursor: pointer;
959
+ }
960
+
961
+ .chip-autocomplete-selected-remove:hover {
962
+ color: #20374f;
963
+ background: rgba(54, 87, 118, 0.09);
964
+ box-shadow: none;
965
+ transform: none;
966
+ }
967
+
968
+ .chip-autocomplete-panel {
969
+ position: absolute;
970
+ top: calc(100% + 6px);
971
+ left: 0;
972
+ right: 0;
973
+ z-index: 35;
974
+ border: 1px solid #c8d7e6;
975
+ border-radius: 12px;
976
+ background: #ffffff;
977
+ box-shadow: 0 8px 24px rgba(46, 77, 107, 0.14);
978
+ padding: 8px;
979
+ max-height: 190px;
980
+ overflow: auto;
981
+ }
982
+
983
+ .chip-autocomplete-panel-head {
984
+ font-size: 0.72rem;
985
+ font-weight: 700;
986
+ color: #4f667d;
987
+ margin: 1px 1px 7px;
988
+ text-transform: uppercase;
989
+ letter-spacing: 0.03em;
990
+ }
991
+
992
+ .chip-autocomplete-chip-wrap {
993
+ display: flex;
994
+ flex-wrap: wrap;
995
+ gap: 6px;
996
+ }
997
+
998
+ .chip-autocomplete-chip {
999
+ border: 1px solid #c9d9ea;
1000
+ border-radius: 999px;
1001
+ background: #f5f9fd;
1002
+ color: #37516b;
1003
+ font-size: 0.78rem;
1004
+ font-weight: 600;
1005
+ line-height: 1.15;
1006
+ padding: 5px 9px;
1007
+ cursor: pointer;
1008
+ max-width: 100%;
1009
+ white-space: nowrap;
1010
+ overflow: hidden;
1011
+ text-overflow: ellipsis;
1012
+ }
1013
+
1014
+ .chip-autocomplete-chip:hover,
1015
+ .chip-autocomplete-chip.is-active {
1016
+ background: #dcecff;
1017
+ border-color: #aac7e8;
1018
+ color: #233b54;
1019
+ }
1020
+
1021
+ .chip-autocomplete-empty {
1022
+ font-size: 0.8rem;
1023
+ color: #657f99;
1024
+ padding: 2px 2px 5px;
1025
+ }
1026
+
1027
  .pesquisa-filtros-groups .pesquisa-field input,
1028
  .pesquisa-filtros-groups .pesquisa-field select {
1029
  width: min(100%, 255px);
 
1110
  align-items: start;
1111
  }
1112
 
1113
+ .pesquisa-area-rh-grid {
1114
+ display: grid;
1115
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1116
+ gap: 12px 14px;
1117
+ align-items: start;
1118
+ }
1119
+
1120
  .pesquisa-avaliando-periodo-pair {
1121
  margin: 0;
1122
  }
 
1125
  gap: 12px;
1126
  }
1127
 
1128
+ .pesquisa-bairro-bottom-field {
1129
+ grid-column: 1 / -1;
1130
+ }
1131
+
1132
  .pesquisa-avaliando-bottom-grid .pesquisa-field-pair {
1133
  grid-column: auto;
1134
  margin: 0;
 
2988
 
2989
  .outlier-inputs-grid {
2990
  display: grid;
2991
+ grid-template-columns: minmax(0, 1fr);
2992
  gap: 12px;
2993
  }
2994
 
 
3008
  width: 100%;
3009
  }
3010
 
3011
+ .outlier-input-card textarea {
3012
+ width: 100%;
3013
+ resize: vertical;
3014
+ min-height: 58px;
3015
+ max-height: 160px;
3016
+ }
3017
+
3018
  .btn-reiniciar-iteracao {
3019
  --btn-bg-start: #ff8c00;
3020
  --btn-bg-end: #e67e00;
 
3104
  }
3105
 
3106
  .outlier-actions-row button.btn-filtro-recursivo {
3107
+ --btn-bg-start: #59b97f;
3108
+ --btn-bg-end: #3d9c63;
3109
+ --btn-border: #338655;
3110
+ --btn-shadow-soft: rgba(61, 156, 99, 0.2);
3111
+ --btn-shadow-strong: rgba(61, 156, 99, 0.28);
3112
+ }
3113
+
3114
+ .outlier-actions-row .btn-filtro-recursivo-wrap {
3115
+ position: relative;
3116
+ display: inline-flex;
3117
+ }
3118
+
3119
+ .outlier-actions-row .btn-filtro-recursivo-wrap::after {
3120
+ content: attr(data-tooltip);
3121
+ position: absolute;
3122
+ left: calc(100% + 10px);
3123
+ bottom: calc(100% + 2px);
3124
+ width: min(520px, 68vw);
3125
+ max-width: 520px;
3126
+ background: #f4fbf7;
3127
+ border: 1px solid #bfe4cc;
3128
+ border-radius: 10px;
3129
+ color: #2f5f43;
3130
+ font-size: 0.78rem;
3131
+ line-height: 1.35;
3132
+ font-weight: 600;
3133
+ padding: 10px 12px;
3134
+ box-shadow: 0 8px 20px rgba(47, 95, 67, 0.18);
3135
+ opacity: 0;
3136
+ transform: translateY(4px);
3137
+ transition: opacity 0.14s ease, transform 0.14s ease;
3138
+ pointer-events: none;
3139
+ z-index: 30;
3140
+ }
3141
+
3142
+ .outlier-actions-row .btn-filtro-recursivo-wrap::before {
3143
+ content: '';
3144
+ position: absolute;
3145
+ left: calc(100% + 4px);
3146
+ bottom: calc(100% + 12px);
3147
+ width: 10px;
3148
+ height: 10px;
3149
+ background: #f4fbf7;
3150
+ border-left: 1px solid #bfe4cc;
3151
+ border-top: 1px solid #bfe4cc;
3152
+ transform: rotate(225deg);
3153
+ opacity: 0;
3154
+ transition: opacity 0.14s ease, transform 0.14s ease;
3155
+ pointer-events: none;
3156
+ z-index: 29;
3157
+ }
3158
+
3159
+ .outlier-actions-row .btn-filtro-recursivo-wrap:hover::after,
3160
+ .outlier-actions-row .btn-filtro-recursivo-wrap:focus-within::after {
3161
+ opacity: 1;
3162
+ transform: translateY(0);
3163
+ }
3164
+
3165
+ .outlier-actions-row .btn-filtro-recursivo-wrap:hover::before,
3166
+ .outlier-actions-row .btn-filtro-recursivo-wrap:focus-within::before {
3167
+ opacity: 1;
3168
+ transform: rotate(225deg);
3169
  }
3170
 
3171
  .resumo-outliers-box {
 
3946
  grid-template-columns: 1fr;
3947
  }
3948
 
3949
+ .pesquisa-area-rh-grid {
3950
+ grid-template-columns: 1fr;
3951
+ }
3952
+
3953
  .pesquisa-dynamic-filter-row,
3954
  .pesquisa-range-values-row,
3955
  .pesquisa-range-row,