Guilherme Silberfarb Costa commited on
Commit
25aa274
·
1 Parent(s): 1b337a7

Update technical work views and scatter transforms

Browse files
README.md CHANGED
@@ -87,7 +87,7 @@ Base de Trabalhos Técnicos:
87
  Geração do banco local de Trabalhos Técnicos:
88
 
89
  - Script: `backend/scripts/build_trabalhos_tecnicos_db.py`
90
- - Planilha padrão de origem: `~/Downloads/dados_geocodificados_limpos_v2.xlsx`
91
  - Exemplo: `backend/.venv/bin/python backend/scripts/build_trabalhos_tecnicos_db.py`
92
  - Saída padrão: `backend/local_data/trabalhos_tecnicos.sqlite3`
93
  - O arquivo SQLite local fica ignorado no git e deve ser enviado manualmente ao dataset `gui-sparim/repositorio_mesa` no caminho `trabalhos_tecnicos/trabalhos_tecnicos.sqlite3`.
 
87
  Geração do banco local de Trabalhos Técnicos:
88
 
89
  - Script: `backend/scripts/build_trabalhos_tecnicos_db.py`
90
+ - Planilha padrão de origem: `~/Downloads/AVALIANDOS_20260515_corrigido_v2.xlsx`
91
  - Exemplo: `backend/.venv/bin/python backend/scripts/build_trabalhos_tecnicos_db.py`
92
  - Saída padrão: `backend/local_data/trabalhos_tecnicos.sqlite3`
93
  - O arquivo SQLite local fica ignorado no git e deve ser enviado manualmente ao dataset `gui-sparim/repositorio_mesa` no caminho `trabalhos_tecnicos/trabalhos_tecnicos.sqlite3`.
backend/app/api/elaboracao.py CHANGED
@@ -98,6 +98,9 @@ class DispersaoPayload(SessionPayload):
98
 
99
  class DispersaoInterativoPayload(SessionPayload):
100
  alvo: str = "secao10"
 
 
 
101
 
102
 
103
  class DiagnosticoInterativoPayload(SessionPayload):
@@ -334,7 +337,13 @@ def model_dispersao(payload: DispersaoPayload) -> dict[str, Any]:
334
  @router.post("/dispersao-interativo")
335
  def dispersao_interativo(payload: DispersaoInterativoPayload) -> dict[str, Any]:
336
  session = session_store.get(payload.session_id)
337
- return elaboracao_service.obter_grafico_dispersao_interativo(session, payload.alvo)
 
 
 
 
 
 
338
 
339
 
340
  @router.post("/diagnostico-interativo")
 
98
 
99
  class DispersaoInterativoPayload(SessionPayload):
100
  alvo: str = "secao10"
101
+ coluna_x: str | None = None
102
+ transformacao_x: str = "(x)"
103
+ transformacao_y: str = "(x)"
104
 
105
 
106
  class DiagnosticoInterativoPayload(SessionPayload):
 
337
  @router.post("/dispersao-interativo")
338
  def dispersao_interativo(payload: DispersaoInterativoPayload) -> dict[str, Any]:
339
  session = session_store.get(payload.session_id)
340
+ return elaboracao_service.obter_grafico_dispersao_interativo(
341
+ session,
342
+ payload.alvo,
343
+ coluna_x=payload.coluna_x,
344
+ transformacao_x=payload.transformacao_x,
345
+ transformacao_y=payload.transformacao_y,
346
+ )
347
 
348
 
349
  @router.post("/diagnostico-interativo")
backend/app/services/elaboracao_service.py CHANGED
@@ -22,6 +22,7 @@ from app.core.elaboracao.core import (
22
  PERCENTUAL_RUIDO_TOL,
23
  TRANSFORMACOES,
24
  ajustar_modelo,
 
25
  normalizar_coluna_area,
26
  normalizar_tipo_y,
27
  normalizar_config_avaliacao_modelo,
@@ -1863,11 +1864,77 @@ def gerar_grafico_dispersao_modelo(
1863
  }
1864
 
1865
 
1866
- def obter_grafico_dispersao_interativo(session: SessionState, alvo: str) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1867
  key = str(alvo or "").strip().lower()
1868
  if key not in {"secao10", "secao13"}:
1869
  raise HTTPException(status_code=400, detail="Alvo de grafico invalido")
1870
 
 
 
 
 
 
 
 
 
1871
  grafico = session.graficos_dispersao_cache.get(key)
1872
  if not grafico:
1873
  raise HTTPException(status_code=404, detail="Grafico interativo indisponivel para este alvo")
 
22
  PERCENTUAL_RUIDO_TOL,
23
  TRANSFORMACOES,
24
  ajustar_modelo,
25
+ aplicar_transformacao,
26
  normalizar_coluna_area,
27
  normalizar_tipo_y,
28
  normalizar_config_avaliacao_modelo,
 
1864
  }
1865
 
1866
 
1867
+ def _normalizar_transformacao_dispersao(value: str | None) -> str:
1868
+ text = str(value or "").strip()
1869
+ return text if text in TRANSFORMACOES else "(x)"
1870
+
1871
+
1872
+ def _gerar_grafico_secao10_variavel(
1873
+ session: SessionState,
1874
+ coluna_x: str,
1875
+ transformacao_x: str = "(x)",
1876
+ transformacao_y: str = "(x)",
1877
+ ) -> dict[str, Any]:
1878
+ df_base = session.df_filtrado if session.df_filtrado is not None else session.df_original
1879
+ if df_base is None or getattr(df_base, "empty", True):
1880
+ raise HTTPException(status_code=400, detail="Base filtrada indisponivel")
1881
+
1882
+ coluna_x_norm = str(coluna_x or "").strip()
1883
+ if not coluna_x_norm:
1884
+ raise HTTPException(status_code=400, detail="Informe a variavel X")
1885
+ if coluna_x_norm not in (session.colunas_x or []):
1886
+ raise HTTPException(status_code=400, detail="Variavel X fora da selecao atual")
1887
+ if coluna_x_norm not in df_base.columns:
1888
+ raise HTTPException(status_code=404, detail="Variavel X indisponivel na base")
1889
+ if not session.coluna_y or session.coluna_y not in df_base.columns:
1890
+ raise HTTPException(status_code=404, detail="Variavel Y indisponivel na base")
1891
+
1892
+ transf_x = _normalizar_transformacao_dispersao(transformacao_x)
1893
+ transf_y = _normalizar_transformacao_dispersao(transformacao_y)
1894
+
1895
+ x_vals = pd.to_numeric(df_base[coluna_x_norm], errors="coerce").to_numpy(dtype=float)
1896
+ y_vals = pd.to_numeric(df_base[session.coluna_y], errors="coerce").to_numpy(dtype=float)
1897
+ x_transf = aplicar_transformacao(x_vals, transf_x)
1898
+ y_transf = aplicar_transformacao(y_vals, transf_y)
1899
+
1900
+ x_df = pd.DataFrame({coluna_x_norm: x_transf}, index=df_base.index)
1901
+ y_series = pd.Series(y_transf, index=df_base.index, name=session.coluna_y)
1902
+
1903
+ try:
1904
+ fig = charts.criar_graficos_dispersao(x_df, y_series)
1905
+ except Exception:
1906
+ fig = None
1907
+
1908
+ grafico = figure_to_payload(fig)
1909
+ return {
1910
+ "alvo": "secao10",
1911
+ "coluna_x": coluna_x_norm,
1912
+ "transformacao_x": transf_x,
1913
+ "transformacao_y": transf_y,
1914
+ "grafico": sanitize_value(grafico),
1915
+ "grafico_com_indices": _payload_grafico_com_indices(grafico),
1916
+ }
1917
+
1918
+
1919
+ def obter_grafico_dispersao_interativo(
1920
+ session: SessionState,
1921
+ alvo: str,
1922
+ coluna_x: str | None = None,
1923
+ transformacao_x: str = "(x)",
1924
+ transformacao_y: str = "(x)",
1925
+ ) -> dict[str, Any]:
1926
  key = str(alvo or "").strip().lower()
1927
  if key not in {"secao10", "secao13"}:
1928
  raise HTTPException(status_code=400, detail="Alvo de grafico invalido")
1929
 
1930
+ if key == "secao10" and str(coluna_x or "").strip():
1931
+ return _gerar_grafico_secao10_variavel(
1932
+ session,
1933
+ coluna_x=str(coluna_x or "").strip(),
1934
+ transformacao_x=transformacao_x,
1935
+ transformacao_y=transformacao_y,
1936
+ )
1937
+
1938
  grafico = session.graficos_dispersao_cache.get(key)
1939
  if not grafico:
1940
  raise HTTPException(status_code=404, detail="Grafico interativo indisponivel para este alvo")
backend/app/services/trabalhos_tecnicos_importer.py CHANGED
@@ -11,13 +11,18 @@ from pathlib import Path
11
  from typing import Any
12
 
13
 
14
- DEFAULT_SOURCE_XLSX_FILE = "dados_geocodificados_limpos_v2.xlsx"
15
  DEFAULT_LOCAL_DB_FILE = "trabalhos_tecnicos.sqlite3"
16
- COLUNAS_BASE_OBRIGATORIAS = ["ANO", "MODELO", "ENDERECO", "NUM", "x", "y"]
17
- COLUNAS_IDENTIFICADORAS = ["NOME_PASTA", "AVALIANDO", "TRABALHO"]
18
  COLUNAS_TECNICO = ["TÉCNICO", "TECNICO"]
19
- COLUNA_PROCESSO = "PROCESSO"
20
- COLUNA_FINALIDADE_PROCESSO = "FINALIDADE PROCESSO"
 
 
 
 
 
21
  TIPO_LABELS = {
22
  "LA": "Laudo de Avaliacao",
23
  "PT": "Parecer Tecnico",
@@ -54,6 +59,7 @@ class TrabalhoRawGroup:
54
  ano: int | None = None
55
  codigo_trabalho: str = ""
56
  nome_pasta: str = ""
 
57
  endereco_base: str = ""
58
  numero_base: str = ""
59
  first_row_number: int = 0
@@ -86,6 +92,35 @@ def _first_non_empty(record: dict[str, Any], *keys: str) -> str:
86
  return ""
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def _to_int(value: Any) -> int | None:
90
  text = _clean_text(value)
91
  if not text:
@@ -249,16 +284,24 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
249
  invalid_rows = 0
250
 
251
  for offset, record in enumerate(records, start=2):
252
- nome_pasta = _clean_text(record.get("NOME_PASTA"))
 
253
  codigo_trabalho = _clean_text(record.get("TRABALHO"))
254
- raw_name = _first_non_empty(record, "NOME_PASTA", "AVALIANDO", "TRABALHO")
255
  if not raw_name:
256
  invalid_rows += 1
257
  continue
258
 
259
  tecnico = _first_non_empty(record, *COLUNAS_TECNICO)
260
- processo = _clean_text(record.get(COLUNA_PROCESSO))
261
- finalidade_processo = _clean_text(record.get(COLUNA_FINALIDADE_PROCESSO))
 
 
 
 
 
 
 
262
 
263
  group = groups.get(raw_name)
264
  if group is None:
@@ -267,8 +310,9 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
267
  ano=_to_int(record.get("ANO")),
268
  codigo_trabalho=codigo_trabalho,
269
  nome_pasta=nome_pasta,
270
- endereco_base=_clean_text(record.get("ENDERECO")),
271
- numero_base=_clean_text(record.get("NUM")),
 
272
  first_row_number=offset,
273
  )
274
  groups[raw_name] = group
@@ -279,14 +323,16 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
279
  group.codigo_trabalho = codigo_trabalho
280
  if not group.nome_pasta:
281
  group.nome_pasta = nome_pasta
 
 
282
  if not group.endereco_base:
283
- group.endereco_base = _clean_text(record.get("ENDERECO"))
284
  if not group.numero_base:
285
- group.numero_base = _clean_text(record.get("NUM"))
286
 
287
  modelo_nome = _clean_text(record.get("MODELO"))
288
- endereco = _clean_text(record.get("ENDERECO"))
289
- numero = _clean_text(record.get("NUM"))
290
  coord_x = _to_float(record.get("x"))
291
  coord_y = _to_float(record.get("y"))
292
 
@@ -326,8 +372,10 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
326
  "nome_original": raw_name,
327
  "codigo_trabalho": codigo_trabalho,
328
  "nome_pasta": nome_pasta,
 
329
  "tecnico": tecnico,
330
  "processo": processo,
 
331
  "finalidade_processo": finalidade_processo,
332
  "modelo_nome": modelo_nome,
333
  "endereco": endereco,
@@ -341,7 +389,7 @@ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup]
341
 
342
  used_names: dict[str, str] = {}
343
  for group in ordered_groups:
344
- base_name = build_clean_trabalho_name(group.nome_pasta or group.raw_name, group.endereco_base, group.numero_base)
345
  if not base_name:
346
  base_name = _slugify(group.nome_pasta or group.raw_name) or f"TRABALHO_{group.first_row_number}"
347
  candidate = base_name
@@ -393,6 +441,7 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
393
  nome_original TEXT NOT NULL,
394
  codigo_trabalho TEXT,
395
  nome_pasta TEXT,
 
396
  tipo_codigo TEXT NOT NULL,
397
  tipo_label TEXT NOT NULL,
398
  ano INTEGER,
@@ -440,8 +489,10 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
440
  nome_original TEXT NOT NULL,
441
  codigo_trabalho TEXT,
442
  nome_pasta TEXT,
 
443
  tecnico TEXT,
444
  processo TEXT,
 
445
  finalidade_processo TEXT,
446
  modelo_nome TEXT,
447
  endereco TEXT,
@@ -454,6 +505,7 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
454
  CREATE INDEX idx_trabalho_modelos_trabalho ON trabalho_modelos (trabalho_id);
455
  CREATE INDEX idx_trabalho_imoveis_trabalho ON trabalho_imoveis (trabalho_id);
456
  CREATE INDEX idx_trabalho_registros_trabalho ON trabalho_registros (trabalho_id);
 
457
  """
458
  )
459
 
@@ -466,7 +518,7 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
466
  "invalid_row_count": str(invalid_rows),
467
  "total_trabalhos": str(len(groups)),
468
  "generated_at_utc": imported_at,
469
- "generator_version": "sqlite-v2",
470
  }
471
  conn.executemany(
472
  "INSERT INTO meta (key, value) VALUES (?, ?)",
@@ -474,7 +526,8 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
474
  )
475
 
476
  for group in groups:
477
- tipo_codigo = _tipo_codigo_from_name(group.clean_name)
 
478
  modelos_ordenados = sorted(group.modelos.items(), key=lambda item: (item[1], item[0].lower()))
479
  imoveis_ordenados = list(group.imoveis.values())
480
  endereco_resumo = imoveis_ordenados[0]["label"] if imoveis_ordenados else "Endereco nao informado"
@@ -497,6 +550,7 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
497
  nome_original,
498
  codigo_trabalho,
499
  nome_pasta,
 
500
  tipo_codigo,
501
  tipo_label,
502
  ano,
@@ -511,14 +565,15 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
511
  total_imoveis,
512
  total_modelos,
513
  tem_coordenadas
514
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
515
  """,
516
  (
517
  group.clean_name,
518
- group.clean_name,
519
  group.raw_name,
520
  group.codigo_trabalho or None,
521
  group.nome_pasta or None,
 
522
  tipo_codigo,
523
  _tipo_label(tipo_codigo),
524
  group.ano,
@@ -574,15 +629,17 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
574
  nome_original,
575
  codigo_trabalho,
576
  nome_pasta,
 
577
  tecnico,
578
  processo,
 
579
  finalidade_processo,
580
  modelo_nome,
581
  endereco,
582
  numero,
583
  coord_x,
584
  coord_y
585
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
586
  """,
587
  (
588
  group.clean_name,
@@ -591,8 +648,10 @@ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict
591
  registro["nome_original"],
592
  registro["codigo_trabalho"] or None,
593
  registro["nome_pasta"] or None,
 
594
  registro["tecnico"] or None,
595
  registro["processo"] or None,
 
596
  registro["finalidade_processo"] or None,
597
  registro["modelo_nome"],
598
  registro["endereco"],
 
11
  from typing import Any
12
 
13
 
14
+ DEFAULT_SOURCE_XLSX_FILE = "AVALIANDOS_20260515_corrigido_v2.xlsx"
15
  DEFAULT_LOCAL_DB_FILE = "trabalhos_tecnicos.sqlite3"
16
+ COLUNAS_BASE_OBRIGATORIAS = ["ANO", "MODELO", "x", "y"]
17
+ COLUNAS_IDENTIFICADORAS = ["TRABALHO", "NOME_PASTA", "PASTA", "AVALIANDO"]
18
  COLUNAS_TECNICO = ["TÉCNICO", "TECNICO"]
19
+ COLUNAS_PROCESSO = ["PROCESSO", "PROCESO"]
20
+ COLUNAS_LINK_PROCESSO = ["LINK_PROCESSO", "LINK PROCESSO"]
21
+ COLUNAS_FINALIDADE_PROCESSO = ["FINALIDADE PROCESSO", "FINALIDADE_PROCESSO"]
22
+ COLUNAS_PASTA = ["PASTA", "NOME_PASTA"]
23
+ COLUNAS_LINK_PASTA = ["LINK_PASTA", "LINK PASTA", "LINK"]
24
+ COLUNAS_ENDERECO = ["ENDERECO", "ENDEREÇO"]
25
+ COLUNAS_NUMERO = ["NUM", "NUMERO", "NÚMERO", "NUMERO_IMOVEL"]
26
  TIPO_LABELS = {
27
  "LA": "Laudo de Avaliacao",
28
  "PT": "Parecer Tecnico",
 
59
  ano: int | None = None
60
  codigo_trabalho: str = ""
61
  nome_pasta: str = ""
62
+ link_pasta: str = ""
63
  endereco_base: str = ""
64
  numero_base: str = ""
65
  first_row_number: int = 0
 
92
  return ""
93
 
94
 
95
+ def _split_pasta_tokens(value: str) -> list[str]:
96
+ return [token for token in _clean_text(value).split("_") if token]
97
+
98
+
99
+ def _tokens_equal(left: list[str], right: list[str]) -> bool:
100
+ return _slugify("_".join(left)) == _slugify("_".join(right))
101
+
102
+
103
+ def _infer_endereco_numero_from_pasta(nome_pasta: str, codigo_trabalho: str) -> tuple[str, str]:
104
+ tokens = _split_pasta_tokens(nome_pasta)
105
+ if not tokens:
106
+ return "", ""
107
+
108
+ trabalho_tokens = _split_pasta_tokens(codigo_trabalho)
109
+ if trabalho_tokens and len(tokens) > len(trabalho_tokens) and _tokens_equal(tokens[: len(trabalho_tokens)], trabalho_tokens):
110
+ tokens = tokens[len(trabalho_tokens) :]
111
+ elif len(tokens) > 3 and _slugify(tokens[0]) in TIPO_LABELS and tokens[1].isdigit() and re.fullmatch(r"\d{4}", tokens[2] or ""):
112
+ tokens = tokens[3:]
113
+
114
+ if not tokens:
115
+ return "", ""
116
+
117
+ ultimo = _strip_accents(tokens[-1]).upper()
118
+ numero = tokens[-1] if re.fullmatch(r"\d+[A-Z]?", ultimo) else ""
119
+ endereco_tokens = tokens[:-1] if numero else tokens
120
+ endereco = " ".join(token.strip() for token in endereco_tokens if str(token or "").strip()).strip()
121
+ return endereco, numero
122
+
123
+
124
  def _to_int(value: Any) -> int | None:
125
  text = _clean_text(value)
126
  if not text:
 
284
  invalid_rows = 0
285
 
286
  for offset, record in enumerate(records, start=2):
287
+ nome_pasta = _first_non_empty(record, *COLUNAS_PASTA)
288
+ link_pasta = _first_non_empty(record, *COLUNAS_LINK_PASTA)
289
  codigo_trabalho = _clean_text(record.get("TRABALHO"))
290
+ raw_name = _first_non_empty(record, *COLUNAS_IDENTIFICADORAS)
291
  if not raw_name:
292
  invalid_rows += 1
293
  continue
294
 
295
  tecnico = _first_non_empty(record, *COLUNAS_TECNICO)
296
+ processo = _first_non_empty(record, *COLUNAS_PROCESSO)
297
+ link_processo = _first_non_empty(record, *COLUNAS_LINK_PROCESSO)
298
+ finalidade_processo = _first_non_empty(record, *COLUNAS_FINALIDADE_PROCESSO)
299
+ endereco_planilha = _first_non_empty(record, *COLUNAS_ENDERECO)
300
+ numero_planilha = _first_non_empty(record, *COLUNAS_NUMERO)
301
+ endereco_inferido, numero_inferido = _infer_endereco_numero_from_pasta(
302
+ nome_pasta,
303
+ codigo_trabalho or raw_name,
304
+ )
305
 
306
  group = groups.get(raw_name)
307
  if group is None:
 
310
  ano=_to_int(record.get("ANO")),
311
  codigo_trabalho=codigo_trabalho,
312
  nome_pasta=nome_pasta,
313
+ link_pasta=link_pasta,
314
+ endereco_base=endereco_planilha or endereco_inferido,
315
+ numero_base=numero_planilha or numero_inferido,
316
  first_row_number=offset,
317
  )
318
  groups[raw_name] = group
 
323
  group.codigo_trabalho = codigo_trabalho
324
  if not group.nome_pasta:
325
  group.nome_pasta = nome_pasta
326
+ if not group.link_pasta:
327
+ group.link_pasta = link_pasta
328
  if not group.endereco_base:
329
+ group.endereco_base = endereco_planilha or endereco_inferido
330
  if not group.numero_base:
331
+ group.numero_base = numero_planilha or numero_inferido
332
 
333
  modelo_nome = _clean_text(record.get("MODELO"))
334
+ endereco = endereco_planilha or endereco_inferido
335
+ numero = numero_planilha or numero_inferido
336
  coord_x = _to_float(record.get("x"))
337
  coord_y = _to_float(record.get("y"))
338
 
 
372
  "nome_original": raw_name,
373
  "codigo_trabalho": codigo_trabalho,
374
  "nome_pasta": nome_pasta,
375
+ "link_pasta": link_pasta,
376
  "tecnico": tecnico,
377
  "processo": processo,
378
+ "link_processo": link_processo,
379
  "finalidade_processo": finalidade_processo,
380
  "modelo_nome": modelo_nome,
381
  "endereco": endereco,
 
389
 
390
  used_names: dict[str, str] = {}
391
  for group in ordered_groups:
392
+ base_name = _slugify(group.codigo_trabalho or group.raw_name)
393
  if not base_name:
394
  base_name = _slugify(group.nome_pasta or group.raw_name) or f"TRABALHO_{group.first_row_number}"
395
  candidate = base_name
 
441
  nome_original TEXT NOT NULL,
442
  codigo_trabalho TEXT,
443
  nome_pasta TEXT,
444
+ link_pasta TEXT,
445
  tipo_codigo TEXT NOT NULL,
446
  tipo_label TEXT NOT NULL,
447
  ano INTEGER,
 
489
  nome_original TEXT NOT NULL,
490
  codigo_trabalho TEXT,
491
  nome_pasta TEXT,
492
+ link_pasta TEXT,
493
  tecnico TEXT,
494
  processo TEXT,
495
+ link_processo TEXT,
496
  finalidade_processo TEXT,
497
  modelo_nome TEXT,
498
  endereco TEXT,
 
505
  CREATE INDEX idx_trabalho_modelos_trabalho ON trabalho_modelos (trabalho_id);
506
  CREATE INDEX idx_trabalho_imoveis_trabalho ON trabalho_imoveis (trabalho_id);
507
  CREATE INDEX idx_trabalho_registros_trabalho ON trabalho_registros (trabalho_id);
508
+ CREATE INDEX idx_trabalho_registros_processo ON trabalho_registros (trabalho_id, processo);
509
  """
510
  )
511
 
 
518
  "invalid_row_count": str(invalid_rows),
519
  "total_trabalhos": str(len(groups)),
520
  "generated_at_utc": imported_at,
521
+ "generator_version": "sqlite-v3",
522
  }
523
  conn.executemany(
524
  "INSERT INTO meta (key, value) VALUES (?, ?)",
 
526
  )
527
 
528
  for group in groups:
529
+ nome_exibicao = group.codigo_trabalho or group.raw_name or group.clean_name
530
+ tipo_codigo = _tipo_codigo_from_name(nome_exibicao)
531
  modelos_ordenados = sorted(group.modelos.items(), key=lambda item: (item[1], item[0].lower()))
532
  imoveis_ordenados = list(group.imoveis.values())
533
  endereco_resumo = imoveis_ordenados[0]["label"] if imoveis_ordenados else "Endereco nao informado"
 
550
  nome_original,
551
  codigo_trabalho,
552
  nome_pasta,
553
+ link_pasta,
554
  tipo_codigo,
555
  tipo_label,
556
  ano,
 
565
  total_imoveis,
566
  total_modelos,
567
  tem_coordenadas
568
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
569
  """,
570
  (
571
  group.clean_name,
572
+ nome_exibicao,
573
  group.raw_name,
574
  group.codigo_trabalho or None,
575
  group.nome_pasta or None,
576
+ group.link_pasta or None,
577
  tipo_codigo,
578
  _tipo_label(tipo_codigo),
579
  group.ano,
 
629
  nome_original,
630
  codigo_trabalho,
631
  nome_pasta,
632
+ link_pasta,
633
  tecnico,
634
  processo,
635
+ link_processo,
636
  finalidade_processo,
637
  modelo_nome,
638
  endereco,
639
  numero,
640
  coord_x,
641
  coord_y
642
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
643
  """,
644
  (
645
  group.clean_name,
 
648
  registro["nome_original"],
649
  registro["codigo_trabalho"] or None,
650
  registro["nome_pasta"] or None,
651
+ registro["link_pasta"] or None,
652
  registro["tecnico"] or None,
653
  registro["processo"] or None,
654
+ registro["link_processo"] or None,
655
  registro["finalidade_processo"] or None,
656
  registro["modelo_nome"],
657
  registro["endereco"],
backend/app/services/trabalhos_tecnicos_service.py CHANGED
@@ -368,6 +368,21 @@ def _table_has_column(conn: sqlite3.Connection, table_name: str, column_name: st
368
  return any(str(row["name"] or "").strip().casefold() == column_name_norm for row in rows)
369
 
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  def _resumo_textos(valores: list[str], limite_inline: int = 2) -> str:
372
  unicos = _dedupe_text_list(valores)
373
  if not unicos:
@@ -433,6 +448,87 @@ def _carregar_processos_importados_por_trabalho(
433
  return {chave: _dedupe_text_list(valores) for chave, valores in processos_por_trabalho.items()}
434
 
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  def _tipo_label_from_codigo(codigo: Any, fallback: Any = None) -> str:
437
  codigo_texto = str(codigo or "").strip().upper()
438
  if codigo_texto in TIPO_LABELS:
@@ -543,6 +639,10 @@ def _apply_override_trabalho(
543
  trabalho = dict(base)
544
  processos_base = _dedupe_text_list(base.get("processos_sei"))
545
  trabalho["processos_sei"] = processos_base
 
 
 
 
546
  trabalho["processos_sei_resumo"] = _resumo_textos(processos_base, limite_inline=1)
547
  return trabalho
548
 
@@ -569,8 +669,13 @@ def _apply_override_trabalho(
569
  processos_base = _dedupe_text_list(base.get("processos_sei"))
570
  if override.get("processos_sei_informados"):
571
  trabalho["processos_sei"] = _dedupe_text_list(override.get("processos_sei"))
 
572
  else:
573
  trabalho["processos_sei"] = processos_base
 
 
 
 
574
  trabalho["processos_sei_resumo"] = _resumo_textos(_dedupe_text_list(trabalho.get("processos_sei")), limite_inline=1)
575
  trabalho["imoveis"] = imoveis
576
  trabalho["modelos"] = _enriquecer_modelos(modelos_raw, catalogo_modelos)
@@ -610,23 +715,39 @@ def _carregar_trabalho_base(
610
  trabalho_id: str,
611
  catalogo_modelos: dict[str, dict[str, str]],
612
  ) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
613
  trabalho_row = conn.execute(
614
- """
615
  SELECT
616
- trabalho_id,
617
- nome,
618
- nome_original,
619
- tipo_codigo,
620
- tipo_label,
621
- ano,
622
- endereco_resumo,
623
- modelo_resumo,
624
- total_registros,
625
- total_imoveis,
626
- total_modelos,
627
- tem_coordenadas
628
- FROM trabalhos
629
- WHERE trabalho_id = ?
 
 
 
 
 
630
  """,
631
  (trabalho_id,),
632
  ).fetchone()
@@ -655,7 +776,8 @@ def _carregar_trabalho_base(
655
  """,
656
  (trabalho_id,),
657
  ).fetchall()
658
- processos_sei = _carregar_processos_importados_por_trabalho(conn, trabalho_id).get(trabalho_id, [])
 
659
 
660
  modelos = _enriquecer_modelos([str(row["modelo_nome"]) for row in modelo_rows], catalogo_modelos)
661
  modelos_por_imovel: dict[int, list[str]] = {}
@@ -681,16 +803,22 @@ def _carregar_trabalho_base(
681
  "id": str(trabalho_row["trabalho_id"]),
682
  "nome": str(trabalho_row["nome"]),
683
  "nome_original": str(trabalho_row["nome_original"]),
 
 
684
  "tipo_codigo": str(trabalho_row["tipo_codigo"]),
685
  "tipo_label": str(trabalho_row["tipo_label"]),
686
  "ano": trabalho_row["ano"],
687
  "endereco_resumo": str(trabalho_row["endereco_resumo"] or ""),
688
  "modelo_resumo": str(trabalho_row["modelo_resumo"] or ""),
 
 
 
689
  "total_registros_planilha": int(trabalho_row["total_registros"] or 0),
690
  "total_imoveis": int(trabalho_row["total_imoveis"] or 0),
691
  "total_modelos": int(trabalho_row["total_modelos"] or 0),
692
  "tem_coordenadas": bool(trabalho_row["tem_coordenadas"]),
693
  "processos_sei": processos_sei,
 
694
  "processos_sei_resumo": _resumo_textos(processos_sei, limite_inline=1),
695
  "modelos": modelos,
696
  "imoveis": imoveis,
@@ -889,6 +1017,7 @@ def _inserir_trabalho_manual(
889
  nome_original,
890
  codigo_trabalho,
891
  nome_pasta,
 
892
  tipo_codigo,
893
  tipo_label,
894
  ano,
@@ -903,7 +1032,7 @@ def _inserir_trabalho_manual(
903
  total_imoveis,
904
  total_modelos,
905
  tem_coordenadas
906
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
907
  """,
908
  (
909
  trabalho_id,
@@ -911,6 +1040,7 @@ def _inserir_trabalho_manual(
911
  nome,
912
  None,
913
  None,
 
914
  tipo_codigo,
915
  _tipo_label_from_codigo(tipo_codigo),
916
  ano,
@@ -966,15 +1096,17 @@ def _inserir_trabalho_manual(
966
  nome_original,
967
  codigo_trabalho,
968
  nome_pasta,
 
969
  tecnico,
970
  processo,
 
971
  finalidade_processo,
972
  modelo_nome,
973
  endereco,
974
  numero,
975
  coord_x,
976
  coord_y
977
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
978
  """,
979
  (
980
  trabalho_id,
@@ -984,8 +1116,10 @@ def _inserir_trabalho_manual(
984
  None,
985
  None,
986
  None,
 
987
  processo,
988
  None,
 
989
  modelo_nome,
990
  str(imovel.get("endereco") or "").strip() or None,
991
  str(imovel.get("numero") or "").strip() or None,
@@ -1544,24 +1678,40 @@ def listar_trabalhos() -> dict[str, Any]:
1544
  try:
1545
  meta = _fetch_meta(conn)
1546
  overrides = _fetch_overrides(conn)
1547
- processos_por_trabalho = _carregar_processos_importados_por_trabalho(conn)
 
 
 
 
 
 
 
 
 
 
 
1548
  trabalhos_rows = conn.execute(
1549
- """
1550
  SELECT
1551
- trabalho_id,
1552
- nome,
1553
- nome_original,
1554
- tipo_codigo,
1555
- tipo_label,
1556
- ano,
1557
- endereco_resumo,
1558
- modelo_resumo,
1559
- total_registros,
1560
- total_imoveis,
1561
- total_modelos,
1562
- tem_coordenadas
1563
- FROM trabalhos
1564
- ORDER BY COALESCE(ano, 0) DESC, LOWER(tipo_codigo), LOWER(nome)
 
 
 
 
 
1565
  """
1566
  ).fetchall()
1567
  modelos_rows = conn.execute(
@@ -1578,21 +1728,28 @@ def listar_trabalhos() -> dict[str, Any]:
1578
  total_com_modelo_mesa = 0
1579
  for row in trabalhos_rows:
1580
  trabalho_id = str(row["trabalho_id"])
1581
- processos_sei = list(processos_por_trabalho.get(trabalho_id, []))
 
1582
  base = {
1583
  "id": trabalho_id,
1584
  "nome": str(row["nome"]),
1585
  "nome_original": str(row["nome_original"]),
 
 
1586
  "tipo_codigo": str(row["tipo_codigo"]),
1587
  "tipo_label": str(row["tipo_label"]),
1588
  "ano": row["ano"],
1589
  "endereco_resumo": str(row["endereco_resumo"] or ""),
1590
  "modelo_resumo": str(row["modelo_resumo"] or ""),
 
 
 
1591
  "total_registros_planilha": int(row["total_registros"] or 0),
1592
  "total_imoveis": int(row["total_imoveis"] or 0),
1593
  "total_modelos": int(row["total_modelos"] or 0),
1594
  "tem_coordenadas": bool(row["tem_coordenadas"]),
1595
  "processos_sei": processos_sei,
 
1596
  "processos_sei_resumo": _resumo_textos(processos_sei, limite_inline=1),
1597
  "modelos": _enriquecer_modelos(modelos_por_trabalho.get(trabalho_id, []), catalogo_modelos),
1598
  "imoveis": [],
 
368
  return any(str(row["name"] or "").strip().casefold() == column_name_norm for row in rows)
369
 
370
 
371
+ def _select_optional_column(
372
+ conn: sqlite3.Connection,
373
+ table_name: str,
374
+ column_name: str,
375
+ *,
376
+ alias: str | None = None,
377
+ table_alias: str | None = None,
378
+ ) -> str:
379
+ alias_name = alias or column_name
380
+ if _table_has_column(conn, table_name, column_name):
381
+ prefix = f"{table_alias}." if table_alias else ""
382
+ return f"{prefix}{column_name} AS {alias_name}"
383
+ return f"NULL AS {alias_name}"
384
+
385
+
386
  def _resumo_textos(valores: list[str], limite_inline: int = 2) -> str:
387
  unicos = _dedupe_text_list(valores)
388
  if not unicos:
 
448
  return {chave: _dedupe_text_list(valores) for chave, valores in processos_por_trabalho.items()}
449
 
450
 
451
+ def _normalizar_processos_detalhados(
452
+ values: Any,
453
+ fallback_processos: list[str] | None = None,
454
+ ) -> list[dict[str, str]]:
455
+ detalhados: list[dict[str, str]] = []
456
+ vistos: set[str] = set()
457
+ for item in values or []:
458
+ if isinstance(item, dict):
459
+ numero = str(item.get("numero") or item.get("processo") or "").strip()
460
+ link = str(item.get("link") or item.get("href") or "").strip()
461
+ else:
462
+ numero = str(item or "").strip()
463
+ link = ""
464
+ chave = numero.casefold()
465
+ if not numero or chave in vistos:
466
+ continue
467
+ vistos.add(chave)
468
+ detalhados.append({"numero": numero, "link": link})
469
+
470
+ for processo in fallback_processos or []:
471
+ numero = str(processo or "").strip()
472
+ chave = numero.casefold()
473
+ if not numero or chave in vistos:
474
+ continue
475
+ vistos.add(chave)
476
+ detalhados.append({"numero": numero, "link": ""})
477
+
478
+ return detalhados
479
+
480
+
481
+ def _carregar_processos_detalhados_por_trabalho(
482
+ conn: sqlite3.Connection,
483
+ trabalho_id: str | None = None,
484
+ ) -> dict[str, list[dict[str, str]]]:
485
+ processos_por_trabalho: dict[str, list[dict[str, str]]] = {}
486
+
487
+ if _table_has_column(conn, "trabalho_registros", "processo"):
488
+ params: list[Any] = []
489
+ where_clause = "WHERE TRIM(COALESCE(processo, '')) <> ''"
490
+ if trabalho_id:
491
+ where_clause += " AND trabalho_id = ?"
492
+ params.append(trabalho_id)
493
+ link_select = _select_optional_column(
494
+ conn,
495
+ "trabalho_registros",
496
+ "link_processo",
497
+ alias="link_processo",
498
+ )
499
+ rows = conn.execute(
500
+ f"""
501
+ SELECT trabalho_id, processo, {link_select}
502
+ FROM trabalho_registros
503
+ {where_clause}
504
+ ORDER BY trabalho_id, source_row, LOWER(TRIM(processo))
505
+ """,
506
+ params,
507
+ ).fetchall()
508
+ for row in rows:
509
+ chave = str(row["trabalho_id"] or "").strip()
510
+ processo = str(row["processo"] or "").strip()
511
+ if not chave or not processo:
512
+ continue
513
+ processos_por_trabalho.setdefault(chave, []).append(
514
+ {
515
+ "numero": processo,
516
+ "link": str(row["link_processo"] or "").strip(),
517
+ }
518
+ )
519
+
520
+ if processos_por_trabalho:
521
+ return {
522
+ chave: _normalizar_processos_detalhados(valores)
523
+ for chave, valores in processos_por_trabalho.items()
524
+ }
525
+
526
+ return {
527
+ chave: _normalizar_processos_detalhados([], valores)
528
+ for chave, valores in _carregar_processos_importados_por_trabalho(conn, trabalho_id).items()
529
+ }
530
+
531
+
532
  def _tipo_label_from_codigo(codigo: Any, fallback: Any = None) -> str:
533
  codigo_texto = str(codigo or "").strip().upper()
534
  if codigo_texto in TIPO_LABELS:
 
639
  trabalho = dict(base)
640
  processos_base = _dedupe_text_list(base.get("processos_sei"))
641
  trabalho["processos_sei"] = processos_base
642
+ trabalho["processos_detalhados"] = _normalizar_processos_detalhados(
643
+ base.get("processos_detalhados"),
644
+ processos_base,
645
+ )
646
  trabalho["processos_sei_resumo"] = _resumo_textos(processos_base, limite_inline=1)
647
  return trabalho
648
 
 
669
  processos_base = _dedupe_text_list(base.get("processos_sei"))
670
  if override.get("processos_sei_informados"):
671
  trabalho["processos_sei"] = _dedupe_text_list(override.get("processos_sei"))
672
+ trabalho["processos_detalhados"] = _normalizar_processos_detalhados([], trabalho["processos_sei"])
673
  else:
674
  trabalho["processos_sei"] = processos_base
675
+ trabalho["processos_detalhados"] = _normalizar_processos_detalhados(
676
+ base.get("processos_detalhados"),
677
+ processos_base,
678
+ )
679
  trabalho["processos_sei_resumo"] = _resumo_textos(_dedupe_text_list(trabalho.get("processos_sei")), limite_inline=1)
680
  trabalho["imoveis"] = imoveis
681
  trabalho["modelos"] = _enriquecer_modelos(modelos_raw, catalogo_modelos)
 
715
  trabalho_id: str,
716
  catalogo_modelos: dict[str, dict[str, str]],
717
  ) -> dict[str, Any]:
718
+ nome_pasta_select = _select_optional_column(conn, "trabalhos", "nome_pasta", alias="nome_pasta", table_alias="t")
719
+ link_pasta_select = _select_optional_column(conn, "trabalhos", "link_pasta", alias="link_pasta", table_alias="t")
720
+ tecnico_select = _select_optional_column(conn, "trabalhos", "tecnico_resumo", alias="tecnico_resumo", table_alias="t")
721
+ processo_select = _select_optional_column(conn, "trabalhos", "processo_resumo", alias="processo_resumo", table_alias="t")
722
+ finalidade_select = _select_optional_column(
723
+ conn,
724
+ "trabalhos",
725
+ "finalidade_processo_resumo",
726
+ alias="finalidade_processo_resumo",
727
+ table_alias="t",
728
+ )
729
  trabalho_row = conn.execute(
730
+ f"""
731
  SELECT
732
+ t.trabalho_id,
733
+ t.nome,
734
+ t.nome_original,
735
+ {nome_pasta_select},
736
+ {link_pasta_select},
737
+ t.tipo_codigo,
738
+ t.tipo_label,
739
+ t.ano,
740
+ t.endereco_resumo,
741
+ t.modelo_resumo,
742
+ {tecnico_select},
743
+ {processo_select},
744
+ {finalidade_select},
745
+ t.total_registros,
746
+ t.total_imoveis,
747
+ t.total_modelos,
748
+ t.tem_coordenadas
749
+ FROM trabalhos t
750
+ WHERE t.trabalho_id = ?
751
  """,
752
  (trabalho_id,),
753
  ).fetchone()
 
776
  """,
777
  (trabalho_id,),
778
  ).fetchall()
779
+ processos_detalhados = _carregar_processos_detalhados_por_trabalho(conn, trabalho_id).get(trabalho_id, [])
780
+ processos_sei = [item["numero"] for item in processos_detalhados]
781
 
782
  modelos = _enriquecer_modelos([str(row["modelo_nome"]) for row in modelo_rows], catalogo_modelos)
783
  modelos_por_imovel: dict[int, list[str]] = {}
 
803
  "id": str(trabalho_row["trabalho_id"]),
804
  "nome": str(trabalho_row["nome"]),
805
  "nome_original": str(trabalho_row["nome_original"]),
806
+ "pasta_nome": str(trabalho_row["nome_pasta"] or ""),
807
+ "pasta_link": str(trabalho_row["link_pasta"] or ""),
808
  "tipo_codigo": str(trabalho_row["tipo_codigo"]),
809
  "tipo_label": str(trabalho_row["tipo_label"]),
810
  "ano": trabalho_row["ano"],
811
  "endereco_resumo": str(trabalho_row["endereco_resumo"] or ""),
812
  "modelo_resumo": str(trabalho_row["modelo_resumo"] or ""),
813
+ "tecnico_resumo": str(trabalho_row["tecnico_resumo"] or ""),
814
+ "processo_resumo": str(trabalho_row["processo_resumo"] or ""),
815
+ "finalidade_processo_resumo": str(trabalho_row["finalidade_processo_resumo"] or ""),
816
  "total_registros_planilha": int(trabalho_row["total_registros"] or 0),
817
  "total_imoveis": int(trabalho_row["total_imoveis"] or 0),
818
  "total_modelos": int(trabalho_row["total_modelos"] or 0),
819
  "tem_coordenadas": bool(trabalho_row["tem_coordenadas"]),
820
  "processos_sei": processos_sei,
821
+ "processos_detalhados": processos_detalhados,
822
  "processos_sei_resumo": _resumo_textos(processos_sei, limite_inline=1),
823
  "modelos": modelos,
824
  "imoveis": imoveis,
 
1017
  nome_original,
1018
  codigo_trabalho,
1019
  nome_pasta,
1020
+ link_pasta,
1021
  tipo_codigo,
1022
  tipo_label,
1023
  ano,
 
1032
  total_imoveis,
1033
  total_modelos,
1034
  tem_coordenadas
1035
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1036
  """,
1037
  (
1038
  trabalho_id,
 
1040
  nome,
1041
  None,
1042
  None,
1043
+ None,
1044
  tipo_codigo,
1045
  _tipo_label_from_codigo(tipo_codigo),
1046
  ano,
 
1096
  nome_original,
1097
  codigo_trabalho,
1098
  nome_pasta,
1099
+ link_pasta,
1100
  tecnico,
1101
  processo,
1102
+ link_processo,
1103
  finalidade_processo,
1104
  modelo_nome,
1105
  endereco,
1106
  numero,
1107
  coord_x,
1108
  coord_y
1109
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1110
  """,
1111
  (
1112
  trabalho_id,
 
1116
  None,
1117
  None,
1118
  None,
1119
+ None,
1120
  processo,
1121
  None,
1122
+ None,
1123
  modelo_nome,
1124
  str(imovel.get("endereco") or "").strip() or None,
1125
  str(imovel.get("numero") or "").strip() or None,
 
1678
  try:
1679
  meta = _fetch_meta(conn)
1680
  overrides = _fetch_overrides(conn)
1681
+ processos_detalhados_por_trabalho = _carregar_processos_detalhados_por_trabalho(conn)
1682
+ nome_pasta_select = _select_optional_column(conn, "trabalhos", "nome_pasta", alias="nome_pasta", table_alias="t")
1683
+ link_pasta_select = _select_optional_column(conn, "trabalhos", "link_pasta", alias="link_pasta", table_alias="t")
1684
+ tecnico_select = _select_optional_column(conn, "trabalhos", "tecnico_resumo", alias="tecnico_resumo", table_alias="t")
1685
+ processo_select = _select_optional_column(conn, "trabalhos", "processo_resumo", alias="processo_resumo", table_alias="t")
1686
+ finalidade_select = _select_optional_column(
1687
+ conn,
1688
+ "trabalhos",
1689
+ "finalidade_processo_resumo",
1690
+ alias="finalidade_processo_resumo",
1691
+ table_alias="t",
1692
+ )
1693
  trabalhos_rows = conn.execute(
1694
+ f"""
1695
  SELECT
1696
+ t.trabalho_id,
1697
+ t.nome,
1698
+ t.nome_original,
1699
+ {nome_pasta_select},
1700
+ {link_pasta_select},
1701
+ t.tipo_codigo,
1702
+ t.tipo_label,
1703
+ t.ano,
1704
+ t.endereco_resumo,
1705
+ t.modelo_resumo,
1706
+ {tecnico_select},
1707
+ {processo_select},
1708
+ {finalidade_select},
1709
+ t.total_registros,
1710
+ t.total_imoveis,
1711
+ t.total_modelos,
1712
+ t.tem_coordenadas
1713
+ FROM trabalhos t
1714
+ ORDER BY COALESCE(t.ano, 0) DESC, LOWER(t.tipo_codigo), LOWER(t.nome)
1715
  """
1716
  ).fetchall()
1717
  modelos_rows = conn.execute(
 
1728
  total_com_modelo_mesa = 0
1729
  for row in trabalhos_rows:
1730
  trabalho_id = str(row["trabalho_id"])
1731
+ processos_detalhados = list(processos_detalhados_por_trabalho.get(trabalho_id, []))
1732
+ processos_sei = [item["numero"] for item in processos_detalhados]
1733
  base = {
1734
  "id": trabalho_id,
1735
  "nome": str(row["nome"]),
1736
  "nome_original": str(row["nome_original"]),
1737
+ "pasta_nome": str(row["nome_pasta"] or ""),
1738
+ "pasta_link": str(row["link_pasta"] or ""),
1739
  "tipo_codigo": str(row["tipo_codigo"]),
1740
  "tipo_label": str(row["tipo_label"]),
1741
  "ano": row["ano"],
1742
  "endereco_resumo": str(row["endereco_resumo"] or ""),
1743
  "modelo_resumo": str(row["modelo_resumo"] or ""),
1744
+ "tecnico_resumo": str(row["tecnico_resumo"] or ""),
1745
+ "processo_resumo": str(row["processo_resumo"] or ""),
1746
+ "finalidade_processo_resumo": str(row["finalidade_processo_resumo"] or ""),
1747
  "total_registros_planilha": int(row["total_registros"] or 0),
1748
  "total_imoveis": int(row["total_imoveis"] or 0),
1749
  "total_modelos": int(row["total_modelos"] or 0),
1750
  "tem_coordenadas": bool(row["tem_coordenadas"]),
1751
  "processos_sei": processos_sei,
1752
+ "processos_detalhados": processos_detalhados,
1753
  "processos_sei_resumo": _resumo_textos(processos_sei, limite_inline=1),
1754
  "modelos": _enriquecer_modelos(modelos_por_trabalho.get(trabalho_id, []), catalogo_modelos),
1755
  "imoveis": [],
backend/local_data/trabalhos_tecnicos.sqlite3.bak_2026-04-08_1229 ADDED
File without changes
frontend/public/logo_sigedai.png ADDED

Git LFS Details

  • SHA256: c1e96b709785fc35078264b636a406a77e78695280050f39397619e3b4bdf743
  • Pointer size: 131 Bytes
  • Size of remote file: 231 kB
frontend/public/logo_sigedai.svg ADDED
frontend/public/logo_sigedai.svg.png ADDED

Git LFS Details

  • SHA256: 1d2b417bf567d84c479a71bfcc0585f88c5f67a322f8af25351efbe9e94c1732
  • Pointer size: 131 Bytes
  • Size of remote file: 867 kB
frontend/public/logo_sigedai.svg.qlpreview/Preview.url ADDED
@@ -0,0 +1 @@
 
 
1
+ file:///Users/guilhermesilberfarbcosta/Downloads/mesa-frame/frontend/public/logo_sigedai.svg
frontend/public/logo_sigedai.svg.qlpreview/PreviewProperties.plist ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>MimeType</key>
6
+ <string>image/svg+xml</string>
7
+ </dict>
8
+ </plist>
frontend/src/api.js CHANGED
@@ -267,9 +267,10 @@ export const api = {
267
  session_id: sessionId,
268
  ...(payload || {}),
269
  }),
270
- getDispersaoInterativo: (sessionId, alvo) => postJson('/api/elaboracao/dispersao-interativo', {
271
  session_id: sessionId,
272
  alvo,
 
273
  }),
274
  getDiagnosticoInterativo: (sessionId, grafico) => postJson('/api/elaboracao/diagnostico-interativo', {
275
  session_id: sessionId,
@@ -367,10 +368,6 @@ export const api = {
367
  },
368
  visualizacaoRepositorioModelos: () => getJson('/api/visualizacao/repositorio-modelos'),
369
  visualizacaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/visualizacao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
370
- exibirVisualizacao: (sessionId, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/exibir', {
371
- session_id: sessionId,
372
- trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
373
- }),
374
  visualizacaoSection: (sessionId, secao, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/section', {
375
  session_id: sessionId,
376
  secao,
@@ -382,7 +379,6 @@ export const api = {
382
  variavel_mapa: variavelMapa,
383
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
384
  }),
385
- evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
386
  evaluationCalculateViz: (sessionId, valoresX, indiceBase, avaliando = null) => postJson('/api/visualizacao/evaluation/calculate', {
387
  session_id: sessionId,
388
  valores_x: valoresX,
@@ -401,19 +397,7 @@ export const api = {
401
  avaliando_lat: avaliando?.lat ?? null,
402
  avaliando_lon: avaliando?.lon ?? null,
403
  }),
404
- evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
405
- evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
406
- session_id: sessionId,
407
- indice,
408
- indice_base: indiceBase,
409
- }),
410
- evaluationBaseViz: (sessionId, indiceBase) => postJson('/api/visualizacao/evaluation/base', {
411
- session_id: sessionId,
412
- indice_base: indiceBase,
413
- }),
414
- exportEvaluationViz: (sessionId) => getBlob(`/api/visualizacao/evaluation/export?session_id=${encodeURIComponent(sessionId)}`),
415
  exportEquationViz: (sessionId, mode = 'excel') => getBlob(`/api/visualizacao/equation/export?session_id=${encodeURIComponent(sessionId)}&mode=${encodeURIComponent(String(mode || 'excel'))}`),
416
- clearVisualizacao: (sessionId) => postJson('/api/visualizacao/clear', { session_id: sessionId }),
417
 
418
  repositorioListar: () => getJson('/api/repositorio/modelos'),
419
  repositorioUpload(files = [], { confirmarSubstituicao = false } = {}) {
 
267
  session_id: sessionId,
268
  ...(payload || {}),
269
  }),
270
+ getDispersaoInterativo: (sessionId, alvo, options = {}) => postJson('/api/elaboracao/dispersao-interativo', {
271
  session_id: sessionId,
272
  alvo,
273
+ ...(options || {}),
274
  }),
275
  getDiagnosticoInterativo: (sessionId, grafico) => postJson('/api/elaboracao/diagnostico-interativo', {
276
  session_id: sessionId,
 
368
  },
369
  visualizacaoRepositorioModelos: () => getJson('/api/visualizacao/repositorio-modelos'),
370
  visualizacaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/visualizacao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
 
 
 
 
371
  visualizacaoSection: (sessionId, secao, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/section', {
372
  session_id: sessionId,
373
  secao,
 
379
  variavel_mapa: variavelMapa,
380
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
381
  }),
 
382
  evaluationCalculateViz: (sessionId, valoresX, indiceBase, avaliando = null) => postJson('/api/visualizacao/evaluation/calculate', {
383
  session_id: sessionId,
384
  valores_x: valoresX,
 
397
  avaliando_lat: avaliando?.lat ?? null,
398
  avaliando_lon: avaliando?.lon ?? null,
399
  }),
 
 
 
 
 
 
 
 
 
 
 
400
  exportEquationViz: (sessionId, mode = 'excel') => getBlob(`/api/visualizacao/equation/export?session_id=${encodeURIComponent(sessionId)}&mode=${encodeURIComponent(String(mode || 'excel'))}`),
 
401
 
402
  repositorioListar: () => getJson('/api/repositorio/modelos'),
403
  repositorioUpload(files = [], { confirmarSubstituicao = false } = {}) {
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -32,6 +32,8 @@ const GRAU_LABEL_CURTO = {
32
  2: 'Grau II',
33
  3: 'Grau III',
34
  }
 
 
35
  const MAPA_VARIAVEL_PADRAO = 'Visualização Padrão'
36
  const MAPA_MODO_PONTOS = 'pontos'
37
  const MAPA_MODO_CALOR = 'calor'
@@ -493,6 +495,291 @@ function parsePanelHeading(tituloHtml, fallbackTitle) {
493
  }
494
  }
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  function matchesShapeAxisRef(ref, token) {
497
  const text = String(ref || '').trim().toLowerCase()
498
  if (!text) return false
@@ -703,6 +990,70 @@ function buildScatterPanelFigureMap(panels) {
703
  return mapa
704
  }
705
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  function ScatterPlotCarousel({
707
  items,
708
  indexedFigureMap,
@@ -711,6 +1062,8 @@ function ScatterPlotCarousel({
711
  sectionFilePrefix,
712
  onDownloadFigure,
713
  onDownloadAll,
 
 
714
  renderItem = null,
715
  downloadAllLabel = 'Baixar todos os gráficos',
716
  pillsAriaLabel = 'Escolher dupla de gráficos',
@@ -805,6 +1158,20 @@ function ScatterPlotCarousel({
805
  const forceHideLegend = item.forceHideLegend ?? true
806
  const showPointIndexToggle = item.showPointIndexToggle ?? true
807
  const downloadOptions = item.downloadOptions || { forceHideLegend }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
808
  return (
809
  <PlotFigure
810
  key={`${itemKeyPrefix}-${item.id || itemIndex}`}
@@ -817,17 +1184,9 @@ function ScatterPlotCarousel({
817
  forceHideLegend={forceHideLegend}
818
  className={item.className || 'plot-stretch'}
819
  lazy
820
- headerActions={typeof onDownloadFigure === 'function' ? (
821
- <button
822
- type="button"
823
- className="btn-download-subtle plot-card-download-btn"
824
- title={item.legenda || item.label || 'Fazer download'}
825
- onClick={() => onDownloadFigure(item.figure, fileNameBase, downloadOptions)}
826
- disabled={loading || downloadingAssets || !item.figure}
827
- >
828
- Fazer download
829
- </button>
830
- ) : null}
831
  />
832
  )
833
  })}
@@ -1128,6 +1487,9 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1128
  const [secao10InterativoFigura, setSecao10InterativoFigura] = useState(null)
1129
  const [secao10InterativoFiguraComIndices, setSecao10InterativoFiguraComIndices] = useState(null)
1130
  const [secao10InterativoSelecionado, setSecao10InterativoSelecionado] = useState('none')
 
 
 
1131
  const [secao13InterativoFigura, setSecao13InterativoFigura] = useState(null)
1132
  const [secao13InterativoFiguraComIndices, setSecao13InterativoFiguraComIndices] = useState(null)
1133
  const [secao13InterativoSelecionado, setSecao13InterativoSelecionado] = useState('none')
@@ -1596,6 +1958,34 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1596
  }
1597
  return ''
1598
  }, [origemTransformacoes])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1599
  const secao10ModoPng = useMemo(
1600
  () => String(selection?.grafico_dispersao_modo || '') === 'png',
1601
  [selection?.grafico_dispersao_modo],
@@ -1629,9 +2019,18 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1629
  }),
1630
  [selection?.grafico_dispersao_com_indices, colunaY],
1631
  )
1632
- const graficosSecao9ComIndicesMap = useMemo(
1633
- () => buildScatterPanelFigureMap(graficosSecao9ComIndices),
1634
- [graficosSecao9ComIndices],
 
 
 
 
 
 
 
 
 
1635
  )
1636
  const graficosSecao9Interativo = useMemo(
1637
  () => buildScatterPanels(secao10InterativoFigura, {
@@ -1651,9 +2050,18 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1651
  }),
1652
  [secao10InterativoFiguraComIndices, colunaY],
1653
  )
1654
- const graficosSecao9InterativoComIndicesMap = useMemo(
1655
- () => buildScatterPanelFigureMap(graficosSecao9InterativoComIndices),
1656
- [graficosSecao9InterativoComIndices],
 
 
 
 
 
 
 
 
 
1657
  )
1658
  const secao10InterativoOpcoes = useMemo(() => {
1659
  const labels = graficosSecao9Interativo.length > 0
@@ -1662,9 +2070,9 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1662
  return Array.from(new Set(labels))
1663
  }, [graficosSecao9Interativo, selection?.contexto?.colunas_x, colunasX])
1664
  const secao10InterativoAtual = useMemo(() => {
1665
- if (secao10InterativoSelecionado === 'none' || graficosSecao9Interativo.length === 0) return null
1666
- return graficosSecao9Interativo.find((item) => String(item.label || '') === secao10InterativoSelecionado) || graficosSecao9Interativo[0]
1667
- }, [graficosSecao9Interativo, secao10InterativoSelecionado])
1668
  const colunasOriginaisDispersao = useMemo(() => {
1669
  const cols = Array.isArray(dados?.columns) ? dados.columns : []
1670
  return cols
@@ -1807,6 +2215,12 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1807
  }
1808
  }, [modeloLoadSource])
1809
 
 
 
 
 
 
 
1810
  const onDisabledHintEnter = useCallback((event, showHint, hintText) => {
1811
  if (!showHint || !hintText || typeof window === 'undefined') {
1812
  setDisabledHint(null)
@@ -4215,6 +4629,71 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4215
  }
4216
  }
4217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4218
  async function ensureSecao10GraficoComIndices() {
4219
  const figuraAtual = selection?.grafico_dispersao_com_indices || null
4220
  if (figuraAtual) {
@@ -5563,19 +6042,27 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
5563
  <PlotFigure
5564
  key={`s9-interativo-plot-${secao10InterativoAtual.id}`}
5565
  figure={secao10InterativoAtual.figure}
5566
- indexedFigure={graficosSecao9InterativoComIndicesMap.get(String(secao10InterativoAtual.label || '').trim()) || null}
5567
- onRequestIndexedFigure={ensureSecao10GraficoComIndices}
5568
  title={secao10InterativoAtual.title}
5569
  subtitle={secao10InterativoAtual.subtitle}
5570
  showPointIndexToggle
5571
  forceHideLegend
5572
  className="plot-stretch"
5573
  lazy
 
5574
  headerActions={(
5575
- <button
5576
- type="button"
5577
- className="btn-download-subtle plot-card-download-btn"
5578
- title={secao10InterativoAtual?.legenda || ''}
 
 
 
 
 
 
 
5579
  onClick={() => {
5580
  void onDownloadFigurePng(
5581
  secao10InterativoAtual.figure,
@@ -5584,9 +6071,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
5584
  )
5585
  }}
5586
  disabled={loading || downloadingAssets || !secao10InterativoAtual?.figure}
5587
- >
5588
- Fazer download
5589
- </button>
5590
  )}
5591
  />
5592
  ) : (
@@ -5597,16 +6082,32 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
5597
  </>
5598
  ) : (
5599
  <>
5600
- {graficosSecao9.length > 0 ? (
5601
  <ScatterPlotCarousel
5602
- items={graficosSecao9}
5603
- indexedFigureMap={graficosSecao9ComIndicesMap}
5604
  onRequestIndexedFigure={ensureSecao10GraficoComIndices}
5605
  itemKeyPrefix="s9-plot"
5606
  sectionFilePrefix="secao10"
5607
  onDownloadFigure={onDownloadFigurePng}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5608
  onDownloadAll={() => onDownloadFiguresPngBatch(
5609
- graficosSecao9.map((item, idx) => ({
5610
  figure: item.figure,
5611
  fileNameBase: `secao10_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
5612
  forceHideLegend: true,
@@ -6077,11 +6578,10 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6077
  forceHideLegend
6078
  className="plot-stretch"
6079
  lazy
6080
- headerActions={(
6081
- <button
6082
- type="button"
6083
- className="btn-download-subtle plot-card-download-btn"
6084
- title={secao13InterativoAtual?.legenda || ''}
6085
  onClick={() => {
6086
  void onDownloadFigurePng(
6087
  secao13InterativoAtual.figure,
@@ -6090,9 +6590,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6090
  )
6091
  }}
6092
  disabled={loading || downloadingAssets || !secao13InterativoAtual?.figure}
6093
- >
6094
- Fazer download
6095
- </button>
6096
  )}
6097
  />
6098
  ) : (
@@ -6132,8 +6630,8 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6132
  <div className="section14-tabs" role="tablist" aria-label="Conteúdos do diagnóstico do modelo">
6133
  {[
6134
  { id: 'diagnosticos', label: 'Diagnósticos' },
6135
- { id: 'testes', label: 'Testes' },
6136
- { id: 'coeficientes', label: 'Coeficientes' },
6137
  { id: 'equacoes', label: 'Equações' },
6138
  ].map((tab) => (
6139
  <button
@@ -6181,7 +6679,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6181
  {section14Tab === 'coeficientes' ? (
6182
  <div className="section14-table-panel">
6183
  <div className="section14-table-head">
6184
- <h4>Tabela de Coeficientes</h4>
6185
  <button
6186
  type="button"
6187
  className="btn-download-subtle"
@@ -6248,14 +6746,12 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6248
  alt={`${item.title} em PNG`}
6249
  className="plot-stretch"
6250
  headerActions={(
6251
- <button
6252
- type="button"
6253
- className="btn-download-subtle plot-card-download-btn"
6254
  onClick={() => onDownloadPngBase64(item.pngPayload?.image_base64, item.pngPayload?.mime_type, fileNameBase)}
6255
  disabled={loading || downloadingAssets || !item.pngPayload?.image_base64}
6256
- >
6257
- Fazer download
6258
- </button>
6259
  )}
6260
  />
6261
  ) : (
@@ -6265,14 +6761,12 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6265
  className={item.className}
6266
  lazy
6267
  headerActions={(
6268
- <button
6269
- type="button"
6270
- className="btn-download-subtle plot-card-download-btn"
6271
  onClick={() => onDownloadFigurePng(item.figure, fileNameBase)}
6272
  disabled={loading || downloadingAssets || !item.figure}
6273
- >
6274
- Fazer download
6275
- </button>
6276
  )}
6277
  />
6278
  )
@@ -6331,10 +6825,10 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6331
  forceHideLegend={secao15InterativoSelecionado === 'cook'}
6332
  className="plot-stretch"
6333
  lazy
6334
- headerActions={(
6335
- <button
6336
- type="button"
6337
- className="btn-download-subtle plot-card-download-btn"
6338
  onClick={() => {
6339
  if (!secao15InterativoFigura) return
6340
  void onDownloadFigurePng(
@@ -6344,9 +6838,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6344
  )
6345
  }}
6346
  disabled={loading || downloadingAssets || !secao15InterativoFigura}
6347
- >
6348
- Fazer download
6349
- </button>
6350
  )}
6351
  />
6352
  ) : (
 
32
  2: 'Grau II',
33
  3: 'Grau III',
34
  }
35
+ const TRANSFORMACOES_GRAFICO_DISPERSAO = ['(x)', '1/(x)', 'ln(x)', 'exp(x)', '(x)^2', 'raiz(x)', '1/raiz(x)']
36
+ const TRANSFORMACAO_GRAFICO_PADRAO = '(x)'
37
  const MAPA_VARIAVEL_PADRAO = 'Visualização Padrão'
38
  const MAPA_MODO_PONTOS = 'pontos'
39
  const MAPA_MODO_CALOR = 'calor'
 
495
  }
496
  }
497
 
498
+ function normalizarTransformacaoGrafico(value) {
499
+ const text = String(value || '').trim()
500
+ return TRANSFORMACOES_GRAFICO_DISPERSAO.includes(text) ? text : TRANSFORMACAO_GRAFICO_PADRAO
501
+ }
502
+
503
+ function formatTransformacaoEixo(transformacao, eixo = 'x') {
504
+ const text = normalizarTransformacaoGrafico(transformacao)
505
+ if (String(eixo || '').toLowerCase() !== 'y') return text
506
+ return text.replace(/\bx\b/g, 'y')
507
+ }
508
+
509
+ function getScatterTransformKey(label) {
510
+ return String(label || '').trim()
511
+ }
512
+
513
+ function getScatterTransformConfig(map, label) {
514
+ const key = getScatterTransformKey(label)
515
+ const raw = key && map && typeof map === 'object' ? map[key] : null
516
+ return {
517
+ x: normalizarTransformacaoGrafico(raw?.x),
518
+ y: normalizarTransformacaoGrafico(raw?.y),
519
+ }
520
+ }
521
+
522
+ function buildScatterLazyCacheKey(label, config) {
523
+ const safeConfig = {
524
+ x: normalizarTransformacaoGrafico(config?.x),
525
+ y: normalizarTransformacaoGrafico(config?.y),
526
+ }
527
+ return `${getScatterTransformKey(label)}::${safeConfig.x}::${safeConfig.y}`
528
+ }
529
+
530
+ function aplicarTransformacaoNumerica(value, transformacao) {
531
+ const num = Number(value)
532
+ if (!Number.isFinite(num)) return null
533
+ const tipo = normalizarTransformacaoGrafico(transformacao)
534
+ let next = num
535
+ if (tipo === '1/(x)') {
536
+ if (num === 0) return null
537
+ next = 1 / num
538
+ } else if (tipo === 'ln(x)') {
539
+ if (num <= 0) return null
540
+ next = Math.log(num)
541
+ } else if (tipo === 'exp(x)') {
542
+ next = Math.exp(num)
543
+ } else if (tipo === '(x)^2') {
544
+ next = num ** 2
545
+ } else if (tipo === 'raiz(x)') {
546
+ if (num < 0) return null
547
+ next = Math.sqrt(num)
548
+ } else if (tipo === '1/raiz(x)') {
549
+ if (num <= 0) return null
550
+ next = 1 / Math.sqrt(num)
551
+ }
552
+ return Number.isFinite(next) ? next : null
553
+ }
554
+
555
+ function normalizarSequenciaNumerica(values) {
556
+ if (Array.isArray(values)) return values
557
+ if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(values)) {
558
+ return Array.from(values)
559
+ }
560
+ if (values && typeof values === 'object' && Array.isArray(values.values)) {
561
+ return values.values
562
+ }
563
+ if (
564
+ values &&
565
+ typeof values !== 'string' &&
566
+ typeof values.length === 'number' &&
567
+ Number.isFinite(Number(values.length))
568
+ ) {
569
+ try {
570
+ return Array.from(values)
571
+ } catch {
572
+ return null
573
+ }
574
+ }
575
+ return null
576
+ }
577
+
578
+ function transformarArrayNumerico(values, transformacao) {
579
+ const numericValues = normalizarSequenciaNumerica(values)
580
+ if (!numericValues) return values
581
+ const tipo = normalizarTransformacaoGrafico(transformacao)
582
+ if (tipo === TRANSFORMACAO_GRAFICO_PADRAO) {
583
+ return numericValues.map((value) => {
584
+ const num = Number(value)
585
+ return Number.isFinite(num) ? num : null
586
+ })
587
+ }
588
+ return numericValues.map((value) => aplicarTransformacaoNumerica(value, tipo))
589
+ }
590
+
591
+ function coletarParesFinitos(xValues, yValues) {
592
+ const xArray = normalizarSequenciaNumerica(xValues)
593
+ const yArray = normalizarSequenciaNumerica(yValues)
594
+ if (!xArray || !yArray) return []
595
+ const len = Math.min(xArray.length, yArray.length)
596
+ const pairs = []
597
+ for (let index = 0; index < len; index += 1) {
598
+ const x = Number(xArray[index])
599
+ const y = Number(yArray[index])
600
+ if (Number.isFinite(x) && Number.isFinite(y)) pairs.push([x, y])
601
+ }
602
+ return pairs
603
+ }
604
+
605
+ function calcularCorrelacaoPares(pairs) {
606
+ if (!Array.isArray(pairs) || pairs.length < 2) return null
607
+ const n = pairs.length
608
+ const meanX = pairs.reduce((sum, pair) => sum + pair[0], 0) / n
609
+ const meanY = pairs.reduce((sum, pair) => sum + pair[1], 0) / n
610
+ let cov = 0
611
+ let varX = 0
612
+ let varY = 0
613
+ pairs.forEach(([x, y]) => {
614
+ const dx = x - meanX
615
+ const dy = y - meanY
616
+ cov += dx * dy
617
+ varX += dx * dx
618
+ varY += dy * dy
619
+ })
620
+ const denom = Math.sqrt(varX * varY)
621
+ if (!Number.isFinite(denom) || denom <= 0) return null
622
+ const corr = cov / denom
623
+ return Number.isFinite(corr) ? corr : null
624
+ }
625
+
626
+ function formatarCorrelacao(value) {
627
+ if (!Number.isFinite(value)) return ''
628
+ const normalized = Math.abs(value) < 0.0005 ? 0 : value
629
+ return normalized.toFixed(3)
630
+ }
631
+
632
+ function calcularLinhaRegressao(pairs) {
633
+ if (!Array.isArray(pairs) || pairs.length < 3) return null
634
+ const n = pairs.length
635
+ const meanX = pairs.reduce((sum, pair) => sum + pair[0], 0) / n
636
+ const meanY = pairs.reduce((sum, pair) => sum + pair[1], 0) / n
637
+ let varX = 0
638
+ let cov = 0
639
+ pairs.forEach(([x, y]) => {
640
+ const dx = x - meanX
641
+ varX += dx * dx
642
+ cov += dx * (y - meanY)
643
+ })
644
+ if (!Number.isFinite(varX) || varX <= 0) return null
645
+ const a = cov / varX
646
+ const b = meanY - (a * meanX)
647
+ const sortedX = pairs.map((pair) => pair[0]).sort((aValue, bValue) => aValue - bValue)
648
+ return {
649
+ x: sortedX,
650
+ y: sortedX.map((xValue) => (a * xValue) + b),
651
+ }
652
+ }
653
+
654
+ function tituloEixoTransformado(axis, fallback, transformacao, eixo) {
655
+ const baseTitle = getAxisTitleText(axis) || String(fallback || '').trim() || String(eixo || '').toUpperCase()
656
+ const tipo = normalizarTransformacaoGrafico(transformacao)
657
+ return `${baseTitle} (${formatTransformacaoEixo(tipo, eixo)})`
658
+ }
659
+
660
+ function aplicarTransformacoesFiguraDispersao(figure, config, label, yFallback = 'Y') {
661
+ if (!figure || typeof figure !== 'object') return figure
662
+ const transformacaoX = normalizarTransformacaoGrafico(config?.x)
663
+ const transformacaoY = normalizarTransformacaoGrafico(config?.y)
664
+ const data = Array.isArray(figure.data) ? figure.data : []
665
+ const transformedData = data.map((trace) => ({
666
+ ...trace,
667
+ x: transformarArrayNumerico(trace?.x, transformacaoX),
668
+ y: transformarArrayNumerico(trace?.y, transformacaoY),
669
+ }))
670
+
671
+ const markerTrace = transformedData.find((trace) => String(trace?.mode || '').includes('markers'))
672
+ const pairs = markerTrace ? coletarParesFinitos(markerTrace.x, markerTrace.y) : []
673
+ const regression = calcularLinhaRegressao(pairs)
674
+ const dataComRegressao = transformedData.map((trace) => {
675
+ const mode = String(trace?.mode || '')
676
+ if (!mode.includes('lines') || mode.includes('markers')) return trace
677
+ return {
678
+ ...trace,
679
+ x: regression?.x || [],
680
+ y: regression?.y || [],
681
+ }
682
+ })
683
+
684
+ const baseLayout = figure.layout || {}
685
+ const {
686
+ range: _ignoreXRange,
687
+ autorange: _ignoreXAutorange,
688
+ ...xaxisRest
689
+ } = baseLayout.xaxis || {}
690
+ const {
691
+ range: _ignoreYRange,
692
+ autorange: _ignoreYAutorange,
693
+ ...yaxisRest
694
+ } = baseLayout.yaxis || {}
695
+ const xTitle = tituloEixoTransformado(baseLayout.xaxis, label, transformacaoX, 'x')
696
+ const yTitle = tituloEixoTransformado(baseLayout.yaxis, yFallback, transformacaoY, 'y')
697
+
698
+ return {
699
+ ...figure,
700
+ data: dataComRegressao,
701
+ layout: {
702
+ ...baseLayout,
703
+ xaxis: {
704
+ ...xaxisRest,
705
+ autorange: true,
706
+ title: {
707
+ ...(typeof baseLayout.xaxis?.title === 'object' ? baseLayout.xaxis.title : {}),
708
+ text: xTitle,
709
+ },
710
+ },
711
+ yaxis: {
712
+ ...yaxisRest,
713
+ autorange: true,
714
+ title: {
715
+ ...(typeof baseLayout.yaxis?.title === 'object' ? baseLayout.yaxis.title : {}),
716
+ text: yTitle,
717
+ },
718
+ },
719
+ },
720
+ }
721
+ }
722
+
723
+ function rotularFiguraDispersao(figure, config, label, yFallback = 'Y') {
724
+ if (!figure || typeof figure !== 'object') return figure
725
+ const transformacaoX = normalizarTransformacaoGrafico(config?.x)
726
+ const transformacaoY = normalizarTransformacaoGrafico(config?.y)
727
+ const baseLayout = figure.layout || {}
728
+ const xTitle = tituloEixoTransformado(baseLayout.xaxis, label, transformacaoX, 'x')
729
+ const yTitle = tituloEixoTransformado(baseLayout.yaxis, yFallback, transformacaoY, 'y')
730
+ return {
731
+ ...figure,
732
+ layout: {
733
+ ...baseLayout,
734
+ xaxis: {
735
+ ...(baseLayout.xaxis || {}),
736
+ title: {
737
+ ...(typeof baseLayout.xaxis?.title === 'object' ? baseLayout.xaxis.title : {}),
738
+ text: xTitle,
739
+ },
740
+ },
741
+ yaxis: {
742
+ ...(baseLayout.yaxis || {}),
743
+ title: {
744
+ ...(typeof baseLayout.yaxis?.title === 'object' ? baseLayout.yaxis.title : {}),
745
+ text: yTitle,
746
+ },
747
+ },
748
+ },
749
+ }
750
+ }
751
+
752
+ function rotularPainelDispersao(item, config, yFallback = 'Y') {
753
+ if (!item?.figure) return item
754
+ const transformacaoX = normalizarTransformacaoGrafico(config?.x)
755
+ const transformacaoY = normalizarTransformacaoGrafico(config?.y)
756
+ return {
757
+ ...item,
758
+ figure: rotularFiguraDispersao(item.figure, { x: transformacaoX, y: transformacaoY }, item.label, yFallback),
759
+ revisionKey: `${transformacaoX}|${transformacaoY}`,
760
+ }
761
+ }
762
+
763
+ function aplicarTransformacoesPainelDispersao(item, config, yFallback = 'Y') {
764
+ if (!item?.figure) return item
765
+ const transformacaoX = normalizarTransformacaoGrafico(config?.x)
766
+ const transformacaoY = normalizarTransformacaoGrafico(config?.y)
767
+ const figure = aplicarTransformacoesFiguraDispersao(item.figure, { x: transformacaoX, y: transformacaoY }, item.label, yFallback)
768
+ const markerTrace = Array.isArray(figure?.data)
769
+ ? figure.data.find((trace) => String(trace?.mode || '').includes('markers'))
770
+ : null
771
+ const corr = markerTrace ? calcularCorrelacaoPares(coletarParesFinitos(markerTrace.x, markerTrace.y)) : null
772
+ const subtitle = corr === null ? item.subtitle : `Correlação (r = ${formatarCorrelacao(corr)})`
773
+ const revisionKey = `${transformacaoX}|${transformacaoY}`
774
+ return {
775
+ ...item,
776
+ figure,
777
+ subtitle,
778
+ legenda: [item.title, subtitle].filter(Boolean).join(' - '),
779
+ revisionKey,
780
+ }
781
+ }
782
+
783
  function matchesShapeAxisRef(ref, token) {
784
  const text = String(ref || '').trim().toLowerCase()
785
  if (!text) return false
 
990
  return mapa
991
  }
992
 
993
+ function DownloadIcon() {
994
+ return (
995
+ <svg className="plot-card-download-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
996
+ <path d="M12 3v11" />
997
+ <path d="M7.5 9.5 12 14l4.5-4.5" />
998
+ <path d="M5 18.5h14" />
999
+ </svg>
1000
+ )
1001
+ }
1002
+
1003
+ function DownloadIconButton({ onClick, disabled = false, title = 'Fazer download', ariaLabel = 'Fazer download', className = '' }) {
1004
+ return (
1005
+ <button
1006
+ type="button"
1007
+ className={`btn-download-subtle plot-card-download-btn plot-card-download-icon-btn ${className}`.trim()}
1008
+ title={title}
1009
+ aria-label={ariaLabel}
1010
+ onClick={onClick}
1011
+ disabled={disabled}
1012
+ >
1013
+ <DownloadIcon />
1014
+ </button>
1015
+ )
1016
+ }
1017
+
1018
+ function ScatterTransformControls({ label, config, onChange, disabled = false }) {
1019
+ const safeConfig = {
1020
+ x: normalizarTransformacaoGrafico(config?.x),
1021
+ y: normalizarTransformacaoGrafico(config?.y),
1022
+ }
1023
+ return (
1024
+ <div className="scatter-transform-controls" aria-label="Transformações do gráfico">
1025
+ <label className="scatter-transform-field">
1026
+ <span>X</span>
1027
+ <select
1028
+ value={safeConfig.x}
1029
+ onChange={(event) => onChange?.(label, 'x', event.target.value)}
1030
+ disabled={disabled}
1031
+ >
1032
+ {TRANSFORMACOES_GRAFICO_DISPERSAO.map((item) => (
1033
+ <option key={`scatter-transform-x-${item}`} value={item}>
1034
+ {formatTransformacaoEixo(item, 'x')}
1035
+ </option>
1036
+ ))}
1037
+ </select>
1038
+ </label>
1039
+ <label className="scatter-transform-field">
1040
+ <span>Y</span>
1041
+ <select
1042
+ value={safeConfig.y}
1043
+ onChange={(event) => onChange?.(label, 'y', event.target.value)}
1044
+ disabled={disabled}
1045
+ >
1046
+ {TRANSFORMACOES_GRAFICO_DISPERSAO.map((item) => (
1047
+ <option key={`scatter-transform-y-${item}`} value={item}>
1048
+ {formatTransformacaoEixo(item, 'y')}
1049
+ </option>
1050
+ ))}
1051
+ </select>
1052
+ </label>
1053
+ </div>
1054
+ )
1055
+ }
1056
+
1057
  function ScatterPlotCarousel({
1058
  items,
1059
  indexedFigureMap,
 
1062
  sectionFilePrefix,
1063
  onDownloadFigure,
1064
  onDownloadAll,
1065
+ renderHeaderActions = null,
1066
+ renderTrailingHeaderActions = null,
1067
  renderItem = null,
1068
  downloadAllLabel = 'Baixar todos os gráficos',
1069
  pillsAriaLabel = 'Escolher dupla de gráficos',
 
1158
  const forceHideLegend = item.forceHideLegend ?? true
1159
  const showPointIndexToggle = item.showPointIndexToggle ?? true
1160
  const downloadOptions = item.downloadOptions || { forceHideLegend }
1161
+ const defaultDownloadAction = typeof onDownloadFigure === 'function' ? (
1162
+ <DownloadIconButton
1163
+ title={item.legenda || item.label || 'Fazer download'}
1164
+ ariaLabel={`Fazer download de ${item.legenda || item.label || 'gráfico'}`}
1165
+ onClick={() => onDownloadFigure(item.figure, fileNameBase, downloadOptions)}
1166
+ disabled={loading || downloadingAssets || !item.figure}
1167
+ />
1168
+ ) : null
1169
+ const headerActions = typeof renderHeaderActions === 'function'
1170
+ ? renderHeaderActions({ item, itemIndex, fileNameBase, downloadOptions })
1171
+ : null
1172
+ const trailingHeaderActions = typeof renderTrailingHeaderActions === 'function'
1173
+ ? renderTrailingHeaderActions({ item, itemIndex, fileNameBase, downloadOptions })
1174
+ : defaultDownloadAction
1175
  return (
1176
  <PlotFigure
1177
  key={`${itemKeyPrefix}-${item.id || itemIndex}`}
 
1184
  forceHideLegend={forceHideLegend}
1185
  className={item.className || 'plot-stretch'}
1186
  lazy
1187
+ headerActions={headerActions}
1188
+ trailingHeaderActions={trailingHeaderActions}
1189
+ revisionKey={item.revisionKey}
 
 
 
 
 
 
 
 
1190
  />
1191
  )
1192
  })}
 
1487
  const [secao10InterativoFigura, setSecao10InterativoFigura] = useState(null)
1488
  const [secao10InterativoFiguraComIndices, setSecao10InterativoFiguraComIndices] = useState(null)
1489
  const [secao10InterativoSelecionado, setSecao10InterativoSelecionado] = useState('none')
1490
+ const [secao10GraficoTransformacoes, setSecao10GraficoTransformacoes] = useState({})
1491
+ const [secao10GraficosLazyCache, setSecao10GraficosLazyCache] = useState({})
1492
+ const [secao10GraficosLazyLoading, setSecao10GraficosLazyLoading] = useState({})
1493
  const [secao13InterativoFigura, setSecao13InterativoFigura] = useState(null)
1494
  const [secao13InterativoFiguraComIndices, setSecao13InterativoFiguraComIndices] = useState(null)
1495
  const [secao13InterativoSelecionado, setSecao13InterativoSelecionado] = useState('none')
 
1958
  }
1959
  return ''
1960
  }, [origemTransformacoes])
1961
+ function getSecao10PainelComCache(item, useIndices = false, options = {}) {
1962
+ const config = getScatterTransformConfig(secao10GraficoTransformacoes, item?.label)
1963
+ const cacheKey = buildScatterLazyCacheKey(item?.label, config)
1964
+ const cached = secao10GraficosLazyCache[cacheKey]
1965
+ const cachedFigure = useIndices ? cached?.grafico_com_indices : cached?.grafico
1966
+ if (cachedFigure) {
1967
+ const panels = buildScatterPanels(cachedFigure, {
1968
+ singleLabel: 'Dispersão',
1969
+ height: 360,
1970
+ yLabel: colunaY || 'Y',
1971
+ useScatterGlMarkers: Boolean(options.useScatterGlMarkers),
1972
+ })
1973
+ if (panels.length > 0) {
1974
+ return {
1975
+ ...rotularPainelDispersao(panels[0], config, colunaY || 'Y'),
1976
+ id: item?.id || panels[0].id,
1977
+ label: item?.label || panels[0].label,
1978
+ title: item?.title || panels[0].title,
1979
+ onRequestIndexedFigure: () => carregarSecao10GraficoLazy(item?.label || panels[0].label, config, true),
1980
+ }
1981
+ }
1982
+ }
1983
+ return {
1984
+ ...aplicarTransformacoesPainelDispersao(item, config, colunaY || 'Y'),
1985
+ onRequestIndexedFigure: () => carregarSecao10GraficoLazy(item?.label, config, true),
1986
+ }
1987
+ }
1988
+
1989
  const secao10ModoPng = useMemo(
1990
  () => String(selection?.grafico_dispersao_modo || '') === 'png',
1991
  [selection?.grafico_dispersao_modo],
 
2019
  }),
2020
  [selection?.grafico_dispersao_com_indices, colunaY],
2021
  )
2022
+ const graficosSecao9Transformados = useMemo(
2023
+ () => graficosSecao9.map((item) => getSecao10PainelComCache(item)),
2024
+ [graficosSecao9, secao10GraficoTransformacoes, secao10GraficosLazyCache, colunaY],
2025
+ )
2026
+ const graficosSecao9ComIndicesTransformados = useMemo(
2027
+ () => (graficosSecao9ComIndices.length > 0 ? graficosSecao9ComIndices : graficosSecao9)
2028
+ .map((item) => getSecao10PainelComCache(item, true)),
2029
+ [graficosSecao9ComIndices, graficosSecao9, secao10GraficoTransformacoes, secao10GraficosLazyCache, colunaY],
2030
+ )
2031
+ const graficosSecao9ComIndicesTransformadosMap = useMemo(
2032
+ () => buildScatterPanelFigureMap(graficosSecao9ComIndicesTransformados),
2033
+ [graficosSecao9ComIndicesTransformados],
2034
  )
2035
  const graficosSecao9Interativo = useMemo(
2036
  () => buildScatterPanels(secao10InterativoFigura, {
 
2050
  }),
2051
  [secao10InterativoFiguraComIndices, colunaY],
2052
  )
2053
+ const graficosSecao9InterativoTransformados = useMemo(
2054
+ () => graficosSecao9Interativo.map((item) => getSecao10PainelComCache(item, false, { useScatterGlMarkers: true })),
2055
+ [graficosSecao9Interativo, secao10GraficoTransformacoes, secao10GraficosLazyCache, colunaY],
2056
+ )
2057
+ const graficosSecao9InterativoComIndicesTransformados = useMemo(
2058
+ () => (graficosSecao9InterativoComIndices.length > 0 ? graficosSecao9InterativoComIndices : graficosSecao9Interativo)
2059
+ .map((item) => getSecao10PainelComCache(item, true, { useScatterGlMarkers: true })),
2060
+ [graficosSecao9InterativoComIndices, graficosSecao9Interativo, secao10GraficoTransformacoes, secao10GraficosLazyCache, colunaY],
2061
+ )
2062
+ const graficosSecao9InterativoComIndicesTransformadosMap = useMemo(
2063
+ () => buildScatterPanelFigureMap(graficosSecao9InterativoComIndicesTransformados),
2064
+ [graficosSecao9InterativoComIndicesTransformados],
2065
  )
2066
  const secao10InterativoOpcoes = useMemo(() => {
2067
  const labels = graficosSecao9Interativo.length > 0
 
2070
  return Array.from(new Set(labels))
2071
  }, [graficosSecao9Interativo, selection?.contexto?.colunas_x, colunasX])
2072
  const secao10InterativoAtual = useMemo(() => {
2073
+ if (secao10InterativoSelecionado === 'none' || graficosSecao9InterativoTransformados.length === 0) return null
2074
+ return graficosSecao9InterativoTransformados.find((item) => String(item.label || '') === secao10InterativoSelecionado) || graficosSecao9InterativoTransformados[0]
2075
+ }, [graficosSecao9InterativoTransformados, secao10InterativoSelecionado])
2076
  const colunasOriginaisDispersao = useMemo(() => {
2077
  const cols = Array.isArray(dados?.columns) ? dados.columns : []
2078
  return cols
 
2215
  }
2216
  }, [modeloLoadSource])
2217
 
2218
+ useEffect(() => {
2219
+ setSecao10GraficoTransformacoes({})
2220
+ setSecao10GraficosLazyCache({})
2221
+ setSecao10GraficosLazyLoading({})
2222
+ }, [selection?.grafico_dispersao, selection?.grafico_dispersao_png])
2223
+
2224
  const onDisabledHintEnter = useCallback((event, showHint, hintText) => {
2225
  if (!showHint || !hintText || typeof window === 'undefined') {
2226
  setDisabledHint(null)
 
4629
  }
4630
  }
4631
 
4632
+ async function carregarSecao10GraficoLazy(label, config, includeIndices = true) {
4633
+ const safeLabel = getScatterTransformKey(label)
4634
+ if (!safeLabel || !sessionId) return null
4635
+ const safeConfig = {
4636
+ x: normalizarTransformacaoGrafico(config?.x),
4637
+ y: normalizarTransformacaoGrafico(config?.y),
4638
+ }
4639
+ const cacheKey = buildScatterLazyCacheKey(safeLabel, safeConfig)
4640
+ const cached = secao10GraficosLazyCache[cacheKey]
4641
+ if (cached?.grafico && (!includeIndices || cached?.grafico_com_indices)) {
4642
+ return includeIndices ? cached.grafico_com_indices : cached.grafico
4643
+ }
4644
+
4645
+ setSecao10GraficosLazyLoading((prev) => ({ ...(prev || {}), [cacheKey]: true }))
4646
+ try {
4647
+ const resp = await api.getDispersaoInterativo(sessionId, 'secao10', {
4648
+ coluna_x: safeLabel,
4649
+ transformacao_x: safeConfig.x,
4650
+ transformacao_y: safeConfig.y,
4651
+ })
4652
+ const nextEntry = {
4653
+ grafico: resp?.grafico || null,
4654
+ grafico_com_indices: resp?.grafico_com_indices || null,
4655
+ }
4656
+ setSecao10GraficosLazyCache((prev) => ({
4657
+ ...(prev || {}),
4658
+ [cacheKey]: nextEntry,
4659
+ }))
4660
+ return includeIndices ? nextEntry.grafico_com_indices : nextEntry.grafico
4661
+ } catch (err) {
4662
+ setError(err?.message || 'Falha ao carregar gráfico transformado.')
4663
+ return null
4664
+ } finally {
4665
+ setSecao10GraficosLazyLoading((prev) => {
4666
+ const next = { ...(prev || {}) }
4667
+ delete next[cacheKey]
4668
+ return next
4669
+ })
4670
+ }
4671
+ }
4672
+
4673
+ const onChangeSecao10TransformacaoGrafico = useCallback((label, eixo, value) => {
4674
+ const key = getScatterTransformKey(label)
4675
+ if (!key) return
4676
+ const axis = String(eixo || '').toLowerCase() === 'y' ? 'y' : 'x'
4677
+ const nextValue = normalizarTransformacaoGrafico(value)
4678
+ const current = getScatterTransformConfig(secao10GraficoTransformacoes, key)
4679
+ const nextConfigToLoad = { ...current, [axis]: nextValue }
4680
+ setSecao10GraficoTransformacoes((prev) => {
4681
+ const current = getScatterTransformConfig(prev, key)
4682
+ const nextConfig = { ...current, [axis]: nextValue }
4683
+ const next = { ...(prev || {}) }
4684
+ if (
4685
+ nextConfig.x === TRANSFORMACAO_GRAFICO_PADRAO &&
4686
+ nextConfig.y === TRANSFORMACAO_GRAFICO_PADRAO
4687
+ ) {
4688
+ delete next[key]
4689
+ } else {
4690
+ next[key] = nextConfig
4691
+ }
4692
+ return next
4693
+ })
4694
+ void carregarSecao10GraficoLazy(key, nextConfigToLoad, true)
4695
+ }, [secao10GraficoTransformacoes, sessionId, secao10GraficosLazyCache])
4696
+
4697
  async function ensureSecao10GraficoComIndices() {
4698
  const figuraAtual = selection?.grafico_dispersao_com_indices || null
4699
  if (figuraAtual) {
 
6042
  <PlotFigure
6043
  key={`s9-interativo-plot-${secao10InterativoAtual.id}`}
6044
  figure={secao10InterativoAtual.figure}
6045
+ indexedFigure={graficosSecao9InterativoComIndicesTransformadosMap.get(String(secao10InterativoAtual.label || '').trim()) || null}
6046
+ onRequestIndexedFigure={secao10InterativoAtual.onRequestIndexedFigure || ensureSecao10GraficoComIndices}
6047
  title={secao10InterativoAtual.title}
6048
  subtitle={secao10InterativoAtual.subtitle}
6049
  showPointIndexToggle
6050
  forceHideLegend
6051
  className="plot-stretch"
6052
  lazy
6053
+ revisionKey={secao10InterativoAtual.revisionKey}
6054
  headerActions={(
6055
+ <ScatterTransformControls
6056
+ label={secao10InterativoAtual.label}
6057
+ config={getScatterTransformConfig(secao10GraficoTransformacoes, secao10InterativoAtual.label)}
6058
+ onChange={onChangeSecao10TransformacaoGrafico}
6059
+ disabled={loading}
6060
+ />
6061
+ )}
6062
+ trailingHeaderActions={(
6063
+ <DownloadIconButton
6064
+ title={secao10InterativoAtual?.legenda || 'Fazer download'}
6065
+ ariaLabel={`Fazer download de ${secao10InterativoAtual?.legenda || secao10InterativoAtual?.label || 'gráfico'}`}
6066
  onClick={() => {
6067
  void onDownloadFigurePng(
6068
  secao10InterativoAtual.figure,
 
6071
  )
6072
  }}
6073
  disabled={loading || downloadingAssets || !secao10InterativoAtual?.figure}
6074
+ />
 
 
6075
  )}
6076
  />
6077
  ) : (
 
6082
  </>
6083
  ) : (
6084
  <>
6085
+ {graficosSecao9Transformados.length > 0 ? (
6086
  <ScatterPlotCarousel
6087
+ items={graficosSecao9Transformados}
6088
+ indexedFigureMap={graficosSecao9ComIndicesTransformadosMap}
6089
  onRequestIndexedFigure={ensureSecao10GraficoComIndices}
6090
  itemKeyPrefix="s9-plot"
6091
  sectionFilePrefix="secao10"
6092
  onDownloadFigure={onDownloadFigurePng}
6093
+ renderHeaderActions={({ item }) => (
6094
+ <ScatterTransformControls
6095
+ label={item.label}
6096
+ config={getScatterTransformConfig(secao10GraficoTransformacoes, item.label)}
6097
+ onChange={onChangeSecao10TransformacaoGrafico}
6098
+ disabled={loading}
6099
+ />
6100
+ )}
6101
+ renderTrailingHeaderActions={({ item, fileNameBase, downloadOptions }) => (
6102
+ <DownloadIconButton
6103
+ title={item.legenda || item.label || 'Fazer download'}
6104
+ ariaLabel={`Fazer download de ${item.legenda || item.label || 'gráfico'}`}
6105
+ onClick={() => onDownloadFigurePng(item.figure, fileNameBase, downloadOptions)}
6106
+ disabled={loading || downloadingAssets || !item.figure}
6107
+ />
6108
+ )}
6109
  onDownloadAll={() => onDownloadFiguresPngBatch(
6110
+ graficosSecao9Transformados.map((item, idx) => ({
6111
  figure: item.figure,
6112
  fileNameBase: `secao10_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
6113
  forceHideLegend: true,
 
6578
  forceHideLegend
6579
  className="plot-stretch"
6580
  lazy
6581
+ trailingHeaderActions={(
6582
+ <DownloadIconButton
6583
+ title={secao13InterativoAtual?.legenda || 'Fazer download'}
6584
+ ariaLabel={`Fazer download de ${secao13InterativoAtual?.legenda || secao13InterativoAtual?.label || 'gráfico'}`}
 
6585
  onClick={() => {
6586
  void onDownloadFigurePng(
6587
  secao13InterativoAtual.figure,
 
6590
  )
6591
  }}
6592
  disabled={loading || downloadingAssets || !secao13InterativoAtual?.figure}
6593
+ />
 
 
6594
  )}
6595
  />
6596
  ) : (
 
6630
  <div className="section14-tabs" role="tablist" aria-label="Conteúdos do diagnóstico do modelo">
6631
  {[
6632
  { id: 'diagnosticos', label: 'Diagnósticos' },
6633
+ { id: 'testes', label: 'Resultados Gerais' },
6634
+ { id: 'coeficientes', label: 'Resultados por Variável' },
6635
  { id: 'equacoes', label: 'Equações' },
6636
  ].map((tab) => (
6637
  <button
 
6679
  {section14Tab === 'coeficientes' ? (
6680
  <div className="section14-table-panel">
6681
  <div className="section14-table-head">
6682
+ <h4>Resultados por Variável</h4>
6683
  <button
6684
  type="button"
6685
  className="btn-download-subtle"
 
6746
  alt={`${item.title} em PNG`}
6747
  className="plot-stretch"
6748
  headerActions={(
6749
+ <DownloadIconButton
6750
+ title={`Fazer download de ${item.title}`}
6751
+ ariaLabel={`Fazer download de ${item.title}`}
6752
  onClick={() => onDownloadPngBase64(item.pngPayload?.image_base64, item.pngPayload?.mime_type, fileNameBase)}
6753
  disabled={loading || downloadingAssets || !item.pngPayload?.image_base64}
6754
+ />
 
 
6755
  )}
6756
  />
6757
  ) : (
 
6761
  className={item.className}
6762
  lazy
6763
  headerActions={(
6764
+ <DownloadIconButton
6765
+ title={`Fazer download de ${item.title}`}
6766
+ ariaLabel={`Fazer download de ${item.title}`}
6767
  onClick={() => onDownloadFigurePng(item.figure, fileNameBase)}
6768
  disabled={loading || downloadingAssets || !item.figure}
6769
+ />
 
 
6770
  )}
6771
  />
6772
  )
 
6825
  forceHideLegend={secao15InterativoSelecionado === 'cook'}
6826
  className="plot-stretch"
6827
  lazy
6828
+ trailingHeaderActions={(
6829
+ <DownloadIconButton
6830
+ title={`Fazer download de ${secao15InterativoLabel}`}
6831
+ ariaLabel={`Fazer download de ${secao15InterativoLabel}`}
6832
  onClick={() => {
6833
  if (!secao15InterativoFigura) return
6834
  void onDownloadFigurePng(
 
6838
  )
6839
  }}
6840
  disabled={loading || downloadingAssets || !secao15InterativoFigura}
6841
+ />
 
 
6842
  )}
6843
  />
6844
  ) : (
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -54,10 +54,10 @@ const PESQUISA_INNER_TABS = [
54
  { key: 'mapa', label: 'Mapa' },
55
  { key: 'trabalhos_tecnicos', label: 'Trabalhos Técnicos' },
56
  { key: 'dados_mercado', label: 'Dados de Mercado' },
57
- { key: 'metricas', label: 'Métricas' },
58
  { key: 'transformacoes', label: 'Transformações' },
59
- { key: 'resumo', label: 'Resumo' },
60
- { key: 'coeficientes', label: 'Coeficientes' },
61
  { key: 'obs_calc', label: 'Obs x Calc' },
62
  { key: 'graficos', label: 'Gráficos' },
63
  ]
@@ -1898,7 +1898,7 @@ export default function PesquisaTab({
1898
  ? (modeloAbertoLoadedTabs.dados_mercado ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>)
1899
  : null}
1900
  {modeloAbertoActiveTab === 'metricas'
1901
- ? (modeloAbertoLoadedTabs.metricas ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : <div className="empty-box">Carregando métricas do modelo...</div>)
1902
  : null}
1903
 
1904
  {modeloAbertoActiveTab === 'transformacoes' ? (
@@ -1927,12 +1927,12 @@ export default function PesquisaTab({
1927
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
1928
  </>
1929
  ) : (
1930
- <div className="empty-box">Carregando resumo do modelo...</div>
1931
  )
1932
  ) : null}
1933
 
1934
  {modeloAbertoActiveTab === 'coeficientes'
1935
- ? (modeloAbertoLoadedTabs.coeficientes ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : <div className="empty-box">Carregando coeficientes do modelo...</div>)
1936
  : null}
1937
  {modeloAbertoActiveTab === 'obs_calc'
1938
  ? (modeloAbertoLoadedTabs.obs_calc ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>)
 
54
  { key: 'mapa', label: 'Mapa' },
55
  { key: 'trabalhos_tecnicos', label: 'Trabalhos Técnicos' },
56
  { key: 'dados_mercado', label: 'Dados de Mercado' },
57
+ { key: 'metricas', label: 'Estatísticas' },
58
  { key: 'transformacoes', label: 'Transformações' },
59
+ { key: 'resumo', label: 'Resultados Gerais' },
60
+ { key: 'coeficientes', label: 'Resultados por variável' },
61
  { key: 'obs_calc', label: 'Obs x Calc' },
62
  { key: 'graficos', label: 'Gráficos' },
63
  ]
 
1898
  ? (modeloAbertoLoadedTabs.dados_mercado ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>)
1899
  : null}
1900
  {modeloAbertoActiveTab === 'metricas'
1901
+ ? (modeloAbertoLoadedTabs.metricas ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : <div className="empty-box">Carregando estatísticas do modelo...</div>)
1902
  : null}
1903
 
1904
  {modeloAbertoActiveTab === 'transformacoes' ? (
 
1927
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
1928
  </>
1929
  ) : (
1930
+ <div className="empty-box">Carregando resultados gerais do modelo...</div>
1931
  )
1932
  ) : null}
1933
 
1934
  {modeloAbertoActiveTab === 'coeficientes'
1935
+ ? (modeloAbertoLoadedTabs.coeficientes ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : <div className="empty-box">Carregando resultados por variável do modelo...</div>)
1936
  : null}
1937
  {modeloAbertoActiveTab === 'obs_calc'
1938
  ? (modeloAbertoLoadedTabs.obs_calc ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>)
frontend/src/components/PlotFigure.jsx CHANGED
@@ -13,6 +13,8 @@ function PlotFigure({
13
  lazy = false,
14
  showPointIndexToggle = false,
15
  headerActions = null,
 
 
16
  }) {
17
  const containerRef = useRef(null)
18
  const [shouldRenderPlot, setShouldRenderPlot] = useState(() => !lazy)
@@ -82,6 +84,7 @@ function PlotFigure({
82
  }
83
 
84
  const plotHeight = layout.height ? `${layout.height}px` : '100%'
 
85
  const cardClassName = `plot-card ${className}`.trim()
86
 
87
  async function handleToggleChange(event) {
@@ -116,13 +119,14 @@ function PlotFigure({
116
 
117
  return (
118
  <div ref={containerRef} className={cardClassName}>
119
- {title || subtitle || showPointIndexToggle || headerActions ? (
120
  <div className="plot-card-head">
121
  <div className="plot-card-head-main">
122
  {title ? <h4 className="plot-card-title">{title}</h4> : null}
123
  {subtitle ? <div className="plot-card-subtitle">{subtitle}</div> : null}
124
  </div>
125
  <div className="plot-card-head-actions">
 
126
  {showPointIndexToggle ? (
127
  <label className={`plot-card-toggle${canToggleIndices ? '' : ' is-disabled'}`}>
128
  <input
@@ -132,10 +136,10 @@ function PlotFigure({
132
  onChange={handleToggleChange}
133
  title={toggleTitle}
134
  />
135
- {loadingIndexedFigure ? 'Carregando índices...' : 'Exibir índices dos pontos'}
136
  </label>
137
  ) : null}
138
- {headerActions}
139
  </div>
140
  </div>
141
  ) : null}
@@ -144,7 +148,7 @@ function PlotFigure({
144
  <Plot
145
  data={data}
146
  layout={layout}
147
- revision={showPointIndices && hasIndexedFigure ? 1 : 0}
148
  config={{ responsive: true, displaylogo: false }}
149
  style={{ width: '100%', height: plotHeight, minHeight: '320px' }}
150
  useResizeHandler
 
13
  lazy = false,
14
  showPointIndexToggle = false,
15
  headerActions = null,
16
+ trailingHeaderActions = null,
17
+ revisionKey = '',
18
  }) {
19
  const containerRef = useRef(null)
20
  const [shouldRenderPlot, setShouldRenderPlot] = useState(() => !lazy)
 
84
  }
85
 
86
  const plotHeight = layout.height ? `${layout.height}px` : '100%'
87
+ const plotRevision = `${showPointIndices && hasIndexedFigure ? 'indices' : 'base'}:${String(revisionKey || '')}`
88
  const cardClassName = `plot-card ${className}`.trim()
89
 
90
  async function handleToggleChange(event) {
 
119
 
120
  return (
121
  <div ref={containerRef} className={cardClassName}>
122
+ {title || subtitle || showPointIndexToggle || headerActions || trailingHeaderActions ? (
123
  <div className="plot-card-head">
124
  <div className="plot-card-head-main">
125
  {title ? <h4 className="plot-card-title">{title}</h4> : null}
126
  {subtitle ? <div className="plot-card-subtitle">{subtitle}</div> : null}
127
  </div>
128
  <div className="plot-card-head-actions">
129
+ {headerActions}
130
  {showPointIndexToggle ? (
131
  <label className={`plot-card-toggle${canToggleIndices ? '' : ' is-disabled'}`}>
132
  <input
 
136
  onChange={handleToggleChange}
137
  title={toggleTitle}
138
  />
139
+ {loadingIndexedFigure ? 'Carregando...' : 'Índices'}
140
  </label>
141
  ) : null}
142
+ {trailingHeaderActions}
143
  </div>
144
  </div>
145
  ) : null}
 
148
  <Plot
149
  data={data}
150
  layout={layout}
151
+ revision={plotRevision}
152
  config={{ responsive: true, displaylogo: false }}
153
  style={{ width: '100%', height: plotHeight, minHeight: '320px' }}
154
  useResizeHandler
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -19,10 +19,10 @@ const REPO_INNER_TABS = [
19
  { key: 'mapa', label: 'Mapa' },
20
  { key: 'trabalhos_tecnicos', label: 'Trabalhos Técnicos' },
21
  { key: 'dados_mercado', label: 'Dados de Mercado' },
22
- { key: 'metricas', label: 'Métricas' },
23
  { key: 'transformacoes', label: 'Transformações' },
24
- { key: 'resumo', label: 'Resumo' },
25
- { key: 'coeficientes', label: 'Coeficientes' },
26
  { key: 'obs_calc', label: 'Obs x Calc' },
27
  { key: 'graficos', label: 'Gráficos' },
28
  ]
@@ -117,13 +117,13 @@ function getModeloAbertoLoadingLabel(tabKey) {
117
  case 'dados_mercado':
118
  return 'Carregando dados de mercado...'
119
  case 'metricas':
120
- return 'Carregando métricas do modelo...'
121
  case 'transformacoes':
122
  return 'Carregando transformações do modelo...'
123
  case 'resumo':
124
- return 'Carregando resumo do modelo...'
125
  case 'coeficientes':
126
- return 'Carregando coeficientes do modelo...'
127
  case 'obs_calc':
128
  return 'Carregando observados x calculados...'
129
  case 'graficos':
@@ -711,7 +711,7 @@ export default function RepositorioTab({
711
  ? (activeTabLoaded ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>)
712
  : null}
713
  {modeloAbertoActiveTab === 'metricas'
714
- ? (activeTabLoaded ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : <div className="empty-box">Carregando métricas do modelo...</div>)
715
  : null}
716
 
717
  {modeloAbertoActiveTab === 'transformacoes' ? (
@@ -740,12 +740,12 @@ export default function RepositorioTab({
740
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
741
  </>
742
  ) : (
743
- <div className="empty-box">Carregando resumo do modelo...</div>
744
  )
745
  ) : null}
746
 
747
  {modeloAbertoActiveTab === 'coeficientes'
748
- ? (activeTabLoaded ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : <div className="empty-box">Carregando coeficientes do modelo...</div>)
749
  : null}
750
  {modeloAbertoActiveTab === 'obs_calc'
751
  ? (activeTabLoaded ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>)
 
19
  { key: 'mapa', label: 'Mapa' },
20
  { key: 'trabalhos_tecnicos', label: 'Trabalhos Técnicos' },
21
  { key: 'dados_mercado', label: 'Dados de Mercado' },
22
+ { key: 'metricas', label: 'Estatísticas' },
23
  { key: 'transformacoes', label: 'Transformações' },
24
+ { key: 'resumo', label: 'Resultados Gerais' },
25
+ { key: 'coeficientes', label: 'Resultados por variável' },
26
  { key: 'obs_calc', label: 'Obs x Calc' },
27
  { key: 'graficos', label: 'Gráficos' },
28
  ]
 
117
  case 'dados_mercado':
118
  return 'Carregando dados de mercado...'
119
  case 'metricas':
120
+ return 'Carregando estatísticas do modelo...'
121
  case 'transformacoes':
122
  return 'Carregando transformações do modelo...'
123
  case 'resumo':
124
+ return 'Carregando resultados gerais do modelo...'
125
  case 'coeficientes':
126
+ return 'Carregando resultados por variável do modelo...'
127
  case 'obs_calc':
128
  return 'Carregando observados x calculados...'
129
  case 'graficos':
 
711
  ? (activeTabLoaded ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>)
712
  : null}
713
  {modeloAbertoActiveTab === 'metricas'
714
+ ? (activeTabLoaded ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : <div className="empty-box">Carregando estatísticas do modelo...</div>)
715
  : null}
716
 
717
  {modeloAbertoActiveTab === 'transformacoes' ? (
 
740
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
741
  </>
742
  ) : (
743
+ <div className="empty-box">Carregando resultados gerais do modelo...</div>
744
  )
745
  ) : null}
746
 
747
  {modeloAbertoActiveTab === 'coeficientes'
748
+ ? (activeTabLoaded ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : <div className="empty-box">Carregando resultados por variável do modelo...</div>)
749
  : null}
750
  {modeloAbertoActiveTab === 'obs_calc'
751
  ? (activeTabLoaded ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>)
frontend/src/components/TrabalhosTecnicosTab.jsx CHANGED
@@ -5,6 +5,7 @@ import ListPagination from './ListPagination'
5
  import LoadingOverlay from './LoadingOverlay'
6
  import MapFrame from './MapFrame'
7
  import ShareLinkButton from './ShareLinkButton'
 
8
  import TruncatedCellContent from './TruncatedCellContent'
9
 
10
  const PAGE_SIZE = 50
@@ -46,8 +47,25 @@ function dedupeTextList(values) {
46
  return output
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  function listarProcessosSei(item) {
50
  const processosArray = dedupeTextList([
 
51
  ...(Array.isArray(item?.processos_sei) ? item.processos_sei : []),
52
  ...(Array.isArray(item?.processos_administrativos) ? item.processos_administrativos : []),
53
  ])
@@ -64,12 +82,88 @@ function listarProcessosSei(item) {
64
  return ['Nenhum registrado']
65
  }
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  function formatarCoordenada(value) {
68
  const number = Number(value)
69
  if (!Number.isFinite(number)) return 'Nao informada'
70
  return number.toLocaleString('pt-BR', { maximumFractionDigits: 6 })
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  function parseNullableNumber(value) {
74
  const text = String(value ?? '').replace(',', '.').trim()
75
  if (!text) return null
@@ -109,6 +203,8 @@ function buildCadastroState() {
109
  tipo_codigo: '',
110
  ano: '',
111
  processos_sei_text: '',
 
 
112
  modelos: [],
113
  imoveis: [buildEmptyImovel('novo-trabalho', 1)],
114
  }
@@ -130,31 +226,36 @@ function buildEditState(trabalho) {
130
  : [
131
  buildEmptyImovel(trabalho?.id || 'trabalho', 1),
132
  ]
 
133
 
134
  return {
135
  nome: String(trabalho?.nome || '').trim(),
136
  tipo_codigo: String(trabalho?.tipo_codigo || '').trim().toUpperCase(),
137
  ano: trabalho?.ano ?? '',
138
  processos_sei_text: listarProcessosSei(trabalho).filter((value) => value !== 'Nenhum registrado').join('\n'),
 
 
139
  modelos,
140
  imoveis,
141
  }
142
  }
143
 
144
  function buildTrabalhoPayload(edicao) {
 
 
 
 
 
 
 
 
145
  return {
146
  nome: edicao.nome,
147
  tipo_codigo: edicao.tipo_codigo,
148
  ano: parseNullableInt(edicao.ano),
149
  processos_sei: splitTextareaLines(edicao.processos_sei_text),
150
  modelos: dedupeTextList(edicao.modelos),
151
- imoveis: (Array.isArray(edicao.imoveis) ? edicao.imoveis : []).map((item) => ({
152
- label: item.label,
153
- endereco: item.endereco,
154
- numero: item.numero,
155
- coord_x: parseNullableNumber(item.coord_x),
156
- coord_y: parseNullableNumber(item.coord_y),
157
- })),
158
  }
159
  }
160
 
@@ -164,11 +265,16 @@ function toListItemFromDetail(trabalho) {
164
  return {
165
  id: String(trabalho?.id || ''),
166
  nome: String(trabalho?.nome || '').trim(),
 
 
167
  tipo_codigo: String(trabalho?.tipo_codigo || '').trim(),
168
  tipo_label: String(trabalho?.tipo_label || '').trim(),
169
  ano: trabalho?.ano ?? '',
170
  endereco_resumo: String(trabalho?.endereco_resumo || '').trim(),
171
  modelo_resumo: String(trabalho?.modelo_resumo || '').trim(),
 
 
 
172
  total_registros_planilha: Number(trabalho?.total_registros_planilha || 0),
173
  total_imoveis: Number(trabalho?.total_imoveis || 0),
174
  total_modelos: Number(trabalho?.total_modelos || 0),
@@ -176,6 +282,7 @@ function toListItemFromDetail(trabalho) {
176
  modelo_mesa_principal: modelosMesa[0]?.mesa_modelo_nome || null,
177
  tem_coordenadas: Boolean(trabalho?.tem_coordenadas),
178
  modelos,
 
179
  processos_sei: listarProcessosSei(trabalho).filter((value) => value !== 'Nenhum registrado'),
180
  }
181
  }
@@ -221,6 +328,14 @@ export default function TrabalhosTecnicosTab({
221
  const [modeloDraft, setModeloDraft] = useState('')
222
  const [modeloSugestoesMesa, setModeloSugestoesMesa] = useState([])
223
  const [modeloSugestoesLoading, setModeloSugestoesLoading] = useState(false)
 
 
 
 
 
 
 
 
224
  const [origemAbertura, setOrigemAbertura] = useState('lista')
225
  const [abrindoTrabalhoId, setAbrindoTrabalhoId] = useState('')
226
  const quickOpenHandledRef = useRef('')
@@ -487,6 +602,7 @@ export default function TrabalhosTecnicosTab({
487
  setCadastrando(false)
488
  setEdicao(null)
489
  setModeloDraft('')
 
490
  if (options.syncRoute !== false && typeof onRouteChange === 'function') {
491
  onRouteChange({
492
  tab: 'trabalhos',
@@ -535,6 +651,7 @@ export default function TrabalhosTecnicosTab({
535
  })
536
  setEdicao(draft)
537
  setModeloDraft('')
 
538
  setEdicaoErro('')
539
  setTrabalhoError('')
540
  setOrigemAbertura('lista')
@@ -568,6 +685,7 @@ export default function TrabalhosTecnicosTab({
568
  setEdicao(buildEditState(trabalhoAberto))
569
  setEdicaoErro('')
570
  setModeloDraft('')
 
571
  setEditando(true)
572
  void carregarSugestoesModelosMesa()
573
  }
@@ -582,17 +700,117 @@ export default function TrabalhosTecnicosTab({
582
  setEdicao(null)
583
  setEdicaoErro('')
584
  setModeloDraft('')
 
585
  }
586
 
587
  function atualizarCampoEdicao(chave, valor) {
588
  setEdicao((prev) => (prev ? { ...prev, [chave]: valor } : prev))
589
  }
590
 
591
- function atualizarImovel(index, chave, valor) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  setEdicao((prev) => {
593
  if (!prev) return prev
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  const proximosImoveis = prev.imoveis.map((item, itemIndex) => (
595
- itemIndex === index ? { ...item, [chave]: valor } : item
 
 
596
  ))
597
  return { ...prev, imoveis: proximosImoveis }
598
  })
@@ -714,6 +932,9 @@ export default function TrabalhosTecnicosTab({
714
  item?.endereco_resumo,
715
  item?.modelo_resumo,
716
  item?.modelo_mesa_principal,
 
 
 
717
  ...processosSei,
718
  ...(Array.isArray(item?.modelos) ? item.modelos.map((modelo) => modelo?.nome) : []),
719
  ]
@@ -769,7 +990,10 @@ export default function TrabalhosTecnicosTab({
769
  const modelos = Array.isArray(trabalhoAberto?.modelos) ? trabalhoAberto.modelos : []
770
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
771
  const imoveis = Array.isArray(trabalhoAberto?.imoveis) ? trabalhoAberto.imoveis : []
772
- const processosSei = listarProcessosSei(trabalhoAberto)
 
 
 
773
  const shareHref = !isCadastro && trabalhoAberto?.id ? buildTrabalhoTecnicoLink(trabalhoAberto.id) : ''
774
 
775
  return (
@@ -828,48 +1052,64 @@ export default function TrabalhosTecnicosTab({
828
 
829
  <div className="trabalho-tecnico-summary-grid">
830
  <section className="trabalho-tecnico-card trabalho-tecnico-card-primary">
831
- <h4>Dados Gerais</h4>
832
  {!editando ? (
833
- <>
834
- <div className="trabalho-tecnico-kpis">
835
- <div className="trabalho-tecnico-kpi">
836
- <span className="trabalho-tecnico-kpi-label">Tipo</span>
837
- <strong>{trabalhoAberto?.tipo_label || '-'}</strong>
838
- </div>
839
- <div className="trabalho-tecnico-kpi">
840
- <span className="trabalho-tecnico-kpi-label">Ano</span>
841
- <strong>{trabalhoAberto?.ano || '-'}</strong>
842
- </div>
843
- <div className="trabalho-tecnico-kpi">
844
- <span className="trabalho-tecnico-kpi-label">Imóveis</span>
845
- <strong>{trabalhoAberto?.total_imoveis ?? 0}</strong>
846
- </div>
847
- <div className="trabalho-tecnico-kpi">
848
- <span className="trabalho-tecnico-kpi-label">Modelos</span>
849
- <strong>{trabalhoAberto?.total_modelos ?? 0}</strong>
850
- </div>
851
- <div className="trabalho-tecnico-kpi">
852
- <span className="trabalho-tecnico-kpi-label">Registros</span>
853
- <strong>{trabalhoAberto?.total_registros_planilha ?? 0}</strong>
854
- </div>
855
  </div>
856
- <div className="trabalho-tecnico-meta-list">
857
- <div className="trabalho-tecnico-meta-row">
858
- <span className="trabalho-tecnico-meta-label">Endereços</span>
859
- <strong>{trabalhoAberto?.endereco_resumo || 'Não informado'}</strong>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
  </div>
861
- <div className="trabalho-tecnico-meta-row trabalho-tecnico-meta-row-block">
862
- <span className="trabalho-tecnico-meta-label">Processos SEI</span>
863
- <div className="trabalho-processos-stack">
864
- {processosSei.map((processo, index) => (
865
- <span key={`${trabalhoAberto?.id || 'trabalho'}-processo-detalhe-${index + 1}`}>
866
- {processo}
867
- </span>
868
- ))}
869
- </div>
870
  </div>
871
  </div>
872
- </>
873
  ) : (
874
  <div className="trabalho-edit-grid">
875
  <label className="trabalho-edit-field">
@@ -911,110 +1151,190 @@ export default function TrabalhosTecnicosTab({
911
  rows={4}
912
  />
913
  </label>
914
- </div>
915
- )}
916
- </section>
917
-
918
- <section className="trabalho-tecnico-card">
919
- <h4>Imóveis Vinculados</h4>
920
- {!editando ? (
921
- imoveis.length ? (
922
- <div className="trabalho-imoveis-stack">
923
- {imoveis.map((item, index) => (
924
- <div key={`${trabalhoAberto?.id || 'trabalho'}-imovel-${index + 1}`} className="trabalho-imovel-card">
925
- <strong>{item?.label || 'Imóvel sem identificação'}</strong>
926
- <span>{[item?.endereco, item?.numero].filter(Boolean).join(', ') || 'Endereço não informado'}</span>
927
- <span className="trabalho-imovel-coords">
928
- Coordenadas: X {formatarCoordenada(item?.coord_x)} | Y {formatarCoordenada(item?.coord_y)}
929
- </span>
930
- {Array.isArray(item?.modelos) && item.modelos.length ? (
931
- <span>{item.modelos.join(', ')}</span>
932
- ) : (
933
- <span>Sem modelo informado</span>
934
- )}
935
- </div>
936
- ))}
937
- </div>
938
- ) : (
939
- <div className="section1-empty-hint">Nenhum imóvel vinculado.</div>
940
- )
941
- ) : (
942
- <div className="trabalho-edit-imoveis-stack">
943
- {Array.isArray(edicao?.imoveis) ? edicao.imoveis.map((item, index) => (
944
- <div key={item.id || `${trabalhoAberto?.id || 'trabalho'}-imovel-edit-${index + 1}`} className="trabalho-edit-imovel-card">
945
- <div className="trabalho-edit-imovel-head">
946
- <strong>Imóvel {index + 1}</strong>
947
- <button
948
- type="button"
949
- className="trabalho-mini-action"
950
- onClick={() => removerImovel(index)}
951
- disabled={(edicao?.imoveis || []).length <= 1 || salvando}
952
- >
953
- Remover
954
- </button>
955
- </div>
956
- <div className="trabalho-edit-imovel-grid">
957
- <label className="trabalho-edit-field">
958
- Identificação
959
  <input
960
- type="text"
961
- value={item.label}
962
- onChange={(event) => atualizarImovel(index, 'label', event.target.value)}
963
- autoComplete="off"
 
964
  />
965
  </label>
966
- <label className="trabalho-edit-field">
967
- Número
968
  <input
969
- type="text"
970
- value={item.numero}
971
- onChange={(event) => atualizarImovel(index, 'numero', event.target.value)}
972
- autoComplete="off"
 
973
  />
974
  </label>
975
- <label className="trabalho-edit-field trabalho-edit-field-full">
976
- Endereço
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
  <input
978
- type="text"
979
- value={item.endereco}
980
- onChange={(event) => atualizarImovel(index, 'endereco', event.target.value)}
981
- autoComplete="off"
982
  />
983
  </label>
984
- <label className="trabalho-edit-field">
985
- Coordenada X
986
- <input
987
- type="text"
988
- value={item.coord_x}
989
- onChange={(event) => atualizarImovel(index, 'coord_x', event.target.value)}
990
- autoComplete="off"
 
 
 
 
 
 
 
 
991
  />
992
  </label>
993
- <label className="trabalho-edit-field">
994
- Coordenada Y
995
  <input
996
- type="text"
997
- value={item.coord_y}
998
- onChange={(event) => atualizarImovel(index, 'coord_y', event.target.value)}
999
- autoComplete="off"
1000
  />
1001
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
  </div>
1003
- </div>
1004
- )) : null}
1005
- <button
1006
- type="button"
1007
- className="trabalho-mini-action trabalho-mini-action-add"
1008
- onClick={adicionarImovel}
1009
- disabled={salvando}
1010
- >
1011
- Adicionar imóvel
1012
- </button>
1013
  </div>
1014
  )}
1015
  </section>
1016
  </div>
1017
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
  <section className="trabalho-tecnico-card">
1019
  <h4>Modelos associados</h4>
1020
  {!editando ? (
@@ -1161,7 +1481,7 @@ export default function TrabalhosTecnicosTab({
1161
  type="text"
1162
  value={filtroTexto}
1163
  onChange={(event) => setFiltroTexto(event.target.value)}
1164
- placeholder="nome, endereço, modelo ou processo"
1165
  autoComplete="off"
1166
  />
1167
  </label>
@@ -1207,7 +1527,7 @@ export default function TrabalhosTecnicosTab({
1207
  <th className="trabalhos-col-nome">Trabalho</th>
1208
  <th className="trabalhos-col-tipo">Tipo</th>
1209
  <th className="trabalhos-col-ano">Ano</th>
1210
- <th className="trabalhos-col-endereco">Endereço</th>
1211
  <th className="trabalhos-col-modelos">Modelos</th>
1212
  <th className="trabalhos-col-processos">Processos SEI</th>
1213
  <th className="repo-col-open">Abrir</th>
@@ -1252,14 +1572,24 @@ export default function TrabalhosTecnicosTab({
1252
  </td>
1253
  <td className="trabalhos-col-processos">
1254
  <div className="trabalho-model-stack">
1255
- {listarProcessosSei(item).map((processo, index) => (
1256
- <div
1257
- key={`${item?.id || 'trabalho'}-processo-${index + 1}`}
1258
- className="trabalho-model-list-item"
1259
- >
1260
- {processo}
1261
- </div>
1262
- ))}
 
 
 
 
 
 
 
 
 
 
1263
  </div>
1264
  </td>
1265
  <td className="repo-col-open">
@@ -1306,7 +1636,7 @@ export default function TrabalhosTecnicosTab({
1306
  type="text"
1307
  value={filtroTexto}
1308
  onChange={(event) => setFiltroTexto(event.target.value)}
1309
- placeholder="nome, endereço, modelo ou processo"
1310
  autoComplete="off"
1311
  />
1312
  </label>
 
5
  import LoadingOverlay from './LoadingOverlay'
6
  import MapFrame from './MapFrame'
7
  import ShareLinkButton from './ShareLinkButton'
8
+ import SinglePillAutocomplete from './SinglePillAutocomplete'
9
  import TruncatedCellContent from './TruncatedCellContent'
10
 
11
  const PAGE_SIZE = 50
 
47
  return output
48
  }
49
 
50
+ function normalizeLogradouroOption(item) {
51
+ if (typeof item === 'string') {
52
+ const text = String(item || '').trim()
53
+ return text ? { value: text, label: text } : null
54
+ }
55
+ if (!item || typeof item !== 'object') return null
56
+ const value = String(item.value ?? item.logradouro ?? '').trim()
57
+ if (!value) return null
58
+ const label = String(item.label ?? item.display_label ?? value).trim() || value
59
+ return {
60
+ value,
61
+ label,
62
+ secondary: String(item.secondary ?? '').trim(),
63
+ }
64
+ }
65
+
66
  function listarProcessosSei(item) {
67
  const processosArray = dedupeTextList([
68
+ ...(Array.isArray(item?.processos_detalhados) ? item.processos_detalhados.map((processo) => processo?.numero) : []),
69
  ...(Array.isArray(item?.processos_sei) ? item.processos_sei : []),
70
  ...(Array.isArray(item?.processos_administrativos) ? item.processos_administrativos : []),
71
  ])
 
82
  return ['Nenhum registrado']
83
  }
84
 
85
+ function listarProcessosDetalhados(item) {
86
+ const detalhes = []
87
+ const vistos = new Set()
88
+ ;(Array.isArray(item?.processos_detalhados) ? item.processos_detalhados : []).forEach((processo) => {
89
+ const numero = String(processo?.numero || processo?.processo || '').trim()
90
+ const key = numero.toLowerCase()
91
+ if (!numero || vistos.has(key)) return
92
+ vistos.add(key)
93
+ detalhes.push({
94
+ numero,
95
+ link: String(processo?.link || processo?.href || '').trim(),
96
+ })
97
+ })
98
+ listarProcessosSei(item).forEach((processo) => {
99
+ const numero = String(processo || '').trim()
100
+ const key = numero.toLowerCase()
101
+ if (!numero || numero === 'Nenhum registrado' || vistos.has(key)) return
102
+ vistos.add(key)
103
+ detalhes.push({ numero, link: '' })
104
+ })
105
+ return detalhes.length ? detalhes : [{ numero: 'Nenhum registrado', link: '' }]
106
+ }
107
+
108
+ function normalizarHrefExterno(value) {
109
+ const text = String(value || '').trim()
110
+ if (!text) return ''
111
+ if (/^(https?:|mailto:|file:)/i.test(text)) return text
112
+ if (/^\\\\/.test(text)) {
113
+ return `file://${text.replace(/^\\\\+/, '').replace(/\\/g, '/')}`
114
+ }
115
+ if (/^[A-Za-z]:\\/.test(text)) {
116
+ return `file:///${text.replace(/\\/g, '/')}`
117
+ }
118
+ return text
119
+ }
120
+
121
  function formatarCoordenada(value) {
122
  const number = Number(value)
123
  if (!Number.isFinite(number)) return 'Nao informada'
124
  return number.toLocaleString('pt-BR', { maximumFractionDigits: 6 })
125
  }
126
 
127
+ function listarCoordenadasImoveis(imoveis) {
128
+ const seen = new Set()
129
+ const coordenadas = []
130
+ ;(Array.isArray(imoveis) ? imoveis : []).forEach((item) => {
131
+ const x = Number(item?.coord_x)
132
+ const y = Number(item?.coord_y)
133
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return
134
+ const key = `${x.toFixed(8)}|${y.toFixed(8)}`
135
+ if (seen.has(key)) return
136
+ seen.add(key)
137
+ coordenadas.push(`X ${formatarCoordenada(x)} | Y ${formatarCoordenada(y)}`)
138
+ })
139
+ return coordenadas
140
+ }
141
+
142
+ function formatarEnderecoImovel(item) {
143
+ const endereco = String(item?.endereco || '').trim()
144
+ const numero = String(item?.numero || '').trim()
145
+ const texto = [endereco, numero].filter(Boolean).join(', ').trim()
146
+ return texto || String(item?.label || '').trim() || 'Endereço não informado'
147
+ }
148
+
149
+ function formatarEnderecoImovelEditavel(item) {
150
+ const endereco = String(item?.endereco || '').trim()
151
+ const numero = String(item?.numero || '').trim()
152
+ const texto = [endereco, numero].filter(Boolean).join(', ').trim()
153
+ return texto || String(item?.label || '').trim()
154
+ }
155
+
156
+ function primeiraCoordenadaValida(imoveis) {
157
+ const item = (Array.isArray(imoveis) ? imoveis : []).find((imovel) => (
158
+ Number.isFinite(Number(imovel?.coord_x)) && Number.isFinite(Number(imovel?.coord_y))
159
+ ))
160
+ if (!item) return { coord_x: '', coord_y: '' }
161
+ return {
162
+ coord_x: item.coord_x ?? '',
163
+ coord_y: item.coord_y ?? '',
164
+ }
165
+ }
166
+
167
  function parseNullableNumber(value) {
168
  const text = String(value ?? '').replace(',', '.').trim()
169
  if (!text) return null
 
203
  tipo_codigo: '',
204
  ano: '',
205
  processos_sei_text: '',
206
+ coord_x: '',
207
+ coord_y: '',
208
  modelos: [],
209
  imoveis: [buildEmptyImovel('novo-trabalho', 1)],
210
  }
 
226
  : [
227
  buildEmptyImovel(trabalho?.id || 'trabalho', 1),
228
  ]
229
+ const coordenadas = primeiraCoordenadaValida(imoveis)
230
 
231
  return {
232
  nome: String(trabalho?.nome || '').trim(),
233
  tipo_codigo: String(trabalho?.tipo_codigo || '').trim().toUpperCase(),
234
  ano: trabalho?.ano ?? '',
235
  processos_sei_text: listarProcessosSei(trabalho).filter((value) => value !== 'Nenhum registrado').join('\n'),
236
+ coord_x: coordenadas.coord_x,
237
+ coord_y: coordenadas.coord_y,
238
  modelos,
239
  imoveis,
240
  }
241
  }
242
 
243
  function buildTrabalhoPayload(edicao) {
244
+ const imoveisBase = Array.isArray(edicao.imoveis) ? edicao.imoveis : []
245
+ const imoveisPayload = (imoveisBase.length ? imoveisBase : [buildEmptyImovel('trabalho', 1)]).map((item, index) => ({
246
+ label: item.label,
247
+ endereco: item.endereco,
248
+ numero: item.numero,
249
+ coord_x: index === 0 ? parseNullableNumber(edicao.coord_x) : parseNullableNumber(item.coord_x),
250
+ coord_y: index === 0 ? parseNullableNumber(edicao.coord_y) : parseNullableNumber(item.coord_y),
251
+ }))
252
  return {
253
  nome: edicao.nome,
254
  tipo_codigo: edicao.tipo_codigo,
255
  ano: parseNullableInt(edicao.ano),
256
  processos_sei: splitTextareaLines(edicao.processos_sei_text),
257
  modelos: dedupeTextList(edicao.modelos),
258
+ imoveis: imoveisPayload,
 
 
 
 
 
 
259
  }
260
  }
261
 
 
265
  return {
266
  id: String(trabalho?.id || ''),
267
  nome: String(trabalho?.nome || '').trim(),
268
+ pasta_nome: String(trabalho?.pasta_nome || '').trim(),
269
+ pasta_link: String(trabalho?.pasta_link || '').trim(),
270
  tipo_codigo: String(trabalho?.tipo_codigo || '').trim(),
271
  tipo_label: String(trabalho?.tipo_label || '').trim(),
272
  ano: trabalho?.ano ?? '',
273
  endereco_resumo: String(trabalho?.endereco_resumo || '').trim(),
274
  modelo_resumo: String(trabalho?.modelo_resumo || '').trim(),
275
+ tecnico_resumo: String(trabalho?.tecnico_resumo || '').trim(),
276
+ processo_resumo: String(trabalho?.processo_resumo || '').trim(),
277
+ finalidade_processo_resumo: String(trabalho?.finalidade_processo_resumo || '').trim(),
278
  total_registros_planilha: Number(trabalho?.total_registros_planilha || 0),
279
  total_imoveis: Number(trabalho?.total_imoveis || 0),
280
  total_modelos: Number(trabalho?.total_modelos || 0),
 
282
  modelo_mesa_principal: modelosMesa[0]?.mesa_modelo_nome || null,
283
  tem_coordenadas: Boolean(trabalho?.tem_coordenadas),
284
  modelos,
285
+ processos_detalhados: Array.isArray(trabalho?.processos_detalhados) ? trabalho.processos_detalhados : [],
286
  processos_sei: listarProcessosSei(trabalho).filter((value) => value !== 'Nenhum registrado'),
287
  }
288
  }
 
328
  const [modeloDraft, setModeloDraft] = useState('')
329
  const [modeloSugestoesMesa, setModeloSugestoesMesa] = useState([])
330
  const [modeloSugestoesLoading, setModeloSugestoesLoading] = useState(false)
331
+ const [trabalhoGeoModo, setTrabalhoGeoModo] = useState('endereco')
332
+ const [trabalhoGeoInputs, setTrabalhoGeoInputs] = useState(EMPTY_LOCATION_INPUTS)
333
+ const [trabalhoGeoLoading, setTrabalhoGeoLoading] = useState(false)
334
+ const [trabalhoGeoError, setTrabalhoGeoError] = useState('')
335
+ const [trabalhoGeoStatus, setTrabalhoGeoStatus] = useState('')
336
+ const [trabalhoLogradouroOptions, setTrabalhoLogradouroOptions] = useState([])
337
+ const [trabalhoLogradouroOptionsLoading, setTrabalhoLogradouroOptionsLoading] = useState(false)
338
+ const [trabalhoLogradouroOptionsLoaded, setTrabalhoLogradouroOptionsLoaded] = useState(false)
339
  const [origemAbertura, setOrigemAbertura] = useState('lista')
340
  const [abrindoTrabalhoId, setAbrindoTrabalhoId] = useState('')
341
  const quickOpenHandledRef = useRef('')
 
602
  setCadastrando(false)
603
  setEdicao(null)
604
  setModeloDraft('')
605
+ limparFormularioTrabalhoGeo()
606
  if (options.syncRoute !== false && typeof onRouteChange === 'function') {
607
  onRouteChange({
608
  tab: 'trabalhos',
 
651
  })
652
  setEdicao(draft)
653
  setModeloDraft('')
654
+ limparFormularioTrabalhoGeo()
655
  setEdicaoErro('')
656
  setTrabalhoError('')
657
  setOrigemAbertura('lista')
 
685
  setEdicao(buildEditState(trabalhoAberto))
686
  setEdicaoErro('')
687
  setModeloDraft('')
688
+ limparFormularioTrabalhoGeo()
689
  setEditando(true)
690
  void carregarSugestoesModelosMesa()
691
  }
 
700
  setEdicao(null)
701
  setEdicaoErro('')
702
  setModeloDraft('')
703
+ limparFormularioTrabalhoGeo()
704
  }
705
 
706
  function atualizarCampoEdicao(chave, valor) {
707
  setEdicao((prev) => (prev ? { ...prev, [chave]: valor } : prev))
708
  }
709
 
710
+ function limparFormularioTrabalhoGeo() {
711
+ setTrabalhoGeoModo('endereco')
712
+ setTrabalhoGeoInputs(EMPTY_LOCATION_INPUTS)
713
+ setTrabalhoGeoError('')
714
+ setTrabalhoGeoStatus('')
715
+ }
716
+
717
+ function atualizarCampoTrabalhoGeo(field, value) {
718
+ if (!field) return
719
+ setTrabalhoGeoInputs((prev) => ({ ...prev, [field]: value }))
720
+ setTrabalhoGeoError('')
721
+ setTrabalhoGeoStatus('')
722
+ }
723
+
724
+ async function carregarSugestoesLogradouroTrabalho() {
725
+ if (trabalhoLogradouroOptionsLoading || trabalhoLogradouroOptionsLoaded) return
726
+ setTrabalhoLogradouroOptionsLoading(true)
727
+ try {
728
+ const response = await api.pesquisarLogradourosEixos()
729
+ const opcoes = Array.isArray(response?.logradouros_eixos)
730
+ ? response.logradouros_eixos.map(normalizeLogradouroOption).filter(Boolean)
731
+ : []
732
+ setTrabalhoLogradouroOptions(opcoes)
733
+ setTrabalhoLogradouroOptionsLoaded(true)
734
+ } catch {
735
+ setTrabalhoLogradouroOptions([])
736
+ } finally {
737
+ setTrabalhoLogradouroOptionsLoading(false)
738
+ }
739
+ }
740
+
741
+ function aplicarCoordenadasTrabalho(lat, lon) {
742
  setEdicao((prev) => {
743
  if (!prev) return prev
744
+ return {
745
+ ...prev,
746
+ coord_x: Number.isFinite(Number(lon)) ? String(lon) : '',
747
+ coord_y: Number.isFinite(Number(lat)) ? String(lat) : '',
748
+ }
749
+ })
750
+ }
751
+
752
+ async function onResolverCoordenadasTrabalho() {
753
+ setTrabalhoGeoError('')
754
+ setTrabalhoGeoStatus('')
755
+ if (trabalhoGeoModo === 'coords') {
756
+ const lat = Number(trabalhoGeoInputs.latitude)
757
+ const lon = Number(trabalhoGeoInputs.longitude)
758
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
759
+ setTrabalhoGeoError('Informe latitude e longitude válidas.')
760
+ return
761
+ }
762
+ } else {
763
+ const logradouro = String(trabalhoGeoInputs.logradouro || '').trim()
764
+ const cdlogTexto = String(trabalhoGeoInputs.cdlog || '').trim()
765
+ const cdlog = cdlogTexto ? Number(cdlogTexto) : null
766
+ if (!logradouro && !(Number.isFinite(cdlog) && cdlog > 0)) {
767
+ setTrabalhoGeoError('Informe o logradouro ou um CDLOG válido.')
768
+ return
769
+ }
770
+ if (cdlogTexto && !(Number.isFinite(cdlog) && cdlog > 0)) {
771
+ setTrabalhoGeoError('Informe um CDLOG válido.')
772
+ return
773
+ }
774
+ const numero = Number(trabalhoGeoInputs.numero)
775
+ if (!Number.isFinite(numero) || numero <= 0) {
776
+ setTrabalhoGeoError('Informe um número válido.')
777
+ return
778
+ }
779
+ }
780
+
781
+ setTrabalhoGeoLoading(true)
782
+ try {
783
+ const response = await api.pesquisarLocalizacaoAvaliando({
784
+ latitude: trabalhoGeoModo === 'coords' ? Number(trabalhoGeoInputs.latitude) : null,
785
+ longitude: trabalhoGeoModo === 'coords' ? Number(trabalhoGeoInputs.longitude) : null,
786
+ logradouro: trabalhoGeoModo === 'endereco' ? String(trabalhoGeoInputs.logradouro || '').trim() : null,
787
+ numero: trabalhoGeoModo === 'endereco' ? Number(trabalhoGeoInputs.numero) : null,
788
+ cdlog: trabalhoGeoModo === 'endereco' && String(trabalhoGeoInputs.cdlog || '').trim()
789
+ ? Number(trabalhoGeoInputs.cdlog)
790
+ : null,
791
+ })
792
+ const lat = Number(response?.lat)
793
+ const lon = Number(response?.lon)
794
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
795
+ throw new Error('A localização retornada não possui coordenadas válidas.')
796
+ }
797
+ aplicarCoordenadasTrabalho(lat, lon)
798
+ setTrabalhoGeoStatus(response?.status || 'Coordenadas do trabalho técnico definidas.')
799
+ } catch (err) {
800
+ setTrabalhoGeoError(err.message || 'Falha ao localizar as coordenadas do trabalho técnico.')
801
+ } finally {
802
+ setTrabalhoGeoLoading(false)
803
+ }
804
+ }
805
+
806
+ function atualizarEnderecoImovel(index, valor) {
807
+ setEdicao((prev) => {
808
+ if (!prev) return prev
809
+ const endereco = String(valor || '')
810
  const proximosImoveis = prev.imoveis.map((item, itemIndex) => (
811
+ itemIndex === index
812
+ ? { ...item, label: endereco, endereco, numero: '' }
813
+ : item
814
  ))
815
  return { ...prev, imoveis: proximosImoveis }
816
  })
 
932
  item?.endereco_resumo,
933
  item?.modelo_resumo,
934
  item?.modelo_mesa_principal,
935
+ item?.pasta_nome,
936
+ item?.tecnico_resumo,
937
+ item?.finalidade_processo_resumo,
938
  ...processosSei,
939
  ...(Array.isArray(item?.modelos) ? item.modelos.map((modelo) => modelo?.nome) : []),
940
  ]
 
990
  const modelos = Array.isArray(trabalhoAberto?.modelos) ? trabalhoAberto.modelos : []
991
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
992
  const imoveis = Array.isArray(trabalhoAberto?.imoveis) ? trabalhoAberto.imoveis : []
993
+ const processosDetalhados = listarProcessosDetalhados(trabalhoAberto)
994
+ const coordenadasImoveis = listarCoordenadasImoveis(imoveis)
995
+ const pastaNome = String(trabalhoAberto?.pasta_nome || '').trim()
996
+ const pastaHref = normalizarHrefExterno(trabalhoAberto?.pasta_link)
997
  const shareHref = !isCadastro && trabalhoAberto?.id ? buildTrabalhoTecnicoLink(trabalhoAberto.id) : ''
998
 
999
  return (
 
1052
 
1053
  <div className="trabalho-tecnico-summary-grid">
1054
  <section className="trabalho-tecnico-card trabalho-tecnico-card-primary">
1055
+ <h4>Resumo</h4>
1056
  {!editando ? (
1057
+ <div className="trabalho-tecnico-meta-list trabalho-tecnico-meta-list-two-cols">
1058
+ <div className="trabalho-tecnico-meta-row">
1059
+ <span className="trabalho-tecnico-meta-label">Finalidade</span>
1060
+ <strong>{trabalhoAberto?.finalidade_processo_resumo || 'Não informada'}</strong>
1061
+ </div>
1062
+ <div className="trabalho-tecnico-meta-row">
1063
+ <span className="trabalho-tecnico-meta-label">Avaliador</span>
1064
+ <strong>{trabalhoAberto?.tecnico_resumo || 'Não informado'}</strong>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1065
  </div>
1066
+ <div className="trabalho-tecnico-meta-row">
1067
+ <span className="trabalho-tecnico-meta-label">Ano</span>
1068
+ <strong>{trabalhoAberto?.ano || '-'}</strong>
1069
+ </div>
1070
+ <div className="trabalho-tecnico-meta-row trabalho-tecnico-meta-row-block">
1071
+ <span className="trabalho-tecnico-meta-label">Pasta</span>
1072
+ {pastaHref && pastaNome ? (
1073
+ <a className="trabalho-meta-link" href={pastaHref} target="_blank" rel="noreferrer">
1074
+ {pastaNome}
1075
+ </a>
1076
+ ) : (
1077
+ <strong>{pastaNome || 'Não informada'}</strong>
1078
+ )}
1079
+ </div>
1080
+ <div className="trabalho-tecnico-meta-row trabalho-tecnico-meta-row-block">
1081
+ <span className="trabalho-tecnico-meta-label">Processo</span>
1082
+ <div className="trabalho-link-list">
1083
+ {processosDetalhados.map((processo, index) => {
1084
+ const processoHref = normalizarHrefExterno(processo?.link)
1085
+ const processoNumero = String(processo?.numero || '').trim() || 'Nenhum registrado'
1086
+ return processoHref && processoNumero !== 'Nenhum registrado' ? (
1087
+ <a
1088
+ key={`${trabalhoAberto?.id || 'trabalho'}-processo-detalhe-${index + 1}`}
1089
+ className="trabalho-meta-link"
1090
+ href={processoHref}
1091
+ target="_blank"
1092
+ rel="noreferrer"
1093
+ >
1094
+ {processoNumero}
1095
+ </a>
1096
+ ) : (
1097
+ <strong key={`${trabalhoAberto?.id || 'trabalho'}-processo-detalhe-${index + 1}`}>
1098
+ {processoNumero}
1099
+ </strong>
1100
+ )
1101
+ })}
1102
  </div>
1103
+ </div>
1104
+ <div className="trabalho-tecnico-meta-row trabalho-tecnico-meta-row-block">
1105
+ <span className="trabalho-tecnico-meta-label">Coordenadas</span>
1106
+ <div className="trabalho-link-list trabalho-coordinates-list">
1107
+ {coordenadasImoveis.length ? coordenadasImoveis.map((coordenada, index) => (
1108
+ <strong key={`${trabalhoAberto?.id || 'trabalho'}-coord-${index + 1}`}>{coordenada}</strong>
1109
+ )) : <strong>Não informadas</strong>}
 
 
1110
  </div>
1111
  </div>
1112
+ </div>
1113
  ) : (
1114
  <div className="trabalho-edit-grid">
1115
  <label className="trabalho-edit-field">
 
1151
  rows={4}
1152
  />
1153
  </label>
1154
+ <div className="trabalho-edit-field trabalho-edit-field-full trabalho-coordenadas-editor">
1155
+ <span>Coordenadas do trabalho</span>
1156
+ <div className="trabalho-coordenadas-current">
1157
+ <span>
1158
+ <strong>Longitude/X</strong>
1159
+ {formatarCoordenada(edicao?.coord_x)}
1160
+ </span>
1161
+ <span>
1162
+ <strong>Latitude/Y</strong>
1163
+ {formatarCoordenada(edicao?.coord_y)}
1164
+ </span>
1165
+ </div>
1166
+ {trabalhoGeoModo === 'coords' ? (
1167
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-coords trabalho-coordenadas-grid">
1168
+ <label className="pesquisa-field">
1169
+ Forma de localização
1170
+ <select value={trabalhoGeoModo} onChange={(event) => setTrabalhoGeoModo(event.target.value)}>
1171
+ <option value="endereco">Endereço</option>
1172
+ <option value="coords">Coordenadas</option>
1173
+ </select>
1174
+ </label>
1175
+ <label className="pesquisa-field">
1176
+ Latitude
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1177
  <input
1178
+ type="number"
1179
+ step="any"
1180
+ value={trabalhoGeoInputs.latitude}
1181
+ onChange={(event) => atualizarCampoTrabalhoGeo('latitude', event.target.value)}
1182
+ placeholder="-30.000000"
1183
  />
1184
  </label>
1185
+ <label className="pesquisa-field">
1186
+ Longitude
1187
  <input
1188
+ type="number"
1189
+ step="any"
1190
+ value={trabalhoGeoInputs.longitude}
1191
+ onChange={(event) => atualizarCampoTrabalhoGeo('longitude', event.target.value)}
1192
+ placeholder="-51.000000"
1193
  />
1194
  </label>
1195
+ <div className="pesquisa-localizacao-actions-inline">
1196
+ <button
1197
+ type="button"
1198
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1199
+ onClick={() => void onResolverCoordenadasTrabalho()}
1200
+ disabled={trabalhoGeoLoading}
1201
+ >
1202
+ {trabalhoGeoLoading ? 'Definindo...' : 'Definir'}
1203
+ </button>
1204
+ <button
1205
+ type="button"
1206
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1207
+ onClick={limparFormularioTrabalhoGeo}
1208
+ disabled={trabalhoGeoLoading}
1209
+ >
1210
+ Limpar
1211
+ </button>
1212
+ </div>
1213
+ </div>
1214
+ ) : (
1215
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-endereco trabalho-coordenadas-grid">
1216
+ <label className="pesquisa-field">
1217
+ Forma de localização
1218
+ <select value={trabalhoGeoModo} onChange={(event) => setTrabalhoGeoModo(event.target.value)}>
1219
+ <option value="endereco">Endereço</option>
1220
+ <option value="coords">Coordenadas</option>
1221
+ </select>
1222
+ </label>
1223
+ <label className="pesquisa-field">
1224
+ CDLOG
1225
  <input
1226
+ type="number"
1227
+ value={trabalhoGeoInputs.cdlog}
1228
+ onChange={(event) => atualizarCampoTrabalhoGeo('cdlog', event.target.value)}
1229
+ placeholder="Opcional"
1230
  />
1231
  </label>
1232
+ <label className="pesquisa-field pesquisa-localizacao-logradouro-field">
1233
+ Logradouro
1234
+ <SinglePillAutocomplete
1235
+ value={trabalhoGeoInputs.logradouro}
1236
+ onChange={(nextValue) => atualizarCampoTrabalhoGeo('logradouro', nextValue)}
1237
+ options={trabalhoLogradouroOptions}
1238
+ placeholder={trabalhoLogradouroOptionsLoading ? 'Carregando logradouros...' : 'Digite ou selecione um logradouro dos eixos'}
1239
+ panelTitle="Logradouros dos eixos"
1240
+ emptyMessage="Nenhum logradouro encontrado nos eixos."
1241
+ loading={trabalhoLogradouroOptionsLoading}
1242
+ onOpenChange={(open) => {
1243
+ if (open) void carregarSugestoesLogradouroTrabalho()
1244
+ }}
1245
+ inputName="logradouroEixosTrabalhoTecnico"
1246
+ inputAutoComplete="new-password"
1247
  />
1248
  </label>
1249
+ <label className="pesquisa-field">
1250
+ Número
1251
  <input
1252
+ type="number"
1253
+ value={trabalhoGeoInputs.numero}
1254
+ onChange={(event) => atualizarCampoTrabalhoGeo('numero', event.target.value)}
1255
+ placeholder="0"
1256
  />
1257
  </label>
1258
+ <div className="pesquisa-localizacao-actions-inline">
1259
+ <button
1260
+ type="button"
1261
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1262
+ onClick={() => void onResolverCoordenadasTrabalho()}
1263
+ disabled={trabalhoGeoLoading}
1264
+ >
1265
+ {trabalhoGeoLoading ? 'Buscando...' : 'Buscar'}
1266
+ </button>
1267
+ <button
1268
+ type="button"
1269
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1270
+ onClick={limparFormularioTrabalhoGeo}
1271
+ disabled={trabalhoGeoLoading}
1272
+ >
1273
+ Limpar
1274
+ </button>
1275
+ </div>
1276
  </div>
1277
+ )}
1278
+ {trabalhoGeoStatus ? <div className="status-line">{trabalhoGeoStatus}</div> : null}
1279
+ {trabalhoGeoError ? <div className="error-line inline-error">{trabalhoGeoError}</div> : null}
1280
+ </div>
 
 
 
 
 
 
1281
  </div>
1282
  )}
1283
  </section>
1284
  </div>
1285
 
1286
+ <section className="trabalho-tecnico-card">
1287
+ <h4>Imóveis</h4>
1288
+ {!editando ? (
1289
+ imoveis.length ? (
1290
+ <div className="trabalho-imoveis-stack">
1291
+ {imoveis.map((item, index) => (
1292
+ <div key={`${trabalhoAberto?.id || 'trabalho'}-imovel-${index + 1}`} className="trabalho-imovel-card">
1293
+ <strong className="trabalho-imovel-address">{formatarEnderecoImovel(item)}</strong>
1294
+ </div>
1295
+ ))}
1296
+ </div>
1297
+ ) : (
1298
+ <div className="section1-empty-hint">Nenhum imóvel vinculado.</div>
1299
+ )
1300
+ ) : (
1301
+ <div className="trabalho-edit-imoveis-stack">
1302
+ {Array.isArray(edicao?.imoveis) ? edicao.imoveis.map((item, index) => (
1303
+ <div key={item.id || `${trabalhoAberto?.id || 'trabalho'}-imovel-edit-${index + 1}`} className="trabalho-edit-imovel-card">
1304
+ <div className="trabalho-edit-imovel-head">
1305
+ <strong>Imóvel {index + 1}</strong>
1306
+ <button
1307
+ type="button"
1308
+ className="trabalho-mini-action"
1309
+ onClick={() => removerImovel(index)}
1310
+ disabled={(edicao?.imoveis || []).length <= 1 || salvando}
1311
+ >
1312
+ Remover
1313
+ </button>
1314
+ </div>
1315
+ <label className="trabalho-edit-field">
1316
+ Endereço
1317
+ <input
1318
+ type="text"
1319
+ value={formatarEnderecoImovelEditavel(item)}
1320
+ onChange={(event) => atualizarEnderecoImovel(index, event.target.value)}
1321
+ autoComplete="off"
1322
+ />
1323
+ </label>
1324
+ </div>
1325
+ )) : null}
1326
+ <button
1327
+ type="button"
1328
+ className="trabalho-mini-action trabalho-mini-action-add"
1329
+ onClick={adicionarImovel}
1330
+ disabled={salvando}
1331
+ >
1332
+ Adicionar imóvel
1333
+ </button>
1334
+ </div>
1335
+ )}
1336
+ </section>
1337
+
1338
  <section className="trabalho-tecnico-card">
1339
  <h4>Modelos associados</h4>
1340
  {!editando ? (
 
1481
  type="text"
1482
  value={filtroTexto}
1483
  onChange={(event) => setFiltroTexto(event.target.value)}
1484
+ placeholder="nome, imóvel, modelo ou processo"
1485
  autoComplete="off"
1486
  />
1487
  </label>
 
1527
  <th className="trabalhos-col-nome">Trabalho</th>
1528
  <th className="trabalhos-col-tipo">Tipo</th>
1529
  <th className="trabalhos-col-ano">Ano</th>
1530
+ <th className="trabalhos-col-endereco">Imóveis</th>
1531
  <th className="trabalhos-col-modelos">Modelos</th>
1532
  <th className="trabalhos-col-processos">Processos SEI</th>
1533
  <th className="repo-col-open">Abrir</th>
 
1572
  </td>
1573
  <td className="trabalhos-col-processos">
1574
  <div className="trabalho-model-stack">
1575
+ {listarProcessosDetalhados(item).map((processo, index) => {
1576
+ const processoHref = normalizarHrefExterno(processo?.link)
1577
+ const processoNumero = String(processo?.numero || '').trim() || 'Nenhum registrado'
1578
+ return (
1579
+ <div
1580
+ key={`${item?.id || 'trabalho'}-processo-${index + 1}`}
1581
+ className="trabalho-model-list-item"
1582
+ >
1583
+ {processoHref && processoNumero !== 'Nenhum registrado' ? (
1584
+ <a className="trabalho-model-inline-link" href={processoHref} target="_blank" rel="noreferrer">
1585
+ {processoNumero}
1586
+ </a>
1587
+ ) : (
1588
+ processoNumero
1589
+ )}
1590
+ </div>
1591
+ )
1592
+ })}
1593
  </div>
1594
  </td>
1595
  <td className="repo-col-open">
 
1636
  type="text"
1637
  value={filtroTexto}
1638
  onChange={(event) => setFiltroTexto(event.target.value)}
1639
+ placeholder="nome, imóvel, modelo ou processo"
1640
  autoComplete="off"
1641
  />
1642
  </label>
frontend/src/components/VisualizacaoTab.jsx DELETED
@@ -1,877 +0,0 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react'
2
- import { api, downloadBlob } from '../api'
3
- import DataTable from './DataTable'
4
- import EquationFormatsPanel from './EquationFormatsPanel'
5
- import LoadingOverlay from './LoadingOverlay'
6
- import MapFrame from './MapFrame'
7
- import PlotFigure from './PlotFigure'
8
- import SectionBlock from './SectionBlock'
9
- import SinglePillAutocomplete from './SinglePillAutocomplete'
10
-
11
- const INNER_TABS = [
12
- { key: 'mapa', label: 'Mapa' },
13
- { key: 'dados_mercado', label: 'Dados de Mercado' },
14
- { key: 'metricas', label: 'Métricas' },
15
- { key: 'transformacoes', label: 'Transformações' },
16
- { key: 'resumo', label: 'Resumo' },
17
- { key: 'coeficientes', label: 'Coeficientes' },
18
- { key: 'obs_calc', label: 'Obs x Calc' },
19
- { key: 'graficos', label: 'Gráficos' },
20
- { key: 'avaliacao', label: 'Avaliação' },
21
- { key: 'avaliacao_massa', label: 'Avaliação em Massa' },
22
- ]
23
- const BASE_COMPARACAO_SEM_BASE = '__none__'
24
- const TRABALHOS_TECNICOS_MODELOS_PADRAO = 'selecionados_e_outras_versoes'
25
- const SECTION_TABS = new Set([
26
- 'mapa',
27
- 'dados_mercado',
28
- 'metricas',
29
- 'transformacoes',
30
- 'resumo',
31
- 'coeficientes',
32
- 'obs_calc',
33
- 'graficos',
34
- ])
35
-
36
- function getVisualizacaoLoadingLabel(tabKey) {
37
- switch (String(tabKey || '').trim()) {
38
- case 'mapa':
39
- return 'Carregando mapa do modelo...'
40
- case 'dados_mercado':
41
- return 'Carregando dados de mercado...'
42
- case 'metricas':
43
- return 'Carregando métricas do modelo...'
44
- case 'transformacoes':
45
- return 'Carregando transformações do modelo...'
46
- case 'resumo':
47
- return 'Carregando resumo do modelo...'
48
- case 'coeficientes':
49
- return 'Carregando coeficientes do modelo...'
50
- case 'obs_calc':
51
- return 'Carregando observados x calculados...'
52
- case 'graficos':
53
- return 'Carregando gráficos do modelo...'
54
- default:
55
- return 'Processando dados...'
56
- }
57
- }
58
-
59
- export default function VisualizacaoTab({ sessionId }) {
60
- const [loading, setLoading] = useState(false)
61
- const [error, setError] = useState('')
62
- const [status, setStatus] = useState('')
63
- const [badgeHtml, setBadgeHtml] = useState('')
64
- const [modeloCarregado, setModeloCarregado] = useState(false)
65
-
66
- const [uploadedFile, setUploadedFile] = useState(null)
67
- const [uploadDragOver, setUploadDragOver] = useState(false)
68
- const [modeloLoadSource, setModeloLoadSource] = useState('')
69
- const [repoModelos, setRepoModelos] = useState([])
70
- const [repoModeloSelecionado, setRepoModeloSelecionado] = useState('')
71
- const [repoModelosLoading, setRepoModelosLoading] = useState(false)
72
- const [repoFonteModelos, setRepoFonteModelos] = useState('')
73
-
74
- const [dados, setDados] = useState(null)
75
- const [estatisticas, setEstatisticas] = useState(null)
76
- const [escalasHtml, setEscalasHtml] = useState('')
77
- const [dadosTransformados, setDadosTransformados] = useState(null)
78
- const [resumoHtml, setResumoHtml] = useState('')
79
- const [equacoes, setEquacoes] = useState(null)
80
- const [coeficientes, setCoeficientes] = useState(null)
81
- const [obsCalc, setObsCalc] = useState(null)
82
-
83
- const [plotObsCalc, setPlotObsCalc] = useState(null)
84
- const [plotResiduos, setPlotResiduos] = useState(null)
85
- const [plotHistograma, setPlotHistograma] = useState(null)
86
- const [plotCook, setPlotCook] = useState(null)
87
- const [plotCorr, setPlotCorr] = useState(null)
88
-
89
- const [mapaHtml, setMapaHtml] = useState('')
90
- const [mapaPayload, setMapaPayload] = useState(null)
91
- const [mapaChoices, setMapaChoices] = useState(['Visualização Padrão'])
92
- const [mapaVar, setMapaVar] = useState('Visualização Padrão')
93
- const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState(TRABALHOS_TECNICOS_MODELOS_PADRAO)
94
-
95
- const [camposAvaliacao, setCamposAvaliacao] = useState([])
96
- const valoresAvaliacaoRef = useRef({})
97
- const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
98
- const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
99
- const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
100
- const [baseChoices, setBaseChoices] = useState([])
101
- const [baseValue, setBaseValue] = useState('')
102
-
103
- const [activeInnerTab, setActiveInnerTab] = useState('mapa')
104
- const [loadedTabs, setLoadedTabs] = useState({})
105
- const [loadingTabs, setLoadingTabs] = useState({})
106
- const deleteConfirmTimersRef = useRef({})
107
- const uploadInputRef = useRef(null)
108
- const pendingTabRequestsRef = useRef({})
109
- const modeloLoadVersionRef = useRef(0)
110
- const temAvaliacoes = Array.isArray(baseChoices) && baseChoices.length > 0
111
- const repoModeloOptions = useMemo(
112
- () => (repoModelos || []).map((item) => ({
113
- value: String(item?.id || ''),
114
- label: String(item?.nome_modelo || item?.arquivo || item?.id || ''),
115
- secondary: String(item?.arquivo || ''),
116
- })).filter((item) => item.value && item.label),
117
- [repoModelos],
118
- )
119
-
120
- function resetConteudoVisualizacao() {
121
- pendingTabRequestsRef.current = {}
122
- setModeloCarregado(false)
123
- setDados(null)
124
- setEstatisticas(null)
125
- setEscalasHtml('')
126
- setDadosTransformados(null)
127
- setResumoHtml('')
128
- setEquacoes(null)
129
- setCoeficientes(null)
130
- setObsCalc(null)
131
-
132
- setPlotObsCalc(null)
133
- setPlotResiduos(null)
134
- setPlotHistograma(null)
135
- setPlotCook(null)
136
- setPlotCorr(null)
137
-
138
- setMapaHtml('')
139
- setMapaPayload(null)
140
- setMapaChoices(['Visualização Padrão'])
141
- setMapaVar('Visualização Padrão')
142
- setMapaTrabalhosTecnicosModelosModo(TRABALHOS_TECNICOS_MODELOS_PADRAO)
143
-
144
- setCamposAvaliacao([])
145
- valoresAvaliacaoRef.current = {}
146
- setAvaliacaoFormVersion((prev) => prev + 1)
147
- setConfirmarLimpezaAvaliacoes(false)
148
- setResultadoAvaliacaoHtml('')
149
- setBaseChoices([])
150
- setBaseValue('')
151
-
152
- setActiveInnerTab('mapa')
153
- setLoadedTabs({})
154
- setLoadingTabs({})
155
- }
156
-
157
- function applyEvaluationContext(resp) {
158
- setCamposAvaliacao(resp?.campos_avaliacao || [])
159
- setEquacoes(resp?.equacoes || null)
160
- const values = {}
161
- ;(resp?.campos_avaliacao || []).forEach((campo) => {
162
- values[campo.coluna] = ''
163
- })
164
- valoresAvaliacaoRef.current = values
165
- setAvaliacaoFormVersion((prev) => prev + 1)
166
- setConfirmarLimpezaAvaliacoes(false)
167
- setResultadoAvaliacaoHtml('')
168
- setBaseChoices([])
169
- setBaseValue('')
170
- setModeloCarregado(true)
171
- }
172
-
173
- function applyVisualizacaoSection(secao, resp) {
174
- const key = String(secao || '').trim()
175
- if (key === 'dados_mercado') {
176
- setDados(resp?.dados || null)
177
- return
178
- }
179
- if (key === 'metricas') {
180
- setEstatisticas(resp?.estatisticas || null)
181
- return
182
- }
183
- if (key === 'transformacoes') {
184
- setEscalasHtml(resp?.escalas_html || '')
185
- setDadosTransformados(resp?.dados_transformados || null)
186
- return
187
- }
188
- if (key === 'resumo') {
189
- setResumoHtml(resp?.resumo_html || '')
190
- setEquacoes(resp?.equacoes || null)
191
- return
192
- }
193
- if (key === 'coeficientes') {
194
- setCoeficientes(resp?.coeficientes || null)
195
- return
196
- }
197
- if (key === 'obs_calc') {
198
- setObsCalc(resp?.obs_calc || null)
199
- return
200
- }
201
- if (key === 'graficos') {
202
- setPlotObsCalc(resp?.grafico_obs_calc || null)
203
- setPlotResiduos(resp?.grafico_residuos || null)
204
- setPlotHistograma(resp?.grafico_histograma || null)
205
- setPlotCook(resp?.grafico_cook || null)
206
- setPlotCorr(resp?.grafico_correlacao || null)
207
- return
208
- }
209
- if (key === 'mapa') {
210
- const nextChoices = Array.isArray(resp?.mapa_choices) && resp.mapa_choices.length
211
- ? resp.mapa_choices
212
- : ['Visualização Padrão']
213
- setMapaHtml(resp?.mapa_html || '')
214
- setMapaPayload(resp?.mapa_payload || null)
215
- setMapaChoices(nextChoices)
216
- setMapaVar((current) => (nextChoices.includes(current) ? current : 'Visualização Padrão'))
217
- setMapaTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || TRABALHOS_TECNICOS_MODELOS_PADRAO)
218
- }
219
- }
220
-
221
- async function ensureVisualizacaoSection(secao, options = {}) {
222
- const secaoNormalizada = String(secao || '').trim()
223
- if (!sessionId || !SECTION_TABS.has(secaoNormalizada)) return
224
- if (!options.force && loadedTabs[secaoNormalizada]) return
225
- if (pendingTabRequestsRef.current[secaoNormalizada]) {
226
- await pendingTabRequestsRef.current[secaoNormalizada]
227
- return
228
- }
229
-
230
- const expectedVersion = options.expectedVersion ?? modeloLoadVersionRef.current
231
- const trabalhosTecnicosModo = options.trabalhosTecnicosModelosModo || mapaTrabalhosTecnicosModelosModo
232
- setLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: true }))
233
-
234
- const request = (async () => {
235
- try {
236
- const resp = await api.visualizacaoSection(sessionId, secaoNormalizada, trabalhosTecnicosModo)
237
- if (modeloLoadVersionRef.current !== expectedVersion) return
238
- applyVisualizacaoSection(secaoNormalizada, resp)
239
- setLoadedTabs((prev) => ({ ...prev, [secaoNormalizada]: true }))
240
- } catch (err) {
241
- if (modeloLoadVersionRef.current !== expectedVersion) return
242
- setError(err.message || 'Falha ao carregar dados do modelo.')
243
- } finally {
244
- if (modeloLoadVersionRef.current !== expectedVersion) return
245
- setLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: false }))
246
- }
247
- })()
248
-
249
- pendingTabRequestsRef.current[secaoNormalizada] = request
250
- try {
251
- await request
252
- } finally {
253
- if (pendingTabRequestsRef.current[secaoNormalizada] === request) {
254
- delete pendingTabRequestsRef.current[secaoNormalizada]
255
- }
256
- }
257
- }
258
-
259
- async function withBusy(fn) {
260
- setLoading(true)
261
- setError('')
262
- try {
263
- await fn()
264
- } catch (err) {
265
- setError(err.message)
266
- } finally {
267
- setLoading(false)
268
- }
269
- }
270
-
271
- function formatarFonteRepositorio(fonte) {
272
- if (!fonte || typeof fonte !== 'object') return ''
273
- const provider = String(fonte.provider || '').toLowerCase()
274
- if (provider === 'hf_dataset') {
275
- const repo = String(fonte.repo_id || '').trim()
276
- const revision = String(fonte.revision || '').trim()
277
- const degradado = Boolean(fonte.degraded)
278
- const sufixo = degradado ? ' (modo contingência)' : ''
279
- return `Fonte: HF Dataset${repo ? ` (${repo})` : ''}${revision ? ` | revisão ${revision.slice(0, 8)}` : ''}${sufixo}`
280
- }
281
- return 'Fonte: pasta local'
282
- }
283
-
284
- function aplicarRespostaModelosRepositorio(resp) {
285
- const modelos = Array.isArray(resp?.modelos) ? resp.modelos : []
286
- setRepoModelos(modelos)
287
- setRepoFonteModelos(formatarFonteRepositorio(resp?.fonte || null))
288
- setRepoModeloSelecionado((prev) => {
289
- const atual = String(prev || '')
290
- if (atual && modelos.some((item) => String(item.id) === atual)) return atual
291
- return ''
292
- })
293
- }
294
-
295
- async function carregarModelosRepositorio() {
296
- setRepoModelosLoading(true)
297
- try {
298
- const resp = await api.visualizacaoRepositorioModelos()
299
- aplicarRespostaModelosRepositorio(resp)
300
- } catch (err) {
301
- setError(err.message || 'Falha ao carregar modelos do repositório.')
302
- setRepoModelos([])
303
- setRepoModeloSelecionado('')
304
- setRepoFonteModelos('')
305
- } finally {
306
- setRepoModelosLoading(false)
307
- }
308
- }
309
-
310
- useEffect(() => {
311
- let ativo = true
312
- if (!sessionId) return () => {
313
- ativo = false
314
- }
315
-
316
- setRepoModelosLoading(true)
317
- api.visualizacaoRepositorioModelos()
318
- .then((resp) => {
319
- if (!ativo) return
320
- aplicarRespostaModelosRepositorio(resp)
321
- })
322
- .catch(() => {
323
- if (!ativo) return
324
- setRepoModelos([])
325
- setRepoModeloSelecionado('')
326
- setRepoFonteModelos('')
327
- })
328
- .finally(() => {
329
- if (!ativo) return
330
- setRepoModelosLoading(false)
331
- })
332
-
333
- return () => {
334
- ativo = false
335
- }
336
- }, [sessionId])
337
-
338
- async function onUploadModel(arquivo = null) {
339
- const arquivoUpload = arquivo || uploadedFile
340
- if (!sessionId || !arquivoUpload) return
341
- setModeloLoadSource('upload')
342
- await withBusy(async () => {
343
- resetConteudoVisualizacao()
344
- modeloLoadVersionRef.current += 1
345
- const openVersion = modeloLoadVersionRef.current
346
- const uploadResp = await api.uploadVisualizacaoFile(sessionId, arquivoUpload)
347
- if (modeloLoadVersionRef.current !== openVersion) return
348
- setStatus(uploadResp.status || '')
349
- setBadgeHtml(uploadResp.badge_html || '')
350
- const contextoResp = await api.evaluationContextViz(sessionId)
351
- if (modeloLoadVersionRef.current !== openVersion) return
352
- applyEvaluationContext(contextoResp)
353
- await ensureVisualizacaoSection('mapa', {
354
- force: true,
355
- expectedVersion: openVersion,
356
- trabalhosTecnicosModelosModo: TRABALHOS_TECNICOS_MODELOS_PADRAO,
357
- })
358
- })
359
- }
360
-
361
- async function onCarregarModeloRepositorio() {
362
- if (!sessionId || !repoModeloSelecionado) return
363
- setModeloLoadSource('repo')
364
- await withBusy(async () => {
365
- resetConteudoVisualizacao()
366
- modeloLoadVersionRef.current += 1
367
- const openVersion = modeloLoadVersionRef.current
368
- const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, repoModeloSelecionado)
369
- if (modeloLoadVersionRef.current !== openVersion) return
370
- setStatus(uploadResp.status || '')
371
- setBadgeHtml(uploadResp.badge_html || '')
372
- const contextoResp = await api.evaluationContextViz(sessionId)
373
- if (modeloLoadVersionRef.current !== openVersion) return
374
- applyEvaluationContext(contextoResp)
375
- await ensureVisualizacaoSection('mapa', {
376
- force: true,
377
- expectedVersion: openVersion,
378
- trabalhosTecnicosModelosModo: TRABALHOS_TECNICOS_MODELOS_PADRAO,
379
- })
380
- setUploadedFile(null)
381
- })
382
- }
383
-
384
- function onUploadInputChange(event) {
385
- const input = event.target
386
- const file = input.files?.[0] ?? null
387
- setModeloLoadSource('upload')
388
- setUploadedFile(file)
389
- input.value = ''
390
- if (!file || loading) return
391
- void onUploadModel(file)
392
- }
393
-
394
- function onUploadDropZoneDragOver(event) {
395
- event.preventDefault()
396
- event.dataTransfer.dropEffect = 'copy'
397
- setUploadDragOver(true)
398
- }
399
-
400
- function onUploadDropZoneDragLeave(event) {
401
- event.preventDefault()
402
- if (!event.currentTarget.contains(event.relatedTarget)) {
403
- setUploadDragOver(false)
404
- }
405
- }
406
-
407
- function onUploadDropZoneDrop(event) {
408
- event.preventDefault()
409
- setUploadDragOver(false)
410
- const file = event.dataTransfer?.files?.[0]
411
- if (!file || loading) return
412
- setModeloLoadSource('upload')
413
- setUploadedFile(file)
414
- void onUploadModel(file)
415
- }
416
-
417
- async function atualizarMapa(
418
- variavelMapa = mapaVar,
419
- trabalhosTecnicosModelosModo = mapaTrabalhosTecnicosModelosModo,
420
- ) {
421
- if (!sessionId) return
422
- setLoadingTabs((prev) => ({ ...prev, mapa: true }))
423
- try {
424
- const resp = await api.updateVisualizacaoMap(sessionId, variavelMapa, trabalhosTecnicosModelosModo)
425
- setMapaHtml(resp?.mapa_html || '')
426
- setMapaPayload(resp?.mapa_payload || null)
427
- setMapaTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || trabalhosTecnicosModelosModo)
428
- setLoadedTabs((prev) => ({ ...prev, mapa: true }))
429
- } finally {
430
- setLoadingTabs((prev) => ({ ...prev, mapa: false }))
431
- }
432
- }
433
-
434
- async function onMapChange(value) {
435
- setMapaVar(value)
436
- await withBusy(async () => {
437
- await atualizarMapa(value, mapaTrabalhosTecnicosModelosModo)
438
- })
439
- }
440
-
441
- async function onMapTrabalhosTecnicosModeChange(value) {
442
- setMapaTrabalhosTecnicosModelosModo(value)
443
- await withBusy(async () => {
444
- await atualizarMapa(mapaVar, value)
445
- })
446
- }
447
-
448
- async function onCalcularAvaliacao() {
449
- if (!sessionId) return
450
- await withBusy(async () => {
451
- const resp = await api.evaluationCalculateViz(sessionId, valoresAvaliacaoRef.current, baseValue || null)
452
- setResultadoAvaliacaoHtml(resp.resultado_html || '')
453
- setBaseChoices(resp.base_choices || [])
454
- setBaseValue(resp.base_value || '')
455
- setConfirmarLimpezaAvaliacoes(false)
456
- })
457
- }
458
-
459
- function onResetCamposAvaliacao() {
460
- const limpo = {}
461
- camposAvaliacao.forEach((campo) => {
462
- limpo[campo.coluna] = ''
463
- })
464
- valoresAvaliacaoRef.current = limpo
465
- setAvaliacaoFormVersion((prev) => prev + 1)
466
- }
467
-
468
- async function onClearAvaliacao() {
469
- if (!sessionId) return
470
- await withBusy(async () => {
471
- const resp = await api.evaluationClearViz(sessionId)
472
- setResultadoAvaliacaoHtml(resp.resultado_html || '')
473
- setBaseChoices(resp.base_choices || [])
474
- setBaseValue(resp.base_value || '')
475
- setConfirmarLimpezaAvaliacoes(false)
476
- })
477
- }
478
-
479
- async function onDeleteAvaliacao(indice) {
480
- if (!sessionId) return
481
- await withBusy(async () => {
482
- const resp = await api.evaluationDeleteViz(sessionId, indice ? String(indice) : null, baseValue || null)
483
- setResultadoAvaliacaoHtml(resp.resultado_html || '')
484
- setBaseChoices(resp.base_choices || [])
485
- setBaseValue(resp.base_value || '')
486
- setConfirmarLimpezaAvaliacoes(false)
487
- })
488
- }
489
-
490
- function onAvaliacaoResultadoClick(event) {
491
- const ativarExclusao = event.target.closest('[data-avaliacao-delete-arm]')
492
- if (ativarExclusao) {
493
- const indice = ativarExclusao.getAttribute('data-avaliacao-delete-index')
494
- if (!indice) return
495
-
496
- const cell = ativarExclusao.closest('td')
497
- const botaoConfirmar = cell?.querySelector(`[data-avaliacao-delete-confirm="${indice}"]`)
498
- if (!botaoConfirmar) return
499
-
500
- ativarExclusao.style.display = 'none'
501
- botaoConfirmar.style.display = 'inline-block'
502
-
503
- const timerKey = String(indice)
504
- if (deleteConfirmTimersRef.current[timerKey]) {
505
- clearTimeout(deleteConfirmTimersRef.current[timerKey])
506
- }
507
- deleteConfirmTimersRef.current[timerKey] = window.setTimeout(() => {
508
- botaoConfirmar.style.display = 'none'
509
- ativarExclusao.style.display = 'inline'
510
- delete deleteConfirmTimersRef.current[timerKey]
511
- }, 10000)
512
- return
513
- }
514
-
515
- const confirmarExclusao = event.target.closest('[data-avaliacao-delete-confirm]')
516
- if (!confirmarExclusao) return
517
- const indice = confirmarExclusao.getAttribute('data-avaliacao-delete-confirm')
518
- if (!indice) return
519
-
520
- const timerKey = String(indice)
521
- if (deleteConfirmTimersRef.current[timerKey]) {
522
- clearTimeout(deleteConfirmTimersRef.current[timerKey])
523
- delete deleteConfirmTimersRef.current[timerKey]
524
- }
525
- onDeleteAvaliacao(indice)
526
- }
527
-
528
- async function onBaseChange(value) {
529
- setBaseValue(value)
530
- if (!sessionId) return
531
- await withBusy(async () => {
532
- const resp = await api.evaluationBaseViz(sessionId, value)
533
- setResultadoAvaliacaoHtml(resp.resultado_html || '')
534
- })
535
- }
536
-
537
- async function onExportAvaliacoes() {
538
- if (!sessionId) return
539
- await withBusy(async () => {
540
- const blob = await api.exportEvaluationViz(sessionId)
541
- downloadBlob(blob, 'avaliacoes_visualizacao.xlsx')
542
- })
543
- }
544
-
545
- async function onDownloadEquacao(mode) {
546
- if (!sessionId || !mode) return
547
- await withBusy(async () => {
548
- const blob = await api.exportEquationViz(sessionId, mode)
549
- const sufixo = String(mode) === 'excel_sab' ? 'estilo_sab' : 'excel'
550
- downloadBlob(blob, `equacao_modelo_${sufixo}.xlsx`)
551
- })
552
- }
553
-
554
- function onInnerTabSelect(nextTab) {
555
- setActiveInnerTab(nextTab)
556
- if (SECTION_TABS.has(nextTab)) {
557
- void ensureVisualizacaoSection(nextTab)
558
- }
559
- }
560
-
561
- return (
562
- <div className="tab-content">
563
- <SectionBlock step="1" title="Carregar Modelo .dai" subtitle="Carregue o arquivo e o conteúdo será exibido automaticamente.">
564
- {!modeloLoadSource ? (
565
- <div className="model-source-choice-grid">
566
- <button
567
- type="button"
568
- className="model-source-choice-btn model-source-choice-btn-primary"
569
- onClick={() => setModeloLoadSource('repo')}
570
- disabled={loading}
571
- >
572
- Carregar modelo do repositório
573
- </button>
574
- <button
575
- type="button"
576
- className="model-source-choice-btn model-source-choice-btn-secondary"
577
- onClick={() => setModeloLoadSource('upload')}
578
- disabled={loading}
579
- >
580
- Fazer upload de modelo
581
- </button>
582
- </div>
583
- ) : (
584
- <div className="model-source-flow">
585
- <div className="model-source-flow-head">
586
- <button
587
- type="button"
588
- className="model-source-back-btn"
589
- onClick={() => setModeloLoadSource('')}
590
- disabled={loading}
591
- >
592
- Voltar
593
- </button>
594
- </div>
595
-
596
- {modeloLoadSource === 'repo' ? (
597
- <div className="row upload-repo-row">
598
- <label className="upload-repo-field">
599
- Modelo do repositório
600
- <SinglePillAutocomplete
601
- value={repoModeloSelecionado}
602
- onChange={setRepoModeloSelecionado}
603
- options={repoModeloOptions}
604
- placeholder={repoModelosLoading ? 'Carregando lista...' : repoModeloOptions.length > 0 ? 'Digite para buscar modelo' : 'Nenhum modelo disponível'}
605
- emptyMessage={repoModeloOptions.length > 0 ? 'Nenhum modelo encontrado.' : 'Nenhum modelo disponível.'}
606
- loading={repoModelosLoading}
607
- disabled={loading || repoModelosLoading || repoModeloOptions.length === 0}
608
- />
609
- </label>
610
- <div className="row compact upload-repo-actions">
611
- <button type="button" onClick={onCarregarModeloRepositorio} disabled={loading || repoModelosLoading || !repoModeloSelecionado}>
612
- Carregar do repositório
613
- </button>
614
- <button type="button" onClick={() => void carregarModelosRepositorio()} disabled={loading || repoModelosLoading}>
615
- Atualizar lista
616
- </button>
617
- </div>
618
- {repoFonteModelos ? <div className="section1-empty-hint">{repoFonteModelos}</div> : null}
619
- </div>
620
- ) : null}
621
-
622
- {modeloLoadSource === 'upload' ? (
623
- <div
624
- className={`upload-dropzone${uploadDragOver ? ' is-dragover' : ''}`}
625
- onDragOver={onUploadDropZoneDragOver}
626
- onDragEnter={onUploadDropZoneDragOver}
627
- onDragLeave={onUploadDropZoneDragLeave}
628
- onDrop={onUploadDropZoneDrop}
629
- >
630
- <input
631
- ref={uploadInputRef}
632
- type="file"
633
- className="upload-hidden-input"
634
- accept=".dai"
635
- onChange={onUploadInputChange}
636
- />
637
- <div className="row upload-dropzone-main">
638
- <button
639
- type="button"
640
- className="btn-upload-select"
641
- onClick={() => uploadInputRef.current?.click()}
642
- disabled={loading}
643
- >
644
- Selecionar arquivo
645
- </button>
646
- </div>
647
- <div className="upload-dropzone-hint">Ou arraste e solte aqui para carregar automaticamente.</div>
648
- </div>
649
- ) : null}
650
- </div>
651
- )}
652
- {status ? <div className="status-line">{status}</div> : null}
653
- {badgeHtml ? <div className="upload-badge-block" dangerouslySetInnerHTML={{ __html: badgeHtml }} /> : null}
654
- </SectionBlock>
655
-
656
- {modeloCarregado ? (
657
- <SectionBlock step="2" title="Conteúdo do Modelo" subtitle="Carregue o modelo no topo e navegue pelas abas internas abaixo.">
658
- <div className="inner-tabs" role="tablist" aria-label="Abas internas de visualização">
659
- {INNER_TABS.map((tab) => (
660
- <button
661
- key={tab.key}
662
- type="button"
663
- className={activeInnerTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
664
- onClick={() => onInnerTabSelect(tab.key)}
665
- >
666
- {tab.label}
667
- </button>
668
- ))}
669
- </div>
670
-
671
- <div className="inner-tab-panel">
672
- {activeInnerTab === 'mapa' ? (
673
- !loadedTabs.mapa ? (
674
- <div className="empty-box">Carregando mapa do modelo...</div>
675
- ) : (
676
- <>
677
- <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
678
- <label className="pesquisa-field pesquisa-mapa-modo-field">
679
- Variável no mapa
680
- <select value={mapaVar} onChange={(e) => onMapChange(e.target.value)}>
681
- {mapaChoices.map((choice) => (
682
- <option key={choice} value={choice}>{choice}</option>
683
- ))}
684
- </select>
685
- </label>
686
- <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
687
- Exibição dos trabalhos técnicos
688
- <select
689
- value={mapaTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
690
- ? TRABALHOS_TECNICOS_MODELOS_PADRAO
691
- : mapaTrabalhosTecnicosModelosModo}
692
- onChange={(event) => void onMapTrabalhosTecnicosModeChange(event.target.value)}
693
- autoComplete="off"
694
- >
695
- <option value="selecionados">Somente deste modelo</option>
696
- <option value="selecionados_e_outras_versoes">Incluir demais versões do modelo</option>
697
- </select>
698
- </label>
699
- </div>
700
- <MapFrame html={mapaHtml} payload={mapaPayload} sessionId={sessionId} />
701
- </>
702
- )
703
- ) : null}
704
-
705
- {activeInnerTab === 'dados_mercado' ? (
706
- loadedTabs.dados_mercado ? <DataTable table={dados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>
707
- ) : null}
708
-
709
- {activeInnerTab === 'metricas' ? (
710
- loadedTabs.metricas ? <DataTable table={estatisticas} maxHeight={620} /> : <div className="empty-box">Carregando métricas do modelo...</div>
711
- ) : null}
712
-
713
- {activeInnerTab === 'transformacoes' ? (
714
- loadedTabs.transformacoes ? (
715
- <>
716
- <div dangerouslySetInnerHTML={{ __html: escalasHtml }} />
717
- <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
718
- <DataTable table={dadosTransformados} />
719
- </>
720
- ) : (
721
- <div className="empty-box">Carregando transformações do modelo...</div>
722
- )
723
- ) : null}
724
-
725
- {activeInnerTab === 'resumo' ? (
726
- loadedTabs.resumo ? (
727
- <>
728
- <div className="equation-formats-section">
729
- <h4>Equações do Modelo</h4>
730
- <EquationFormatsPanel
731
- equacoes={equacoes}
732
- onDownload={(mode) => void onDownloadEquacao(mode)}
733
- disabled={loading}
734
- />
735
- </div>
736
- <div dangerouslySetInnerHTML={{ __html: resumoHtml }} />
737
- </>
738
- ) : (
739
- <div className="empty-box">Carregando resumo do modelo...</div>
740
- )
741
- ) : null}
742
-
743
- {activeInnerTab === 'coeficientes' ? (
744
- loadedTabs.coeficientes ? <DataTable table={coeficientes} maxHeight={620} /> : <div className="empty-box">Carregando coeficientes do modelo...</div>
745
- ) : null}
746
-
747
- {activeInnerTab === 'obs_calc' ? (
748
- loadedTabs.obs_calc ? <DataTable table={obsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>
749
- ) : null}
750
-
751
- {activeInnerTab === 'graficos' ? (
752
- loadedTabs.graficos ? (
753
- <>
754
- <div className="plot-grid-2-fixed">
755
- <PlotFigure figure={plotObsCalc} title="Obs x Calc" />
756
- <PlotFigure figure={plotResiduos} title="Resíduos" />
757
- <PlotFigure figure={plotHistograma} title="Histograma" />
758
- <PlotFigure figure={plotCook} title="Cook" forceHideLegend />
759
- </div>
760
- <div className="plot-full-width">
761
- <PlotFigure figure={plotCorr} title="Correlação" className="plot-correlation-card" />
762
- </div>
763
- </>
764
- ) : (
765
- <div className="empty-box">Carregando gráficos do modelo...</div>
766
- )
767
- ) : null}
768
-
769
- {activeInnerTab === 'avaliacao' ? (
770
- <>
771
- <div className="equation-formats-section avaliacao-equacao-section">
772
- <h5>Equação (estilo SAB)</h5>
773
- <div className="equation-box equation-box-plain">
774
- {equacoes?.excel_sab || 'Equação indisponível.'}
775
- </div>
776
- </div>
777
- <div className="avaliacao-groups">
778
- <div className="subpanel avaliacao-group">
779
- <h4>Parâmetros</h4>
780
- <div className="avaliacao-grid" key={`avaliacao-grid-viz-${avaliacaoFormVersion}`}>
781
- {camposAvaliacao.map((campo) => (
782
- <div key={`campo-${campo.coluna}`} className="avaliacao-card">
783
- <label>{String(campo?.rotulo || campo?.coluna || '')}</label>
784
- {campo.tipo === 'dicotomica' ? (
785
- <select
786
- defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
787
- onChange={(e) => {
788
- valoresAvaliacaoRef.current[campo.coluna] = e.target.value
789
- }}
790
- >
791
- <option value="">Selecione</option>
792
- {(campo.opcoes || [0, 1]).map((opcao) => (
793
- <option key={`op-viz-${campo.coluna}-${opcao}`} value={String(opcao)}>
794
- {opcao}
795
- </option>
796
- ))}
797
- </select>
798
- ) : (
799
- <input
800
- type="number"
801
- defaultValue={valoresAvaliacaoRef.current[campo.coluna] ?? ''}
802
- placeholder={campo.placeholder || ''}
803
- onChange={(e) => {
804
- valoresAvaliacaoRef.current[campo.coluna] = e.target.value
805
- }}
806
- />
807
- )}
808
- </div>
809
- ))}
810
- </div>
811
- <div className="row-wrap avaliacao-actions-row">
812
- <button onClick={onCalcularAvaliacao} disabled={loading}>Calcular</button>
813
- <button onClick={onResetCamposAvaliacao} disabled={loading}>Resetar campos</button>
814
- </div>
815
- </div>
816
-
817
- {temAvaliacoes ? (
818
- <div className="subpanel avaliacao-group">
819
- <h4>Avaliações</h4>
820
- <div className="row avaliacao-base-row">
821
- <label>Base comparação</label>
822
- <select value={baseValue || ''} onChange={(e) => onBaseChange(e.target.value)}>
823
- <option value={BASE_COMPARACAO_SEM_BASE}>Sem base</option>
824
- {baseChoices.map((choice) => (
825
- <option key={`base-${choice}`} value={choice}>{choice}</option>
826
- ))}
827
- </select>
828
- <button type="button" className="btn-avaliacao-export" onClick={onExportAvaliacoes} disabled={loading}>
829
- Exportar avaliações
830
- </button>
831
- {!confirmarLimpezaAvaliacoes ? (
832
- <button
833
- type="button"
834
- className="btn-avaliacao-clear"
835
- onClick={() => setConfirmarLimpezaAvaliacoes(true)}
836
- disabled={loading}
837
- >
838
- Limpar avaliações
839
- </button>
840
- ) : (
841
- <div className="avaliacao-clear-confirm avaliacao-clear-confirm-inline">
842
- <span>Confirmar limpeza?</span>
843
- <button type="button" className="btn-avaliacao-clear" onClick={onClearAvaliacao} disabled={loading}>
844
- Confirmar
845
- </button>
846
- <button type="button" onClick={() => setConfirmarLimpezaAvaliacoes(false)} disabled={loading}>
847
- Cancelar
848
- </button>
849
- </div>
850
- )}
851
- </div>
852
- <div
853
- className="avaliacao-resultado-box"
854
- onClick={onAvaliacaoResultadoClick}
855
- dangerouslySetInnerHTML={{ __html: resultadoAvaliacaoHtml }}
856
- />
857
- </div>
858
- ) : null}
859
- </div>
860
- </>
861
- ) : null}
862
-
863
- {activeInnerTab === 'avaliacao_massa' ? (
864
- <div className="empty-box">Módulo em desenvolvimento.</div>
865
- ) : null}
866
- </div>
867
- </SectionBlock>
868
- ) : null}
869
-
870
- <LoadingOverlay
871
- show={loading || Boolean(loadingTabs[activeInnerTab])}
872
- label={loading ? 'Processando dados...' : getVisualizacaoLoadingLabel(activeInnerTab)}
873
- />
874
- {error ? <div className="error-line">{error}</div> : null}
875
- </div>
876
- )
877
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/styles.css CHANGED
@@ -410,6 +410,13 @@ textarea {
410
  align-items: flex-start;
411
  gap: 4px;
412
  }
 
 
 
 
 
 
 
413
  }
414
 
415
  .repositorio-standalone-panel {
@@ -907,9 +914,8 @@ textarea {
907
 
908
  .trabalho-tecnico-summary-grid {
909
  display: grid;
910
- grid-template-columns: repeat(2, minmax(0, 1fr));
911
  gap: 12px;
912
- margin-bottom: 12px;
913
  }
914
 
915
  .trabalho-tecnico-card {
@@ -937,6 +943,25 @@ textarea {
937
  gap: 8px;
938
  }
939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
940
  .trabalho-tecnico-meta-row {
941
  display: flex;
942
  justify-content: space-between;
@@ -945,6 +970,10 @@ textarea {
945
  color: #344e65;
946
  }
947
 
 
 
 
 
948
  .trabalho-tecnico-meta-row-block {
949
  align-items: flex-start;
950
  }
@@ -963,6 +992,35 @@ textarea {
963
  line-height: 1.5;
964
  }
965
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
966
  .trabalho-tecnico-kpis {
967
  display: grid;
968
  grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -1007,6 +1065,10 @@ textarea {
1007
  line-height: 1.4;
1008
  }
1009
 
 
 
 
 
1010
  .trabalho-imovel-coords {
1011
  font-family: 'IBM Plex Mono', monospace;
1012
  }
@@ -1134,6 +1196,41 @@ textarea {
1134
  resize: vertical;
1135
  }
1136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1137
  .trabalho-edit-imoveis-stack {
1138
  display: grid;
1139
  gap: 10px;
@@ -1259,7 +1356,8 @@ textarea {
1259
  padding-bottom: 0;
1260
  }
1261
 
1262
- .trabalho-tecnico-summary-grid {
 
1263
  grid-template-columns: 1fr;
1264
  }
1265
 
@@ -6708,6 +6806,7 @@ button.import-preview-clear-btn {
6708
  align-items: flex-start;
6709
  justify-content: space-between;
6710
  gap: 10px;
 
6711
  position: relative;
6712
  z-index: 2;
6713
  }
@@ -6722,9 +6821,10 @@ button.import-preview-clear-btn {
6722
  display: inline-flex;
6723
  align-items: center;
6724
  justify-content: flex-end;
6725
- gap: 8px;
6726
  flex-wrap: wrap;
6727
  flex-shrink: 0;
 
6728
  }
6729
 
6730
  .plot-card-download-btn {
@@ -6732,6 +6832,54 @@ button.import-preview-clear-btn {
6732
  padding: 6px 9px;
6733
  }
6734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6735
  .plot-card-title {
6736
  margin: 0;
6737
  color: #2f465c;
 
410
  align-items: flex-start;
411
  gap: 4px;
412
  }
413
+
414
+ .trabalho-tecnico-meta-row strong,
415
+ .trabalho-link-list,
416
+ .trabalho-meta-link {
417
+ justify-items: start;
418
+ text-align: left;
419
+ }
420
  }
421
 
422
  .repositorio-standalone-panel {
 
914
 
915
  .trabalho-tecnico-summary-grid {
916
  display: grid;
917
+ grid-template-columns: minmax(0, 1fr);
918
  gap: 12px;
 
919
  }
920
 
921
  .trabalho-tecnico-card {
 
943
  gap: 8px;
944
  }
945
 
946
+ .trabalho-tecnico-meta-list-two-cols {
947
+ grid-template-columns: repeat(2, minmax(0, 1fr));
948
+ gap: 14px 28px;
949
+ }
950
+
951
+ .trabalho-tecnico-meta-list-two-cols .trabalho-tecnico-meta-row {
952
+ display: grid;
953
+ justify-content: stretch;
954
+ align-items: start;
955
+ gap: 4px;
956
+ }
957
+
958
+ .trabalho-tecnico-meta-list-two-cols .trabalho-tecnico-meta-row strong,
959
+ .trabalho-tecnico-meta-list-two-cols .trabalho-link-list,
960
+ .trabalho-tecnico-meta-list-two-cols .trabalho-meta-link {
961
+ justify-items: start;
962
+ text-align: left;
963
+ }
964
+
965
  .trabalho-tecnico-meta-row {
966
  display: flex;
967
  justify-content: space-between;
 
970
  color: #344e65;
971
  }
972
 
973
+ .trabalho-tecnico-meta-row strong {
974
+ text-align: right;
975
+ }
976
+
977
  .trabalho-tecnico-meta-row-block {
978
  align-items: flex-start;
979
  }
 
992
  line-height: 1.5;
993
  }
994
 
995
+ .trabalho-link-list {
996
+ display: grid;
997
+ justify-items: end;
998
+ gap: 4px;
999
+ min-width: 0;
1000
+ text-align: right;
1001
+ }
1002
+
1003
+ .trabalho-meta-link {
1004
+ color: #1f5d8d;
1005
+ font-weight: 800;
1006
+ line-height: 1.35;
1007
+ text-align: right;
1008
+ overflow-wrap: anywhere;
1009
+ text-decoration: underline;
1010
+ text-decoration-color: rgba(31, 93, 141, 0.28);
1011
+ text-underline-offset: 2px;
1012
+ }
1013
+
1014
+ .trabalho-meta-link:hover {
1015
+ color: #164a72;
1016
+ text-decoration-color: rgba(31, 93, 141, 0.58);
1017
+ }
1018
+
1019
+ .trabalho-coordinates-list {
1020
+ font-family: 'IBM Plex Mono', monospace;
1021
+ font-size: 0.86rem;
1022
+ }
1023
+
1024
  .trabalho-tecnico-kpis {
1025
  display: grid;
1026
  grid-template-columns: repeat(5, minmax(0, 1fr));
 
1065
  line-height: 1.4;
1066
  }
1067
 
1068
+ .trabalho-imovel-address {
1069
+ overflow-wrap: anywhere;
1070
+ }
1071
+
1072
  .trabalho-imovel-coords {
1073
  font-family: 'IBM Plex Mono', monospace;
1074
  }
 
1196
  resize: vertical;
1197
  }
1198
 
1199
+ .trabalho-coordenadas-editor {
1200
+ border: 1px solid #dbe6f0;
1201
+ border-radius: 10px;
1202
+ background: #fbfdff;
1203
+ padding: 12px;
1204
+ }
1205
+
1206
+ .trabalho-coordenadas-current {
1207
+ display: flex;
1208
+ gap: 10px;
1209
+ flex-wrap: wrap;
1210
+ color: #405a73;
1211
+ font-size: 0.84rem;
1212
+ font-weight: 600;
1213
+ }
1214
+
1215
+ .trabalho-coordenadas-current span {
1216
+ border: 1px solid #dce7f1;
1217
+ border-radius: 8px;
1218
+ background: #ffffff;
1219
+ padding: 7px 9px;
1220
+ }
1221
+
1222
+ .trabalho-coordenadas-current strong {
1223
+ margin-right: 6px;
1224
+ color: #6b849a;
1225
+ font-size: 0.72rem;
1226
+ letter-spacing: 0.04em;
1227
+ text-transform: uppercase;
1228
+ }
1229
+
1230
+ .trabalho-coordenadas-grid {
1231
+ margin: 2px 0 0;
1232
+ }
1233
+
1234
  .trabalho-edit-imoveis-stack {
1235
  display: grid;
1236
  gap: 10px;
 
1356
  padding-bottom: 0;
1357
  }
1358
 
1359
+ .trabalho-tecnico-summary-grid,
1360
+ .trabalho-tecnico-meta-list-two-cols {
1361
  grid-template-columns: 1fr;
1362
  }
1363
 
 
6806
  align-items: flex-start;
6807
  justify-content: space-between;
6808
  gap: 10px;
6809
+ flex-wrap: wrap;
6810
  position: relative;
6811
  z-index: 2;
6812
  }
 
6821
  display: inline-flex;
6822
  align-items: center;
6823
  justify-content: flex-end;
6824
+ gap: 12px;
6825
  flex-wrap: wrap;
6826
  flex-shrink: 0;
6827
+ max-width: 100%;
6828
  }
6829
 
6830
  .plot-card-download-btn {
 
6832
  padding: 6px 9px;
6833
  }
6834
 
6835
+ .plot-card-download-icon-btn {
6836
+ width: 32px;
6837
+ height: 32px;
6838
+ min-width: 32px;
6839
+ padding: 0;
6840
+ display: inline-flex;
6841
+ align-items: center;
6842
+ justify-content: center;
6843
+ }
6844
+
6845
+ .plot-card-download-icon {
6846
+ width: 16px;
6847
+ height: 16px;
6848
+ display: block;
6849
+ fill: none;
6850
+ stroke: currentColor;
6851
+ stroke-width: 2;
6852
+ stroke-linecap: round;
6853
+ stroke-linejoin: round;
6854
+ }
6855
+
6856
+ .scatter-transform-controls {
6857
+ display: inline-flex;
6858
+ align-items: center;
6859
+ gap: 10px;
6860
+ flex-wrap: wrap;
6861
+ }
6862
+
6863
+ .scatter-transform-field {
6864
+ display: inline-flex;
6865
+ align-items: center;
6866
+ gap: 4px;
6867
+ color: #42586e;
6868
+ font-size: 0.76rem;
6869
+ font-weight: 800;
6870
+ line-height: 1;
6871
+ }
6872
+
6873
+ .scatter-transform-field select {
6874
+ width: 92px;
6875
+ min-width: 0;
6876
+ height: 30px;
6877
+ padding: 4px 22px 4px 7px;
6878
+ border-radius: 8px;
6879
+ font-size: 0.73rem;
6880
+ font-weight: 700;
6881
+ }
6882
+
6883
  .plot-card-title {
6884
  margin: 0;
6885
  color: #2f465c;
release/hf-modelos-dai/modelos_dai_hf.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1bcaa29d2cbb87cc57cce70efd5226eada2dc640b3c8f2896456d5a6dc370d52
3
+ size 18436140
release/windows-gh/MesaFrame-portable-run-25354206310.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5de46c0ae3fcc170298bb556025686a1d491234f457abebf28dae4a56794c4f8
3
+ size 158996901
repositorio_mesa_colunas.xlsx ADDED
Binary file (6.84 kB). View file
 
repositorio_mesa_colunas_dai.xlsx ADDED
Binary file (76.5 kB). View file
 
repositorio_mesa_colunas_dai_summary.json ADDED
The diff for this file is too large to render. See raw diff
 
repositorio_mesa_colunas_summary.json ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "repo_id": "gui-sparim/repositorio_mesa",
3
+ "output_xlsx": "/Users/guilhermesilberfarbcosta/Downloads/mesa-frame/repositorio_mesa_colunas.xlsx",
4
+ "summary_json": "/Users/guilhermesilberfarbcosta/Downloads/mesa-frame/repositorio_mesa_colunas_summary.json",
5
+ "tabular_files_found": 106,
6
+ "tabular_files_scanned": 106,
7
+ "tabular_files_skipped": 0,
8
+ "unique_columns": 14,
9
+ "models_found": 5,
10
+ "columns": [
11
+ "ts",
12
+ "event_id",
13
+ "scope",
14
+ "action",
15
+ "status",
16
+ "session_id",
17
+ "usuario",
18
+ "nome",
19
+ "perfil",
20
+ "details",
21
+ "path",
22
+ "method",
23
+ "ip",
24
+ "user_agent"
25
+ ],
26
+ "column_sources": {
27
+ "ts": {
28
+ "model": "logs/auth",
29
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
30
+ },
31
+ "event_id": {
32
+ "model": "logs/auth",
33
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
34
+ },
35
+ "scope": {
36
+ "model": "logs/auth",
37
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
38
+ },
39
+ "action": {
40
+ "model": "logs/auth",
41
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
42
+ },
43
+ "status": {
44
+ "model": "logs/auth",
45
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
46
+ },
47
+ "session_id": {
48
+ "model": "logs/auth",
49
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
50
+ },
51
+ "usuario": {
52
+ "model": "logs/auth",
53
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
54
+ },
55
+ "nome": {
56
+ "model": "logs/auth",
57
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
58
+ },
59
+ "perfil": {
60
+ "model": "logs/auth",
61
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
62
+ },
63
+ "details": {
64
+ "model": "logs/auth",
65
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
66
+ },
67
+ "path": {
68
+ "model": "logs/auth",
69
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
70
+ },
71
+ "method": {
72
+ "model": "logs/auth",
73
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
74
+ },
75
+ "ip": {
76
+ "model": "logs/auth",
77
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
78
+ },
79
+ "user_agent": {
80
+ "model": "logs/auth",
81
+ "file": "logs/auth/2026/03/auth-2026-03-02.jsonl"
82
+ }
83
+ },
84
+ "skipped_files": []
85
+ }
scan_hf_dai_columns.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ import os
7
+ import sys
8
+ from collections import OrderedDict
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import joblib
13
+ import pandas as pd
14
+ from huggingface_hub import HfApi, hf_hub_download
15
+
16
+
17
+ REPO_ID = "gui-sparim/repositorio_mesa"
18
+ OUTPUT_XLSX = "repositorio_mesa_colunas_dai.xlsx"
19
+ SUMMARY_JSON = "repositorio_mesa_colunas_dai_summary.json"
20
+
21
+
22
+ def normalize_value(value: Any) -> str | None:
23
+ if value is None:
24
+ return None
25
+ if isinstance(value, float) and math.isnan(value):
26
+ return None
27
+ try:
28
+ if pd.isna(value):
29
+ return None
30
+ except Exception: # noqa: BLE001
31
+ pass
32
+ if isinstance(value, bytes):
33
+ value = value.decode("utf-8", errors="replace")
34
+ if isinstance(value, (dict, list, tuple, set)):
35
+ value = json.dumps(value, ensure_ascii=False)
36
+ text = str(value).strip()
37
+ return text or None
38
+
39
+
40
+ def first_nonblank_samples(frame: pd.DataFrame, column: str, limit: int = 5) -> list[str]:
41
+ samples: list[str] = []
42
+ for value in frame[column].tolist():
43
+ normalized = normalize_value(value)
44
+ if normalized is not None:
45
+ samples.append(normalized)
46
+ if len(samples) >= limit:
47
+ break
48
+ return samples
49
+
50
+
51
+ def walk_dataframes(node: Any, path: str = "root", seen: set[int] | None = None) -> list[tuple[str, pd.DataFrame]]:
52
+ if seen is None:
53
+ seen = set()
54
+
55
+ node_id = id(node)
56
+ if node_id in seen:
57
+ return []
58
+ seen.add(node_id)
59
+
60
+ results: list[tuple[str, pd.DataFrame]] = []
61
+ if isinstance(node, pd.DataFrame):
62
+ return [(path, node)]
63
+
64
+ if isinstance(node, dict):
65
+ for key, value in node.items():
66
+ results.extend(walk_dataframes(value, f"{path}.{key}", seen))
67
+ return results
68
+
69
+ if isinstance(node, (list, tuple)):
70
+ for index, value in enumerate(node):
71
+ results.extend(walk_dataframes(value, f"{path}[{index}]", seen))
72
+ return results
73
+
74
+ attrs = getattr(node, "__dict__", None)
75
+ if isinstance(attrs, dict):
76
+ for key, value in attrs.items():
77
+ results.extend(walk_dataframes(value, f"{path}.{key}", seen))
78
+
79
+ return results
80
+
81
+
82
+ def main() -> None:
83
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
84
+ if not token:
85
+ raise SystemExit("HF_TOKEN or HUGGINGFACE_HUB_TOKEN is required.")
86
+
87
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
88
+
89
+ api = HfApi(token=token)
90
+ tree = list(api.list_repo_tree(REPO_ID, repo_type="dataset", recursive=True, expand=False))
91
+ dai_files = sorted(
92
+ item.path
93
+ for item in tree
94
+ if getattr(item, "type", "file") == "file" and item.path.lower().endswith(".dai")
95
+ )
96
+
97
+ ordered_columns: OrderedDict[str, list[str]] = OrderedDict()
98
+ sources: OrderedDict[str, dict[str, str]] = OrderedDict()
99
+ dataframe_rows: list[dict[str, Any]] = []
100
+ scanned_files: list[str] = []
101
+ skipped_files: list[dict[str, str]] = []
102
+
103
+ for dai_path in dai_files:
104
+ print(f"Scanning {dai_path}...", file=sys.stderr)
105
+ try:
106
+ local_path = hf_hub_download(
107
+ repo_id=REPO_ID,
108
+ repo_type="dataset",
109
+ filename=dai_path,
110
+ token=token,
111
+ )
112
+ obj = joblib.load(local_path)
113
+ dataframes = walk_dataframes(obj)
114
+ scanned_files.append(dai_path)
115
+
116
+ for dataframe_path, frame in dataframes:
117
+ dataframe_rows.append(
118
+ {
119
+ "dai_file": dai_path,
120
+ "dataframe_path": dataframe_path,
121
+ "row_count": int(frame.shape[0]),
122
+ "column_count": int(frame.shape[1]),
123
+ }
124
+ )
125
+ for column in frame.columns:
126
+ if column in ordered_columns:
127
+ continue
128
+ samples = first_nonblank_samples(frame, column)
129
+ ordered_columns[column] = samples
130
+ sources[column] = {
131
+ "dai_file": dai_path,
132
+ "dataframe_path": dataframe_path,
133
+ }
134
+ except Exception as exc: # noqa: BLE001
135
+ skipped_files.append({"dai_file": dai_path, "error": str(exc)})
136
+ print(f"Skipped {dai_path}: {exc}", file=sys.stderr)
137
+
138
+ if not ordered_columns:
139
+ raise SystemExit("No dataframe columns were extracted from the .dai files.")
140
+
141
+ workbook_data = {
142
+ column: ordered_columns[column] + [""] * (5 - len(ordered_columns[column]))
143
+ for column in ordered_columns
144
+ }
145
+ columns_df = pd.DataFrame(workbook_data)
146
+ origins_df = pd.DataFrame(
147
+ [
148
+ {
149
+ "column_name": column,
150
+ "first_seen_dai_file": source["dai_file"],
151
+ "first_seen_dataframe_path": source["dataframe_path"],
152
+ "sample_count": len(ordered_columns[column]),
153
+ }
154
+ for column, source in sources.items()
155
+ ]
156
+ )
157
+ dataframes_df = pd.DataFrame(dataframe_rows)
158
+ skipped_df = pd.DataFrame(skipped_files or [{"dai_file": "", "error": ""}])
159
+
160
+ output_path = Path(OUTPUT_XLSX).resolve()
161
+ summary_path = Path(SUMMARY_JSON).resolve()
162
+
163
+ with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
164
+ columns_df.to_excel(writer, sheet_name="colunas", index=False)
165
+ origins_df.to_excel(writer, sheet_name="origem_colunas", index=False)
166
+ dataframes_df.to_excel(writer, sheet_name="dataframes_lidos", index=False)
167
+ skipped_df.to_excel(writer, sheet_name="arquivos_pulados", index=False)
168
+
169
+ summary = {
170
+ "repo_id": REPO_ID,
171
+ "output_xlsx": str(output_path),
172
+ "summary_json": str(summary_path),
173
+ "dai_files_found": len(dai_files),
174
+ "dai_files_scanned": len(scanned_files),
175
+ "dai_files_skipped": len(skipped_files),
176
+ "dataframes_found": len(dataframe_rows),
177
+ "unique_columns": len(ordered_columns),
178
+ "columns": list(ordered_columns.keys()),
179
+ "sources": sources,
180
+ "skipped_files": skipped_files,
181
+ }
182
+ summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
183
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()