Guilherme Silberfarb Costa commited on
Commit
2e13456
·
1 Parent(s): c2c2480

diversas atualizacoes na aba pesquisas e na estrutura do app

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -124,6 +124,10 @@ class UpdateMapaPayload(SessionPayload):
124
  variavel_mapa: str | None = None
125
 
126
 
 
 
 
 
127
  @router.post("/upload")
128
  async def upload_file(
129
  session_id: str = Form(...),
@@ -333,6 +337,18 @@ def map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
333
  return elaboracao_service.atualizar_mapa(session, payload.variavel_mapa)
334
 
335
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  @router.get("/context")
337
  def context(session_id: str) -> dict[str, Any]:
338
  session = session_store.get(session_id)
@@ -344,4 +360,10 @@ def context(session_id: str) -> dict[str, Any]:
344
  "percentuais": session.percentuais,
345
  "outliers_anteriores": session.outliers_anteriores,
346
  "iteracao": session.iteracao,
 
 
 
 
 
 
347
  }
 
124
  variavel_mapa: str | None = None
125
 
126
 
127
+ class ColunaDataMercadoPayload(SessionPayload):
128
+ coluna_data: str
129
+
130
+
131
  @router.post("/upload")
132
  async def upload_file(
133
  session_id: str = Form(...),
 
337
  return elaboracao_service.atualizar_mapa(session, payload.variavel_mapa)
338
 
339
 
340
+ @router.post("/market-date/preview")
341
+ def market_date_preview(payload: ColunaDataMercadoPayload) -> dict[str, Any]:
342
+ session = session_store.get(payload.session_id)
343
+ return elaboracao_service.previsualizar_coluna_data_mercado(session, payload.coluna_data)
344
+
345
+
346
+ @router.post("/market-date/apply")
347
+ def market_date_apply(payload: ColunaDataMercadoPayload) -> dict[str, Any]:
348
+ session = session_store.get(payload.session_id)
349
+ return elaboracao_service.aplicar_coluna_data_mercado(session, payload.coluna_data)
350
+
351
+
352
  @router.get("/context")
353
  def context(session_id: str) -> dict[str, Any]:
354
  session = session_store.get(session_id)
 
360
  "percentuais": session.percentuais,
361
  "outliers_anteriores": session.outliers_anteriores,
362
  "iteracao": session.iteracao,
363
+ "coluna_data_mercado": session.coluna_data_mercado,
364
+ "periodo_dados_mercado": {
365
+ "coluna_data": session.coluna_data_mercado,
366
+ "data_inicial": session.periodo_dados_mercado_inicio,
367
+ "data_final": session.periodo_dados_mercado_fim,
368
+ },
369
  }
backend/app/api/pesquisa.py CHANGED
@@ -42,10 +42,11 @@ def pesquisar_admin_config_salvar(payload: PesquisaAdminConfigPayload) -> dict:
42
  @router.get("/modelos")
43
  def pesquisar_modelos(
44
  somente_contexto: bool = Query(False),
45
- otica: str = Query("modelo"),
46
  nome: str | None = Query(None),
47
  autor: str | None = Query(None),
48
  contem_app: str | None = Query(None),
 
49
  finalidade: str | None = Query(None),
50
  finalidade_colunas: str | None = Query(None),
51
  bairro: str | None = Query(None),
@@ -87,6 +88,7 @@ def pesquisar_modelos(
87
  nome=nome,
88
  autor=autor,
89
  contem_app=contem_app,
 
90
  finalidade=finalidade,
91
  finalidade_colunas=_split_csv(finalidade_colunas),
92
  bairro=bairro,
 
42
  @router.get("/modelos")
43
  def pesquisar_modelos(
44
  somente_contexto: bool = Query(False),
45
+ otica: str = Query("avaliando"),
46
  nome: str | None = Query(None),
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),
 
88
  nome=nome,
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,
backend/app/core/elaboracao/carregamento.py CHANGED
@@ -258,7 +258,21 @@ def carregar_dados_de_dai(caminho_arquivo):
258
  Consome aplicar_selecao_callback por índice (r[0], r[1], ..., r[23:]).
259
  Consome ajustar_modelo_callback por índice (m[0], ..., m[32]).
260
  """
261
- df, coluna_y, colunas_x, transformacao_y, transformacoes_x, dicotomicas, codigo_alocado, percentuais, msg, sucesso, elaborador, outliers_excluidos = carregar_dai(caminho_arquivo)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
  nome_exibicao = os.path.basename(caminho_arquivo)
264
  html_nome = f"<h2 style='margin:0 0 12px 0; font-size:1.4em;'>{nome_exibicao}</h2>"
 
258
  Consome aplicar_selecao_callback por índice (r[0], r[1], ..., r[23:]).
259
  Consome ajustar_modelo_callback por índice (m[0], ..., m[32]).
260
  """
261
+ (
262
+ df,
263
+ coluna_y,
264
+ colunas_x,
265
+ transformacao_y,
266
+ transformacoes_x,
267
+ dicotomicas,
268
+ codigo_alocado,
269
+ percentuais,
270
+ msg,
271
+ sucesso,
272
+ elaborador,
273
+ outliers_excluidos,
274
+ _periodo_dados_mercado,
275
+ ) = carregar_dai(caminho_arquivo)
276
 
277
  nome_exibicao = os.path.basename(caminho_arquivo)
278
  html_nome = f"<h2 style='margin:0 0 12px 0; font-size:1.4em;'>{nome_exibicao}</h2>"
backend/app/core/elaboracao/core.py CHANGED
@@ -142,6 +142,11 @@ def _migrar_pacote_v1_para_v2(pacote):
142
  resumo = pacote.get("modelos_resumos", {})
143
  return {
144
  "versao": 2,
 
 
 
 
 
145
  "dados": {
146
  "df": pacote["Xy_preview_out_coords"],
147
  "estatisticas": pacote["estatisticas"],
@@ -194,12 +199,40 @@ def _migrar_pacote_v1_para_v2(pacote):
194
  }
195
 
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  def carregar_dai(caminho):
198
  """
199
  Carrega arquivo .dai e extrai DataFrame, variáveis e transformações.
200
 
201
  Retorna:
202
- tuple: (df, coluna_y, colunas_x, transformacao_y, transformacoes_x, dicotomicas, codigo_alocado, percentuais, mensagem, sucesso)
203
  """
204
  try:
205
  pacote = load(caminho)
@@ -211,6 +244,9 @@ def carregar_dai(caminho):
211
  # Extrai novos campos (se existirem)
212
  df_completo = pacote["dados"].get("df_completo", None)
213
  outliers_excluidos = pacote["dados"].get("outliers_excluidos", [])
 
 
 
214
 
215
  # Extrai DataFrame — preserva índices originais se df_completo disponível
216
  if df_completo is not None:
@@ -270,10 +306,38 @@ def carregar_dai(caminho):
270
 
271
  elaborador = pacote.get("elaborador", None)
272
  msg = f"Modelo .dai carregado: {os.path.basename(caminho)} ({len(df)} dados, Y={coluna_y}, {len(colunas_x)} variáveis X)"
273
- return df, coluna_y, colunas_x, transformacao_y, transformacoes_x, dicotomicas, codigo_alocado_salvo or [], percentuais_salvo or [], msg, True, elaborador, outliers_excluidos
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  except Exception as e:
276
- return None, None, None, None, None, [], [], [], f"Erro ao carregar .dai: {str(e)}", False, None, []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
 
279
  def identificar_coluna_y_padrao(df):
@@ -1717,7 +1781,8 @@ def exportar_base_csv(df):
1717
  # ============================================================
1718
 
1719
  def exportar_modelo_dai(resultado_modelo, df_original, df_completo=None, estatisticas=None,
1720
- nome_arquivo="", elaborador=None, outliers_excluidos=None):
 
1721
  """
1722
  Exporta o modelo em formato .dai v2 (estrutura nested).
1723
 
@@ -1729,6 +1794,7 @@ def exportar_modelo_dai(resultado_modelo, df_original, df_completo=None, estatis
1729
  nome_arquivo: nome do arquivo de saída
1730
  elaborador: dict com dados do avaliador (ou None)
1731
  outliers_excluidos: lista de índices excluídos como outliers (ou None)
 
1732
 
1733
  Retorna:
1734
  tuple: (caminho_arquivo, mensagem)
@@ -1848,6 +1914,7 @@ def exportar_modelo_dai(resultado_modelo, df_original, df_completo=None, estatis
1848
  pacote = {
1849
  "versao": 2,
1850
  "elaborador": elaborador,
 
1851
  "dados": {
1852
  "df": df_modelo,
1853
  "df_completo": df_completo_export,
 
142
  resumo = pacote.get("modelos_resumos", {})
143
  return {
144
  "versao": 2,
145
+ "periodo_dados_mercado": {
146
+ "coluna_data": None,
147
+ "data_inicial": None,
148
+ "data_final": None,
149
+ },
150
  "dados": {
151
  "df": pacote["Xy_preview_out_coords"],
152
  "estatisticas": pacote["estatisticas"],
 
199
  }
200
 
201
 
202
+ 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):
211
+ return None
212
+ return dt.date().isoformat()
213
+
214
+
215
+ def _normalizar_periodo_dados_mercado(periodo):
216
+ """Garante payload padronizado para período de dados de mercado."""
217
+ if not isinstance(periodo, dict):
218
+ periodo = {}
219
+ coluna_data = periodo.get("coluna_data", None)
220
+ coluna_data = str(coluna_data).strip() if coluna_data is not None else None
221
+ if coluna_data == "":
222
+ coluna_data = None
223
+ return {
224
+ "coluna_data": coluna_data,
225
+ "data_inicial": _normalizar_data_iso(periodo.get("data_inicial")),
226
+ "data_final": _normalizar_data_iso(periodo.get("data_final")),
227
+ }
228
+
229
+
230
  def carregar_dai(caminho):
231
  """
232
  Carrega arquivo .dai e extrai DataFrame, variáveis e transformações.
233
 
234
  Retorna:
235
+ tuple: (df, coluna_y, colunas_x, transformacao_y, transformacoes_x, dicotomicas, codigo_alocado, percentuais, mensagem, sucesso, elaborador, outliers_excluidos, periodo_dados_mercado)
236
  """
237
  try:
238
  pacote = load(caminho)
 
244
  # Extrai novos campos (se existirem)
245
  df_completo = pacote["dados"].get("df_completo", None)
246
  outliers_excluidos = pacote["dados"].get("outliers_excluidos", [])
247
+ periodo_dados_mercado = _normalizar_periodo_dados_mercado(
248
+ pacote.get("periodo_dados_mercado", None)
249
+ )
250
 
251
  # Extrai DataFrame — preserva índices originais se df_completo disponível
252
  if df_completo is not None:
 
306
 
307
  elaborador = pacote.get("elaborador", None)
308
  msg = f"Modelo .dai carregado: {os.path.basename(caminho)} ({len(df)} dados, Y={coluna_y}, {len(colunas_x)} variáveis X)"
309
+ return (
310
+ df,
311
+ coluna_y,
312
+ colunas_x,
313
+ transformacao_y,
314
+ transformacoes_x,
315
+ dicotomicas,
316
+ codigo_alocado_salvo or [],
317
+ percentuais_salvo or [],
318
+ msg,
319
+ True,
320
+ elaborador,
321
+ outliers_excluidos,
322
+ periodo_dados_mercado,
323
+ )
324
 
325
  except Exception as e:
326
+ return (
327
+ None,
328
+ None,
329
+ None,
330
+ None,
331
+ None,
332
+ [],
333
+ [],
334
+ [],
335
+ f"Erro ao carregar .dai: {str(e)}",
336
+ False,
337
+ None,
338
+ [],
339
+ {"data_inicial": None, "data_final": None},
340
+ )
341
 
342
 
343
  def identificar_coluna_y_padrao(df):
 
1781
  # ============================================================
1782
 
1783
  def exportar_modelo_dai(resultado_modelo, df_original, df_completo=None, estatisticas=None,
1784
+ nome_arquivo="", elaborador=None, outliers_excluidos=None,
1785
+ periodo_dados_mercado=None):
1786
  """
1787
  Exporta o modelo em formato .dai v2 (estrutura nested).
1788
 
 
1794
  nome_arquivo: nome do arquivo de saída
1795
  elaborador: dict com dados do avaliador (ou None)
1796
  outliers_excluidos: lista de índices excluídos como outliers (ou None)
1797
+ periodo_dados_mercado: dict com data_inicial e data_final dos dados de mercado
1798
 
1799
  Retorna:
1800
  tuple: (caminho_arquivo, mensagem)
 
1914
  pacote = {
1915
  "versao": 2,
1916
  "elaborador": elaborador,
1917
+ "periodo_dados_mercado": _normalizar_periodo_dados_mercado(periodo_dados_mercado),
1918
  "dados": {
1919
  "df": df_modelo,
1920
  "df_completo": df_completo_export,
backend/app/core/pesquisa/modelos_dai/MOD_A_SALA_Z1_006C.dai CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:5b318a58a4c4d12863e1f867e4bd63066a07c13bba345e7381d0b2ebe880ae16
3
- size 303169
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0398acda7bfaa01c782d0b551aa679b21afa85bc3882e5249b5cddceb7b733fd
3
+ size 305003
backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_011D.dai CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:aa5df27fa5189ad426aef21abdb20b0f6a46d80691fd1baf3fd24425669b1285
3
- size 5305080
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:61ff06842995a9e3d559e33c51aaaa584f33477df414ca16abc3861cfc14a6c9
3
+ size 5307303
backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_020B.dai CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:746ca361b72784b56428a8e8c8c5aec6510ae2e8f429af98b2d128cd130c1372
3
- size 1995943
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:319994b70fe36527d8a198c87383b0de819d12a83f5098f4ef89bdfb013fb86f
3
+ size 1999486
backend/app/core/pesquisa/modelos_dai/MOD_V_AP_Z1_022.dai CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:32ab5afc7449171d1ecdd919d5d79b3e57154df13a582fd804e0017da45525d9
3
- size 1315111
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e263780042eb5ec056a49ee8862ed9523bebeac831e5ca6a0f7671ec66097413
3
+ size 1318536
backend/app/core/pesquisa/modelos_dai/MOD_V_EDIF_Z1_Z2_Z3_Z4_002E.dai CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:b5c61890fd372f035bd18b71bc16fcf25cb17c267fab25992afdeb5c1a7a261f
3
- size 1582885
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:96391e419571bfb37a208a56a969aef8a175e26b11e86f0af99deba40dca18bf
3
+ size 1584486
backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z2_008C.dai CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:6fe2eb81570027803174b9140066d8ff36ba7583fa1d1349ce9941b0a6692f52
3
- size 822283
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5f802758c2a569aba28986de3c0dbf1e4c436556a0ba7ee909d6576de6e3f719
3
+ size 824046
backend/app/core/pesquisa/modelos_dai/MOD_V_TER_Z4_003J.dai CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c78101a594ad36c76c13c564894152c276c7f1a34e7f38bb157d8aca838050e9
3
- size 1558637
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9e4fc5f42b69b3f912853ca1bc14ade1246187ce80288b461ea1064c6d743575
3
+ size 1559857
backend/app/core/visualizacao/app.py CHANGED
@@ -1234,6 +1234,18 @@ def _formatar_badge_completo(pacote):
1234
  if not pacote:
1235
  return ""
1236
 
 
 
 
 
 
 
 
 
 
 
 
 
1237
  # --- Lado direito: lista de variáveis ---
1238
  variaveis_html = ""
1239
  info = pacote.get("transformacoes", {}).get("info")
@@ -1266,11 +1278,25 @@ def _formatar_badge_completo(pacote):
1266
  x_badges.append(badge)
1267
  variaveis_html = (
1268
  "<div style='font-size:0.9em;line-height:2;'>"
1269
- "<div><span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;'>Variável Dependente:</span>"
1270
  + y_badge + "</div>"
1271
- "<div style='margin-top:4px;'>"
1272
- "<span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;'>Variáveis Independentes:</span>"
1273
  + "".join(x_badges) + "</div>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1274
  "</div>"
1275
  )
1276
 
 
1234
  if not pacote:
1235
  return ""
1236
 
1237
+ def _data_br(value):
1238
+ texto = str(value or "").strip()
1239
+ match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", texto)
1240
+ if match:
1241
+ return f"{match.group(3)}/{match.group(2)}/{match.group(1)}"
1242
+ return texto
1243
+
1244
+ periodo = pacote.get("periodo_dados_mercado") or {}
1245
+ data_inicial = _data_br(periodo.get("data_inicial"))
1246
+ data_final = _data_br(periodo.get("data_final"))
1247
+ periodo_txt = f"{data_inicial} a {data_final}" if data_inicial and data_final else ""
1248
+
1249
  # --- Lado direito: lista de variáveis ---
1250
  variaveis_html = ""
1251
  info = pacote.get("transformacoes", {}).get("info")
 
1278
  x_badges.append(badge)
1279
  variaveis_html = (
1280
  "<div style='font-size:0.9em;line-height:2;'>"
1281
+ "<div><span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;display:inline-block;min-width:190px;'>Variável Dependente:</span>"
1282
  + y_badge + "</div>"
1283
+ + "<div style='margin-top:4px;'>"
1284
+ "<span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;display:inline-block;min-width:190px;'>Variáveis Independentes:</span>"
1285
  + "".join(x_badges) + "</div>"
1286
+ + (
1287
+ "<div style='margin-top:6px;'>"
1288
+ "<span style='font-weight:600;color:#495057;margin-right:8px;font-size:1.1em;display:inline-block;min-width:190px;'>Período dos dados de mercado:</span>"
1289
+ f"<span style='color:#2f4458;'>{periodo_txt}</span>"
1290
+ "</div>"
1291
+ if periodo_txt else ""
1292
+ )
1293
+ + "</div>"
1294
+ )
1295
+ elif periodo_txt:
1296
+ variaveis_html = (
1297
+ "<div style='font-size:0.95em;line-height:1.7;'>"
1298
+ "<span style='font-weight:600;color:#495057;margin-right:8px;display:inline-block;min-width:190px;'>Período dos dados de mercado:</span>"
1299
+ f"<span style='color:#2f4458;'>{periodo_txt}</span>"
1300
  "</div>"
1301
  )
1302
 
backend/app/models/session.py CHANGED
@@ -44,6 +44,9 @@ class SessionState:
44
  geo_col_cdlog: str | None = None
45
  geo_col_num: str | None = None
46
  mapa_habilitado: bool = False
 
 
 
47
 
48
  pacote_visualizacao: dict[str, Any] | None = None
49
  dados_visualizacao: pd.DataFrame | None = None
 
44
  geo_col_cdlog: str | None = None
45
  geo_col_num: str | None = None
46
  mapa_habilitado: bool = False
47
+ coluna_data_mercado: str | None = None
48
+ periodo_dados_mercado_inicio: str | None = None
49
+ periodo_dados_mercado_fim: str | None = None
50
 
51
  pacote_visualizacao: dict[str, Any] | None = None
52
  dados_visualizacao: pd.DataFrame | None = None
backend/app/services/elaboracao_service.py CHANGED
@@ -97,6 +97,127 @@ def _selection_context(session: SessionState) -> dict[str, Any]:
97
  "codigo_alocado": list(session.codigo_alocado),
98
  "percentuais": list(session.percentuais),
99
  "outliers_anteriores": list(session.outliers_anteriores),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  }
101
 
102
 
@@ -271,6 +392,9 @@ def _set_dataframe_base(
271
  session.percentuais = []
272
  session.outliers_anteriores = []
273
  session.iteracao = 1
 
 
 
274
 
275
  colunas_numericas = [str(c) for c in obter_colunas_numericas(df)]
276
  coluna_y_padrao = identificar_coluna_y_padrao(df)
@@ -282,6 +406,7 @@ def _set_dataframe_base(
282
  "colunas_numericas": colunas_numericas,
283
  "coluna_y_padrao": coluna_y_padrao,
284
  "coords": _build_coords_payload(df, tem_coords),
 
285
  }
286
 
287
 
@@ -342,6 +467,7 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
342
  sucesso,
343
  elaborador,
344
  outliers_excluidos,
 
345
  ) = carregar_dai(caminho_arquivo)
346
 
347
  if not sucesso or df is None:
@@ -349,8 +475,12 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
349
 
350
  session.elaborador = elaborador
351
  session.outliers_anteriores = _clean_int_list(outliers_excluidos)
 
352
 
353
  base = _set_dataframe_base(session, df, clear_models=True)
 
 
 
354
  session.transformacao_y = str(transformacao_y or "(x)")
355
  session.transformacoes_x = {str(k): str(v) for k, v in (transformacoes_x or {}).items()}
356
 
@@ -389,6 +519,7 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
389
  "outliers_html": html_outliers,
390
  "contexto": _selection_context(session),
391
  "elaborador": sanitize_value(elaborador),
 
392
  }
393
 
394
 
@@ -1026,6 +1157,57 @@ def exportar_avaliacoes_elaboracao(session: SessionState) -> str:
1026
  return caminho
1027
 
1028
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1029
  def exportar_modelo(session: SessionState, nome_arquivo: str, elaborador: dict[str, Any] | None = None) -> tuple[str, str]:
1030
  if session.resultado_modelo is None:
1031
  raise HTTPException(status_code=400, detail="Ajuste um modelo antes de exportar")
@@ -1043,6 +1225,11 @@ def exportar_modelo(session: SessionState, nome_arquivo: str, elaborador: dict[s
1043
  nome_arquivo.strip(),
1044
  elaborador=elaborador,
1045
  outliers_excluidos=session.outliers_anteriores,
 
 
 
 
 
1046
  )
1047
 
1048
  if not caminho:
 
97
  "codigo_alocado": list(session.codigo_alocado),
98
  "percentuais": list(session.percentuais),
99
  "outliers_anteriores": list(session.outliers_anteriores),
100
+ "iteracao": session.iteracao,
101
+ "coluna_data_mercado": session.coluna_data_mercado,
102
+ "periodo_dados_mercado": {
103
+ "coluna_data": session.coluna_data_mercado,
104
+ "data_inicial": session.periodo_dados_mercado_inicio,
105
+ "data_final": session.periodo_dados_mercado_fim,
106
+ },
107
+ }
108
+
109
+
110
+ def _normalizar_periodo_dados_mercado(periodo: dict[str, Any] | None) -> dict[str, str | None]:
111
+ if not isinstance(periodo, dict):
112
+ return {"coluna_data": None, "data_inicial": None, "data_final": None}
113
+ coluna_data = str(periodo.get("coluna_data") or "").strip() or None
114
+ inicio = pd.to_datetime(periodo.get("data_inicial"), errors="coerce", dayfirst=True)
115
+ fim = pd.to_datetime(periodo.get("data_final"), errors="coerce", dayfirst=True)
116
+ return {
117
+ "coluna_data": coluna_data,
118
+ "data_inicial": None if pd.isna(inicio) else inicio.date().isoformat(),
119
+ "data_final": None if pd.isna(fim) else fim.date().isoformat(),
120
+ }
121
+
122
+
123
+ def _payload_data_mercado(session: SessionState) -> dict[str, Any]:
124
+ df = session.df_original
125
+ colunas = [str(c) for c in df.columns] if df is not None else []
126
+ coluna_sugerida = _sugerir_coluna_data_mercado(df) if df is not None else None
127
+ return {
128
+ "colunas_data_mercado": colunas,
129
+ "coluna_data_mercado": session.coluna_data_mercado,
130
+ "coluna_data_mercado_sugerida": coluna_sugerida,
131
+ "periodo_dados_mercado": {
132
+ "coluna_data": session.coluna_data_mercado,
133
+ "data_inicial": session.periodo_dados_mercado_inicio,
134
+ "data_final": session.periodo_dados_mercado_fim,
135
+ },
136
+ }
137
+
138
+
139
+ def _converter_coluna_para_datas(
140
+ serie: pd.Series,
141
+ coluna: str,
142
+ proporcao_minima: float = 0.8,
143
+ proporcao_excel_minima: float = 0.8,
144
+ ) -> pd.Series:
145
+ serie_base = serie.copy()
146
+ if pd.api.types.is_object_dtype(serie_base) or pd.api.types.is_string_dtype(serie_base):
147
+ serie_base = serie_base.astype(str).str.strip().replace("", np.nan)
148
+
149
+ mascara_preenchida = serie_base.notna()
150
+ total_preenchido = int(mascara_preenchida.sum())
151
+ if total_preenchido == 0:
152
+ raise HTTPException(status_code=400, detail=f"A coluna '{coluna}' não possui dados preenchidos.")
153
+
154
+ if pd.api.types.is_datetime64_any_dtype(serie_base):
155
+ datas = pd.to_datetime(serie_base, errors="coerce")
156
+ elif pd.api.types.is_numeric_dtype(serie_base):
157
+ serie_num = pd.to_numeric(serie_base, errors="coerce")
158
+ valores_validos = serie_num[mascara_preenchida].dropna()
159
+ if valores_validos.empty:
160
+ raise HTTPException(status_code=400, detail=f"A coluna '{coluna}' não possui datas válidas.")
161
+
162
+ proporcao_excel = float(valores_validos.between(20000, 80000).mean())
163
+ if proporcao_excel < proporcao_excel_minima:
164
+ raise HTTPException(
165
+ status_code=400,
166
+ detail=(
167
+ f"A coluna '{coluna}' não parece conter datas de calendário "
168
+ "(esperado texto de data ou serial de data do Excel)."
169
+ ),
170
+ )
171
+ datas = pd.to_datetime(serie_num, unit="D", origin="1899-12-30", errors="coerce")
172
+ else:
173
+ datas = pd.to_datetime(serie_base, errors="coerce", dayfirst=True)
174
+
175
+ datas_validas = datas[mascara_preenchida].dropna()
176
+ proporcao = len(datas_validas) / total_preenchido if total_preenchido else 0.0
177
+ if proporcao < proporcao_minima:
178
+ raise HTTPException(
179
+ status_code=400,
180
+ detail=(
181
+ f"Não foi possível interpretar a coluna '{coluna}' como data "
182
+ f"(conversão válida em {proporcao:.0%} dos registros preenchidos)."
183
+ ),
184
+ )
185
+
186
+ return datas
187
+
188
+
189
+ def _sugerir_coluna_data_mercado(df: pd.DataFrame | None) -> str | None:
190
+ if df is None or df.empty:
191
+ return None
192
+ for coluna in df.columns:
193
+ nome = str(coluna)
194
+ try:
195
+ _converter_coluna_para_datas(
196
+ df[coluna],
197
+ nome,
198
+ proporcao_minima=1.0,
199
+ proporcao_excel_minima=1.0,
200
+ )
201
+ return nome
202
+ except HTTPException:
203
+ continue
204
+ return None
205
+
206
+
207
+ def _calcular_periodo_dados_mercado(df: pd.DataFrame, coluna_data: str) -> dict[str, str]:
208
+ if coluna_data not in df.columns:
209
+ raise HTTPException(status_code=400, detail=f"Coluna '{coluna_data}' não encontrada na base.")
210
+
211
+ datas = _converter_coluna_para_datas(df[coluna_data], coluna_data)
212
+ datas_validas = datas.dropna()
213
+ if datas_validas.empty:
214
+ raise HTTPException(status_code=400, detail=f"A coluna '{coluna_data}' não possui datas válidas.")
215
+
216
+ data_inicial = datas_validas.min().date().isoformat()
217
+ data_final = datas_validas.max().date().isoformat()
218
+ return {
219
+ "data_inicial": data_inicial,
220
+ "data_final": data_final,
221
  }
222
 
223
 
 
392
  session.percentuais = []
393
  session.outliers_anteriores = []
394
  session.iteracao = 1
395
+ session.coluna_data_mercado = None
396
+ session.periodo_dados_mercado_inicio = None
397
+ session.periodo_dados_mercado_fim = None
398
 
399
  colunas_numericas = [str(c) for c in obter_colunas_numericas(df)]
400
  coluna_y_padrao = identificar_coluna_y_padrao(df)
 
406
  "colunas_numericas": colunas_numericas,
407
  "coluna_y_padrao": coluna_y_padrao,
408
  "coords": _build_coords_payload(df, tem_coords),
409
+ **_payload_data_mercado(session),
410
  }
411
 
412
 
 
467
  sucesso,
468
  elaborador,
469
  outliers_excluidos,
470
+ periodo_dados_mercado,
471
  ) = carregar_dai(caminho_arquivo)
472
 
473
  if not sucesso or df is None:
 
475
 
476
  session.elaborador = elaborador
477
  session.outliers_anteriores = _clean_int_list(outliers_excluidos)
478
+ periodo_normalizado = _normalizar_periodo_dados_mercado(periodo_dados_mercado)
479
 
480
  base = _set_dataframe_base(session, df, clear_models=True)
481
+ session.coluna_data_mercado = periodo_normalizado["coluna_data"]
482
+ session.periodo_dados_mercado_inicio = periodo_normalizado["data_inicial"]
483
+ session.periodo_dados_mercado_fim = periodo_normalizado["data_final"]
484
  session.transformacao_y = str(transformacao_y or "(x)")
485
  session.transformacoes_x = {str(k): str(v) for k, v in (transformacoes_x or {}).items()}
486
 
 
519
  "outliers_html": html_outliers,
520
  "contexto": _selection_context(session),
521
  "elaborador": sanitize_value(elaborador),
522
+ **_payload_data_mercado(session),
523
  }
524
 
525
 
 
1157
  return caminho
1158
 
1159
 
1160
+ def previsualizar_coluna_data_mercado(session: SessionState, coluna_data: str) -> dict[str, Any]:
1161
+ df = session.df_original
1162
+ if df is None:
1163
+ raise HTTPException(status_code=400, detail="Carregue uma base antes de definir a coluna de data.")
1164
+
1165
+ coluna = str(coluna_data or "").strip()
1166
+ if not coluna:
1167
+ raise HTTPException(status_code=400, detail="Selecione a coluna de data dos dados de mercado.")
1168
+
1169
+ periodo = _calcular_periodo_dados_mercado(df, coluna)
1170
+ return {
1171
+ "status": (
1172
+ f"Prévia do período para '{coluna}': "
1173
+ f"{periodo['data_inicial']} a {periodo['data_final']}"
1174
+ ),
1175
+ "coluna_data_mercado": coluna,
1176
+ "periodo_dados_mercado": {
1177
+ "coluna_data": coluna,
1178
+ **periodo,
1179
+ },
1180
+ }
1181
+
1182
+
1183
+ def aplicar_coluna_data_mercado(session: SessionState, coluna_data: str) -> dict[str, Any]:
1184
+ df = session.df_original
1185
+ if df is None:
1186
+ raise HTTPException(status_code=400, detail="Carregue uma base antes de definir a coluna de data.")
1187
+
1188
+ coluna = str(coluna_data or "").strip()
1189
+ if not coluna:
1190
+ raise HTTPException(status_code=400, detail="Selecione a coluna de data dos dados de mercado.")
1191
+
1192
+ periodo = _calcular_periodo_dados_mercado(df, coluna)
1193
+ session.coluna_data_mercado = coluna
1194
+ session.periodo_dados_mercado_inicio = periodo["data_inicial"]
1195
+ session.periodo_dados_mercado_fim = periodo["data_final"]
1196
+
1197
+ return {
1198
+ "status": (
1199
+ "Coluna de data dos dados de mercado aplicada: "
1200
+ f"{coluna} ({periodo['data_inicial']} a {periodo['data_final']})."
1201
+ ),
1202
+ "coluna_data_mercado": session.coluna_data_mercado,
1203
+ "periodo_dados_mercado": {
1204
+ "coluna_data": session.coluna_data_mercado,
1205
+ **periodo,
1206
+ },
1207
+ "contexto": _selection_context(session),
1208
+ }
1209
+
1210
+
1211
  def exportar_modelo(session: SessionState, nome_arquivo: str, elaborador: dict[str, Any] | None = None) -> tuple[str, str]:
1212
  if session.resultado_modelo is None:
1213
  raise HTTPException(status_code=400, detail="Ajuste um modelo antes de exportar")
 
1225
  nome_arquivo.strip(),
1226
  elaborador=elaborador,
1227
  outliers_excluidos=session.outliers_anteriores,
1228
+ periodo_dados_mercado={
1229
+ "coluna_data": session.coluna_data_mercado,
1230
+ "data_inicial": session.periodo_dados_mercado_inicio,
1231
+ "data_final": session.periodo_dados_mercado_fim,
1232
+ },
1233
  )
1234
 
1235
  if not caminho:
backend/app/services/pesquisa_service.py CHANGED
@@ -45,9 +45,9 @@ TIPO_POR_TOKEN = {
45
  "DEPOS": "Deposito",
46
  "RES": "Residencias isoladas / casas",
47
  "SALA": "Salas comerciais",
48
- "APTO": "Apartamentos residenciais",
49
- "APART": "Apartamentos residenciais",
50
- "AP": "Apartamentos residenciais",
51
  "TERRENO": "Terrenos",
52
  "TER": "Terrenos",
53
  "EDIF": "Edificio",
@@ -70,7 +70,7 @@ CAMPO_TEXTO_ALIASES_COLUNA = {
70
  }
71
 
72
  CAMPO_FAIXA_META_FONTES = {
73
- "data": [],
74
  "area": [],
75
  "rh": [],
76
  "aval_data": [],
@@ -82,10 +82,7 @@ CAMPO_FAIXA_META_FONTES = {
82
  "aval_valor_total": [],
83
  }
84
 
85
- CAMPO_FAIXA_ALIASES_COLUNA = {
86
- "data": DATA_ALIASES,
87
- "aval_data": DATA_ALIASES,
88
- }
89
 
90
  CAMPO_FAIXA_ALIASES_VARIAVEL = {
91
  "area": AREA_GERAL_ALIASES,
@@ -150,10 +147,11 @@ MAP_COLORS = [
150
 
151
  @dataclass(frozen=True)
152
  class PesquisaFiltros:
153
- otica: str = "modelo"
154
  nome: str | None = None
155
  autor: str | None = None
156
  contem_app: str | None = None
 
157
  finalidade: str | None = None
158
  finalidade_colunas: list[str] | None = None
159
  bairro: str | None = None
@@ -258,6 +256,7 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
258
  "nome": filtros.nome,
259
  "autor": filtros.autor,
260
  "contem_app": filtros.contem_app,
 
261
  "finalidade": filtros.finalidade,
262
  "finalidade_colunas": filtros.finalidade_colunas or [],
263
  "bairro": filtros.bairro,
@@ -321,6 +320,7 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
321
  "nome": filtros.nome,
322
  "autor": filtros.autor,
323
  "contem_app": filtros.contem_app,
 
324
  "finalidade": filtros.finalidade,
325
  "finalidade_colunas": filtros.finalidade_colunas or [],
326
  "bairro": filtros.bairro,
@@ -586,14 +586,14 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
586
  )
587
  resumo["faixa_rh"] = _merge_ranges(resumo["faixa_rh"], _extrair_faixa_por_alias(estat_df, RH_ALIASES))
588
 
589
- resumo["faixa_data"] = _merge_ranges(resumo["faixa_data"], _extrair_faixa_data_dataframe(df_modelo))
590
 
591
  colunas_catalogo = _coletar_colunas_para_catalogo(estat_df, df_modelo)
592
  resumo["compatibilidade_campos"] = _mapear_compatibilidade(colunas_catalogo)
593
  resumo["variaveis_resumo"] = _resumo_variaveis(estat_df)
594
  resumo["mapa_disponivel"] = _tem_colunas_mapa(df_modelo)
595
  resumo["_texto_colunas_index"] = _indexar_texto_colunas(df_modelo)
596
- resumo["_faixa_colunas_index"] = _indexar_faixas_colunas(df_modelo)
597
  resumo["_faixa_variaveis_index"] = _indexar_faixas_variaveis(estat_df, resumo["_variaveis_modelo"])
598
 
599
  return resumo
@@ -619,6 +619,26 @@ def _r2_do_pacote(pacote: dict[str, Any]) -> float | None:
619
  return _to_float_or_none(gerais.get("r2"))
620
 
621
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  def _variaveis_do_modelo(pacote: dict[str, Any]) -> list[str]:
623
  variaveis: list[str] = []
624
 
@@ -961,6 +981,9 @@ def _aceita_filtros(modelo: dict[str, Any], filtros: PesquisaFiltros, fontes_adm
961
  if app_flag is not None and _modelo_contem_variavel(modelo, APP_ALIASES) != app_flag:
962
  return False
963
 
 
 
 
964
  if filtros.finalidade and not _aceita_texto_com_colunas(modelo, filtros.finalidade, "finalidade", fontes_admin.get("finalidade")):
965
  return False
966
 
@@ -979,7 +1002,13 @@ def _aceita_filtros(modelo: dict[str, Any], filtros: PesquisaFiltros, fontes_adm
979
  if not _aceita_range_com_colunas(modelo, "rh", fontes_admin.get("rh"), filtros.rh_min, filtros.rh_max):
980
  return False
981
 
982
- if not _aceita_range_com_colunas(modelo, "data", fontes_admin.get("data"), filtros.data_min, filtros.data_max):
 
 
 
 
 
 
983
  return False
984
 
985
  return True
@@ -997,7 +1026,7 @@ def _normalizar_contem_app(value: str | None) -> bool | None:
997
 
998
 
999
  def _normalizar_otica(value: str | None) -> str:
1000
- return "avaliando" if _normalize(value or "") == "avaliando" else "modelo"
1001
 
1002
 
1003
  def _anexar_avaliando_info(
@@ -1032,11 +1061,17 @@ def _anexar_avaliando_info(
1032
  aceito = _contains_any(candidatos, str(endereco_info))
1033
  registrar("endereco", endereco_info, aceito, "sem correspondencia textual no modelo")
1034
 
1035
- faixa_data_ref = _faixa_resumo_com_colunas(item, "aval_data", fontes_admin.get("aval_data"))
 
 
 
 
 
 
1036
  registrar(
1037
  "data",
1038
- filtros.aval_data,
1039
- _aceita_valor_com_colunas(item, "aval_data", fontes_admin.get("aval_data"), filtros.aval_data),
1040
  f"fora da faixa {formatar_faixa(faixa_data_ref)}",
1041
  )
1042
  faixa_rh_ref = _faixa_resumo_com_colunas(item, "aval_rh", fontes_admin.get("aval_rh"))
@@ -1107,6 +1142,7 @@ def _extrair_sugestoes(
1107
  finalidades: list[str] = []
1108
  bairros: list[str] = []
1109
  enderecos: list[str] = []
 
1110
 
1111
  fontes_finalidade = _dedupe_strings((fontes_admin.get("finalidade") or []) + (fontes_admin.get("aval_finalidade") or []))
1112
  fontes_bairro = _dedupe_strings((fontes_admin.get("bairros") or []) + (fontes_admin.get("aval_bairro") or []))
@@ -1125,6 +1161,7 @@ def _extrair_sugestoes(
1125
  else:
1126
  bairros.extend([str(item) for item in (modelo.get("bairros") or [])])
1127
  enderecos.append(str(modelo.get("endereco_referencia") or ""))
 
1128
 
1129
  return {
1130
  "nomes_modelo": _lista_textos_unicos(nomes, limite),
@@ -1132,6 +1169,7 @@ def _extrair_sugestoes(
1132
  "finalidades": _lista_textos_unicos(finalidades, limite),
1133
  "bairros": _lista_textos_unicos(bairros, limite),
1134
  "enderecos": _lista_textos_unicos(enderecos, limite),
 
1135
  }
1136
 
1137
 
@@ -1238,6 +1276,11 @@ def _fontes_texto_padrao(modelo: dict[str, Any], campo: str) -> list[str]:
1238
  def _fontes_faixa_disponiveis(modelo: dict[str, Any], campo: str) -> list[str]:
1239
  fontes: list[str] = []
1240
 
 
 
 
 
 
1241
  aliases_coluna = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
1242
  indice_colunas = modelo.get("_faixa_colunas_index") or {}
1243
  if isinstance(indice_colunas, dict):
@@ -1258,6 +1301,11 @@ def _fontes_faixa_disponiveis(modelo: dict[str, Any], campo: str) -> list[str]:
1258
  def _fontes_faixa_padrao(modelo: dict[str, Any], campo: str) -> list[str]:
1259
  fontes: list[str] = []
1260
 
 
 
 
 
 
1261
  aliases_coluna = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
1262
  indice_colunas = modelo.get("_faixa_colunas_index") or {}
1263
  if isinstance(indice_colunas, dict):
@@ -1310,6 +1358,21 @@ def _aceita_range_com_colunas(
1310
  return any(_range_overlaps(faixa, filtro_min, filtro_max) for faixa in faixas)
1311
 
1312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1313
  def _aceita_valor_com_colunas(
1314
  modelo: dict[str, Any],
1315
  campo: str,
@@ -1596,6 +1659,26 @@ def formatar_faixa(faixa: dict[str, Any] | None) -> str:
1596
  return f"ate {maximo}"
1597
 
1598
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1599
  def _extrair_termos_bairro(filtros: PesquisaFiltros) -> list[str]:
1600
  termos: list[str] = []
1601
  if filtros.bairro:
@@ -1668,6 +1751,52 @@ def _range_overlaps(model_range: dict[str, Any] | None, filtro_min: Any, filtro_
1668
  return True
1669
 
1670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1671
  def _to_comparable(value: Any) -> tuple[str, Any] | None:
1672
  if value is None:
1673
  return None
 
45
  "DEPOS": "Deposito",
46
  "RES": "Residencias isoladas / casas",
47
  "SALA": "Salas comerciais",
48
+ "APTO": "Apartamentos",
49
+ "APART": "Apartamentos",
50
+ "AP": "Apartamentos",
51
  "TERRENO": "Terrenos",
52
  "TER": "Terrenos",
53
  "EDIF": "Edificio",
 
70
  }
71
 
72
  CAMPO_FAIXA_META_FONTES = {
73
+ "data": ["meta:faixa_data"],
74
  "area": [],
75
  "rh": [],
76
  "aval_data": [],
 
82
  "aval_valor_total": [],
83
  }
84
 
85
+ CAMPO_FAIXA_ALIASES_COLUNA = {}
 
 
 
86
 
87
  CAMPO_FAIXA_ALIASES_VARIAVEL = {
88
  "area": AREA_GERAL_ALIASES,
 
147
 
148
  @dataclass(frozen=True)
149
  class PesquisaFiltros:
150
+ otica: str = "avaliando"
151
  nome: str | None = None
152
  autor: str | None = None
153
  contem_app: str | None = None
154
+ tipo_modelo: str | None = None
155
  finalidade: str | None = None
156
  finalidade_colunas: list[str] | None = None
157
  bairro: str | None = None
 
256
  "nome": filtros.nome,
257
  "autor": filtros.autor,
258
  "contem_app": filtros.contem_app,
259
+ "tipo_modelo": filtros.tipo_modelo,
260
  "finalidade": filtros.finalidade,
261
  "finalidade_colunas": filtros.finalidade_colunas or [],
262
  "bairro": filtros.bairro,
 
320
  "nome": filtros.nome,
321
  "autor": filtros.autor,
322
  "contem_app": filtros.contem_app,
323
+ "tipo_modelo": filtros.tipo_modelo,
324
  "finalidade": filtros.finalidade,
325
  "finalidade_colunas": filtros.finalidade_colunas or [],
326
  "bairro": filtros.bairro,
 
586
  )
587
  resumo["faixa_rh"] = _merge_ranges(resumo["faixa_rh"], _extrair_faixa_por_alias(estat_df, RH_ALIASES))
588
 
589
+ resumo["faixa_data"] = _merge_ranges(resumo["faixa_data"], _faixa_data_do_pacote(pacote))
590
 
591
  colunas_catalogo = _coletar_colunas_para_catalogo(estat_df, df_modelo)
592
  resumo["compatibilidade_campos"] = _mapear_compatibilidade(colunas_catalogo)
593
  resumo["variaveis_resumo"] = _resumo_variaveis(estat_df)
594
  resumo["mapa_disponivel"] = _tem_colunas_mapa(df_modelo)
595
  resumo["_texto_colunas_index"] = _indexar_texto_colunas(df_modelo)
596
+ resumo["_faixa_colunas_index"] = {}
597
  resumo["_faixa_variaveis_index"] = _indexar_faixas_variaveis(estat_df, resumo["_variaveis_modelo"])
598
 
599
  return resumo
 
619
  return _to_float_or_none(gerais.get("r2"))
620
 
621
 
622
+ def _faixa_data_do_pacote(pacote: dict[str, Any]) -> dict[str, Any] | None:
623
+ periodo = pacote.get("periodo_dados_mercado") if isinstance(pacote.get("periodo_dados_mercado"), dict) else {}
624
+ data_inicial = _data_iso_or_none(periodo.get("data_inicial"))
625
+ data_final = _data_iso_or_none(periodo.get("data_final"))
626
+ if data_inicial is None and data_final is None:
627
+ return None
628
+ if data_inicial and data_final and data_inicial > data_final:
629
+ data_inicial, data_final = data_final, data_inicial
630
+ return {"min": data_inicial, "max": data_final}
631
+
632
+
633
+ def _data_iso_or_none(value: Any) -> str | None:
634
+ if _is_empty(value):
635
+ return None
636
+ parsed = pd.to_datetime(value, errors="coerce", dayfirst=True)
637
+ if pd.isna(parsed):
638
+ return None
639
+ return parsed.date().isoformat()
640
+
641
+
642
  def _variaveis_do_modelo(pacote: dict[str, Any]) -> list[str]:
643
  variaveis: list[str] = []
644
 
 
981
  if app_flag is not None and _modelo_contem_variavel(modelo, APP_ALIASES) != app_flag:
982
  return False
983
 
984
+ if filtros.tipo_modelo and not _contains_any([_tipo_modelo_modelo(modelo)], filtros.tipo_modelo):
985
+ return False
986
+
987
  if filtros.finalidade and not _aceita_texto_com_colunas(modelo, filtros.finalidade, "finalidade", fontes_admin.get("finalidade")):
988
  return False
989
 
 
1002
  if not _aceita_range_com_colunas(modelo, "rh", fontes_admin.get("rh"), filtros.rh_min, filtros.rh_max):
1003
  return False
1004
 
1005
+ data_min = filtros.data_min
1006
+ data_max = filtros.data_max
1007
+ if _is_provided(filtros.aval_data) and not _is_provided(data_min) and not _is_provided(data_max):
1008
+ data_min = filtros.aval_data
1009
+ data_max = filtros.aval_data
1010
+
1011
+ if not _aceita_range_contido_com_colunas(modelo, "data", fontes_admin.get("data"), data_min, data_max):
1012
  return False
1013
 
1014
  return True
 
1026
 
1027
 
1028
  def _normalizar_otica(value: str | None) -> str:
1029
+ return "avaliando"
1030
 
1031
 
1032
  def _anexar_avaliando_info(
 
1061
  aceito = _contains_any(candidatos, str(endereco_info))
1062
  registrar("endereco", endereco_info, aceito, "sem correspondencia textual no modelo")
1063
 
1064
+ data_min = filtros.data_min
1065
+ data_max = filtros.data_max
1066
+ if _is_provided(filtros.aval_data) and not _is_provided(data_min) and not _is_provided(data_max):
1067
+ data_min = filtros.aval_data
1068
+ data_max = filtros.aval_data
1069
+
1070
+ faixa_data_ref = _faixa_resumo_com_colunas(item, "data", fontes_admin.get("data"))
1071
  registrar(
1072
  "data",
1073
+ _periodo_texto_informado(data_min, data_max),
1074
+ _aceita_range_contido_com_colunas(item, "data", fontes_admin.get("data"), data_min, data_max),
1075
  f"fora da faixa {formatar_faixa(faixa_data_ref)}",
1076
  )
1077
  faixa_rh_ref = _faixa_resumo_com_colunas(item, "aval_rh", fontes_admin.get("aval_rh"))
 
1142
  finalidades: list[str] = []
1143
  bairros: list[str] = []
1144
  enderecos: list[str] = []
1145
+ tipos_modelo: list[str] = []
1146
 
1147
  fontes_finalidade = _dedupe_strings((fontes_admin.get("finalidade") or []) + (fontes_admin.get("aval_finalidade") or []))
1148
  fontes_bairro = _dedupe_strings((fontes_admin.get("bairros") or []) + (fontes_admin.get("aval_bairro") or []))
 
1161
  else:
1162
  bairros.extend([str(item) for item in (modelo.get("bairros") or [])])
1163
  enderecos.append(str(modelo.get("endereco_referencia") or ""))
1164
+ tipos_modelo.append(str(_tipo_modelo_modelo(modelo) or ""))
1165
 
1166
  return {
1167
  "nomes_modelo": _lista_textos_unicos(nomes, limite),
 
1169
  "finalidades": _lista_textos_unicos(finalidades, limite),
1170
  "bairros": _lista_textos_unicos(bairros, limite),
1171
  "enderecos": _lista_textos_unicos(enderecos, limite),
1172
+ "tipos_modelo": _lista_textos_unicos(tipos_modelo, limite),
1173
  }
1174
 
1175
 
 
1276
  def _fontes_faixa_disponiveis(modelo: dict[str, Any], campo: str) -> list[str]:
1277
  fontes: list[str] = []
1278
 
1279
+ for fonte_meta in CAMPO_FAIXA_META_FONTES.get(campo, []):
1280
+ faixa_meta = _faixa_meta(modelo, fonte_meta)
1281
+ if isinstance(faixa_meta, dict) and not (_is_empty(faixa_meta.get("min")) and _is_empty(faixa_meta.get("max"))):
1282
+ fontes.append(fonte_meta)
1283
+
1284
  aliases_coluna = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
1285
  indice_colunas = modelo.get("_faixa_colunas_index") or {}
1286
  if isinstance(indice_colunas, dict):
 
1301
  def _fontes_faixa_padrao(modelo: dict[str, Any], campo: str) -> list[str]:
1302
  fontes: list[str] = []
1303
 
1304
+ for fonte_meta in CAMPO_FAIXA_META_FONTES.get(campo, []):
1305
+ faixa_meta = _faixa_meta(modelo, fonte_meta)
1306
+ if isinstance(faixa_meta, dict) and not (_is_empty(faixa_meta.get("min")) and _is_empty(faixa_meta.get("max"))):
1307
+ fontes.append(fonte_meta)
1308
+
1309
  aliases_coluna = CAMPO_FAIXA_ALIASES_COLUNA.get(campo, [])
1310
  indice_colunas = modelo.get("_faixa_colunas_index") or {}
1311
  if isinstance(indice_colunas, dict):
 
1358
  return any(_range_overlaps(faixa, filtro_min, filtro_max) for faixa in faixas)
1359
 
1360
 
1361
+ def _aceita_range_contido_com_colunas(
1362
+ modelo: dict[str, Any],
1363
+ campo: str,
1364
+ fontes_selecionadas: list[str] | None,
1365
+ filtro_min: Any,
1366
+ filtro_max: Any,
1367
+ ) -> bool:
1368
+ if filtro_min is None and filtro_max is None:
1369
+ return True
1370
+ faixas = _faixas_para_campo(modelo, campo, fontes_selecionadas)
1371
+ if not faixas:
1372
+ return False
1373
+ return any(_range_contains(faixa, filtro_min, filtro_max) for faixa in faixas)
1374
+
1375
+
1376
  def _aceita_valor_com_colunas(
1377
  modelo: dict[str, Any],
1378
  campo: str,
 
1659
  return f"ate {maximo}"
1660
 
1661
 
1662
+ def _periodo_texto_informado(data_min: Any, data_max: Any) -> str | None:
1663
+ inicio = _str_or_none(data_min)
1664
+ fim = _str_or_none(data_max)
1665
+ if not inicio and not fim:
1666
+ return None
1667
+ if inicio and fim:
1668
+ return f"{inicio} a {fim}"
1669
+ if inicio:
1670
+ return f"a partir de {inicio}"
1671
+ return f"ate {fim}"
1672
+
1673
+
1674
+ def _tipo_modelo_modelo(modelo: dict[str, Any]) -> str | None:
1675
+ tipo = _str_or_none(modelo.get("tipo_imovel"))
1676
+ if tipo:
1677
+ return tipo
1678
+ nome_referencia = _str_or_none(modelo.get("nome_modelo")) or _str_or_none(modelo.get("arquivo")) or ""
1679
+ return _inferir_tipo_por_nome(nome_referencia)
1680
+
1681
+
1682
  def _extrair_termos_bairro(filtros: PesquisaFiltros) -> list[str]:
1683
  termos: list[str] = []
1684
  if filtros.bairro:
 
1751
  return True
1752
 
1753
 
1754
+ def _range_contains(model_range: dict[str, Any] | None, filtro_min: Any, filtro_max: Any) -> bool:
1755
+ if filtro_min is None and filtro_max is None:
1756
+ return True
1757
+
1758
+ if not model_range:
1759
+ return False
1760
+
1761
+ model_min_cmp = _to_comparable(model_range.get("min"))
1762
+ model_max_cmp = _to_comparable(model_range.get("max"))
1763
+ filtro_min_cmp = _to_comparable(filtro_min) if filtro_min is not None else None
1764
+ filtro_max_cmp = _to_comparable(filtro_max) if filtro_max is not None else None
1765
+
1766
+ if filtro_min_cmp is None and filtro_max_cmp is None:
1767
+ return True
1768
+
1769
+ kinds = {
1770
+ item[0]
1771
+ for item in [model_min_cmp, model_max_cmp, filtro_min_cmp, filtro_max_cmp]
1772
+ if item is not None
1773
+ }
1774
+
1775
+ if len(kinds) != 1:
1776
+ return False
1777
+
1778
+ model_min_val = model_min_cmp[1] if model_min_cmp is not None else None
1779
+ model_max_val = model_max_cmp[1] if model_max_cmp is not None else None
1780
+ filtro_min_val = filtro_min_cmp[1] if filtro_min_cmp is not None else None
1781
+ filtro_max_val = filtro_max_cmp[1] if filtro_max_cmp is not None else None
1782
+
1783
+ if model_min_val is None or model_max_val is None:
1784
+ return False
1785
+
1786
+ if filtro_min_val is not None and filtro_max_val is not None and filtro_min_val > filtro_max_val:
1787
+ filtro_min_val, filtro_max_val = filtro_max_val, filtro_min_val
1788
+
1789
+ if filtro_min_val is not None:
1790
+ if filtro_min_val < model_min_val or filtro_min_val > model_max_val:
1791
+ return False
1792
+
1793
+ if filtro_max_val is not None:
1794
+ if filtro_max_val < model_min_val or filtro_max_val > model_max_val:
1795
+ return False
1796
+
1797
+ return True
1798
+
1799
+
1800
  def _to_comparable(value: Any) -> tuple[str, Any] | None:
1801
  if value is None:
1802
  return None
backend/app/services/visualizacao_service.py CHANGED
@@ -71,6 +71,10 @@ def _extrair_modelo_info(pacote: dict[str, Any]) -> dict[str, Any]:
71
  dicotomicas = [str(v) for v in (pacote["transformacoes"].get("dicotomicas", []) or [])]
72
  codigo_alocado = [str(v) for v in (pacote["transformacoes"].get("codigo_alocado", []) or [])]
73
  percentuais = [str(v) for v in (pacote["transformacoes"].get("percentuais", []) or [])]
 
 
 
 
74
 
75
  return {
76
  "nome_y": nome_y.strip(),
@@ -80,6 +84,11 @@ def _extrair_modelo_info(pacote: dict[str, Any]) -> dict[str, Any]:
80
  "dicotomicas": dicotomicas,
81
  "codigo_alocado": codigo_alocado,
82
  "percentuais": percentuais,
 
 
 
 
 
83
  }
84
 
85
 
 
71
  dicotomicas = [str(v) for v in (pacote["transformacoes"].get("dicotomicas", []) or [])]
72
  codigo_alocado = [str(v) for v in (pacote["transformacoes"].get("codigo_alocado", []) or [])]
73
  percentuais = [str(v) for v in (pacote["transformacoes"].get("percentuais", []) or [])]
74
+ periodo = pacote.get("periodo_dados_mercado") or {}
75
+ coluna_data = str(periodo.get("coluna_data") or "").strip() or None
76
+ data_inicial = str(periodo.get("data_inicial") or "").strip() or None
77
+ data_final = str(periodo.get("data_final") or "").strip() or None
78
 
79
  return {
80
  "nome_y": nome_y.strip(),
 
84
  "dicotomicas": dicotomicas,
85
  "codigo_alocado": codigo_alocado,
86
  "percentuais": percentuais,
87
+ "periodo_dados_mercado": {
88
+ "coluna_data": coluna_data,
89
+ "data_inicial": data_inicial,
90
+ "data_final": data_final,
91
+ },
92
  }
93
 
94
 
frontend/src/App.jsx CHANGED
@@ -1,10 +1,12 @@
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' },
@@ -58,6 +60,10 @@ export default function App() {
58
 
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 />
 
1
  import React, { useEffect, useState } from 'react'
2
  import { api } from './api'
3
  import ElaboracaoTab from './components/ElaboracaoTab'
4
+ import InicioTab from './components/InicioTab'
5
  import PesquisaTab from './components/PesquisaTab'
6
  import VisualizacaoTab from './components/VisualizacaoTab'
7
 
8
  const TABS = [
9
+ { key: 'Início', label: 'Início', hint: 'Visão geral rápida do aplicativo' },
10
  { key: 'Pesquisa', label: 'Pesquisa', hint: 'Busca inicial de modelos .dai' },
11
  { key: 'Elaboração/Edição', label: 'Elaboração/Edição', hint: 'Fluxo completo de modelagem' },
12
  { key: 'Visualização/Avaliação', label: 'Visualização/Avaliação', hint: 'Leitura e avaliação de modelos .dai' },
 
60
 
61
  {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
62
 
63
+ <div className="tab-pane" hidden={activeTab !== 'Início'}>
64
+ <InicioTab />
65
+ </div>
66
+
67
  {activeTab === 'Pesquisa' ? (
68
  <div className="tab-pane">
69
  <PesquisaTab />
frontend/src/api.js CHANGED
@@ -153,6 +153,8 @@ export const api = {
153
  },
154
  exportBase: (sessionId, filtered = true) => getBlob(`/api/elaboracao/export-base?session_id=${encodeURIComponent(sessionId)}&filtered=${String(filtered)}`),
155
  updateElaboracaoMap: (sessionId, variavelMapa) => postJson('/api/elaboracao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
 
 
156
  getContext: (sessionId) => getJson(`/api/elaboracao/context?session_id=${encodeURIComponent(sessionId)}`),
157
 
158
  uploadVisualizacaoFile(sessionId, file) {
 
153
  },
154
  exportBase: (sessionId, filtered = true) => getBlob(`/api/elaboracao/export-base?session_id=${encodeURIComponent(sessionId)}&filtered=${String(filtered)}`),
155
  updateElaboracaoMap: (sessionId, variavelMapa) => postJson('/api/elaboracao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
156
+ previewMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/preview', { session_id: sessionId, coluna_data: colunaData }),
157
+ applyMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/apply', { session_id: sessionId, coluna_data: colunaData }),
158
  getContext: (sessionId) => getJson(`/api/elaboracao/context?session_id=${encodeURIComponent(sessionId)}`),
159
 
160
  uploadVisualizacaoFile(sessionId, file) {
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -60,6 +60,30 @@ function formatTransformacaoBadge(transformacao) {
60
  return valor
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  function buildLoadedModelInfo(resp) {
64
  const tipo = String(resp?.tipo || '').toLowerCase()
65
  if (tipo !== 'dai') return null
@@ -77,6 +101,7 @@ function buildLoadedModelInfo(resp) {
77
  colunas_x: colunasX,
78
  transformacao_y: fit.transformacao_y || contexto.transformacao_y || '(x)',
79
  transformacoes_x: transformacoesX,
 
80
  }
81
  }
82
 
@@ -154,6 +179,13 @@ export default function ElaboracaoTab({ sessionId }) {
154
  const [dicotomicas, setDicotomicas] = useState([])
155
  const [codigoAlocado, setCodigoAlocado] = useState([])
156
  const [percentuais, setPercentuais] = useState([])
 
 
 
 
 
 
 
157
 
158
  const [outliersAnteriores, setOutliersAnteriores] = useState([])
159
  const [iteracao, setIteracao] = useState(1)
@@ -225,6 +257,18 @@ export default function ElaboracaoTab({ sessionId }) {
225
  transformacao: formatTransformacaoBadge(transformacoes[coluna]),
226
  }))
227
  }, [modeloCarregadoInfo])
 
 
 
 
 
 
 
 
 
 
 
 
228
  const transformacaoAplicadaYBadge = useMemo(
229
  () => formatTransformacaoBadge(transformacoesAplicadas?.transformacao_y),
230
  [transformacoesAplicadas],
@@ -364,10 +408,25 @@ export default function ElaboracaoTab({ sessionId }) {
364
 
365
  function applyBaseResponse(resp, options = {}) {
366
  const resetXSelection = Boolean(options.resetXSelection)
 
367
  if (resp.status) setStatus(resp.status)
368
  if (resp.dados) setDados(resp.dados)
369
  if (resp.mapa_html) setMapaHtml(resp.mapa_html)
370
  if (resp.colunas_numericas) setColunasNumericas(resp.colunas_numericas)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  if (resp.coluna_y_padrao) setColunaY(resp.coluna_y_padrao)
372
 
373
  if (resp.contexto && !resetXSelection) {
@@ -389,6 +448,11 @@ export default function ElaboracaoTab({ sessionId }) {
389
  setOrigemTransformacoes(null)
390
  setOutliersAnteriores([])
391
  setIteracao(1)
 
 
 
 
 
392
  setSelection(null)
393
  setFit(null)
394
  setCamposAvaliacao([])
@@ -526,6 +590,12 @@ export default function ElaboracaoTab({ sessionId }) {
526
  setTransformacoesX({})
527
  setTransformacoesAplicadas(null)
528
  setOrigemTransformacoes(null)
 
 
 
 
 
 
529
  setCamposAvaliacao([])
530
  valoresAvaliacaoRef.current = {}
531
  setAvaliacaoFormVersion((prev) => prev + 1)
@@ -587,6 +657,50 @@ export default function ElaboracaoTab({ sessionId }) {
587
  })
588
  }
589
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
  async function onMapCoords() {
591
  if (!manualLat || !manualLon || !sessionId) return
592
  setLoading(true)
@@ -1090,6 +1204,10 @@ export default function ElaboracaoTab({ sessionId }) {
1090
  ) : (
1091
  <div className="section1-empty-hint">Sem variáveis independentes no modelo carregado.</div>
1092
  )}
 
 
 
 
1093
  </div>
1094
  </div>
1095
  </div>
@@ -1377,7 +1495,47 @@ export default function ElaboracaoTab({ sessionId }) {
1377
  </div>
1378
  </SectionBlock>
1379
 
1380
- <SectionBlock step="4" title="Selecionar Variável Dependente" subtitle="Defina a variável dependente (Y).">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1381
  <div className="row">
1382
  <label>Variável Dependente (Y)</label>
1383
  <select value={colunaY} onChange={(e) => setColunaY(e.target.value)}>
@@ -1389,7 +1547,7 @@ export default function ElaboracaoTab({ sessionId }) {
1389
  </div>
1390
  </SectionBlock>
1391
 
1392
- <SectionBlock step="5" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
1393
  <div className="compact-option-group compact-option-group-x">
1394
  <h4>Variáveis Independentes (X)</h4>
1395
  <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
@@ -1472,15 +1630,15 @@ export default function ElaboracaoTab({ sessionId }) {
1472
 
1473
  {selection ? (
1474
  <>
1475
- <SectionBlock step="6" title="Estatísticas das Variáveis Selecionadas" subtitle="Resumo estatístico para Y e regressoras.">
1476
  <DataTable table={selection.estatisticas} />
1477
  </SectionBlock>
1478
 
1479
- <SectionBlock step="7" title="Teste de Micronumerosidade" subtitle="Validação de amostra mínima para variáveis selecionadas.">
1480
  <div dangerouslySetInnerHTML={{ __html: selection.micronumerosidade_html || '' }} />
1481
  </SectionBlock>
1482
 
1483
- <SectionBlock step="8" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
1484
  <details
1485
  className="section-content-toggle"
1486
  open={section8Open}
@@ -1498,7 +1656,7 @@ export default function ElaboracaoTab({ sessionId }) {
1498
  </details>
1499
  </SectionBlock>
1500
 
1501
- <SectionBlock step="9" title="Transformações Sugeridas" subtitle="Busca automática de combinações por R² e enquadramento.">
1502
  <div className="row">
1503
  <label>Grau mínimo dos coeficientes</label>
1504
  <select value={grauCoef} onChange={(e) => setGrauCoef(Number(e.target.value))}>
@@ -1555,7 +1713,7 @@ export default function ElaboracaoTab({ sessionId }) {
1555
  )}
1556
  </SectionBlock>
1557
 
1558
- <SectionBlock step="10" title="Aplicação das Transformações" subtitle="Configuração manual para ajuste do modelo.">
1559
  <div className="manual-transform-toggle">
1560
  <button
1561
  type="button"
@@ -1625,6 +1783,10 @@ export default function ElaboracaoTab({ sessionId }) {
1625
  </div>
1626
  </div>
1627
  ) : null}
 
 
 
 
1628
  </div>
1629
  </div>
1630
  </div>
@@ -1635,7 +1797,7 @@ export default function ElaboracaoTab({ sessionId }) {
1635
 
1636
  {fit ? (
1637
  <>
1638
- <SectionBlock step="11" title="Gráficos de Dispersão (Variáveis Transformadas)" subtitle="Dispersão com variáveis já transformadas.">
1639
  <details
1640
  className="section-content-toggle"
1641
  open={section11Open}
@@ -1658,7 +1820,7 @@ export default function ElaboracaoTab({ sessionId }) {
1658
  </details>
1659
  </SectionBlock>
1660
 
1661
- <SectionBlock step="12" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
1662
  <div dangerouslySetInnerHTML={{ __html: fit.diagnosticos_html || '' }} />
1663
  <div className="two-col diagnostic-tables">
1664
  <div className="pane">
@@ -1672,7 +1834,7 @@ export default function ElaboracaoTab({ sessionId }) {
1672
  </div>
1673
  </SectionBlock>
1674
 
1675
- <SectionBlock step="13" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
1676
  <div className="plot-grid-2-fixed">
1677
  <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
1678
  <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
@@ -1684,11 +1846,11 @@ export default function ElaboracaoTab({ sessionId }) {
1684
  </div>
1685
  </SectionBlock>
1686
 
1687
- <SectionBlock step="14" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
1688
  <DataTable table={fit.tabela_metricas} maxHeight={320} />
1689
  </SectionBlock>
1690
 
1691
- <SectionBlock step="15" title="Exclusão ou Reinclusão de Outliers" subtitle="Filtre índices, revise e atualize o modelo.">
1692
  {outliersAnteriores.length > 0 && outliersHtml ? (
1693
  <div className="outliers-html-box" dangerouslySetInnerHTML={{ __html: outliersHtml }} />
1694
  ) : null}
@@ -1772,7 +1934,7 @@ export default function ElaboracaoTab({ sessionId }) {
1772
  <div className="resumo-outliers-box">Outliers anteriores: {joinSelection(outliersAnteriores) || '-'}</div>
1773
  </SectionBlock>
1774
 
1775
- <SectionBlock step="16" title="Avaliação de Imóvel" subtitle="Cálculo individual e comparação entre avaliações.">
1776
  <div className="avaliacao-grid" key={`avaliacao-grid-elab-${avaliacaoFormVersion}`}>
1777
  {camposAvaliacao.map((campo) => (
1778
  <div key={`aval-${campo.coluna}`} className="avaliacao-card">
@@ -1825,7 +1987,7 @@ export default function ElaboracaoTab({ sessionId }) {
1825
  />
1826
  </SectionBlock>
1827
 
1828
- <SectionBlock step="17" title="Exportar Modelo" subtitle="Geração do pacote .dai e download da base tratada.">
1829
  <div className="row">
1830
  <label>Nome do arquivo (.dai)</label>
1831
  <input type="text" value={nomeArquivoExport} onChange={(e) => setNomeArquivoExport(e.target.value)} />
 
60
  return valor
61
  }
62
 
63
+ function formatDateBr(value) {
64
+ const text = String(value || '').trim()
65
+ if (!text) return ''
66
+ const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
67
+ if (isoMatch) {
68
+ return `${isoMatch[3]}/${isoMatch[2]}/${isoMatch[1]}`
69
+ }
70
+ return text
71
+ }
72
+
73
+ function normalizePeriodoDadosMercado(periodo) {
74
+ if (!periodo || typeof periodo !== 'object') return null
75
+ const dataInicial = String(periodo.data_inicial || '').trim()
76
+ const dataFinal = String(periodo.data_final || '').trim()
77
+ if (!dataInicial || !dataFinal) return null
78
+ return { data_inicial: dataInicial, data_final: dataFinal }
79
+ }
80
+
81
+ function formatPeriodoDadosMercado(periodo) {
82
+ const normalizado = normalizePeriodoDadosMercado(periodo)
83
+ if (!normalizado) return '-'
84
+ return `${formatDateBr(normalizado.data_inicial)} a ${formatDateBr(normalizado.data_final)}`
85
+ }
86
+
87
  function buildLoadedModelInfo(resp) {
88
  const tipo = String(resp?.tipo || '').toLowerCase()
89
  if (tipo !== 'dai') return null
 
101
  colunas_x: colunasX,
102
  transformacao_y: fit.transformacao_y || contexto.transformacao_y || '(x)',
103
  transformacoes_x: transformacoesX,
104
+ periodo_dados_mercado: normalizePeriodoDadosMercado(resp?.periodo_dados_mercado || contexto?.periodo_dados_mercado),
105
  }
106
  }
107
 
 
179
  const [dicotomicas, setDicotomicas] = useState([])
180
  const [codigoAlocado, setCodigoAlocado] = useState([])
181
  const [percentuais, setPercentuais] = useState([])
182
+ const [colunasDataMercado, setColunasDataMercado] = useState([])
183
+ const [colunaDataMercadoSugerida, setColunaDataMercadoSugerida] = useState('')
184
+ const [colunaDataMercado, setColunaDataMercado] = useState('')
185
+ const [periodoDadosMercado, setPeriodoDadosMercado] = useState(null)
186
+ const [periodoDadosMercadoPreview, setPeriodoDadosMercadoPreview] = useState(null)
187
+ const [dataMercadoError, setDataMercadoError] = useState('')
188
+ const [dataMercadoLoading, setDataMercadoLoading] = useState(false)
189
 
190
  const [outliersAnteriores, setOutliersAnteriores] = useState([])
191
  const [iteracao, setIteracao] = useState(1)
 
257
  transformacao: formatTransformacaoBadge(transformacoes[coluna]),
258
  }))
259
  }, [modeloCarregadoInfo])
260
+ const periodoModeloCarregadoTexto = useMemo(
261
+ () => formatPeriodoDadosMercado(modeloCarregadoInfo?.periodo_dados_mercado),
262
+ [modeloCarregadoInfo],
263
+ )
264
+ const periodoDadosMercadoTexto = useMemo(
265
+ () => formatPeriodoDadosMercado(periodoDadosMercado),
266
+ [periodoDadosMercado],
267
+ )
268
+ const periodoDadosMercadoPreviewTexto = useMemo(
269
+ () => formatPeriodoDadosMercado(periodoDadosMercadoPreview),
270
+ [periodoDadosMercadoPreview],
271
+ )
272
  const transformacaoAplicadaYBadge = useMemo(
273
  () => formatTransformacaoBadge(transformacoesAplicadas?.transformacao_y),
274
  [transformacoesAplicadas],
 
408
 
409
  function applyBaseResponse(resp, options = {}) {
410
  const resetXSelection = Boolean(options.resetXSelection)
411
+ setDataMercadoError('')
412
  if (resp.status) setStatus(resp.status)
413
  if (resp.dados) setDados(resp.dados)
414
  if (resp.mapa_html) setMapaHtml(resp.mapa_html)
415
  if (resp.colunas_numericas) setColunasNumericas(resp.colunas_numericas)
416
+ if (Array.isArray(resp.colunas_data_mercado)) {
417
+ setColunasDataMercado(resp.colunas_data_mercado.map((item) => String(item)))
418
+ }
419
+ if (Object.prototype.hasOwnProperty.call(resp, 'coluna_data_mercado_sugerida')) {
420
+ setColunaDataMercadoSugerida(String(resp.coluna_data_mercado_sugerida || ''))
421
+ }
422
+ if (Object.prototype.hasOwnProperty.call(resp, 'coluna_data_mercado')) {
423
+ setColunaDataMercado(String(resp.coluna_data_mercado || ''))
424
+ }
425
+ if (Object.prototype.hasOwnProperty.call(resp, 'periodo_dados_mercado')) {
426
+ const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
427
+ setPeriodoDadosMercado(periodo)
428
+ setPeriodoDadosMercadoPreview(periodo)
429
+ }
430
  if (resp.coluna_y_padrao) setColunaY(resp.coluna_y_padrao)
431
 
432
  if (resp.contexto && !resetXSelection) {
 
448
  setOrigemTransformacoes(null)
449
  setOutliersAnteriores([])
450
  setIteracao(1)
451
+ setColunaDataMercadoSugerida('')
452
+ setColunaDataMercado('')
453
+ setPeriodoDadosMercado(null)
454
+ setPeriodoDadosMercadoPreview(null)
455
+ setDataMercadoError('')
456
  setSelection(null)
457
  setFit(null)
458
  setCamposAvaliacao([])
 
590
  setTransformacoesX({})
591
  setTransformacoesAplicadas(null)
592
  setOrigemTransformacoes(null)
593
+ setColunasDataMercado([])
594
+ setColunaDataMercadoSugerida('')
595
+ setColunaDataMercado('')
596
+ setPeriodoDadosMercado(null)
597
+ setPeriodoDadosMercadoPreview(null)
598
+ setDataMercadoError('')
599
  setCamposAvaliacao([])
600
  valoresAvaliacaoRef.current = {}
601
  setAvaliacaoFormVersion((prev) => prev + 1)
 
657
  })
658
  }
659
 
660
+ async function onColunaDataMercadoChange(value) {
661
+ const coluna = String(value || '')
662
+ setColunaDataMercado(coluna)
663
+ setDataMercadoError('')
664
+
665
+ if (!sessionId || !coluna) {
666
+ setPeriodoDadosMercadoPreview(null)
667
+ return
668
+ }
669
+
670
+ setDataMercadoLoading(true)
671
+ try {
672
+ const resp = await api.previewMarketDateColumn(sessionId, coluna)
673
+ const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
674
+ setPeriodoDadosMercadoPreview(periodo)
675
+ if (resp.status) setStatus(resp.status)
676
+ } catch (err) {
677
+ setPeriodoDadosMercadoPreview(null)
678
+ setDataMercadoError(err.message || 'Falha ao identificar período da coluna selecionada.')
679
+ } finally {
680
+ setDataMercadoLoading(false)
681
+ }
682
+ }
683
+
684
+ async function onAplicarColunaDataMercado() {
685
+ if (!sessionId || !colunaDataMercado) return
686
+ await withBusy(async () => {
687
+ const resp = await api.applyMarketDateColumn(sessionId, colunaDataMercado)
688
+ const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
689
+ setColunaDataMercado(String(resp.coluna_data_mercado || colunaDataMercado))
690
+ setPeriodoDadosMercado(periodo)
691
+ setPeriodoDadosMercadoPreview(periodo)
692
+ setDataMercadoError('')
693
+ if (resp.status) setStatus(resp.status)
694
+ setModeloCarregadoInfo((prev) => {
695
+ if (!prev) return prev
696
+ return {
697
+ ...prev,
698
+ periodo_dados_mercado: periodo,
699
+ }
700
+ })
701
+ })
702
+ }
703
+
704
  async function onMapCoords() {
705
  if (!manualLat || !manualLon || !sessionId) return
706
  setLoading(true)
 
1204
  ) : (
1205
  <div className="section1-empty-hint">Sem variáveis independentes no modelo carregado.</div>
1206
  )}
1207
+ <div className="variavel-badge-line">
1208
+ <span className="variavel-badge-label">Período dados:</span>
1209
+ <span className="variavel-badge-value">{periodoModeloCarregadoTexto}</span>
1210
+ </div>
1211
  </div>
1212
  </div>
1213
  </div>
 
1495
  </div>
1496
  </SectionBlock>
1497
 
1498
+ <SectionBlock
1499
+ step="4"
1500
+ title="Definir Data dos Dados de Mercado"
1501
+ subtitle="Selecione a coluna de data dos dados de mercado, visualize o período e aplique."
1502
+ >
1503
+ <div className="market-date-grid">
1504
+ <div className="section1-empty-hint">
1505
+ Sugestão de coluna: {colunaDataMercadoSugerida || '-'}
1506
+ </div>
1507
+ <div className="row market-date-row">
1508
+ <label>Coluna de data dos dados de mercado</label>
1509
+ <select
1510
+ value={colunaDataMercado}
1511
+ onChange={(e) => {
1512
+ void onColunaDataMercadoChange(e.target.value)
1513
+ }}
1514
+ disabled={loading || dataMercadoLoading || colunasDataMercado.length === 0}
1515
+ >
1516
+ <option value="">Selecione</option>
1517
+ {colunasDataMercado.map((col) => (
1518
+ <option key={`data-${col}`} value={col}>{col}</option>
1519
+ ))}
1520
+ </select>
1521
+ </div>
1522
+ <div className="row market-date-actions-row">
1523
+ <div className="resumo-outliers-box">
1524
+ Período identificado: {periodoDadosMercadoPreviewTexto}
1525
+ </div>
1526
+ <button
1527
+ type="button"
1528
+ onClick={onAplicarColunaDataMercado}
1529
+ disabled={loading || dataMercadoLoading || !colunaDataMercado || !periodoDadosMercadoPreview}
1530
+ >
1531
+ Aplicar período
1532
+ </button>
1533
+ </div>
1534
+ {dataMercadoError ? <div className="error-line inline-error">{dataMercadoError}</div> : null}
1535
+ </div>
1536
+ </SectionBlock>
1537
+
1538
+ <SectionBlock step="5" title="Selecionar Variável Dependente" subtitle="Defina a variável dependente (Y).">
1539
  <div className="row">
1540
  <label>Variável Dependente (Y)</label>
1541
  <select value={colunaY} onChange={(e) => setColunaY(e.target.value)}>
 
1547
  </div>
1548
  </SectionBlock>
1549
 
1550
+ <SectionBlock step="6" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
1551
  <div className="compact-option-group compact-option-group-x">
1552
  <h4>Variáveis Independentes (X)</h4>
1553
  <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
 
1630
 
1631
  {selection ? (
1632
  <>
1633
+ <SectionBlock step="7" title="Estatísticas das Variáveis Selecionadas" subtitle="Resumo estatístico para Y e regressoras.">
1634
  <DataTable table={selection.estatisticas} />
1635
  </SectionBlock>
1636
 
1637
+ <SectionBlock step="8" title="Teste de Micronumerosidade" subtitle="Validação de amostra mínima para variáveis selecionadas.">
1638
  <div dangerouslySetInnerHTML={{ __html: selection.micronumerosidade_html || '' }} />
1639
  </SectionBlock>
1640
 
1641
+ <SectionBlock step="9" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
1642
  <details
1643
  className="section-content-toggle"
1644
  open={section8Open}
 
1656
  </details>
1657
  </SectionBlock>
1658
 
1659
+ <SectionBlock step="10" title="Transformações Sugeridas" subtitle="Busca automática de combinações por R² e enquadramento.">
1660
  <div className="row">
1661
  <label>Grau mínimo dos coeficientes</label>
1662
  <select value={grauCoef} onChange={(e) => setGrauCoef(Number(e.target.value))}>
 
1713
  )}
1714
  </SectionBlock>
1715
 
1716
+ <SectionBlock step="11" title="Aplicação das Transformações" subtitle="Configuração manual para ajuste do modelo.">
1717
  <div className="manual-transform-toggle">
1718
  <button
1719
  type="button"
 
1783
  </div>
1784
  </div>
1785
  ) : null}
1786
+ <div className="variavel-badge-line">
1787
+ <span className="variavel-badge-label">Período dados:</span>
1788
+ <span className="variavel-badge-value">{periodoDadosMercadoTexto}</span>
1789
+ </div>
1790
  </div>
1791
  </div>
1792
  </div>
 
1797
 
1798
  {fit ? (
1799
  <>
1800
+ <SectionBlock step="12" title="Gráficos de Dispersão (Variáveis Transformadas)" subtitle="Dispersão com variáveis já transformadas.">
1801
  <details
1802
  className="section-content-toggle"
1803
  open={section11Open}
 
1820
  </details>
1821
  </SectionBlock>
1822
 
1823
+ <SectionBlock step="13" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
1824
  <div dangerouslySetInnerHTML={{ __html: fit.diagnosticos_html || '' }} />
1825
  <div className="two-col diagnostic-tables">
1826
  <div className="pane">
 
1834
  </div>
1835
  </SectionBlock>
1836
 
1837
+ <SectionBlock step="14" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
1838
  <div className="plot-grid-2-fixed">
1839
  <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
1840
  <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
 
1846
  </div>
1847
  </SectionBlock>
1848
 
1849
+ <SectionBlock step="15" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
1850
  <DataTable table={fit.tabela_metricas} maxHeight={320} />
1851
  </SectionBlock>
1852
 
1853
+ <SectionBlock step="16" title="Exclusão ou Reinclusão de Outliers" subtitle="Filtre índices, revise e atualize o modelo.">
1854
  {outliersAnteriores.length > 0 && outliersHtml ? (
1855
  <div className="outliers-html-box" dangerouslySetInnerHTML={{ __html: outliersHtml }} />
1856
  ) : null}
 
1934
  <div className="resumo-outliers-box">Outliers anteriores: {joinSelection(outliersAnteriores) || '-'}</div>
1935
  </SectionBlock>
1936
 
1937
+ <SectionBlock step="17" title="Avaliação de Imóvel" subtitle="Cálculo individual e comparação entre avaliações.">
1938
  <div className="avaliacao-grid" key={`avaliacao-grid-elab-${avaliacaoFormVersion}`}>
1939
  {camposAvaliacao.map((campo) => (
1940
  <div key={`aval-${campo.coluna}`} className="avaliacao-card">
 
1987
  />
1988
  </SectionBlock>
1989
 
1990
+ <SectionBlock step="18" title="Exportar Modelo" subtitle="Geração do pacote .dai e download da base tratada.">
1991
  <div className="row">
1992
  <label>Nome do arquivo (.dai)</label>
1993
  <input type="text" value={nomeArquivoExport} onChange={(e) => setNomeArquivoExport(e.target.value)} />
frontend/src/components/InicioTab.jsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export default function InicioTab() {
4
+ return (
5
+ <div className="tab-content">
6
+ <section className="inicio-card">
7
+ <h3>Resumo rápido</h3>
8
+ <ul className="inicio-lista">
9
+ <li><strong>Pesquisa:</strong> encontra modelos compatíveis com os filtros informados.</li>
10
+ <li><strong>Elaboração/Edição:</strong> cria, ajusta e exporta modelos estatísticos.</li>
11
+ <li><strong>Visualização/Avaliação:</strong> abre modelos `.dai`, mostra diagnósticos e permite avaliação.</li>
12
+ </ul>
13
+ <p className="inicio-creditos">
14
+ Aplicativo criado por Guilherme Silberfarb Costa e David Schuch Bertoglio.
15
+ </p>
16
+ </section>
17
+ </div>
18
+ )
19
+ }
frontend/src/components/PesquisaAdminConfigPanel.jsx CHANGED
@@ -1,76 +1,94 @@
1
  import React, { useEffect, useMemo, useState } from 'react'
2
  import { api } from '../api'
3
 
4
- const CAMPOS_GRUPOS = [
5
- {
6
- titulo: 'Otica do modelo',
7
- campos: ['finalidade', 'bairros', 'data', 'area', 'rh'],
8
- },
9
- {
10
- titulo: 'Otica do avaliando',
11
- campos: [
12
- 'aval_finalidade',
13
- 'aval_bairro',
14
- 'aval_data',
15
- 'aval_area',
16
- 'aval_rh',
17
- ],
18
- },
19
  ]
20
 
21
- const CAMPO_LABELS = {
22
- finalidade: 'Finalidade',
23
- bairros: 'Bairro',
24
- data: 'Data',
25
- area: 'Area',
26
- rh: 'RH',
27
- aval_finalidade: 'Finalidade (avaliando)',
28
- aval_bairro: 'Bairro (avaliando)',
29
- aval_data: 'Data (avaliando)',
30
- aval_area: 'Area (avaliando)',
31
- aval_rh: 'RH (avaliando)',
32
  }
33
 
34
  function normalizeColunasConfig(rawConfig = {}) {
35
  const out = {}
36
- Object.entries(rawConfig || {}).forEach(([campo, config]) => {
37
- const disponiveis = []
38
- const vistos = new Set()
39
- ;(Array.isArray(config?.disponiveis) ? config.disponiveis : []).forEach((item) => {
40
- const id = typeof item === 'string' ? item : item?.id
41
- const label = typeof item === 'string' ? item : item?.label || item?.id
42
- const idText = String(id || '').trim()
43
- if (!idText || vistos.has(idText)) return
44
- vistos.add(idText)
45
- disponiveis.push({ id: idText, label: String(label || idText) })
46
- })
47
  const padrao = []
48
- ;(Array.isArray(config?.padrao) ? config.padrao : []).forEach((item) => {
49
- const idText = String(item || '').trim()
50
- if (!idText || !vistos.has(idText) || padrao.includes(idText)) return
51
- padrao.push(idText)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  })
53
- out[campo] = { disponiveis, padrao }
 
 
 
 
 
54
  })
55
  return out
56
  }
57
 
58
  function normalizeSelecionadas(raw = {}, configNormalizada = {}) {
59
  const out = {}
60
- Object.keys(configNormalizada || {}).forEach((campo) => {
61
- const disponiveis = new Set((configNormalizada[campo]?.disponiveis || []).map((item) => item.id))
62
- const preferidas = Array.isArray(raw?.[campo]) ? raw[campo] : []
63
- const validas = preferidas.map((item) => String(item || '').trim()).filter((item) => item && disponiveis.has(item))
 
 
 
64
  if (validas.length) {
65
- out[campo] = Array.from(new Set(validas))
66
  return
67
  }
68
- const padrao = (configNormalizada[campo]?.padrao || []).filter((item) => disponiveis.has(item))
69
- out[campo] = Array.from(new Set(padrao))
70
  })
71
  return out
72
  }
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  function serializarCampos(campos = {}) {
75
  const normalizado = {}
76
  Object.keys(campos || {}).sort().forEach((campo) => {
@@ -145,7 +163,7 @@ export default function PesquisaAdminConfigPanel({ onSaved }) {
145
  setError('')
146
  setStatus('')
147
  try {
148
- const response = await api.pesquisaAdminConfigSalvar(selecionadas)
149
  const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
150
  const selecionadasNormalizadas = normalizeSelecionadas(response.admin_fontes || {}, configNormalizada)
151
  const base = serializarCampos(selecionadasNormalizadas)
@@ -161,7 +179,7 @@ export default function PesquisaAdminConfigPanel({ onSaved }) {
161
  }
162
  }
163
 
164
- function renderCampo(campo) {
165
  const configCampo = colunasConfig[campo] || { disponiveis: [], padrao: [] }
166
  const selecionadasCampo = selecionadas[campo] || []
167
  const selectedSet = new Set(selecionadasCampo)
@@ -170,7 +188,7 @@ export default function PesquisaAdminConfigPanel({ onSaved }) {
170
  return (
171
  <div key={campo} className="pesquisa-admin-field">
172
  <div className="pesquisa-admin-field-head">
173
- <strong>{CAMPO_LABELS[campo] || campo}</strong>
174
  <button type="button" className="btn-pesquisa-expand" onClick={() => onRestaurarPadrao(campo)}>
175
  Restaurar padrao
176
  </button>
@@ -179,10 +197,10 @@ export default function PesquisaAdminConfigPanel({ onSaved }) {
179
  <div className="pesquisa-dynamic-filter-row pesquisa-admin-row">
180
  <div className="pesquisa-colunas-box">
181
  <div className="pesquisa-colunas-chip-list">
182
- {selecionadasCampo.map((id) => (
183
- <span key={`${campo}-${id}`} className="pesquisa-coluna-chip">
184
- <span>{findLabel(campo, id)}</span>
185
- <button type="button" className="pesquisa-coluna-remove" onClick={() => onRemove(campo, id)} aria-label={`Remover fonte ${findLabel(campo, id)}`}>
186
  x
187
  </button>
188
  </span>
@@ -225,14 +243,9 @@ export default function PesquisaAdminConfigPanel({ onSaved }) {
225
  {status ? <div className="status-line">{status}</div> : null}
226
  {error ? <div className="error-line inline-error">{error}</div> : null}
227
 
228
- {CAMPOS_GRUPOS.map((grupo) => (
229
- <section key={grupo.titulo} className="pesquisa-filtro-grupo">
230
- <h5>{grupo.titulo}</h5>
231
- <div className="pesquisa-admin-fields">
232
- {grupo.campos.map((campo) => renderCampo(campo))}
233
- </div>
234
- </section>
235
- ))}
236
  </div>
237
  )
238
  }
 
1
  import React, { useEffect, useMemo, useState } from 'react'
2
  import { api } from '../api'
3
 
4
+ const CAMPOS_UNIFICADOS = [
5
+ { id: 'finalidade', label: 'Finalidade', targets: ['aval_finalidade'] },
6
+ { id: 'bairros', label: 'Bairro', targets: ['aval_bairro'] },
7
+ { id: 'data', label: 'Data', targets: ['data'] },
8
+ { id: 'area', label: 'Area', targets: ['aval_area'] },
9
+ { id: 'rh', label: 'RH', targets: ['aval_rh'] },
 
 
 
 
 
 
 
 
 
10
  ]
11
 
12
+ function sortByLabel(items = []) {
13
+ return [...items].sort((a, b) => a.label.localeCompare(b.label, 'pt-BR', { sensitivity: 'base' }))
 
 
 
 
 
 
 
 
 
14
  }
15
 
16
  function normalizeColunasConfig(rawConfig = {}) {
17
  const out = {}
18
+ CAMPOS_UNIFICADOS.forEach(({ id, targets }) => {
19
+ const disponiveisMap = new Map()
 
 
 
 
 
 
 
 
 
20
  const padrao = []
21
+ const padraoVistos = new Set()
22
+
23
+ targets.forEach((target) => {
24
+ const config = rawConfig?.[target] || {}
25
+ ;(Array.isArray(config?.disponiveis) ? config.disponiveis : []).forEach((item) => {
26
+ const itemId = typeof item === 'string' ? item : item?.id
27
+ const itemLabel = typeof item === 'string' ? item : item?.label || item?.id
28
+ const idText = String(itemId || '').trim()
29
+ if (!idText) return
30
+ if (!disponiveisMap.has(idText)) {
31
+ disponiveisMap.set(idText, String(itemLabel || idText))
32
+ }
33
+ })
34
+ })
35
+
36
+ const disponiveis = sortByLabel(Array.from(disponiveisMap.entries()).map(([itemId, label]) => ({ id: itemId, label })))
37
+ const idsDisponiveis = new Set(disponiveis.map((item) => item.id))
38
+
39
+ targets.forEach((target) => {
40
+ const config = rawConfig?.[target] || {}
41
+ ;(Array.isArray(config?.padrao) ? config.padrao : []).forEach((item) => {
42
+ const idText = String(item || '').trim()
43
+ if (!idText || !idsDisponiveis.has(idText) || padraoVistos.has(idText)) return
44
+ padraoVistos.add(idText)
45
+ padrao.push(idText)
46
+ })
47
  })
48
+
49
+ if (!padrao.length) {
50
+ disponiveis.forEach((item) => padrao.push(item.id))
51
+ }
52
+
53
+ out[id] = { disponiveis, padrao }
54
  })
55
  return out
56
  }
57
 
58
  function normalizeSelecionadas(raw = {}, configNormalizada = {}) {
59
  const out = {}
60
+ CAMPOS_UNIFICADOS.forEach(({ id, targets }) => {
61
+ const disponiveis = new Set((configNormalizada[id]?.disponiveis || []).map((item) => item.id))
62
+ const preferidas = []
63
+ targets.forEach((target) => {
64
+ ;(Array.isArray(raw?.[target]) ? raw[target] : []).forEach((item) => preferidas.push(String(item || '').trim()))
65
+ })
66
+ const validas = preferidas.filter((item, idx) => item && disponiveis.has(item) && preferidas.indexOf(item) === idx)
67
  if (validas.length) {
68
+ out[id] = validas
69
  return
70
  }
71
+ const padrao = (configNormalizada[id]?.padrao || []).filter((item) => disponiveis.has(item))
72
+ out[id] = Array.from(new Set(padrao))
73
  })
74
  return out
75
  }
76
 
77
+ function buildPayload(selecionadas = {}, colunasConfig = {}) {
78
+ const payload = {}
79
+ CAMPOS_UNIFICADOS.forEach(({ id, targets }) => {
80
+ const idsDisponiveis = new Set((colunasConfig[id]?.disponiveis || []).map((item) => item.id))
81
+ const selecionadasCampo = (selecionadas[id] || [])
82
+ .map((item) => String(item || '').trim())
83
+ .filter((item, idx, arr) => item && idsDisponiveis.has(item) && arr.indexOf(item) === idx)
84
+
85
+ targets.forEach((target) => {
86
+ payload[target] = [...selecionadasCampo]
87
+ })
88
+ })
89
+ return payload
90
+ }
91
+
92
  function serializarCampos(campos = {}) {
93
  const normalizado = {}
94
  Object.keys(campos || {}).sort().forEach((campo) => {
 
163
  setError('')
164
  setStatus('')
165
  try {
166
+ const response = await api.pesquisaAdminConfigSalvar(buildPayload(selecionadas, colunasConfig))
167
  const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
168
  const selecionadasNormalizadas = normalizeSelecionadas(response.admin_fontes || {}, configNormalizada)
169
  const base = serializarCampos(selecionadasNormalizadas)
 
179
  }
180
  }
181
 
182
+ function renderCampo(campo, label) {
183
  const configCampo = colunasConfig[campo] || { disponiveis: [], padrao: [] }
184
  const selecionadasCampo = selecionadas[campo] || []
185
  const selectedSet = new Set(selecionadasCampo)
 
188
  return (
189
  <div key={campo} className="pesquisa-admin-field">
190
  <div className="pesquisa-admin-field-head">
191
+ <strong>{label}</strong>
192
  <button type="button" className="btn-pesquisa-expand" onClick={() => onRestaurarPadrao(campo)}>
193
  Restaurar padrao
194
  </button>
 
197
  <div className="pesquisa-dynamic-filter-row pesquisa-admin-row">
198
  <div className="pesquisa-colunas-box">
199
  <div className="pesquisa-colunas-chip-list">
200
+ {selecionadasCampo.map((itemId) => (
201
+ <span key={`${campo}-${itemId}`} className="pesquisa-coluna-chip">
202
+ <span>{findLabel(campo, itemId)}</span>
203
+ <button type="button" className="pesquisa-coluna-remove" onClick={() => onRemove(campo, itemId)} aria-label={`Remover fonte ${findLabel(campo, itemId)}`}>
204
  x
205
  </button>
206
  </span>
 
243
  {status ? <div className="status-line">{status}</div> : null}
244
  {error ? <div className="error-line inline-error">{error}</div> : null}
245
 
246
+ <div className="pesquisa-admin-fields">
247
+ {CAMPOS_UNIFICADOS.map((campo) => renderCampo(campo.id, campo.label))}
248
+ </div>
 
 
 
 
 
249
  </div>
250
  )
251
  }
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -5,21 +5,12 @@ import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
5
  import SectionBlock from './SectionBlock'
6
 
7
  const EMPTY_FILTERS = {
8
- otica: 'modelo',
9
- nome: '',
10
- autor: '',
11
  contemApp: '',
12
- finalidade: '',
13
- bairros: '',
14
  dataMin: '',
15
  dataMax: '',
16
- areaMin: '',
17
- areaMax: '',
18
- rhMin: '',
19
- rhMax: '',
20
  avalFinalidade: '',
21
  avalBairro: '',
22
- avalData: '',
23
  avalArea: '',
24
  avalRh: '',
25
  }
@@ -31,48 +22,14 @@ const RESULT_INITIAL = {
31
  total_geral: 0,
32
  }
33
 
34
- const CAMPOS_COLUNAS_FILTRO = [
35
- 'finalidade',
36
- 'bairros',
37
- 'data',
38
- 'area',
39
- 'rh',
40
- 'aval_finalidade',
41
- 'aval_bairro',
42
- 'aval_data',
43
- 'aval_area',
44
- 'aval_area_privativa',
45
- 'aval_area_total',
46
- 'aval_rh',
47
- 'aval_valor_unitario',
48
- 'aval_valor_total',
49
- ]
50
-
51
- const COLUNAS_FILTRO_INITIAL = {
52
- finalidade: [],
53
- bairros: [],
54
- data: [],
55
- area: [],
56
- rh: [],
57
- aval_finalidade: [],
58
- aval_bairro: [],
59
- aval_data: [],
60
- aval_area: [],
61
- aval_area_privativa: [],
62
- aval_area_total: [],
63
- aval_rh: [],
64
- aval_valor_unitario: [],
65
- aval_valor_total: [],
66
- }
67
-
68
  const TIPO_SIGLAS = {
69
  RECOND: 'Residencia em condominio',
70
  RCOMD: 'Residencia em condominio',
71
  TCOND: 'Terreno em condominio',
72
  SALA: 'Salas comerciais',
73
- APTO: 'Apartamentos residenciais',
74
- APART: 'Apartamentos residenciais',
75
- AP: 'Apartamentos residenciais',
76
  TERRENO: 'Terrenos',
77
  TER: 'Terrenos',
78
  EDIF: 'Edificio',
@@ -90,9 +47,9 @@ function formatRange(range) {
90
  const min = range.min ?? null
91
  const max = range.max ?? null
92
  if (min === null && max === null) return '-'
93
- if (min !== null && max !== null) return `${min} a ${max}`
94
- if (min !== null) return `a partir de ${min}`
95
- return `ate ${max}`
96
  }
97
 
98
  function formatCount(value) {
@@ -103,6 +60,14 @@ function formatCount(value) {
103
  return String(value)
104
  }
105
 
 
 
 
 
 
 
 
 
106
  function normalizeTokenText(value) {
107
  return String(value || '')
108
  .normalize('NFD')
@@ -136,75 +101,17 @@ function formatTipoImovel(modelo) {
136
  return mapped || text
137
  }
138
 
139
- function normalizeColunasConfig(rawConfig = {}) {
140
- const out = {}
141
- CAMPOS_COLUNAS_FILTRO.forEach((campo) => {
142
- const config = rawConfig?.[campo] || {}
143
- const disponiveis = []
144
- const vistos = new Set()
145
- ;(Array.isArray(config.disponiveis) ? config.disponiveis : []).forEach((item) => {
146
- const id = typeof item === 'string' ? item : item?.id
147
- const label = typeof item === 'string' ? item : item?.label || item?.id
148
- const idText = String(id || '').trim()
149
- if (!idText || vistos.has(idText)) return
150
- vistos.add(idText)
151
- disponiveis.push({ id: idText, label: String(label || idText) })
152
- })
153
-
154
- const padrao = []
155
- ;(Array.isArray(config.padrao) ? config.padrao : []).forEach((item) => {
156
- const idText = String(item || '').trim()
157
- if (!idText || !vistos.has(idText) || padrao.includes(idText)) return
158
- padrao.push(idText)
159
- })
160
-
161
- out[campo] = { disponiveis, padrao }
162
- })
163
- return out
164
- }
165
-
166
- function reconciliarColunasSelecionadas(atual, configNormalizada, camposEditados = {}) {
167
- const next = { ...COLUNAS_FILTRO_INITIAL, ...atual }
168
- CAMPOS_COLUNAS_FILTRO.forEach((campo) => {
169
- const configCampo = configNormalizada[campo] || { disponiveis: [], padrao: [] }
170
- const idsDisponiveis = new Set((configCampo.disponiveis || []).map((item) => item.id))
171
- const selecionadasValidas = (next[campo] || []).filter((id) => idsDisponiveis.has(id))
172
- if (camposEditados[campo]) {
173
- next[campo] = selecionadasValidas
174
- return
175
- }
176
- const padraoValido = (configCampo.padrao || []).filter((id) => idsDisponiveis.has(id))
177
- next[campo] = padraoValido.length ? padraoValido : selecionadasValidas
178
- })
179
- return next
180
- }
181
-
182
  function buildApiFilters(filters) {
183
- if (filters.otica === 'avaliando') {
184
- return {
185
- otica: filters.otica,
186
- contem_app: filters.contemApp,
187
- aval_finalidade: filters.avalFinalidade,
188
- aval_bairro: filters.avalBairro,
189
- aval_data: filters.avalData,
190
- aval_area: filters.avalArea,
191
- aval_rh: filters.avalRh,
192
- }
193
- }
194
-
195
  return {
196
- otica: filters.otica,
197
- nome: filters.nome,
198
- autor: filters.autor,
199
  contem_app: filters.contemApp,
200
- finalidade: filters.finalidade,
201
- bairros: filters.bairros,
 
202
  data_min: filters.dataMin,
203
  data_max: filters.dataMax,
204
- area_min: filters.areaMin,
205
- area_max: filters.areaMax,
206
- rh_min: filters.rhMin,
207
- rh_max: filters.rhMax,
208
  }
209
  }
210
 
@@ -244,150 +151,15 @@ function NumberFieldInput({ field, ...props }) {
244
  )
245
  }
246
 
247
- function DynamicFilterField({
248
- label,
249
- campoValor,
250
- campoColunas,
251
- configCampo,
252
- selecionadas,
253
- onAddColuna,
254
- onRemoveColuna,
255
- value,
256
- onChange,
257
- list,
258
- placeholder,
259
- inputKind = 'text',
260
- }) {
261
- const disponiveis = configCampo?.disponiveis || []
262
- const selectedSet = new Set(selecionadas || [])
263
- const opcoesAdicionar = disponiveis.filter((item) => !selectedSet.has(item.id))
264
- const InputComponent = inputKind === 'number' ? NumberFieldInput : TextFieldInput
265
-
266
- function findLabel(id) {
267
- const match = disponiveis.find((item) => item.id === id)
268
- return match?.label || id
269
- }
270
-
271
  return (
272
- <label className="pesquisa-field pesquisa-field-wide">
273
- {label ? <span>{label}</span> : null}
274
- <div className="pesquisa-dynamic-filter-row">
275
- <div className="pesquisa-colunas-box">
276
- <div className="pesquisa-colunas-chip-list">
277
- {(selecionadas || []).map((id) => (
278
- <span key={`${campoColunas}-${id}`} className="pesquisa-coluna-chip">
279
- <span>{findLabel(id)}</span>
280
- <button type="button" className="pesquisa-coluna-remove" onClick={() => onRemoveColuna(campoColunas, id)} aria-label={`Remover coluna ${findLabel(id)}`}>
281
- x
282
- </button>
283
- </span>
284
- ))}
285
- {!(selecionadas || []).length ? <span className="pesquisa-colunas-empty">Nenhuma coluna selecionada.</span> : null}
286
- </div>
287
- </div>
288
-
289
- <select
290
- className="pesquisa-colunas-add"
291
- defaultValue=""
292
- onChange={(event) => {
293
- const selected = String(event.target.value || '').trim()
294
- if (!selected) return
295
- onAddColuna(campoColunas, selected)
296
- event.target.value = ''
297
- }}
298
- >
299
- <option value="">Adicionar coluna...</option>
300
- {opcoesAdicionar.map((item) => (
301
- <option key={`${campoColunas}-opt-${item.id}`} value={item.id}>{item.label}</option>
302
- ))}
303
- </select>
304
-
305
- <InputComponent
306
- list={list}
307
- field={campoValor}
308
- value={value}
309
- onChange={onChange}
310
- placeholder={placeholder}
311
- />
312
- </div>
313
- </label>
314
- )
315
- }
316
-
317
- function DynamicRangeFilterField({
318
- label,
319
- campoColunas,
320
- configCampo,
321
- selecionadas,
322
- onAddColuna,
323
- onRemoveColuna,
324
- minLabel,
325
- minField,
326
- minValue,
327
- maxLabel,
328
- maxField,
329
- maxValue,
330
- onChange,
331
- minPlaceholder,
332
- maxPlaceholder,
333
- inputKind = 'number',
334
- }) {
335
- const disponiveis = configCampo?.disponiveis || []
336
- const selectedSet = new Set(selecionadas || [])
337
- const opcoesAdicionar = disponiveis.filter((item) => !selectedSet.has(item.id))
338
- const InputComponent = inputKind === 'number' ? NumberFieldInput : TextFieldInput
339
-
340
- function findLabel(id) {
341
- const match = disponiveis.find((item) => item.id === id)
342
- return match?.label || id
343
- }
344
-
345
- return (
346
- <div className="pesquisa-field pesquisa-field-wide">
347
- {label ? <span>{label}</span> : null}
348
- <div className="pesquisa-dynamic-filter-row pesquisa-dynamic-filter-row-range">
349
- <div className="pesquisa-colunas-box">
350
- <div className="pesquisa-colunas-chip-list">
351
- {(selecionadas || []).map((id) => (
352
- <span key={`${campoColunas}-${id}`} className="pesquisa-coluna-chip">
353
- <span>{findLabel(id)}</span>
354
- <button type="button" className="pesquisa-coluna-remove" onClick={() => onRemoveColuna(campoColunas, id)} aria-label={`Remover coluna ${findLabel(id)}`}>
355
- x
356
- </button>
357
- </span>
358
- ))}
359
- {!(selecionadas || []).length ? <span className="pesquisa-colunas-empty">Nenhuma coluna selecionada.</span> : null}
360
- </div>
361
- </div>
362
-
363
- <select
364
- className="pesquisa-colunas-add"
365
- defaultValue=""
366
- onChange={(event) => {
367
- const selected = String(event.target.value || '').trim()
368
- if (!selected) return
369
- onAddColuna(campoColunas, selected)
370
- event.target.value = ''
371
- }}
372
- >
373
- <option value="">Adicionar coluna...</option>
374
- {opcoesAdicionar.map((item) => (
375
- <option key={`${campoColunas}-opt-${item.id}`} value={item.id}>{item.label}</option>
376
- ))}
377
- </select>
378
- </div>
379
-
380
- <div className="pesquisa-range-values-row">
381
- <label className="pesquisa-field">
382
- {minLabel}
383
- <InputComponent field={minField} value={minValue} onChange={onChange} placeholder={minPlaceholder} />
384
- </label>
385
- <label className="pesquisa-field">
386
- {maxLabel}
387
- <InputComponent field={maxField} value={maxValue} onChange={onChange} placeholder={maxPlaceholder} />
388
- </label>
389
- </div>
390
- </div>
391
  )
392
  }
393
 
@@ -403,9 +175,6 @@ export default function PesquisaTab() {
403
  const [selectedIds, setSelectedIds] = useState([])
404
  const [detailModelId, setDetailModelId] = useState('')
405
  const selectAllRef = useRef(null)
406
- const [colunasConfig, setColunasConfig] = useState({})
407
- const [colunasFiltro, setColunasFiltro] = useState(COLUNAS_FILTRO_INITIAL)
408
- const [colunasEditadas, setColunasEditadas] = useState({})
409
 
410
  const [mapaLoading, setMapaLoading] = useState(false)
411
  const [mapaError, setMapaError] = useState('')
@@ -413,21 +182,24 @@ export default function PesquisaTab() {
413
  const [mapaHtml, setMapaHtml] = useState('')
414
  const [mapaLegendas, setMapaLegendas] = useState([])
415
 
416
- const usandoOticaAvaliando = filters.otica === 'avaliando'
417
  const sugestoes = result.sugestoes || {}
 
 
 
 
 
418
  const resultIds = useMemo(() => (result.modelos || []).map((modelo) => modelo.id), [result.modelos])
419
  const detalheModelo = useMemo(() => (result.modelos || []).find((modelo) => modelo.id === detailModelId) || null, [result.modelos, detailModelId])
420
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
421
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
422
 
423
- async function buscarModelos(nextFilters = filters, nextColunasFiltro = colunasFiltro, nextColunasEditadas = colunasEditadas) {
424
  setLoading(true)
425
  setError('')
426
  try {
427
- const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextColunasFiltro))
428
  const modelos = response.modelos || []
429
  const idsNovos = new Set(modelos.map((item) => item.id))
430
- const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
431
 
432
  setResult({
433
  ...RESULT_INITIAL,
@@ -435,8 +207,6 @@ export default function PesquisaTab() {
435
  modelos,
436
  sugestoes: response.sugestoes || {},
437
  })
438
- setColunasConfig(configNormalizada)
439
- setColunasFiltro((current) => reconciliarColunasSelecionadas(current, configNormalizada, nextColunasEditadas))
440
 
441
  setSelectedIds((current) => current.filter((id) => idsNovos.has(id)))
442
 
@@ -457,7 +227,6 @@ export default function PesquisaTab() {
457
  setError('')
458
  try {
459
  const response = await api.pesquisarModelos({ somente_contexto: true })
460
- const configNormalizada = normalizeColunasConfig(response.colunas_filtro || {})
461
 
462
  setResult({
463
  ...RESULT_INITIAL,
@@ -465,8 +234,6 @@ export default function PesquisaTab() {
465
  modelos: [],
466
  sugestoes: response.sugestoes || {},
467
  })
468
- setColunasConfig(configNormalizada)
469
- setColunasFiltro((current) => reconciliarColunasSelecionadas(current, configNormalizada, {}))
470
  setSelectedIds([])
471
  setMapaHtml('')
472
  setMapaStatus('')
@@ -514,15 +281,9 @@ export default function PesquisaTab() {
514
  setFilters((prev) => ({ ...prev, [field]: value }))
515
  }
516
 
517
- function onChangeOtica(otica) {
518
- setFilters((prev) => ({ ...prev, otica }))
519
- }
520
-
521
  async function onLimparFiltros() {
522
  setFilters(EMPTY_FILTERS)
523
- setColunasEditadas({})
524
- setColunasFiltro(COLUNAS_FILTRO_INITIAL)
525
- await buscarModelos(EMPTY_FILTERS, COLUNAS_FILTRO_INITIAL, {})
526
  }
527
 
528
  function onToggleSelecionado(modelId) {
@@ -554,23 +315,6 @@ export default function PesquisaTab() {
554
  setDetailModelId('')
555
  }
556
 
557
- function onAddColunaFiltro(campo, colunaId) {
558
- setColunasFiltro((current) => {
559
- const atual = current[campo] || []
560
- if (atual.includes(colunaId)) return current
561
- return { ...current, [campo]: [...atual, colunaId] }
562
- })
563
- setColunasEditadas((current) => ({ ...current, [campo]: true }))
564
- }
565
-
566
- function onRemoveColunaFiltro(campo, colunaId) {
567
- setColunasFiltro((current) => ({
568
- ...current,
569
- [campo]: (current[campo] || []).filter((item) => item !== colunaId),
570
- }))
571
- setColunasEditadas((current) => ({ ...current, [campo]: true }))
572
- }
573
-
574
  async function onGerarMapaSelecionados() {
575
  if (!selectedIds.length) {
576
  setMapaError('Selecione ao menos um modelo para plotar no mapa.')
@@ -604,7 +348,7 @@ export default function PesquisaTab() {
604
  <SectionBlock
605
  step="1"
606
  title="Filtros de Pesquisa"
607
- subtitle="Use a otica do modelo ou do avaliando. Todos os filtros sao cumulativos."
608
  aside={(
609
  <button
610
  type="button"
@@ -620,34 +364,7 @@ export default function PesquisaTab() {
620
  <PesquisaAdminConfigPanel onSaved={() => void onAdminConfigSalva()} />
621
  ) : null}
622
 
623
- <div className="pesquisa-otica-switch" role="tablist" aria-label="Otica de pesquisa">
624
- <button
625
- type="button"
626
- className={`pesquisa-otica-btn${!usandoOticaAvaliando ? ' active' : ''}`}
627
- role="tab"
628
- id="pesquisa-otica-modelo"
629
- aria-selected={!usandoOticaAvaliando}
630
- aria-controls="pesquisa-panel-modelo"
631
- tabIndex={!usandoOticaAvaliando ? 0 : -1}
632
- onClick={() => onChangeOtica('modelo')}
633
- >
634
- Otica do modelo
635
- </button>
636
- <button
637
- type="button"
638
- className={`pesquisa-otica-btn${usandoOticaAvaliando ? ' active' : ''}`}
639
- role="tab"
640
- id="pesquisa-otica-avaliando"
641
- aria-selected={usandoOticaAvaliando}
642
- aria-controls="pesquisa-panel-avaliando"
643
- tabIndex={usandoOticaAvaliando ? 0 : -1}
644
- onClick={() => onChangeOtica('avaliando')}
645
- >
646
- Otica do avaliando
647
- </button>
648
- </div>
649
-
650
- <div className="pesquisa-fields-grid pesquisa-fields-grid-single">
651
  <label className="pesquisa-field">
652
  Contem variavel APP (% APP)
653
  <select
@@ -662,147 +379,69 @@ export default function PesquisaTab() {
662
  <option value="nao">Nao</option>
663
  </select>
664
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  </div>
666
 
667
- {usandoOticaAvaliando ? (
668
- <div
669
- id="pesquisa-panel-avaliando"
670
- role="tabpanel"
671
- aria-labelledby="pesquisa-otica-avaliando"
672
- className="pesquisa-fields-grid pesquisa-avaliando-grid"
673
- >
674
- <label className="pesquisa-field">
675
- Finalidade do imovel
676
- <TextFieldInput
677
- list="pesquisa-finalidades"
678
- field="avalFinalidade"
679
- value={filters.avalFinalidade}
680
- onChange={onFieldChange}
681
- placeholder="Ex: Apartamento"
682
- />
683
- </label>
684
 
685
- <label className="pesquisa-field">
686
- Bairro do imovel
687
- <TextFieldInput
688
- list="pesquisa-bairros"
689
- field="avalBairro"
690
- value={filters.avalBairro}
691
- onChange={onFieldChange}
692
- placeholder="Ex: Centro"
693
- />
694
- </label>
695
 
696
- <div className="pesquisa-avaliando-inline">
697
- <label className="pesquisa-field">
698
- Data do imovel
699
- <TextFieldInput field="avalData" value={filters.avalData} onChange={onFieldChange} placeholder="2025-02-27" />
700
- </label>
701
  <label className="pesquisa-field">
702
- Area do imovel
703
- <NumberFieldInput field="avalArea" value={filters.avalArea} onChange={onFieldChange} placeholder="0" />
704
  </label>
705
  <label className="pesquisa-field">
706
- RH do imovel
707
- <NumberFieldInput field="avalRh" value={filters.avalRh} onChange={onFieldChange} placeholder="0" />
708
  </label>
709
  </div>
710
- </div>
711
- ) : (
712
- <div id="pesquisa-panel-modelo" role="tabpanel" aria-labelledby="pesquisa-otica-modelo" className="pesquisa-fields-grid">
713
  <label className="pesquisa-field">
714
- Nome do modelo
715
- <TextFieldInput
716
- list="pesquisa-nomes-modelo"
717
- field="nome"
718
- value={filters.nome}
719
- onChange={onFieldChange}
720
- placeholder="Ex: MOD_A_SALA_Z1"
721
- />
722
  </label>
723
  <label className="pesquisa-field">
724
- Autor
725
- <TextFieldInput
726
- list="pesquisa-autores"
727
- field="autor"
728
- value={filters.autor}
729
- onChange={onFieldChange}
730
- placeholder="Nome do avaliador"
731
- />
732
  </label>
733
-
734
- <label className="pesquisa-field">
735
- Finalidade
736
- <TextFieldInput
737
- list="pesquisa-finalidades"
738
- field="finalidade"
739
- value={filters.finalidade}
740
- onChange={onFieldChange}
741
- placeholder="Apartamento, sala, deposito..."
742
- />
743
- </label>
744
-
745
- <label className="pesquisa-field">
746
- Bairro
747
- <TextFieldInput
748
- list="pesquisa-bairros"
749
- field="bairros"
750
- value={filters.bairros}
751
- onChange={onFieldChange}
752
- placeholder="Centro, Moinhos de Vento"
753
- />
754
- </label>
755
-
756
- <div className="pesquisa-inline-trio">
757
- <div className="pesquisa-field-pair pesquisa-field-pair-inline">
758
- <span className="pesquisa-field-pair-title">Data</span>
759
- <label className="pesquisa-field">
760
- Minima
761
- <TextFieldInput field="dataMin" value={filters.dataMin} onChange={onFieldChange} placeholder="2022-01-01" />
762
- </label>
763
- <label className="pesquisa-field">
764
- Maxima
765
- <TextFieldInput field="dataMax" value={filters.dataMax} onChange={onFieldChange} placeholder="2025-12-31" />
766
- </label>
767
- </div>
768
-
769
- <div className="pesquisa-field-pair pesquisa-field-pair-inline">
770
- <span className="pesquisa-field-pair-title">Area</span>
771
- <label className="pesquisa-field">
772
- Minima
773
- <NumberFieldInput field="areaMin" value={filters.areaMin} onChange={onFieldChange} placeholder="0" />
774
- </label>
775
- <label className="pesquisa-field">
776
- Maxima
777
- <NumberFieldInput field="areaMax" value={filters.areaMax} onChange={onFieldChange} placeholder="0" />
778
- </label>
779
- </div>
780
-
781
- <div className="pesquisa-field-pair pesquisa-field-pair-inline">
782
- <span className="pesquisa-field-pair-title">RH</span>
783
- <label className="pesquisa-field">
784
- Minimo
785
- <NumberFieldInput field="rhMin" value={filters.rhMin} onChange={onFieldChange} placeholder="0" />
786
- </label>
787
- <label className="pesquisa-field">
788
- Maximo
789
- <NumberFieldInput field="rhMax" value={filters.rhMax} onChange={onFieldChange} placeholder="1" />
790
- </label>
791
- </div>
792
- </div>
793
  </div>
794
- )}
795
 
796
- <datalist id="pesquisa-nomes-modelo">
797
- {(sugestoes.nomes_modelo || []).map((item) => (
798
- <option key={`nome-${item}`} value={item} />
799
- ))}
800
- </datalist>
801
- <datalist id="pesquisa-autores">
802
- {(sugestoes.autores || []).map((item) => (
803
- <option key={`autor-${item}`} value={item} />
804
- ))}
805
- </datalist>
806
  <datalist id="pesquisa-finalidades">
807
  {(sugestoes.finalidades || []).map((item) => (
808
  <option key={`finalidade-${item}`} value={item} />
@@ -829,16 +468,12 @@ export default function PesquisaTab() {
829
  <SectionBlock
830
  step="2"
831
  title="Resultados"
832
- subtitle={
833
- usandoOticaAvaliando
834
- ? 'Modelos aceitos para os parametros do avaliando informado.'
835
- : 'Lista de modelos encontrados para os filtros atuais.'
836
- }
837
  >
838
  <div className="pesquisa-results-toolbar">
839
  <div className="pesquisa-summary-line">
840
  <strong>{formatCount(result.total_filtrado)}</strong>{' '}
841
- {usandoOticaAvaliando ? 'modelo(s) aceito(s)' : 'modelo(s) exibido(s)'} de <strong>{formatCount(result.total_geral)}</strong>.
842
  </div>
843
  {resultIds.length ? (
844
  <label className="pesquisa-select-all">
@@ -852,9 +487,7 @@ export default function PesquisaTab() {
852
  <div className="empty-box">
853
  {!pesquisaInicializada
854
  ? 'Defina os filtros desejados e clique em Pesquisar.'
855
- : usandoOticaAvaliando
856
- ? 'Nenhum modelo aceitou os parametros do avaliando informado.'
857
- : 'Nenhum modelo encontrado com os filtros atuais.'}
858
  </div>
859
  ) : (
860
  <div className="pesquisa-card-grid">
@@ -866,7 +499,6 @@ export default function PesquisaTab() {
866
  <div className="pesquisa-card-head">
867
  <div className="pesquisa-card-head-main">
868
  <h4>{modelo.nome_modelo || modelo.arquivo}</h4>
869
- <p>{modelo.arquivo}</p>
870
  <div className="pesquisa-card-head-actions">
871
  <label className="pesquisa-select-toggle">
872
  <input
@@ -883,11 +515,9 @@ export default function PesquisaTab() {
883
  </div>
884
  </div>
885
 
886
- {usandoOticaAvaliando ? (
887
- <div className="pesquisa-card-status-row">
888
- <div className="status-pill done">Aceito para o avaliando ({modelo.avaliando?.campos_informados || 0} campo(s) validado(s))</div>
889
- </div>
890
- ) : null}
891
  <div className="pesquisa-card-body">
892
  <div className="pesquisa-card-dados-list">
893
  <div><strong>Finalidades no modelo:</strong> {(modelo.finalidades || []).length ? modelo.finalidades.join(', ') : '-'}</div>
 
5
  import SectionBlock from './SectionBlock'
6
 
7
  const EMPTY_FILTERS = {
 
 
 
8
  contemApp: '',
9
+ tipoModelo: '',
 
10
  dataMin: '',
11
  dataMax: '',
 
 
 
 
12
  avalFinalidade: '',
13
  avalBairro: '',
 
14
  avalArea: '',
15
  avalRh: '',
16
  }
 
22
  total_geral: 0,
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  const TIPO_SIGLAS = {
26
  RECOND: 'Residencia em condominio',
27
  RCOMD: 'Residencia em condominio',
28
  TCOND: 'Terreno em condominio',
29
  SALA: 'Salas comerciais',
30
+ APTO: 'Apartamentos',
31
+ APART: 'Apartamentos',
32
+ AP: 'Apartamentos',
33
  TERRENO: 'Terrenos',
34
  TER: 'Terrenos',
35
  EDIF: 'Edificio',
 
47
  const min = range.min ?? null
48
  const max = range.max ?? null
49
  if (min === null && max === null) return '-'
50
+ if (min !== null && max !== null) return `${formatDateBrIfIso(min)} a ${formatDateBrIfIso(max)}`
51
+ if (min !== null) return `a partir de ${formatDateBrIfIso(min)}`
52
+ return `ate ${formatDateBrIfIso(max)}`
53
  }
54
 
55
  function formatCount(value) {
 
60
  return String(value)
61
  }
62
 
63
+ function formatDateBrIfIso(value) {
64
+ const text = String(value ?? '').trim()
65
+ if (!text) return '-'
66
+ const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
67
+ if (!isoMatch) return text
68
+ return `${isoMatch[3]}/${isoMatch[2]}/${isoMatch[1]}`
69
+ }
70
+
71
  function normalizeTokenText(value) {
72
  return String(value || '')
73
  .normalize('NFD')
 
101
  return mapped || text
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  function buildApiFilters(filters) {
 
 
 
 
 
 
 
 
 
 
 
 
105
  return {
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,
112
  data_max: filters.dataMax,
113
+ aval_area: filters.avalArea,
114
+ aval_rh: filters.avalRh,
 
 
115
  }
116
  }
117
 
 
151
  )
152
  }
153
 
154
+ function DateFieldInput({ field, ...props }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  return (
156
+ <input
157
+ {...props}
158
+ type="date"
159
+ data-field={field}
160
+ name={toInputName(field)}
161
+ autoComplete="off"
162
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  )
164
  }
165
 
 
175
  const [selectedIds, setSelectedIds] = useState([])
176
  const [detailModelId, setDetailModelId] = useState('')
177
  const selectAllRef = useRef(null)
 
 
 
178
 
179
  const [mapaLoading, setMapaLoading] = useState(false)
180
  const [mapaError, setMapaError] = useState('')
 
182
  const [mapaHtml, setMapaHtml] = useState('')
183
  const [mapaLegendas, setMapaLegendas] = useState([])
184
 
 
185
  const sugestoes = result.sugestoes || {}
186
+ const opcoesTipoModelo = useMemo(
187
+ () => [...new Set((sugestoes.tipos_modelo || []).map((item) => String(item || '').trim()).filter(Boolean))]
188
+ .sort((a, b) => a.localeCompare(b, 'pt-BR', { sensitivity: 'base' })),
189
+ [sugestoes.tipos_modelo],
190
+ )
191
  const resultIds = useMemo(() => (result.modelos || []).map((modelo) => modelo.id), [result.modelos])
192
  const detalheModelo = useMemo(() => (result.modelos || []).find((modelo) => modelo.id === detailModelId) || null, [result.modelos, detailModelId])
193
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
194
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
195
 
196
+ async function buscarModelos(nextFilters = filters) {
197
  setLoading(true)
198
  setError('')
199
  try {
200
+ const response = await api.pesquisarModelos(buildApiFilters(nextFilters))
201
  const modelos = response.modelos || []
202
  const idsNovos = new Set(modelos.map((item) => item.id))
 
203
 
204
  setResult({
205
  ...RESULT_INITIAL,
 
207
  modelos,
208
  sugestoes: response.sugestoes || {},
209
  })
 
 
210
 
211
  setSelectedIds((current) => current.filter((id) => idsNovos.has(id)))
212
 
 
227
  setError('')
228
  try {
229
  const response = await api.pesquisarModelos({ somente_contexto: true })
 
230
 
231
  setResult({
232
  ...RESULT_INITIAL,
 
234
  modelos: [],
235
  sugestoes: response.sugestoes || {},
236
  })
 
 
237
  setSelectedIds([])
238
  setMapaHtml('')
239
  setMapaStatus('')
 
281
  setFilters((prev) => ({ ...prev, [field]: value }))
282
  }
283
 
 
 
 
 
284
  async function onLimparFiltros() {
285
  setFilters(EMPTY_FILTERS)
286
+ await carregarContextoInicial()
 
 
287
  }
288
 
289
  function onToggleSelecionado(modelId) {
 
315
  setDetailModelId('')
316
  }
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  async function onGerarMapaSelecionados() {
319
  if (!selectedIds.length) {
320
  setMapaError('Selecione ao menos um modelo para plotar no mapa.')
 
348
  <SectionBlock
349
  step="1"
350
  title="Filtros de Pesquisa"
351
+ subtitle="Informe os dados do avaliando. Todos os filtros sao cumulativos."
352
  aside={(
353
  <button
354
  type="button"
 
364
  <PesquisaAdminConfigPanel onSaved={() => void onAdminConfigSalva()} />
365
  ) : null}
366
 
367
+ <div className="pesquisa-fields-grid">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  <label className="pesquisa-field">
369
  Contem variavel APP (% APP)
370
  <select
 
379
  <option value="nao">Nao</option>
380
  </select>
381
  </label>
382
+ <label className="pesquisa-field">
383
+ Tipo do modelo
384
+ <select
385
+ data-field="tipoModelo"
386
+ name={toInputName('tipoModelo')}
387
+ value={filters.tipoModelo}
388
+ onChange={onFieldChange}
389
+ autoComplete="off"
390
+ >
391
+ <option value="">Todos</option>
392
+ {opcoesTipoModelo.map((tipo) => (
393
+ <option key={`tipo-modelo-${tipo}`} value={tipo}>{tipo}</option>
394
+ ))}
395
+ </select>
396
+ </label>
397
  </div>
398
 
399
+ <div className="pesquisa-fields-grid pesquisa-avaliando-grid">
400
+ <label className="pesquisa-field">
401
+ Finalidade do imovel
402
+ <TextFieldInput
403
+ list="pesquisa-finalidades"
404
+ field="avalFinalidade"
405
+ value={filters.avalFinalidade}
406
+ onChange={onFieldChange}
407
+ placeholder="Ex: Apartamento"
408
+ />
409
+ </label>
 
 
 
 
 
 
410
 
411
+ <label className="pesquisa-field">
412
+ Bairro do imovel
413
+ <TextFieldInput
414
+ list="pesquisa-bairros"
415
+ field="avalBairro"
416
+ value={filters.avalBairro}
417
+ onChange={onFieldChange}
418
+ placeholder="Ex: Centro"
419
+ />
420
+ </label>
421
 
422
+ <div className="pesquisa-avaliando-inline pesquisa-avaliando-inline-periodo">
423
+ <div className="pesquisa-field-pair pesquisa-field-pair-inline">
424
+ <span className="pesquisa-field-pair-title">Periodo de data do imovel</span>
 
 
425
  <label className="pesquisa-field">
426
+ Data inicial
427
+ <DateFieldInput field="dataMin" value={filters.dataMin} onChange={onFieldChange} />
428
  </label>
429
  <label className="pesquisa-field">
430
+ Data final
431
+ <DateFieldInput field="dataMax" value={filters.dataMax} onChange={onFieldChange} />
432
  </label>
433
  </div>
 
 
 
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} />
 
468
  <SectionBlock
469
  step="2"
470
  title="Resultados"
471
+ subtitle="Modelos aceitos para os parametros do avaliando informado."
 
 
 
 
472
  >
473
  <div className="pesquisa-results-toolbar">
474
  <div className="pesquisa-summary-line">
475
  <strong>{formatCount(result.total_filtrado)}</strong>{' '}
476
+ modelo(s) aceito(s) de <strong>{formatCount(result.total_geral)}</strong>.
477
  </div>
478
  {resultIds.length ? (
479
  <label className="pesquisa-select-all">
 
487
  <div className="empty-box">
488
  {!pesquisaInicializada
489
  ? 'Defina os filtros desejados e clique em Pesquisar.'
490
+ : 'Nenhum modelo aceitou os parametros do avaliando informado.'}
 
 
491
  </div>
492
  ) : (
493
  <div className="pesquisa-card-grid">
 
499
  <div className="pesquisa-card-head">
500
  <div className="pesquisa-card-head-main">
501
  <h4>{modelo.nome_modelo || modelo.arquivo}</h4>
 
502
  <div className="pesquisa-card-head-actions">
503
  <label className="pesquisa-select-toggle">
504
  <input
 
515
  </div>
516
  </div>
517
 
518
+ <div className="pesquisa-card-status-row">
519
+ <div className="status-pill done">Aceito para o avaliando ({modelo.avaliando?.campos_informados || 0} campo(s) validado(s))</div>
520
+ </div>
 
 
521
  <div className="pesquisa-card-body">
522
  <div className="pesquisa-card-dados-list">
523
  <div><strong>Finalidades no modelo:</strong> {(modelo.finalidades || []).length ? modelo.finalidades.join(', ') : '-'}</div>
frontend/src/styles.css CHANGED
@@ -136,7 +136,7 @@ textarea {
136
 
137
  .tabs {
138
  display: grid;
139
- grid-template-columns: repeat(3, minmax(180px, 1fr));
140
  gap: 10px;
141
  }
142
 
@@ -225,6 +225,36 @@ textarea {
225
  display: none !important;
226
  }
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  .status-strip {
229
  display: flex;
230
  gap: 8px;
@@ -614,6 +644,16 @@ button.pesquisa-otica-btn.active:hover {
614
  gap: 12px 14px;
615
  }
616
 
 
 
 
 
 
 
 
 
 
 
617
  .pesquisa-fields-grid .pesquisa-field {
618
  min-width: 0;
619
  }
@@ -814,7 +854,8 @@ button.pesquisa-coluna-remove:hover {
814
  border-radius: 14px;
815
  background: linear-gradient(180deg, #ffffff 0%, #fcfdff 100%);
816
  padding: 12px;
817
- display: grid;
 
818
  gap: 10px;
819
  min-width: 0;
820
  height: 100%;
@@ -841,9 +882,11 @@ button.pesquisa-coluna-remove:hover {
841
  }
842
 
843
  .pesquisa-card-top {
844
- display: grid;
 
845
  gap: 8px;
846
  min-width: 0;
 
847
  }
848
 
849
  .pesquisa-card-head {
@@ -1449,7 +1492,7 @@ button.btn-upload-select {
1449
 
1450
  .variavel-badge-line {
1451
  display: grid;
1452
- grid-template-columns: 118px minmax(0, 1fr);
1453
  gap: 10px;
1454
  align-items: flex-start;
1455
  margin-top: 8px;
@@ -1465,6 +1508,13 @@ button.btn-upload-select {
1465
  letter-spacing: 0.02em;
1466
  }
1467
 
 
 
 
 
 
 
 
1468
  .variavel-chip-wrap {
1469
  display: flex;
1470
  flex-wrap: wrap;
@@ -2217,6 +2267,29 @@ button.btn-upload-select {
2217
  font-weight: 600;
2218
  }
2219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2220
  .geo-correcoes {
2221
  display: grid;
2222
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -2621,7 +2694,7 @@ button.btn-upload-select {
2621
 
2622
  @media (max-width: 1150px) {
2623
  .tabs {
2624
- grid-template-columns: 1fr;
2625
  }
2626
 
2627
  .app-header {
@@ -2659,6 +2732,10 @@ button.btn-upload-select {
2659
  }
2660
 
2661
  @media (max-width: 760px) {
 
 
 
 
2662
  .app-shell {
2663
  width: 97vw;
2664
  margin-top: 10px;
 
136
 
137
  .tabs {
138
  display: grid;
139
+ grid-template-columns: repeat(4, minmax(0, 1fr));
140
  gap: 10px;
141
  }
142
 
 
225
  display: none !important;
226
  }
227
 
228
+ .inicio-card {
229
+ border: 1px solid #d6e3ef;
230
+ border-radius: 12px;
231
+ background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
232
+ padding: 16px 18px;
233
+ }
234
+
235
+ .inicio-card h3 {
236
+ margin: 0 0 10px;
237
+ font-family: 'Sora', sans-serif;
238
+ color: #2a3f54;
239
+ font-size: 1rem;
240
+ }
241
+
242
+ .inicio-lista {
243
+ margin: 0 0 10px;
244
+ padding-left: 18px;
245
+ color: #40596f;
246
+ display: grid;
247
+ gap: 6px;
248
+ font-size: 0.9rem;
249
+ }
250
+
251
+ .inicio-creditos {
252
+ margin: 0;
253
+ color: #334d65;
254
+ font-size: 0.9rem;
255
+ font-weight: 600;
256
+ }
257
+
258
  .status-strip {
259
  display: flex;
260
  gap: 8px;
 
644
  gap: 12px 14px;
645
  }
646
 
647
+ .pesquisa-avaliando-inline-periodo {
648
+ grid-template-columns: minmax(280px, 1.45fr) repeat(2, minmax(0, 1fr));
649
+ align-items: end;
650
+ }
651
+
652
+ .pesquisa-avaliando-inline-periodo .pesquisa-field-pair {
653
+ grid-column: auto;
654
+ margin: 0;
655
+ }
656
+
657
  .pesquisa-fields-grid .pesquisa-field {
658
  min-width: 0;
659
  }
 
854
  border-radius: 14px;
855
  background: linear-gradient(180deg, #ffffff 0%, #fcfdff 100%);
856
  padding: 12px;
857
+ display: flex;
858
+ flex-direction: column;
859
  gap: 10px;
860
  min-width: 0;
861
  height: 100%;
 
882
  }
883
 
884
  .pesquisa-card-top {
885
+ display: flex;
886
+ flex-direction: column;
887
  gap: 8px;
888
  min-width: 0;
889
+ flex: 1 1 auto;
890
  }
891
 
892
  .pesquisa-card-head {
 
1492
 
1493
  .variavel-badge-line {
1494
  display: grid;
1495
+ grid-template-columns: 146px minmax(0, 1fr);
1496
  gap: 10px;
1497
  align-items: flex-start;
1498
  margin-top: 8px;
 
1508
  letter-spacing: 0.02em;
1509
  }
1510
 
1511
+ .variavel-badge-value {
1512
+ align-self: center;
1513
+ color: #30475e;
1514
+ font-weight: 700;
1515
+ font-size: 0.86rem;
1516
+ }
1517
+
1518
  .variavel-chip-wrap {
1519
  display: flex;
1520
  flex-wrap: wrap;
 
2267
  font-weight: 600;
2268
  }
2269
 
2270
+ .market-date-grid {
2271
+ display: grid;
2272
+ gap: 10px;
2273
+ }
2274
+
2275
+ .market-date-row {
2276
+ margin-bottom: 0;
2277
+ }
2278
+
2279
+ .market-date-actions-row {
2280
+ align-items: center;
2281
+ justify-content: space-between;
2282
+ gap: 10px;
2283
+ flex-wrap: wrap;
2284
+ }
2285
+
2286
+ .market-date-actions-row .resumo-outliers-box {
2287
+ margin-top: 0;
2288
+ min-height: 38px;
2289
+ display: inline-flex;
2290
+ align-items: center;
2291
+ }
2292
+
2293
  .geo-correcoes {
2294
  display: grid;
2295
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 
2694
 
2695
  @media (max-width: 1150px) {
2696
  .tabs {
2697
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2698
  }
2699
 
2700
  .app-header {
 
2732
  }
2733
 
2734
  @media (max-width: 760px) {
2735
+ .tabs {
2736
+ grid-template-columns: 1fr;
2737
+ }
2738
+
2739
  .app-shell {
2740
  width: 97vw;
2741
  margin-top: 10px;