Guilherme Silberfarb Costa commited on
Commit
9e7c650
·
1 Parent(s): 696b48a

multiplas alteracoes

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Mesa React
3
  emoji: "🌖"
4
  colorFrom: blue
5
  colorTo: indigo
@@ -8,7 +8,7 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # MESA Frame (FastAPI + React)
12
 
13
  Rearquitetura do app MESA com:
14
 
@@ -29,12 +29,15 @@ Rearquitetura do app MESA com:
29
 
30
  ```bash
31
  cd backend
32
- python -m venv .venv
33
  source .venv/bin/activate
34
  pip install -r requirements.txt
35
  ./run_backend.sh
36
  ```
37
 
 
 
 
38
  API: `http://localhost:8000`
39
  Swagger: `http://localhost:8000/docs`
40
 
 
1
  ---
2
+ title: Mesa React Beta
3
  emoji: "🌖"
4
  colorFrom: blue
5
  colorTo: indigo
 
8
  pinned: false
9
  ---
10
 
11
+ # MESA React Beta (FastAPI + React)
12
 
13
  Rearquitetura do app MESA com:
14
 
 
29
 
30
  ```bash
31
  cd backend
32
+ python3.12 -m venv .venv
33
  source .venv/bin/activate
34
  pip install -r requirements.txt
35
  ./run_backend.sh
36
  ```
37
 
38
+ Observacao: o stack geoespacial do backend fecha melhor com Python 3.12 no macOS.
39
+ Se `backend/.venv` existir, `./run_backend.sh` passa a usa-lo automaticamente.
40
+
41
  API: `http://localhost:8000`
42
  Swagger: `http://localhost:8000/docs`
43
 
backend/app/api/pesquisa.py CHANGED
@@ -9,6 +9,7 @@ from pydantic import BaseModel
9
  from app.services.pesquisa_service import (
10
  PesquisaFiltros,
11
  gerar_mapa_modelos,
 
12
  listar_modelos,
13
  obter_admin_config_pesquisa,
14
  resolver_localizacao_avaliando,
@@ -83,6 +84,11 @@ def pesquisar_admin_config_salvar(payload: PesquisaAdminConfigPayload) -> dict:
83
  return salvar_admin_config_pesquisa(payload.campos)
84
 
85
 
 
 
 
 
 
86
  @router.get("/modelos")
87
  def pesquisar_modelos(
88
  somente_contexto: bool = Query(False),
 
9
  from app.services.pesquisa_service import (
10
  PesquisaFiltros,
11
  gerar_mapa_modelos,
12
+ listar_logradouros_eixos,
13
  listar_modelos,
14
  obter_admin_config_pesquisa,
15
  resolver_localizacao_avaliando,
 
84
  return salvar_admin_config_pesquisa(payload.campos)
85
 
86
 
87
+ @router.get("/logradouros-eixos")
88
+ def pesquisar_logradouros_eixos(limite: int = Query(2000, ge=1, le=20000)) -> dict:
89
+ return listar_logradouros_eixos(limite=limite)
90
+
91
+
92
  @router.get("/modelos")
93
  def pesquisar_modelos(
94
  somente_contexto: bool = Query(False),
backend/app/api/visualizacao.py CHANGED
@@ -28,6 +28,11 @@ class MapaPayload(SessionPayload):
28
  trabalhos_tecnicos_modelos_modo: str | None = None
29
 
30
 
 
 
 
 
 
31
  class MapaPopupPayload(SessionPayload):
32
  row_id: int
33
 
@@ -110,6 +115,18 @@ def exibir(payload: ExibirPayload, request: Request) -> dict[str, Any]:
110
  )
111
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  @router.post("/evaluation/context")
114
  def evaluation_context(payload: SessionPayload) -> dict[str, Any]:
115
  session = session_store.get(payload.session_id)
 
28
  trabalhos_tecnicos_modelos_modo: str | None = None
29
 
30
 
31
+ class SecaoPayload(SessionPayload):
32
+ secao: str
33
+ trabalhos_tecnicos_modelos_modo: str | None = None
34
+
35
+
36
  class MapaPopupPayload(SessionPayload):
37
  row_id: int
38
 
 
115
  )
116
 
117
 
118
+ @router.post("/section")
119
+ def section(payload: SecaoPayload, request: Request) -> dict[str, Any]:
120
+ session = session_store.get(payload.session_id)
121
+ return visualizacao_service.carregar_secao_modelo(
122
+ session,
123
+ payload.secao,
124
+ trabalhos_tecnicos_modelos_modo=payload.trabalhos_tecnicos_modelos_modo,
125
+ api_base_url=str(request.base_url).rstrip("/"),
126
+ popup_auth_token=getattr(request.state, "auth_token", None),
127
+ )
128
+
129
+
130
  @router.post("/evaluation/context")
131
  def evaluation_context(payload: SessionPayload) -> dict[str, Any]:
132
  session = session_store.get(payload.session_id)
backend/app/core/dados/Bairros_LC12112_16.shp ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1446d8cc7fdff70154b792fd1cf78b251d064ce38a58c8a81128702d2352967a
3
+ size 5562340
backend/app/core/map_layers.py CHANGED
@@ -16,18 +16,34 @@ _SIMPLIFY_TOLERANCE = 0.00005
16
 
17
  _BAIRROS_CACHE_LOCK = Lock()
18
  _BAIRROS_GEOJSON_CACHE: dict[str, Any] | None = None
19
- _BAIRROS_GEOJSON_CARREGADO = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  def _carregar_bairros_geojson() -> dict[str, Any] | None:
23
- global _BAIRROS_GEOJSON_CACHE, _BAIRROS_GEOJSON_CARREGADO
 
 
 
 
24
  with _BAIRROS_CACHE_LOCK:
25
- if _BAIRROS_GEOJSON_CARREGADO:
26
  return _BAIRROS_GEOJSON_CACHE
27
- _BAIRROS_GEOJSON_CARREGADO = True
28
 
29
- if not _BAIRROS_SHP_PATH.exists():
30
- return None
31
 
32
  try:
33
  import geopandas as gpd
@@ -54,7 +70,12 @@ def _carregar_bairros_geojson() -> dict[str, Any] | None:
54
  geojson = None
55
 
56
  with _BAIRROS_CACHE_LOCK:
57
- _BAIRROS_GEOJSON_CACHE = geojson
 
 
 
 
 
58
  return geojson
59
 
60
 
 
16
 
17
  _BAIRROS_CACHE_LOCK = Lock()
18
  _BAIRROS_GEOJSON_CACHE: dict[str, Any] | None = None
19
+ _BAIRROS_SOURCE_SIGNATURE: tuple[tuple[str, int, int], ...] | None = None
20
+
21
+
22
+ def _assinatura_bairros_source() -> tuple[tuple[str, int, int], ...] | None:
23
+ arquivos = sorted(_BAIRROS_SHP_PATH.parent.glob(f"{_BAIRROS_SHP_PATH.stem}.*"))
24
+ if not arquivos:
25
+ return None
26
+
27
+ assinatura: list[tuple[str, int, int]] = []
28
+ for caminho in arquivos:
29
+ try:
30
+ stat = caminho.stat()
31
+ except OSError:
32
+ continue
33
+ assinatura.append((caminho.name, int(stat.st_mtime_ns), int(stat.st_size)))
34
+ return tuple(assinatura) or None
35
 
36
 
37
  def _carregar_bairros_geojson() -> dict[str, Any] | None:
38
+ global _BAIRROS_GEOJSON_CACHE, _BAIRROS_SOURCE_SIGNATURE
39
+ assinatura = _assinatura_bairros_source()
40
+ if assinatura is None or not _BAIRROS_SHP_PATH.exists():
41
+ return None
42
+
43
  with _BAIRROS_CACHE_LOCK:
44
+ if _BAIRROS_SOURCE_SIGNATURE == assinatura and _BAIRROS_GEOJSON_CACHE is not None:
45
  return _BAIRROS_GEOJSON_CACHE
 
46
 
 
 
47
 
48
  try:
49
  import geopandas as gpd
 
70
  geojson = None
71
 
72
  with _BAIRROS_CACHE_LOCK:
73
+ if geojson is not None:
74
+ _BAIRROS_GEOJSON_CACHE = geojson
75
+ _BAIRROS_SOURCE_SIGNATURE = assinatura
76
+ else:
77
+ _BAIRROS_GEOJSON_CACHE = None
78
+ _BAIRROS_SOURCE_SIGNATURE = None
79
  return geojson
80
 
81
 
backend/app/models/session.py CHANGED
@@ -53,6 +53,7 @@ class SessionState:
53
  pacote_visualizacao: dict[str, Any] | None = None
54
  dados_visualizacao: pd.DataFrame | None = None
55
  avaliacoes_visualizacao: list[dict[str, Any]] = field(default_factory=list)
 
56
  graficos_dispersao_cache: dict[str, dict[str, Any]] = field(default_factory=dict)
57
 
58
  elaborador: dict[str, Any] | None = None
@@ -73,3 +74,4 @@ class SessionState:
73
  self.pacote_visualizacao = None
74
  self.dados_visualizacao = None
75
  self.avaliacoes_visualizacao = []
 
 
53
  pacote_visualizacao: dict[str, Any] | None = None
54
  dados_visualizacao: pd.DataFrame | None = None
55
  avaliacoes_visualizacao: list[dict[str, Any]] = field(default_factory=list)
56
+ visualizacao_cache: dict[str, Any] = field(default_factory=dict)
57
  graficos_dispersao_cache: dict[str, dict[str, Any]] = field(default_factory=dict)
58
 
59
  elaborador: dict[str, Any] | None = None
 
74
  self.pacote_visualizacao = None
75
  self.dados_visualizacao = None
76
  self.avaliacoes_visualizacao = []
77
+ self.visualizacao_cache = {}
backend/app/services/elaboracao_service.py CHANGED
@@ -383,7 +383,7 @@ def _montar_payload_dispersao(
383
 
384
  usar_png = _usar_png_dispersao(int(total_pontos))
385
  if not usar_png:
386
- session.graficos_dispersao_cache.pop(key, None)
387
  return {
388
  "modo": "interativo",
389
  "grafico": payload_interativo,
@@ -419,6 +419,99 @@ def _montar_payload_dispersao(
419
  }
420
 
421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  def _clean_int_list(values: list[Any] | None) -> list[int]:
423
  if not values:
424
  return []
@@ -1414,6 +1507,10 @@ def fit_model(
1414
 
1415
  graficos = charts.criar_painel_diagnostico(resultado)
1416
  usar_png_diagnosticos = _usar_png_dispersao(total_pontos_modelo)
 
 
 
 
1417
  obs_calc_png = _renderizar_png_grafico(graficos.get("obs_calc")) if usar_png_diagnosticos else None
1418
  residuos_png = _renderizar_png_grafico(graficos.get("residuos")) if usar_png_diagnosticos else None
1419
  histograma_png = _renderizar_png_grafico(graficos.get("histograma")) if usar_png_diagnosticos else None
@@ -1425,7 +1522,7 @@ def fit_model(
1425
  "secao15_cook": graficos.get("cook"),
1426
  }
1427
  for cache_key, fig_diag in diagnosticos_cache_map.items():
1428
- if usar_png_diagnosticos and fig_diag is not None:
1429
  payload_diag = figure_to_payload(fig_diag)
1430
  if payload_diag:
1431
  session.graficos_dispersao_cache[cache_key] = payload_diag
@@ -1499,10 +1596,10 @@ def fit_model(
1499
  "graficos_diagnostico_modo": "png" if usar_png_diagnosticos else "interativo",
1500
  "graficos_diagnostico_total_pontos": total_pontos_modelo,
1501
  "graficos_diagnostico_limiar_png": LIMIAR_DISPERSAO_PNG,
1502
- "grafico_obs_calc": None if usar_png_diagnosticos else figure_to_payload(graficos.get("obs_calc")),
1503
- "grafico_residuos": None if usar_png_diagnosticos else figure_to_payload(graficos.get("residuos")),
1504
- "grafico_histograma": None if usar_png_diagnosticos else figure_to_payload(graficos.get("histograma")),
1505
- "grafico_cook": None if usar_png_diagnosticos else figure_to_payload(graficos.get("cook")),
1506
  "grafico_obs_calc_png": obs_calc_png,
1507
  "grafico_residuos_png": residuos_png,
1508
  "grafico_histograma_png": histograma_png,
@@ -1660,6 +1757,7 @@ def obter_grafico_dispersao_interativo(session: SessionState, alvo: str) -> dict
1660
  return {
1661
  "alvo": key,
1662
  "grafico": sanitize_value(grafico),
 
1663
  }
1664
 
1665
 
@@ -1682,6 +1780,7 @@ def obter_grafico_diagnostico_interativo(session: SessionState, grafico: str) ->
1682
  return {
1683
  "grafico": alvo,
1684
  "payload": sanitize_value(payload),
 
1685
  }
1686
 
1687
 
 
383
 
384
  usar_png = _usar_png_dispersao(int(total_pontos))
385
  if not usar_png:
386
+ session.graficos_dispersao_cache[key] = payload_interativo
387
  return {
388
  "modo": "interativo",
389
  "grafico": payload_interativo,
 
419
  }
420
 
421
 
422
+ def _trace_mode_includes_markers(mode: Any) -> bool:
423
+ return "markers" in str(mode or "").strip().lower()
424
+
425
+
426
+ def _extrair_sequencia_payload(valores: Any) -> list[Any]:
427
+ if isinstance(valores, list):
428
+ return valores
429
+ if isinstance(valores, tuple):
430
+ return list(valores)
431
+ if isinstance(valores, dict):
432
+ dtype_text = str(valores.get("dtype") or "").strip()
433
+ bdata_text = str(valores.get("bdata") or "").strip()
434
+ if dtype_text and bdata_text:
435
+ try:
436
+ buffer = base64.b64decode(bdata_text)
437
+ array = np.frombuffer(buffer, dtype=np.dtype(dtype_text))
438
+ return array.tolist()
439
+ except Exception:
440
+ return []
441
+ return []
442
+ try:
443
+ return list(valores)
444
+ except Exception:
445
+ return []
446
+
447
+
448
+ def _coletar_rotulos_indices_trace_payload(trace: dict[str, Any] | None) -> list[str]:
449
+ if not isinstance(trace, dict):
450
+ return []
451
+ if not _trace_mode_includes_markers(trace.get("mode")):
452
+ return []
453
+
454
+ for source_key in ("customdata", "ids", "text"):
455
+ valores = _extrair_sequencia_payload(trace.get(source_key))
456
+ if not valores:
457
+ continue
458
+ rotulos: list[str] = []
459
+ for item in valores:
460
+ if isinstance(item, (list, tuple)):
461
+ texto = next((str(sub).strip() for sub in item if str(sub).strip()), "")
462
+ elif isinstance(item, dict):
463
+ texto = str(
464
+ item.get("indice")
465
+ or item.get("Indice")
466
+ or item.get("Índice")
467
+ or "",
468
+ ).strip()
469
+ else:
470
+ texto = str(item or "").strip()
471
+ rotulos.append(texto)
472
+ if any(rotulos):
473
+ return rotulos
474
+ return []
475
+
476
+
477
+ def _payload_grafico_com_indices(payload: Any) -> dict[str, Any] | None:
478
+ payload_sanitizado = sanitize_value(payload)
479
+ if not isinstance(payload_sanitizado, dict):
480
+ return None
481
+
482
+ clone = json.loads(json.dumps(payload_sanitizado))
483
+ data = clone.get("data")
484
+ if not isinstance(data, list):
485
+ return None
486
+
487
+ alterado = False
488
+
489
+ for trace in data:
490
+ if not isinstance(trace, dict):
491
+ continue
492
+ rotulos = _coletar_rotulos_indices_trace_payload(trace)
493
+ if not rotulos:
494
+ continue
495
+ mode_parts = [part.strip() for part in str(trace.get("mode") or "markers").split("+") if part.strip()]
496
+ if "text" not in mode_parts:
497
+ mode_parts.append("text")
498
+ if "markers" not in mode_parts:
499
+ mode_parts.append("markers")
500
+ trace["mode"] = "+".join(mode_parts)
501
+ trace["text"] = rotulos
502
+ trace["textposition"] = trace.get("textposition") or "top center"
503
+ base_textfont = trace.get("textfont") if isinstance(trace.get("textfont"), dict) else {}
504
+ trace["textfont"] = {
505
+ "size": 10,
506
+ "color": "#243746",
507
+ **base_textfont,
508
+ }
509
+ trace["cliponaxis"] = False
510
+ alterado = True
511
+
512
+ return clone if alterado else None
513
+
514
+
515
  def _clean_int_list(values: list[Any] | None) -> list[int]:
516
  if not values:
517
  return []
 
1507
 
1508
  graficos = charts.criar_painel_diagnostico(resultado)
1509
  usar_png_diagnosticos = _usar_png_dispersao(total_pontos_modelo)
1510
+ obs_calc_payload = None if usar_png_diagnosticos else figure_to_payload(graficos.get("obs_calc"))
1511
+ residuos_payload = None if usar_png_diagnosticos else figure_to_payload(graficos.get("residuos"))
1512
+ histograma_payload = None if usar_png_diagnosticos else figure_to_payload(graficos.get("histograma"))
1513
+ cook_payload = None if usar_png_diagnosticos else figure_to_payload(graficos.get("cook"))
1514
  obs_calc_png = _renderizar_png_grafico(graficos.get("obs_calc")) if usar_png_diagnosticos else None
1515
  residuos_png = _renderizar_png_grafico(graficos.get("residuos")) if usar_png_diagnosticos else None
1516
  histograma_png = _renderizar_png_grafico(graficos.get("histograma")) if usar_png_diagnosticos else None
 
1522
  "secao15_cook": graficos.get("cook"),
1523
  }
1524
  for cache_key, fig_diag in diagnosticos_cache_map.items():
1525
+ if fig_diag is not None:
1526
  payload_diag = figure_to_payload(fig_diag)
1527
  if payload_diag:
1528
  session.graficos_dispersao_cache[cache_key] = payload_diag
 
1596
  "graficos_diagnostico_modo": "png" if usar_png_diagnosticos else "interativo",
1597
  "graficos_diagnostico_total_pontos": total_pontos_modelo,
1598
  "graficos_diagnostico_limiar_png": LIMIAR_DISPERSAO_PNG,
1599
+ "grafico_obs_calc": obs_calc_payload,
1600
+ "grafico_residuos": residuos_payload,
1601
+ "grafico_histograma": histograma_payload,
1602
+ "grafico_cook": cook_payload,
1603
  "grafico_obs_calc_png": obs_calc_png,
1604
  "grafico_residuos_png": residuos_png,
1605
  "grafico_histograma_png": histograma_png,
 
1757
  return {
1758
  "alvo": key,
1759
  "grafico": sanitize_value(grafico),
1760
+ "grafico_com_indices": _payload_grafico_com_indices(grafico),
1761
  }
1762
 
1763
 
 
1780
  return {
1781
  "grafico": alvo,
1782
  "payload": sanitize_value(payload),
1783
+ "payload_com_indices": _payload_grafico_com_indices(payload),
1784
  }
1785
 
1786
 
backend/app/services/pesquisa_service.py CHANGED
@@ -287,7 +287,7 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
287
  todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
288
  colunas_filtro = _montar_config_colunas_filtro(todos)
289
  admin_fontes = _carregar_fontes_admin(colunas_filtro)
290
- sugestoes = _extrair_sugestoes(todos, admin_fontes)
291
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(filtros_exec.aval_lat, filtros_exec.aval_lon)
292
  avaliandos_geo = _normalizar_avaliandos_geo(filtros_exec.avaliandos_geo)
293
  if (aval_lat is None or aval_lon is None) and len(avaliandos_geo) == 1:
@@ -3057,6 +3057,7 @@ def _extrair_sugestoes(
3057
  modelos: list[dict[str, Any]],
3058
  fontes_admin: dict[str, list[str]] | None = None,
3059
  limite: int = 200,
 
3060
  ) -> dict[str, list[str]]:
3061
  fontes_admin = fontes_admin or {}
3062
  nomes: list[str] = []
@@ -3087,13 +3088,14 @@ def _extrair_sugestoes(
3087
  tipos_modelo.append(str(_tipo_modelo_modelo(modelo) or ""))
3088
  zonas_avaliacao.extend(_zonas_avaliacao_modelo(modelo))
3089
 
3090
- try:
3091
- logradouros_eixos = [
3092
- str(item.get("logradouro") or "").strip()
3093
- for item in _carregar_catalogo_vias()
3094
- ]
3095
- except HTTPException:
3096
- logradouros_eixos = []
 
3097
 
3098
  return {
3099
  "nomes_modelo": _lista_textos_unicos(nomes, limite),
@@ -3107,6 +3109,24 @@ def _extrair_sugestoes(
3107
  }
3108
 
3109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3110
  def _nomes_modelo_sugestao(modelo: dict[str, Any]) -> list[str]:
3111
  nomes: list[str] = []
3112
  vistos = set()
 
287
  todos = [_carregar_resumo_com_cache(caminho) for caminho in modelos]
288
  colunas_filtro = _montar_config_colunas_filtro(todos)
289
  admin_fontes = _carregar_fontes_admin(colunas_filtro)
290
+ sugestoes = _extrair_sugestoes(todos, admin_fontes, incluir_logradouros_eixos=False)
291
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(filtros_exec.aval_lat, filtros_exec.aval_lon)
292
  avaliandos_geo = _normalizar_avaliandos_geo(filtros_exec.avaliandos_geo)
293
  if (aval_lat is None or aval_lon is None) and len(avaliandos_geo) == 1:
 
3057
  modelos: list[dict[str, Any]],
3058
  fontes_admin: dict[str, list[str]] | None = None,
3059
  limite: int = 200,
3060
+ incluir_logradouros_eixos: bool = True,
3061
  ) -> dict[str, list[str]]:
3062
  fontes_admin = fontes_admin or {}
3063
  nomes: list[str] = []
 
3088
  tipos_modelo.append(str(_tipo_modelo_modelo(modelo) or ""))
3089
  zonas_avaliacao.extend(_zonas_avaliacao_modelo(modelo))
3090
 
3091
+ if incluir_logradouros_eixos:
3092
+ try:
3093
+ logradouros_eixos = [
3094
+ str(item.get("logradouro") or "").strip()
3095
+ for item in _carregar_catalogo_vias()
3096
+ ]
3097
+ except HTTPException:
3098
+ logradouros_eixos = []
3099
 
3100
  return {
3101
  "nomes_modelo": _lista_textos_unicos(nomes, limite),
 
3109
  }
3110
 
3111
 
3112
+ def listar_logradouros_eixos(limite: int | None = None) -> dict[str, Any]:
3113
+ try:
3114
+ logradouros = [
3115
+ str(item.get("logradouro") or "").strip()
3116
+ for item in _carregar_catalogo_vias()
3117
+ ]
3118
+ except HTTPException:
3119
+ logradouros = []
3120
+
3121
+ itens = _lista_textos_unicos(logradouros, limite)
3122
+ return sanitize_value(
3123
+ {
3124
+ "logradouros_eixos": itens,
3125
+ "total_logradouros": len(itens),
3126
+ }
3127
+ )
3128
+
3129
+
3130
  def _nomes_modelo_sugestao(modelo: dict[str, Any]) -> list[str]:
3131
  nomes: list[str] = []
3132
  vistos = set()
backend/app/services/trabalhos_tecnicos_service.py CHANGED
@@ -6,6 +6,7 @@ import math
6
  import sqlite3
7
  from pathlib import Path
8
  from statistics import median
 
9
  from typing import Any
10
 
11
  import folium
@@ -33,6 +34,12 @@ MAPA_TRABALHOS_CLUSTERIZADO = "clusterizado"
33
  MAPA_TRABALHOS_PONTOS = "pontos"
34
 
35
 
 
 
 
 
 
 
36
  def _connect_database(path: Path) -> sqlite3.Connection:
37
  conn = sqlite3.connect(str(path))
38
  conn.row_factory = sqlite3.Row
@@ -170,6 +177,70 @@ def _expandir_chaves_modelo(values: list[str], catalogo: dict[str, dict[str, str
170
  return chaves
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  def _enriquecer_modelos(modelo_nomes: list[str], catalogo: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
174
  enriched: list[dict[str, Any]] = []
175
  for nome in modelo_nomes:
@@ -426,8 +497,7 @@ def contar_avaliandos_por_modelo(chaves_modelo: list[str]) -> int:
426
  return 0
427
 
428
  try:
429
- resolved = trabalhos_tecnicos_repository.resolve_database()
430
- catalogo_modelos = _catalogo_modelos_mesa()
431
  except Exception:
432
  return 0
433
 
@@ -435,35 +505,10 @@ def contar_avaliandos_por_modelo(chaves_modelo: list[str]) -> int:
435
  if not chaves_selecionadas:
436
  return 0
437
 
438
- conn = _connect_database(resolved.db_path)
439
- try:
440
- overrides = _fetch_overrides(conn)
441
- rows = conn.execute(
442
- "SELECT trabalho_id, modelo_nome FROM trabalho_modelos ORDER BY trabalho_id, ordem, LOWER(modelo_nome)"
443
- ).fetchall()
444
- finally:
445
- try:
446
- conn.close()
447
- except Exception:
448
- pass
449
-
450
- modelos_por_trabalho: dict[str, list[str]] = {}
451
- trabalho_ids_override = set(overrides.keys())
452
- for row in rows:
453
- trabalho_id = str(row["trabalho_id"])
454
- if trabalho_id in trabalho_ids_override:
455
- continue
456
- modelos_por_trabalho.setdefault(trabalho_id, []).append(str(row["modelo_nome"] or ""))
457
-
458
- for trabalho_id, override in overrides.items():
459
- modelos_por_trabalho[trabalho_id] = _dedupe_text_list(override.get("modelos"))
460
-
461
- total = 0
462
- for modelos in modelos_por_trabalho.values():
463
- chaves_trabalho = _expandir_chaves_modelo(modelos, catalogo_modelos)
464
- if chaves_trabalho and not chaves_trabalho.isdisjoint(chaves_selecionadas):
465
- total += 1
466
- return total
467
 
468
 
469
  def _carregar_trabalho_base(
 
6
  import sqlite3
7
  from pathlib import Path
8
  from statistics import median
9
+ from threading import Lock
10
  from typing import Any
11
 
12
  import folium
 
34
  MAPA_TRABALHOS_PONTOS = "pontos"
35
 
36
 
37
+ _MODEL_MATCH_CACHE_LOCK = Lock()
38
+ _MODEL_MATCH_CACHE_SIGNATURE: str | None = None
39
+ _MODEL_MATCH_CACHE_CATALOGO: dict[str, dict[str, str]] = {}
40
+ _MODEL_MATCH_CACHE_TRABALHOS: dict[str, set[str]] = {}
41
+
42
+
43
  def _connect_database(path: Path) -> sqlite3.Connection:
44
  conn = sqlite3.connect(str(path))
45
  conn.row_factory = sqlite3.Row
 
177
  return chaves
178
 
179
 
180
+ def _assinatura_cache_modelos_relacionados() -> str:
181
+ resolved_db = trabalhos_tecnicos_repository.resolve_database()
182
+ resolved_modelos = model_repository.resolve_model_repository()
183
+ return "|".join(
184
+ [
185
+ str(resolved_db.provider or ""),
186
+ str(resolved_db.repo_id or ""),
187
+ str(resolved_db.revision or ""),
188
+ str(resolved_db.db_path or ""),
189
+ str(resolved_modelos.signature or ""),
190
+ ]
191
+ )
192
+
193
+
194
+ def _montar_cache_trabalhos_por_modelo() -> tuple[dict[str, dict[str, str]], dict[str, set[str]]]:
195
+ global _MODEL_MATCH_CACHE_SIGNATURE, _MODEL_MATCH_CACHE_CATALOGO, _MODEL_MATCH_CACHE_TRABALHOS
196
+ assinatura = _assinatura_cache_modelos_relacionados()
197
+ with _MODEL_MATCH_CACHE_LOCK:
198
+ if _MODEL_MATCH_CACHE_SIGNATURE == assinatura:
199
+ return _MODEL_MATCH_CACHE_CATALOGO, _MODEL_MATCH_CACHE_TRABALHOS
200
+ resolved = trabalhos_tecnicos_repository.resolve_database()
201
+ catalogo_modelos = _catalogo_modelos_mesa()
202
+
203
+ conn = _connect_database(resolved.db_path)
204
+ try:
205
+ overrides = _fetch_overrides(conn)
206
+ rows = conn.execute(
207
+ "SELECT trabalho_id, modelo_nome FROM trabalho_modelos ORDER BY trabalho_id, ordem, LOWER(modelo_nome)"
208
+ ).fetchall()
209
+ finally:
210
+ try:
211
+ conn.close()
212
+ except Exception:
213
+ pass
214
+
215
+ modelos_por_trabalho: dict[str, list[str]] = {}
216
+ trabalho_ids_override = set(overrides.keys())
217
+ for row in rows:
218
+ trabalho_id = str(row["trabalho_id"])
219
+ if trabalho_id in trabalho_ids_override:
220
+ continue
221
+ modelos_por_trabalho.setdefault(trabalho_id, []).append(str(row["modelo_nome"] or ""))
222
+
223
+ for trabalho_id, override in overrides.items():
224
+ modelos_por_trabalho[trabalho_id] = _dedupe_text_list(override.get("modelos"))
225
+
226
+ trabalhos_por_chave: dict[str, set[str]] = {}
227
+ for trabalho_id, modelos in modelos_por_trabalho.items():
228
+ chaves_trabalho: set[str] = set()
229
+ for modelo_nome in modelos:
230
+ texto = str(modelo_nome or "").strip()
231
+ if not texto:
232
+ continue
233
+ _registrar_chaves_modelo(chaves_trabalho, texto)
234
+
235
+ for chave in chaves_trabalho:
236
+ trabalhos_por_chave.setdefault(chave, set()).add(trabalho_id)
237
+
238
+ _MODEL_MATCH_CACHE_SIGNATURE = assinatura
239
+ _MODEL_MATCH_CACHE_CATALOGO = catalogo_modelos
240
+ _MODEL_MATCH_CACHE_TRABALHOS = trabalhos_por_chave
241
+ return _MODEL_MATCH_CACHE_CATALOGO, _MODEL_MATCH_CACHE_TRABALHOS
242
+
243
+
244
  def _enriquecer_modelos(modelo_nomes: list[str], catalogo: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
245
  enriched: list[dict[str, Any]] = []
246
  for nome in modelo_nomes:
 
497
  return 0
498
 
499
  try:
500
+ catalogo_modelos, trabalhos_por_chave = _montar_cache_trabalhos_por_modelo()
 
501
  except Exception:
502
  return 0
503
 
 
505
  if not chaves_selecionadas:
506
  return 0
507
 
508
+ trabalhos_relacionados: set[str] = set()
509
+ for chave in chaves_selecionadas:
510
+ trabalhos_relacionados.update(trabalhos_por_chave.get(chave) or ())
511
+ return len(trabalhos_relacionados)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
 
513
 
514
  def _carregar_trabalho_base(
backend/app/services/visualizacao_service.py CHANGED
@@ -340,6 +340,7 @@ def carregar_modelo(session: SessionState, caminho_arquivo: str) -> dict[str, An
340
  session.pacote_visualizacao = pacote
341
  session.dados_visualizacao = None
342
  session.avaliacoes_visualizacao = []
 
343
 
344
  nome_modelo = Path(caminho_arquivo).stem
345
  badge_html = viz_app._formatar_badge_completo(pacote, nome_modelo=nome_modelo)
@@ -623,37 +624,91 @@ def _preparar_dados_visualizacao(pacote: dict[str, Any]) -> pd.DataFrame:
623
  return dados
624
 
625
 
626
- def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
627
- pacote = session.pacote_visualizacao
628
- if pacote is None:
629
- raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
 
 
630
 
631
- info = _extrair_modelo_info(pacote)
632
- equacoes = _equacoes_do_modelo(pacote, info)
633
- return {
634
- "campos_avaliacao": campos_avaliacao(session),
635
- "meta_modelo": sanitize_value(info),
636
- "equacoes": sanitize_value(equacoes),
637
- }
638
 
 
 
 
 
 
 
 
639
 
640
- def exibir_modelo(
641
- session: SessionState,
642
- trabalhos_tecnicos_modelos_modo: Any = None,
643
- api_base_url: str | None = None,
644
- popup_auth_token: str | None = None,
645
- ) -> dict[str, Any]:
646
  pacote = session.pacote_visualizacao
647
  if pacote is None:
648
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
649
 
 
 
 
 
 
 
 
 
650
  dados = _preparar_dados_visualizacao(pacote)
 
651
  dados_publicos = dados.drop(columns=["__mesa_row_id__"])
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
- estat = _tabela_estatisticas(pacote).round(2)
654
 
655
- escalas_html = viz_app.formatar_escalas_html(pacote["transformacoes"]["info"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
 
 
 
 
 
 
 
 
 
 
657
  X = pacote["transformacoes"]["X"].reset_index()
658
  y = pacote["transformacoes"]["y"].reset_index()
659
  if "index" in y.columns and "index" in X.columns:
@@ -661,8 +716,40 @@ def exibir_modelo(
661
  df_xy = pd.concat([X, y], axis=1)
662
  df_xy = df_xy.loc[:, ~df_xy.columns.duplicated()].round(2)
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  resumo_html = viz_app.formatar_resumo_html(viz_app.reorganizar_modelos_resumos(pacote["modelo"]["diagnosticos"]))
 
665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  tab_coef = pd.DataFrame(pacote["modelo"]["coeficientes"])
667
  if not isinstance(tab_coef.index, pd.RangeIndex):
668
  tab_coef.insert(0, "Variável", tab_coef.index.astype(str))
@@ -672,58 +759,170 @@ def exibir_modelo(
672
  tab_coef = pd.concat([tab_coef[mask], tab_coef[~mask]], ignore_index=True)
673
  tab_coef = tab_coef.round(4)
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  tab_obs_calc = pacote["modelo"]["obs_calc"].reset_index().round(2)
 
 
 
 
 
676
 
677
- figs = viz_app.gerar_todos_graficos(pacote)
678
 
679
- info = _extrair_modelo_info(pacote)
680
- equacoes = _equacoes_do_modelo(pacote, info)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
  avaliandos_tecnicos, trabalhos_tecnicos, trabalhos_tecnicos_modelos_modo_norm = _carregar_trabalhos_tecnicos_visualizacao(
682
  session,
683
  trabalhos_tecnicos_modelos_modo,
684
  )
685
  popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
686
  mapa_html = viz_app.criar_mapa(
687
- dados,
688
  col_y=info["nome_y"],
689
  session_id=session.session_id,
690
  popup_endpoint=popup_endpoint,
691
  popup_auth_token=popup_auth_token,
692
  avaliandos_tecnicos=avaliandos_tecnicos,
693
  )
 
 
 
 
 
 
694
 
695
- colunas_numericas = [
696
- str(col)
697
- for col in dados_publicos.select_dtypes(include=[np.number]).columns
698
- if str(col).lower() not in ["lat", "lon", "latitude", "longitude", "index"]
699
- ]
700
- choices_mapa = ["Visualização Padrão"] + colunas_numericas
701
 
702
- session.dados_visualizacao = dados
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
 
 
 
 
 
 
 
 
 
704
  return {
705
- "dados": dataframe_to_payload(dados_publicos, decimals=2, max_rows=None),
706
- "estatisticas": dataframe_to_payload(estat, decimals=2),
707
- "escalas_html": escalas_html,
708
- "dados_transformados": dataframe_to_payload(df_xy, decimals=2, max_rows=None),
709
- "resumo_html": resumo_html,
710
- "coeficientes": dataframe_to_payload(tab_coef, decimals=4),
711
- "obs_calc": dataframe_to_payload(tab_obs_calc, decimals=2),
712
- "grafico_obs_calc": figure_to_payload(figs.get("obs_calc")),
713
- "grafico_residuos": figure_to_payload(figs.get("residuos")),
714
- "grafico_histograma": figure_to_payload(figs.get("hist")),
715
- "grafico_cook": figure_to_payload(figs.get("cook")),
716
- "grafico_correlacao": figure_to_payload(figs.get("corr")),
717
- "mapa_html": mapa_html,
718
- "mapa_choices": choices_mapa,
719
  "campos_avaliacao": campos_avaliacao(session),
720
  "meta_modelo": sanitize_value(info),
721
  "equacoes": sanitize_value(equacoes),
722
- "trabalhos_tecnicos": trabalhos_tecnicos,
723
- "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
724
  }
725
 
726
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  def atualizar_mapa(
728
  session: SessionState,
729
  variavel_mapa: str | None,
 
340
  session.pacote_visualizacao = pacote
341
  session.dados_visualizacao = None
342
  session.avaliacoes_visualizacao = []
343
+ session.visualizacao_cache = {}
344
 
345
  nome_modelo = Path(caminho_arquivo).stem
346
  badge_html = viz_app._formatar_badge_completo(pacote, nome_modelo=nome_modelo)
 
624
  return dados
625
 
626
 
627
+ def _visualizacao_cache(session: SessionState) -> dict[str, Any]:
628
+ cache = getattr(session, "visualizacao_cache", None)
629
+ if isinstance(cache, dict):
630
+ return cache
631
+ session.visualizacao_cache = {}
632
+ return session.visualizacao_cache
633
 
 
 
 
 
 
 
 
634
 
635
+ def _visualizacao_tabs_cache(session: SessionState) -> dict[str, Any]:
636
+ cache = _visualizacao_cache(session)
637
+ tabs = cache.get("tabs")
638
+ if isinstance(tabs, dict):
639
+ return tabs
640
+ cache["tabs"] = {}
641
+ return cache["tabs"]
642
 
643
+
644
+ def _obter_visualizacao_core(session: SessionState) -> dict[str, Any]:
 
 
 
 
645
  pacote = session.pacote_visualizacao
646
  if pacote is None:
647
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
648
 
649
+ cache = _visualizacao_cache(session)
650
+ core = cache.get("core")
651
+ if isinstance(core, dict):
652
+ dados_cache = core.get("dados")
653
+ if isinstance(dados_cache, pd.DataFrame):
654
+ session.dados_visualizacao = dados_cache
655
+ return core
656
+
657
  dados = _preparar_dados_visualizacao(pacote)
658
+ info = _extrair_modelo_info(pacote)
659
  dados_publicos = dados.drop(columns=["__mesa_row_id__"])
660
+ colunas_numericas = [
661
+ str(col)
662
+ for col in dados_publicos.select_dtypes(include=[np.number]).columns
663
+ if str(col).lower() not in ["lat", "lon", "latitude", "longitude", "index"]
664
+ ]
665
+ core = {
666
+ "dados": dados,
667
+ "info": info,
668
+ "mapa_choices": ["Visualização Padrão"] + colunas_numericas,
669
+ }
670
+ cache["core"] = core
671
+ session.dados_visualizacao = dados
672
+ return core
673
 
 
674
 
675
+ def _payload_modelo_dados_mercado(session: SessionState) -> dict[str, Any]:
676
+ tabs_cache = _visualizacao_tabs_cache(session)
677
+ cached = tabs_cache.get("dados_mercado")
678
+ if isinstance(cached, dict):
679
+ return cached
680
+
681
+ core = _obter_visualizacao_core(session)
682
+ dados_publicos = core["dados"].drop(columns=["__mesa_row_id__"])
683
+ payload = {
684
+ "dados": dataframe_to_payload(dados_publicos, decimals=2, max_rows=None),
685
+ }
686
+ tabs_cache["dados_mercado"] = payload
687
+ return payload
688
+
689
+
690
+ def _payload_modelo_metricas(session: SessionState) -> dict[str, Any]:
691
+ tabs_cache = _visualizacao_tabs_cache(session)
692
+ cached = tabs_cache.get("metricas")
693
+ if isinstance(cached, dict):
694
+ return cached
695
+
696
+ estat = _tabela_estatisticas(session.pacote_visualizacao).round(2)
697
+ payload = {
698
+ "estatisticas": dataframe_to_payload(estat, decimals=2),
699
+ }
700
+ tabs_cache["metricas"] = payload
701
+ return payload
702
 
703
+
704
+ def _payload_modelo_transformacoes(session: SessionState) -> dict[str, Any]:
705
+ tabs_cache = _visualizacao_tabs_cache(session)
706
+ cached = tabs_cache.get("transformacoes")
707
+ if isinstance(cached, dict):
708
+ return cached
709
+
710
+ pacote = session.pacote_visualizacao
711
+ escalas_html = viz_app.formatar_escalas_html(pacote["transformacoes"]["info"])
712
  X = pacote["transformacoes"]["X"].reset_index()
713
  y = pacote["transformacoes"]["y"].reset_index()
714
  if "index" in y.columns and "index" in X.columns:
 
716
  df_xy = pd.concat([X, y], axis=1)
717
  df_xy = df_xy.loc[:, ~df_xy.columns.duplicated()].round(2)
718
 
719
+ payload = {
720
+ "escalas_html": escalas_html,
721
+ "dados_transformados": dataframe_to_payload(df_xy, decimals=2, max_rows=None),
722
+ }
723
+ tabs_cache["transformacoes"] = payload
724
+ return payload
725
+
726
+
727
+ def _payload_modelo_resumo(session: SessionState) -> dict[str, Any]:
728
+ tabs_cache = _visualizacao_tabs_cache(session)
729
+ cached = tabs_cache.get("resumo")
730
+ if isinstance(cached, dict):
731
+ return cached
732
+
733
+ pacote = session.pacote_visualizacao
734
+ core = _obter_visualizacao_core(session)
735
  resumo_html = viz_app.formatar_resumo_html(viz_app.reorganizar_modelos_resumos(pacote["modelo"]["diagnosticos"]))
736
+ equacoes = _equacoes_do_modelo(pacote, core["info"])
737
 
738
+ payload = {
739
+ "resumo_html": resumo_html,
740
+ "equacoes": sanitize_value(equacoes),
741
+ }
742
+ tabs_cache["resumo"] = payload
743
+ return payload
744
+
745
+
746
+ def _payload_modelo_coeficientes(session: SessionState) -> dict[str, Any]:
747
+ tabs_cache = _visualizacao_tabs_cache(session)
748
+ cached = tabs_cache.get("coeficientes")
749
+ if isinstance(cached, dict):
750
+ return cached
751
+
752
+ pacote = session.pacote_visualizacao
753
  tab_coef = pd.DataFrame(pacote["modelo"]["coeficientes"])
754
  if not isinstance(tab_coef.index, pd.RangeIndex):
755
  tab_coef.insert(0, "Variável", tab_coef.index.astype(str))
 
759
  tab_coef = pd.concat([tab_coef[mask], tab_coef[~mask]], ignore_index=True)
760
  tab_coef = tab_coef.round(4)
761
 
762
+ payload = {
763
+ "coeficientes": dataframe_to_payload(tab_coef, decimals=4),
764
+ }
765
+ tabs_cache["coeficientes"] = payload
766
+ return payload
767
+
768
+
769
+ def _payload_modelo_obs_calc(session: SessionState) -> dict[str, Any]:
770
+ tabs_cache = _visualizacao_tabs_cache(session)
771
+ cached = tabs_cache.get("obs_calc")
772
+ if isinstance(cached, dict):
773
+ return cached
774
+
775
+ pacote = session.pacote_visualizacao
776
  tab_obs_calc = pacote["modelo"]["obs_calc"].reset_index().round(2)
777
+ payload = {
778
+ "obs_calc": dataframe_to_payload(tab_obs_calc, decimals=2),
779
+ }
780
+ tabs_cache["obs_calc"] = payload
781
+ return payload
782
 
 
783
 
784
+ def _payload_modelo_graficos(session: SessionState) -> dict[str, Any]:
785
+ tabs_cache = _visualizacao_tabs_cache(session)
786
+ cached = tabs_cache.get("graficos")
787
+ if isinstance(cached, dict):
788
+ return cached
789
+
790
+ figs = viz_app.gerar_todos_graficos(session.pacote_visualizacao)
791
+ payload = {
792
+ "grafico_obs_calc": figure_to_payload(figs.get("obs_calc")),
793
+ "grafico_residuos": figure_to_payload(figs.get("residuos")),
794
+ "grafico_histograma": figure_to_payload(figs.get("hist")),
795
+ "grafico_cook": figure_to_payload(figs.get("cook")),
796
+ "grafico_correlacao": figure_to_payload(figs.get("corr")),
797
+ }
798
+ tabs_cache["graficos"] = payload
799
+ return payload
800
+
801
+
802
+ def _payload_modelo_trabalhos_tecnicos(
803
+ session: SessionState,
804
+ trabalhos_tecnicos_modelos_modo: Any = None,
805
+ ) -> dict[str, Any]:
806
+ _, trabalhos_tecnicos, trabalhos_tecnicos_modelos_modo_norm = _carregar_trabalhos_tecnicos_visualizacao(
807
+ session,
808
+ trabalhos_tecnicos_modelos_modo,
809
+ )
810
+ return {
811
+ "trabalhos_tecnicos": trabalhos_tecnicos,
812
+ "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
813
+ }
814
+
815
+
816
+ def _payload_modelo_mapa(
817
+ session: SessionState,
818
+ trabalhos_tecnicos_modelos_modo: Any = None,
819
+ api_base_url: str | None = None,
820
+ popup_auth_token: str | None = None,
821
+ ) -> dict[str, Any]:
822
+ core = _obter_visualizacao_core(session)
823
+ info = core["info"]
824
  avaliandos_tecnicos, trabalhos_tecnicos, trabalhos_tecnicos_modelos_modo_norm = _carregar_trabalhos_tecnicos_visualizacao(
825
  session,
826
  trabalhos_tecnicos_modelos_modo,
827
  )
828
  popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
829
  mapa_html = viz_app.criar_mapa(
830
+ core["dados"],
831
  col_y=info["nome_y"],
832
  session_id=session.session_id,
833
  popup_endpoint=popup_endpoint,
834
  popup_auth_token=popup_auth_token,
835
  avaliandos_tecnicos=avaliandos_tecnicos,
836
  )
837
+ return {
838
+ "mapa_html": mapa_html,
839
+ "mapa_choices": core["mapa_choices"],
840
+ "trabalhos_tecnicos": trabalhos_tecnicos,
841
+ "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
842
+ }
843
 
 
 
 
 
 
 
844
 
845
+ def carregar_secao_modelo(
846
+ session: SessionState,
847
+ secao: str,
848
+ trabalhos_tecnicos_modelos_modo: Any = None,
849
+ api_base_url: str | None = None,
850
+ popup_auth_token: str | None = None,
851
+ ) -> dict[str, Any]:
852
+ secao_norm = str(secao or "").strip().lower().replace("-", "_")
853
+ if secao_norm == "mapa":
854
+ return _payload_modelo_mapa(
855
+ session,
856
+ trabalhos_tecnicos_modelos_modo=trabalhos_tecnicos_modelos_modo,
857
+ api_base_url=api_base_url,
858
+ popup_auth_token=popup_auth_token,
859
+ )
860
+ if secao_norm == "trabalhos_tecnicos":
861
+ return _payload_modelo_trabalhos_tecnicos(session, trabalhos_tecnicos_modelos_modo)
862
+ if secao_norm == "dados_mercado":
863
+ return _payload_modelo_dados_mercado(session)
864
+ if secao_norm == "metricas":
865
+ return _payload_modelo_metricas(session)
866
+ if secao_norm == "transformacoes":
867
+ return _payload_modelo_transformacoes(session)
868
+ if secao_norm == "resumo":
869
+ return _payload_modelo_resumo(session)
870
+ if secao_norm == "coeficientes":
871
+ return _payload_modelo_coeficientes(session)
872
+ if secao_norm == "obs_calc":
873
+ return _payload_modelo_obs_calc(session)
874
+ if secao_norm == "graficos":
875
+ return _payload_modelo_graficos(session)
876
+ raise HTTPException(status_code=400, detail="Secao de visualizacao invalida")
877
 
878
+
879
+ def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
880
+ pacote = session.pacote_visualizacao
881
+ if pacote is None:
882
+ raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
883
+
884
+ info = _extrair_modelo_info(pacote)
885
+ equacoes = _equacoes_do_modelo(pacote, info)
886
  return {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
887
  "campos_avaliacao": campos_avaliacao(session),
888
  "meta_modelo": sanitize_value(info),
889
  "equacoes": sanitize_value(equacoes),
 
 
890
  }
891
 
892
 
893
+ def exibir_modelo(
894
+ session: SessionState,
895
+ trabalhos_tecnicos_modelos_modo: Any = None,
896
+ api_base_url: str | None = None,
897
+ popup_auth_token: str | None = None,
898
+ ) -> dict[str, Any]:
899
+ core = _obter_visualizacao_core(session)
900
+ resposta = {
901
+ "campos_avaliacao": campos_avaliacao(session),
902
+ "meta_modelo": sanitize_value(core["info"]),
903
+ }
904
+ for secao in (
905
+ "dados_mercado",
906
+ "metricas",
907
+ "transformacoes",
908
+ "resumo",
909
+ "coeficientes",
910
+ "obs_calc",
911
+ "graficos",
912
+ ):
913
+ resposta.update(carregar_secao_modelo(session, secao))
914
+ resposta.update(
915
+ carregar_secao_modelo(
916
+ session,
917
+ "mapa",
918
+ trabalhos_tecnicos_modelos_modo=trabalhos_tecnicos_modelos_modo,
919
+ api_base_url=api_base_url,
920
+ popup_auth_token=popup_auth_token,
921
+ )
922
+ )
923
+ return resposta
924
+
925
+
926
  def atualizar_mapa(
927
  session: SessionState,
928
  variavel_mapa: str | None,
backend/run_backend.sh CHANGED
@@ -4,4 +4,10 @@ set -euo pipefail
4
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
  cd "${SCRIPT_DIR}"
6
 
 
 
 
 
 
 
7
  uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" --reload --reload-dir "${SCRIPT_DIR}"
 
4
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
  cd "${SCRIPT_DIR}"
6
 
7
+ # Prefere o ambiente virtual local do backend quando ele existir.
8
+ if [[ -f "${SCRIPT_DIR}/.venv/bin/activate" ]]; then
9
+ # shellcheck disable=SC1091
10
+ source "${SCRIPT_DIR}/.venv/bin/activate"
11
+ fi
12
+
13
  uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" --reload --reload-dir "${SCRIPT_DIR}"
frontend/src/App.jsx CHANGED
@@ -1,5 +1,13 @@
1
  import React, { useEffect, useRef, useState } from 'react'
2
  import { api, getAuthToken, setAuthToken } from './api'
 
 
 
 
 
 
 
 
3
  import AvaliacaoTab from './components/AvaliacaoTab'
4
  import ElaboracaoTab from './components/ElaboracaoTab'
5
  import InicioTab from './components/InicioTab'
@@ -21,9 +29,13 @@ export default function App() {
21
  const [sessionId, setSessionId] = useState('')
22
  const [bootError, setBootError] = useState('')
23
  const [avaliacaoQuickLoad, setAvaliacaoQuickLoad] = useState(null)
24
- const [modeloRepositorioQuickOpen, setModeloRepositorioQuickOpen] = useState(null)
25
  const [trabalhoTecnicoQuickOpen, setTrabalhoTecnicoQuickOpen] = useState(null)
26
- const [pesquisaMapaReturnRequest, setPesquisaMapaReturnRequest] = useState(null)
 
 
 
 
27
 
28
  const [authLoading, setAuthLoading] = useState(true)
29
  const [authUser, setAuthUser] = useState(null)
@@ -45,6 +57,11 @@ export default function App() {
45
  const [scrollHomeBtnLeft, setScrollHomeBtnLeft] = useState(8)
46
  const headerRef = useRef(null)
47
  const settingsMenuRef = useRef(null)
 
 
 
 
 
48
 
49
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
50
  const logsEnabled = Boolean(logsStatus?.enabled)
@@ -56,6 +73,44 @@ export default function App() {
56
  const logsVisibleEvents = logsEvents.slice(logsStartIndex, logsStartIndex + LOGS_PAGE_SIZE)
57
  const logsEndIndex = logsEvents.length ? Math.min(logsStartIndex + LOGS_PAGE_SIZE, logsEvents.length) : 0
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  function resetToLogin(message = '') {
60
  setAuthToken('')
61
  setAuthUser(null)
@@ -63,9 +118,13 @@ export default function App() {
63
  setLoginLoading(false)
64
  setSessionId('')
65
  setBootError('')
66
- setModeloRepositorioQuickOpen(null)
67
  setTrabalhoTecnicoQuickOpen(null)
68
- setPesquisaMapaReturnRequest(null)
 
 
 
 
69
  setLogsStatus(null)
70
  setLogsOpen(false)
71
  setLogsEvents([])
@@ -76,6 +135,19 @@ export default function App() {
76
  setAuthError(message)
77
  }
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  useEffect(() => {
80
  let mounted = true
81
 
@@ -128,7 +200,7 @@ export default function App() {
128
  if (!trabalhoId) return
129
 
130
  setTrabalhoTecnicoQuickOpen({
131
- requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
132
  trabalhoId,
133
  origem: String(data?.origem || '').trim() || 'pesquisa_mapa',
134
  })
@@ -136,6 +208,10 @@ export default function App() {
136
  setLogsOpen(false)
137
  setShowStartupIntro(false)
138
  setSettingsOpen(false)
 
 
 
 
139
  }
140
 
141
  if (typeof window !== 'undefined') {
@@ -174,6 +250,70 @@ export default function App() {
174
  }
175
  }, [authUser])
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  useEffect(() => {
178
  if (!authUser || !isAdmin) {
179
  setLogsStatus(null)
@@ -346,7 +486,7 @@ export default function App() {
346
  const modeloId = String(modelo?.id || '').trim()
347
  if (!modeloId) return
348
  setAvaliacaoQuickLoad({
349
- requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
350
  modeloId,
351
  modeloArquivo: String(modelo?.arquivo || '').trim(),
352
  nomeModelo: String(modelo?.nome_modelo || modelo?.arquivo || modeloId),
@@ -354,30 +494,111 @@ export default function App() {
354
  setActiveTab('Avaliação')
355
  setLogsOpen(false)
356
  setShowStartupIntro(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  }
358
 
359
  function onAbrirModeloNoRepositorio(modelo) {
360
  const modeloId = String(modelo?.modeloId || modelo?.id || '').trim()
361
  if (!modeloId) return
362
- setModeloRepositorioQuickOpen({
363
- requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
 
 
 
 
 
 
 
364
  modeloId,
365
- modeloArquivo: String(modelo?.modeloArquivo || modelo?.arquivo || '').trim(),
366
- nomeModelo: String(modelo?.nomeModelo || modelo?.nome_modelo || modeloId).trim(),
 
 
367
  })
368
  setActiveTab('Modelos Estatísticos')
369
  setLogsOpen(false)
370
  setShowStartupIntro(false)
 
 
 
 
 
 
371
  }
372
 
373
  function onVoltarAoMapaPesquisa() {
374
- setPesquisaMapaReturnRequest({
375
- requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
 
 
 
 
 
376
  })
377
  setActiveTab('Modelos Estatísticos')
378
  setLogsOpen(false)
379
  setShowStartupIntro(false)
380
  setSettingsOpen(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  }
382
 
383
  function onScrollToHeader() {
@@ -390,81 +611,99 @@ export default function App() {
390
 
391
  return (
392
  <div className="app-shell">
393
- <header ref={headerRef} className={authUser ? 'app-header app-header-logged' : 'app-header app-header-logo-only'}>
394
- <div className="brand-mark" aria-hidden="true">
395
- <img src={`${import.meta.env.BASE_URL}logo_mesa.png`} alt="MESA" />
396
- </div>
397
- {authUser ? (
398
- <div className="app-top-actions">
399
- <nav className="tabs" aria-label="Navegação principal">
400
- {TABS.map((tab) => {
401
- const active = tab.key === activeTab
402
- return (
403
- <button
404
- key={tab.key}
405
- className={active ? 'tab-pill active' : 'tab-pill'}
406
- onClick={() => {
407
- setActiveTab(tab.key)
408
- setShowStartupIntro(false)
409
- setLogsOpen(false)
410
- setSettingsOpen(false)
411
- }}
412
- type="button"
413
- >
414
- <strong>{tab.label}</strong>
415
- </button>
416
- )
417
- })}
418
- </nav>
419
-
420
- <div ref={settingsMenuRef} className={`settings-menu${settingsOpen ? ' is-open' : ''}`}>
421
- <button
422
- type="button"
423
- className="settings-gear-btn"
424
- aria-haspopup="menu"
425
- aria-expanded={settingsOpen}
426
- aria-label="Abrir configurações"
427
- onClick={() => setSettingsOpen((prev) => !prev)}
428
- title="Configurações"
429
- >
430
- &#9881;
431
- </button>
432
- {settingsOpen ? (
433
- <div className="settings-menu-panel" role="menu" aria-label="Configurações do usuário">
434
- <div className="settings-user-summary">
435
- Usuário: <strong>{authUser.nome || authUser.usuario}</strong> ({authUser.perfil || 'viewer'})
436
- </div>
437
- <div className="settings-menu-actions">
438
- {isAdmin ? (
439
- <button
440
- type="button"
441
- className="settings-menu-btn"
442
- onClick={() => {
443
- void onToggleLogs()
444
- setSettingsOpen(false)
445
- }}
446
- disabled={logsStatusLoading || (!logsEnabled && !logsOpen)}
447
- title={logsOpen ? 'Fechar visualização de logs' : !logsEnabled ? logsDisabledReason : 'Abrir leitura de logs'}
448
- >
449
- {logsOpen ? 'Fechar logs' : 'Abrir logs'}
450
- </button>
451
- ) : null}
452
  <button
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  type="button"
454
- className="settings-menu-btn settings-menu-btn-danger"
455
- onClick={() => void onLogout()}
456
  >
457
- Sair
458
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  </div>
460
- </div>
461
- ) : null}
462
  </div>
463
- </div>
464
- ) : null}
465
- </header>
466
 
467
- {authUser && showScrollHomeBtn ? (
468
  <button
469
  type="button"
470
  className="scroll-home-btn"
@@ -645,13 +884,21 @@ export default function App() {
645
  sessionId={sessionId}
646
  authUser={authUser}
647
  onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao}
648
- openRepositorioModeloRequest={modeloRepositorioQuickOpen}
649
- returnToPesquisaMapaRequest={pesquisaMapaReturnRequest}
 
 
 
650
  />
651
  </div>
652
 
653
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Ediç��o'}>
654
- <ElaboracaoTab sessionId={sessionId} authUser={authUser} />
 
 
 
 
 
655
  </div>
656
 
657
  <div className="tab-pane" hidden={activeTab !== 'Trabalhos Técnicos'}>
@@ -659,12 +906,19 @@ export default function App() {
659
  sessionId={sessionId}
660
  onAbrirModeloNoRepositorio={onAbrirModeloNoRepositorio}
661
  quickOpenRequest={trabalhoTecnicoQuickOpen}
 
 
662
  onVoltarAoMapaPesquisa={onVoltarAoMapaPesquisa}
 
663
  />
664
  </div>
665
 
666
  <div className="tab-pane" hidden={activeTab !== 'Avaliação'}>
667
- <AvaliacaoTab sessionId={sessionId} quickLoadRequest={avaliacaoQuickLoad} />
 
 
 
 
668
  </div>
669
  </>
670
  )
 
1
  import React, { useEffect, useRef, useState } from 'react'
2
  import { api, getAuthToken, setAuthToken } from './api'
3
+ import {
4
+ getAppTabKeyFromSlug,
5
+ getAppTabSlugFromKey,
6
+ hasMesaDeepLink,
7
+ normalizeMesaDeepLink,
8
+ parseMesaDeepLink,
9
+ replaceMesaDeepLink,
10
+ } from './deepLinks'
11
  import AvaliacaoTab from './components/AvaliacaoTab'
12
  import ElaboracaoTab from './components/ElaboracaoTab'
13
  import InicioTab from './components/InicioTab'
 
29
  const [sessionId, setSessionId] = useState('')
30
  const [bootError, setBootError] = useState('')
31
  const [avaliacaoQuickLoad, setAvaliacaoQuickLoad] = useState(null)
32
+ const [elaboracaoQuickLoad, setElaboracaoQuickLoad] = useState(null)
33
  const [trabalhoTecnicoQuickOpen, setTrabalhoTecnicoQuickOpen] = useState(null)
34
+ const [modelosRouteRequest, setModelosRouteRequest] = useState(null)
35
+ const [trabalhosRouteRequest, setTrabalhosRouteRequest] = useState(null)
36
+ const [pendingDeepLinkIntent, setPendingDeepLinkIntent] = useState(null)
37
+ const [modelosModoImersivo, setModelosModoImersivo] = useState(false)
38
+ const [trabalhosModoImersivo, setTrabalhosModoImersivo] = useState(false)
39
 
40
  const [authLoading, setAuthLoading] = useState(true)
41
  const [authUser, setAuthUser] = useState(null)
 
57
  const [scrollHomeBtnLeft, setScrollHomeBtnLeft] = useState(8)
58
  const headerRef = useRef(null)
59
  const settingsMenuRef = useRef(null)
60
+ const lastModelosRouteRef = useRef(null)
61
+ const lastPesquisaRouteRef = useRef(null)
62
+ const lastTrabalhosRouteRef = useRef(null)
63
+ const lastAvaliacaoRouteRef = useRef(null)
64
+ const lastElaboracaoRouteRef = useRef(null)
65
 
66
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
67
  const logsEnabled = Boolean(logsStatus?.enabled)
 
73
  const logsVisibleEvents = logsEvents.slice(logsStartIndex, logsStartIndex + LOGS_PAGE_SIZE)
74
  const logsEndIndex = logsEvents.length ? Math.min(logsStartIndex + LOGS_PAGE_SIZE, logsEvents.length) : 0
75
 
76
+ function buildRequestKey(prefix = 'mesa') {
77
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
78
+ }
79
+
80
+ function rememberRouteIntent(intent) {
81
+ const normalized = normalizeMesaDeepLink(intent)
82
+ if (!normalized.tab) return normalized
83
+ if (normalized.tab === 'modelos') {
84
+ lastModelosRouteRef.current = normalized
85
+ if (normalized.subtab === 'pesquisa') {
86
+ lastPesquisaRouteRef.current = normalized
87
+ }
88
+ }
89
+ if (normalized.tab === 'trabalhos') lastTrabalhosRouteRef.current = normalized
90
+ if (normalized.tab === 'avaliacao') lastAvaliacaoRouteRef.current = normalized
91
+ if (normalized.tab === 'elaboracao') lastElaboracaoRouteRef.current = normalized
92
+ return normalized
93
+ }
94
+
95
+ function syncRouteIntent(intent) {
96
+ const normalized = rememberRouteIntent(intent)
97
+ replaceMesaDeepLink(normalized)
98
+ return normalized
99
+ }
100
+
101
+ function getCurrentMesaRouteIntent(fallbackIntent = null) {
102
+ if (typeof window !== 'undefined') {
103
+ const parsed = parseMesaDeepLink(window.location.search)
104
+ if (hasMesaDeepLink(parsed)) {
105
+ return normalizeMesaDeepLink(parsed)
106
+ }
107
+ }
108
+ if (fallbackIntent) {
109
+ return normalizeMesaDeepLink(fallbackIntent)
110
+ }
111
+ return null
112
+ }
113
+
114
  function resetToLogin(message = '') {
115
  setAuthToken('')
116
  setAuthUser(null)
 
118
  setLoginLoading(false)
119
  setSessionId('')
120
  setBootError('')
121
+ setElaboracaoQuickLoad(null)
122
  setTrabalhoTecnicoQuickOpen(null)
123
+ setModelosRouteRequest(null)
124
+ setTrabalhosRouteRequest(null)
125
+ setPendingDeepLinkIntent(null)
126
+ setModelosModoImersivo(false)
127
+ setTrabalhosModoImersivo(false)
128
  setLogsStatus(null)
129
  setLogsOpen(false)
130
  setLogsEvents([])
 
135
  setAuthError(message)
136
  }
137
 
138
+ useEffect(() => {
139
+ if (typeof window === 'undefined') return undefined
140
+
141
+ function syncPendingDeepLink() {
142
+ const nextIntent = parseMesaDeepLink(window.location.search)
143
+ setPendingDeepLinkIntent(hasMesaDeepLink(nextIntent) ? nextIntent : null)
144
+ }
145
+
146
+ syncPendingDeepLink()
147
+ window.addEventListener('popstate', syncPendingDeepLink)
148
+ return () => window.removeEventListener('popstate', syncPendingDeepLink)
149
+ }, [])
150
+
151
  useEffect(() => {
152
  let mounted = true
153
 
 
200
  if (!trabalhoId) return
201
 
202
  setTrabalhoTecnicoQuickOpen({
203
+ requestKey: buildRequestKey('trabalho-open'),
204
  trabalhoId,
205
  origem: String(data?.origem || '').trim() || 'pesquisa_mapa',
206
  })
 
208
  setLogsOpen(false)
209
  setShowStartupIntro(false)
210
  setSettingsOpen(false)
211
+ syncRouteIntent({
212
+ tab: 'trabalhos',
213
+ trabalhoId,
214
+ })
215
  }
216
 
217
  if (typeof window !== 'undefined') {
 
250
  }
251
  }, [authUser])
252
 
253
+ useEffect(() => {
254
+ if (!pendingDeepLinkIntent || !authUser) return
255
+ const normalized = normalizeMesaDeepLink(pendingDeepLinkIntent)
256
+ const requiresSession = Boolean(
257
+ normalized.modeloId
258
+ || normalized.trabalhoId
259
+ || normalized.tab === 'avaliacao'
260
+ || normalized.tab === 'elaboracao',
261
+ )
262
+ if (requiresSession && !sessionId) return
263
+
264
+ const nextTab = getAppTabKeyFromSlug(normalized.tab)
265
+ const requestKey = buildRequestKey('deep-link')
266
+ if (nextTab) {
267
+ setActiveTab(nextTab)
268
+ setShowStartupIntro(false)
269
+ setLogsOpen(false)
270
+ setSettingsOpen(false)
271
+ }
272
+
273
+ if (normalized.tab === 'modelos') {
274
+ setModelosRouteRequest({
275
+ requestKey,
276
+ subtab: normalized.subtab,
277
+ filters: normalized.filters,
278
+ avaliando: normalized.avaliando,
279
+ modeloId: normalized.modeloId,
280
+ modelTab: normalized.modelTab,
281
+ })
282
+ rememberRouteIntent(normalized)
283
+ }
284
+
285
+ if (normalized.tab === 'avaliacao' && normalized.modeloId) {
286
+ setAvaliacaoQuickLoad({
287
+ requestKey,
288
+ modeloId: normalized.modeloId,
289
+ modeloArquivo: '',
290
+ nomeModelo: normalized.modeloId,
291
+ })
292
+ rememberRouteIntent(normalized)
293
+ }
294
+
295
+ if (normalized.tab === 'elaboracao' && normalized.modeloId) {
296
+ setElaboracaoQuickLoad({
297
+ requestKey,
298
+ modeloId: normalized.modeloId,
299
+ nomeModelo: normalized.modeloId,
300
+ })
301
+ rememberRouteIntent(normalized)
302
+ }
303
+
304
+ if (normalized.tab === 'trabalhos') {
305
+ setTrabalhosRouteRequest({
306
+ requestKey,
307
+ subtab: normalized.subtab,
308
+ trabalhoId: normalized.trabalhoId,
309
+ })
310
+ rememberRouteIntent(normalized)
311
+ }
312
+
313
+ replaceMesaDeepLink(normalized)
314
+ setPendingDeepLinkIntent(null)
315
+ }, [authUser, pendingDeepLinkIntent, sessionId])
316
+
317
  useEffect(() => {
318
  if (!authUser || !isAdmin) {
319
  setLogsStatus(null)
 
486
  const modeloId = String(modelo?.id || '').trim()
487
  if (!modeloId) return
488
  setAvaliacaoQuickLoad({
489
+ requestKey: buildRequestKey('avaliacao-open'),
490
  modeloId,
491
  modeloArquivo: String(modelo?.arquivo || '').trim(),
492
  nomeModelo: String(modelo?.nome_modelo || modelo?.arquivo || modeloId),
 
494
  setActiveTab('Avaliação')
495
  setLogsOpen(false)
496
  setShowStartupIntro(false)
497
+ syncRouteIntent({ tab: 'avaliacao', modeloId })
498
+ }
499
+
500
+ function onEditarModeloEmElaboracao(modelo) {
501
+ const modeloId = String(modelo?.id || '').trim()
502
+ if (!modeloId) return
503
+ setElaboracaoQuickLoad({
504
+ requestKey: buildRequestKey('elaboracao-open'),
505
+ modeloId,
506
+ modeloArquivo: String(modelo?.arquivo || '').trim(),
507
+ nomeModelo: String(modelo?.nome_modelo || modelo?.arquivo || modeloId),
508
+ })
509
+ setActiveTab('Elaboração/Edição')
510
+ setLogsOpen(false)
511
+ setShowStartupIntro(false)
512
+ syncRouteIntent({ tab: 'elaboracao', modeloId })
513
  }
514
 
515
  function onAbrirModeloNoRepositorio(modelo) {
516
  const modeloId = String(modelo?.modeloId || modelo?.id || '').trim()
517
  if (!modeloId) return
518
+ const nomeModelo = String(modelo?.nomeModelo || modelo?.nome_modelo || modeloId).trim()
519
+ const modeloArquivo = String(modelo?.modeloArquivo || modelo?.arquivo || '').trim()
520
+ const explicitReturnIntent = modelo?.returnIntent && typeof modelo.returnIntent === 'object'
521
+ ? modelo.returnIntent
522
+ : null
523
+ const returnIntent = explicitReturnIntent || getCurrentMesaRouteIntent(lastModelosRouteRef.current || { tab: 'modelos', subtab: 'repositorio' })
524
+ setModelosRouteRequest({
525
+ requestKey: buildRequestKey('repo-open'),
526
+ subtab: 'repositorio',
527
  modeloId,
528
+ modelTab: 'mapa',
529
+ nomeModelo,
530
+ modeloArquivo,
531
+ returnIntent,
532
  })
533
  setActiveTab('Modelos Estatísticos')
534
  setLogsOpen(false)
535
  setShowStartupIntro(false)
536
+ syncRouteIntent({
537
+ tab: 'modelos',
538
+ subtab: 'repositorio',
539
+ modeloId,
540
+ modelTab: 'mapa',
541
+ })
542
  }
543
 
544
  function onVoltarAoMapaPesquisa() {
545
+ const nextIntent = lastPesquisaRouteRef.current || { tab: 'modelos', subtab: 'pesquisa' }
546
+ setModelosRouteRequest({
547
+ requestKey: buildRequestKey('pesquisa-return'),
548
+ subtab: nextIntent.subtab || 'pesquisa',
549
+ filters: nextIntent.filters || {},
550
+ avaliando: nextIntent.avaliando || null,
551
+ scrollToMapa: true,
552
  })
553
  setActiveTab('Modelos Estatísticos')
554
  setLogsOpen(false)
555
  setShowStartupIntro(false)
556
  setSettingsOpen(false)
557
+ syncRouteIntent(nextIntent)
558
+ }
559
+
560
+ function onModelosRouteChange(intent) {
561
+ const normalized = normalizeMesaDeepLink(intent)
562
+ if (intent?.forceRouteRequest) {
563
+ const nextTab = getAppTabKeyFromSlug(normalized.tab)
564
+ if (nextTab) {
565
+ setActiveTab(nextTab)
566
+ setShowStartupIntro(false)
567
+ setLogsOpen(false)
568
+ setSettingsOpen(false)
569
+ }
570
+ if (normalized.tab === 'modelos' && !normalized.modeloId) {
571
+ setModelosRouteRequest({
572
+ requestKey: buildRequestKey('modelos-route'),
573
+ subtab: normalized.subtab || 'pesquisa',
574
+ filters: normalized.filters,
575
+ avaliando: normalized.avaliando,
576
+ avaliandos: Array.isArray(intent?.avaliandos) ? intent.avaliandos : null,
577
+ pesquisaExecutada: Boolean(intent?.pesquisaExecutada),
578
+ scrollToMapa: Boolean(intent?.scrollToMapa),
579
+ })
580
+ }
581
+ if (normalized.tab === 'trabalhos') {
582
+ setTrabalhosRouteRequest({
583
+ requestKey: buildRequestKey('trabalhos-route'),
584
+ subtab: normalized.subtab,
585
+ trabalhoId: normalized.trabalhoId,
586
+ })
587
+ }
588
+ }
589
+ syncRouteIntent(normalized)
590
+ }
591
+
592
+ function onAvaliacaoRouteChange(intent) {
593
+ syncRouteIntent(intent)
594
+ }
595
+
596
+ function onElaboracaoRouteChange(intent) {
597
+ syncRouteIntent(intent)
598
+ }
599
+
600
+ function onTrabalhosRouteChange(intent) {
601
+ syncRouteIntent(intent)
602
  }
603
 
604
  function onScrollToHeader() {
 
611
 
612
  return (
613
  <div className="app-shell">
614
+ {!(modelosModoImersivo || trabalhosModoImersivo) ? (
615
+ <header ref={headerRef} className={authUser ? 'app-header app-header-logged' : 'app-header app-header-logo-only'}>
616
+ <div className="brand-mark" aria-hidden="true">
617
+ <img src={`${import.meta.env.BASE_URL}logo_mesa.png`} alt="MESA" />
618
+ </div>
619
+ {authUser ? (
620
+ <div className="app-top-actions">
621
+ <nav className="tabs" aria-label="Navegação principal">
622
+ {TABS.map((tab) => {
623
+ const active = tab.key === activeTab
624
+ return (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  <button
626
+ key={tab.key}
627
+ className={active ? 'tab-pill active' : 'tab-pill'}
628
+ onClick={() => {
629
+ setActiveTab(tab.key)
630
+ setShowStartupIntro(false)
631
+ setLogsOpen(false)
632
+ setSettingsOpen(false)
633
+ const slug = getAppTabSlugFromKey(tab.key)
634
+ if (slug === 'modelos') {
635
+ syncRouteIntent(lastModelosRouteRef.current || { tab: 'modelos' })
636
+ return
637
+ }
638
+ if (slug === 'trabalhos') {
639
+ syncRouteIntent(lastTrabalhosRouteRef.current || { tab: 'trabalhos' })
640
+ return
641
+ }
642
+ if (slug === 'avaliacao') {
643
+ syncRouteIntent(lastAvaliacaoRouteRef.current || { tab: 'avaliacao' })
644
+ return
645
+ }
646
+ if (slug === 'elaboracao') {
647
+ syncRouteIntent(lastElaboracaoRouteRef.current || { tab: 'elaboracao' })
648
+ }
649
+ }}
650
  type="button"
 
 
651
  >
652
+ <strong>{tab.label}</strong>
653
  </button>
654
+ )
655
+ })}
656
+ </nav>
657
+
658
+ <div ref={settingsMenuRef} className={`settings-menu${settingsOpen ? ' is-open' : ''}`}>
659
+ <button
660
+ type="button"
661
+ className="settings-gear-btn"
662
+ aria-haspopup="menu"
663
+ aria-expanded={settingsOpen}
664
+ aria-label="Abrir configurações"
665
+ onClick={() => setSettingsOpen((prev) => !prev)}
666
+ title="Configurações"
667
+ >
668
+ &#9881;
669
+ </button>
670
+ {settingsOpen ? (
671
+ <div className="settings-menu-panel" role="menu" aria-label="Configurações do usuário">
672
+ <div className="settings-user-summary">
673
+ Usuário: <strong>{authUser.nome || authUser.usuario}</strong> ({authUser.perfil || 'viewer'})
674
+ </div>
675
+ <div className="settings-menu-actions">
676
+ {isAdmin ? (
677
+ <button
678
+ type="button"
679
+ className="settings-menu-btn"
680
+ onClick={() => {
681
+ void onToggleLogs()
682
+ setSettingsOpen(false)
683
+ }}
684
+ disabled={logsStatusLoading || (!logsEnabled && !logsOpen)}
685
+ title={logsOpen ? 'Fechar visualização de logs' : !logsEnabled ? logsDisabledReason : 'Abrir leitura de logs'}
686
+ >
687
+ {logsOpen ? 'Fechar logs' : 'Abrir logs'}
688
+ </button>
689
+ ) : null}
690
+ <button
691
+ type="button"
692
+ className="settings-menu-btn settings-menu-btn-danger"
693
+ onClick={() => void onLogout()}
694
+ >
695
+ Sair
696
+ </button>
697
+ </div>
698
  </div>
699
+ ) : null}
700
+ </div>
701
  </div>
702
+ ) : null}
703
+ </header>
704
+ ) : null}
705
 
706
+ {authUser && showScrollHomeBtn && !(modelosModoImersivo || trabalhosModoImersivo) ? (
707
  <button
708
  type="button"
709
  className="scroll-home-btn"
 
884
  sessionId={sessionId}
885
  authUser={authUser}
886
  onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao}
887
+ onEditarModeloEmElaboracao={onEditarModeloEmElaboracao}
888
+ onAbrirModeloNoRepositorio={onAbrirModeloNoRepositorio}
889
+ routeRequest={modelosRouteRequest}
890
+ onRouteChange={onModelosRouteChange}
891
+ onModoImersivoChange={setModelosModoImersivo}
892
  />
893
  </div>
894
 
895
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Ediç��o'}>
896
+ <ElaboracaoTab
897
+ sessionId={sessionId}
898
+ authUser={authUser}
899
+ quickLoadRequest={elaboracaoQuickLoad}
900
+ onRouteChange={onElaboracaoRouteChange}
901
+ />
902
  </div>
903
 
904
  <div className="tab-pane" hidden={activeTab !== 'Trabalhos Técnicos'}>
 
906
  sessionId={sessionId}
907
  onAbrirModeloNoRepositorio={onAbrirModeloNoRepositorio}
908
  quickOpenRequest={trabalhoTecnicoQuickOpen}
909
+ routeRequest={trabalhosRouteRequest}
910
+ onRouteChange={onTrabalhosRouteChange}
911
  onVoltarAoMapaPesquisa={onVoltarAoMapaPesquisa}
912
+ onModoImersivoChange={setTrabalhosModoImersivo}
913
  />
914
  </div>
915
 
916
  <div className="tab-pane" hidden={activeTab !== 'Avaliação'}>
917
+ <AvaliacaoTab
918
+ sessionId={sessionId}
919
+ quickLoadRequest={avaliacaoQuickLoad}
920
+ onRouteChange={onAvaliacaoRouteChange}
921
+ />
922
  </div>
923
  </>
924
  )
frontend/src/api.js CHANGED
@@ -174,6 +174,7 @@ export const api = {
174
 
175
  pesquisaAdminConfig: () => getJson('/api/pesquisa/admin-config'),
176
  pesquisaAdminConfigSalvar: (campos = {}) => postJson('/api/pesquisa/admin-config', { campos }),
 
177
 
178
  pesquisarModelos(filtros = {}) {
179
  const params = new URLSearchParams()
@@ -355,6 +356,11 @@ export const api = {
355
  session_id: sessionId,
356
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
357
  }),
 
 
 
 
 
358
  evaluationContextViz: (sessionId) => postJson('/api/visualizacao/evaluation/context', { session_id: sessionId }),
359
  updateVisualizacaoMap: (sessionId, variavelMapa, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/map/update', {
360
  session_id: sessionId,
 
174
 
175
  pesquisaAdminConfig: () => getJson('/api/pesquisa/admin-config'),
176
  pesquisaAdminConfigSalvar: (campos = {}) => postJson('/api/pesquisa/admin-config', { campos }),
177
+ pesquisarLogradourosEixos: (limite = 2000) => getJson(`/api/pesquisa/logradouros-eixos?limite=${encodeURIComponent(String(limite))}`),
178
 
179
  pesquisarModelos(filtros = {}) {
180
  const params = new URLSearchParams()
 
356
  session_id: sessionId,
357
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
358
  }),
359
+ visualizacaoSection: (sessionId, secao, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/section', {
360
+ session_id: sessionId,
361
+ secao,
362
+ trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
363
+ }),
364
  evaluationContextViz: (sessionId) => postJson('/api/visualizacao/evaluation/context', { session_id: sessionId }),
365
  updateVisualizacaoMap: (sessionId, variavelMapa, trabalhosTecnicosModelosModo = 'selecionados_e_outras_versoes') => postJson('/api/visualizacao/map/update', {
366
  session_id: sessionId,
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -1,8 +1,10 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import { buildCsvBlob } from '../csv'
 
4
  import LoadingOverlay from './LoadingOverlay'
5
  import MapFrame from './MapFrame'
 
6
  import SinglePillAutocomplete from './SinglePillAutocomplete'
7
  import TruncatedCellContent from './TruncatedCellContent'
8
 
@@ -496,7 +498,7 @@ function obterCoordenadasResolvidas(localizacao) {
496
  return { lat, lon }
497
  }
498
 
499
- export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
500
  const [loading, setLoading] = useState(false)
501
  const [error, setError] = useState('')
502
  const [status, setStatus] = useState('')
@@ -832,6 +834,9 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
832
  const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
833
  const contextoResp = await api.evaluationContextViz(sessionId)
834
  aplicarRespostaExibicao(contextoResp, nomeModelo)
 
 
 
835
  })
836
  }
837
 
@@ -858,6 +863,12 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
858
  const contextoResp = await api.evaluationContextViz(sessionId)
859
  aplicarRespostaExibicao(contextoResp, nomeModelo)
860
  setUploadedFile(null)
 
 
 
 
 
 
861
  })
862
  }
863
 
@@ -1189,6 +1200,9 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
1189
  ? (avaliacoesCards.findIndex((item) => item.id === baseCard?.id) + 1)
1190
  : 0
1191
  const avaliandoLocalizacaoAtiva = Boolean(obterCoordenadasResolvidas(avaliandoLocalizacaoResolvida))
 
 
 
1192
 
1193
  return (
1194
  <div className="tab-content">
@@ -1220,11 +1234,17 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
1220
  <button
1221
  type="button"
1222
  className="model-source-back-btn"
1223
- onClick={() => setModeloLoadSource('')}
 
 
 
 
 
1224
  disabled={loading}
1225
  >
1226
  Voltar
1227
  </button>
 
1228
  </div>
1229
 
1230
  {modeloLoadSource === 'repo' ? (
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import { buildCsvBlob } from '../csv'
4
+ import { buildAvaliacaoModeloLink } from '../deepLinks'
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
 
 
498
  return { lat, lon }
499
  }
500
 
501
+ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRouteChange = null }) {
502
  const [loading, setLoading] = useState(false)
503
  const [error, setError] = useState('')
504
  const [status, setStatus] = useState('')
 
834
  const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
835
  const contextoResp = await api.evaluationContextViz(sessionId)
836
  aplicarRespostaExibicao(contextoResp, nomeModelo)
837
+ if (typeof onRouteChange === 'function') {
838
+ onRouteChange({ tab: 'avaliacao' })
839
+ }
840
  })
841
  }
842
 
 
863
  const contextoResp = await api.evaluationContextViz(sessionId)
864
  aplicarRespostaExibicao(contextoResp, nomeModelo)
865
  setUploadedFile(null)
866
+ if (typeof onRouteChange === 'function') {
867
+ onRouteChange({
868
+ tab: 'avaliacao',
869
+ modeloId: modeloIdUsado,
870
+ })
871
+ }
872
  })
873
  }
874
 
 
1200
  ? (avaliacoesCards.findIndex((item) => item.id === baseCard?.id) + 1)
1201
  : 0
1202
  const avaliandoLocalizacaoAtiva = Boolean(obterCoordenadasResolvidas(avaliandoLocalizacaoResolvida))
1203
+ const avaliacaoShareHref = repoModeloSelecionado
1204
+ ? buildAvaliacaoModeloLink(repoModeloSelecionado)
1205
+ : ''
1206
 
1207
  return (
1208
  <div className="tab-content">
 
1234
  <button
1235
  type="button"
1236
  className="model-source-back-btn"
1237
+ onClick={() => {
1238
+ setModeloLoadSource('')
1239
+ if (typeof onRouteChange === 'function') {
1240
+ onRouteChange({ tab: 'avaliacao' })
1241
+ }
1242
+ }}
1243
  disabled={loading}
1244
  >
1245
  Voltar
1246
  </button>
1247
+ {modeloLoadSource === 'repo' && repoModeloSelecionado ? <ShareLinkButton href={avaliacaoShareHref} /> : null}
1248
  </div>
1249
 
1250
  {modeloLoadSource === 'repo' ? (
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import { tableToCsvBlob } from '../csv'
 
4
  import Plotly from 'plotly.js-dist-min'
5
  import DataTable from './DataTable'
6
  import EquationFormatsPanel from './EquationFormatsPanel'
@@ -8,6 +9,7 @@ import LoadingOverlay from './LoadingOverlay'
8
  import MapFrame from './MapFrame'
9
  import PlotFigure from './PlotFigure'
10
  import SectionBlock from './SectionBlock'
 
11
  import SinglePillAutocomplete from './SinglePillAutocomplete'
12
  import TruncatedCellContent from './TruncatedCellContent'
13
 
@@ -678,6 +680,16 @@ function buildScatterPanels(figure, options = {}) {
678
  })
679
  }
680
 
 
 
 
 
 
 
 
 
 
 
681
  function formatConselhoRegistro(elaborador) {
682
  if (!elaborador) return ''
683
  const conselho = String(elaborador.conselho || '').trim()
@@ -847,7 +859,7 @@ function DiagnosticPngCard({ title, pngPayload, alt }) {
847
  )
848
  }
849
 
850
- export default function ElaboracaoTab({ sessionId, authUser }) {
851
  const [loading, setLoading] = useState(false)
852
  const [downloadingAssets, setDownloadingAssets] = useState(false)
853
  const [error, setError] = useState('')
@@ -942,10 +954,13 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
942
  const [secao13InterativoEixoYResiduo, setSecao13InterativoEixoYResiduo] = useState('residuo_pad')
943
  const [secao13InterativoEixoYColuna, setSecao13InterativoEixoYColuna] = useState('')
944
  const [secao10InterativoFigura, setSecao10InterativoFigura] = useState(null)
 
945
  const [secao10InterativoSelecionado, setSecao10InterativoSelecionado] = useState('none')
946
  const [secao13InterativoFigura, setSecao13InterativoFigura] = useState(null)
 
947
  const [secao13InterativoSelecionado, setSecao13InterativoSelecionado] = useState('none')
948
  const [secao15InterativoFigura, setSecao15InterativoFigura] = useState(null)
 
949
  const [secao15InterativoSelecionado, setSecao15InterativoSelecionado] = useState('none')
950
 
951
  const [filtros, setFiltros] = useState(defaultFiltros())
@@ -989,11 +1004,15 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
989
  const deleteConfirmTimersRef = useRef({})
990
  const uploadInputRef = useRef(null)
991
  const elaboracaoRootRef = useRef(null)
 
992
  const [disabledHint, setDisabledHint] = useState(null)
993
  const [sectionsMountKey, setSectionsMountKey] = useState(0)
994
  const [renderedSectionSteps, setRenderedSectionSteps] = useState(() => ['1'])
995
  const [visibleSectionSteps, setVisibleSectionSteps] = useState(() => ['1'])
996
  const visibleSectionStepsRef = useRef(new Set(['1']))
 
 
 
997
  const [sideNavDynamicStyle, setSideNavDynamicStyle] = useState({})
998
 
999
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
@@ -1334,6 +1353,18 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
1334
  }),
1335
  [selection?.grafico_dispersao, colunaY],
1336
  )
 
 
 
 
 
 
 
 
 
 
 
 
1337
  const graficosSecao9Interativo = useMemo(
1338
  () => buildScatterPanels(secao10InterativoFigura, {
1339
  singleLabel: 'Dispersão',
@@ -1343,6 +1374,19 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
1343
  }),
1344
  [secao10InterativoFigura, colunaY],
1345
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
1346
  const secao10InterativoOpcoes = useMemo(() => {
1347
  const labels = graficosSecao9Interativo.length > 0
1348
  ? graficosSecao9Interativo.map((item) => String(item.label || '').trim()).filter(Boolean)
@@ -1383,6 +1427,18 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
1383
  }),
1384
  [fit?.grafico_dispersao_modelo, yLabelSecao13],
1385
  )
 
 
 
 
 
 
 
 
 
 
 
 
1386
  const secao13ModoPng = useMemo(
1387
  () => String(fit?.grafico_dispersao_modelo_modo || '') === 'png',
1388
  [fit?.grafico_dispersao_modelo_modo],
@@ -1409,6 +1465,19 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
1409
  }),
1410
  [secao13InterativoFigura, yLabelSecao13Interativo],
1411
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
1412
  const secao13InterativoOpcoes = useMemo(() => {
1413
  const labels = graficosSecao12Interativo.map((item) => String(item.label || '').trim()).filter(Boolean)
1414
  return Array.from(new Set(labels))
@@ -1790,8 +1859,9 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
1790
  if (!fit || !secao15DiagnosticoPng) {
1791
  if (secao15InterativoSelecionado !== 'none') setSecao15InterativoSelecionado('none')
1792
  if (secao15InterativoFigura) setSecao15InterativoFigura(null)
 
1793
  }
1794
- }, [fit, secao15DiagnosticoPng, secao15InterativoSelecionado, secao15InterativoFigura])
1795
 
1796
  useEffect(() => {
1797
  if (!sessionId || tipoFonteDados === 'dai') return undefined
@@ -2125,8 +2195,10 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2125
  setSection6EditOpen(true)
2126
  setFit(null)
2127
  setSecao10InterativoFigura(null)
 
2128
  setSecao10InterativoSelecionado('none')
2129
  setSecao13InterativoFigura(null)
 
2130
  setSecao13InterativoSelecionado('none')
2131
  setFiltros(defaultFiltros())
2132
  setOutliersTexto('')
@@ -2164,6 +2236,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2164
  function applySelectionResponse(resp) {
2165
  setSelection(resp)
2166
  setSecao10InterativoFigura(null)
 
2167
  setSecao10InterativoSelecionado('none')
2168
  setSection6EditOpen(false)
2169
  setSection11LocksOpen(false)
@@ -2225,8 +2298,10 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2225
  function applyFitResponse(resp, origemMeta = null) {
2226
  setFit(resp)
2227
  setSecao13InterativoFigura(null)
 
2228
  setSecao13InterativoSelecionado('none')
2229
  setSecao15InterativoFigura(null)
 
2230
  setSecao15InterativoSelecionado('none')
2231
  setDispersaoEixoX('transformado')
2232
  setDispersaoEixoYTipo('y_transformado')
@@ -2335,8 +2410,10 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2335
  setGrauF(0)
2336
  setFit(null)
2337
  setSecao10InterativoFigura(null)
 
2338
  setSecao10InterativoSelecionado('none')
2339
  setSecao13InterativoFigura(null)
 
2340
  setSecao13InterativoSelecionado('none')
2341
  setSelectionAppliedSnapshot(buildSelectionSnapshot())
2342
  setColunasX([])
@@ -2413,8 +2490,16 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2413
  })
2414
  }
2415
 
2416
- async function onCarregarModeloRepositorio() {
2417
- if (!sessionId || !repoModeloSelecionado) return
 
 
 
 
 
 
 
 
2418
  setModeloLoadSource('repo')
2419
  setImportacaoErro('')
2420
  setArquivoCarregadoInfo(null)
@@ -2432,14 +2517,32 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2432
  setRequiresSheet(false)
2433
  setSheetOptions([])
2434
  setTipoFonteDados('dai')
2435
- const resp = await api.elaboracaoRepositorioCarregar(sessionId, repoModeloSelecionado)
2436
  aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
 
2437
  setUploadedFile(null)
 
 
 
 
 
 
2438
  }, {
2439
  onError: (err) => setImportacaoErro(err?.message || 'Falha ao carregar modelo do repositório.'),
2440
  })
2441
  }
2442
 
 
 
 
 
 
 
 
 
 
 
 
2443
  function onUploadInputChange(event) {
2444
  const input = event.target
2445
  const file = input.files?.[0] ?? null
@@ -2594,8 +2697,10 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2594
  setSelection(null)
2595
  setFit(null)
2596
  setSecao10InterativoFigura(null)
 
2597
  setSecao10InterativoSelecionado('none')
2598
  setSecao13InterativoFigura(null)
 
2599
  setSecao13InterativoSelecionado('none')
2600
  setTransformacaoY('(x)')
2601
  setTransformacoesX({})
@@ -2761,6 +2866,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2761
  setSection6EditOpen(false)
2762
  setFit(null)
2763
  setSecao13InterativoFigura(null)
 
2764
  setSecao13InterativoSelecionado('none')
2765
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
2766
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
@@ -2867,6 +2973,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2867
  setFit((prev) => ({
2868
  ...prev,
2869
  grafico_dispersao_modelo: resp.grafico,
 
2870
  grafico_dispersao_modelo_modo: resp.modo || (resp.grafico ? 'interativo' : ''),
2871
  grafico_dispersao_modelo_png: resp.grafico_png || null,
2872
  grafico_dispersao_modelo_total_pontos: resp.total_pontos,
@@ -2901,6 +3008,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
2901
  figuraInterativa = respInterativo?.grafico || null
2902
  }
2903
  setSecao13InterativoFigura(figuraInterativa)
 
2904
  const yLabelInterativo = getDispersaoYLabel(eixoYTipo, eixoYResiduo, eixoYColuna, colunaYComRotulo)
2905
  const paineis = buildScatterPanels(figuraInterativa, {
2906
  singleLabel: 'Dispersão do modelo',
@@ -3053,6 +3161,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3053
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
3054
  setFit(null)
3055
  setSecao13InterativoFigura(null)
 
3056
  setSecao13InterativoSelecionado('none')
3057
  setTransformacoesAplicadas(null)
3058
  setOrigemTransformacoes(null)
@@ -3100,8 +3209,10 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3100
  setSection6EditOpen(true)
3101
  setFit(null)
3102
  setSecao10InterativoFigura(null)
 
3103
  setSecao10InterativoSelecionado('none')
3104
  setSecao13InterativoFigura(null)
 
3105
  setSecao13InterativoSelecionado('none')
3106
  setTransformacoesAplicadas(null)
3107
  setOrigemTransformacoes(null)
@@ -3639,6 +3750,84 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3639
  }
3640
  }
3641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3642
  async function onChangeSecao10InterativoSelecionado(value) {
3643
  const nextValue = String(value || 'none').trim() || 'none'
3644
  setSecao10InterativoSelecionado(nextValue)
@@ -3650,6 +3839,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3650
  await withBusy(async () => {
3651
  const resp = await api.getDispersaoInterativo(sessionId, 'secao10')
3652
  setSecao10InterativoFigura(resp?.grafico || null)
 
3653
  })
3654
  }
3655
 
@@ -3668,6 +3858,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3668
  setSecao15InterativoSelecionado(nextValue)
3669
  if (nextValue === 'none') {
3670
  setSecao15InterativoFigura(null)
 
3671
  return
3672
  }
3673
  if (!sessionId) return
@@ -3675,6 +3866,7 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3675
  await withBusy(async () => {
3676
  const resp = await api.getDiagnosticoInterativo(sessionId, nextValue)
3677
  setSecao15InterativoFigura(resp?.payload || null)
 
3678
  })
3679
  }
3680
 
@@ -3786,21 +3978,25 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3786
  </button>
3787
  </div>
3788
  ) : (
3789
- <div className="model-source-flow">
3790
- <div className="model-source-flow-head">
3791
- <button
3792
- type="button"
3793
- className="model-source-back-btn"
3794
- onClick={() => {
3795
- setModeloLoadSource('')
3796
- setRepoModeloDropdownOpen(false)
3797
- setImportacaoErro('')
3798
- }}
3799
- disabled={loading}
3800
- >
3801
- Voltar
3802
- </button>
3803
- </div>
 
 
 
 
3804
 
3805
  {modeloLoadSource === 'repo' ? (
3806
  <div className="row upload-repo-row">
@@ -3816,15 +4012,15 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
3816
  disabled={loading || repoModelosLoading || repoModeloOptions.length === 0}
3817
  onOpenChange={setRepoModeloDropdownOpen}
3818
  />
3819
- </label>
3820
- <div className="row compact upload-repo-actions">
3821
- <button type="button" onClick={onCarregarModeloRepositorio} disabled={loading || repoModelosLoading || !repoModeloSelecionado}>
3822
- Carregar do repositório
3823
- </button>
3824
- <button type="button" onClick={() => void carregarModelosRepositorio()} disabled={loading || repoModelosLoading}>
3825
- Atualizar lista
3826
- </button>
3827
- </div>
3828
  {repoFonteModelos ? <div className="section1-empty-hint">{repoFonteModelos}</div> : null}
3829
  </div>
3830
  ) : null}
@@ -4755,8 +4951,11 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
4755
  <PlotFigure
4756
  key={`s9-interativo-plot-${secao10InterativoAtual.id}`}
4757
  figure={secao10InterativoAtual.figure}
 
 
4758
  title={secao10InterativoAtual.title}
4759
  subtitle={secao10InterativoAtual.subtitle}
 
4760
  forceHideLegend
4761
  className="plot-stretch"
4762
  lazy
@@ -4809,8 +5008,11 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
4809
  <PlotFigure
4810
  key={`s9-plot-${item.id}`}
4811
  figure={item.figure}
 
 
4812
  title={item.title}
4813
  subtitle={item.subtitle}
 
4814
  forceHideLegend
4815
  className="plot-stretch"
4816
  lazy
@@ -5286,8 +5488,11 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
5286
  <PlotFigure
5287
  key={`s12-interativo-plot-${secao13InterativoAtual.id}`}
5288
  figure={secao13InterativoAtual.figure}
 
 
5289
  title={secao13InterativoAtual.title}
5290
  subtitle={secao13InterativoAtual.subtitle}
 
5291
  forceHideLegend
5292
  className="plot-stretch"
5293
  lazy
@@ -5340,8 +5545,11 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
5340
  <PlotFigure
5341
  key={`s12-plot-${item.id}`}
5342
  figure={item.figure}
 
 
5343
  title={item.title}
5344
  subtitle={item.subtitle}
 
5345
  forceHideLegend
5346
  className="plot-stretch"
5347
  lazy
@@ -5510,14 +5718,14 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
5510
  </div>
5511
  ) : (
5512
  <div className="plot-grid-2-fixed">
5513
- <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
5514
- <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
5515
- <PlotFigure figure={fit.grafico_histograma} title="Histograma" />
5516
- <PlotFigure figure={fit.grafico_cook} title="Cook" forceHideLegend />
5517
  </div>
5518
  )}
5519
  <div className="plot-full-width">
5520
- <PlotFigure figure={fit.grafico_correlacao} title="Matriz de correlação" className="plot-correlation-card" />
5521
  </div>
5522
  {secao15DiagnosticoPng ? (
5523
  <>
@@ -5558,7 +5766,10 @@ export default function ElaboracaoTab({ sessionId, authUser }) {
5558
  <PlotFigure
5559
  key={`s15-interativo-${secao15InterativoSelecionado}`}
5560
  figure={secao15InterativoFigura}
 
 
5561
  title={secao15InterativoLabel}
 
5562
  forceHideLegend={secao15InterativoSelecionado === 'cook'}
5563
  className="plot-stretch"
5564
  lazy
 
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import { tableToCsvBlob } from '../csv'
4
+ import { buildElaboracaoModeloLink } from '../deepLinks'
5
  import Plotly from 'plotly.js-dist-min'
6
  import DataTable from './DataTable'
7
  import EquationFormatsPanel from './EquationFormatsPanel'
 
9
  import MapFrame from './MapFrame'
10
  import PlotFigure from './PlotFigure'
11
  import SectionBlock from './SectionBlock'
12
+ import ShareLinkButton from './ShareLinkButton'
13
  import SinglePillAutocomplete from './SinglePillAutocomplete'
14
  import TruncatedCellContent from './TruncatedCellContent'
15
 
 
680
  })
681
  }
682
 
683
+ function buildScatterPanelFigureMap(panels) {
684
+ const mapa = new Map()
685
+ ;(panels || []).forEach((item) => {
686
+ const label = String(item?.label || '').trim()
687
+ if (!label || !item?.figure) return
688
+ mapa.set(label, item.figure)
689
+ })
690
+ return mapa
691
+ }
692
+
693
  function formatConselhoRegistro(elaborador) {
694
  if (!elaborador) return ''
695
  const conselho = String(elaborador.conselho || '').trim()
 
859
  )
860
  }
861
 
862
+ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest = null, onRouteChange = null }) {
863
  const [loading, setLoading] = useState(false)
864
  const [downloadingAssets, setDownloadingAssets] = useState(false)
865
  const [error, setError] = useState('')
 
954
  const [secao13InterativoEixoYResiduo, setSecao13InterativoEixoYResiduo] = useState('residuo_pad')
955
  const [secao13InterativoEixoYColuna, setSecao13InterativoEixoYColuna] = useState('')
956
  const [secao10InterativoFigura, setSecao10InterativoFigura] = useState(null)
957
+ const [secao10InterativoFiguraComIndices, setSecao10InterativoFiguraComIndices] = useState(null)
958
  const [secao10InterativoSelecionado, setSecao10InterativoSelecionado] = useState('none')
959
  const [secao13InterativoFigura, setSecao13InterativoFigura] = useState(null)
960
+ const [secao13InterativoFiguraComIndices, setSecao13InterativoFiguraComIndices] = useState(null)
961
  const [secao13InterativoSelecionado, setSecao13InterativoSelecionado] = useState('none')
962
  const [secao15InterativoFigura, setSecao15InterativoFigura] = useState(null)
963
+ const [secao15InterativoFiguraComIndices, setSecao15InterativoFiguraComIndices] = useState(null)
964
  const [secao15InterativoSelecionado, setSecao15InterativoSelecionado] = useState('none')
965
 
966
  const [filtros, setFiltros] = useState(defaultFiltros())
 
1004
  const deleteConfirmTimersRef = useRef({})
1005
  const uploadInputRef = useRef(null)
1006
  const elaboracaoRootRef = useRef(null)
1007
+ const quickLoadHandledRef = useRef('')
1008
  const [disabledHint, setDisabledHint] = useState(null)
1009
  const [sectionsMountKey, setSectionsMountKey] = useState(0)
1010
  const [renderedSectionSteps, setRenderedSectionSteps] = useState(() => ['1'])
1011
  const [visibleSectionSteps, setVisibleSectionSteps] = useState(() => ['1'])
1012
  const visibleSectionStepsRef = useRef(new Set(['1']))
1013
+ const elaboracaoShareHref = repoModeloSelecionado
1014
+ ? buildElaboracaoModeloLink(repoModeloSelecionado)
1015
+ : ''
1016
  const [sideNavDynamicStyle, setSideNavDynamicStyle] = useState({})
1017
 
1018
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
 
1353
  }),
1354
  [selection?.grafico_dispersao, colunaY],
1355
  )
1356
+ const graficosSecao9ComIndices = useMemo(
1357
+ () => buildScatterPanels(selection?.grafico_dispersao_com_indices, {
1358
+ singleLabel: 'Dispersão',
1359
+ height: 360,
1360
+ yLabel: colunaY || 'Y',
1361
+ }),
1362
+ [selection?.grafico_dispersao_com_indices, colunaY],
1363
+ )
1364
+ const graficosSecao9ComIndicesMap = useMemo(
1365
+ () => buildScatterPanelFigureMap(graficosSecao9ComIndices),
1366
+ [graficosSecao9ComIndices],
1367
+ )
1368
  const graficosSecao9Interativo = useMemo(
1369
  () => buildScatterPanels(secao10InterativoFigura, {
1370
  singleLabel: 'Dispersão',
 
1374
  }),
1375
  [secao10InterativoFigura, colunaY],
1376
  )
1377
+ const graficosSecao9InterativoComIndices = useMemo(
1378
+ () => buildScatterPanels(secao10InterativoFiguraComIndices, {
1379
+ singleLabel: 'Dispersão',
1380
+ height: 360,
1381
+ yLabel: colunaY || 'Y',
1382
+ useScatterGlMarkers: true,
1383
+ }),
1384
+ [secao10InterativoFiguraComIndices, colunaY],
1385
+ )
1386
+ const graficosSecao9InterativoComIndicesMap = useMemo(
1387
+ () => buildScatterPanelFigureMap(graficosSecao9InterativoComIndices),
1388
+ [graficosSecao9InterativoComIndices],
1389
+ )
1390
  const secao10InterativoOpcoes = useMemo(() => {
1391
  const labels = graficosSecao9Interativo.length > 0
1392
  ? graficosSecao9Interativo.map((item) => String(item.label || '').trim()).filter(Boolean)
 
1427
  }),
1428
  [fit?.grafico_dispersao_modelo, yLabelSecao13],
1429
  )
1430
+ const graficosSecao12ComIndices = useMemo(
1431
+ () => buildScatterPanels(fit?.grafico_dispersao_modelo_com_indices, {
1432
+ singleLabel: 'Dispersão do modelo',
1433
+ height: 360,
1434
+ yLabel: yLabelSecao13,
1435
+ }),
1436
+ [fit?.grafico_dispersao_modelo_com_indices, yLabelSecao13],
1437
+ )
1438
+ const graficosSecao12ComIndicesMap = useMemo(
1439
+ () => buildScatterPanelFigureMap(graficosSecao12ComIndices),
1440
+ [graficosSecao12ComIndices],
1441
+ )
1442
  const secao13ModoPng = useMemo(
1443
  () => String(fit?.grafico_dispersao_modelo_modo || '') === 'png',
1444
  [fit?.grafico_dispersao_modelo_modo],
 
1465
  }),
1466
  [secao13InterativoFigura, yLabelSecao13Interativo],
1467
  )
1468
+ const graficosSecao12InterativoComIndices = useMemo(
1469
+ () => buildScatterPanels(secao13InterativoFiguraComIndices, {
1470
+ singleLabel: 'Dispersão do modelo',
1471
+ height: 360,
1472
+ yLabel: yLabelSecao13Interativo,
1473
+ useScatterGlMarkers: true,
1474
+ }),
1475
+ [secao13InterativoFiguraComIndices, yLabelSecao13Interativo],
1476
+ )
1477
+ const graficosSecao12InterativoComIndicesMap = useMemo(
1478
+ () => buildScatterPanelFigureMap(graficosSecao12InterativoComIndices),
1479
+ [graficosSecao12InterativoComIndices],
1480
+ )
1481
  const secao13InterativoOpcoes = useMemo(() => {
1482
  const labels = graficosSecao12Interativo.map((item) => String(item.label || '').trim()).filter(Boolean)
1483
  return Array.from(new Set(labels))
 
1859
  if (!fit || !secao15DiagnosticoPng) {
1860
  if (secao15InterativoSelecionado !== 'none') setSecao15InterativoSelecionado('none')
1861
  if (secao15InterativoFigura) setSecao15InterativoFigura(null)
1862
+ if (secao15InterativoFiguraComIndices) setSecao15InterativoFiguraComIndices(null)
1863
  }
1864
+ }, [fit, secao15DiagnosticoPng, secao15InterativoSelecionado, secao15InterativoFigura, secao15InterativoFiguraComIndices])
1865
 
1866
  useEffect(() => {
1867
  if (!sessionId || tipoFonteDados === 'dai') return undefined
 
2195
  setSection6EditOpen(true)
2196
  setFit(null)
2197
  setSecao10InterativoFigura(null)
2198
+ setSecao10InterativoFiguraComIndices(null)
2199
  setSecao10InterativoSelecionado('none')
2200
  setSecao13InterativoFigura(null)
2201
+ setSecao13InterativoFiguraComIndices(null)
2202
  setSecao13InterativoSelecionado('none')
2203
  setFiltros(defaultFiltros())
2204
  setOutliersTexto('')
 
2236
  function applySelectionResponse(resp) {
2237
  setSelection(resp)
2238
  setSecao10InterativoFigura(null)
2239
+ setSecao10InterativoFiguraComIndices(null)
2240
  setSecao10InterativoSelecionado('none')
2241
  setSection6EditOpen(false)
2242
  setSection11LocksOpen(false)
 
2298
  function applyFitResponse(resp, origemMeta = null) {
2299
  setFit(resp)
2300
  setSecao13InterativoFigura(null)
2301
+ setSecao13InterativoFiguraComIndices(null)
2302
  setSecao13InterativoSelecionado('none')
2303
  setSecao15InterativoFigura(null)
2304
+ setSecao15InterativoFiguraComIndices(null)
2305
  setSecao15InterativoSelecionado('none')
2306
  setDispersaoEixoX('transformado')
2307
  setDispersaoEixoYTipo('y_transformado')
 
2410
  setGrauF(0)
2411
  setFit(null)
2412
  setSecao10InterativoFigura(null)
2413
+ setSecao10InterativoFiguraComIndices(null)
2414
  setSecao10InterativoSelecionado('none')
2415
  setSecao13InterativoFigura(null)
2416
+ setSecao13InterativoFiguraComIndices(null)
2417
  setSecao13InterativoSelecionado('none')
2418
  setSelectionAppliedSnapshot(buildSelectionSnapshot())
2419
  setColunasX([])
 
2490
  })
2491
  }
2492
 
2493
+ async function onCarregarModeloRepositorio(modeloIdOverride = '') {
2494
+ const overrideNormalizado = (
2495
+ modeloIdOverride
2496
+ && typeof modeloIdOverride === 'object'
2497
+ && typeof modeloIdOverride.preventDefault === 'function'
2498
+ )
2499
+ ? ''
2500
+ : modeloIdOverride
2501
+ const modeloId = String(overrideNormalizado || repoModeloSelecionado || '').trim()
2502
+ if (!sessionId || !modeloId) return
2503
  setModeloLoadSource('repo')
2504
  setImportacaoErro('')
2505
  setArquivoCarregadoInfo(null)
 
2517
  setRequiresSheet(false)
2518
  setSheetOptions([])
2519
  setTipoFonteDados('dai')
2520
+ const resp = await api.elaboracaoRepositorioCarregar(sessionId, modeloId)
2521
  aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
2522
+ setRepoModeloSelecionado(modeloId)
2523
  setUploadedFile(null)
2524
+ if (typeof onRouteChange === 'function') {
2525
+ onRouteChange({
2526
+ tab: 'elaboracao',
2527
+ modeloId,
2528
+ })
2529
+ }
2530
  }, {
2531
  onError: (err) => setImportacaoErro(err?.message || 'Falha ao carregar modelo do repositório.'),
2532
  })
2533
  }
2534
 
2535
+ useEffect(() => {
2536
+ const requestKey = String(quickLoadRequest?.requestKey || '').trim()
2537
+ const modeloId = String(quickLoadRequest?.modeloId || '').trim()
2538
+ if (!sessionId || !requestKey || !modeloId) return
2539
+ if (quickLoadHandledRef.current === requestKey) return
2540
+ quickLoadHandledRef.current = requestKey
2541
+ setModeloLoadSource('repo')
2542
+ setRepoModeloSelecionado(modeloId)
2543
+ void onCarregarModeloRepositorio(modeloId)
2544
+ }, [quickLoadRequest, sessionId])
2545
+
2546
  function onUploadInputChange(event) {
2547
  const input = event.target
2548
  const file = input.files?.[0] ?? null
 
2697
  setSelection(null)
2698
  setFit(null)
2699
  setSecao10InterativoFigura(null)
2700
+ setSecao10InterativoFiguraComIndices(null)
2701
  setSecao10InterativoSelecionado('none')
2702
  setSecao13InterativoFigura(null)
2703
+ setSecao13InterativoFiguraComIndices(null)
2704
  setSecao13InterativoSelecionado('none')
2705
  setTransformacaoY('(x)')
2706
  setTransformacoesX({})
 
2866
  setSection6EditOpen(false)
2867
  setFit(null)
2868
  setSecao13InterativoFigura(null)
2869
+ setSecao13InterativoFiguraComIndices(null)
2870
  setSecao13InterativoSelecionado('none')
2871
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
2872
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
 
2973
  setFit((prev) => ({
2974
  ...prev,
2975
  grafico_dispersao_modelo: resp.grafico,
2976
+ grafico_dispersao_modelo_com_indices: null,
2977
  grafico_dispersao_modelo_modo: resp.modo || (resp.grafico ? 'interativo' : ''),
2978
  grafico_dispersao_modelo_png: resp.grafico_png || null,
2979
  grafico_dispersao_modelo_total_pontos: resp.total_pontos,
 
3008
  figuraInterativa = respInterativo?.grafico || null
3009
  }
3010
  setSecao13InterativoFigura(figuraInterativa)
3011
+ setSecao13InterativoFiguraComIndices(null)
3012
  const yLabelInterativo = getDispersaoYLabel(eixoYTipo, eixoYResiduo, eixoYColuna, colunaYComRotulo)
3013
  const paineis = buildScatterPanels(figuraInterativa, {
3014
  singleLabel: 'Dispersão do modelo',
 
3161
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
3162
  setFit(null)
3163
  setSecao13InterativoFigura(null)
3164
+ setSecao13InterativoFiguraComIndices(null)
3165
  setSecao13InterativoSelecionado('none')
3166
  setTransformacoesAplicadas(null)
3167
  setOrigemTransformacoes(null)
 
3209
  setSection6EditOpen(true)
3210
  setFit(null)
3211
  setSecao10InterativoFigura(null)
3212
+ setSecao10InterativoFiguraComIndices(null)
3213
  setSecao10InterativoSelecionado('none')
3214
  setSecao13InterativoFigura(null)
3215
+ setSecao13InterativoFiguraComIndices(null)
3216
  setSecao13InterativoSelecionado('none')
3217
  setTransformacoesAplicadas(null)
3218
  setOrigemTransformacoes(null)
 
3750
  }
3751
  }
3752
 
3753
+ async function ensureSecao10GraficoComIndices() {
3754
+ const figuraAtual = selection?.grafico_dispersao_com_indices || null
3755
+ if (figuraAtual) {
3756
+ if (secao10InterativoFigura && !secao10InterativoFiguraComIndices) {
3757
+ setSecao10InterativoFiguraComIndices(figuraAtual)
3758
+ }
3759
+ return figuraAtual
3760
+ }
3761
+ if (!sessionId) return null
3762
+ try {
3763
+ const resp = await api.getDispersaoInterativo(sessionId, 'secao10')
3764
+ const figura = resp?.grafico_com_indices || null
3765
+ setSelection((prev) => (prev ? { ...prev, grafico_dispersao_com_indices: figura } : prev))
3766
+ if (secao10InterativoFigura) {
3767
+ setSecao10InterativoFiguraComIndices(figura)
3768
+ }
3769
+ return figura
3770
+ } catch (err) {
3771
+ setError(err?.message || 'Falha ao carregar índices da seção 10.')
3772
+ return null
3773
+ }
3774
+ }
3775
+
3776
+ async function ensureSecao13GraficoComIndices() {
3777
+ const figuraAtual = fit?.grafico_dispersao_modelo_com_indices || null
3778
+ if (figuraAtual) {
3779
+ if (secao13InterativoFigura && !secao13InterativoFiguraComIndices) {
3780
+ setSecao13InterativoFiguraComIndices(figuraAtual)
3781
+ }
3782
+ return figuraAtual
3783
+ }
3784
+ if (!sessionId) return null
3785
+ try {
3786
+ const resp = await api.getDispersaoInterativo(sessionId, 'secao13')
3787
+ const figura = resp?.grafico_com_indices || null
3788
+ setFit((prev) => (prev ? { ...prev, grafico_dispersao_modelo_com_indices: figura } : prev))
3789
+ if (secao13InterativoFigura) {
3790
+ setSecao13InterativoFiguraComIndices(figura)
3791
+ }
3792
+ return figura
3793
+ } catch (err) {
3794
+ setError(err?.message || 'Falha ao carregar índices da seção 13.')
3795
+ return null
3796
+ }
3797
+ }
3798
+
3799
+ async function ensureSecao15GraficoComIndices(grafico) {
3800
+ const alvo = String(grafico || '').trim()
3801
+ if (!alvo || !sessionId) return null
3802
+ const fieldMap = {
3803
+ obs_calc: 'grafico_obs_calc_com_indices',
3804
+ residuos: 'grafico_residuos_com_indices',
3805
+ histograma: 'grafico_histograma_com_indices',
3806
+ cook: 'grafico_cook_com_indices',
3807
+ }
3808
+ const field = fieldMap[alvo]
3809
+ if (!field) return null
3810
+ const figuraAtual = fit?.[field] || null
3811
+ if (figuraAtual) {
3812
+ if (secao15InterativoSelecionado === alvo && !secao15InterativoFiguraComIndices) {
3813
+ setSecao15InterativoFiguraComIndices(figuraAtual)
3814
+ }
3815
+ return figuraAtual
3816
+ }
3817
+ try {
3818
+ const resp = await api.getDiagnosticoInterativo(sessionId, alvo)
3819
+ const figura = resp?.payload_com_indices || null
3820
+ setFit((prev) => (prev ? { ...prev, [field]: figura } : prev))
3821
+ if (secao15InterativoSelecionado === alvo) {
3822
+ setSecao15InterativoFiguraComIndices(figura)
3823
+ }
3824
+ return figura
3825
+ } catch (err) {
3826
+ setError(err?.message || 'Falha ao carregar índices da seção 15.')
3827
+ return null
3828
+ }
3829
+ }
3830
+
3831
  async function onChangeSecao10InterativoSelecionado(value) {
3832
  const nextValue = String(value || 'none').trim() || 'none'
3833
  setSecao10InterativoSelecionado(nextValue)
 
3839
  await withBusy(async () => {
3840
  const resp = await api.getDispersaoInterativo(sessionId, 'secao10')
3841
  setSecao10InterativoFigura(resp?.grafico || null)
3842
+ setSecao10InterativoFiguraComIndices(null)
3843
  })
3844
  }
3845
 
 
3858
  setSecao15InterativoSelecionado(nextValue)
3859
  if (nextValue === 'none') {
3860
  setSecao15InterativoFigura(null)
3861
+ setSecao15InterativoFiguraComIndices(null)
3862
  return
3863
  }
3864
  if (!sessionId) return
 
3866
  await withBusy(async () => {
3867
  const resp = await api.getDiagnosticoInterativo(sessionId, nextValue)
3868
  setSecao15InterativoFigura(resp?.payload || null)
3869
+ setSecao15InterativoFiguraComIndices(null)
3870
  })
3871
  }
3872
 
 
3978
  </button>
3979
  </div>
3980
  ) : (
3981
+ <div className="model-source-flow">
3982
+ <div className="model-source-flow-head">
3983
+ <button
3984
+ type="button"
3985
+ className="model-source-back-btn"
3986
+ onClick={() => {
3987
+ setModeloLoadSource('')
3988
+ setRepoModeloDropdownOpen(false)
3989
+ setImportacaoErro('')
3990
+ if (typeof onRouteChange === 'function') {
3991
+ onRouteChange({ tab: 'elaboracao' })
3992
+ }
3993
+ }}
3994
+ disabled={loading}
3995
+ >
3996
+ Voltar
3997
+ </button>
3998
+ {modeloLoadSource === 'repo' && repoModeloSelecionado ? <ShareLinkButton href={elaboracaoShareHref} /> : null}
3999
+ </div>
4000
 
4001
  {modeloLoadSource === 'repo' ? (
4002
  <div className="row upload-repo-row">
 
4012
  disabled={loading || repoModelosLoading || repoModeloOptions.length === 0}
4013
  onOpenChange={setRepoModeloDropdownOpen}
4014
  />
4015
+ </label>
4016
+ <div className="row compact upload-repo-actions">
4017
+ <button type="button" onClick={onCarregarModeloRepositorio} disabled={loading || repoModelosLoading || !repoModeloSelecionado}>
4018
+ Carregar do repositório
4019
+ </button>
4020
+ <button type="button" onClick={() => void carregarModelosRepositorio()} disabled={loading || repoModelosLoading}>
4021
+ Atualizar lista
4022
+ </button>
4023
+ </div>
4024
  {repoFonteModelos ? <div className="section1-empty-hint">{repoFonteModelos}</div> : null}
4025
  </div>
4026
  ) : null}
 
4951
  <PlotFigure
4952
  key={`s9-interativo-plot-${secao10InterativoAtual.id}`}
4953
  figure={secao10InterativoAtual.figure}
4954
+ indexedFigure={graficosSecao9InterativoComIndicesMap.get(String(secao10InterativoAtual.label || '').trim()) || null}
4955
+ onRequestIndexedFigure={ensureSecao10GraficoComIndices}
4956
  title={secao10InterativoAtual.title}
4957
  subtitle={secao10InterativoAtual.subtitle}
4958
+ showPointIndexToggle
4959
  forceHideLegend
4960
  className="plot-stretch"
4961
  lazy
 
5008
  <PlotFigure
5009
  key={`s9-plot-${item.id}`}
5010
  figure={item.figure}
5011
+ indexedFigure={graficosSecao9ComIndicesMap.get(String(item.label || '').trim()) || null}
5012
+ onRequestIndexedFigure={ensureSecao10GraficoComIndices}
5013
  title={item.title}
5014
  subtitle={item.subtitle}
5015
+ showPointIndexToggle
5016
  forceHideLegend
5017
  className="plot-stretch"
5018
  lazy
 
5488
  <PlotFigure
5489
  key={`s12-interativo-plot-${secao13InterativoAtual.id}`}
5490
  figure={secao13InterativoAtual.figure}
5491
+ indexedFigure={graficosSecao12InterativoComIndicesMap.get(String(secao13InterativoAtual.label || '').trim()) || null}
5492
+ onRequestIndexedFigure={ensureSecao13GraficoComIndices}
5493
  title={secao13InterativoAtual.title}
5494
  subtitle={secao13InterativoAtual.subtitle}
5495
+ showPointIndexToggle
5496
  forceHideLegend
5497
  className="plot-stretch"
5498
  lazy
 
5545
  <PlotFigure
5546
  key={`s12-plot-${item.id}`}
5547
  figure={item.figure}
5548
+ indexedFigure={graficosSecao12ComIndicesMap.get(String(item.label || '').trim()) || null}
5549
+ onRequestIndexedFigure={ensureSecao13GraficoComIndices}
5550
  title={item.title}
5551
  subtitle={item.subtitle}
5552
+ showPointIndexToggle
5553
  forceHideLegend
5554
  className="plot-stretch"
5555
  lazy
 
5718
  </div>
5719
  ) : (
5720
  <div className="plot-grid-2-fixed">
5721
+ <PlotFigure figure={fit.grafico_obs_calc} indexedFigure={fit.grafico_obs_calc_com_indices || null} onRequestIndexedFigure={() => ensureSecao15GraficoComIndices('obs_calc')} title="Obs x Calc" showPointIndexToggle />
5722
+ <PlotFigure figure={fit.grafico_residuos} indexedFigure={fit.grafico_residuos_com_indices || null} onRequestIndexedFigure={() => ensureSecao15GraficoComIndices('residuos')} title="Resíduos" showPointIndexToggle />
5723
+ <PlotFigure figure={fit.grafico_histograma} indexedFigure={fit.grafico_histograma_com_indices || null} onRequestIndexedFigure={() => ensureSecao15GraficoComIndices('histograma')} title="Histograma" showPointIndexToggle />
5724
+ <PlotFigure figure={fit.grafico_cook} indexedFigure={fit.grafico_cook_com_indices || null} onRequestIndexedFigure={() => ensureSecao15GraficoComIndices('cook')} title="Cook" showPointIndexToggle forceHideLegend />
5725
  </div>
5726
  )}
5727
  <div className="plot-full-width">
5728
+ <PlotFigure figure={fit.grafico_correlacao} title="Matriz de correlação" showPointIndexToggle className="plot-correlation-card" />
5729
  </div>
5730
  {secao15DiagnosticoPng ? (
5731
  <>
 
5766
  <PlotFigure
5767
  key={`s15-interativo-${secao15InterativoSelecionado}`}
5768
  figure={secao15InterativoFigura}
5769
+ indexedFigure={secao15InterativoFiguraComIndices}
5770
+ onRequestIndexedFigure={() => ensureSecao15GraficoComIndices(secao15InterativoSelecionado)}
5771
  title={secao15InterativoLabel}
5772
+ showPointIndexToggle
5773
  forceHideLegend={secao15InterativoSelecionado === 'cook'}
5774
  className="plot-stretch"
5775
  lazy
frontend/src/components/ModelosEstatisticosTab.jsx CHANGED
@@ -1,4 +1,5 @@
1
- import React, { useEffect, useState } from 'react'
 
2
  import PesquisaTab from './PesquisaTab'
3
  import RepositorioTab from './RepositorioTab'
4
  import VisaoGeralTab from './VisaoGeralTab'
@@ -13,51 +14,91 @@ export default function ModelosEstatisticosTab({
13
  sessionId,
14
  authUser,
15
  onUsarModeloEmAvaliacao,
16
- openRepositorioModeloRequest = null,
17
- returnToPesquisaMapaRequest = null,
 
 
 
18
  }) {
19
  const [activeSubtab, setActiveSubtab] = useState('Pesquisar Modelos')
20
  const [pesquisaMapaScrollRequest, setPesquisaMapaScrollRequest] = useState(null)
 
 
21
 
22
  useEffect(() => {
23
- const modeloId = String(openRepositorioModeloRequest?.modeloId || '').trim()
24
- if (!modeloId) return
25
- setActiveSubtab('Repositório de Modelos')
26
- }, [openRepositorioModeloRequest])
27
-
28
- useEffect(() => {
29
- const requestKey = String(returnToPesquisaMapaRequest?.requestKey || '').trim()
30
  if (!requestKey) return
31
- setActiveSubtab('Pesquisar Modelos')
32
- }, [returnToPesquisaMapaRequest])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  useEffect(() => {
35
- const requestKey = String(returnToPesquisaMapaRequest?.requestKey || '').trim()
36
- if (!requestKey) return
37
- if (activeSubtab !== 'Pesquisar Modelos') return
38
- setPesquisaMapaScrollRequest(returnToPesquisaMapaRequest)
39
- }, [activeSubtab, returnToPesquisaMapaRequest])
40
 
41
  return (
42
  <div className="tab-content">
43
- <div className="inner-tabs" role="tablist" aria-label="Abas de modelos estatísticos">
44
- {SUBTABS.map((tab) => (
45
- <button
46
- key={tab.key}
47
- type="button"
48
- className={activeSubtab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
49
- onClick={() => setActiveSubtab(tab.key)}
50
- >
51
- {tab.label}
52
- </button>
53
- ))}
54
- </div>
 
 
 
 
 
 
 
 
 
 
55
 
56
  <div className="tab-pane" hidden={activeSubtab !== 'Pesquisar Modelos'}>
57
  <PesquisaTab
58
  sessionId={sessionId}
59
  onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao}
 
 
60
  scrollToMapaRequest={pesquisaMapaScrollRequest}
 
61
  />
62
  </div>
63
 
@@ -65,7 +106,11 @@ export default function ModelosEstatisticosTab({
65
  <RepositorioTab
66
  authUser={authUser}
67
  sessionId={sessionId}
68
- openModeloRequest={openRepositorioModeloRequest}
 
 
 
 
69
  />
70
  </div>
71
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { getModelosSubtabKeyFromSlug, getModelosSubtabSlugFromKey } from '../deepLinks'
3
  import PesquisaTab from './PesquisaTab'
4
  import RepositorioTab from './RepositorioTab'
5
  import VisaoGeralTab from './VisaoGeralTab'
 
14
  sessionId,
15
  authUser,
16
  onUsarModeloEmAvaliacao,
17
+ onEditarModeloEmElaboracao = null,
18
+ onAbrirModeloNoRepositorio = null,
19
+ routeRequest = null,
20
+ onRouteChange = null,
21
+ onModoImersivoChange = null,
22
  }) {
23
  const [activeSubtab, setActiveSubtab] = useState('Pesquisar Modelos')
24
  const [pesquisaMapaScrollRequest, setPesquisaMapaScrollRequest] = useState(null)
25
+ const [repositorioModeloAberto, setRepositorioModeloAberto] = useState(false)
26
+ const handledRouteRequestRef = useRef('')
27
 
28
  useEffect(() => {
29
+ const requestKey = String(routeRequest?.requestKey || '').trim()
 
 
 
 
 
 
30
  if (!requestKey) return
31
+ if (handledRouteRequestRef.current === requestKey) return
32
+ handledRouteRequestRef.current = requestKey
33
+
34
+ const nextSubtab = getModelosSubtabKeyFromSlug(routeRequest?.subtab)
35
+ setActiveSubtab(routeRequest?.modeloId ? 'Repositório de Modelos' : nextSubtab)
36
+
37
+ if (routeRequest?.scrollToMapa) {
38
+ setPesquisaMapaScrollRequest({ requestKey })
39
+ }
40
+ }, [routeRequest])
41
+
42
+ const pesquisaRouteRequest = useMemo(() => {
43
+ const requestKey = String(routeRequest?.requestKey || '').trim()
44
+ if (!requestKey) return null
45
+ if (String(routeRequest?.subtab || '').trim() !== 'pesquisa') return null
46
+ return routeRequest
47
+ }, [routeRequest])
48
+
49
+ const repositorioOpenRequest = useMemo(() => {
50
+ const requestKey = String(routeRequest?.requestKey || '').trim()
51
+ const modeloId = String(routeRequest?.modeloId || '').trim()
52
+ if (!requestKey || !modeloId) return null
53
+ return {
54
+ requestKey,
55
+ modeloId,
56
+ modelTab: routeRequest?.modelTab || 'mapa',
57
+ nomeModelo: routeRequest?.nomeModelo || modeloId,
58
+ modeloArquivo: routeRequest?.modeloArquivo || '',
59
+ returnIntent: routeRequest?.returnIntent || null,
60
+ }
61
+ }, [routeRequest])
62
 
63
  useEffect(() => {
64
+ if (typeof onModoImersivoChange !== 'function') return undefined
65
+ onModoImersivoChange(repositorioModeloAberto)
66
+ return () => onModoImersivoChange(false)
67
+ }, [repositorioModeloAberto, onModoImersivoChange])
 
68
 
69
  return (
70
  <div className="tab-content">
71
+ {!repositorioModeloAberto ? (
72
+ <div className="inner-tabs" role="tablist" aria-label="Abas de modelos estatísticos">
73
+ {SUBTABS.map((tab) => (
74
+ <button
75
+ key={tab.key}
76
+ type="button"
77
+ className={activeSubtab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
78
+ onClick={() => {
79
+ setActiveSubtab(tab.key)
80
+ if (typeof onRouteChange === 'function') {
81
+ onRouteChange({
82
+ tab: 'modelos',
83
+ subtab: getModelosSubtabSlugFromKey(tab.key),
84
+ })
85
+ }
86
+ }}
87
+ >
88
+ {tab.label}
89
+ </button>
90
+ ))}
91
+ </div>
92
+ ) : null}
93
 
94
  <div className="tab-pane" hidden={activeSubtab !== 'Pesquisar Modelos'}>
95
  <PesquisaTab
96
  sessionId={sessionId}
97
  onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao}
98
+ onAbrirModeloNoRepositorio={onAbrirModeloNoRepositorio}
99
+ routeRequest={pesquisaRouteRequest}
100
  scrollToMapaRequest={pesquisaMapaScrollRequest}
101
+ onRouteChange={onRouteChange}
102
  />
103
  </div>
104
 
 
106
  <RepositorioTab
107
  authUser={authUser}
108
  sessionId={sessionId}
109
+ openModeloRequest={repositorioOpenRequest}
110
+ onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao}
111
+ onEditarModeloEmElaboracao={onEditarModeloEmElaboracao}
112
+ onRouteChange={onRouteChange}
113
+ onModeloAbertoChange={setRepositorioModeloAberto}
114
  />
115
  </div>
116
 
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { createPortal } from 'react-dom'
3
  import { api, downloadBlob } from '../api'
 
4
  import DataTable from './DataTable'
5
  import EquationFormatsPanel from './EquationFormatsPanel'
6
  import LoadingOverlay from './LoadingOverlay'
@@ -9,6 +10,7 @@ import ModeloTrabalhosTecnicosPanel from './ModeloTrabalhosTecnicosPanel'
9
  import PlotFigure from './PlotFigure'
10
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
11
  import SectionBlock from './SectionBlock'
 
12
  import SinglePillAutocomplete from './SinglePillAutocomplete'
13
  import { getFaixaDataRecencyInfo } from '../modelRecency'
14
 
@@ -944,13 +946,20 @@ function ChipAutocompleteInput({
944
  export default function PesquisaTab({
945
  sessionId,
946
  onUsarModeloEmAvaliacao = null,
 
 
947
  scrollToMapaRequest = null,
 
948
  }) {
949
- const [loading, setLoading] = useState(false)
 
950
  const [error, setError] = useState('')
951
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
952
  const [sugestoesInicializadas, setSugestoesInicializadas] = useState(false)
953
  const [mostrarAdminConfig, setMostrarAdminConfig] = useState(false)
 
 
 
954
 
955
  const [filters, setFilters] = useState(EMPTY_FILTERS)
956
  const [result, setResult] = useState(RESULT_INITIAL)
@@ -1001,6 +1010,10 @@ export default function PesquisaTab({
1001
  const sectionResultadosRef = useRef(null)
1002
  const sectionMapaRef = useRef(null)
1003
  const scrollMapaHandledRef = useRef('')
 
 
 
 
1004
 
1005
  const sugestoes = result.sugestoes || {}
1006
  const opcoesTipoModelo = useMemo(
@@ -1025,6 +1038,33 @@ export default function PesquisaTab({
1025
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
1026
  const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
1027
  const mapaFoiGerado = Boolean(mapaHtmls.pontos || mapaHtmls.cobertura)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1028
 
1029
  function scrollToElementTop(el, behavior = 'smooth', offsetPx = 0) {
1030
  if (!el || typeof window === 'undefined') return
@@ -1033,13 +1073,8 @@ export default function PesquisaTab({
1033
  window.scrollTo({ top: targetTop, behavior })
1034
  }
1035
 
1036
- function scrollParaAbasGeraisNoTopo() {
1037
- if (typeof document === 'undefined') return
1038
- const tabsEl = document.querySelector('.tabs')
1039
- if (tabsEl) {
1040
- scrollToElementTop(tabsEl, 'smooth', 0)
1041
- return
1042
- }
1043
  window.scrollTo({ top: 0, behavior: 'smooth' })
1044
  }
1045
 
@@ -1126,11 +1161,16 @@ export default function PesquisaTab({
1126
  }
1127
  }
1128
 
1129
- async function buscarModelos(nextFilters = filters, nextAvaliandos = avaliandosGeolocalizados) {
1130
- setLoading(true)
 
 
 
 
1131
  setError('')
1132
  try {
1133
  const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextAvaliandos))
 
1134
  const modelos = response.modelos || []
1135
  const idsNovos = new Set(modelos.map((item) => item.id))
1136
 
@@ -1146,18 +1186,29 @@ export default function PesquisaTab({
1146
  resetMapaPesquisa()
1147
  setPesquisaInicializada(true)
1148
  setSugestoesInicializadas(true)
 
 
 
1149
  } catch (err) {
 
1150
  setError(err.message)
1151
  } finally {
1152
- setLoading(false)
 
 
1153
  }
1154
  }
1155
 
1156
- async function carregarContextoInicial() {
1157
- setLoading(true)
 
 
 
 
1158
  setError('')
1159
  try {
1160
  const response = await api.pesquisarModelos({ somente_contexto: true })
 
1161
 
1162
  setResult({
1163
  ...RESULT_INITIAL,
@@ -1169,17 +1220,102 @@ export default function PesquisaTab({
1169
  resetMapaPesquisa()
1170
  setPesquisaInicializada(false)
1171
  setSugestoesInicializadas(true)
 
 
 
1172
  } catch (err) {
 
1173
  setError(err.message)
1174
  setSugestoesInicializadas(true)
1175
  } finally {
1176
- setLoading(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1177
  }
1178
  }
1179
 
1180
  useEffect(() => {
 
 
1181
  void carregarContextoInicial()
1182
- }, [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1183
 
1184
  useEffect(() => {
1185
  if (!selectAllRef.current) return
@@ -1245,8 +1381,13 @@ export default function PesquisaTab({
1245
  }
1246
 
1247
  async function onLimparFiltros() {
1248
- setFilters(EMPTY_FILTERS)
1249
- await carregarContextoInicial()
 
 
 
 
 
1250
  }
1251
 
1252
  function onToggleSelecionado(modelId) {
@@ -1302,6 +1443,14 @@ export default function PesquisaTab({
1302
  }
1303
 
1304
  async function onAbrirModelo(modelo) {
 
 
 
 
 
 
 
 
1305
  if (!sessionId) {
1306
  setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
1307
  return
@@ -1319,7 +1468,7 @@ export default function PesquisaTab({
1319
  observacao: String(resp?.meta_modelo?.observacao_modelo || '').trim(),
1320
  })
1321
  window.requestAnimationFrame(() => {
1322
- scrollParaAbasGeraisNoTopo()
1323
  })
1324
  } catch (err) {
1325
  setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
@@ -1623,7 +1772,7 @@ export default function PesquisaTab({
1623
  type="button"
1624
  className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1625
  onClick={() => void onRemoverAvaliandoLocalizacao(item.id)}
1626
- disabled={localizacaoLoading || loading}
1627
  >
1628
  Excluir avaliando
1629
  </button>
@@ -1697,11 +1846,14 @@ export default function PesquisaTab({
1697
  <SinglePillAutocomplete
1698
  value={localizacaoInputs.logradouro}
1699
  onChange={(nextValue) => atualizarCampoLocalizacao('logradouro', nextValue)}
1700
- options={sugestoes.logradouros_eixos || []}
1701
  placeholder="Digite ou selecione um logradouro dos eixos"
1702
  panelTitle="Logradouros dos eixos"
1703
  emptyMessage="Nenhum logradouro encontrado nos eixos."
1704
- loading={loading && !sugestoesInicializadas}
 
 
 
1705
  inputName={toInputName('logradouroEixosPesquisa')}
1706
  inputAutoComplete="new-password"
1707
  />
@@ -1765,7 +1917,7 @@ export default function PesquisaTab({
1765
  placeholder="Digite e pressione Enter"
1766
  suggestions={sugestoes.nomes_modelo || []}
1767
  panelTitle="Modelos sugeridos"
1768
- loading={loading && !sugestoesInicializadas}
1769
  />
1770
  </label>
1771
  <label className="pesquisa-field">
@@ -1804,7 +1956,7 @@ export default function PesquisaTab({
1804
  placeholder="Digite e pressione Enter"
1805
  suggestions={sugestoes.finalidades || []}
1806
  panelTitle="Finalidades sugeridas"
1807
- loading={loading && !sugestoesInicializadas}
1808
  />
1809
  </label>
1810
  </div>
@@ -1831,7 +1983,7 @@ export default function PesquisaTab({
1831
  placeholder="Selecione uma ou mais zonas"
1832
  suggestions={sugestoes.zonas_avaliacao || []}
1833
  panelTitle="Zonas sugeridas"
1834
- loading={loading && !sugestoesInicializadas}
1835
  />
1836
  </label>
1837
  <label className="pesquisa-field pesquisa-bairro-bottom-field">
@@ -1843,7 +1995,7 @@ export default function PesquisaTab({
1843
  placeholder="Digite e pressione Enter"
1844
  suggestions={sugestoes.bairros || []}
1845
  panelTitle="Bairros sugeridos"
1846
- loading={loading && !sugestoesInicializadas}
1847
  />
1848
  </label>
1849
  </div>
@@ -1876,10 +2028,10 @@ export default function PesquisaTab({
1876
  </div>
1877
 
1878
  <div className="row pesquisa-actions pesquisa-actions-primary">
1879
- <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
1880
- {loading ? 'Pesquisando...' : 'Pesquisar'}
1881
  </button>
1882
- <button type="button" onClick={() => void onLimparFiltros()} disabled={loading}>
1883
  Limpar filtros
1884
  </button>
1885
  </div>
@@ -1912,12 +2064,20 @@ export default function PesquisaTab({
1912
  </select>
1913
  </label>
1914
  ) : null}
1915
- {resultIds.length ? (
1916
- <label className="pesquisa-select-all">
1917
- <input ref={selectAllRef} type="checkbox" checked={todosSelecionados} onChange={onToggleSelecionarTodos} />
1918
- Selecionar todos os exibidos
1919
- </label>
1920
- ) : null}
 
 
 
 
 
 
 
 
1921
  </div>
1922
 
1923
  {!modelosOrdenados.length ? (
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { createPortal } from 'react-dom'
3
  import { api, downloadBlob } from '../api'
4
+ import { buildPesquisaLink, buildPesquisaRoutePayload, getPesquisaFilterDefaults, hasPesquisaRoutePayload } from '../deepLinks'
5
  import DataTable from './DataTable'
6
  import EquationFormatsPanel from './EquationFormatsPanel'
7
  import LoadingOverlay from './LoadingOverlay'
 
10
  import PlotFigure from './PlotFigure'
11
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
12
  import SectionBlock from './SectionBlock'
13
+ import ShareLinkButton from './ShareLinkButton'
14
  import SinglePillAutocomplete from './SinglePillAutocomplete'
15
  import { getFaixaDataRecencyInfo } from '../modelRecency'
16
 
 
946
  export default function PesquisaTab({
947
  sessionId,
948
  onUsarModeloEmAvaliacao = null,
949
+ onAbrirModeloNoRepositorio = null,
950
+ routeRequest = null,
951
  scrollToMapaRequest = null,
952
+ onRouteChange = null,
953
  }) {
954
+ const [searchLoading, setSearchLoading] = useState(false)
955
+ const [contextLoading, setContextLoading] = useState(false)
956
  const [error, setError] = useState('')
957
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
958
  const [sugestoesInicializadas, setSugestoesInicializadas] = useState(false)
959
  const [mostrarAdminConfig, setMostrarAdminConfig] = useState(false)
960
+ const [logradouroOptions, setLogradouroOptions] = useState([])
961
+ const [logradouroOptionsLoading, setLogradouroOptionsLoading] = useState(false)
962
+ const [logradouroOptionsLoaded, setLogradouroOptionsLoaded] = useState(false)
963
 
964
  const [filters, setFilters] = useState(EMPTY_FILTERS)
965
  const [result, setResult] = useState(RESULT_INITIAL)
 
1010
  const sectionResultadosRef = useRef(null)
1011
  const sectionMapaRef = useRef(null)
1012
  const scrollMapaHandledRef = useRef('')
1013
+ const routeRequestHandledRef = useRef('')
1014
+ const resultRequestSeqRef = useRef(0)
1015
+ const searchRequestSeqRef = useRef(0)
1016
+ const contextRequestSeqRef = useRef(0)
1017
 
1018
  const sugestoes = result.sugestoes || {}
1019
  const opcoesTipoModelo = useMemo(
 
1038
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
1039
  const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
1040
  const mapaFoiGerado = Boolean(mapaHtmls.pontos || mapaHtmls.cobertura)
1041
+ const pesquisaShareAvaliando = !localizacaoMultipla ? (avaliandosGeoPayload[0] || null) : null
1042
+ const pesquisaShareHref = buildPesquisaLink(filters, pesquisaShareAvaliando)
1043
+ const pesquisaShareDisabled = localizacaoMultipla
1044
+ const isPesquisaBusy = searchLoading
1045
+
1046
+ function buildPesquisaReturnIntent() {
1047
+ return {
1048
+ ...buildPesquisaRoutePayload(filters, pesquisaShareAvaliando),
1049
+ pesquisaExecutada: true,
1050
+ avaliandos: avaliandosGeolocalizados.map((item, index) => ({
1051
+ id: String(item?.id || `avaliando-${index + 1}`),
1052
+ lat: Number(item?.lat),
1053
+ lon: Number(item?.lon),
1054
+ logradouro: String(item?.logradouro || ''),
1055
+ numero_usado: String(item?.numero_usado || ''),
1056
+ cdlog: item?.cdlog ?? null,
1057
+ origem: String(item?.origem || 'coords'),
1058
+ })).filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lon)),
1059
+ }
1060
+ }
1061
+
1062
+ function emitPesquisaRoute(nextFilters = filters, nextAvaliandos = avaliandosGeolocalizados) {
1063
+ if (typeof onRouteChange !== 'function') return
1064
+ const avaliandosPayload = buildAvaliandosGeoPayload(nextAvaliandos)
1065
+ const avaliando = avaliandosPayload.length === 1 ? avaliandosPayload[0] : null
1066
+ onRouteChange(buildPesquisaRoutePayload(nextFilters, avaliando))
1067
+ }
1068
 
1069
  function scrollToElementTop(el, behavior = 'smooth', offsetPx = 0) {
1070
  if (!el || typeof window === 'undefined') return
 
1073
  window.scrollTo({ top: targetTop, behavior })
1074
  }
1075
 
1076
+ function scrollParaTopoDaPagina() {
1077
+ if (typeof window === 'undefined') return
 
 
 
 
 
1078
  window.scrollTo({ top: 0, behavior: 'smooth' })
1079
  }
1080
 
 
1161
  }
1162
  }
1163
 
1164
+ async function buscarModelos(nextFilters = filters, nextAvaliandos = avaliandosGeolocalizados, options = {}) {
1165
+ const requestId = resultRequestSeqRef.current + 1
1166
+ const searchRequestId = searchRequestSeqRef.current + 1
1167
+ resultRequestSeqRef.current = requestId
1168
+ searchRequestSeqRef.current = searchRequestId
1169
+ setSearchLoading(true)
1170
  setError('')
1171
  try {
1172
  const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextAvaliandos))
1173
+ if (requestId !== resultRequestSeqRef.current) return
1174
  const modelos = response.modelos || []
1175
  const idsNovos = new Set(modelos.map((item) => item.id))
1176
 
 
1186
  resetMapaPesquisa()
1187
  setPesquisaInicializada(true)
1188
  setSugestoesInicializadas(true)
1189
+ if (options.syncRoute !== false) {
1190
+ emitPesquisaRoute(nextFilters, nextAvaliandos)
1191
+ }
1192
  } catch (err) {
1193
+ if (requestId !== resultRequestSeqRef.current) return
1194
  setError(err.message)
1195
  } finally {
1196
+ if (searchRequestId === searchRequestSeqRef.current) {
1197
+ setSearchLoading(false)
1198
+ }
1199
  }
1200
  }
1201
 
1202
+ async function carregarContextoInicial(options = {}) {
1203
+ const requestId = resultRequestSeqRef.current + 1
1204
+ const contextRequestId = contextRequestSeqRef.current + 1
1205
+ resultRequestSeqRef.current = requestId
1206
+ contextRequestSeqRef.current = contextRequestId
1207
+ setContextLoading(true)
1208
  setError('')
1209
  try {
1210
  const response = await api.pesquisarModelos({ somente_contexto: true })
1211
+ if (requestId !== resultRequestSeqRef.current) return
1212
 
1213
  setResult({
1214
  ...RESULT_INITIAL,
 
1220
  resetMapaPesquisa()
1221
  setPesquisaInicializada(false)
1222
  setSugestoesInicializadas(true)
1223
+ if (options.syncRoute) {
1224
+ emitPesquisaRoute(options.filters || getPesquisaFilterDefaults(), options.avaliandos || avaliandosGeolocalizados)
1225
+ }
1226
  } catch (err) {
1227
+ if (requestId !== resultRequestSeqRef.current) return
1228
  setError(err.message)
1229
  setSugestoesInicializadas(true)
1230
  } finally {
1231
+ if (contextRequestId === contextRequestSeqRef.current) {
1232
+ setContextLoading(false)
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ async function carregarSugestoesLogradouro() {
1238
+ if (logradouroOptionsLoading || logradouroOptionsLoaded) return
1239
+ setLogradouroOptionsLoading(true)
1240
+ try {
1241
+ const response = await api.pesquisarLogradourosEixos()
1242
+ const opcoes = Array.isArray(response?.logradouros_eixos)
1243
+ ? response.logradouros_eixos.map((item) => String(item || '').trim()).filter(Boolean)
1244
+ : []
1245
+ setLogradouroOptions(opcoes)
1246
+ setLogradouroOptionsLoaded(true)
1247
+ } catch (_err) {
1248
+ setLogradouroOptions([])
1249
+ } finally {
1250
+ setLogradouroOptionsLoading(false)
1251
  }
1252
  }
1253
 
1254
  useEffect(() => {
1255
+ const requestKey = String(routeRequest?.requestKey || '').trim()
1256
+ if (requestKey) return undefined
1257
  void carregarContextoInicial()
1258
+ return undefined
1259
+ }, [routeRequest])
1260
+
1261
+ useEffect(() => {
1262
+ const requestKey = String(routeRequest?.requestKey || '').trim()
1263
+ if (!requestKey) return
1264
+ if (routeRequestHandledRef.current === requestKey) return
1265
+ routeRequestHandledRef.current = requestKey
1266
+
1267
+ const nextFilters = {
1268
+ ...getPesquisaFilterDefaults(),
1269
+ ...(routeRequest?.filters || {}),
1270
+ }
1271
+ const rawAvaliandos = Array.isArray(routeRequest?.avaliandos) ? routeRequest.avaliandos : []
1272
+ let nextEntries = rawAvaliandos
1273
+ .map((item, index) => {
1274
+ const lat = Number(item?.lat)
1275
+ const lon = Number(item?.lon)
1276
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
1277
+ return createLocalizacaoEntry({
1278
+ lat,
1279
+ lon,
1280
+ origem: String(item?.origem || 'coords'),
1281
+ logradouro: String(item?.logradouro || ''),
1282
+ numero_usado: String(item?.numero_usado || ''),
1283
+ cdlog: item?.cdlog ?? null,
1284
+ }, String(item?.id || `avaliando-${localizacaoIdCounterRef.current + index}`))
1285
+ })
1286
+ .filter(Boolean)
1287
+ const lat = Number(routeRequest?.avaliando?.lat)
1288
+ const lon = Number(routeRequest?.avaliando?.lon)
1289
+ const possuiAvaliando = Number.isFinite(lat) && Number.isFinite(lon)
1290
+ if (!nextEntries.length && possuiAvaliando) {
1291
+ nextEntries = [createLocalizacaoEntry({
1292
+ lat,
1293
+ lon,
1294
+ origem: 'coords',
1295
+ logradouro: '',
1296
+ numero_usado: '',
1297
+ cdlog: null,
1298
+ }, `avaliando-${localizacaoIdCounterRef.current}`)]
1299
+ localizacaoIdCounterRef.current += 1
1300
+ } else if (nextEntries.length) {
1301
+ localizacaoIdCounterRef.current += nextEntries.length
1302
+ }
1303
+
1304
+ setFilters(nextFilters)
1305
+ setAvaliandosGeolocalizados(nextEntries)
1306
+ setLocalizacaoModo(possuiAvaliando ? 'coords' : 'endereco')
1307
+ setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1308
+ setLocalizacaoError('')
1309
+ setLocalizacaoStatus('')
1310
+ resetMapaPesquisa()
1311
+
1312
+ if (routeRequest?.pesquisaExecutada || hasPesquisaRoutePayload(nextFilters, routeRequest?.avaliando) || nextEntries.length > 1) {
1313
+ void buscarModelos(nextFilters, nextEntries, { syncRoute: false })
1314
+ return
1315
+ }
1316
+
1317
+ void carregarContextoInicial({ syncRoute: false, filters: nextFilters, avaliandos: nextEntries })
1318
+ }, [routeRequest])
1319
 
1320
  useEffect(() => {
1321
  if (!selectAllRef.current) return
 
1381
  }
1382
 
1383
  async function onLimparFiltros() {
1384
+ const nextFilters = getPesquisaFilterDefaults()
1385
+ setFilters(nextFilters)
1386
+ await carregarContextoInicial({
1387
+ syncRoute: true,
1388
+ filters: nextFilters,
1389
+ avaliandos: avaliandosGeolocalizados,
1390
+ })
1391
  }
1392
 
1393
  function onToggleSelecionado(modelId) {
 
1443
  }
1444
 
1445
  async function onAbrirModelo(modelo) {
1446
+ if (typeof onAbrirModeloNoRepositorio === 'function') {
1447
+ onAbrirModeloNoRepositorio({
1448
+ ...modelo,
1449
+ returnIntent: buildPesquisaReturnIntent(),
1450
+ })
1451
+ return
1452
+ }
1453
+
1454
  if (!sessionId) {
1455
  setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
1456
  return
 
1468
  observacao: String(resp?.meta_modelo?.observacao_modelo || '').trim(),
1469
  })
1470
  window.requestAnimationFrame(() => {
1471
+ scrollParaTopoDaPagina()
1472
  })
1473
  } catch (err) {
1474
  setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
 
1772
  type="button"
1773
  className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1774
  onClick={() => void onRemoverAvaliandoLocalizacao(item.id)}
1775
+ disabled={localizacaoLoading || isPesquisaBusy}
1776
  >
1777
  Excluir avaliando
1778
  </button>
 
1846
  <SinglePillAutocomplete
1847
  value={localizacaoInputs.logradouro}
1848
  onChange={(nextValue) => atualizarCampoLocalizacao('logradouro', nextValue)}
1849
+ options={logradouroOptions}
1850
  placeholder="Digite ou selecione um logradouro dos eixos"
1851
  panelTitle="Logradouros dos eixos"
1852
  emptyMessage="Nenhum logradouro encontrado nos eixos."
1853
+ loading={logradouroOptionsLoading}
1854
+ onOpenChange={(open) => {
1855
+ if (open) void carregarSugestoesLogradouro()
1856
+ }}
1857
  inputName={toInputName('logradouroEixosPesquisa')}
1858
  inputAutoComplete="new-password"
1859
  />
 
1917
  placeholder="Digite e pressione Enter"
1918
  suggestions={sugestoes.nomes_modelo || []}
1919
  panelTitle="Modelos sugeridos"
1920
+ loading={contextLoading && !sugestoesInicializadas}
1921
  />
1922
  </label>
1923
  <label className="pesquisa-field">
 
1956
  placeholder="Digite e pressione Enter"
1957
  suggestions={sugestoes.finalidades || []}
1958
  panelTitle="Finalidades sugeridas"
1959
+ loading={contextLoading && !sugestoesInicializadas}
1960
  />
1961
  </label>
1962
  </div>
 
1983
  placeholder="Selecione uma ou mais zonas"
1984
  suggestions={sugestoes.zonas_avaliacao || []}
1985
  panelTitle="Zonas sugeridas"
1986
+ loading={contextLoading && !sugestoesInicializadas}
1987
  />
1988
  </label>
1989
  <label className="pesquisa-field pesquisa-bairro-bottom-field">
 
1995
  placeholder="Digite e pressione Enter"
1996
  suggestions={sugestoes.bairros || []}
1997
  panelTitle="Bairros sugeridos"
1998
+ loading={contextLoading && !sugestoesInicializadas}
1999
  />
2000
  </label>
2001
  </div>
 
2028
  </div>
2029
 
2030
  <div className="row pesquisa-actions pesquisa-actions-primary">
2031
+ <button type="button" onClick={() => void buscarModelos()} disabled={isPesquisaBusy}>
2032
+ {searchLoading ? 'Pesquisando...' : 'Pesquisar'}
2033
  </button>
2034
+ <button type="button" onClick={() => void onLimparFiltros()} disabled={isPesquisaBusy}>
2035
  Limpar filtros
2036
  </button>
2037
  </div>
 
2064
  </select>
2065
  </label>
2066
  ) : null}
2067
+ <div className="pesquisa-results-toolbar-actions">
2068
+ <ShareLinkButton
2069
+ href={pesquisaShareHref}
2070
+ label="Copiar link da pesquisa"
2071
+ disabled={pesquisaShareDisabled}
2072
+ title={pesquisaShareDisabled ? 'O compartilhamento da pesquisa suporta apenas um avaliando na versão atual.' : pesquisaShareHref}
2073
+ />
2074
+ {resultIds.length ? (
2075
+ <label className="pesquisa-select-all">
2076
+ <input ref={selectAllRef} type="checkbox" checked={todosSelecionados} onChange={onToggleSelecionarTodos} />
2077
+ Selecionar todos os exibidos
2078
+ </label>
2079
+ ) : null}
2080
+ </div>
2081
  </div>
2082
 
2083
  {!modelosOrdenados.length ? (
frontend/src/components/PlotFigure.jsx CHANGED
@@ -2,9 +2,21 @@ import React, { useEffect, useRef, useState } from 'react'
2
  import Plot from 'react-plotly.js'
3
  import Plotly from 'plotly.js-dist-min'
4
 
5
- function PlotFigure({ figure, title, subtitle = '', forceHideLegend = false, className = '', lazy = false }) {
 
 
 
 
 
 
 
 
 
 
6
  const containerRef = useRef(null)
7
  const [shouldRenderPlot, setShouldRenderPlot] = useState(() => !lazy)
 
 
8
 
9
  useEffect(() => {
10
  if (!lazy) {
@@ -32,16 +44,21 @@ function PlotFigure({ figure, title, subtitle = '', forceHideLegend = false, cla
32
  return () => observer.disconnect()
33
  }, [lazy, shouldRenderPlot])
34
 
 
 
 
 
 
35
  if (!figure) {
36
  return <div className="empty-box">Grafico indisponivel.</div>
37
  }
38
 
39
- const data = (figure.data || []).map((trace) => {
40
- if (!forceHideLegend) return trace
41
- return { ...trace, showlegend: false }
42
- })
43
-
44
- const baseLayout = figure.layout || {}
45
  const { width: _ignoreWidth, ...layoutNoWidth } = baseLayout
46
  const safeAnnotations = Array.isArray(baseLayout.annotations)
47
  ? baseLayout.annotations.map((annotation) => {
@@ -49,35 +66,85 @@ function PlotFigure({ figure, title, subtitle = '', forceHideLegend = false, cla
49
  return { ...clean, showarrow: false }
50
  })
51
  : baseLayout.annotations
 
 
 
 
52
  const layout = {
53
  ...layoutNoWidth,
54
  autosize: true,
55
- annotations: safeAnnotations,
56
  margin: baseLayout.margin || { t: 40, r: 20, b: 50, l: 50 },
57
  }
58
  if (forceHideLegend) {
59
  layout.showlegend = false
60
  }
 
61
  const plotHeight = layout.height ? `${layout.height}px` : '100%'
62
  const cardClassName = `plot-card ${className}`.trim()
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  return (
65
  <div ref={containerRef} className={cardClassName}>
66
  {title || subtitle ? (
67
  <div className="plot-card-head">
68
  {title ? <h4 className="plot-card-title">{title}</h4> : null}
69
  {subtitle ? <div className="plot-card-subtitle">{subtitle}</div> : null}
 
 
 
 
 
 
 
 
 
 
 
 
70
  </div>
71
  ) : null}
72
  {shouldRenderPlot ? (
73
- <Plot
74
- data={data}
75
- layout={layout}
76
- config={{ responsive: true, displaylogo: false }}
77
- style={{ width: '100%', height: plotHeight, minHeight: '320px' }}
78
- useResizeHandler
79
- plotly={Plotly}
80
- />
 
 
 
81
  ) : (
82
  <div className="plot-lazy-placeholder">Carregando gráfico...</div>
83
  )}
@@ -85,4 +152,4 @@ function PlotFigure({ figure, title, subtitle = '', forceHideLegend = false, cla
85
  )
86
  }
87
 
88
- export default React.memo(PlotFigure)
 
2
  import Plot from 'react-plotly.js'
3
  import Plotly from 'plotly.js-dist-min'
4
 
5
+ function PlotFigure({
6
+ figure,
7
+ indexedFigure = null,
8
+ onRequestIndexedFigure = null,
9
+ title,
10
+ subtitle = '',
11
+ forceHideLegend = false,
12
+ className = '',
13
+ lazy = false,
14
+ showPointIndexToggle = false,
15
+ }) {
16
  const containerRef = useRef(null)
17
  const [shouldRenderPlot, setShouldRenderPlot] = useState(() => !lazy)
18
+ const [showPointIndices, setShowPointIndices] = useState(false)
19
+ const [loadingIndexedFigure, setLoadingIndexedFigure] = useState(false)
20
 
21
  useEffect(() => {
22
  if (!lazy) {
 
44
  return () => observer.disconnect()
45
  }, [lazy, shouldRenderPlot])
46
 
47
+ useEffect(() => {
48
+ setShowPointIndices(false)
49
+ setLoadingIndexedFigure(false)
50
+ }, [figure])
51
+
52
  if (!figure) {
53
  return <div className="empty-box">Grafico indisponivel.</div>
54
  }
55
 
56
+ const hasIndexedFigure = Boolean(indexedFigure)
57
+ const canRequestIndexedFigure = typeof onRequestIndexedFigure === 'function'
58
+ const canToggleIndices = hasIndexedFigure || canRequestIndexedFigure
59
+ const activeFigure = showPointIndices && hasIndexedFigure ? indexedFigure : figure
60
+ const safeFigure = activeFigure || { data: [], layout: {} }
61
+ const baseLayout = safeFigure.layout || {}
62
  const { width: _ignoreWidth, ...layoutNoWidth } = baseLayout
63
  const safeAnnotations = Array.isArray(baseLayout.annotations)
64
  ? baseLayout.annotations.map((annotation) => {
 
66
  return { ...clean, showarrow: false }
67
  })
68
  : baseLayout.annotations
69
+
70
+ const data = (safeFigure.data || []).map((trace) => (
71
+ forceHideLegend ? { ...trace, showlegend: false } : { ...trace }
72
+ ))
73
  const layout = {
74
  ...layoutNoWidth,
75
  autosize: true,
76
+ annotations: Array.isArray(safeAnnotations) ? safeAnnotations : [],
77
  margin: baseLayout.margin || { t: 40, r: 20, b: 50, l: 50 },
78
  }
79
  if (forceHideLegend) {
80
  layout.showlegend = false
81
  }
82
+
83
  const plotHeight = layout.height ? `${layout.height}px` : '100%'
84
  const cardClassName = `plot-card ${className}`.trim()
85
 
86
+ async function handleToggleChange(event) {
87
+ const checked = Boolean(event.target.checked)
88
+ if (!checked) {
89
+ setShowPointIndices(false)
90
+ return
91
+ }
92
+ if (hasIndexedFigure) {
93
+ setShowPointIndices(true)
94
+ return
95
+ }
96
+ if (!canRequestIndexedFigure || loadingIndexedFigure) return
97
+ setLoadingIndexedFigure(true)
98
+ try {
99
+ const loadedFigure = await onRequestIndexedFigure()
100
+ setShowPointIndices(Boolean(loadedFigure))
101
+ } catch {
102
+ setShowPointIndices(false)
103
+ } finally {
104
+ setLoadingIndexedFigure(false)
105
+ }
106
+ }
107
+
108
+ const toggleTitle = loadingIndexedFigure
109
+ ? 'Carregando índices dos pontos...'
110
+ : hasIndexedFigure
111
+ ? 'Exibir índices dos pontos'
112
+ : canRequestIndexedFigure
113
+ ? 'Carregar índices dos pontos sob demanda'
114
+ : 'Este gráfico não trouxe uma versão com índices.'
115
+
116
  return (
117
  <div ref={containerRef} className={cardClassName}>
118
  {title || subtitle ? (
119
  <div className="plot-card-head">
120
  {title ? <h4 className="plot-card-title">{title}</h4> : null}
121
  {subtitle ? <div className="plot-card-subtitle">{subtitle}</div> : null}
122
+ {showPointIndexToggle ? (
123
+ <label className={`plot-card-toggle${canToggleIndices ? '' : ' is-disabled'}`}>
124
+ <input
125
+ type="checkbox"
126
+ checked={showPointIndices}
127
+ disabled={!canToggleIndices || loadingIndexedFigure}
128
+ onChange={handleToggleChange}
129
+ title={toggleTitle}
130
+ />
131
+ {loadingIndexedFigure ? 'Carregando índices...' : 'Exibir índices dos pontos'}
132
+ </label>
133
+ ) : null}
134
  </div>
135
  ) : null}
136
  {shouldRenderPlot ? (
137
+ <div className="plot-card-body">
138
+ <Plot
139
+ data={data}
140
+ layout={layout}
141
+ revision={showPointIndices && hasIndexedFigure ? 1 : 0}
142
+ config={{ responsive: true, displaylogo: false }}
143
+ style={{ width: '100%', height: plotHeight, minHeight: '320px' }}
144
+ useResizeHandler
145
+ plotly={Plotly}
146
+ />
147
+ </div>
148
  ) : (
149
  <div className="plot-lazy-placeholder">Carregando gráfico...</div>
150
  )}
 
152
  )
153
  }
154
 
155
+ export default PlotFigure
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -1,5 +1,6 @@
1
  import React, { useEffect, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
 
3
  import DataTable from './DataTable'
4
  import EquationFormatsPanel from './EquationFormatsPanel'
5
  import ListPagination from './ListPagination'
@@ -7,6 +8,7 @@ import LoadingOverlay from './LoadingOverlay'
7
  import MapFrame from './MapFrame'
8
  import ModeloTrabalhosTecnicosPanel from './ModeloTrabalhosTecnicosPanel'
9
  import PlotFigure from './PlotFigure'
 
10
  import TruncatedCellContent from './TruncatedCellContent'
11
  import { getFaixaDataRecencyInfo } from '../modelRecency'
12
 
@@ -24,6 +26,54 @@ const REPO_INNER_TABS = [
24
  { key: 'graficos', label: 'Gráficos' },
25
  ]
26
  const MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO = 'selecionados_e_outras_versoes'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  function formatarFonte(fonte) {
29
  if (!fonte || typeof fonte !== 'object') return 'Fonte não informada'
@@ -37,7 +87,60 @@ function formatarFonte(fonte) {
37
  return 'Pasta local'
38
  }
39
 
40
- export default function RepositorioTab({ authUser, sessionId, openModeloRequest = null }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  const [modelos, setModelos] = useState([])
42
  const [fonte, setFonte] = useState(null)
43
  const [loading, setLoading] = useState(false)
@@ -57,6 +160,7 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
57
  })
58
 
59
  const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
 
60
  const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
61
  const [modeloAbertoError, setModeloAbertoError] = useState('')
62
  const [modeloAbertoActiveTab, setModeloAbertoActiveTab] = useState('mapa')
@@ -78,7 +182,11 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
78
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
79
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
80
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
 
 
81
  const lastOpenRequestKeyRef = useRef('')
 
 
82
 
83
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
84
  const totalModelos = modelos.length
@@ -92,6 +200,12 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
92
  void carregarModelos()
93
  }, [])
94
 
 
 
 
 
 
 
95
  useEffect(() => {
96
  const totalPages = Math.max(1, Math.ceil(totalModelos / PAGE_SIZE))
97
  setListaPage((prev) => Math.min(Math.max(1, prev), totalPages))
@@ -103,20 +217,30 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
103
  if (!sessionId || !requestKey || !modeloId) return
104
  if (lastOpenRequestKeyRef.current === requestKey) return
105
  lastOpenRequestKeyRef.current = requestKey
106
- setModeloAbertoMeta({
107
- id: modeloId,
108
- nome: openModeloRequest?.nomeModelo || modeloId,
109
- observacao: '',
110
- })
111
- setModeloAbertoError('')
112
- setModeloAbertoActiveTab('mapa')
113
  void onAbrirModelo({
114
  id: modeloId,
115
  nome_modelo: openModeloRequest?.nomeModelo || modeloId,
116
  arquivo: openModeloRequest?.modeloArquivo || '',
 
 
 
117
  })
118
  }, [openModeloRequest, sessionId])
119
 
 
 
 
 
 
 
 
 
 
 
120
  async function carregarModelos() {
121
  setLoading(true)
122
  setError('')
@@ -213,55 +337,208 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
213
  await onExcluirModelo(modeloId)
214
  }
215
 
216
- function preencherModeloAberto(resp) {
217
- setModeloAbertoDados(resp?.dados || null)
218
- setModeloAbertoEstatisticas(resp?.estatisticas || null)
219
- setModeloAbertoEscalasHtml(resp?.escalas_html || '')
220
- setModeloAbertoDadosTransformados(resp?.dados_transformados || null)
221
- setModeloAbertoResumoHtml(resp?.resumo_html || '')
222
- setModeloAbertoEquacoes(resp?.equacoes || null)
223
- setModeloAbertoCoeficientes(resp?.coeficientes || null)
224
- setModeloAbertoObsCalc(resp?.obs_calc || null)
225
- setModeloAbertoMapaHtml(resp?.mapa_html || '')
226
- setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
 
227
  setModeloAbertoMapaVar('Visualização Padrão')
228
- setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
229
- setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
230
- setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
231
- setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
232
- setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
233
- setModeloAbertoPlotCook(resp?.grafico_cook || null)
234
- setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  }
236
 
237
- async function onAbrirModelo(item) {
 
 
 
 
 
 
 
 
 
 
 
238
  if (!sessionId) {
239
  setError('Sessão indisponível no momento. Aguarde e tente novamente.')
240
  return
241
  }
 
 
 
 
 
 
 
 
 
 
 
242
  setModeloAbertoLoading(true)
243
  setModeloAbertoError('')
 
 
 
 
 
244
  try {
245
- await api.visualizacaoRepositorioCarregar(sessionId, String(item?.id || ''))
246
- const resp = await api.exibirVisualizacao(sessionId, MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
247
- preencherModeloAberto(resp)
248
- setModeloAbertoActiveTab('mapa')
249
- setModeloAbertoMeta({
250
- id: String(item?.id || ''),
251
- nome: item?.nome_modelo || item?.arquivo || String(item?.id || ''),
252
- observacao: String(resp?.meta_modelo?.observacao_modelo || '').trim(),
 
 
253
  })
254
  } catch (err) {
 
255
  setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
256
  } finally {
257
- setModeloAbertoLoading(false)
 
 
258
  }
259
  }
260
 
261
  function onVoltarRepositorio() {
 
 
 
 
 
 
 
 
 
 
 
 
262
  setModeloAbertoMeta(null)
 
263
  setModeloAbertoError('')
264
  setModeloAbertoActiveTab('mapa')
 
 
 
 
 
265
  }
266
 
267
  async function atualizarMapaModeloAberto(
@@ -269,10 +546,16 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
269
  nextTrabalhosTecnicosModo = modeloAbertoTrabalhosTecnicosModelosModo,
270
  ) {
271
  if (!sessionId) return
272
- const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
273
- setModeloAbertoMapaHtml(resp?.mapa_html || '')
274
- setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
275
- setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
 
 
 
 
 
 
276
  }
277
 
278
  async function onModeloAbertoMapChange(nextVar) {
@@ -307,7 +590,28 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
307
  }
308
  }
309
 
 
 
 
 
 
 
310
  if (modoModeloAberto) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  return (
312
  <div className="tab-content">
313
  <div className="pesquisa-opened-model-view">
@@ -316,9 +620,32 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
316
  <h3>{modeloAbertoMeta?.nome || 'Modelo'}</h3>
317
  {modeloAbertoMeta?.observacao ? <p>{modeloAbertoMeta.observacao}</p> : null}
318
  </div>
319
- <button type="button" className="model-source-back-btn model-source-back-btn-danger" onClick={onVoltarRepositorio} disabled={modeloAbertoLoading}>
320
- Voltar ao repositório
321
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  </div>
323
 
324
  <div className="inner-tabs" role="tablist" aria-label="Abas internas do modelo aberto no repositório">
@@ -327,7 +654,8 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
327
  key={tab.key}
328
  type="button"
329
  className={modeloAbertoActiveTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
330
- onClick={() => setModeloAbertoActiveTab(tab.key)}
 
331
  >
332
  {tab.label}
333
  </button>
@@ -336,84 +664,110 @@ export default function RepositorioTab({ authUser, sessionId, openModeloRequest
336
 
337
  <div className="inner-tab-panel">
338
  {modeloAbertoActiveTab === 'mapa' ? (
339
- <>
340
- <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
341
- <label className="pesquisa-field pesquisa-mapa-modo-field">
342
- Variável no mapa
343
- <select value={modeloAbertoMapaVar} onChange={(event) => void onModeloAbertoMapChange(event.target.value)}>
344
- {modeloAbertoMapaChoices.map((choice) => (
345
- <option key={`repo-modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
346
- ))}
347
- </select>
348
- </label>
349
- <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
350
- Exibição dos trabalhos técnicos
351
- <select
352
- value={modeloAbertoTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
353
- ? MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO
354
- : modeloAbertoTrabalhosTecnicosModelosModo}
355
- onChange={(event) => void onModeloAbertoTrabalhosTecnicosModeChange(event.target.value)}
356
- autoComplete="off"
357
- >
358
- <option value="selecionados">Somente deste modelo</option>
359
- <option value="selecionados_e_outras_versoes">Incluir demais versões do modelo</option>
360
- </select>
361
- </label>
362
- </div>
363
- <MapFrame html={modeloAbertoMapaHtml} />
364
- </>
 
 
 
 
365
  ) : null}
366
 
367
  {modeloAbertoActiveTab === 'trabalhos_tecnicos' ? (
368
- <ModeloTrabalhosTecnicosPanel trabalhos={modeloAbertoTrabalhosTecnicos} />
 
 
369
  ) : null}
370
 
371
- {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
372
- {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
 
 
 
 
373
 
374
  {modeloAbertoActiveTab === 'transformacoes' ? (
375
- <>
376
- <div dangerouslySetInnerHTML={{ __html: modeloAbertoEscalasHtml }} />
377
- <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
378
- <DataTable table={modeloAbertoDadosTransformados} />
379
- </>
 
 
 
 
380
  ) : null}
381
 
382
  {modeloAbertoActiveTab === 'resumo' ? (
383
- <>
384
- <div className="equation-formats-section">
385
- <h4>Equações do Modelo</h4>
386
- <EquationFormatsPanel
387
- equacoes={modeloAbertoEquacoes}
388
- onDownload={(mode) => void onDownloadEquacaoModeloAberto(mode)}
389
- disabled={modeloAbertoLoading}
390
- />
391
- </div>
392
- <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
393
- </>
 
 
 
 
394
  ) : null}
395
 
396
- {modeloAbertoActiveTab === 'coeficientes' ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : null}
397
- {modeloAbertoActiveTab === 'obs_calc' ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : null}
 
 
 
 
398
 
399
  {modeloAbertoActiveTab === 'graficos' ? (
400
- <>
401
- <div className="plot-grid-2-fixed">
402
- <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
403
- <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
404
- <PlotFigure figure={modeloAbertoPlotHistograma} title="Histograma" />
405
- <PlotFigure figure={modeloAbertoPlotCook} title="Cook" forceHideLegend />
406
- </div>
407
- <div className="plot-full-width">
408
- <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
409
- </div>
410
- </>
 
 
 
 
411
  ) : null}
412
  </div>
413
 
414
  {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
415
  </div>
416
- <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
417
  </div>
418
  )
419
  }
 
1
  import React, { useEffect, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
+ import { buildRepositorioModeloLink, hasMesaDeepLink, normalizeMesaDeepLink, parseMesaDeepLink } from '../deepLinks'
4
  import DataTable from './DataTable'
5
  import EquationFormatsPanel from './EquationFormatsPanel'
6
  import ListPagination from './ListPagination'
 
8
  import MapFrame from './MapFrame'
9
  import ModeloTrabalhosTecnicosPanel from './ModeloTrabalhosTecnicosPanel'
10
  import PlotFigure from './PlotFigure'
11
+ import ShareLinkButton from './ShareLinkButton'
12
  import TruncatedCellContent from './TruncatedCellContent'
13
  import { getFaixaDataRecencyInfo } from '../modelRecency'
14
 
 
26
  { key: 'graficos', label: 'Gráficos' },
27
  ]
28
  const MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO = 'selecionados_e_outras_versoes'
29
+ const REPO_MODEL_TAB_KEY_BY_SLUG = {
30
+ mapa: 'mapa',
31
+ 'trabalhos-tecnicos': 'trabalhos_tecnicos',
32
+ 'dados-mercado': 'dados_mercado',
33
+ metricas: 'metricas',
34
+ transformacoes: 'transformacoes',
35
+ resumo: 'resumo',
36
+ coeficientes: 'coeficientes',
37
+ 'obs-calc': 'obs_calc',
38
+ graficos: 'graficos',
39
+ }
40
+ const REPO_MODEL_TAB_SLUG_BY_KEY = Object.fromEntries(
41
+ Object.entries(REPO_MODEL_TAB_KEY_BY_SLUG).map(([slug, key]) => [key, slug]),
42
+ )
43
+
44
+ function getRepoModelTabKeyFromSlug(slug) {
45
+ return REPO_MODEL_TAB_KEY_BY_SLUG[String(slug || '').trim()] || 'mapa'
46
+ }
47
+
48
+ function getRepoModelTabSlugFromKey(key) {
49
+ return REPO_MODEL_TAB_SLUG_BY_KEY[String(key || '').trim()] || 'mapa'
50
+ }
51
+
52
+ function scrollParaTopoDaPagina() {
53
+ if (typeof window === 'undefined') return
54
+ window.requestAnimationFrame(() => {
55
+ window.requestAnimationFrame(() => {
56
+ window.scrollTo({ top: 0, behavior: 'smooth' })
57
+ })
58
+ })
59
+ }
60
+
61
+ function buildReturnIntent(value) {
62
+ const normalized = normalizeMesaDeepLink(value)
63
+ if (!value || typeof value !== 'object') return normalized
64
+ return {
65
+ ...normalized,
66
+ pesquisaExecutada: Boolean(value.pesquisaExecutada),
67
+ avaliandos: Array.isArray(value.avaliandos) ? value.avaliandos : null,
68
+ scrollToMapa: Boolean(value.scrollToMapa),
69
+ }
70
+ }
71
+
72
+ function resolveRepoModelTabKey(value) {
73
+ const text = String(value || '').trim()
74
+ if (REPO_MODEL_TAB_SLUG_BY_KEY[text]) return text
75
+ return getRepoModelTabKeyFromSlug(text)
76
+ }
77
 
78
  function formatarFonte(fonte) {
79
  if (!fonte || typeof fonte !== 'object') return 'Fonte não informada'
 
87
  return 'Pasta local'
88
  }
89
 
90
+ function getReturnButtonLabel(intent) {
91
+ if (!intent || typeof intent !== 'object') {
92
+ return 'Voltar ao repositório'
93
+ }
94
+ const normalized = normalizeMesaDeepLink(intent)
95
+ if (normalized.tab === 'modelos' && normalized.subtab === 'pesquisa') {
96
+ return 'Voltar à pesquisa'
97
+ }
98
+ if (normalized.tab === 'trabalhos') {
99
+ return normalized.trabalhoId ? 'Voltar ao trabalho técnico' : 'Voltar aos trabalhos técnicos'
100
+ }
101
+ if (normalized.tab === 'avaliacao') {
102
+ return 'Voltar à avaliação'
103
+ }
104
+ if (normalized.tab === 'elaboracao') {
105
+ return 'Voltar à elaboração'
106
+ }
107
+ return 'Voltar ao repositório'
108
+ }
109
+
110
+ function getModeloAbertoLoadingLabel(tabKey) {
111
+ switch (resolveRepoModelTabKey(tabKey)) {
112
+ case 'mapa':
113
+ return 'Carregando mapa do modelo...'
114
+ case 'trabalhos_tecnicos':
115
+ return 'Carregando trabalhos técnicos do modelo...'
116
+ case 'dados_mercado':
117
+ return 'Carregando dados de mercado...'
118
+ case 'metricas':
119
+ return 'Carregando métricas do modelo...'
120
+ case 'transformacoes':
121
+ return 'Carregando transformações do modelo...'
122
+ case 'resumo':
123
+ return 'Carregando resumo do modelo...'
124
+ case 'coeficientes':
125
+ return 'Carregando coeficientes do modelo...'
126
+ case 'obs_calc':
127
+ return 'Carregando observados x calculados...'
128
+ case 'graficos':
129
+ return 'Carregando gráficos do modelo...'
130
+ default:
131
+ return 'Carregando modelo...'
132
+ }
133
+ }
134
+
135
+ export default function RepositorioTab({
136
+ authUser,
137
+ sessionId,
138
+ openModeloRequest = null,
139
+ onUsarModeloEmAvaliacao = null,
140
+ onEditarModeloEmElaboracao = null,
141
+ onRouteChange = null,
142
+ onModeloAbertoChange = null,
143
+ }) {
144
  const [modelos, setModelos] = useState([])
145
  const [fonte, setFonte] = useState(null)
146
  const [loading, setLoading] = useState(false)
 
160
  })
161
 
162
  const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
163
+ const [modeloAbertoReturnIntent, setModeloAbertoReturnIntent] = useState(null)
164
  const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
165
  const [modeloAbertoError, setModeloAbertoError] = useState('')
166
  const [modeloAbertoActiveTab, setModeloAbertoActiveTab] = useState('mapa')
 
182
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
183
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
184
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
185
+ const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
186
+ const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
187
  const lastOpenRequestKeyRef = useRef('')
188
+ const pendingTabRequestsRef = useRef({})
189
+ const modeloOpenVersionRef = useRef(0)
190
 
191
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
192
  const totalModelos = modelos.length
 
200
  void carregarModelos()
201
  }, [])
202
 
203
+ useEffect(() => {
204
+ if (typeof onModeloAbertoChange !== 'function') return undefined
205
+ onModeloAbertoChange(modoModeloAberto)
206
+ return () => onModeloAbertoChange(false)
207
+ }, [modoModeloAberto, onModeloAbertoChange])
208
+
209
  useEffect(() => {
210
  const totalPages = Math.max(1, Math.ceil(totalModelos / PAGE_SIZE))
211
  setListaPage((prev) => Math.min(Math.max(1, prev), totalPages))
 
217
  if (!sessionId || !requestKey || !modeloId) return
218
  if (lastOpenRequestKeyRef.current === requestKey) return
219
  lastOpenRequestKeyRef.current = requestKey
220
+ const nextModelTab = resolveRepoModelTabKey(openModeloRequest?.modelTab)
221
+ const nextReturnIntent = buildReturnIntent(
222
+ openModeloRequest?.returnIntent || { tab: 'modelos', subtab: 'repositorio' },
223
+ )
 
 
 
224
  void onAbrirModelo({
225
  id: modeloId,
226
  nome_modelo: openModeloRequest?.nomeModelo || modeloId,
227
  arquivo: openModeloRequest?.modeloArquivo || '',
228
+ }, {
229
+ initialModelTab: nextModelTab,
230
+ returnIntent: nextReturnIntent,
231
  })
232
  }, [openModeloRequest, sessionId])
233
 
234
+ function resolveCurrentReturnIntent() {
235
+ if (typeof window !== 'undefined') {
236
+ const parsed = parseMesaDeepLink(window.location.search)
237
+ if (hasMesaDeepLink(parsed)) {
238
+ return normalizeMesaDeepLink(parsed)
239
+ }
240
+ }
241
+ return normalizeMesaDeepLink({ tab: 'modelos', subtab: 'repositorio' })
242
+ }
243
+
244
  async function carregarModelos() {
245
  setLoading(true)
246
  setError('')
 
337
  await onExcluirModelo(modeloId)
338
  }
339
 
340
+ function limparConteudoModeloAberto() {
341
+ pendingTabRequestsRef.current = {}
342
+ setModeloAbertoDados(null)
343
+ setModeloAbertoEstatisticas(null)
344
+ setModeloAbertoEscalasHtml('')
345
+ setModeloAbertoDadosTransformados(null)
346
+ setModeloAbertoResumoHtml('')
347
+ setModeloAbertoEquacoes(null)
348
+ setModeloAbertoCoeficientes(null)
349
+ setModeloAbertoObsCalc(null)
350
+ setModeloAbertoMapaHtml('')
351
+ setModeloAbertoMapaChoices(['Visualização Padrão'])
352
  setModeloAbertoMapaVar('Visualização Padrão')
353
+ setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
354
+ setModeloAbertoTrabalhosTecnicos([])
355
+ setModeloAbertoPlotObsCalc(null)
356
+ setModeloAbertoPlotResiduos(null)
357
+ setModeloAbertoPlotHistograma(null)
358
+ setModeloAbertoPlotCook(null)
359
+ setModeloAbertoPlotCorr(null)
360
+ setModeloAbertoLoadedTabs({})
361
+ setModeloAbertoLoadingTabs({})
362
+ }
363
+
364
+ function aplicarSecaoModeloAberto(secao, resp) {
365
+ const secaoNormalizada = resolveRepoModelTabKey(secao)
366
+ if (secaoNormalizada === 'dados_mercado') {
367
+ setModeloAbertoDados(resp?.dados || null)
368
+ return
369
+ }
370
+ if (secaoNormalizada === 'metricas') {
371
+ setModeloAbertoEstatisticas(resp?.estatisticas || null)
372
+ return
373
+ }
374
+ if (secaoNormalizada === 'transformacoes') {
375
+ setModeloAbertoEscalasHtml(resp?.escalas_html || '')
376
+ setModeloAbertoDadosTransformados(resp?.dados_transformados || null)
377
+ return
378
+ }
379
+ if (secaoNormalizada === 'resumo') {
380
+ setModeloAbertoResumoHtml(resp?.resumo_html || '')
381
+ setModeloAbertoEquacoes(resp?.equacoes || null)
382
+ return
383
+ }
384
+ if (secaoNormalizada === 'coeficientes') {
385
+ setModeloAbertoCoeficientes(resp?.coeficientes || null)
386
+ return
387
+ }
388
+ if (secaoNormalizada === 'obs_calc') {
389
+ setModeloAbertoObsCalc(resp?.obs_calc || null)
390
+ return
391
+ }
392
+ if (secaoNormalizada === 'graficos') {
393
+ setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
394
+ setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
395
+ setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
396
+ setModeloAbertoPlotCook(resp?.grafico_cook || null)
397
+ setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
398
+ return
399
+ }
400
+ if (secaoNormalizada === 'trabalhos_tecnicos') {
401
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
402
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
403
+ return
404
+ }
405
+ if (secaoNormalizada === 'mapa') {
406
+ const nextChoices = Array.isArray(resp?.mapa_choices) && resp.mapa_choices.length
407
+ ? resp.mapa_choices
408
+ : ['Visualização Padrão']
409
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
410
+ setModeloAbertoMapaChoices(nextChoices)
411
+ setModeloAbertoMapaVar((current) => (nextChoices.includes(current) ? current : 'Visualização Padrão'))
412
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
413
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
414
+ }
415
+ }
416
+
417
+ async function garantirSecaoModeloAberto(secao, options = {}) {
418
+ const secaoNormalizada = resolveRepoModelTabKey(secao)
419
+ const modeloId = String(options.modeloId || modeloAbertoMeta?.id || '').trim()
420
+ if (!sessionId || !modeloId) return
421
+ if (!options.force && modeloAbertoLoadedTabs[secaoNormalizada]) return
422
+ if (pendingTabRequestsRef.current[secaoNormalizada]) {
423
+ await pendingTabRequestsRef.current[secaoNormalizada]
424
+ return
425
+ }
426
+
427
+ const expectedVersion = options.expectedVersion ?? modeloOpenVersionRef.current
428
+ const trabalhosTecnicosModo = options.trabalhosTecnicosModelosModo || modeloAbertoTrabalhosTecnicosModelosModo
429
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: true }))
430
+
431
+ const request = (async () => {
432
+ try {
433
+ const resp = await api.visualizacaoSection(
434
+ sessionId,
435
+ getRepoModelTabSlugFromKey(secaoNormalizada),
436
+ trabalhosTecnicosModo,
437
+ )
438
+ if (modeloOpenVersionRef.current !== expectedVersion) return
439
+ aplicarSecaoModeloAberto(secaoNormalizada, resp)
440
+ setModeloAbertoLoadedTabs((prev) => ({
441
+ ...prev,
442
+ [secaoNormalizada]: true,
443
+ ...(secaoNormalizada === 'mapa' ? { trabalhos_tecnicos: true } : {}),
444
+ }))
445
+ } catch (err) {
446
+ if (modeloOpenVersionRef.current !== expectedVersion) return
447
+ setModeloAbertoError(err.message || 'Falha ao carregar dados do modelo.')
448
+ } finally {
449
+ if (modeloOpenVersionRef.current !== expectedVersion) return
450
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: false }))
451
+ }
452
+ })()
453
+
454
+ pendingTabRequestsRef.current[secaoNormalizada] = request
455
+ try {
456
+ await request
457
+ } finally {
458
+ if (pendingTabRequestsRef.current[secaoNormalizada] === request) {
459
+ delete pendingTabRequestsRef.current[secaoNormalizada]
460
+ }
461
+ }
462
  }
463
 
464
+ function emitOpenedModelRoute(meta = modeloAbertoMeta, activeTab = modeloAbertoActiveTab) {
465
+ const modeloId = String(meta?.id || '').trim()
466
+ if (!modeloId || typeof onRouteChange !== 'function') return
467
+ onRouteChange({
468
+ tab: 'modelos',
469
+ subtab: 'repositorio',
470
+ modeloId,
471
+ modelTab: getRepoModelTabSlugFromKey(activeTab),
472
+ })
473
+ }
474
+
475
+ async function onAbrirModelo(item, options = {}) {
476
  if (!sessionId) {
477
  setError('Sessão indisponível no momento. Aguarde e tente novamente.')
478
  return
479
  }
480
+ const nextModelTab = resolveRepoModelTabKey(options?.initialModelTab)
481
+ const nextReturnIntent = buildReturnIntent(options?.returnIntent || resolveCurrentReturnIntent())
482
+ const nextMeta = {
483
+ id: String(item?.id || ''),
484
+ nome: item?.nome_modelo || item?.arquivo || String(item?.id || ''),
485
+ arquivo: String(item?.arquivo || '').trim(),
486
+ observacao: '',
487
+ }
488
+ modeloOpenVersionRef.current += 1
489
+ const openVersion = modeloOpenVersionRef.current
490
+ limparConteudoModeloAberto()
491
  setModeloAbertoLoading(true)
492
  setModeloAbertoError('')
493
+ setModeloAbertoMeta(nextMeta)
494
+ setModeloAbertoActiveTab(nextModelTab)
495
+ setModeloAbertoReturnIntent(nextReturnIntent)
496
+ scrollParaTopoDaPagina()
497
+ emitOpenedModelRoute(nextMeta, nextModelTab)
498
  try {
499
+ const resp = await api.visualizacaoRepositorioCarregar(sessionId, String(item?.id || ''))
500
+ if (modeloOpenVersionRef.current !== openVersion) return
501
+ setModeloAbertoMeta((prev) => (
502
+ prev ? { ...prev, observacao: String(resp?.observacao_modelo || '').trim() } : prev
503
+ ))
504
+ await garantirSecaoModeloAberto(nextModelTab, {
505
+ force: true,
506
+ expectedVersion: openVersion,
507
+ modeloId: nextMeta.id,
508
+ trabalhosTecnicosModelosModo: MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO,
509
  })
510
  } catch (err) {
511
+ if (modeloOpenVersionRef.current !== openVersion) return
512
  setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
513
  } finally {
514
+ if (modeloOpenVersionRef.current === openVersion) {
515
+ setModeloAbertoLoading(false)
516
+ }
517
  }
518
  }
519
 
520
  function onVoltarRepositorio() {
521
+ const nextIntent = hasMesaDeepLink(modeloAbertoReturnIntent)
522
+ ? {
523
+ ...modeloAbertoReturnIntent,
524
+ forceRouteRequest: true,
525
+ }
526
+ : {
527
+ tab: 'modelos',
528
+ subtab: 'repositorio',
529
+ forceRouteRequest: true,
530
+ }
531
+ modeloOpenVersionRef.current += 1
532
+ pendingTabRequestsRef.current = {}
533
  setModeloAbertoMeta(null)
534
+ setModeloAbertoReturnIntent(null)
535
  setModeloAbertoError('')
536
  setModeloAbertoActiveTab('mapa')
537
+ setModeloAbertoLoading(false)
538
+ limparConteudoModeloAberto()
539
+ if (typeof onRouteChange === 'function') {
540
+ onRouteChange(nextIntent)
541
+ }
542
  }
543
 
544
  async function atualizarMapaModeloAberto(
 
546
  nextTrabalhosTecnicosModo = modeloAbertoTrabalhosTecnicosModelosModo,
547
  ) {
548
  if (!sessionId) return
549
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, mapa: true }))
550
+ try {
551
+ const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
552
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
553
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
554
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
555
+ setModeloAbertoLoadedTabs((prev) => ({ ...prev, mapa: true, trabalhos_tecnicos: true }))
556
+ } finally {
557
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, mapa: false }))
558
+ }
559
  }
560
 
561
  async function onModeloAbertoMapChange(nextVar) {
 
590
  }
591
  }
592
 
593
+ function onModeloAbertoTabSelect(nextTab) {
594
+ setModeloAbertoActiveTab(nextTab)
595
+ emitOpenedModelRoute(modeloAbertoMeta, nextTab)
596
+ void garantirSecaoModeloAberto(nextTab)
597
+ }
598
+
599
  if (modoModeloAberto) {
600
+ const shareHref = buildRepositorioModeloLink(
601
+ modeloAbertoMeta?.id || '',
602
+ getRepoModelTabSlugFromKey(modeloAbertoActiveTab),
603
+ )
604
+ const modeloAcao = {
605
+ id: String(modeloAbertoMeta?.id || '').trim(),
606
+ nome_modelo: String(modeloAbertoMeta?.nome || '').trim(),
607
+ arquivo: String(modeloAbertoMeta?.arquivo || '').trim(),
608
+ }
609
+ const returnButtonLabel = getReturnButtonLabel(modeloAbertoReturnIntent)
610
+ const activeTabLoading = Boolean(modeloAbertoLoadingTabs[modeloAbertoActiveTab])
611
+ const activeTabLoaded = Boolean(modeloAbertoLoadedTabs[modeloAbertoActiveTab])
612
+ const modeloLoadingLabel = modeloAbertoLoading
613
+ ? 'Abrindo modelo...'
614
+ : getModeloAbertoLoadingLabel(modeloAbertoActiveTab)
615
  return (
616
  <div className="tab-content">
617
  <div className="pesquisa-opened-model-view">
 
620
  <h3>{modeloAbertoMeta?.nome || 'Modelo'}</h3>
621
  {modeloAbertoMeta?.observacao ? <p>{modeloAbertoMeta.observacao}</p> : null}
622
  </div>
623
+ <div className="pesquisa-opened-model-actions">
624
+ <ShareLinkButton href={shareHref} />
625
+ {typeof onUsarModeloEmAvaliacao === 'function' ? (
626
+ <button
627
+ type="button"
628
+ className="pesquisa-opened-model-action-btn pesquisa-opened-model-action-btn-secondary"
629
+ onClick={() => onUsarModeloEmAvaliacao(modeloAcao)}
630
+ disabled={modeloAbertoLoading}
631
+ >
632
+ Usar na avaliação
633
+ </button>
634
+ ) : null}
635
+ {typeof onEditarModeloEmElaboracao === 'function' ? (
636
+ <button
637
+ type="button"
638
+ className="pesquisa-opened-model-action-btn pesquisa-opened-model-action-btn-secondary"
639
+ onClick={() => onEditarModeloEmElaboracao(modeloAcao)}
640
+ disabled={modeloAbertoLoading}
641
+ >
642
+ Editar na elaboração
643
+ </button>
644
+ ) : null}
645
+ <button type="button" className="model-source-back-btn model-source-back-btn-danger" onClick={onVoltarRepositorio} disabled={modeloAbertoLoading}>
646
+ {returnButtonLabel}
647
+ </button>
648
+ </div>
649
  </div>
650
 
651
  <div className="inner-tabs" role="tablist" aria-label="Abas internas do modelo aberto no repositório">
 
654
  key={tab.key}
655
  type="button"
656
  className={modeloAbertoActiveTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
657
+ onClick={() => onModeloAbertoTabSelect(tab.key)}
658
+ disabled={modeloAbertoLoading}
659
  >
660
  {tab.label}
661
  </button>
 
664
 
665
  <div className="inner-tab-panel">
666
  {modeloAbertoActiveTab === 'mapa' ? (
667
+ !activeTabLoaded ? (
668
+ <div className="empty-box">Carregando mapa do modelo...</div>
669
+ ) : (
670
+ <>
671
+ <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
672
+ <label className="pesquisa-field pesquisa-mapa-modo-field">
673
+ Variável no mapa
674
+ <select value={modeloAbertoMapaVar} onChange={(event) => void onModeloAbertoMapChange(event.target.value)}>
675
+ {modeloAbertoMapaChoices.map((choice) => (
676
+ <option key={`repo-modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
677
+ ))}
678
+ </select>
679
+ </label>
680
+ <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
681
+ Exibição dos trabalhos técnicos
682
+ <select
683
+ value={modeloAbertoTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
684
+ ? MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO
685
+ : modeloAbertoTrabalhosTecnicosModelosModo}
686
+ onChange={(event) => void onModeloAbertoTrabalhosTecnicosModeChange(event.target.value)}
687
+ autoComplete="off"
688
+ >
689
+ <option value="selecionados">Somente deste modelo</option>
690
+ <option value="selecionados_e_outras_versoes">Incluir demais versões do modelo</option>
691
+ </select>
692
+ </label>
693
+ </div>
694
+ <MapFrame html={modeloAbertoMapaHtml} />
695
+ </>
696
+ )
697
  ) : null}
698
 
699
  {modeloAbertoActiveTab === 'trabalhos_tecnicos' ? (
700
+ activeTabLoaded
701
+ ? <ModeloTrabalhosTecnicosPanel trabalhos={modeloAbertoTrabalhosTecnicos} />
702
+ : <div className="empty-box">Carregando trabalhos técnicos do modelo...</div>
703
  ) : null}
704
 
705
+ {modeloAbertoActiveTab === 'dados_mercado'
706
+ ? (activeTabLoaded ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>)
707
+ : null}
708
+ {modeloAbertoActiveTab === 'metricas'
709
+ ? (activeTabLoaded ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : <div className="empty-box">Carregando métricas do modelo...</div>)
710
+ : null}
711
 
712
  {modeloAbertoActiveTab === 'transformacoes' ? (
713
+ activeTabLoaded ? (
714
+ <>
715
+ <div dangerouslySetInnerHTML={{ __html: modeloAbertoEscalasHtml }} />
716
+ <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
717
+ <DataTable table={modeloAbertoDadosTransformados} />
718
+ </>
719
+ ) : (
720
+ <div className="empty-box">Carregando transformações do modelo...</div>
721
+ )
722
  ) : null}
723
 
724
  {modeloAbertoActiveTab === 'resumo' ? (
725
+ activeTabLoaded ? (
726
+ <>
727
+ <div className="equation-formats-section">
728
+ <h4>Equações do Modelo</h4>
729
+ <EquationFormatsPanel
730
+ equacoes={modeloAbertoEquacoes}
731
+ onDownload={(mode) => void onDownloadEquacaoModeloAberto(mode)}
732
+ disabled={modeloAbertoLoading}
733
+ />
734
+ </div>
735
+ <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
736
+ </>
737
+ ) : (
738
+ <div className="empty-box">Carregando resumo do modelo...</div>
739
+ )
740
  ) : null}
741
 
742
+ {modeloAbertoActiveTab === 'coeficientes'
743
+ ? (activeTabLoaded ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : <div className="empty-box">Carregando coeficientes do modelo...</div>)
744
+ : null}
745
+ {modeloAbertoActiveTab === 'obs_calc'
746
+ ? (activeTabLoaded ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>)
747
+ : null}
748
 
749
  {modeloAbertoActiveTab === 'graficos' ? (
750
+ activeTabLoaded ? (
751
+ <>
752
+ <div className="plot-grid-2-fixed">
753
+ <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
754
+ <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
755
+ <PlotFigure figure={modeloAbertoPlotHistograma} title="Histograma" />
756
+ <PlotFigure figure={modeloAbertoPlotCook} title="Cook" forceHideLegend />
757
+ </div>
758
+ <div className="plot-full-width">
759
+ <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
760
+ </div>
761
+ </>
762
+ ) : (
763
+ <div className="empty-box">Carregando gráficos do modelo...</div>
764
+ )
765
  ) : null}
766
  </div>
767
 
768
  {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
769
  </div>
770
+ <LoadingOverlay show={modeloAbertoLoading || activeTabLoading} label={modeloLoadingLabel} />
771
  </div>
772
  )
773
  }
frontend/src/components/ShareLinkButton.jsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+
3
+ function fallbackCopyText(text) {
4
+ const textarea = document.createElement('textarea')
5
+ textarea.value = text
6
+ textarea.setAttribute('readonly', 'true')
7
+ textarea.style.position = 'fixed'
8
+ textarea.style.opacity = '0'
9
+ document.body.appendChild(textarea)
10
+ textarea.select()
11
+ document.execCommand('copy')
12
+ document.body.removeChild(textarea)
13
+ }
14
+
15
+ export default function ShareLinkButton({
16
+ href = '',
17
+ label = 'Copiar link',
18
+ copiedLabel = 'Link copiado',
19
+ className = 'model-source-back-btn',
20
+ disabled = false,
21
+ title = '',
22
+ }) {
23
+ const [copied, setCopied] = useState(false)
24
+
25
+ useEffect(() => {
26
+ if (!copied) return undefined
27
+ const timeoutId = window.setTimeout(() => setCopied(false), 1800)
28
+ return () => window.clearTimeout(timeoutId)
29
+ }, [copied])
30
+
31
+ async function onCopyClick() {
32
+ if (disabled || !href) return
33
+ try {
34
+ if (navigator.clipboard?.writeText) {
35
+ await navigator.clipboard.writeText(href)
36
+ } else {
37
+ fallbackCopyText(href)
38
+ }
39
+ setCopied(true)
40
+ } catch {
41
+ fallbackCopyText(href)
42
+ setCopied(true)
43
+ }
44
+ }
45
+
46
+ return (
47
+ <button
48
+ type="button"
49
+ className={className}
50
+ onClick={() => void onCopyClick()}
51
+ disabled={disabled || !href}
52
+ title={title || href || 'Copiar link'}
53
+ >
54
+ {copied ? copiedLabel : label}
55
+ </button>
56
+ )
57
+ }
frontend/src/components/TrabalhosTecnicosTab.jsx CHANGED
@@ -1,8 +1,10 @@
1
  import React, { useEffect, useRef, useState } from 'react'
2
  import { api } from '../api'
 
3
  import ListPagination from './ListPagination'
4
  import LoadingOverlay from './LoadingOverlay'
5
  import MapFrame from './MapFrame'
 
6
  import TruncatedCellContent from './TruncatedCellContent'
7
 
8
  const PAGE_SIZE = 50
@@ -150,7 +152,10 @@ export default function TrabalhosTecnicosTab({
150
  sessionId,
151
  onAbrirModeloNoRepositorio = null,
152
  quickOpenRequest = null,
 
 
153
  onVoltarAoMapaPesquisa = null,
 
154
  }) {
155
  const [activeInnerTab, setActiveInnerTab] = useState('repositorio')
156
  const [trabalhos, setTrabalhos] = useState([])
@@ -183,7 +188,9 @@ export default function TrabalhosTecnicosTab({
183
  const [modeloSugestoesMesa, setModeloSugestoesMesa] = useState([])
184
  const [modeloSugestoesLoading, setModeloSugestoesLoading] = useState(false)
185
  const [origemAbertura, setOrigemAbertura] = useState('lista')
 
186
  const quickOpenHandledRef = useRef('')
 
187
  const mapaRequestKeyRef = useRef('')
188
  const mapaUltimaSelecaoRef = useRef({ ids: [], avaliando: null })
189
  const mapaAreaRef = useRef(null)
@@ -210,6 +217,34 @@ export default function TrabalhosTecnicosTab({
210
  void abrirTrabalhoPorId(trabalhoId, { origem })
211
  }, [quickOpenRequest])
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  async function carregarTrabalhos() {
214
  setLoading(true)
215
  setError('')
@@ -376,6 +411,7 @@ export default function TrabalhosTecnicosTab({
376
  async function abrirTrabalhoPorId(trabalhoId, options = {}) {
377
  const chave = String(trabalhoId || '').trim()
378
  if (!chave) return
 
379
  setTrabalhoLoading(true)
380
  setTrabalhoError('')
381
  setEdicaoErro('')
@@ -385,10 +421,17 @@ export default function TrabalhosTecnicosTab({
385
  try {
386
  const resp = await api.trabalhosTecnicosDetalhe(chave)
387
  setTrabalhoAberto(resp?.trabalho || null)
 
 
 
 
 
 
388
  } catch (err) {
389
  setTrabalhoError(err.message || 'Falha ao abrir trabalho técnico.')
390
  } finally {
391
  setTrabalhoLoading(false)
 
392
  }
393
  }
394
 
@@ -398,28 +441,34 @@ export default function TrabalhosTecnicosTab({
398
  await abrirTrabalhoPorId(trabalhoId, { origem: 'lista' })
399
  }
400
 
401
- function onVoltarLista() {
402
  setTrabalhoAberto(null)
403
  setTrabalhoError('')
404
  setEdicaoErro('')
405
  setEditando(false)
406
  setEdicao(null)
407
  setModeloDraft('')
 
 
 
 
 
 
408
  }
409
 
410
  function onVoltarDoDetalhe() {
411
  if (origemAbertura === 'pesquisa_mapa' && typeof onVoltarAoMapaPesquisa === 'function') {
412
- onVoltarLista()
413
  onVoltarAoMapaPesquisa()
414
  return
415
  }
416
  if (origemAbertura === 'trabalhos_tecnicos_mapa') {
417
  scrollRetornoMapaRef.current = true
418
  setActiveInnerTab('mapa')
419
- onVoltarLista()
420
  return
421
  }
422
- onVoltarLista()
423
  }
424
 
425
  function onAbrirModeloAssociado(modelo) {
@@ -428,6 +477,11 @@ export default function TrabalhosTecnicosTab({
428
  modeloId: modelo?.mesa_modelo_id,
429
  nomeModelo: modelo?.mesa_modelo_nome || modelo?.nome,
430
  modeloArquivo: modelo?.mesa_modelo_arquivo || '',
 
 
 
 
 
431
  })
432
  }
433
 
@@ -632,6 +686,7 @@ export default function TrabalhosTecnicosTab({
632
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
633
  const imoveis = Array.isArray(trabalhoAberto?.imoveis) ? trabalhoAberto.imoveis : []
634
  const processosSei = listarProcessosSei(trabalhoAberto)
 
635
 
636
  return (
637
  <div className="tab-content">
@@ -642,6 +697,7 @@ export default function TrabalhosTecnicosTab({
642
  <p>{trabalhoAberto?.tipo_label || 'Tipo não identificado'}</p>
643
  </div>
644
  <div className="trabalho-detail-actions">
 
645
  {!editando ? (
646
  <button
647
  type="button"
@@ -898,7 +954,7 @@ export default function TrabalhosTecnicosTab({
898
  )}
899
  {modelosMesa.length ? (
900
  <div className="section1-empty-hint">
901
- Clique em um modelo disponível para abri-lo em Modelos Estatísticos &gt; Repositório de Modelos.
902
  </div>
903
  ) : null}
904
  </>
@@ -962,7 +1018,7 @@ export default function TrabalhosTecnicosTab({
962
  </div>
963
  <LoadingOverlay
964
  show={trabalhoLoading || salvando}
965
- label={salvando ? 'Salvando trabalho técnico...' : 'Carregando trabalho técnico...'}
966
  />
967
  </div>
968
  )
@@ -976,7 +1032,15 @@ export default function TrabalhosTecnicosTab({
976
  key={tab.key}
977
  type="button"
978
  className={activeInnerTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
979
- onClick={() => setActiveInnerTab(tab.key)}
 
 
 
 
 
 
 
 
980
  >
981
  {tab.label}
982
  </button>
@@ -1059,7 +1123,10 @@ export default function TrabalhosTecnicosTab({
1059
  </tr>
1060
  </thead>
1061
  <tbody>
1062
- {trabalhosPagina.map((item) => (
 
 
 
1063
  <tr key={String(item?.id || '')}>
1064
  <td className="trabalhos-col-nome"><TruncatedCellContent tooltipContent={item?.nome || '-'}>{item?.nome || '-'}</TruncatedCellContent></td>
1065
  <td className="trabalhos-col-tipo"><TruncatedCellContent tooltipContent={item?.tipo_label || item?.tipo_codigo || '-'}>{item?.tipo_label || item?.tipo_codigo || '-'}</TruncatedCellContent></td>
@@ -1107,16 +1174,18 @@ export default function TrabalhosTecnicosTab({
1107
  <td className="repo-col-open">
1108
  <button
1109
  type="button"
1110
- className="repo-open-btn"
1111
  onClick={() => void onAbrirTrabalho(item)}
1112
- title="Abrir trabalho técnico"
1113
- aria-label={`Abrir ${item?.nome || 'trabalho técnico'}`}
 
1114
  >
1115
-
1116
  </button>
1117
  </td>
1118
  </tr>
1119
- ))}
 
1120
  {!trabalhosFiltrados.length ? (
1121
  <tr>
1122
  <td colSpan={7}>
@@ -1345,8 +1414,10 @@ export default function TrabalhosTecnicosTab({
1345
  )}
1346
  </div>
1347
  <LoadingOverlay
1348
- show={loading || mapaLoading}
1349
- label={mapaLoading ? 'Gerando mapa dos trabalhos técnicos...' : 'Carregando trabalhos técnicos...'}
 
 
1350
  />
1351
  </div>
1352
  )
 
1
  import React, { useEffect, useRef, useState } from 'react'
2
  import { api } from '../api'
3
+ import { buildTrabalhoTecnicoLink, getTrabalhosSubtabKeyFromSlug } from '../deepLinks'
4
  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
 
152
  sessionId,
153
  onAbrirModeloNoRepositorio = null,
154
  quickOpenRequest = null,
155
+ routeRequest = null,
156
+ onRouteChange = null,
157
  onVoltarAoMapaPesquisa = null,
158
+ onModoImersivoChange = null,
159
  }) {
160
  const [activeInnerTab, setActiveInnerTab] = useState('repositorio')
161
  const [trabalhos, setTrabalhos] = useState([])
 
188
  const [modeloSugestoesMesa, setModeloSugestoesMesa] = useState([])
189
  const [modeloSugestoesLoading, setModeloSugestoesLoading] = useState(false)
190
  const [origemAbertura, setOrigemAbertura] = useState('lista')
191
+ const [abrindoTrabalhoId, setAbrindoTrabalhoId] = useState('')
192
  const quickOpenHandledRef = useRef('')
193
+ const routeRequestHandledRef = useRef('')
194
  const mapaRequestKeyRef = useRef('')
195
  const mapaUltimaSelecaoRef = useRef({ ids: [], avaliando: null })
196
  const mapaAreaRef = useRef(null)
 
217
  void abrirTrabalhoPorId(trabalhoId, { origem })
218
  }, [quickOpenRequest])
219
 
220
+ useEffect(() => {
221
+ const requestKey = String(routeRequest?.requestKey || '').trim()
222
+ if (!requestKey) return
223
+ if (routeRequestHandledRef.current === requestKey) return
224
+ routeRequestHandledRef.current = requestKey
225
+ const nextSubtab = getTrabalhosSubtabKeyFromSlug(routeRequest?.subtab)
226
+ setActiveInnerTab(nextSubtab)
227
+ const trabalhoId = String(routeRequest?.trabalhoId || '').trim()
228
+ if (trabalhoId) {
229
+ const trabalhoAbertoId = String(trabalhoAberto?.id || '').trim()
230
+ if (trabalhoAbertoId && trabalhoAbertoId === trabalhoId) {
231
+ setTrabalhoError('')
232
+ return
233
+ }
234
+ void abrirTrabalhoPorId(trabalhoId, { origem: 'lista' })
235
+ return
236
+ }
237
+ if (trabalhoAberto) {
238
+ onVoltarLista({ syncRoute: false, subtab: nextSubtab })
239
+ }
240
+ }, [routeRequest, trabalhoAberto])
241
+
242
+ useEffect(() => {
243
+ if (typeof onModoImersivoChange !== 'function') return undefined
244
+ onModoImersivoChange(Boolean(trabalhoAberto))
245
+ return () => onModoImersivoChange(false)
246
+ }, [trabalhoAberto, onModoImersivoChange])
247
+
248
  async function carregarTrabalhos() {
249
  setLoading(true)
250
  setError('')
 
411
  async function abrirTrabalhoPorId(trabalhoId, options = {}) {
412
  const chave = String(trabalhoId || '').trim()
413
  if (!chave) return
414
+ setAbrindoTrabalhoId(chave)
415
  setTrabalhoLoading(true)
416
  setTrabalhoError('')
417
  setEdicaoErro('')
 
421
  try {
422
  const resp = await api.trabalhosTecnicosDetalhe(chave)
423
  setTrabalhoAberto(resp?.trabalho || null)
424
+ if (typeof onRouteChange === 'function') {
425
+ onRouteChange({
426
+ tab: 'trabalhos',
427
+ trabalhoId: chave,
428
+ })
429
+ }
430
  } catch (err) {
431
  setTrabalhoError(err.message || 'Falha ao abrir trabalho técnico.')
432
  } finally {
433
  setTrabalhoLoading(false)
434
+ setAbrindoTrabalhoId((prev) => (prev === chave ? '' : prev))
435
  }
436
  }
437
 
 
441
  await abrirTrabalhoPorId(trabalhoId, { origem: 'lista' })
442
  }
443
 
444
+ function onVoltarLista(options = {}) {
445
  setTrabalhoAberto(null)
446
  setTrabalhoError('')
447
  setEdicaoErro('')
448
  setEditando(false)
449
  setEdicao(null)
450
  setModeloDraft('')
451
+ if (options.syncRoute !== false && typeof onRouteChange === 'function') {
452
+ onRouteChange({
453
+ tab: 'trabalhos',
454
+ subtab: options.subtab || activeInnerTab,
455
+ })
456
+ }
457
  }
458
 
459
  function onVoltarDoDetalhe() {
460
  if (origemAbertura === 'pesquisa_mapa' && typeof onVoltarAoMapaPesquisa === 'function') {
461
+ onVoltarLista({ syncRoute: false })
462
  onVoltarAoMapaPesquisa()
463
  return
464
  }
465
  if (origemAbertura === 'trabalhos_tecnicos_mapa') {
466
  scrollRetornoMapaRef.current = true
467
  setActiveInnerTab('mapa')
468
+ onVoltarLista({ subtab: 'mapa' })
469
  return
470
  }
471
+ onVoltarLista({ subtab: activeInnerTab })
472
  }
473
 
474
  function onAbrirModeloAssociado(modelo) {
 
477
  modeloId: modelo?.mesa_modelo_id,
478
  nomeModelo: modelo?.mesa_modelo_nome || modelo?.nome,
479
  modeloArquivo: modelo?.mesa_modelo_arquivo || '',
480
+ returnIntent: {
481
+ tab: 'trabalhos',
482
+ subtab: activeInnerTab,
483
+ trabalhoId: String(trabalhoAberto?.id || '').trim(),
484
+ },
485
  })
486
  }
487
 
 
686
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
687
  const imoveis = Array.isArray(trabalhoAberto?.imoveis) ? trabalhoAberto.imoveis : []
688
  const processosSei = listarProcessosSei(trabalhoAberto)
689
+ const shareHref = buildTrabalhoTecnicoLink(trabalhoAberto?.id || '')
690
 
691
  return (
692
  <div className="tab-content">
 
697
  <p>{trabalhoAberto?.tipo_label || 'Tipo não identificado'}</p>
698
  </div>
699
  <div className="trabalho-detail-actions">
700
+ <ShareLinkButton href={shareHref} />
701
  {!editando ? (
702
  <button
703
  type="button"
 
954
  )}
955
  {modelosMesa.length ? (
956
  <div className="section1-empty-hint">
957
+ Clique em um modelo disponível para abri-lo na MESA.
958
  </div>
959
  ) : null}
960
  </>
 
1018
  </div>
1019
  <LoadingOverlay
1020
  show={trabalhoLoading || salvando}
1021
+ label={salvando ? 'Salvando trabalho técnico...' : 'Abrindo trabalho técnico...'}
1022
  />
1023
  </div>
1024
  )
 
1032
  key={tab.key}
1033
  type="button"
1034
  className={activeInnerTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
1035
+ onClick={() => {
1036
+ setActiveInnerTab(tab.key)
1037
+ if (typeof onRouteChange === 'function') {
1038
+ onRouteChange({
1039
+ tab: 'trabalhos',
1040
+ subtab: tab.key,
1041
+ })
1042
+ }
1043
+ }}
1044
  >
1045
  {tab.label}
1046
  </button>
 
1123
  </tr>
1124
  </thead>
1125
  <tbody>
1126
+ {trabalhosPagina.map((item) => {
1127
+ const trabalhoId = String(item?.id || '').trim()
1128
+ const abrindoEsteTrabalho = abrindoTrabalhoId === trabalhoId
1129
+ return (
1130
  <tr key={String(item?.id || '')}>
1131
  <td className="trabalhos-col-nome"><TruncatedCellContent tooltipContent={item?.nome || '-'}>{item?.nome || '-'}</TruncatedCellContent></td>
1132
  <td className="trabalhos-col-tipo"><TruncatedCellContent tooltipContent={item?.tipo_label || item?.tipo_codigo || '-'}>{item?.tipo_label || item?.tipo_codigo || '-'}</TruncatedCellContent></td>
 
1174
  <td className="repo-col-open">
1175
  <button
1176
  type="button"
1177
+ className={abrindoEsteTrabalho ? 'repo-open-btn is-loading' : 'repo-open-btn'}
1178
  onClick={() => void onAbrirTrabalho(item)}
1179
+ disabled={trabalhoLoading}
1180
+ title={abrindoEsteTrabalho ? 'Abrindo trabalho técnico...' : 'Abrir trabalho técnico'}
1181
+ aria-label={`${abrindoEsteTrabalho ? 'Abrindo' : 'Abrir'} ${item?.nome || 'trabalho técnico'}`}
1182
  >
1183
+ {abrindoEsteTrabalho ? <span className="repo-open-btn-spinner" aria-hidden="true" /> : ''}
1184
  </button>
1185
  </td>
1186
  </tr>
1187
+ )
1188
+ })}
1189
  {!trabalhosFiltrados.length ? (
1190
  <tr>
1191
  <td colSpan={7}>
 
1414
  )}
1415
  </div>
1416
  <LoadingOverlay
1417
+ show={loading || mapaLoading || trabalhoLoading}
1418
+ label={trabalhoLoading
1419
+ ? 'Abrindo trabalho técnico...'
1420
+ : (mapaLoading ? 'Gerando mapa dos trabalhos técnicos...' : 'Carregando trabalhos técnicos...')}
1421
  />
1422
  </div>
1423
  )
frontend/src/deepLinks.js ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const TAB_LABEL_BY_SLUG = {
2
+ modelos: 'Modelos Estatísticos',
3
+ elaboracao: 'Elaboração/Edição',
4
+ avaliacao: 'Avaliação',
5
+ trabalhos: 'Trabalhos Técnicos',
6
+ }
7
+
8
+ const TAB_SLUG_BY_LABEL = Object.fromEntries(
9
+ Object.entries(TAB_LABEL_BY_SLUG).map(([slug, label]) => [label, slug]),
10
+ )
11
+
12
+ const MODELOS_SUBTAB_LABEL_BY_SLUG = {
13
+ pesquisa: 'Pesquisar Modelos',
14
+ repositorio: 'Repositório de Modelos',
15
+ 'visao-geral': 'Visão Geral',
16
+ }
17
+
18
+ const MODELOS_SUBTAB_SLUG_BY_LABEL = Object.fromEntries(
19
+ Object.entries(MODELOS_SUBTAB_LABEL_BY_SLUG).map(([slug, label]) => [label, slug]),
20
+ )
21
+
22
+ const TRABALHOS_SUBTAB_LABEL_BY_SLUG = {
23
+ repositorio: 'repositorio',
24
+ mapa: 'mapa',
25
+ }
26
+
27
+ const TRABALHOS_SUBTAB_SLUG_BY_LABEL = Object.fromEntries(
28
+ Object.entries(TRABALHOS_SUBTAB_LABEL_BY_SLUG).map(([slug, label]) => [label, slug]),
29
+ )
30
+
31
+ const MODEL_TAB_SET = new Set([
32
+ 'mapa',
33
+ 'trabalhos-tecnicos',
34
+ 'dados-mercado',
35
+ 'metricas',
36
+ 'transformacoes',
37
+ 'resumo',
38
+ 'coeficientes',
39
+ 'obs-calc',
40
+ 'graficos',
41
+ ])
42
+
43
+ const PESQUISA_FILTER_KEYS = [
44
+ 'nomeModelo',
45
+ 'tipoModelo',
46
+ 'negociacaoModelo',
47
+ 'dataMin',
48
+ 'dataMax',
49
+ 'versionamentoModelos',
50
+ 'avalFinalidade',
51
+ 'avalZona',
52
+ 'avalBairro',
53
+ 'avalArea',
54
+ 'avalRh',
55
+ ]
56
+
57
+ const PESQUISA_FILTER_DEFAULTS = {
58
+ nomeModelo: '',
59
+ tipoModelo: '',
60
+ negociacaoModelo: '',
61
+ dataMin: '',
62
+ dataMax: '',
63
+ versionamentoModelos: 'incluir_antigos',
64
+ avalFinalidade: '',
65
+ avalZona: '',
66
+ avalBairro: '',
67
+ avalArea: '',
68
+ avalRh: '',
69
+ }
70
+
71
+ function normalizeSlug(value) {
72
+ return String(value || '').trim().toLowerCase()
73
+ }
74
+
75
+ function trimValue(value) {
76
+ return String(value || '').trim()
77
+ }
78
+
79
+ function parseFiniteNumber(value) {
80
+ const text = trimValue(value).replace(',', '.')
81
+ if (!text) return null
82
+ const parsed = Number(text)
83
+ return Number.isFinite(parsed) ? parsed : null
84
+ }
85
+
86
+ function sanitizePesquisaFilters(rawFilters = {}) {
87
+ const next = { ...PESQUISA_FILTER_DEFAULTS }
88
+ PESQUISA_FILTER_KEYS.forEach((key) => {
89
+ if (!Object.prototype.hasOwnProperty.call(rawFilters, key)) return
90
+ const value = trimValue(rawFilters[key])
91
+ if (key === 'versionamentoModelos') {
92
+ next[key] = value === 'atuais' ? 'atuais' : 'incluir_antigos'
93
+ return
94
+ }
95
+ next[key] = value
96
+ })
97
+ return next
98
+ }
99
+
100
+ function hasPesquisaFilters(filters = {}) {
101
+ return PESQUISA_FILTER_KEYS.some((key) => {
102
+ const value = key === 'versionamentoModelos'
103
+ ? trimValue(filters[key] || PESQUISA_FILTER_DEFAULTS[key])
104
+ : trimValue(filters[key])
105
+ if (key === 'versionamentoModelos') return value === 'atuais'
106
+ return Boolean(value)
107
+ })
108
+ }
109
+
110
+ export function getAppTabKeyFromSlug(slug) {
111
+ return TAB_LABEL_BY_SLUG[normalizeSlug(slug)] || ''
112
+ }
113
+
114
+ export function getAppTabSlugFromKey(label) {
115
+ return TAB_SLUG_BY_LABEL[String(label || '').trim()] || ''
116
+ }
117
+
118
+ export function getModelosSubtabKeyFromSlug(slug) {
119
+ return MODELOS_SUBTAB_LABEL_BY_SLUG[normalizeSlug(slug)] || MODELOS_SUBTAB_LABEL_BY_SLUG.pesquisa
120
+ }
121
+
122
+ export function getModelosSubtabSlugFromKey(label) {
123
+ return MODELOS_SUBTAB_SLUG_BY_LABEL[String(label || '').trim()] || 'pesquisa'
124
+ }
125
+
126
+ export function getTrabalhosSubtabKeyFromSlug(slug) {
127
+ return TRABALHOS_SUBTAB_LABEL_BY_SLUG[normalizeSlug(slug)] || TRABALHOS_SUBTAB_LABEL_BY_SLUG.repositorio
128
+ }
129
+
130
+ export function getTrabalhosSubtabSlugFromKey(label) {
131
+ return TRABALHOS_SUBTAB_SLUG_BY_LABEL[String(label || '').trim()] || 'repositorio'
132
+ }
133
+
134
+ export function getModelTabSlug(value) {
135
+ const slug = normalizeSlug(value)
136
+ return MODEL_TAB_SET.has(slug) ? slug : 'mapa'
137
+ }
138
+
139
+ export function hasMesaDeepLink(intent) {
140
+ if (!intent || typeof intent !== 'object') return false
141
+ return Boolean(
142
+ trimValue(intent.tab)
143
+ || trimValue(intent.modeloId)
144
+ || trimValue(intent.trabalhoId)
145
+ || trimValue(intent.subtab)
146
+ || hasPesquisaFilters(intent.filters)
147
+ || (intent.avaliando && Number.isFinite(Number(intent.avaliando.lat)) && Number.isFinite(Number(intent.avaliando.lon))),
148
+ )
149
+ }
150
+
151
+ export function normalizeMesaDeepLink(intent = {}) {
152
+ const safeIntent = intent && typeof intent === 'object' ? intent : {}
153
+ const rawTab = normalizeSlug(safeIntent.tab)
154
+ const modeloId = trimValue(safeIntent.modeloId)
155
+ const trabalhoId = trimValue(safeIntent.trabalhoId)
156
+ let tab = TAB_LABEL_BY_SLUG[rawTab] ? rawTab : ''
157
+
158
+ if (!tab) {
159
+ if (modeloId) tab = 'modelos'
160
+ if (!tab && trabalhoId) tab = 'trabalhos'
161
+ }
162
+
163
+ let subtab = normalizeSlug(safeIntent.subtab)
164
+ if (tab === 'modelos') {
165
+ if (modeloId) {
166
+ subtab = 'repositorio'
167
+ } else if (!MODELOS_SUBTAB_LABEL_BY_SLUG[subtab]) {
168
+ subtab = 'pesquisa'
169
+ }
170
+ } else if (tab === 'trabalhos') {
171
+ if (!TRABALHOS_SUBTAB_LABEL_BY_SLUG[subtab]) subtab = 'repositorio'
172
+ } else {
173
+ subtab = ''
174
+ }
175
+
176
+ const filters = sanitizePesquisaFilters(safeIntent.filters)
177
+ const lat = parseFiniteNumber(safeIntent.avaliando?.lat)
178
+ const lon = parseFiniteNumber(safeIntent.avaliando?.lon)
179
+ const avaliando = lat !== null && lon !== null ? { lat, lon } : null
180
+ const modelTab = tab === 'modelos' && subtab === 'repositorio' && modeloId
181
+ ? getModelTabSlug(safeIntent.modelTab)
182
+ : ''
183
+
184
+ return {
185
+ tab,
186
+ subtab,
187
+ modelTab,
188
+ modeloId,
189
+ trabalhoId,
190
+ filters,
191
+ avaliando,
192
+ }
193
+ }
194
+
195
+ export function parseMesaDeepLink(search = '') {
196
+ const params = new URLSearchParams(String(search || '').replace(/^\?/, ''))
197
+ const filters = {}
198
+ PESQUISA_FILTER_KEYS.forEach((key) => {
199
+ if (!params.has(key)) return
200
+ filters[key] = params.get(key)
201
+ })
202
+
203
+ return normalizeMesaDeepLink({
204
+ tab: params.get('tab'),
205
+ subtab: params.get('subtab'),
206
+ modelTab: params.get('modelTab'),
207
+ modeloId: params.get('modeloId'),
208
+ trabalhoId: params.get('trabalhoId'),
209
+ filters,
210
+ avaliando: {
211
+ lat: params.get('avalLat'),
212
+ lon: params.get('avalLon'),
213
+ },
214
+ })
215
+ }
216
+
217
+ export function serializeMesaDeepLink(intent = {}) {
218
+ const normalized = normalizeMesaDeepLink(intent)
219
+ const params = new URLSearchParams()
220
+
221
+ if (normalized.tab) params.set('tab', normalized.tab)
222
+
223
+ if (normalized.tab === 'modelos' && normalized.subtab) {
224
+ params.set('subtab', normalized.subtab)
225
+ }
226
+
227
+ if (normalized.tab === 'trabalhos' && normalized.subtab && !normalized.trabalhoId) {
228
+ params.set('subtab', normalized.subtab)
229
+ }
230
+
231
+ if (normalized.modeloId) params.set('modeloId', normalized.modeloId)
232
+ if (normalized.trabalhoId) params.set('trabalhoId', normalized.trabalhoId)
233
+ if (normalized.modelTab) params.set('modelTab', normalized.modelTab)
234
+
235
+ if (normalized.tab === 'modelos' && normalized.subtab === 'pesquisa') {
236
+ PESQUISA_FILTER_KEYS.forEach((key) => {
237
+ const value = key === 'versionamentoModelos'
238
+ ? trimValue(normalized.filters[key] || PESQUISA_FILTER_DEFAULTS[key])
239
+ : trimValue(normalized.filters[key])
240
+ if (key === 'versionamentoModelos') {
241
+ if (value === 'atuais') params.set(key, value)
242
+ return
243
+ }
244
+ if (value) params.set(key, value)
245
+ })
246
+ if (normalized.avaliando) {
247
+ params.set('avalLat', String(normalized.avaliando.lat))
248
+ params.set('avalLon', String(normalized.avaliando.lon))
249
+ }
250
+ }
251
+
252
+ const query = params.toString()
253
+ return query ? `?${query}` : ''
254
+ }
255
+
256
+ export function buildMesaUrl(intent = {}) {
257
+ if (typeof window === 'undefined') return serializeMesaDeepLink(intent)
258
+ const url = new URL(window.location.href)
259
+ url.search = serializeMesaDeepLink(intent)
260
+ url.hash = ''
261
+ return url.toString()
262
+ }
263
+
264
+ function syncHuggingFaceParentUrl(queryString = '', hash = '') {
265
+ if (typeof window === 'undefined') return
266
+ if (!window.parent || window.parent === window) return
267
+ try {
268
+ window.parent.postMessage(
269
+ {
270
+ queryString,
271
+ hash,
272
+ },
273
+ 'https://huggingface.co',
274
+ )
275
+ } catch {
276
+ // Ignora falhas cross-origin e mantém o sync local no iframe.
277
+ }
278
+ }
279
+
280
+ export function replaceMesaDeepLink(intent = {}) {
281
+ if (typeof window === 'undefined') return
282
+ const queryString = serializeMesaDeepLink(intent)
283
+ window.history.replaceState(null, '', buildMesaUrl(intent))
284
+ syncHuggingFaceParentUrl(queryString, '')
285
+ }
286
+
287
+ export function pushMesaDeepLink(intent = {}) {
288
+ if (typeof window === 'undefined') return
289
+ const queryString = serializeMesaDeepLink(intent)
290
+ window.history.pushState(null, '', buildMesaUrl(intent))
291
+ syncHuggingFaceParentUrl(queryString, '')
292
+ }
293
+
294
+ export function buildRepositorioModeloLink(modeloId, modelTab = 'mapa') {
295
+ return buildMesaUrl({
296
+ tab: 'modelos',
297
+ subtab: 'repositorio',
298
+ modeloId,
299
+ modelTab,
300
+ })
301
+ }
302
+
303
+ export function buildAvaliacaoModeloLink(modeloId) {
304
+ return buildMesaUrl({
305
+ tab: 'avaliacao',
306
+ modeloId,
307
+ })
308
+ }
309
+
310
+ export function buildElaboracaoModeloLink(modeloId) {
311
+ return buildMesaUrl({
312
+ tab: 'elaboracao',
313
+ modeloId,
314
+ })
315
+ }
316
+
317
+ export function buildTrabalhoTecnicoLink(trabalhoId) {
318
+ return buildMesaUrl({
319
+ tab: 'trabalhos',
320
+ trabalhoId,
321
+ })
322
+ }
323
+
324
+ export function buildPesquisaLink(filters = {}, avaliando = null) {
325
+ return buildMesaUrl({
326
+ tab: 'modelos',
327
+ subtab: 'pesquisa',
328
+ filters,
329
+ avaliando,
330
+ })
331
+ }
332
+
333
+ export function buildPesquisaRoutePayload(filters = {}, avaliando = null) {
334
+ return normalizeMesaDeepLink({
335
+ tab: 'modelos',
336
+ subtab: 'pesquisa',
337
+ filters,
338
+ avaliando,
339
+ })
340
+ }
341
+
342
+ export function getPesquisaFilterDefaults() {
343
+ return { ...PESQUISA_FILTER_DEFAULTS }
344
+ }
345
+
346
+ export function hasPesquisaRoutePayload(filters = {}, avaliando = null) {
347
+ return hasPesquisaFilters(filters) || Boolean(avaliando)
348
+ }
frontend/src/styles.css CHANGED
@@ -780,6 +780,20 @@ textarea {
780
  color: #1b7a40;
781
  }
782
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
  .repo-delete-icon-btn {
784
  min-width: 28px;
785
  min-height: 28px;
@@ -2984,7 +2998,7 @@ button.pesquisa-coluna-remove:hover {
2984
  .pesquisa-results-toolbar {
2985
  display: flex;
2986
  align-items: center;
2987
- justify-content: space-between;
2988
  flex-wrap: wrap;
2989
  gap: 10px;
2990
  margin-bottom: 10px;
@@ -3007,6 +3021,14 @@ button.pesquisa-coluna-remove:hover {
3007
  min-height: 34px;
3008
  }
3009
 
 
 
 
 
 
 
 
 
3010
  .pesquisa-select-all {
3011
  display: inline-flex;
3012
  align-items: center;
@@ -3064,6 +3086,66 @@ button.pesquisa-coluna-remove:hover {
3064
  justify-content: space-between;
3065
  align-items: flex-start;
3066
  gap: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3067
  }
3068
 
3069
  .pesquisa-opened-model-title-wrap h3 {
@@ -5521,6 +5603,7 @@ button.btn-upload-select {
5521
  background: #fff;
5522
  min-height: 420px;
5523
  padding: 8px;
 
5524
  }
5525
 
5526
  .plot-card-head {
@@ -5529,6 +5612,8 @@ button.btn-upload-select {
5529
  display: grid;
5530
  align-content: start;
5531
  gap: 2px;
 
 
5532
  }
5533
 
5534
  .plot-card-title {
@@ -5547,6 +5632,10 @@ button.btn-upload-select {
5547
  line-height: 1.15;
5548
  }
5549
 
 
 
 
 
5550
  .plot-lazy-placeholder {
5551
  min-height: 320px;
5552
  display: flex;
@@ -6790,6 +6879,21 @@ button.btn-download-subtle {
6790
  width: 100%;
6791
  }
6792
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6793
  .pesquisa-select-all {
6794
  white-space: normal;
6795
  }
 
780
  color: #1b7a40;
781
  }
782
 
783
+ .repo-open-btn.is-loading {
784
+ cursor: wait;
785
+ }
786
+
787
+ .repo-open-btn-spinner {
788
+ width: 14px;
789
+ height: 14px;
790
+ display: inline-block;
791
+ border-radius: 50%;
792
+ border: 2px solid currentColor;
793
+ border-right-color: transparent;
794
+ animation: spinLoader 0.7s linear infinite;
795
+ }
796
+
797
  .repo-delete-icon-btn {
798
  min-width: 28px;
799
  min-height: 28px;
 
2998
  .pesquisa-results-toolbar {
2999
  display: flex;
3000
  align-items: center;
3001
+ justify-content: flex-start;
3002
  flex-wrap: wrap;
3003
  gap: 10px;
3004
  margin-bottom: 10px;
 
3021
  min-height: 34px;
3022
  }
3023
 
3024
+ .pesquisa-results-toolbar-actions {
3025
+ display: flex;
3026
+ align-items: center;
3027
+ gap: 10px;
3028
+ flex-wrap: wrap;
3029
+ margin-left: auto;
3030
+ }
3031
+
3032
  .pesquisa-select-all {
3033
  display: inline-flex;
3034
  align-items: center;
 
3086
  justify-content: space-between;
3087
  align-items: flex-start;
3088
  gap: 12px;
3089
+ padding-bottom: 12px;
3090
+ margin-bottom: 14px;
3091
+ border-bottom: 1px solid #dbe5ef;
3092
+ }
3093
+
3094
+ .pesquisa-opened-model-title-wrap {
3095
+ flex: 1 1 320px;
3096
+ }
3097
+
3098
+ .pesquisa-opened-model-actions {
3099
+ display: flex;
3100
+ align-items: center;
3101
+ justify-content: flex-end;
3102
+ gap: 10px;
3103
+ flex-wrap: wrap;
3104
+ }
3105
+
3106
+ .pesquisa-opened-model-action-btn {
3107
+ min-height: 36px;
3108
+ padding: 7px 12px;
3109
+ font-size: 0.82rem;
3110
+ }
3111
+
3112
+ .pesquisa-opened-model-action-btn-primary {
3113
+ --btn-bg-start: #3f90d5;
3114
+ --btn-bg-end: #2f79b8;
3115
+ --btn-border: #2a6da8;
3116
+ --btn-shadow-soft: rgba(42, 109, 168, 0.2);
3117
+ --btn-shadow-strong: rgba(42, 109, 168, 0.28);
3118
+ color: #fff;
3119
+ }
3120
+
3121
+ .pesquisa-opened-model-action-btn-secondary {
3122
+ --btn-bg-start: #f1f4f7;
3123
+ --btn-bg-end: #e4e9ef;
3124
+ --btn-border: #c6d0db;
3125
+ --btn-shadow-soft: rgba(66, 84, 103, 0.12);
3126
+ --btn-shadow-strong: rgba(66, 84, 103, 0.2);
3127
+ color: #35506a;
3128
+ }
3129
+
3130
+ .plot-card-toggle {
3131
+ display: inline-flex;
3132
+ align-items: center;
3133
+ gap: 8px;
3134
+ margin-top: 8px;
3135
+ color: #42586e;
3136
+ font-size: 0.8rem;
3137
+ font-weight: 700;
3138
+ position: relative;
3139
+ z-index: 2;
3140
+ pointer-events: auto;
3141
+ }
3142
+
3143
+ .plot-card-toggle.is-disabled {
3144
+ color: #7a8b9c;
3145
+ }
3146
+
3147
+ .plot-card-toggle input {
3148
+ margin: 0;
3149
  }
3150
 
3151
  .pesquisa-opened-model-title-wrap h3 {
 
5603
  background: #fff;
5604
  min-height: 420px;
5605
  padding: 8px;
5606
+ position: relative;
5607
  }
5608
 
5609
  .plot-card-head {
 
5612
  display: grid;
5613
  align-content: start;
5614
  gap: 2px;
5615
+ position: relative;
5616
+ z-index: 2;
5617
  }
5618
 
5619
  .plot-card-title {
 
5632
  line-height: 1.15;
5633
  }
5634
 
5635
+ .plot-card-body {
5636
+ position: relative;
5637
+ }
5638
+
5639
  .plot-lazy-placeholder {
5640
  min-height: 320px;
5641
  display: flex;
 
6879
  width: 100%;
6880
  }
6881
 
6882
+ .pesquisa-results-toolbar-actions {
6883
+ width: 100%;
6884
+ justify-content: flex-end;
6885
+ margin-left: 0;
6886
+ }
6887
+
6888
+ .pesquisa-opened-model-head {
6889
+ flex-direction: column;
6890
+ }
6891
+
6892
+ .pesquisa-opened-model-actions {
6893
+ width: 100%;
6894
+ justify-content: flex-start;
6895
+ }
6896
+
6897
  .pesquisa-select-all {
6898
  white-space: normal;
6899
  }