Guilherme Silberfarb Costa commited on
Commit
1426bed
·
1 Parent(s): 3e958a5

repositorio de trabalhos tecncios e implicacoes

Browse files
.gitignore CHANGED
@@ -14,3 +14,8 @@ frontend/dist/
14
 
15
  # Runtime logs
16
  logs/**/*.jsonl
 
 
 
 
 
 
14
 
15
  # Runtime logs
16
  logs/**/*.jsonl
17
+
18
+ # Local technical-work database
19
+ backend/local_data/*.sqlite3
20
+ backend/local_data/*.sqlite3-shm
21
+ backend/local_data/*.sqlite3-wal
README.md CHANGED
@@ -71,6 +71,22 @@ Variáveis de ambiente do backend:
71
  - `MODELOS_REPOSITORIO_HF_SUBDIR` (ex.: `modelos_dai`)
72
  - `HF_TOKEN` (opcional para dataset privado)
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  Regra automática de provider:
75
 
76
  - Em runtime HF Spaces (`SPACE_ID`/`SPACE_AUTHOR_NAME`/`HF_SPACE_ID`), o backend força `hf_dataset`.
@@ -98,6 +114,7 @@ Logs são gravados em JSONL por escopo:
98
 
99
  - `auth`
100
  - `repositorio`
 
101
  - `elaboracao`
102
  - `visualizacao`
103
 
 
71
  - `MODELOS_REPOSITORIO_HF_SUBDIR` (ex.: `modelos_dai`)
72
  - `HF_TOKEN` (opcional para dataset privado)
73
 
74
+ Base de Trabalhos Técnicos:
75
+
76
+ - Fora do HF Spaces, o app usa por padrão o banco SQLite local.
77
+ - Em runtime HF Spaces, o app usa por padrão o banco SQLite hospedado no dataset.
78
+ - Override opcional: `TRABALHOS_TECNICOS_PROVIDER` (`local` ou `hf_dataset`)
79
+ - Banco local: `TRABALHOS_TECNICOS_DB_LOCAL_PATH`
80
+ - Banco no dataset: `TRABALHOS_TECNICOS_HF_REPO_ID`, `TRABALHOS_TECNICOS_HF_REVISION`, `TRABALHOS_TECNICOS_HF_PATH`
81
+
82
+ Geração do banco local de Trabalhos Técnicos:
83
+
84
+ - Script: `backend/scripts/build_trabalhos_tecnicos_db.py`
85
+ - Planilha padrão de origem: `~/Downloads/dados_geocodificados_limpos_v2.xlsx`
86
+ - Exemplo: `backend/.venv/bin/python backend/scripts/build_trabalhos_tecnicos_db.py`
87
+ - Saída padrão: `backend/local_data/trabalhos_tecnicos.sqlite3`
88
+ - O arquivo SQLite local fica ignorado no git e deve ser enviado manualmente ao dataset `gui-sparim/repositorio_mesa` no caminho `trabalhos_tecnicos/trabalhos_tecnicos.sqlite3`.
89
+
90
  Regra automática de provider:
91
 
92
  - Em runtime HF Spaces (`SPACE_ID`/`SPACE_AUTHOR_NAME`/`HF_SPACE_ID`), o backend força `hf_dataset`.
 
114
 
115
  - `auth`
116
  - `repositorio`
117
+ - `trabalhos_tecnicos`
118
  - `elaboracao`
119
  - `visualizacao`
120
 
backend/app/api/trabalhos_tecnicos.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, Request
6
+ from pydantic import BaseModel
7
+
8
+ from app.services import auth_service, trabalhos_tecnicos_service
9
+ from app.services.audit_log_service import log_event
10
+
11
+
12
+ router = APIRouter(prefix="/api/trabalhos-tecnicos", tags=["trabalhos_tecnicos"])
13
+
14
+
15
+ class TrabalhoDetalhePayload(BaseModel):
16
+ trabalho_id: str
17
+
18
+
19
+ @router.get("")
20
+ def listar_trabalhos(request: Request) -> dict[str, Any]:
21
+ user = auth_service.require_user(request)
22
+ resposta = trabalhos_tecnicos_service.listar_trabalhos()
23
+ log_event(
24
+ "trabalhos_tecnicos",
25
+ "listar_trabalhos",
26
+ user=user,
27
+ status="ok",
28
+ request=request,
29
+ details={"total_trabalhos": resposta.get("total_trabalhos")},
30
+ )
31
+ return resposta
32
+
33
+
34
+ @router.post("/detalhe")
35
+ def detalhar_trabalho(payload: TrabalhoDetalhePayload, request: Request) -> dict[str, Any]:
36
+ user = auth_service.require_user(request)
37
+ resposta = trabalhos_tecnicos_service.detalhar_trabalho(payload.trabalho_id)
38
+ log_event(
39
+ "trabalhos_tecnicos",
40
+ "detalhar_trabalho",
41
+ user=user,
42
+ status="ok",
43
+ request=request,
44
+ details={"trabalho_id": payload.trabalho_id},
45
+ )
46
+ return resposta
backend/app/api/visualizacao.py CHANGED
@@ -23,13 +23,21 @@ class MapaPayload(SessionPayload):
23
  variavel_mapa: str | None = None
24
 
25
 
 
 
 
 
26
  class AvaliacaoPayload(SessionPayload):
27
  valores_x: dict[str, Any]
28
  indice_base: str | None = None
 
 
29
 
30
 
31
  class AvaliacaoKnnDetalhesPayload(SessionPayload):
32
  valores_x: dict[str, Any]
 
 
33
 
34
 
35
  class AvaliacaoDeletePayload(SessionPayload):
@@ -87,9 +95,13 @@ def repositorio_carregar(payload: RepositorioModeloPayload, request: Request) ->
87
 
88
 
89
  @router.post("/exibir")
90
- def exibir(payload: SessionPayload) -> dict[str, Any]:
91
  session = session_store.get(payload.session_id)
92
- return visualizacao_service.exibir_modelo(session)
 
 
 
 
93
 
94
 
95
  @router.post("/evaluation/context")
@@ -99,9 +111,20 @@ def evaluation_context(payload: SessionPayload) -> dict[str, Any]:
99
 
100
 
101
  @router.post("/map/update")
102
- def map_update(payload: MapaPayload) -> dict[str, Any]:
103
  session = session_store.get(payload.session_id)
104
- return visualizacao_service.atualizar_mapa(session, payload.variavel_mapa)
 
 
 
 
 
 
 
 
 
 
 
105
 
106
 
107
  @router.post("/evaluation/fields")
@@ -114,7 +137,13 @@ def evaluation_fields(payload: SessionPayload) -> dict[str, Any]:
114
  def evaluation_calculate(payload: AvaliacaoPayload, request: Request) -> dict[str, Any]:
115
  session = session_store.get(payload.session_id)
116
  user = auth_service.require_user(request)
117
- resposta = visualizacao_service.calcular_avaliacao(session, payload.valores_x, payload.indice_base)
 
 
 
 
 
 
118
  log_event(
119
  "visualizacao",
120
  "avaliacao_calcular",
@@ -130,7 +159,12 @@ def evaluation_calculate(payload: AvaliacaoPayload, request: Request) -> dict[st
130
  def evaluation_knn_details(payload: AvaliacaoKnnDetalhesPayload, request: Request) -> dict[str, Any]:
131
  session = session_store.get(payload.session_id)
132
  user = auth_service.require_user(request)
133
- resposta = visualizacao_service.detalhes_knn_avaliacao(session, payload.valores_x)
 
 
 
 
 
134
  log_event(
135
  "visualizacao",
136
  "avaliacao_knn_detalhes",
 
23
  variavel_mapa: str | None = None
24
 
25
 
26
+ class MapaPopupPayload(SessionPayload):
27
+ row_id: int
28
+
29
+
30
  class AvaliacaoPayload(SessionPayload):
31
  valores_x: dict[str, Any]
32
  indice_base: str | None = None
33
+ avaliando_lat: float | None = None
34
+ avaliando_lon: float | None = None
35
 
36
 
37
  class AvaliacaoKnnDetalhesPayload(SessionPayload):
38
  valores_x: dict[str, Any]
39
+ avaliando_lat: float | None = None
40
+ avaliando_lon: float | None = None
41
 
42
 
43
  class AvaliacaoDeletePayload(SessionPayload):
 
95
 
96
 
97
  @router.post("/exibir")
98
+ def exibir(payload: SessionPayload, request: Request) -> dict[str, Any]:
99
  session = session_store.get(payload.session_id)
100
+ return visualizacao_service.exibir_modelo(
101
+ session,
102
+ api_base_url=str(request.base_url).rstrip("/"),
103
+ popup_auth_token=getattr(request.state, "auth_token", None),
104
+ )
105
 
106
 
107
  @router.post("/evaluation/context")
 
111
 
112
 
113
  @router.post("/map/update")
114
+ def map_update(payload: MapaPayload, request: Request) -> dict[str, Any]:
115
  session = session_store.get(payload.session_id)
116
+ return visualizacao_service.atualizar_mapa(
117
+ session,
118
+ payload.variavel_mapa,
119
+ api_base_url=str(request.base_url).rstrip("/"),
120
+ popup_auth_token=getattr(request.state, "auth_token", None),
121
+ )
122
+
123
+
124
+ @router.post("/map/popup")
125
+ def map_popup(payload: MapaPopupPayload) -> dict[str, Any]:
126
+ session = session_store.get(payload.session_id)
127
+ return visualizacao_service.carregar_popup_ponto_mapa(session, payload.row_id)
128
 
129
 
130
  @router.post("/evaluation/fields")
 
137
  def evaluation_calculate(payload: AvaliacaoPayload, request: Request) -> dict[str, Any]:
138
  session = session_store.get(payload.session_id)
139
  user = auth_service.require_user(request)
140
+ resposta = visualizacao_service.calcular_avaliacao(
141
+ session,
142
+ payload.valores_x,
143
+ payload.indice_base,
144
+ payload.avaliando_lat,
145
+ payload.avaliando_lon,
146
+ )
147
  log_event(
148
  "visualizacao",
149
  "avaliacao_calcular",
 
159
  def evaluation_knn_details(payload: AvaliacaoKnnDetalhesPayload, request: Request) -> dict[str, Any]:
160
  session = session_store.get(payload.session_id)
161
  user = auth_service.require_user(request)
162
+ resposta = visualizacao_service.detalhes_knn_avaliacao(
163
+ session,
164
+ payload.valores_x,
165
+ payload.avaliando_lat,
166
+ payload.avaliando_lon,
167
+ )
168
  log_event(
169
  "visualizacao",
170
  "avaliacao_knn_detalhes",
backend/app/core/map_layers.py CHANGED
@@ -143,6 +143,71 @@ def add_indice_marker(
143
  ).add_to(camada)
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def add_zoom_responsive_circle_markers(
147
  mapa: folium.Map,
148
  *,
@@ -412,6 +477,277 @@ def add_popup_pagination_handlers(mapa: folium.Map) -> None:
412
  }}
413
  }}
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  function handleClick(event) {{
416
  const target = event && event.target ? event.target : null;
417
  if (!target || !target.closest) return;
@@ -461,11 +797,20 @@ def add_popup_pagination_handlers(mapa: folium.Map) -> None:
461
  const popup = evt && evt.popup ? evt.popup : null;
462
  const contentNode = popup && popup._contentNode ? popup._contentNode : null;
463
  if (!contentNode || !contentNode.querySelector) return;
464
- const root = contentNode.querySelector('[data-pager]');
465
- if (!root) return;
466
- const initialRaw = parseInt(root.dataset.currentPage || '1', 10);
467
- const initialPage = Number.isFinite(initialRaw) && initialRaw > 0 ? initialRaw : 1;
468
- updatePager(root, initialPage);
 
 
 
 
 
 
 
 
 
469
  }});
470
 
471
  let resizeTimer = null;
 
143
  ).add_to(camada)
144
 
145
 
146
+ def add_trabalhos_tecnicos_markers(
147
+ camada: folium.map.FeatureGroup,
148
+ trabalhos: list[dict[str, Any]] | None,
149
+ ) -> None:
150
+ for item in trabalhos or []:
151
+ try:
152
+ lat = float(item.get("coord_lat"))
153
+ lon = float(item.get("coord_lon"))
154
+ except Exception:
155
+ continue
156
+
157
+ trabalho_nome = str(item.get("trabalho_nome") or item.get("trabalho_id") or "Trabalho tecnico").strip()
158
+ tipo_label = str(item.get("tipo_label") or "").strip()
159
+ label = str(item.get("label") or "").strip()
160
+ endereco = str(item.get("endereco") or "").strip()
161
+ numero = str(item.get("numero") or "").strip()
162
+ modelos = [str(valor).strip() for valor in (item.get("modelos_relacionados") or []) if str(valor).strip()]
163
+
164
+ endereco_parts = []
165
+ if endereco:
166
+ endereco_parts.append(endereco)
167
+ if numero:
168
+ endereco_parts.append(numero)
169
+ endereco_texto = ", ".join(endereco_parts) or label or "Endereco nao informado"
170
+ label_texto = label or endereco_texto
171
+ modelos_texto = ", ".join(modelos) or "Modelo nao informado"
172
+
173
+ tooltip_html = (
174
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
175
+ f"<b>{escape(trabalho_nome)}</b>"
176
+ f"<br><span style='color:#555;'>Endereco:</span> {escape(endereco_texto)}"
177
+ "</div>"
178
+ )
179
+
180
+ popup_html = (
181
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.55; min-width:260px;'>"
182
+ f"<div style='font-weight:700; color:#7d5a00; margin-bottom:6px;'>{escape(trabalho_nome)}</div>"
183
+ + (f"<div><span style='color:#666;'>Tipo:</span> {escape(tipo_label)}</div>" if tipo_label else "")
184
+ + f"<div><span style='color:#666;'>Avaliando:</span> {escape(label_texto)}</div>"
185
+ + f"<div><span style='color:#666;'>Endereco:</span> {escape(endereco_texto)}</div>"
186
+ + f"<div><span style='color:#666;'>Modelo(s):</span> {escape(modelos_texto)}</div>"
187
+ + "</div>"
188
+ )
189
+
190
+ folium.Marker(
191
+ location=[lat, lon],
192
+ tooltip=folium.Tooltip(tooltip_html, sticky=True),
193
+ popup=folium.Popup(popup_html, max_width=360),
194
+ icon=folium.DivIcon(
195
+ html=(
196
+ "<div style='display:flex;align-items:center;justify-content:center;"
197
+ "width:14px;height:14px;'>"
198
+ "<svg width='14' height='14' viewBox='0 0 24 24' aria-hidden='true'>"
199
+ "<polygon points='12,1.8 15.2,8.2 22.2,9.2 17.1,14.1 18.3,21.1 "
200
+ "12,17.8 5.7,21.1 6.9,14.1 1.8,9.2 8.8,8.2' "
201
+ "fill='#c62828' stroke='#000000' stroke-width='1.4' stroke-linejoin='round'/>"
202
+ "</svg></div>"
203
+ ),
204
+ icon_size=(14, 14),
205
+ icon_anchor=(7, 7),
206
+ class_name="mesa-trabalho-tecnico-marker",
207
+ ),
208
+ ).add_to(camada)
209
+
210
+
211
  def add_zoom_responsive_circle_markers(
212
  mapa: folium.Map,
213
  *,
 
477
  }}
478
  }}
479
 
480
+ function buildLazyPopupError(message) {{
481
+ const text = String(message || 'Falha ao carregar os dados do registro.')
482
+ .replace(/&/g, '&amp;')
483
+ .replace(/</g, '&lt;')
484
+ .replace(/>/g, '&gt;');
485
+ return (
486
+ "<div style=\\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden; width:340px; max-width:340px;\\">" +
487
+ "<div style=\\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\\">Dados do Registro</div>" +
488
+ "<div style=\\"padding:12px 15px; background:#f8f9fa; color:#b42318; font-size:12px;\\">" + text + "</div>" +
489
+ "</div>"
490
+ );
491
+ }}
492
+
493
+ function isLocalHostname(hostname) {{
494
+ const name = String(hostname || '').trim().toLowerCase();
495
+ return name === 'localhost' || name === '127.0.0.1' || name === '0.0.0.0';
496
+ }}
497
+
498
+ function parentOriginOrNull() {{
499
+ try {{
500
+ if (window.parent && window.parent.location && window.parent.location.origin) {{
501
+ return String(window.parent.location.origin);
502
+ }}
503
+ }} catch (_error) {{
504
+ return null;
505
+ }}
506
+ return null;
507
+ }}
508
+
509
+ function resolvePopupEndpointCandidates(rawEndpoint) {{
510
+ const endpoint = String(rawEndpoint || '/api/visualizacao/map/popup').trim();
511
+ const candidates = [];
512
+ const seen = new Set();
513
+
514
+ function pushCandidate(value) {{
515
+ const text = String(value || '').trim();
516
+ if (!text || seen.has(text)) return;
517
+ seen.add(text);
518
+ candidates.push(text);
519
+ }}
520
+
521
+ const parentOrigin = parentOriginOrNull();
522
+ let endpointUrl = null;
523
+ try {{
524
+ const base = parentOrigin || window.location.href || undefined;
525
+ endpointUrl = new URL(endpoint, base);
526
+ }} catch (_error) {{
527
+ endpointUrl = null;
528
+ }}
529
+
530
+ if (endpointUrl) {{
531
+ pushCandidate(endpointUrl.href);
532
+
533
+ if (parentOrigin) {{
534
+ try {{
535
+ const parentUrl = new URL(parentOrigin);
536
+ const endpointLooksInternal = isLocalHostname(endpointUrl.hostname) && !isLocalHostname(parentUrl.hostname);
537
+ const mixedContentRisk = parentUrl.protocol === 'https:' && endpointUrl.protocol === 'http:';
538
+ if (endpointLooksInternal || mixedContentRisk) {{
539
+ pushCandidate(new URL(endpointUrl.pathname + endpointUrl.search, parentOrigin).href);
540
+ }}
541
+ }} catch (_error) {{
542
+ // no-op
543
+ }}
544
+ }}
545
+ }} else if (parentOrigin) {{
546
+ try {{
547
+ pushCandidate(new URL(endpoint, parentOrigin).href);
548
+ }} catch (_error) {{
549
+ // no-op
550
+ }}
551
+ }}
552
+
553
+ pushCandidate(endpoint);
554
+ return candidates;
555
+ }}
556
+
557
+ async function fetchJsonWithTimeout(url, options, timeoutMs) {{
558
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
559
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 8000;
560
+ let timerId = null;
561
+
562
+ if (controller) {{
563
+ timerId = window.setTimeout(function() {{
564
+ try {{
565
+ controller.abort();
566
+ }} catch (_error) {{
567
+ // no-op
568
+ }}
569
+ }}, timeout);
570
+ }}
571
+
572
+ try {{
573
+ const response = await fetch(url, {{
574
+ ...options,
575
+ signal: controller ? controller.signal : undefined,
576
+ }});
577
+ let payload = null;
578
+ try {{
579
+ payload = await response.json();
580
+ }} catch (_error) {{
581
+ payload = null;
582
+ }}
583
+ return {{ response, payload }};
584
+ }} finally {{
585
+ if (timerId) {{
586
+ window.clearTimeout(timerId);
587
+ }}
588
+ }}
589
+ }}
590
+
591
+ async function loadLazyPopupContent(popup, contentNode) {{
592
+ if (!contentNode || !contentNode.querySelector) return;
593
+ const placeholder = contentNode.querySelector('[data-mesa-lazy-popup]');
594
+ if (!placeholder) {{
595
+ const root = contentNode.querySelector('[data-pager]');
596
+ if (root) {{
597
+ const initialRaw = parseInt(root.dataset.currentPage || '1', 10);
598
+ const initialPage = Number.isFinite(initialRaw) && initialRaw > 0 ? initialRaw : 1;
599
+ updatePager(root, initialPage);
600
+ }}
601
+ return;
602
+ }}
603
+
604
+ const currentState = String(placeholder.dataset.lazyState || '').trim();
605
+ if (currentState === 'loading' || currentState === 'loaded') return;
606
+ if (popup && popup.__mesaLazyPopupState === 'loaded') {{
607
+ const root = contentNode.querySelector('[data-pager]');
608
+ if (root) {{
609
+ const initialRaw = parseInt(root.dataset.currentPage || '1', 10);
610
+ const initialPage = Number.isFinite(initialRaw) && initialRaw > 0 ? initialRaw : 1;
611
+ updatePager(root, initialPage);
612
+ }}
613
+ return;
614
+ }}
615
+
616
+ placeholder.dataset.lazyState = 'loading';
617
+ const endpoint = String(placeholder.dataset.popupEndpoint || '/api/visualizacao/map/popup').trim();
618
+ const authToken = String(placeholder.dataset.authToken || '').trim();
619
+ const sessionId = String(placeholder.dataset.sessionId || '').trim();
620
+ const rowIdRaw = String(placeholder.dataset.rowId || '').trim();
621
+ const rowId = parseInt(rowIdRaw, 10);
622
+
623
+ if (!endpoint || !sessionId || !Number.isFinite(rowId)) {{
624
+ placeholder.dataset.lazyState = '';
625
+ const errorHtml = buildLazyPopupError('Identificador do ponto indisponivel.');
626
+ if (popup && typeof popup.setContent === 'function') {{
627
+ popup.setContent(errorHtml);
628
+ }} else {{
629
+ contentNode.innerHTML = errorHtml;
630
+ }}
631
+ if (popup && typeof popup.update === 'function') popup.update();
632
+ return;
633
+ }}
634
+
635
+ if (popup) {{
636
+ popup.__mesaLazyPopupState = 'loading';
637
+ }}
638
+
639
+ try {{
640
+ const headers = {{
641
+ 'Content-Type': 'application/json',
642
+ }};
643
+ if (authToken) {{
644
+ headers['X-Auth-Token'] = authToken;
645
+ }}
646
+
647
+ const endpointCandidates = resolvePopupEndpointCandidates(endpoint);
648
+ let response = null;
649
+ let payload = null;
650
+ let lastError = null;
651
+
652
+ for (const candidate of endpointCandidates) {{
653
+ try {{
654
+ const result = await fetchJsonWithTimeout(candidate, {{
655
+ method: 'POST',
656
+ headers,
657
+ body: JSON.stringify({{
658
+ session_id: sessionId,
659
+ row_id: rowId,
660
+ }}),
661
+ }}, 8000);
662
+ response = result.response;
663
+ payload = result.payload;
664
+
665
+ if (!response.ok) {{
666
+ const detail = payload && payload.detail ? String(payload.detail) : 'Falha ao carregar os dados do registro.';
667
+ throw new Error(detail);
668
+ }}
669
+
670
+ break;
671
+ }} catch (error) {{
672
+ lastError = error;
673
+ response = null;
674
+ payload = null;
675
+ }}
676
+ }}
677
+
678
+ if (!response) {{
679
+ throw lastError || new Error('Falha ao carregar os dados do registro.');
680
+ }}
681
+
682
+ const popupHtml = payload && typeof payload.popup_html === 'string'
683
+ ? payload.popup_html
684
+ : buildLazyPopupError('Nenhum dado disponivel para este ponto.');
685
+ const popupWidth = Number(payload && payload.popup_width);
686
+
687
+ let resolvedContentNode = contentNode;
688
+ if (popup && typeof popup.setContent === 'function') {{
689
+ popup.setContent(popupHtml);
690
+ if (popup && popup._contentNode) {{
691
+ resolvedContentNode = popup._contentNode;
692
+ }}
693
+ }} else {{
694
+ contentNode.innerHTML = popupHtml;
695
+ }}
696
+ if (Number.isFinite(popupWidth) && popupWidth > 0) {{
697
+ if (popup && popup.options) {{
698
+ popup.options.maxWidth = popupWidth;
699
+ }}
700
+ }}
701
+ if (popup) {{
702
+ popup.__mesaLazyPopupState = 'loaded';
703
+ }}
704
+
705
+ if (popup && typeof popup.update === 'function') {{
706
+ popup.update();
707
+ }}
708
+
709
+ const root = resolvedContentNode && resolvedContentNode.querySelector
710
+ ? resolvedContentNode.querySelector('[data-pager]')
711
+ : null;
712
+ if (root) {{
713
+ const initialRaw = parseInt(root.dataset.currentPage || '1', 10);
714
+ const initialPage = Number.isFinite(initialRaw) && initialRaw > 0 ? initialRaw : 1;
715
+ updatePager(root, initialPage);
716
+ }}
717
+ }} catch (error) {{
718
+ placeholder.dataset.lazyState = '';
719
+ if (popup) {{
720
+ popup.__mesaLazyPopupState = '';
721
+ }}
722
+ const errorHtml = buildLazyPopupError(error && error.message ? error.message : 'Falha ao carregar os dados do registro.');
723
+ if (popup && typeof popup.setContent === 'function') {{
724
+ popup.setContent(errorHtml);
725
+ }} else {{
726
+ contentNode.innerHTML = errorHtml;
727
+ }}
728
+ if (popup && typeof popup.update === 'function') {{
729
+ popup.update();
730
+ }}
731
+ }}
732
+ }}
733
+
734
+ function scanForLazyPopups(map) {{
735
+ if (!map || typeof map.getContainer !== 'function') return;
736
+ const mapContainer = map.getContainer();
737
+ if (!mapContainer || !mapContainer.ownerDocument) return;
738
+ const doc = mapContainer.ownerDocument;
739
+ const contents = doc.querySelectorAll('.leaflet-popup-content');
740
+ contents.forEach(function(contentNode) {{
741
+ try {{
742
+ if (!contentNode || !contentNode.querySelector) return;
743
+ if (!contentNode.querySelector('[data-mesa-lazy-popup]')) return;
744
+ loadLazyPopupContent(null, contentNode);
745
+ }} catch (_error) {{
746
+ // no-op
747
+ }}
748
+ }});
749
+ }}
750
+
751
  function handleClick(event) {{
752
  const target = event && event.target ? event.target : null;
753
  if (!target || !target.closest) return;
 
797
  const popup = evt && evt.popup ? evt.popup : null;
798
  const contentNode = popup && popup._contentNode ? popup._contentNode : null;
799
  if (!contentNode || !contentNode.querySelector) return;
800
+ loadLazyPopupContent(popup, contentNode);
801
+ window.setTimeout(function() {{ scanForLazyPopups(map); }}, 20);
802
+ }});
803
+
804
+ if (typeof MutationObserver !== 'undefined' && mapContainer) {{
805
+ const observer = new MutationObserver(function() {{
806
+ scanForLazyPopups(map);
807
+ }});
808
+ observer.observe(mapContainer, {{ childList: true, subtree: true }});
809
+ map.__mesaPopupObserver = observer;
810
+ }}
811
+
812
+ map.whenReady(function() {{
813
+ window.setTimeout(function() {{ scanForLazyPopups(map); }}, 40);
814
  }});
815
 
816
  let resizeTimer = null;
backend/app/core/visualizacao/app.py CHANGED
@@ -27,6 +27,7 @@ from app.core.map_layers import (
27
  add_bairros_layer,
28
  add_indice_marker,
29
  add_popup_pagination_handlers,
 
30
  add_zoom_responsive_circle_markers,
31
  )
32
 
@@ -834,10 +835,69 @@ def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
834
  )
835
  return html, popup_largura_px
836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
  # ============================================================
838
  # FUNÇÃO: GERAR MAPA FOLIUM (com suporte a dimensionamento por variável)
839
  # ============================================================
840
- def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None, col_y=None):
 
 
 
 
 
 
 
 
 
 
 
841
  """
842
  Cria mapa Folium com os dados, com suporte a dimensionamento proporcional.
843
 
@@ -972,6 +1032,11 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
972
  tooltip_key = "__mesa_tooltip__"
973
  df_mapa[tooltip_key] = serie_tooltip
974
 
 
 
 
 
 
975
  # Adiciona pontos
976
  for marker_ordem, (idx, row) in enumerate(df_plot_pontos.iterrows()):
977
  # Cor do ponto
@@ -983,31 +1048,32 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
983
  # Calcula raio
984
  if tamanho_func and tamanho_key and pd.notna(row[tamanho_key]):
985
  raio = tamanho_func(row[tamanho_key])
 
986
  else:
987
- raio = 8
988
-
989
- # Popup com informações
990
- itens = []
991
- for col, val in row.items():
992
- col_txt = str(col)
993
- if col_txt.startswith("__mesa_"):
994
- continue
995
- if col_txt.lower() not in ['lat', 'latitude', 'lon', 'longitude']:
996
- col_norm = str(col).lower()
997
- if isinstance(val, (int, float, np.floating)):
998
- if any(k in col_norm for k in ["valor", "preco", "vu", "vunit"]):
999
- val_fmt = formatar_monetario(val)
1000
- else:
1001
- val_fmt = f"{val:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
1002
- else:
1003
- val_fmt = str(val)
1004
- itens.append((col, val_fmt))
1005
 
1006
  # Tooltip (hover): índice + variável selecionada no dropdown (ou dependente como fallback)
1007
  # Usa coluna "index" (original, gerada pelo reset_index) quando disponível
1008
  idx_display = int(row["index"]) if "index" in row.index else idx
1009
  popup_uid = f"mesa-pop-{marker_ordem}"
1010
- popup_html, popup_width = _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1011
  tooltip_html = (
1012
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
1013
  " line-height:1.7; padding:2px 4px;'>"
@@ -1035,10 +1101,10 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
1035
  popup=folium.Popup(popup_html, max_width=popup_width),
1036
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
1037
  color='black',
1038
- weight=1,
1039
  fill=True,
1040
  fillColor=cor,
1041
- fillOpacity=0.7
1042
  ).add_to(m)
1043
  marcador.options["mesaBaseRadius"] = float(max(1.0, raio))
1044
 
@@ -1053,6 +1119,11 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
1053
  if mostrar_indices and camada_indices is not None:
1054
  camada_indices.add_to(m)
1055
 
 
 
 
 
 
1056
  # Controles
1057
  folium.LayerControl().add_to(m)
1058
  plugins.Fullscreen().add_to(m)
 
27
  add_bairros_layer,
28
  add_indice_marker,
29
  add_popup_pagination_handlers,
30
+ add_trabalhos_tecnicos_markers,
31
  add_zoom_responsive_circle_markers,
32
  )
33
 
 
835
  )
836
  return html, popup_largura_px
837
 
838
+
839
+ def _formatar_valor_popup_registro(coluna, valor):
840
+ if valor is None:
841
+ return "—"
842
+ try:
843
+ if pd.isna(valor):
844
+ return "—"
845
+ except Exception:
846
+ pass
847
+
848
+ col_norm = str(coluna).lower()
849
+ if isinstance(valor, (int, float, np.integer, np.floating)):
850
+ numero = float(valor)
851
+ if not np.isfinite(numero):
852
+ return "—"
853
+ if any(k in col_norm for k in ["valor", "preco", "vu", "vunit"]):
854
+ return formatar_monetario(numero)
855
+ return f"{numero:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
856
+ return str(valor)
857
+
858
+
859
+ def montar_popup_registro_html(row, popup_uid, max_itens_pagina=8):
860
+ itens = []
861
+ for col, val in row.items():
862
+ col_txt = str(col)
863
+ if col_txt.startswith("__mesa_"):
864
+ continue
865
+ if col_txt.lower() not in ["lat", "latitude", "lon", "longitude"]:
866
+ itens.append((col, _formatar_valor_popup_registro(col, val)))
867
+ return _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=max_itens_pagina)
868
+
869
+
870
+ def _montar_popup_registro_placeholder(session_id, row_id, popup_uid, popup_endpoint, auth_token=None):
871
+ popup_id = escape(str(popup_uid))
872
+ session_attr = escape(str(session_id))
873
+ row_attr = escape(str(int(row_id)))
874
+ endpoint_attr = escape(str(popup_endpoint or "/api/visualizacao/map/popup"))
875
+ token_attr = escape(str(auth_token or ""))
876
+ html = (
877
+ f"<div id='{popup_id}' data-mesa-lazy-popup='1' data-session-id='{session_attr}' "
878
+ f"data-row-id='{row_attr}' data-popup-endpoint='{endpoint_attr}' data-auth-token='{token_attr}' "
879
+ "style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden; width:340px; max-width:340px;\">"
880
+ "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
881
+ "<div style=\"padding:12px 15px; background:#f8f9fa; color:#6c757d; font-size:12px;\">Carregando detalhes...</div>"
882
+ "</div>"
883
+ )
884
+ return html, 380
885
+
886
  # ============================================================
887
  # FUNÇÃO: GERAR MAPA FOLIUM (com suporte a dimensionamento por variável)
888
  # ============================================================
889
+ def criar_mapa(
890
+ df,
891
+ lat_col="lat",
892
+ lon_col="lon",
893
+ cor_col=None,
894
+ tamanho_col=None,
895
+ col_y=None,
896
+ session_id=None,
897
+ popup_endpoint=None,
898
+ popup_auth_token=None,
899
+ avaliandos_tecnicos=None,
900
+ ):
901
  """
902
  Cria mapa Folium com os dados, com suporte a dimensionamento proporcional.
903
 
 
1032
  tooltip_key = "__mesa_tooltip__"
1033
  df_mapa[tooltip_key] = serie_tooltip
1034
 
1035
+ total_pontos_plot = len(df_plot_pontos)
1036
+ raio_padrao = 4 if total_pontos_plot <= 2500 else 3
1037
+ contorno_padrao = 0.8 if total_pontos_plot <= 2500 else 0.55
1038
+ opacidade_preenchimento = 0.68 if total_pontos_plot <= 2500 else 0.6
1039
+
1040
  # Adiciona pontos
1041
  for marker_ordem, (idx, row) in enumerate(df_plot_pontos.iterrows()):
1042
  # Cor do ponto
 
1048
  # Calcula raio
1049
  if tamanho_func and tamanho_key and pd.notna(row[tamanho_key]):
1050
  raio = tamanho_func(row[tamanho_key])
1051
+ peso_contorno = 1
1052
  else:
1053
+ raio = raio_padrao
1054
+ peso_contorno = contorno_padrao
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1055
 
1056
  # Tooltip (hover): índice + variável selecionada no dropdown (ou dependente como fallback)
1057
  # Usa coluna "index" (original, gerada pelo reset_index) quando disponível
1058
  idx_display = int(row["index"]) if "index" in row.index else idx
1059
  popup_uid = f"mesa-pop-{marker_ordem}"
1060
+ popup_html = None
1061
+ popup_width = None
1062
+ row_id_raw = row["__mesa_row_id__"] if "__mesa_row_id__" in row.index else None
1063
+ if session_id is not None and row_id_raw is not None:
1064
+ try:
1065
+ popup_html, popup_width = _montar_popup_registro_placeholder(
1066
+ session_id=session_id,
1067
+ row_id=int(row_id_raw),
1068
+ popup_uid=popup_uid,
1069
+ popup_endpoint=popup_endpoint,
1070
+ auth_token=popup_auth_token,
1071
+ )
1072
+ except Exception:
1073
+ popup_html = None
1074
+ popup_width = None
1075
+ if popup_html is None or popup_width is None:
1076
+ popup_html, popup_width = montar_popup_registro_html(row, popup_uid, max_itens_pagina=8)
1077
  tooltip_html = (
1078
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
1079
  " line-height:1.7; padding:2px 4px;'>"
 
1101
  popup=folium.Popup(popup_html, max_width=popup_width),
1102
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
1103
  color='black',
1104
+ weight=peso_contorno,
1105
  fill=True,
1106
  fillColor=cor,
1107
+ fillOpacity=opacidade_preenchimento
1108
  ).add_to(m)
1109
  marcador.options["mesaBaseRadius"] = float(max(1.0, raio))
1110
 
 
1119
  if mostrar_indices and camada_indices is not None:
1120
  camada_indices.add_to(m)
1121
 
1122
+ if avaliandos_tecnicos:
1123
+ camada_trabalhos_tecnicos = folium.FeatureGroup(name="Avaliandos que usaram o modelo", show=True)
1124
+ add_trabalhos_tecnicos_markers(camada_trabalhos_tecnicos, avaliandos_tecnicos)
1125
+ camada_trabalhos_tecnicos.add_to(m)
1126
+
1127
  # Controles
1128
  folium.LayerControl().add_to(m)
1129
  plugins.Fullscreen().add_to(m)
backend/app/main.py CHANGED
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
7
  from fastapi.responses import JSONResponse
8
  from fastapi.staticfiles import StaticFiles
9
 
10
- from app.api import auth, elaboracao, health, logs, pesquisa, repositorio, session, visualizacao
11
  from app.services import auth_service
12
 
13
 
@@ -53,6 +53,7 @@ app.include_router(elaboracao.router)
53
  app.include_router(visualizacao.router)
54
  app.include_router(pesquisa.router)
55
  app.include_router(repositorio.router)
 
56
  app.include_router(logs.router)
57
 
58
 
 
7
  from fastapi.responses import JSONResponse
8
  from fastapi.staticfiles import StaticFiles
9
 
10
+ from app.api import auth, elaboracao, health, logs, pesquisa, repositorio, session, trabalhos_tecnicos, visualizacao
11
  from app.services import auth_service
12
 
13
 
 
53
  app.include_router(visualizacao.router)
54
  app.include_router(pesquisa.router)
55
  app.include_router(repositorio.router)
56
+ app.include_router(trabalhos_tecnicos.router)
57
  app.include_router(logs.router)
58
 
59
 
backend/app/services/audit_log_service.py CHANGED
@@ -23,7 +23,7 @@ except Exception: # pragma: no cover
23
 
24
  LOGS_ROOT = "logs"
25
  _TZ_GMT_MINUS_3 = timezone(timedelta(hours=-3), name="GMT-3")
26
- _SCOPE_ALLOWED = {"auth", "repositorio", "elaboracao", "visualizacao", "geral"}
27
  _HF_LOCK = Lock()
28
  _HF_ROOT_READY: dict[str, bool] = {}
29
 
 
23
 
24
  LOGS_ROOT = "logs"
25
  _TZ_GMT_MINUS_3 = timezone(timedelta(hours=-3), name="GMT-3")
26
+ _SCOPE_ALLOWED = {"auth", "repositorio", "trabalhos_tecnicos", "elaboracao", "visualizacao", "geral"}
27
  _HF_LOCK = Lock()
28
  _HF_ROOT_READY: dict[str, bool] = {}
29
 
backend/app/services/pesquisa_service.py CHANGED
@@ -18,8 +18,13 @@ from joblib import load
18
 
19
  from app.core.elaboracao import geocodificacao
20
  from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_observacao_modelo
21
- from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
22
- from app.services import model_repository
 
 
 
 
 
23
  from app.services.serializers import sanitize_value
24
 
25
  AREA_PRIVATIVA_ALIASES = ["APRIV", "APRIVEQ", "ATPRIV", "ACOPRIV", "AREAPRIV", "AREA_PRIVATIVA", "AREA PRIVATIVA"]
@@ -587,6 +592,9 @@ def gerar_mapa_modelos(
587
  cor = MAP_COLORS[idx % len(MAP_COLORS)]
588
  nome = str(resumo.get("nome_modelo") or modelo_id)
589
  distancia_info = _calcular_distancia_geometria_cache(geometria, aval_lat, aval_lon)
 
 
 
590
 
591
  modelos_plotados.append(
592
  {
@@ -598,6 +606,7 @@ def gerar_mapa_modelos(
598
  "geometria": geometria,
599
  "distancia_km": distancia_info.get("distancia_km"),
600
  "distancia_label": distancia_info.get("distancia_label"),
 
601
  }
602
  )
603
  bounds.extend([[ponto["lat"], ponto["lon"]] for ponto in pontos])
@@ -679,18 +688,16 @@ def _renderizar_mapa_modelos(
679
  add_bairros_layer(mapa, show=True)
680
 
681
  mostrar_indices = renderizar_pontos and sum(int(modelo["total_pontos"]) for modelo in modelos_plotados) <= 800
682
- camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
683
- camada_pontos = folium.FeatureGroup(name="Dados de mercado", show=True) if renderizar_pontos else None
684
- camada_poligonos = folium.FeatureGroup(name="Cobertura dos modelos", show=True) if renderizar_cobertura else None
685
  camada_avaliando = folium.FeatureGroup(name="Avaliando", show=True)
686
- camada_distancias = folium.FeatureGroup(name="Distancias", show=False) if renderizar_cobertura else None
687
 
688
  for modelo in modelos_plotados:
689
- tooltip_modelo = modelo["nome"]
690
- if modelo.get("distancia_label"):
691
- tooltip_modelo = f'{tooltip_modelo} • Distancia: {modelo["distancia_label"]}'
692
 
693
- if renderizar_pontos and camada_pontos is not None:
 
 
 
694
  for ponto in modelo["pontos"]:
695
  marcador = folium.CircleMarker(
696
  location=[ponto["lat"], ponto["lon"]],
@@ -702,25 +709,22 @@ def _renderizar_mapa_modelos(
702
  opacity=0.9,
703
  weight=1,
704
  tooltip=tooltip_modelo,
705
- ).add_to(camada_pontos)
706
  marcador.options["mesaBaseRadius"] = 3.0
707
- if mostrar_indices and camada_indices is not None and ponto.get("indice") is not None:
708
  add_indice_marker(
709
- camada_indices,
710
  lat=float(ponto["lat"]),
711
  lon=float(ponto["lon"]),
712
  indice=ponto["indice"],
713
  )
714
 
715
- if renderizar_cobertura and camada_poligonos is not None:
716
- _adicionar_geometria_modelo_no_mapa(camada_poligonos, camada_distancias, modelo, aval_lat, aval_lon)
 
 
 
717
 
718
- if camada_pontos is not None:
719
- camada_pontos.add_to(mapa)
720
- if mostrar_indices and camada_indices is not None:
721
- camada_indices.add_to(mapa)
722
- if camada_poligonos is not None:
723
- camada_poligonos.add_to(mapa)
724
  if aval_lat is not None and aval_lon is not None:
725
  folium.Marker(
726
  location=[aval_lat, aval_lon],
@@ -728,11 +732,10 @@ def _renderizar_mapa_modelos(
728
  icon=folium.Icon(color="red", icon="home", prefix="glyphicon"),
729
  ).add_to(camada_avaliando)
730
  camada_avaliando.add_to(mapa)
731
- if camada_distancias is not None and any(modelo.get("distancia_km") not in (None, "") for modelo in modelos_plotados):
732
- camada_distancias.add_to(mapa)
733
 
734
  plugins.Fullscreen().add_to(mapa)
735
  add_zoom_responsive_circle_markers(mapa)
 
736
  if bounds:
737
  lat_values = [float(coord[0]) for coord in bounds]
738
  lon_values = [float(coord[1]) for coord in bounds]
 
18
 
19
  from app.core.elaboracao import geocodificacao
20
  from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_observacao_modelo
21
+ from app.core.map_layers import (
22
+ add_bairros_layer,
23
+ add_indice_marker,
24
+ add_trabalhos_tecnicos_markers,
25
+ add_zoom_responsive_circle_markers,
26
+ )
27
+ from app.services import model_repository, trabalhos_tecnicos_service
28
  from app.services.serializers import sanitize_value
29
 
30
  AREA_PRIVATIVA_ALIASES = ["APRIV", "APRIVEQ", "ATPRIV", "ACOPRIV", "AREAPRIV", "AREA_PRIVATIVA", "AREA PRIVATIVA"]
 
592
  cor = MAP_COLORS[idx % len(MAP_COLORS)]
593
  nome = str(resumo.get("nome_modelo") or modelo_id)
594
  distancia_info = _calcular_distancia_geometria_cache(geometria, aval_lat, aval_lon)
595
+ avaliandos_tecnicos = trabalhos_tecnicos_service.listar_avaliandos_por_modelo(
596
+ [modelo_id, caminho.name, nome]
597
+ )
598
 
599
  modelos_plotados.append(
600
  {
 
606
  "geometria": geometria,
607
  "distancia_km": distancia_info.get("distancia_km"),
608
  "distancia_label": distancia_info.get("distancia_label"),
609
+ "avaliandos_tecnicos": avaliandos_tecnicos,
610
  }
611
  )
612
  bounds.extend([[ponto["lat"], ponto["lon"]] for ponto in pontos])
 
688
  add_bairros_layer(mapa, show=True)
689
 
690
  mostrar_indices = renderizar_pontos and sum(int(modelo["total_pontos"]) for modelo in modelos_plotados) <= 800
 
 
 
691
  camada_avaliando = folium.FeatureGroup(name="Avaliando", show=True)
 
692
 
693
  for modelo in modelos_plotados:
694
+ nome_layer = str(modelo.get("nome") or modelo.get("id") or "Modelo").strip() or "Modelo"
695
+ camada_modelo = folium.FeatureGroup(name=nome_layer, show=True)
 
696
 
697
+ if renderizar_pontos:
698
+ tooltip_modelo = nome_layer
699
+ if modelo.get("distancia_label"):
700
+ tooltip_modelo = f'{tooltip_modelo} • Distancia: {modelo["distancia_label"]}'
701
  for ponto in modelo["pontos"]:
702
  marcador = folium.CircleMarker(
703
  location=[ponto["lat"], ponto["lon"]],
 
709
  opacity=0.9,
710
  weight=1,
711
  tooltip=tooltip_modelo,
712
+ ).add_to(camada_modelo)
713
  marcador.options["mesaBaseRadius"] = 3.0
714
+ if mostrar_indices and ponto.get("indice") is not None:
715
  add_indice_marker(
716
+ camada_modelo,
717
  lat=float(ponto["lat"]),
718
  lon=float(ponto["lon"]),
719
  indice=ponto["indice"],
720
  )
721
 
722
+ if renderizar_cobertura:
723
+ _adicionar_geometria_modelo_no_mapa(camada_modelo, camada_modelo, modelo, aval_lat, aval_lon)
724
+
725
+ add_trabalhos_tecnicos_markers(camada_modelo, modelo.get("avaliandos_tecnicos") or [])
726
+ camada_modelo.add_to(mapa)
727
 
 
 
 
 
 
 
728
  if aval_lat is not None and aval_lon is not None:
729
  folium.Marker(
730
  location=[aval_lat, aval_lon],
 
732
  icon=folium.Icon(color="red", icon="home", prefix="glyphicon"),
733
  ).add_to(camada_avaliando)
734
  camada_avaliando.add_to(mapa)
 
 
735
 
736
  plugins.Fullscreen().add_to(mapa)
737
  add_zoom_responsive_circle_markers(mapa)
738
+ folium.LayerControl(collapsed=False).add_to(mapa)
739
  if bounds:
740
  lat_values = [float(coord[0]) for coord in bounds]
741
  lon_values = [float(coord[1]) for coord in bounds]
backend/app/services/trabalhos_tecnicos_importer.py ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sqlite3
5
+ import unicodedata
6
+ import zipfile
7
+ import xml.etree.ElementTree as ET
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ DEFAULT_SOURCE_XLSX_FILE = "dados_geocodificados_limpos_v2.xlsx"
15
+ DEFAULT_LOCAL_DB_FILE = "trabalhos_tecnicos.sqlite3"
16
+ COLUNAS_ESPERADAS = ["ANO", "AVALIANDO", "MODELO", "ENDERECO", "NUM", "x", "y"]
17
+ TIPO_LABELS = {
18
+ "LA": "Laudo de Avaliacao",
19
+ "PT": "Parecer Tecnico",
20
+ "IT": "Informacao Tecnica",
21
+ "PTF": "Parecer Tecnico Fundamentado",
22
+ "PIV": "Parecer Indicativo de Valor",
23
+ }
24
+ LOGRADOURO_ABREV = {
25
+ "RUA": "R",
26
+ "R": "R",
27
+ "AVENIDA": "AV",
28
+ "AV": "AV",
29
+ "ESTRADA": "ESTR",
30
+ "ESTR": "ESTR",
31
+ "RODOVIA": "ROD",
32
+ "ROD": "ROD",
33
+ "ALAMEDA": "AL",
34
+ "AL": "AL",
35
+ "TRAVESSA": "TV",
36
+ "TV": "TV",
37
+ "LARGO": "LGO",
38
+ "PRACA": "PRACA",
39
+ "PRAÇA": "PRACA",
40
+ }
41
+ XLSX_NS = {
42
+ "a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
43
+ "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class TrabalhoRawGroup:
49
+ raw_name: str
50
+ ano: int | None = None
51
+ endereco_base: str = ""
52
+ numero_base: str = ""
53
+ first_row_number: int = 0
54
+ modelos: dict[str, int] = field(default_factory=dict)
55
+ imoveis: dict[tuple[str, str, str, str], dict[str, Any]] = field(default_factory=dict)
56
+ registros: list[dict[str, Any]] = field(default_factory=list)
57
+ clean_name: str = ""
58
+
59
+
60
+ def _clean_text(value: Any) -> str:
61
+ if value is None:
62
+ return ""
63
+ text = str(value).strip()
64
+ if text.endswith(".0"):
65
+ try:
66
+ return str(int(float(text)))
67
+ except Exception:
68
+ return text
69
+ return text
70
+
71
+
72
+ def _to_int(value: Any) -> int | None:
73
+ text = _clean_text(value)
74
+ if not text:
75
+ return None
76
+ try:
77
+ return int(float(text))
78
+ except Exception:
79
+ return None
80
+
81
+
82
+ def _to_float(value: Any) -> float | None:
83
+ text = _clean_text(value)
84
+ if not text:
85
+ return None
86
+ try:
87
+ return float(text)
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def _strip_accents(value: str) -> str:
93
+ normalized = unicodedata.normalize("NFKD", value)
94
+ return "".join(ch for ch in normalized if not unicodedata.combining(ch))
95
+
96
+
97
+ def _slugify(value: Any) -> str:
98
+ text = _clean_text(value)
99
+ if not text:
100
+ return ""
101
+ text = _strip_accents(text.upper()).replace("�", "")
102
+ text = re.sub(r"[^A-Z0-9]+", "_", text)
103
+ text = re.sub(r"_+", "_", text)
104
+ return text.strip("_")
105
+
106
+
107
+ def _address_slug(endereco: str) -> str:
108
+ slug = _slugify(endereco)
109
+ if not slug:
110
+ return ""
111
+ tokens = [token for token in slug.split("_") if token]
112
+ if not tokens:
113
+ return ""
114
+ tokens[0] = LOGRADOURO_ABREV.get(tokens[0], tokens[0])
115
+ return "_".join(tokens)
116
+
117
+
118
+ def _extract_prefix_tokens(raw_name: str) -> list[str]:
119
+ tokens = [token for token in _clean_text(raw_name).split("_") if token]
120
+ if not tokens:
121
+ return []
122
+ prefix: list[str] = [_slugify(tokens[0])]
123
+ if len(tokens) >= 2 and tokens[1].isdigit():
124
+ prefix.append(tokens[1])
125
+ if len(tokens) >= 3 and re.fullmatch(r"\d{4}", tokens[2] or ""):
126
+ prefix.append(tokens[2])
127
+ return [token for token in prefix if token]
128
+
129
+
130
+ def build_clean_trabalho_name(raw_name: str, endereco: str, numero: str) -> str:
131
+ prefix_tokens = _extract_prefix_tokens(raw_name)
132
+ address_slug = _address_slug(endereco)
133
+ number_slug = _slugify(numero)
134
+
135
+ parts = prefix_tokens[:]
136
+ if address_slug:
137
+ parts.append(address_slug)
138
+ if number_slug:
139
+ parts.append(number_slug)
140
+
141
+ if parts:
142
+ return "_".join(parts)
143
+ return _slugify(raw_name)
144
+
145
+
146
+ def _tipo_codigo_from_name(nome: str) -> str:
147
+ tokens = [token for token in _clean_text(nome).split("_") if token]
148
+ if not tokens:
149
+ return ""
150
+ return _slugify(tokens[0])
151
+
152
+
153
+ def _tipo_label(codigo: str) -> str:
154
+ return TIPO_LABELS.get(_clean_text(codigo).upper(), _clean_text(codigo).upper() or "Nao identificado")
155
+
156
+
157
+ def _col_ref_to_index(ref: str) -> int:
158
+ letters = "".join(ch for ch in str(ref or "") if ch.isalpha())
159
+ index = 0
160
+ for char in letters:
161
+ index = (index * 26) + (ord(char.upper()) - 64)
162
+ return max(0, index - 1)
163
+
164
+
165
+ def _shared_strings(zf: zipfile.ZipFile) -> list[str]:
166
+ if "xl/sharedStrings.xml" not in zf.namelist():
167
+ return []
168
+ root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
169
+ values: list[str] = []
170
+ for si in root.findall("a:si", XLSX_NS):
171
+ values.append("".join((node.text or "") for node in si.findall(".//a:t", XLSX_NS)))
172
+ return values
173
+
174
+
175
+ def read_xlsx_rows(path: str | Path) -> tuple[str, list[dict[str, Any]]]:
176
+ workbook_path = Path(path).expanduser().resolve()
177
+ with zipfile.ZipFile(workbook_path) as zf:
178
+ shared = _shared_strings(zf)
179
+ workbook = ET.fromstring(zf.read("xl/workbook.xml"))
180
+ rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
181
+ rel_map = {rel.attrib["Id"]: rel.attrib["Target"] for rel in rels}
182
+ sheet = workbook.find("a:sheets", XLSX_NS)[0]
183
+ sheet_name = str(sheet.attrib.get("name") or "Sheet1")
184
+ rid = sheet.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
185
+ worksheet = ET.fromstring(zf.read(f"xl/{rel_map[rid]}"))
186
+ rows = worksheet.findall(".//a:sheetData/a:row", XLSX_NS)
187
+
188
+ headers: list[str] = []
189
+ records: list[dict[str, Any]] = []
190
+ for row in rows:
191
+ values_by_index: dict[int, str] = {}
192
+ for cell in row.findall("a:c", XLSX_NS):
193
+ idx = _col_ref_to_index(cell.attrib.get("r", ""))
194
+ kind = cell.attrib.get("t")
195
+ raw_value = ""
196
+ value_node = cell.find("a:v", XLSX_NS)
197
+ if value_node is not None:
198
+ raw_value = value_node.text or ""
199
+ if kind == "s":
200
+ raw_value = shared[int(raw_value)] if raw_value.isdigit() and int(raw_value) < len(shared) else raw_value
201
+ elif kind == "inlineStr":
202
+ inline_node = cell.find("a:is", XLSX_NS)
203
+ if inline_node is not None:
204
+ raw_value = "".join((node.text or "") for node in inline_node.findall(".//a:t", XLSX_NS))
205
+ values_by_index[idx] = raw_value
206
+
207
+ if not headers:
208
+ headers = [values_by_index.get(idx, "") for idx in range(max(values_by_index.keys()) + 1)]
209
+ continue
210
+
211
+ record = {headers[idx]: values_by_index.get(idx, "") for idx in range(len(headers))}
212
+ records.append(record)
213
+
214
+ return sheet_name, records
215
+
216
+
217
+ def _build_groups(records: list[dict[str, Any]]) -> tuple[list[TrabalhoRawGroup], int]:
218
+ missing_columns = [column for column in COLUNAS_ESPERADAS if not records or column not in records[0]]
219
+ if missing_columns:
220
+ raise ValueError(f"Planilha sem colunas obrigatorias: {', '.join(missing_columns)}")
221
+
222
+ groups: dict[str, TrabalhoRawGroup] = {}
223
+ invalid_rows = 0
224
+
225
+ for offset, record in enumerate(records, start=2):
226
+ raw_name = _clean_text(record.get("AVALIANDO"))
227
+ if not raw_name:
228
+ invalid_rows += 1
229
+ continue
230
+
231
+ group = groups.get(raw_name)
232
+ if group is None:
233
+ group = TrabalhoRawGroup(
234
+ raw_name=raw_name,
235
+ ano=_to_int(record.get("ANO")),
236
+ endereco_base=_clean_text(record.get("ENDERECO")),
237
+ numero_base=_clean_text(record.get("NUM")),
238
+ first_row_number=offset,
239
+ )
240
+ groups[raw_name] = group
241
+ else:
242
+ if group.ano is None:
243
+ group.ano = _to_int(record.get("ANO"))
244
+ if not group.endereco_base:
245
+ group.endereco_base = _clean_text(record.get("ENDERECO"))
246
+ if not group.numero_base:
247
+ group.numero_base = _clean_text(record.get("NUM"))
248
+
249
+ modelo_nome = _clean_text(record.get("MODELO"))
250
+ endereco = _clean_text(record.get("ENDERECO"))
251
+ numero = _clean_text(record.get("NUM"))
252
+ coord_x = _to_float(record.get("x"))
253
+ coord_y = _to_float(record.get("y"))
254
+
255
+ if modelo_nome:
256
+ group.modelos.setdefault(modelo_nome, len(group.modelos) + 1)
257
+
258
+ imovel_key = (
259
+ _slugify(endereco),
260
+ _slugify(numero),
261
+ "" if coord_x is None else f"{coord_x:.8f}",
262
+ "" if coord_y is None else f"{coord_y:.8f}",
263
+ )
264
+ imovel = group.imoveis.get(imovel_key)
265
+ if imovel is None:
266
+ imovel = {
267
+ "endereco": endereco,
268
+ "numero": numero or "S/N",
269
+ "label": f"{endereco}, {numero or 'S/N'}" if endereco else (numero or "S/N"),
270
+ "coord_x": coord_x,
271
+ "coord_y": coord_y,
272
+ "modelos": [],
273
+ }
274
+ group.imoveis[imovel_key] = imovel
275
+ if modelo_nome and modelo_nome not in imovel["modelos"]:
276
+ imovel["modelos"].append(modelo_nome)
277
+
278
+ group.registros.append(
279
+ {
280
+ "source_row": offset,
281
+ "ano": _to_int(record.get("ANO")),
282
+ "nome_original": raw_name,
283
+ "modelo_nome": modelo_nome,
284
+ "endereco": endereco,
285
+ "numero": numero or "S/N",
286
+ "coord_x": coord_x,
287
+ "coord_y": coord_y,
288
+ }
289
+ )
290
+
291
+ ordered_groups = sorted(groups.values(), key=lambda item: item.first_row_number)
292
+
293
+ used_names: dict[str, str] = {}
294
+ for group in ordered_groups:
295
+ base_name = build_clean_trabalho_name(group.raw_name, group.endereco_base, group.numero_base)
296
+ if not base_name:
297
+ base_name = _slugify(group.raw_name) or f"TRABALHO_{group.first_row_number}"
298
+ candidate = base_name
299
+ suffix = 2
300
+ while candidate in used_names and used_names[candidate] != group.raw_name:
301
+ candidate = f"{base_name}__{suffix}"
302
+ suffix += 1
303
+ used_names[candidate] = group.raw_name
304
+ group.clean_name = candidate
305
+
306
+ return ordered_groups, invalid_rows
307
+
308
+
309
+ def build_database_from_xlsx(xlsx_path: str | Path, db_path: str | Path) -> dict[str, Any]:
310
+ source_path = Path(xlsx_path).expanduser().resolve()
311
+ target_path = Path(db_path).expanduser().resolve()
312
+
313
+ sheet_name, records = read_xlsx_rows(source_path)
314
+ groups, invalid_rows = _build_groups(records)
315
+
316
+ target_path.parent.mkdir(parents=True, exist_ok=True)
317
+ if target_path.exists():
318
+ target_path.unlink()
319
+
320
+ conn = sqlite3.connect(str(target_path))
321
+ try:
322
+ conn.executescript(
323
+ """
324
+ PRAGMA journal_mode = DELETE;
325
+ PRAGMA foreign_keys = ON;
326
+
327
+ CREATE TABLE meta (
328
+ key TEXT PRIMARY KEY,
329
+ value TEXT NOT NULL
330
+ );
331
+
332
+ CREATE TABLE trabalhos (
333
+ trabalho_id TEXT PRIMARY KEY,
334
+ nome TEXT NOT NULL,
335
+ nome_original TEXT NOT NULL,
336
+ tipo_codigo TEXT NOT NULL,
337
+ tipo_label TEXT NOT NULL,
338
+ ano INTEGER,
339
+ endereco_principal TEXT,
340
+ numero_principal TEXT,
341
+ endereco_resumo TEXT,
342
+ modelo_resumo TEXT,
343
+ total_registros INTEGER NOT NULL,
344
+ total_imoveis INTEGER NOT NULL,
345
+ total_modelos INTEGER NOT NULL,
346
+ tem_coordenadas INTEGER NOT NULL DEFAULT 0
347
+ );
348
+
349
+ CREATE TABLE trabalho_modelos (
350
+ trabalho_id TEXT NOT NULL,
351
+ modelo_nome TEXT NOT NULL,
352
+ ordem INTEGER NOT NULL,
353
+ PRIMARY KEY (trabalho_id, modelo_nome)
354
+ );
355
+
356
+ CREATE TABLE trabalho_imoveis (
357
+ imovel_id INTEGER PRIMARY KEY AUTOINCREMENT,
358
+ trabalho_id TEXT NOT NULL,
359
+ endereco TEXT,
360
+ numero TEXT,
361
+ label TEXT NOT NULL,
362
+ coord_x REAL,
363
+ coord_y REAL
364
+ );
365
+
366
+ CREATE TABLE trabalho_imovel_modelos (
367
+ imovel_id INTEGER NOT NULL,
368
+ modelo_nome TEXT NOT NULL,
369
+ PRIMARY KEY (imovel_id, modelo_nome)
370
+ );
371
+
372
+ CREATE TABLE trabalho_registros (
373
+ registro_id INTEGER PRIMARY KEY AUTOINCREMENT,
374
+ trabalho_id TEXT NOT NULL,
375
+ source_row INTEGER NOT NULL,
376
+ ano INTEGER,
377
+ nome_original TEXT NOT NULL,
378
+ modelo_nome TEXT,
379
+ endereco TEXT,
380
+ numero TEXT,
381
+ coord_x REAL,
382
+ coord_y REAL
383
+ );
384
+
385
+ CREATE INDEX idx_trabalhos_ano ON trabalhos (ano);
386
+ CREATE INDEX idx_trabalho_modelos_trabalho ON trabalho_modelos (trabalho_id);
387
+ CREATE INDEX idx_trabalho_imoveis_trabalho ON trabalho_imoveis (trabalho_id);
388
+ CREATE INDEX idx_trabalho_registros_trabalho ON trabalho_registros (trabalho_id);
389
+ """
390
+ )
391
+
392
+ imported_at = datetime.now(timezone.utc).isoformat()
393
+ meta_entries = {
394
+ "source_xlsx_path": str(source_path),
395
+ "source_xlsx_name": source_path.name,
396
+ "source_sheet_name": sheet_name,
397
+ "source_row_count": str(len(records)),
398
+ "invalid_row_count": str(invalid_rows),
399
+ "total_trabalhos": str(len(groups)),
400
+ "generated_at_utc": imported_at,
401
+ "generator_version": "sqlite-v1",
402
+ }
403
+ conn.executemany(
404
+ "INSERT INTO meta (key, value) VALUES (?, ?)",
405
+ list(meta_entries.items()),
406
+ )
407
+
408
+ for group in groups:
409
+ tipo_codigo = _tipo_codigo_from_name(group.clean_name)
410
+ modelos_ordenados = sorted(group.modelos.items(), key=lambda item: (item[1], item[0].lower()))
411
+ imoveis_ordenados = list(group.imoveis.values())
412
+ endereco_resumo = imoveis_ordenados[0]["label"] if imoveis_ordenados else "Endereco nao informado"
413
+ if len(imoveis_ordenados) > 1:
414
+ endereco_resumo = f"{endereco_resumo} (+{len(imoveis_ordenados) - 1})"
415
+ modelo_resumo = "Sem modelo informado"
416
+ if len(modelos_ordenados) == 1:
417
+ modelo_resumo = modelos_ordenados[0][0]
418
+ elif len(modelos_ordenados) > 1:
419
+ modelo_resumo = f"{len(modelos_ordenados)} modelos vinculados"
420
+
421
+ conn.execute(
422
+ """
423
+ INSERT INTO trabalhos (
424
+ trabalho_id,
425
+ nome,
426
+ nome_original,
427
+ tipo_codigo,
428
+ tipo_label,
429
+ ano,
430
+ endereco_principal,
431
+ numero_principal,
432
+ endereco_resumo,
433
+ modelo_resumo,
434
+ total_registros,
435
+ total_imoveis,
436
+ total_modelos,
437
+ tem_coordenadas
438
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
439
+ """,
440
+ (
441
+ group.clean_name,
442
+ group.clean_name,
443
+ group.raw_name,
444
+ tipo_codigo,
445
+ _tipo_label(tipo_codigo),
446
+ group.ano,
447
+ group.endereco_base,
448
+ group.numero_base or "S/N",
449
+ endereco_resumo,
450
+ modelo_resumo,
451
+ len(group.registros),
452
+ len(imoveis_ordenados),
453
+ len(modelos_ordenados),
454
+ 1 if any(item.get("coord_x") is not None and item.get("coord_y") is not None for item in imoveis_ordenados) else 0,
455
+ ),
456
+ )
457
+
458
+ for modelo_nome, ordem in modelos_ordenados:
459
+ conn.execute(
460
+ "INSERT INTO trabalho_modelos (trabalho_id, modelo_nome, ordem) VALUES (?, ?, ?)",
461
+ (group.clean_name, modelo_nome, ordem),
462
+ )
463
+
464
+ for imovel in imoveis_ordenados:
465
+ cursor = conn.execute(
466
+ """
467
+ INSERT INTO trabalho_imoveis (trabalho_id, endereco, numero, label, coord_x, coord_y)
468
+ VALUES (?, ?, ?, ?, ?, ?)
469
+ """,
470
+ (
471
+ group.clean_name,
472
+ imovel.get("endereco"),
473
+ imovel.get("numero"),
474
+ imovel.get("label"),
475
+ imovel.get("coord_x"),
476
+ imovel.get("coord_y"),
477
+ ),
478
+ )
479
+ imovel_id = int(cursor.lastrowid)
480
+ for modelo_nome in imovel.get("modelos") or []:
481
+ conn.execute(
482
+ "INSERT INTO trabalho_imovel_modelos (imovel_id, modelo_nome) VALUES (?, ?)",
483
+ (imovel_id, modelo_nome),
484
+ )
485
+
486
+ for registro in group.registros:
487
+ conn.execute(
488
+ """
489
+ INSERT INTO trabalho_registros (
490
+ trabalho_id,
491
+ source_row,
492
+ ano,
493
+ nome_original,
494
+ modelo_nome,
495
+ endereco,
496
+ numero,
497
+ coord_x,
498
+ coord_y
499
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
500
+ """,
501
+ (
502
+ group.clean_name,
503
+ registro["source_row"],
504
+ registro["ano"],
505
+ registro["nome_original"],
506
+ registro["modelo_nome"],
507
+ registro["endereco"],
508
+ registro["numero"],
509
+ registro["coord_x"],
510
+ registro["coord_y"],
511
+ ),
512
+ )
513
+
514
+ conn.commit()
515
+ finally:
516
+ conn.close()
517
+
518
+ corrected_names = sum(1 for group in groups if group.clean_name != group.raw_name)
519
+ return {
520
+ "db_path": str(target_path),
521
+ "source_xlsx_path": str(source_path),
522
+ "source_sheet_name": sheet_name,
523
+ "source_row_count": len(records),
524
+ "invalid_row_count": invalid_rows,
525
+ "total_trabalhos": len(groups),
526
+ "corrected_names": corrected_names,
527
+ }
backend/app/services/trabalhos_tecnicos_repository.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from fastapi import HTTPException
8
+
9
+ try:
10
+ from huggingface_hub import HfApi, hf_hub_download
11
+ except Exception: # pragma: no cover
12
+ HfApi = None # type: ignore[assignment]
13
+ hf_hub_download = None # type: ignore[assignment]
14
+
15
+ from app.services.trabalhos_tecnicos_importer import DEFAULT_LOCAL_DB_FILE
16
+
17
+
18
+ DEFAULT_HF_REPO_ID = "gui-sparim/repositorio_mesa"
19
+ DEFAULT_HF_REVISION = "main"
20
+ DEFAULT_HF_DB_PATH = f"trabalhos_tecnicos/{DEFAULT_LOCAL_DB_FILE}"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TrabalhosTecnicosDbResolution:
25
+ provider: str
26
+ db_path: Path
27
+ revision: str | None = None
28
+ repo_id: str | None = None
29
+ path_in_repo: str | None = None
30
+
31
+ def as_payload(self) -> dict[str, str | None]:
32
+ return {
33
+ "provider": self.provider,
34
+ "db_path": str(self.db_path),
35
+ "revision": self.revision,
36
+ "repo_id": self.repo_id,
37
+ "path_in_repo": self.path_in_repo,
38
+ }
39
+
40
+
41
+ def _is_hf_runtime() -> bool:
42
+ for key in ("SPACE_ID", "SPACE_AUTHOR_NAME", "HF_SPACE_ID"):
43
+ if str(os.getenv(key) or "").strip():
44
+ return True
45
+ return False
46
+
47
+
48
+ def _provider() -> str:
49
+ raw = str(os.getenv("TRABALHOS_TECNICOS_PROVIDER") or "auto").strip().lower()
50
+ if raw in {"local", "file"}:
51
+ return "local"
52
+ if raw in {"hf", "hf_dataset", "dataset", "huggingface"}:
53
+ return "hf_dataset"
54
+ return "hf_dataset" if _is_hf_runtime() else "local"
55
+
56
+
57
+ def _local_db_path() -> Path:
58
+ raw = str(os.getenv("TRABALHOS_TECNICOS_DB_LOCAL_PATH") or "").strip()
59
+ if raw:
60
+ return Path(raw).expanduser().resolve()
61
+ return (Path(__file__).resolve().parents[2] / "local_data" / DEFAULT_LOCAL_DB_FILE).resolve()
62
+
63
+
64
+ def _hf_repo_id() -> str:
65
+ return str(
66
+ os.getenv("TRABALHOS_TECNICOS_HF_REPO_ID")
67
+ or os.getenv("MODELOS_REPOSITORIO_HF_REPO_ID")
68
+ or DEFAULT_HF_REPO_ID
69
+ ).strip()
70
+
71
+
72
+ def _hf_revision() -> str:
73
+ return str(
74
+ os.getenv("TRABALHOS_TECNICOS_HF_REVISION")
75
+ or os.getenv("MODELOS_REPOSITORIO_HF_REVISION")
76
+ or DEFAULT_HF_REVISION
77
+ ).strip()
78
+
79
+
80
+ def _hf_path_in_repo() -> str:
81
+ return str(os.getenv("TRABALHOS_TECNICOS_HF_PATH") or DEFAULT_HF_DB_PATH).strip().strip("/")
82
+
83
+
84
+ def _hf_token() -> str | None:
85
+ for key in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HUGGINGFACE_TOKEN"):
86
+ value = os.getenv(key)
87
+ if value:
88
+ return value
89
+ return None
90
+
91
+
92
+ def resolve_database() -> TrabalhosTecnicosDbResolution:
93
+ provider = _provider()
94
+ if provider == "local":
95
+ path = _local_db_path()
96
+ if not path.exists():
97
+ raise HTTPException(
98
+ status_code=404,
99
+ detail=(
100
+ "Banco local de trabalhos tecnicos nao encontrado. "
101
+ "Gere-o com o script de importacao antes de abrir a aba."
102
+ ),
103
+ )
104
+ return TrabalhosTecnicosDbResolution(provider="local", db_path=path)
105
+
106
+ if HfApi is None or hf_hub_download is None:
107
+ raise HTTPException(status_code=500, detail="huggingface_hub nao disponivel no backend")
108
+
109
+ repo_id = _hf_repo_id()
110
+ revision_ref = _hf_revision()
111
+ path_in_repo = _hf_path_in_repo()
112
+ token = _hf_token()
113
+ api = HfApi(token=token)
114
+ try:
115
+ info = api.dataset_info(repo_id=repo_id, revision=revision_ref, token=token)
116
+ revision = str(getattr(info, "sha", "") or "").strip() or revision_ref
117
+ local_path = Path(
118
+ hf_hub_download(
119
+ repo_id=repo_id,
120
+ repo_type="dataset",
121
+ revision=revision,
122
+ filename=path_in_repo,
123
+ token=token,
124
+ )
125
+ )
126
+ except Exception as exc:
127
+ raise HTTPException(
128
+ status_code=503,
129
+ detail=f"Nao foi possivel carregar o banco de trabalhos tecnicos do dataset HF: {exc}",
130
+ ) from exc
131
+
132
+ return TrabalhosTecnicosDbResolution(
133
+ provider="hf_dataset",
134
+ db_path=local_path,
135
+ revision=revision,
136
+ repo_id=repo_id,
137
+ path_in_repo=path_in_repo,
138
+ )
backend/app/services/trabalhos_tecnicos_service.py ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from statistics import median
7
+ from typing import Any
8
+
9
+ import folium
10
+ from fastapi import HTTPException
11
+ from folium import plugins
12
+
13
+ from app.core.map_layers import add_bairros_layer, add_popup_pagination_handlers, add_zoom_responsive_circle_markers
14
+ from app.services import model_repository, trabalhos_tecnicos_repository
15
+ from app.services.serializers import sanitize_value
16
+
17
+
18
+ def _connect_database(path: Path) -> sqlite3.Connection:
19
+ conn = sqlite3.connect(str(path))
20
+ conn.row_factory = sqlite3.Row
21
+ return conn
22
+
23
+
24
+ def _fetch_meta(conn: sqlite3.Connection) -> dict[str, str]:
25
+ rows = conn.execute("SELECT key, value FROM meta").fetchall()
26
+ return {str(row["key"]): str(row["value"]) for row in rows}
27
+
28
+
29
+ def _table_payload(records: list[dict[str, Any]], columns: list[str]) -> dict[str, Any]:
30
+ rows: list[dict[str, Any]] = []
31
+ for index, record in enumerate(records, start=1):
32
+ payload_row = {"_index": index}
33
+ for column in columns:
34
+ payload_row[column] = sanitize_value(record.get(column))
35
+ rows.append(payload_row)
36
+ return {
37
+ "columns": ["_index"] + columns,
38
+ "rows": rows,
39
+ "total_rows": len(records),
40
+ "returned_rows": len(records),
41
+ "truncated": False,
42
+ }
43
+
44
+
45
+ def _source_payload(
46
+ resolved: trabalhos_tecnicos_repository.TrabalhosTecnicosDbResolution,
47
+ meta: dict[str, str],
48
+ ) -> dict[str, Any]:
49
+ return sanitize_value(
50
+ {
51
+ "provider": resolved.provider,
52
+ "arquivo": str(resolved.db_path),
53
+ "arquivo_nome": Path(resolved.path_in_repo or resolved.db_path.name).name,
54
+ "repo_id": resolved.repo_id,
55
+ "revision": resolved.revision,
56
+ "path_in_repo": resolved.path_in_repo,
57
+ "source_xlsx_name": meta.get("source_xlsx_name"),
58
+ "source_sheet_name": meta.get("source_sheet_name"),
59
+ "source_row_count": int(meta.get("source_row_count") or 0),
60
+ "generated_at_utc": meta.get("generated_at_utc"),
61
+ }
62
+ )
63
+
64
+
65
+ def _catalogo_modelos_mesa() -> dict[str, dict[str, str]]:
66
+ try:
67
+ payload = model_repository.list_repository_models()
68
+ except Exception:
69
+ return {}
70
+
71
+ itens = payload.get("modelos") if isinstance(payload, dict) else []
72
+ if not isinstance(itens, list):
73
+ return {}
74
+
75
+ catalogo: dict[str, dict[str, str]] = {}
76
+ for item in itens:
77
+ if not isinstance(item, dict):
78
+ continue
79
+ modelo_id = str(item.get("id") or "").strip()
80
+ arquivo = str(item.get("arquivo") or "").strip()
81
+ nome_modelo = str(item.get("nome_modelo") or modelo_id or arquivo).strip()
82
+ if not modelo_id and not arquivo:
83
+ continue
84
+ registro = {
85
+ "id": modelo_id or Path(arquivo).stem,
86
+ "arquivo": arquivo or f"{modelo_id}.dai",
87
+ "nome_modelo": nome_modelo,
88
+ }
89
+ for key in {modelo_id, arquivo, Path(arquivo).stem if arquivo else "", nome_modelo}:
90
+ key_norm = str(key or "").strip().casefold()
91
+ if key_norm:
92
+ catalogo[key_norm] = registro
93
+ return catalogo
94
+
95
+
96
+ def _registrar_chaves_modelo(destino: set[str], *values: Any) -> None:
97
+ for value in values:
98
+ texto = str(value or "").strip()
99
+ if not texto:
100
+ continue
101
+ destino.add(texto.casefold())
102
+ if texto.lower().endswith(".dai"):
103
+ stem = Path(texto).stem.strip()
104
+ if stem:
105
+ destino.add(stem.casefold())
106
+
107
+
108
+ def _expandir_chaves_modelo(values: list[str], catalogo: dict[str, dict[str, str]]) -> set[str]:
109
+ chaves: set[str] = set()
110
+ for value in values:
111
+ _registrar_chaves_modelo(chaves, value)
112
+
113
+ registros = [catalogo.get(chave) for chave in list(chaves)]
114
+ for registro in registros:
115
+ if not registro:
116
+ continue
117
+ _registrar_chaves_modelo(
118
+ chaves,
119
+ registro.get("id"),
120
+ registro.get("arquivo"),
121
+ registro.get("nome_modelo"),
122
+ )
123
+ return chaves
124
+
125
+
126
+ def _enriquecer_modelos(modelo_nomes: list[str], catalogo: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
127
+ enriched: list[dict[str, Any]] = []
128
+ for nome in modelo_nomes:
129
+ key = str(nome or "").strip().casefold()
130
+ mesa = catalogo.get(key)
131
+ enriched.append(
132
+ {
133
+ "nome": nome,
134
+ "disponivel_mesa": bool(mesa),
135
+ "mesa_modelo_id": mesa.get("id") if mesa else None,
136
+ "mesa_modelo_arquivo": mesa.get("arquivo") if mesa else None,
137
+ "mesa_modelo_nome": mesa.get("nome_modelo") if mesa else None,
138
+ }
139
+ )
140
+ return enriched
141
+
142
+
143
+ def _coordenada_valida(value: Any) -> bool:
144
+ if value is None:
145
+ return False
146
+ try:
147
+ number = float(value)
148
+ except Exception:
149
+ return False
150
+ return math.isfinite(number)
151
+
152
+
153
+ def listar_avaliandos_por_modelo(chaves_modelo: list[str]) -> list[dict[str, Any]]:
154
+ aliases = [str(item or "").strip() for item in (chaves_modelo or []) if str(item or "").strip()]
155
+ if not aliases:
156
+ return []
157
+
158
+ try:
159
+ resolved = trabalhos_tecnicos_repository.resolve_database()
160
+ catalogo_modelos = _catalogo_modelos_mesa()
161
+ except Exception:
162
+ return []
163
+
164
+ chaves_selecionadas = _expandir_chaves_modelo(aliases, catalogo_modelos)
165
+ if not chaves_selecionadas:
166
+ return []
167
+
168
+ conn = _connect_database(resolved.db_path)
169
+ try:
170
+ rows = conn.execute(
171
+ """
172
+ SELECT
173
+ t.trabalho_id,
174
+ t.nome AS trabalho_nome,
175
+ t.tipo_label,
176
+ ti.imovel_id,
177
+ ti.label,
178
+ ti.endereco,
179
+ ti.numero,
180
+ ti.coord_x,
181
+ ti.coord_y,
182
+ tim.modelo_nome
183
+ FROM trabalho_imoveis ti
184
+ JOIN trabalhos t
185
+ ON t.trabalho_id = ti.trabalho_id
186
+ JOIN trabalho_imovel_modelos tim
187
+ ON tim.imovel_id = ti.imovel_id
188
+ ORDER BY LOWER(t.nome), ti.imovel_id, LOWER(tim.modelo_nome)
189
+ """
190
+ ).fetchall()
191
+ finally:
192
+ try:
193
+ conn.close()
194
+ except Exception:
195
+ pass
196
+ if not rows:
197
+ return []
198
+
199
+ agregados: dict[tuple[str, int], dict[str, Any]] = {}
200
+ for row in rows:
201
+ coord_x = row["coord_x"]
202
+ coord_y = row["coord_y"]
203
+ if not _coordenada_valida(coord_x) or not _coordenada_valida(coord_y):
204
+ continue
205
+
206
+ chaves_linha = _expandir_chaves_modelo([str(row["modelo_nome"] or "")], catalogo_modelos)
207
+ if not chaves_linha or chaves_linha.isdisjoint(chaves_selecionadas):
208
+ continue
209
+
210
+ trabalho_id = str(row["trabalho_id"])
211
+ imovel_id = int(row["imovel_id"])
212
+ chave = (trabalho_id, imovel_id)
213
+ item = agregados.setdefault(
214
+ chave,
215
+ {
216
+ "trabalho_id": trabalho_id,
217
+ "trabalho_nome": str(row["trabalho_nome"] or trabalho_id),
218
+ "tipo_label": str(row["tipo_label"] or ""),
219
+ "label": str(row["label"] or ""),
220
+ "endereco": str(row["endereco"] or ""),
221
+ "numero": str(row["numero"] or ""),
222
+ "coord_x": float(coord_x),
223
+ "coord_y": float(coord_y),
224
+ "coord_lon": float(coord_x),
225
+ "coord_lat": float(coord_y),
226
+ "modelos_relacionados": set(),
227
+ },
228
+ )
229
+ modelo_nome = str(row["modelo_nome"] or "").strip()
230
+ if modelo_nome:
231
+ item["modelos_relacionados"].add(modelo_nome)
232
+
233
+ resultado: list[dict[str, Any]] = []
234
+ for item in agregados.values():
235
+ modelos_relacionados = sorted(item["modelos_relacionados"], key=lambda value: str(value).casefold())
236
+ resultado.append(
237
+ {
238
+ **item,
239
+ "modelos_relacionados": modelos_relacionados,
240
+ }
241
+ )
242
+
243
+ resultado.sort(
244
+ key=lambda item: (
245
+ str(item.get("trabalho_nome") or "").casefold(),
246
+ str(item.get("label") or item.get("endereco") or "").casefold(),
247
+ )
248
+ )
249
+ return sanitize_value(resultado)
250
+
251
+
252
+ def _criar_mapa_trabalho(nome_trabalho: str, imoveis: list[dict[str, Any]]) -> str:
253
+ pontos = [
254
+ item for item in imoveis
255
+ if _coordenada_valida(item.get("coord_x")) and _coordenada_valida(item.get("coord_y"))
256
+ ]
257
+ if not pontos:
258
+ return "<p>Coordenadas indisponiveis para este trabalho tecnico.</p>"
259
+
260
+ latitudes = [float(item["coord_y"]) for item in pontos]
261
+ longitudes = [float(item["coord_x"]) for item in pontos]
262
+ centro_lat = float(median(latitudes))
263
+ centro_lon = float(median(longitudes))
264
+
265
+ mapa = folium.Map(
266
+ location=[centro_lat, centro_lon],
267
+ zoom_start=14,
268
+ tiles=None,
269
+ prefer_canvas=True,
270
+ control_scale=True,
271
+ )
272
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
273
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
274
+ add_bairros_layer(mapa, show=True)
275
+
276
+ camada = folium.FeatureGroup(name="Imoveis do trabalho", show=True)
277
+ for index, item in enumerate(pontos, start=1):
278
+ modelos_texto = ", ".join(item.get("modelos") or []) or "Sem modelo informado"
279
+ tooltip_html = (
280
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
281
+ f"<b>{nome_trabalho}</b>"
282
+ f"<br><span style='color:#555;'>Imovel:</span> <b>{item.get('label') or 'Nao informado'}</b>"
283
+ f"<br><span style='color:#555;'>Modelos:</span> {modelos_texto}"
284
+ "</div>"
285
+ )
286
+ marcador = folium.CircleMarker(
287
+ location=[float(item["coord_y"]), float(item["coord_x"])],
288
+ radius=8,
289
+ tooltip=folium.Tooltip(tooltip_html, sticky=True),
290
+ color="#ffffff",
291
+ weight=1.0,
292
+ fill=True,
293
+ fillColor="#1f6fb2",
294
+ fillOpacity=0.9,
295
+ )
296
+ marcador.options["mesaBaseRadius"] = 8.0
297
+ camada.add_child(marcador)
298
+ if len(pontos) > 1:
299
+ folium.Marker(
300
+ location=[float(item["coord_y"]), float(item["coord_x"])],
301
+ icon=folium.DivIcon(
302
+ html=(
303
+ "<div style='display:flex;align-items:center;justify-content:center;"
304
+ "width:20px;height:20px;border-radius:999px;background:#ffffff;"
305
+ "border:1px solid #1f6fb2;color:#1f4b75;font:700 11px Arial,sans-serif;'>"
306
+ f"{index}</div>"
307
+ )
308
+ ),
309
+ ).add_to(camada)
310
+ camada.add_to(mapa)
311
+ folium.LayerControl().add_to(mapa)
312
+ plugins.Fullscreen().add_to(mapa)
313
+ add_zoom_responsive_circle_markers(mapa)
314
+ add_popup_pagination_handlers(mapa)
315
+
316
+ lat_min = min(latitudes)
317
+ lat_max = max(latitudes)
318
+ lon_min = min(longitudes)
319
+ lon_max = max(longitudes)
320
+ if math.isclose(lat_min, lat_max):
321
+ lat_min -= 0.0008
322
+ lat_max += 0.0008
323
+ if math.isclose(lon_min, lon_max):
324
+ lon_min -= 0.0008
325
+ lon_max += 0.0008
326
+ mapa.fit_bounds([[lat_min, lon_min], [lat_max, lon_max]], padding=(42, 42), max_zoom=18)
327
+ return mapa.get_root().render()
328
+
329
+
330
+ def listar_trabalhos() -> dict[str, Any]:
331
+ resolved = trabalhos_tecnicos_repository.resolve_database()
332
+ catalogo_modelos = _catalogo_modelos_mesa()
333
+
334
+ conn = _connect_database(resolved.db_path)
335
+ try:
336
+ meta = _fetch_meta(conn)
337
+ trabalhos_rows = conn.execute(
338
+ """
339
+ SELECT
340
+ trabalho_id,
341
+ nome,
342
+ nome_original,
343
+ tipo_codigo,
344
+ tipo_label,
345
+ ano,
346
+ endereco_resumo,
347
+ modelo_resumo,
348
+ total_registros,
349
+ total_imoveis,
350
+ total_modelos,
351
+ tem_coordenadas
352
+ FROM trabalhos
353
+ ORDER BY COALESCE(ano, 0) DESC, LOWER(tipo_codigo), LOWER(nome)
354
+ """
355
+ ).fetchall()
356
+ modelos_rows = conn.execute(
357
+ "SELECT trabalho_id, modelo_nome FROM trabalho_modelos ORDER BY trabalho_id, ordem, LOWER(modelo_nome)"
358
+ ).fetchall()
359
+ finally:
360
+ conn.close()
361
+
362
+ modelos_por_trabalho: dict[str, list[str]] = {}
363
+ for row in modelos_rows:
364
+ modelos_por_trabalho.setdefault(str(row["trabalho_id"]), []).append(str(row["modelo_nome"]))
365
+
366
+ trabalhos: list[dict[str, Any]] = []
367
+ total_com_modelo_mesa = 0
368
+ for row in trabalhos_rows:
369
+ trabalho_id = str(row["trabalho_id"])
370
+ modelos = _enriquecer_modelos(modelos_por_trabalho.get(trabalho_id, []), catalogo_modelos)
371
+ modelos_mesa = [item for item in modelos if item.get("disponivel_mesa")]
372
+ if modelos_mesa:
373
+ total_com_modelo_mesa += 1
374
+ trabalhos.append(
375
+ {
376
+ "id": trabalho_id,
377
+ "nome": str(row["nome"]),
378
+ "nome_original": str(row["nome_original"]),
379
+ "tipo_codigo": str(row["tipo_codigo"]),
380
+ "tipo_label": str(row["tipo_label"]),
381
+ "ano": row["ano"],
382
+ "endereco_resumo": str(row["endereco_resumo"] or ""),
383
+ "modelo_resumo": str(row["modelo_resumo"] or ""),
384
+ "total_registros_planilha": int(row["total_registros"] or 0),
385
+ "total_imoveis": int(row["total_imoveis"] or 0),
386
+ "total_modelos": int(row["total_modelos"] or 0),
387
+ "possui_modelo_mesa": bool(modelos_mesa),
388
+ "modelo_mesa_principal": modelos_mesa[0]["mesa_modelo_nome"] if modelos_mesa else None,
389
+ "tem_coordenadas": bool(row["tem_coordenadas"]),
390
+ "modelos": modelos,
391
+ }
392
+ )
393
+
394
+ return sanitize_value(
395
+ {
396
+ "trabalhos": trabalhos,
397
+ "total_trabalhos": len(trabalhos),
398
+ "total_com_modelo_mesa": total_com_modelo_mesa,
399
+ "fonte": _source_payload(resolved, meta),
400
+ }
401
+ )
402
+
403
+
404
+ def detalhar_trabalho(trabalho_id: str) -> dict[str, Any]:
405
+ chave = str(trabalho_id or "").strip()
406
+ if not chave:
407
+ raise HTTPException(status_code=400, detail="Informe o identificador do trabalho tecnico")
408
+
409
+ resolved = trabalhos_tecnicos_repository.resolve_database()
410
+ catalogo_modelos = _catalogo_modelos_mesa()
411
+
412
+ conn = _connect_database(resolved.db_path)
413
+ try:
414
+ meta = _fetch_meta(conn)
415
+ trabalho_row = conn.execute(
416
+ """
417
+ SELECT
418
+ trabalho_id,
419
+ nome,
420
+ nome_original,
421
+ tipo_codigo,
422
+ tipo_label,
423
+ ano,
424
+ endereco_resumo,
425
+ modelo_resumo,
426
+ total_registros,
427
+ total_imoveis,
428
+ total_modelos,
429
+ tem_coordenadas
430
+ FROM trabalhos
431
+ WHERE trabalho_id = ?
432
+ """,
433
+ (chave,),
434
+ ).fetchone()
435
+ if trabalho_row is None:
436
+ raise HTTPException(status_code=404, detail="Trabalho tecnico nao encontrado")
437
+
438
+ modelo_rows = conn.execute(
439
+ "SELECT modelo_nome FROM trabalho_modelos WHERE trabalho_id = ? ORDER BY ordem, LOWER(modelo_nome)",
440
+ (chave,),
441
+ ).fetchall()
442
+ imovel_rows = conn.execute(
443
+ """
444
+ SELECT imovel_id, endereco, numero, label, coord_x, coord_y
445
+ FROM trabalho_imoveis
446
+ WHERE trabalho_id = ?
447
+ ORDER BY imovel_id
448
+ """,
449
+ (chave,),
450
+ ).fetchall()
451
+ imovel_model_rows = conn.execute(
452
+ """
453
+ SELECT imovel_id, modelo_nome
454
+ FROM trabalho_imovel_modelos
455
+ WHERE imovel_id IN (SELECT imovel_id FROM trabalho_imoveis WHERE trabalho_id = ?)
456
+ ORDER BY imovel_id, LOWER(modelo_nome)
457
+ """,
458
+ (chave,),
459
+ ).fetchall()
460
+ registro_rows = conn.execute(
461
+ """
462
+ SELECT source_row, ano, nome_original, modelo_nome, endereco, numero, coord_x, coord_y
463
+ FROM trabalho_registros
464
+ WHERE trabalho_id = ?
465
+ ORDER BY source_row
466
+ """,
467
+ (chave,),
468
+ ).fetchall()
469
+ finally:
470
+ conn.close()
471
+
472
+ modelos = _enriquecer_modelos([str(row["modelo_nome"]) for row in modelo_rows], catalogo_modelos)
473
+ modelos_por_imovel: dict[int, list[str]] = {}
474
+ for row in imovel_model_rows:
475
+ modelos_por_imovel.setdefault(int(row["imovel_id"]), []).append(str(row["modelo_nome"]))
476
+
477
+ imoveis: list[dict[str, Any]] = []
478
+ for row in imovel_rows:
479
+ imoveis.append(
480
+ {
481
+ "endereco": str(row["endereco"] or ""),
482
+ "numero": str(row["numero"] or ""),
483
+ "label": str(row["label"] or ""),
484
+ "coord_x": row["coord_x"],
485
+ "coord_y": row["coord_y"],
486
+ "coord_lon": row["coord_x"],
487
+ "coord_lat": row["coord_y"],
488
+ "modelos": modelos_por_imovel.get(int(row["imovel_id"]), []),
489
+ }
490
+ )
491
+
492
+ imoveis_tabela = _table_payload(
493
+ [
494
+ {
495
+ "Endereco": item.get("endereco"),
496
+ "Numero": item.get("numero"),
497
+ "Coordenada X": item.get("coord_x"),
498
+ "Coordenada Y": item.get("coord_y"),
499
+ "Modelos associados": ", ".join(item.get("modelos") or []),
500
+ }
501
+ for item in imoveis
502
+ ],
503
+ ["Endereco", "Numero", "Coordenada X", "Coordenada Y", "Modelos associados"],
504
+ )
505
+ modelos_tabela = _table_payload(
506
+ [
507
+ {
508
+ "Modelo importado": item.get("nome"),
509
+ "Disponivel na MESA": "Sim" if item.get("disponivel_mesa") else "Nao",
510
+ "Modelo no repositorio": item.get("mesa_modelo_nome") or "",
511
+ }
512
+ for item in modelos
513
+ ],
514
+ ["Modelo importado", "Disponivel na MESA", "Modelo no repositorio"],
515
+ )
516
+ registros_tabela = _table_payload(
517
+ [
518
+ {
519
+ "Linha origem": int(row["source_row"]),
520
+ "Ano": row["ano"],
521
+ "Nome original": str(row["nome_original"]),
522
+ "Modelo": str(row["modelo_nome"] or ""),
523
+ "Endereco": str(row["endereco"] or ""),
524
+ "Numero": str(row["numero"] or ""),
525
+ "Coordenada X": row["coord_x"],
526
+ "Coordenada Y": row["coord_y"],
527
+ }
528
+ for row in registro_rows
529
+ ],
530
+ ["Linha origem", "Ano", "Nome original", "Modelo", "Endereco", "Numero", "Coordenada X", "Coordenada Y"],
531
+ )
532
+
533
+ return sanitize_value(
534
+ {
535
+ "trabalho": {
536
+ "id": str(trabalho_row["trabalho_id"]),
537
+ "nome": str(trabalho_row["nome"]),
538
+ "nome_original": str(trabalho_row["nome_original"]),
539
+ "tipo_codigo": str(trabalho_row["tipo_codigo"]),
540
+ "tipo_label": str(trabalho_row["tipo_label"]),
541
+ "ano": trabalho_row["ano"],
542
+ "endereco_resumo": str(trabalho_row["endereco_resumo"] or ""),
543
+ "modelo_resumo": str(trabalho_row["modelo_resumo"] or ""),
544
+ "total_registros_planilha": int(trabalho_row["total_registros"] or 0),
545
+ "total_imoveis": int(trabalho_row["total_imoveis"] or 0),
546
+ "total_modelos": int(trabalho_row["total_modelos"] or 0),
547
+ "tem_coordenadas": bool(trabalho_row["tem_coordenadas"]),
548
+ "modelos": modelos,
549
+ "imoveis": imoveis,
550
+ "mapa_html": _criar_mapa_trabalho(str(trabalho_row["nome"]), imoveis),
551
+ "imoveis_tabela": imoveis_tabela,
552
+ "modelos_tabela": modelos_tabela,
553
+ "registros_tabela": registros_tabela,
554
+ "fonte": _source_payload(resolved, meta),
555
+ }
556
+ }
557
+ )
backend/app/services/visualizacao_service.py CHANGED
@@ -24,7 +24,7 @@ from app.core.elaboracao.core import (
24
  )
25
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
26
  from app.models.session import SessionState
27
- from app.services import model_repository
28
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
29
  from app.services.knn_avaliacao_service import estimar_valor_knn_avaliacao
30
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
@@ -96,6 +96,76 @@ def _formatar_tooltip_valor(coluna: str, valor: Any) -> str:
96
  return str(valor)
97
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def _criar_mapa_knn_destaque(df_base: pd.DataFrame, posicoes_knn: list[int], coluna_y: str) -> str:
100
  if df_base is None or df_base.empty:
101
  return "<p>Base de dados indisponivel para mapa KNN.</p>"
@@ -342,6 +412,54 @@ def _resolver_valor_area_avaliacao(valores: dict[str, Any], coluna_area: str | N
342
  return float(valor)
343
 
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  def _equacoes_do_modelo(pacote: dict[str, Any], info: dict[str, Any]) -> dict[str, Any]:
346
  diagnosticos = pacote.get("modelo", {}).get("diagnosticos", {}) if isinstance(pacote.get("modelo"), dict) else {}
347
  return build_equacoes_payload(
@@ -354,6 +472,15 @@ def _equacoes_do_modelo(pacote: dict[str, Any], info: dict[str, Any]) -> dict[st
354
  )
355
 
356
 
 
 
 
 
 
 
 
 
 
357
  def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
358
  pacote = session.pacote_visualizacao
359
  if pacote is None:
@@ -368,15 +495,13 @@ def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
368
  }
369
 
370
 
371
- def exibir_modelo(session: SessionState) -> dict[str, Any]:
372
  pacote = session.pacote_visualizacao
373
  if pacote is None:
374
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
375
 
376
- dados = pacote["dados"]["df"].reset_index()
377
- for col in dados.columns:
378
- if pd.api.types.is_numeric_dtype(dados[col]) and str(col).lower() not in ["lat", "latitude", "lon", "longitude", "long", "siat_latitude", "siat_longitude"]:
379
- dados[col] = dados[col].round(2)
380
 
381
  estat = _tabela_estatisticas(pacote).round(2)
382
 
@@ -406,11 +531,22 @@ def exibir_modelo(session: SessionState) -> dict[str, Any]:
406
 
407
  info = _extrair_modelo_info(pacote)
408
  equacoes = _equacoes_do_modelo(pacote, info)
409
- mapa_html = viz_app.criar_mapa(dados, col_y=info["nome_y"])
 
 
 
 
 
 
 
 
 
 
 
410
 
411
  colunas_numericas = [
412
  str(col)
413
- for col in dados.select_dtypes(include=[np.number]).columns
414
  if str(col).lower() not in ["lat", "lon", "latitude", "longitude", "index"]
415
  ]
416
  choices_mapa = ["Visualização Padrão"] + colunas_numericas
@@ -418,7 +554,7 @@ def exibir_modelo(session: SessionState) -> dict[str, Any]:
418
  session.dados_visualizacao = dados
419
 
420
  return {
421
- "dados": dataframe_to_payload(dados, decimals=2, max_rows=None),
422
  "estatisticas": dataframe_to_payload(estat, decimals=2),
423
  "escalas_html": escalas_html,
424
  "dados_transformados": dataframe_to_payload(df_xy, decimals=2, max_rows=None),
@@ -435,10 +571,11 @@ def exibir_modelo(session: SessionState) -> dict[str, Any]:
435
  "campos_avaliacao": campos_avaliacao(session),
436
  "meta_modelo": sanitize_value(info),
437
  "equacoes": sanitize_value(equacoes),
 
438
  }
439
 
440
 
441
- def atualizar_mapa(session: SessionState, variavel_mapa: str | None) -> dict[str, Any]:
442
  pacote = session.pacote_visualizacao
443
  dados = session.dados_visualizacao
444
  if pacote is None or dados is None or dados.empty:
@@ -449,10 +586,50 @@ def atualizar_mapa(session: SessionState, variavel_mapa: str | None) -> dict[str
449
  if variavel_mapa and variavel_mapa != "Visualização Padrão":
450
  tamanho_col = variavel_mapa
451
 
452
- mapa_html = viz_app.criar_mapa(dados, tamanho_col=tamanho_col, col_y=info["nome_y"])
 
 
 
 
 
 
 
 
 
 
 
 
453
  return {"mapa_html": mapa_html}
454
 
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  def campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
457
  pacote = session.pacote_visualizacao
458
  if pacote is None:
@@ -532,7 +709,13 @@ def campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
532
  return sanitize_value(campos)
533
 
534
 
535
- def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_base: str | None) -> dict[str, Any]:
 
 
 
 
 
 
536
  pacote = session.pacote_visualizacao
537
  if pacote is None:
538
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
@@ -578,6 +761,7 @@ def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_
578
  valor_area = _resolver_valor_area_avaliacao(valores_x, info.get("coluna_area"))
579
  if info.get("tipo_y") and valor_area is None:
580
  raise HTTPException(status_code=400, detail="Informe um valor de area maior que 1 para a coluna configurada.")
 
581
 
582
  resultado = avaliar_imovel(
583
  modelo_sm=pacote["modelo"]["sm"],
@@ -605,7 +789,7 @@ def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_
605
  df_base=df_knn,
606
  coluna_y=info["nome_y"],
607
  colunas_x=colunas_x,
608
- valores_x=entradas,
609
  alpha_geo=0.35,
610
  ),
611
  tipo_y=info.get("tipo_y"),
@@ -613,6 +797,8 @@ def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_
613
  valor_area=valor_area,
614
  )
615
  resultado.update(resultado_knn)
 
 
616
 
617
  session.avaliacoes_visualizacao.append(resultado)
618
 
@@ -629,7 +815,12 @@ def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_
629
  }
630
 
631
 
632
- def detalhes_knn_avaliacao(session: SessionState, valores_x: dict[str, Any]) -> dict[str, Any]:
 
 
 
 
 
633
  pacote = session.pacote_visualizacao
634
  if pacote is None:
635
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
@@ -674,6 +865,7 @@ def detalhes_knn_avaliacao(session: SessionState, valores_x: dict[str, Any]) ->
674
  valor_area = _resolver_valor_area_avaliacao(valores_x, info.get("coluna_area"))
675
  if info.get("tipo_y") and valor_area is None:
676
  raise HTTPException(status_code=400, detail="Informe um valor de area maior que 1 para a coluna configurada.")
 
677
 
678
  df_knn = pacote.get("dados", {}).get("df")
679
  if not isinstance(df_knn, pd.DataFrame):
@@ -684,7 +876,7 @@ def detalhes_knn_avaliacao(session: SessionState, valores_x: dict[str, Any]) ->
684
  df_base=df_knn,
685
  coluna_y=info["nome_y"],
686
  colunas_x=colunas_x,
687
- valores_x=entradas,
688
  alpha_geo=0.35,
689
  retornar_detalhes=True,
690
  ),
@@ -727,6 +919,9 @@ def detalhes_knn_avaliacao(session: SessionState, valores_x: dict[str, Any]) ->
727
  avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
728
  if info.get("coluna_area") and info.get("coluna_area") not in colunas_x:
729
  avaliando.append({"variavel": f"{info['coluna_area']} (area)", "valor": valor_area})
 
 
 
730
 
731
  return sanitize_value(
732
  {
 
24
  )
25
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
26
  from app.models.session import SessionState
27
+ from app.services import model_repository, trabalhos_tecnicos_service
28
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
29
  from app.services.knn_avaliacao_service import estimar_valor_knn_avaliacao
30
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
 
96
  return str(valor)
97
 
98
 
99
+ def _resumir_trabalhos_tecnicos(avaliandos_tecnicos: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
100
+ agrupados: dict[str, dict[str, Any]] = {}
101
+ for item in avaliandos_tecnicos or []:
102
+ trabalho_id = str(item.get("trabalho_id") or "").strip()
103
+ if not trabalho_id:
104
+ continue
105
+
106
+ trabalho = agrupados.setdefault(
107
+ trabalho_id,
108
+ {
109
+ "trabalho_id": trabalho_id,
110
+ "trabalho_nome": str(item.get("trabalho_nome") or trabalho_id).strip(),
111
+ "tipo_label": str(item.get("tipo_label") or "").strip(),
112
+ "_imoveis_indexados": set(),
113
+ "_modelos_relacionados": set(),
114
+ "imoveis": [],
115
+ },
116
+ )
117
+
118
+ endereco = str(item.get("endereco") or "").strip()
119
+ numero = str(item.get("numero") or "").strip()
120
+ endereco_texto = ", ".join([valor for valor in [endereco, numero] if valor]) or "Endereco nao informado"
121
+ label = str(item.get("label") or "").strip() or endereco_texto
122
+ imovel_key = f"{label}::{endereco_texto}"
123
+ if imovel_key not in trabalho["_imoveis_indexados"]:
124
+ trabalho["_imoveis_indexados"].add(imovel_key)
125
+ trabalho["imoveis"].append(
126
+ {
127
+ "label": label,
128
+ "endereco": endereco_texto,
129
+ }
130
+ )
131
+
132
+ for modelo_nome in item.get("modelos_relacionados") or []:
133
+ modelo_texto = str(modelo_nome or "").strip()
134
+ if modelo_texto:
135
+ trabalho["_modelos_relacionados"].add(modelo_texto)
136
+
137
+ resultado: list[dict[str, Any]] = []
138
+ for trabalho in agrupados.values():
139
+ imoveis = sorted(
140
+ trabalho["imoveis"],
141
+ key=lambda imovel: (
142
+ str(imovel.get("label") or "").casefold(),
143
+ str(imovel.get("endereco") or "").casefold(),
144
+ ),
145
+ )
146
+ resultado.append(
147
+ {
148
+ "trabalho_id": trabalho["trabalho_id"],
149
+ "trabalho_nome": trabalho["trabalho_nome"],
150
+ "tipo_label": trabalho["tipo_label"],
151
+ "total_imoveis": len(imoveis),
152
+ "imoveis": imoveis,
153
+ "modelos_relacionados": sorted(
154
+ trabalho["_modelos_relacionados"],
155
+ key=lambda valor: str(valor).casefold(),
156
+ ),
157
+ }
158
+ )
159
+
160
+ resultado.sort(
161
+ key=lambda item: (
162
+ str(item.get("trabalho_nome") or "").casefold(),
163
+ str(item.get("tipo_label") or "").casefold(),
164
+ )
165
+ )
166
+ return sanitize_value(resultado)
167
+
168
+
169
  def _criar_mapa_knn_destaque(df_base: pd.DataFrame, posicoes_knn: list[int], coluna_y: str) -> str:
170
  if df_base is None or df_base.empty:
171
  return "<p>Base de dados indisponivel para mapa KNN.</p>"
 
412
  return float(valor)
413
 
414
 
415
+ def _normalizar_coordenadas_avaliando(lat_raw: Any, lon_raw: Any) -> tuple[float | None, float | None]:
416
+ try:
417
+ lat = float(lat_raw)
418
+ lon = float(lon_raw)
419
+ except Exception:
420
+ return None, None
421
+ if not np.isfinite(lat) or not np.isfinite(lon):
422
+ return None, None
423
+ if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0):
424
+ return None, None
425
+ return float(lat), float(lon)
426
+
427
+
428
+ def _montar_valores_knn(
429
+ valores_x: dict[str, float],
430
+ avaliando_lat: Any = None,
431
+ avaliando_lon: Any = None,
432
+ ) -> tuple[dict[str, float], float | None, float | None]:
433
+ valores_knn = dict(valores_x or {})
434
+ lat, lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
435
+ if lat is not None and lon is not None:
436
+ valores_knn["latitude"] = lat
437
+ valores_knn["longitude"] = lon
438
+ return valores_knn, lat, lon
439
+
440
+
441
+ def _aliases_modelo_visualizacao(session: SessionState) -> list[str]:
442
+ aliases: list[str] = []
443
+ for value in (session.uploaded_filename, session.uploaded_file_path):
444
+ texto = str(value or "").strip()
445
+ if not texto:
446
+ continue
447
+ aliases.append(texto)
448
+ try:
449
+ aliases.append(Path(texto).stem)
450
+ except Exception:
451
+ continue
452
+ vistos = set()
453
+ unicos: list[str] = []
454
+ for item in aliases:
455
+ chave = str(item or "").strip().casefold()
456
+ if not chave or chave in vistos:
457
+ continue
458
+ vistos.add(chave)
459
+ unicos.append(str(item).strip())
460
+ return unicos
461
+
462
+
463
  def _equacoes_do_modelo(pacote: dict[str, Any], info: dict[str, Any]) -> dict[str, Any]:
464
  diagnosticos = pacote.get("modelo", {}).get("diagnosticos", {}) if isinstance(pacote.get("modelo"), dict) else {}
465
  return build_equacoes_payload(
 
472
  )
473
 
474
 
475
+ def _preparar_dados_visualizacao(pacote: dict[str, Any]) -> pd.DataFrame:
476
+ dados = pacote["dados"]["df"].reset_index()
477
+ for col in dados.columns:
478
+ if pd.api.types.is_numeric_dtype(dados[col]) and str(col).lower() not in ["lat", "latitude", "lon", "longitude", "long", "siat_latitude", "siat_longitude"]:
479
+ dados[col] = dados[col].round(2)
480
+ dados["__mesa_row_id__"] = np.arange(len(dados), dtype=int)
481
+ return dados
482
+
483
+
484
  def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
485
  pacote = session.pacote_visualizacao
486
  if pacote is None:
 
495
  }
496
 
497
 
498
+ def exibir_modelo(session: SessionState, api_base_url: str | None = None, popup_auth_token: str | None = None) -> dict[str, Any]:
499
  pacote = session.pacote_visualizacao
500
  if pacote is None:
501
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
502
 
503
+ dados = _preparar_dados_visualizacao(pacote)
504
+ dados_publicos = dados.drop(columns=["__mesa_row_id__"])
 
 
505
 
506
  estat = _tabela_estatisticas(pacote).round(2)
507
 
 
531
 
532
  info = _extrair_modelo_info(pacote)
533
  equacoes = _equacoes_do_modelo(pacote, info)
534
+ aliases_modelo = _aliases_modelo_visualizacao(session)
535
+ avaliandos_tecnicos = trabalhos_tecnicos_service.listar_avaliandos_por_modelo(aliases_modelo)
536
+ trabalhos_tecnicos = _resumir_trabalhos_tecnicos(avaliandos_tecnicos)
537
+ popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
538
+ mapa_html = viz_app.criar_mapa(
539
+ dados,
540
+ col_y=info["nome_y"],
541
+ session_id=session.session_id,
542
+ popup_endpoint=popup_endpoint,
543
+ popup_auth_token=popup_auth_token,
544
+ avaliandos_tecnicos=avaliandos_tecnicos,
545
+ )
546
 
547
  colunas_numericas = [
548
  str(col)
549
+ for col in dados_publicos.select_dtypes(include=[np.number]).columns
550
  if str(col).lower() not in ["lat", "lon", "latitude", "longitude", "index"]
551
  ]
552
  choices_mapa = ["Visualização Padrão"] + colunas_numericas
 
554
  session.dados_visualizacao = dados
555
 
556
  return {
557
+ "dados": dataframe_to_payload(dados_publicos, decimals=2, max_rows=None),
558
  "estatisticas": dataframe_to_payload(estat, decimals=2),
559
  "escalas_html": escalas_html,
560
  "dados_transformados": dataframe_to_payload(df_xy, decimals=2, max_rows=None),
 
571
  "campos_avaliacao": campos_avaliacao(session),
572
  "meta_modelo": sanitize_value(info),
573
  "equacoes": sanitize_value(equacoes),
574
+ "trabalhos_tecnicos": trabalhos_tecnicos,
575
  }
576
 
577
 
578
+ def atualizar_mapa(session: SessionState, variavel_mapa: str | None, api_base_url: str | None = None, popup_auth_token: str | None = None) -> dict[str, Any]:
579
  pacote = session.pacote_visualizacao
580
  dados = session.dados_visualizacao
581
  if pacote is None or dados is None or dados.empty:
 
586
  if variavel_mapa and variavel_mapa != "Visualização Padrão":
587
  tamanho_col = variavel_mapa
588
 
589
+ avaliandos_tecnicos = trabalhos_tecnicos_service.listar_avaliandos_por_modelo(
590
+ _aliases_modelo_visualizacao(session)
591
+ )
592
+ popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
593
+ mapa_html = viz_app.criar_mapa(
594
+ dados,
595
+ tamanho_col=tamanho_col,
596
+ col_y=info["nome_y"],
597
+ session_id=session.session_id,
598
+ popup_endpoint=popup_endpoint,
599
+ popup_auth_token=popup_auth_token,
600
+ avaliandos_tecnicos=avaliandos_tecnicos,
601
+ )
602
  return {"mapa_html": mapa_html}
603
 
604
 
605
+ def carregar_popup_ponto_mapa(session: SessionState, row_id: int) -> dict[str, Any]:
606
+ dados = session.dados_visualizacao
607
+ if dados is None or dados.empty:
608
+ raise HTTPException(status_code=400, detail="Exiba o modelo antes de abrir os detalhes do ponto")
609
+ if "__mesa_row_id__" not in dados.columns:
610
+ raise HTTPException(status_code=400, detail="Sessao sem identificadores de pontos carregados")
611
+
612
+ try:
613
+ row_id_int = int(row_id)
614
+ except (TypeError, ValueError) as exc:
615
+ raise HTTPException(status_code=400, detail="Identificador de ponto invalido") from exc
616
+
617
+ registros = dados.loc[dados["__mesa_row_id__"] == row_id_int]
618
+ if registros.empty:
619
+ raise HTTPException(status_code=404, detail="Ponto nao encontrado para esta sessao")
620
+
621
+ row = registros.iloc[0]
622
+ popup_html, popup_width = viz_app.montar_popup_registro_html(
623
+ row,
624
+ popup_uid=f"mesa-popup-row-{row_id_int}",
625
+ max_itens_pagina=8,
626
+ )
627
+ return {
628
+ "popup_html": popup_html,
629
+ "popup_width": int(popup_width),
630
+ }
631
+
632
+
633
  def campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
634
  pacote = session.pacote_visualizacao
635
  if pacote is None:
 
709
  return sanitize_value(campos)
710
 
711
 
712
+ def calcular_avaliacao(
713
+ session: SessionState,
714
+ valores_x: dict[str, Any],
715
+ indice_base: str | None,
716
+ avaliando_lat: float | None = None,
717
+ avaliando_lon: float | None = None,
718
+ ) -> dict[str, Any]:
719
  pacote = session.pacote_visualizacao
720
  if pacote is None:
721
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
 
761
  valor_area = _resolver_valor_area_avaliacao(valores_x, info.get("coluna_area"))
762
  if info.get("tipo_y") and valor_area is None:
763
  raise HTTPException(status_code=400, detail="Informe um valor de area maior que 1 para a coluna configurada.")
764
+ valores_knn, aval_lat, aval_lon = _montar_valores_knn(entradas, avaliando_lat, avaliando_lon)
765
 
766
  resultado = avaliar_imovel(
767
  modelo_sm=pacote["modelo"]["sm"],
 
789
  df_base=df_knn,
790
  coluna_y=info["nome_y"],
791
  colunas_x=colunas_x,
792
+ valores_x=valores_knn,
793
  alpha_geo=0.35,
794
  ),
795
  tipo_y=info.get("tipo_y"),
 
797
  valor_area=valor_area,
798
  )
799
  resultado.update(resultado_knn)
800
+ resultado["avaliando_lat"] = aval_lat
801
+ resultado["avaliando_lon"] = aval_lon
802
 
803
  session.avaliacoes_visualizacao.append(resultado)
804
 
 
815
  }
816
 
817
 
818
+ def detalhes_knn_avaliacao(
819
+ session: SessionState,
820
+ valores_x: dict[str, Any],
821
+ avaliando_lat: float | None = None,
822
+ avaliando_lon: float | None = None,
823
+ ) -> dict[str, Any]:
824
  pacote = session.pacote_visualizacao
825
  if pacote is None:
826
  raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
 
865
  valor_area = _resolver_valor_area_avaliacao(valores_x, info.get("coluna_area"))
866
  if info.get("tipo_y") and valor_area is None:
867
  raise HTTPException(status_code=400, detail="Informe um valor de area maior que 1 para a coluna configurada.")
868
+ valores_knn, aval_lat, aval_lon = _montar_valores_knn(entradas, avaliando_lat, avaliando_lon)
869
 
870
  df_knn = pacote.get("dados", {}).get("df")
871
  if not isinstance(df_knn, pd.DataFrame):
 
876
  df_base=df_knn,
877
  coluna_y=info["nome_y"],
878
  colunas_x=colunas_x,
879
+ valores_x=valores_knn,
880
  alpha_geo=0.35,
881
  retornar_detalhes=True,
882
  ),
 
919
  avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
920
  if info.get("coluna_area") and info.get("coluna_area") not in colunas_x:
921
  avaliando.append({"variavel": f"{info['coluna_area']} (area)", "valor": valor_area})
922
+ if aval_lat is not None and aval_lon is not None:
923
+ avaliando.append({"variavel": "Latitude", "valor": aval_lat})
924
+ avaliando.append({"variavel": "Longitude", "valor": aval_lon})
925
 
926
  return sanitize_value(
927
  {
backend/scripts/build_trabalhos_tecnicos_db.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _repo_root() -> Path:
10
+ return Path(__file__).resolve().parents[2]
11
+
12
+
13
+ def main() -> int:
14
+ repo_root = _repo_root()
15
+ if str(repo_root / "backend") not in sys.path:
16
+ sys.path.insert(0, str(repo_root / "backend"))
17
+
18
+ from app.services.trabalhos_tecnicos_importer import DEFAULT_LOCAL_DB_FILE, DEFAULT_SOURCE_XLSX_FILE, build_database_from_xlsx
19
+
20
+ parser = argparse.ArgumentParser(description="Importa o Excel de trabalhos tecnicos para um banco SQLite.")
21
+ parser.add_argument(
22
+ "--xlsx",
23
+ default=str(Path.home() / "Downloads" / DEFAULT_SOURCE_XLSX_FILE),
24
+ help="Caminho da planilha xlsx de origem.",
25
+ )
26
+ parser.add_argument(
27
+ "--db",
28
+ default=str(repo_root / "backend" / "local_data" / DEFAULT_LOCAL_DB_FILE),
29
+ help="Caminho do banco SQLite de destino.",
30
+ )
31
+ args = parser.parse_args()
32
+
33
+ resultado = build_database_from_xlsx(args.xlsx, args.db)
34
+ print(json.dumps(resultado, ensure_ascii=False, indent=2))
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ raise SystemExit(main())
frontend/src/App.jsx CHANGED
@@ -3,16 +3,16 @@ import { api, getAuthToken, setAuthToken } from './api'
3
  import AvaliacaoTab from './components/AvaliacaoTab'
4
  import ElaboracaoTab from './components/ElaboracaoTab'
5
  import InicioTab from './components/InicioTab'
6
- import PesquisaTab from './components/PesquisaTab'
7
- import RepositorioTab from './components/RepositorioTab'
8
 
9
  const LOGS_PAGE_SIZE = 30
10
 
11
  const TABS = [
12
- { key: 'Pesquisa/Visualização', label: 'Pesquisa/Visualização' },
13
  { key: 'Elaboração/Edição', label: 'Elaboração/Edição' },
14
  { key: 'Avaliação', label: 'Avaliação de Imóveis' },
15
- { key: 'Repositório de Modelos', label: 'Repositório de Modelos' },
16
  ]
17
 
18
  export default function App() {
@@ -21,6 +21,7 @@ export default function App() {
21
  const [sessionId, setSessionId] = useState('')
22
  const [bootError, setBootError] = useState('')
23
  const [avaliacaoQuickLoad, setAvaliacaoQuickLoad] = useState(null)
 
24
 
25
  const [authLoading, setAuthLoading] = useState(true)
26
  const [authUser, setAuthUser] = useState(null)
@@ -60,6 +61,7 @@ export default function App() {
60
  setLoginLoading(false)
61
  setSessionId('')
62
  setBootError('')
 
63
  setLogsStatus(null)
64
  setLogsOpen(false)
65
  setLogsEvents([])
@@ -323,6 +325,20 @@ export default function App() {
323
  setShowStartupIntro(false)
324
  }
325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  function onScrollToHeader() {
327
  if (typeof window === 'undefined') return
328
  const headerEl = headerRef.current
@@ -493,6 +509,7 @@ export default function App() {
493
  <option value="">Todos</option>
494
  <option value="auth">Auth</option>
495
  <option value="repositorio">Repositório</option>
 
496
  <option value="elaboracao">Elaboração</option>
497
  <option value="visualizacao">Visualização</option>
498
  </select>
@@ -582,16 +599,24 @@ export default function App() {
582
  </div>
583
  ) : null}
584
 
585
- <div className="tab-pane" hidden={activeTab !== 'Pesquisa/Visualização'}>
586
- <PesquisaTab sessionId={sessionId} onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao} />
 
 
 
 
 
587
  </div>
588
 
589
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
590
  <ElaboracaoTab sessionId={sessionId} authUser={authUser} />
591
  </div>
592
 
593
- <div className="tab-pane" hidden={activeTab !== 'Repositório de Modelos'}>
594
- <RepositorioTab authUser={authUser} sessionId={sessionId} />
 
 
 
595
  </div>
596
 
597
  <div className="tab-pane" hidden={activeTab !== 'Avaliação'}>
 
3
  import AvaliacaoTab from './components/AvaliacaoTab'
4
  import ElaboracaoTab from './components/ElaboracaoTab'
5
  import InicioTab from './components/InicioTab'
6
+ import ModelosEstatisticosTab from './components/ModelosEstatisticosTab'
7
+ import TrabalhosTecnicosTab from './components/TrabalhosTecnicosTab'
8
 
9
  const LOGS_PAGE_SIZE = 30
10
 
11
  const TABS = [
12
+ { key: 'Modelos Estatísticos', label: 'Modelos Estatísticos' },
13
  { key: 'Elaboração/Edição', label: 'Elaboração/Edição' },
14
  { key: 'Avaliação', label: 'Avaliação de Imóveis' },
15
+ { key: 'Trabalhos Técnicos', label: 'Trabalhos Técnicos' },
16
  ]
17
 
18
  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
 
26
  const [authLoading, setAuthLoading] = useState(true)
27
  const [authUser, setAuthUser] = useState(null)
 
61
  setLoginLoading(false)
62
  setSessionId('')
63
  setBootError('')
64
+ setModeloRepositorioQuickOpen(null)
65
  setLogsStatus(null)
66
  setLogsOpen(false)
67
  setLogsEvents([])
 
325
  setShowStartupIntro(false)
326
  }
327
 
328
+ function onAbrirModeloNoRepositorio(modelo) {
329
+ const modeloId = String(modelo?.modeloId || modelo?.id || '').trim()
330
+ if (!modeloId) return
331
+ setModeloRepositorioQuickOpen({
332
+ requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
333
+ modeloId,
334
+ modeloArquivo: String(modelo?.modeloArquivo || modelo?.arquivo || '').trim(),
335
+ nomeModelo: String(modelo?.nomeModelo || modelo?.nome_modelo || modeloId).trim(),
336
+ })
337
+ setActiveTab('Modelos Estatísticos')
338
+ setLogsOpen(false)
339
+ setShowStartupIntro(false)
340
+ }
341
+
342
  function onScrollToHeader() {
343
  if (typeof window === 'undefined') return
344
  const headerEl = headerRef.current
 
509
  <option value="">Todos</option>
510
  <option value="auth">Auth</option>
511
  <option value="repositorio">Repositório</option>
512
+ <option value="trabalhos_tecnicos">Trabalhos Técnicos</option>
513
  <option value="elaboracao">Elaboração</option>
514
  <option value="visualizacao">Visualização</option>
515
  </select>
 
599
  </div>
600
  ) : null}
601
 
602
+ <div className="tab-pane" hidden={activeTab !== 'Modelos Estatísticos'}>
603
+ <ModelosEstatisticosTab
604
+ sessionId={sessionId}
605
+ authUser={authUser}
606
+ onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao}
607
+ openRepositorioModeloRequest={modeloRepositorioQuickOpen}
608
+ />
609
  </div>
610
 
611
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
612
  <ElaboracaoTab sessionId={sessionId} authUser={authUser} />
613
  </div>
614
 
615
+ <div className="tab-pane" hidden={activeTab !== 'Trabalhos Técnicos'}>
616
+ <TrabalhosTecnicosTab
617
+ sessionId={sessionId}
618
+ onAbrirModeloNoRepositorio={onAbrirModeloNoRepositorio}
619
+ />
620
  </div>
621
 
622
  <div className="tab-pane" hidden={activeTab !== 'Avaliação'}>
frontend/src/api.js CHANGED
@@ -328,14 +328,18 @@ export const api = {
328
  evaluationContextViz: (sessionId) => postJson('/api/visualizacao/evaluation/context', { session_id: sessionId }),
329
  updateVisualizacaoMap: (sessionId, variavelMapa) => postJson('/api/visualizacao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
330
  evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
331
- evaluationCalculateViz: (sessionId, valoresX, indiceBase) => postJson('/api/visualizacao/evaluation/calculate', {
332
  session_id: sessionId,
333
  valores_x: valoresX,
334
  indice_base: indiceBase,
 
 
335
  }),
336
- evaluationKnnDetailsViz: (sessionId, valoresX) => postJson('/api/visualizacao/evaluation/knn-details', {
337
  session_id: sessionId,
338
  valores_x: valoresX,
 
 
339
  }),
340
  evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
341
  evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
@@ -361,6 +365,8 @@ export const api = {
361
  return postForm('/api/repositorio/upload', form)
362
  },
363
  repositorioDelete: (modelosIds = []) => postJson('/api/repositorio/delete', { modelos_ids: modelosIds }),
 
 
364
 
365
  logsStatus: () => getJson('/api/logs/status'),
366
  logsEvents({ scope = '', usuario = '', limit = 200 } = {}) {
 
328
  evaluationContextViz: (sessionId) => postJson('/api/visualizacao/evaluation/context', { session_id: sessionId }),
329
  updateVisualizacaoMap: (sessionId, variavelMapa) => postJson('/api/visualizacao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
330
  evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
331
+ evaluationCalculateViz: (sessionId, valoresX, indiceBase, avaliando = null) => postJson('/api/visualizacao/evaluation/calculate', {
332
  session_id: sessionId,
333
  valores_x: valoresX,
334
  indice_base: indiceBase,
335
+ avaliando_lat: avaliando?.lat ?? null,
336
+ avaliando_lon: avaliando?.lon ?? null,
337
  }),
338
+ evaluationKnnDetailsViz: (sessionId, valoresX, avaliando = null) => postJson('/api/visualizacao/evaluation/knn-details', {
339
  session_id: sessionId,
340
  valores_x: valoresX,
341
+ avaliando_lat: avaliando?.lat ?? null,
342
+ avaliando_lon: avaliando?.lon ?? null,
343
  }),
344
  evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
345
  evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
 
365
  return postForm('/api/repositorio/upload', form)
366
  },
367
  repositorioDelete: (modelosIds = []) => postJson('/api/repositorio/delete', { modelos_ids: modelosIds }),
368
+ trabalhosTecnicosListar: () => getJson('/api/trabalhos-tecnicos'),
369
+ trabalhosTecnicosDetalhe: (trabalhoId) => postJson('/api/trabalhos-tecnicos/detalhe', { trabalho_id: trabalhoId }),
370
 
371
  logsStatus: () => getJson('/api/logs/status'),
372
  logsEvents({ scope = '', usuario = '', limit = 200 } = {}) {
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -459,6 +459,21 @@ function removerObservacaoDoBadgeHtml(html) {
459
  }
460
 
461
  const BASE_COMPARACAO_SEM_BASE = '__none__'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
  export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
464
  const [loading, setLoading] = useState(false)
@@ -480,6 +495,14 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
480
  const [equacaoSab, setEquacaoSab] = useState('')
481
  const valoresAvaliacaoRef = useRef({})
482
  const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
 
 
 
 
 
 
 
 
483
 
484
  const [avaliacoesCards, setAvaliacoesCards] = useState([])
485
  const [baseCardId, setBaseCardId] = useState(BASE_COMPARACAO_SEM_BASE)
@@ -672,6 +695,35 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
672
  }
673
  }, [sessionId])
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  useEffect(() => {
676
  if (!avaliacoesCards.length) {
677
  if (baseCardId !== BASE_COMPARACAO_SEM_BASE) {
@@ -825,10 +877,78 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
825
  void onUploadModel(file)
826
  }
827
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
  async function onIncluirAvaliacao() {
829
  if (!sessionId || !camposAvaliacao.length) return
830
  await withBusy(async () => {
831
- const resp = await api.evaluationCalculateViz(sessionId, valoresAvaliacaoRef.current, null)
 
 
 
 
 
832
  const lista = Array.isArray(resp?.avaliacoes) ? resp.avaliacoes : []
833
  const ultima = lista.length ? lista[lista.length - 1] : null
834
  if (!ultima || typeof ultima !== 'object') {
@@ -925,7 +1045,14 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
925
  setKnnDetalheTabela(null)
926
  setKnnDetalheInfo(null)
927
  try {
928
- const resp = await api.evaluationKnnDetailsViz(sessionId, construirPayloadKnnAvaliacao(card.avaliacao))
 
 
 
 
 
 
 
929
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
930
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
931
  setKnnDetalheTabela(resp?.vizinhos_tabela || null)
@@ -1031,6 +1158,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
1031
  const indiceBaseSelecionada = mostrarComparacaoBase
1032
  ? (avaliacoesCards.findIndex((item) => item.id === baseCard?.id) + 1)
1033
  : 0
 
1034
 
1035
  return (
1036
  <div className="tab-content">
@@ -1147,6 +1275,171 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
1147
  </div>
1148
 
1149
  <div className="avaliacao-groups avaliacao-modelos-groups">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
  <div className="subpanel avaliacao-group">
1151
  <h4>Parâmetros</h4>
1152
  <div className="avaliacao-grid" key={`avaliacao-grid-avaliacao-${avaliacaoFormVersion}`}>
 
459
  }
460
 
461
  const BASE_COMPARACAO_SEM_BASE = '__none__'
462
+ const EMPTY_LOCATION_INPUTS = {
463
+ latitude: '',
464
+ longitude: '',
465
+ logradouro: '',
466
+ numero: '',
467
+ cdlog: '',
468
+ }
469
+
470
+ function obterCoordenadasResolvidas(localizacao) {
471
+ const lat = Number(localizacao?.lat)
472
+ const lon = Number(localizacao?.lon)
473
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
474
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null
475
+ return { lat, lon }
476
+ }
477
 
478
  export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
479
  const [loading, setLoading] = useState(false)
 
495
  const [equacaoSab, setEquacaoSab] = useState('')
496
  const valoresAvaliacaoRef = useRef({})
497
  const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
498
+ const [avaliandoLocalizacaoModo, setAvaliandoLocalizacaoModo] = useState('endereco')
499
+ const [avaliandoLocalizacaoInputs, setAvaliandoLocalizacaoInputs] = useState(EMPTY_LOCATION_INPUTS)
500
+ const [avaliandoLocalizacaoResolvida, setAvaliandoLocalizacaoResolvida] = useState(null)
501
+ const [avaliandoLocalizacaoLoading, setAvaliandoLocalizacaoLoading] = useState(false)
502
+ const [avaliandoLocalizacaoError, setAvaliandoLocalizacaoError] = useState('')
503
+ const [avaliandoLocalizacaoStatus, setAvaliandoLocalizacaoStatus] = useState('')
504
+ const [avaliandoLogradouroOptions, setAvaliandoLogradouroOptions] = useState([])
505
+ const [avaliandoLogradouroLoading, setAvaliandoLogradouroLoading] = useState(false)
506
 
507
  const [avaliacoesCards, setAvaliacoesCards] = useState([])
508
  const [baseCardId, setBaseCardId] = useState(BASE_COMPARACAO_SEM_BASE)
 
695
  }
696
  }, [sessionId])
697
 
698
+ useEffect(() => {
699
+ let ativo = true
700
+ if (!sessionId) return () => {
701
+ ativo = false
702
+ }
703
+
704
+ setAvaliandoLogradouroLoading(true)
705
+ api.pesquisarModelos({ somente_contexto: true })
706
+ .then((resp) => {
707
+ if (!ativo) return
708
+ const logradouros = Array.isArray(resp?.sugestoes?.logradouros_eixos)
709
+ ? resp.sugestoes.logradouros_eixos
710
+ : []
711
+ setAvaliandoLogradouroOptions(logradouros)
712
+ })
713
+ .catch(() => {
714
+ if (!ativo) return
715
+ setAvaliandoLogradouroOptions([])
716
+ })
717
+ .finally(() => {
718
+ if (!ativo) return
719
+ setAvaliandoLogradouroLoading(false)
720
+ })
721
+
722
+ return () => {
723
+ ativo = false
724
+ }
725
+ }, [sessionId])
726
+
727
  useEffect(() => {
728
  if (!avaliacoesCards.length) {
729
  if (baseCardId !== BASE_COMPARACAO_SEM_BASE) {
 
877
  void onUploadModel(file)
878
  }
879
 
880
+ function atualizarCampoAvaliandoLocalizacao(campo, valor) {
881
+ setAvaliandoLocalizacaoInputs((prev) => ({
882
+ ...prev,
883
+ [campo]: String(valor ?? ''),
884
+ }))
885
+ }
886
+
887
+ async function onResolverAvaliandoLocalizacao() {
888
+ setAvaliandoLocalizacaoError('')
889
+ setAvaliandoLocalizacaoStatus('')
890
+
891
+ if (avaliandoLocalizacaoModo === 'coords') {
892
+ const lat = Number(avaliandoLocalizacaoInputs.latitude)
893
+ const lon = Number(avaliandoLocalizacaoInputs.longitude)
894
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
895
+ setAvaliandoLocalizacaoError('Informe latitude e longitude válidas para localizar o avaliando.')
896
+ return
897
+ }
898
+ } else {
899
+ if (!String(avaliandoLocalizacaoInputs.logradouro || '').trim()) {
900
+ setAvaliandoLocalizacaoError('Informe o logradouro para localizar o avaliando.')
901
+ return
902
+ }
903
+ const numero = Number(avaliandoLocalizacaoInputs.numero)
904
+ if (!Number.isFinite(numero) || numero <= 0) {
905
+ setAvaliandoLocalizacaoError('Informe um número válido para localizar o avaliando.')
906
+ return
907
+ }
908
+ }
909
+
910
+ setAvaliandoLocalizacaoLoading(true)
911
+ try {
912
+ const response = await api.pesquisarLocalizacaoAvaliando({
913
+ latitude: avaliandoLocalizacaoModo === 'coords' ? Number(avaliandoLocalizacaoInputs.latitude) : null,
914
+ longitude: avaliandoLocalizacaoModo === 'coords' ? Number(avaliandoLocalizacaoInputs.longitude) : null,
915
+ logradouro: avaliandoLocalizacaoModo === 'endereco' ? String(avaliandoLocalizacaoInputs.logradouro || '').trim() : null,
916
+ numero: avaliandoLocalizacaoModo === 'endereco' ? Number(avaliandoLocalizacaoInputs.numero) : null,
917
+ cdlog: avaliandoLocalizacaoModo === 'endereco' && String(avaliandoLocalizacaoInputs.cdlog || '').trim()
918
+ ? Number(avaliandoLocalizacaoInputs.cdlog)
919
+ : null,
920
+ })
921
+ const resolvida = {
922
+ ...response,
923
+ lat: Number(response?.lat),
924
+ lon: Number(response?.lon),
925
+ }
926
+ setAvaliandoLocalizacaoResolvida(resolvida)
927
+ setAvaliandoLocalizacaoStatus(response?.status || 'Localização do avaliando definida.')
928
+ } catch (err) {
929
+ setAvaliandoLocalizacaoError(err?.message || 'Falha ao localizar o avaliando.')
930
+ setAvaliandoLocalizacaoResolvida(null)
931
+ } finally {
932
+ setAvaliandoLocalizacaoLoading(false)
933
+ }
934
+ }
935
+
936
+ function onLimparAvaliandoLocalizacao() {
937
+ setAvaliandoLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
938
+ setAvaliandoLocalizacaoResolvida(null)
939
+ setAvaliandoLocalizacaoError('')
940
+ setAvaliandoLocalizacaoStatus('')
941
+ }
942
+
943
  async function onIncluirAvaliacao() {
944
  if (!sessionId || !camposAvaliacao.length) return
945
  await withBusy(async () => {
946
+ const resp = await api.evaluationCalculateViz(
947
+ sessionId,
948
+ valoresAvaliacaoRef.current,
949
+ null,
950
+ obterCoordenadasResolvidas(avaliandoLocalizacaoResolvida),
951
+ )
952
  const lista = Array.isArray(resp?.avaliacoes) ? resp.avaliacoes : []
953
  const ultima = lista.length ? lista[lista.length - 1] : null
954
  if (!ultima || typeof ultima !== 'object') {
 
1045
  setKnnDetalheTabela(null)
1046
  setKnnDetalheInfo(null)
1047
  try {
1048
+ const resp = await api.evaluationKnnDetailsViz(
1049
+ sessionId,
1050
+ construirPayloadKnnAvaliacao(card.avaliacao),
1051
+ obterCoordenadasResolvidas({
1052
+ lat: card?.avaliacao?.avaliando_lat,
1053
+ lon: card?.avaliacao?.avaliando_lon,
1054
+ }),
1055
+ )
1056
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
1057
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
1058
  setKnnDetalheTabela(resp?.vizinhos_tabela || null)
 
1158
  const indiceBaseSelecionada = mostrarComparacaoBase
1159
  ? (avaliacoesCards.findIndex((item) => item.id === baseCard?.id) + 1)
1160
  : 0
1161
+ const avaliandoLocalizacaoAtiva = Boolean(obterCoordenadasResolvidas(avaliandoLocalizacaoResolvida))
1162
 
1163
  return (
1164
  <div className="tab-content">
 
1275
  </div>
1276
 
1277
  <div className="avaliacao-groups avaliacao-modelos-groups">
1278
+ <div className="subpanel avaliacao-group">
1279
+ <h4>Geolocalização do avaliando</h4>
1280
+ <div className="pesquisa-field-pair pesquisa-localizacao-group">
1281
+ <span className="pesquisa-field-pair-title">Localização para estimativa KNN (opcional)</span>
1282
+ <div className="pesquisa-localizacao-optional-hint">
1283
+ Esta localização é usada apenas para a componente geográfica da estimativa KNN. Se nada for informado, a avaliação continua normal e o KNN usa somente as características do imóvel.
1284
+ </div>
1285
+
1286
+ {avaliandoLocalizacaoModo === 'coords' ? (
1287
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-coords">
1288
+ <label className="pesquisa-field">
1289
+ Forma de localização
1290
+ <select
1291
+ value={avaliandoLocalizacaoModo}
1292
+ onChange={(event) => setAvaliandoLocalizacaoModo(event.target.value)}
1293
+ autoComplete="off"
1294
+ >
1295
+ <option value="endereco">Endereço</option>
1296
+ <option value="coords">Coordenadas</option>
1297
+ </select>
1298
+ </label>
1299
+ <label className="pesquisa-field">
1300
+ Latitude
1301
+ <input
1302
+ type="number"
1303
+ step="any"
1304
+ value={avaliandoLocalizacaoInputs.latitude}
1305
+ onChange={(event) => atualizarCampoAvaliandoLocalizacao('latitude', event.target.value)}
1306
+ placeholder="-30.000000"
1307
+ />
1308
+ </label>
1309
+ <label className="pesquisa-field">
1310
+ Longitude
1311
+ <input
1312
+ type="number"
1313
+ step="any"
1314
+ value={avaliandoLocalizacaoInputs.longitude}
1315
+ onChange={(event) => atualizarCampoAvaliandoLocalizacao('longitude', event.target.value)}
1316
+ placeholder="-51.000000"
1317
+ />
1318
+ </label>
1319
+ <div className="pesquisa-localizacao-actions-inline">
1320
+ <button
1321
+ type="button"
1322
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1323
+ onClick={() => void onResolverAvaliandoLocalizacao()}
1324
+ disabled={avaliandoLocalizacaoLoading}
1325
+ >
1326
+ {avaliandoLocalizacaoLoading ? 'Buscando...' : 'Buscar'}
1327
+ </button>
1328
+ <button
1329
+ type="button"
1330
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1331
+ onClick={onLimparAvaliandoLocalizacao}
1332
+ disabled={avaliandoLocalizacaoLoading}
1333
+ >
1334
+ Limpar
1335
+ </button>
1336
+ </div>
1337
+ </div>
1338
+ ) : (
1339
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-endereco">
1340
+ <label className="pesquisa-field">
1341
+ Forma de localização
1342
+ <select
1343
+ value={avaliandoLocalizacaoModo}
1344
+ onChange={(event) => setAvaliandoLocalizacaoModo(event.target.value)}
1345
+ autoComplete="off"
1346
+ >
1347
+ <option value="endereco">Endereço</option>
1348
+ <option value="coords">Coordenadas</option>
1349
+ </select>
1350
+ </label>
1351
+ <label className="pesquisa-field">
1352
+ CDLOG
1353
+ <input
1354
+ type="number"
1355
+ value={avaliandoLocalizacaoInputs.cdlog}
1356
+ onChange={(event) => atualizarCampoAvaliandoLocalizacao('cdlog', event.target.value)}
1357
+ placeholder="Opcional"
1358
+ />
1359
+ </label>
1360
+ <label className="pesquisa-field pesquisa-localizacao-logradouro-field">
1361
+ Logradouro
1362
+ <SinglePillAutocomplete
1363
+ value={avaliandoLocalizacaoInputs.logradouro}
1364
+ onChange={(nextValue) => atualizarCampoAvaliandoLocalizacao('logradouro', nextValue)}
1365
+ options={avaliandoLogradouroOptions}
1366
+ placeholder={avaliandoLogradouroLoading ? 'Carregando logradouros...' : 'Digite ou selecione um logradouro dos eixos'}
1367
+ panelTitle="Logradouros dos eixos"
1368
+ emptyMessage="Nenhum logradouro encontrado nos eixos."
1369
+ loading={avaliandoLogradouroLoading}
1370
+ inputName="logradouroEixosAvaliacao"
1371
+ inputAutoComplete="new-password"
1372
+ />
1373
+ </label>
1374
+ <label className="pesquisa-field">
1375
+ Número
1376
+ <input
1377
+ type="number"
1378
+ value={avaliandoLocalizacaoInputs.numero}
1379
+ onChange={(event) => atualizarCampoAvaliandoLocalizacao('numero', event.target.value)}
1380
+ placeholder="0"
1381
+ />
1382
+ </label>
1383
+ <div className="pesquisa-localizacao-actions-inline">
1384
+ <button
1385
+ type="button"
1386
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1387
+ onClick={() => void onResolverAvaliandoLocalizacao()}
1388
+ disabled={avaliandoLocalizacaoLoading}
1389
+ >
1390
+ {avaliandoLocalizacaoLoading ? 'Buscando...' : 'Buscar'}
1391
+ </button>
1392
+ <button
1393
+ type="button"
1394
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1395
+ onClick={onLimparAvaliandoLocalizacao}
1396
+ disabled={avaliandoLocalizacaoLoading}
1397
+ >
1398
+ Limpar
1399
+ </button>
1400
+ </div>
1401
+ </div>
1402
+ )}
1403
+
1404
+ {avaliandoLocalizacaoStatus && !avaliandoLocalizacaoAtiva ? <div className="status-line">{avaliandoLocalizacaoStatus}</div> : null}
1405
+ {avaliandoLocalizacaoError ? <div className="error-line inline-error">{avaliandoLocalizacaoError}</div> : null}
1406
+
1407
+ {avaliandoLocalizacaoAtiva ? (
1408
+ <div className="pesquisa-localizacao-summary">
1409
+ {avaliandoLocalizacaoResolvida?.logradouro ? (
1410
+ <div className="pesquisa-localizacao-summary-row">
1411
+ <span className="pesquisa-localizacao-summary-label">Endereço</span>
1412
+ <span className="pesquisa-localizacao-summary-value">
1413
+ {avaliandoLocalizacaoResolvida.logradouro}
1414
+ {avaliandoLocalizacaoResolvida?.numero_usado ? `, ${avaliandoLocalizacaoResolvida.numero_usado}` : ''}
1415
+ </span>
1416
+ </div>
1417
+ ) : null}
1418
+ {avaliandoLocalizacaoResolvida?.cdlog ? (
1419
+ <div className="pesquisa-localizacao-summary-row">
1420
+ <span className="pesquisa-localizacao-summary-label">CDLOG</span>
1421
+ <span className="pesquisa-localizacao-summary-value">{avaliandoLocalizacaoResolvida.cdlog}</span>
1422
+ </div>
1423
+ ) : null}
1424
+ <div className="pesquisa-localizacao-summary-row">
1425
+ <span className="pesquisa-localizacao-summary-label">Latitude</span>
1426
+ <span className="pesquisa-localizacao-summary-value">{Number(avaliandoLocalizacaoResolvida.lat).toFixed(6)}</span>
1427
+ </div>
1428
+ <div className="pesquisa-localizacao-summary-row">
1429
+ <span className="pesquisa-localizacao-summary-label">Longitude</span>
1430
+ <span className="pesquisa-localizacao-summary-value">{Number(avaliandoLocalizacaoResolvida.lon).toFixed(6)}</span>
1431
+ </div>
1432
+ <div className="pesquisa-localizacao-summary-row">
1433
+ <span className="pesquisa-localizacao-summary-label">Origem</span>
1434
+ <span className="pesquisa-localizacao-summary-value">
1435
+ {avaliandoLocalizacaoResolvida?.origem === 'eixos' ? 'Eixos de logradouro' : 'Coordenadas informadas'}
1436
+ </span>
1437
+ </div>
1438
+ </div>
1439
+ ) : null}
1440
+ </div>
1441
+ </div>
1442
+
1443
  <div className="subpanel avaliacao-group">
1444
  <h4>Parâmetros</h4>
1445
  <div className="avaliacao-grid" key={`avaliacao-grid-avaliacao-${avaliacaoFormVersion}`}>
frontend/src/components/InicioTab.jsx CHANGED
@@ -6,10 +6,10 @@ export default function InicioTab() {
6
  <section className="inicio-card">
7
  <h3>Resumo rápido</h3>
8
  <ul className="inicio-lista">
9
- <li><strong>Pesquisa/Visualização:</strong> encontra modelos e abre visualização completa a partir da pesquisa.</li>
10
  <li><strong>Elaboração/Edição:</strong> cria, ajusta e exporta modelos estatísticos.</li>
11
  <li><strong>Avaliação:</strong> compara avaliações de modelos diferentes em cards no mesmo painel.</li>
12
- <li><strong>Repositório de Modelos:</strong> lista, adiciona e remove modelos do acervo central.</li>
13
  </ul>
14
  <p className="inicio-creditos">
15
  Aplicativo criado por Guilherme Silberfarb Costa e David Schuch Bertoglio.
 
6
  <section className="inicio-card">
7
  <h3>Resumo rápido</h3>
8
  <ul className="inicio-lista">
9
+ <li><strong>Modelos Estatísticos:</strong> reúne as subabas de pesquisa e de repositório de modelos.</li>
10
  <li><strong>Elaboração/Edição:</strong> cria, ajusta e exporta modelos estatísticos.</li>
11
  <li><strong>Avaliação:</strong> compara avaliações de modelos diferentes em cards no mesmo painel.</li>
12
+ <li><strong>Trabalhos Técnicos:</strong> lista trabalhos técnicos georreferenciados, seus imóveis e os modelos associados.</li>
13
  </ul>
14
  <p className="inicio-creditos">
15
  Aplicativo criado por Guilherme Silberfarb Costa e David Schuch Bertoglio.
frontend/src/components/ListPagination.jsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ export default function ListPagination({
4
+ totalItems = 0,
5
+ currentPage = 1,
6
+ pageSize = 50,
7
+ onPageChange,
8
+ loading = false,
9
+ }) {
10
+ const total = Math.max(0, Number(totalItems) || 0)
11
+ const tamanhoPagina = Math.max(1, Number(pageSize) || 50)
12
+ const totalPages = Math.max(1, Math.ceil(total / tamanhoPagina))
13
+ const paginaAtual = Math.min(Math.max(1, Number(currentPage) || 1), totalPages)
14
+ const startIndex = total ? ((paginaAtual - 1) * tamanhoPagina) + 1 : 0
15
+ const endIndex = total ? Math.min(total, paginaAtual * tamanhoPagina) : 0
16
+
17
+ return (
18
+ <div className="logs-pagination">
19
+ <span>{total ? `Exibindo ${startIndex}-${endIndex} de ${total}` : 'Exibindo 0 de 0'}</span>
20
+ <div className="logs-pagination-actions">
21
+ <button
22
+ type="button"
23
+ onClick={() => onPageChange?.(Math.max(1, paginaAtual - 1))}
24
+ disabled={loading || paginaAtual <= 1}
25
+ >
26
+ Anterior
27
+ </button>
28
+ <span>Página {paginaAtual} de {totalPages}</span>
29
+ <button
30
+ type="button"
31
+ onClick={() => onPageChange?.(Math.min(totalPages, paginaAtual + 1))}
32
+ disabled={loading || paginaAtual >= totalPages}
33
+ >
34
+ Próxima
35
+ </button>
36
+ </div>
37
+ </div>
38
+ )
39
+ }
frontend/src/components/ModeloTrabalhosTecnicosPanel.jsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import DataTable from './DataTable'
3
+
4
+ function formatarProcessosAdministrativos(item) {
5
+ const processosArray = Array.isArray(item?.processos_administrativos)
6
+ ? item.processos_administrativos.map((valor) => String(valor || '').trim()).filter(Boolean)
7
+ : []
8
+ if (processosArray.length) {
9
+ return processosArray.join(' | ')
10
+ }
11
+
12
+ const processoResumo = String(
13
+ item?.processos_administrativos_resumo || item?.processo_administrativo || ''
14
+ ).trim()
15
+ if (processoResumo) {
16
+ return processoResumo
17
+ }
18
+
19
+ return 'Nenhum registrado'
20
+ }
21
+
22
+ function montarTabelaTrabalhos(trabalhos) {
23
+ const lista = Array.isArray(trabalhos) ? trabalhos : []
24
+ const rows = lista.map((item) => {
25
+ const imoveis = Array.isArray(item?.imoveis) ? item.imoveis : []
26
+ const enderecosTexto = imoveis
27
+ .map((imovel) => String(imovel?.endereco || '').trim())
28
+ .filter(Boolean)
29
+ .join(' | ') || '-'
30
+ return {
31
+ 'Trabalho Técnico': String(item?.trabalho_nome || item?.trabalho_id || '-').trim() || '-',
32
+ Tipo: String(item?.tipo_label || '-').trim() || '-',
33
+ 'Total de Imóveis': item?.total_imoveis ?? 0,
34
+ Endereços: enderecosTexto,
35
+ 'Processos SEI': formatarProcessosAdministrativos(item),
36
+ }
37
+ })
38
+
39
+ return {
40
+ columns: ['Trabalho Técnico', 'Tipo', 'Total de Imóveis', 'Endereços', 'Processos SEI'],
41
+ rows,
42
+ }
43
+ }
44
+
45
+ export default function ModeloTrabalhosTecnicosPanel({ trabalhos }) {
46
+ const lista = Array.isArray(trabalhos) ? trabalhos : []
47
+ if (!lista.length) {
48
+ return <div className="empty-box">Nenhum trabalho técnico vinculado foi encontrado na base atual. Essa base ainda pode não estar completa.</div>
49
+ }
50
+
51
+ return (
52
+ <>
53
+ <div className="section1-empty-hint modelo-trabalhos-tecnicos-disclaimer">
54
+ {lista.length} trabalho(s) técnico(s) utilizando este modelo foram encontrados na base atual. Essa base ainda pode não estar completa.
55
+ </div>
56
+ <DataTable table={montarTabelaTrabalhos(lista)} maxHeight={620} />
57
+ </>
58
+ )
59
+ }
frontend/src/components/ModelosEstatisticosTab.jsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import PesquisaTab from './PesquisaTab'
3
+ import RepositorioTab from './RepositorioTab'
4
+
5
+ const SUBTABS = [
6
+ { key: 'Pesquisar Modelos', label: 'Pesquisar Modelos' },
7
+ { key: 'Repositório de Modelos', label: 'Repositório de Modelos' },
8
+ ]
9
+
10
+ export default function ModelosEstatisticosTab({
11
+ sessionId,
12
+ authUser,
13
+ onUsarModeloEmAvaliacao,
14
+ openRepositorioModeloRequest = null,
15
+ }) {
16
+ const [activeSubtab, setActiveSubtab] = useState('Pesquisar Modelos')
17
+
18
+ useEffect(() => {
19
+ const modeloId = String(openRepositorioModeloRequest?.modeloId || '').trim()
20
+ if (!modeloId) return
21
+ setActiveSubtab('Repositório de Modelos')
22
+ }, [openRepositorioModeloRequest])
23
+
24
+ return (
25
+ <div className="tab-content">
26
+ <div className="inner-tabs" role="tablist" aria-label="Abas de modelos estatísticos">
27
+ {SUBTABS.map((tab) => (
28
+ <button
29
+ key={tab.key}
30
+ type="button"
31
+ className={activeSubtab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
32
+ onClick={() => setActiveSubtab(tab.key)}
33
+ >
34
+ {tab.label}
35
+ </button>
36
+ ))}
37
+ </div>
38
+
39
+ <div className="tab-pane" hidden={activeSubtab !== 'Pesquisar Modelos'}>
40
+ <PesquisaTab sessionId={sessionId} onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao} />
41
+ </div>
42
+
43
+ <div className="tab-pane" hidden={activeSubtab !== 'Repositório de Modelos'}>
44
+ <RepositorioTab
45
+ authUser={authUser}
46
+ sessionId={sessionId}
47
+ openModeloRequest={openRepositorioModeloRequest}
48
+ />
49
+ </div>
50
+ </div>
51
+ )
52
+ }
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -5,6 +5,7 @@ import DataTable from './DataTable'
5
  import EquationFormatsPanel from './EquationFormatsPanel'
6
  import LoadingOverlay from './LoadingOverlay'
7
  import MapFrame from './MapFrame'
 
8
  import PlotFigure from './PlotFigure'
9
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
10
  import SectionBlock from './SectionBlock'
@@ -42,6 +43,7 @@ const RESULT_INITIAL = {
42
 
43
  const PESQUISA_INNER_TABS = [
44
  { key: 'mapa', label: 'Mapa' },
 
45
  { key: 'dados_mercado', label: 'Dados de Mercado' },
46
  { key: 'metricas', label: 'Métricas' },
47
  { key: 'transformacoes', label: 'Transformações' },
@@ -826,9 +828,7 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
826
  const [mapaError, setMapaError] = useState('')
827
  const [mapaStatus, setMapaStatus] = useState('')
828
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
829
- const [mapaLegendas, setMapaLegendas] = useState([])
830
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
831
- const [mapaIdsVisiveis, setMapaIdsVisiveis] = useState([])
832
 
833
  const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
834
  const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
@@ -845,6 +845,7 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
845
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
846
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
847
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
 
848
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
849
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
850
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
@@ -893,20 +894,17 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
893
  function resetMapaPesquisa() {
894
  setMapaHtmls({ pontos: '', cobertura: '' })
895
  setMapaStatus('')
896
- setMapaLegendas([])
897
  setMapaError('')
898
  setMapaModoExibicao('pontos')
899
- setMapaIdsVisiveis([])
900
  }
901
 
902
- async function carregarMapaPesquisa(ids, { atualizarLegendas = false } = {}) {
903
  const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
904
 
905
  if (!idsValidos.length) {
906
  setMapaHtmls({ pontos: '', cobertura: '' })
907
  setMapaStatus('Nenhum modelo marcado para exibição no mapa.')
908
  setMapaError('')
909
- setMapaIdsVisiveis([])
910
  return
911
  }
912
 
@@ -919,10 +917,6 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
919
  cobertura: response.mapa_html_cobertura || '',
920
  })
921
  setMapaStatus(response.status || '')
922
- setMapaIdsVisiveis(idsValidos)
923
- if (atualizarLegendas) {
924
- setMapaLegendas(response.modelos_plotados || [])
925
- }
926
  } catch (err) {
927
  setMapaError(err.message)
928
  } finally {
@@ -1067,6 +1061,7 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1067
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
1068
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
1069
  setModeloAbertoMapaVar('Visualização Padrão')
 
1070
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
1071
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
1072
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
@@ -1140,21 +1135,7 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1140
  }
1141
 
1142
  setMapaModoExibicao('pontos')
1143
- await carregarMapaPesquisa(selectedIds, { atualizarLegendas: true })
1144
- }
1145
-
1146
- async function onToggleLegendaMapa(modeloId) {
1147
- const modeloIdNorm = String(modeloId || '').trim()
1148
- if (!modeloIdNorm || mapaLoading) return
1149
-
1150
- const ativosAtuais = mapaIdsVisiveis.length
1151
- ? mapaIdsVisiveis
1152
- : mapaLegendas.map((item) => item.id)
1153
- const nextIds = ativosAtuais.includes(modeloIdNorm)
1154
- ? ativosAtuais.filter((id) => id !== modeloIdNorm)
1155
- : [...ativosAtuais, modeloIdNorm]
1156
-
1157
- await carregarMapaPesquisa(nextIds, { atualizarLegendas: false })
1158
  }
1159
 
1160
  async function onAdminConfigSalva() {
@@ -1275,6 +1256,10 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1275
  </>
1276
  ) : null}
1277
 
 
 
 
 
1278
  {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
1279
  {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
1280
 
@@ -1780,29 +1765,6 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1780
  </div>
1781
  ) : null}
1782
 
1783
- {mapaLegendas.length ? (
1784
- <div className="pesquisa-legenda-grid">
1785
- {mapaLegendas.map((item) => (
1786
- <label
1787
- key={item.id}
1788
- className={`pesquisa-legenda-item${mapaIdsVisiveis.includes(item.id) ? '' : ' is-disabled'}`}
1789
- >
1790
- <input
1791
- type="checkbox"
1792
- checked={mapaIdsVisiveis.includes(item.id)}
1793
- onChange={() => void onToggleLegendaMapa(item.id)}
1794
- disabled={mapaLoading}
1795
- />
1796
- <span className="pesquisa-legenda-color" style={{ backgroundColor: item.cor }} />
1797
- <span>
1798
- {item.nome} ({item.total_pontos})
1799
- {String(item?.distancia_label || '').trim() ? ` • ${item.distancia_label}` : ''}
1800
- </span>
1801
- </label>
1802
- ))}
1803
- </div>
1804
- ) : null}
1805
-
1806
  {mapaHtmlAtual ? <MapFrame html={mapaHtmlAtual} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
1807
  </SectionBlock>
1808
 
 
5
  import EquationFormatsPanel from './EquationFormatsPanel'
6
  import LoadingOverlay from './LoadingOverlay'
7
  import MapFrame from './MapFrame'
8
+ import ModeloTrabalhosTecnicosPanel from './ModeloTrabalhosTecnicosPanel'
9
  import PlotFigure from './PlotFigure'
10
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
11
  import SectionBlock from './SectionBlock'
 
43
 
44
  const PESQUISA_INNER_TABS = [
45
  { key: 'mapa', label: 'Mapa' },
46
+ { key: 'trabalhos_tecnicos', label: 'Trabalhos Técnicos' },
47
  { key: 'dados_mercado', label: 'Dados de Mercado' },
48
  { key: 'metricas', label: 'Métricas' },
49
  { key: 'transformacoes', label: 'Transformações' },
 
828
  const [mapaError, setMapaError] = useState('')
829
  const [mapaStatus, setMapaStatus] = useState('')
830
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
 
831
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
 
832
 
833
  const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
834
  const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
 
845
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
846
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
847
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
848
+ const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
849
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
850
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
851
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
 
894
  function resetMapaPesquisa() {
895
  setMapaHtmls({ pontos: '', cobertura: '' })
896
  setMapaStatus('')
 
897
  setMapaError('')
898
  setMapaModoExibicao('pontos')
 
899
  }
900
 
901
+ async function carregarMapaPesquisa(ids) {
902
  const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
903
 
904
  if (!idsValidos.length) {
905
  setMapaHtmls({ pontos: '', cobertura: '' })
906
  setMapaStatus('Nenhum modelo marcado para exibição no mapa.')
907
  setMapaError('')
 
908
  return
909
  }
910
 
 
917
  cobertura: response.mapa_html_cobertura || '',
918
  })
919
  setMapaStatus(response.status || '')
 
 
 
 
920
  } catch (err) {
921
  setMapaError(err.message)
922
  } finally {
 
1061
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
1062
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
1063
  setModeloAbertoMapaVar('Visualização Padrão')
1064
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1065
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
1066
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
1067
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
 
1135
  }
1136
 
1137
  setMapaModoExibicao('pontos')
1138
+ await carregarMapaPesquisa(selectedIds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1139
  }
1140
 
1141
  async function onAdminConfigSalva() {
 
1256
  </>
1257
  ) : null}
1258
 
1259
+ {modeloAbertoActiveTab === 'trabalhos_tecnicos' ? (
1260
+ <ModeloTrabalhosTecnicosPanel trabalhos={modeloAbertoTrabalhosTecnicos} />
1261
+ ) : null}
1262
+
1263
  {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
1264
  {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
1265
 
 
1765
  </div>
1766
  ) : null}
1767
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1768
  {mapaHtmlAtual ? <MapFrame html={mapaHtmlAtual} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
1769
  </SectionBlock>
1770
 
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -1,14 +1,19 @@
1
- import React, { useEffect, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import DataTable from './DataTable'
4
  import EquationFormatsPanel from './EquationFormatsPanel'
 
5
  import LoadingOverlay from './LoadingOverlay'
6
  import MapFrame from './MapFrame'
 
7
  import PlotFigure from './PlotFigure'
8
  import { getFaixaDataRecencyInfo } from '../modelRecency'
9
 
 
 
10
  const REPO_INNER_TABS = [
11
  { key: 'mapa', label: 'Mapa' },
 
12
  { key: 'dados_mercado', label: 'Dados de Mercado' },
13
  { key: 'metricas', label: 'Métricas' },
14
  { key: 'transformacoes', label: 'Transformações' },
@@ -30,12 +35,13 @@ function formatarFonte(fonte) {
30
  return 'Pasta local'
31
  }
32
 
33
- export default function RepositorioTab({ authUser, sessionId }) {
34
  const [modelos, setModelos] = useState([])
35
  const [fonte, setFonte] = useState(null)
36
  const [loading, setLoading] = useState(false)
37
  const [uploading, setUploading] = useState(false)
38
  const [deleting, setDeleting] = useState(false)
 
39
  const [error, setError] = useState('')
40
  const [status, setStatus] = useState('')
41
  const [arquivoUpload, setArquivoUpload] = useState(null)
@@ -63,11 +69,13 @@ export default function RepositorioTab({ authUser, sessionId }) {
63
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
64
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
65
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
 
66
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
67
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
68
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
69
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
70
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
 
71
 
72
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
73
  const totalModelos = modelos.length
@@ -81,6 +89,31 @@ export default function RepositorioTab({ authUser, sessionId }) {
81
  void carregarModelos()
82
  }, [])
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  async function carregarModelos() {
85
  setLoading(true)
86
  setError('')
@@ -189,6 +222,7 @@ export default function RepositorioTab({ authUser, sessionId }) {
189
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
190
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
191
  setModeloAbertoMapaVar('Visualização Padrão')
 
192
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
193
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
194
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
@@ -293,6 +327,10 @@ export default function RepositorioTab({ authUser, sessionId }) {
293
  </>
294
  ) : null}
295
 
 
 
 
 
296
  {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
297
  {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
298
 
@@ -343,6 +381,11 @@ export default function RepositorioTab({ authUser, sessionId }) {
343
  )
344
  }
345
 
 
 
 
 
 
346
  return (
347
  <div className="tab-content">
348
  <div className="repositorio-standalone-panel">
@@ -401,7 +444,7 @@ export default function RepositorioTab({ authUser, sessionId }) {
401
  </tr>
402
  </thead>
403
  <tbody>
404
- {modelos.map((item) => {
405
  const key = String(item.id)
406
  const emConfirmacao = confirmDeleteId === key
407
  const periodoRecency = getFaixaDataRecencyInfo(item.periodo_dados)
@@ -481,6 +524,13 @@ export default function RepositorioTab({ authUser, sessionId }) {
481
  </tbody>
482
  </table>
483
  </div>
 
 
 
 
 
 
 
484
  </div>
485
 
486
  {confirmarSubstituicao.open ? (
 
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'
6
  import LoadingOverlay from './LoadingOverlay'
7
  import MapFrame from './MapFrame'
8
+ import ModeloTrabalhosTecnicosPanel from './ModeloTrabalhosTecnicosPanel'
9
  import PlotFigure from './PlotFigure'
10
  import { getFaixaDataRecencyInfo } from '../modelRecency'
11
 
12
+ const PAGE_SIZE = 50
13
+
14
  const REPO_INNER_TABS = [
15
  { key: 'mapa', label: 'Mapa' },
16
+ { key: 'trabalhos_tecnicos', label: 'Trabalhos Técnicos' },
17
  { key: 'dados_mercado', label: 'Dados de Mercado' },
18
  { key: 'metricas', label: 'Métricas' },
19
  { key: 'transformacoes', label: 'Transformações' },
 
35
  return 'Pasta local'
36
  }
37
 
38
+ export default function RepositorioTab({ authUser, sessionId, openModeloRequest = null }) {
39
  const [modelos, setModelos] = useState([])
40
  const [fonte, setFonte] = useState(null)
41
  const [loading, setLoading] = useState(false)
42
  const [uploading, setUploading] = useState(false)
43
  const [deleting, setDeleting] = useState(false)
44
+ const [listaPage, setListaPage] = useState(1)
45
  const [error, setError] = useState('')
46
  const [status, setStatus] = useState('')
47
  const [arquivoUpload, setArquivoUpload] = useState(null)
 
69
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
70
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
71
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
72
+ const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
73
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
74
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
75
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
76
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
77
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
78
+ const lastOpenRequestKeyRef = useRef('')
79
 
80
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
81
  const totalModelos = modelos.length
 
89
  void carregarModelos()
90
  }, [])
91
 
92
+ useEffect(() => {
93
+ const totalPages = Math.max(1, Math.ceil(totalModelos / PAGE_SIZE))
94
+ setListaPage((prev) => Math.min(Math.max(1, prev), totalPages))
95
+ }, [totalModelos])
96
+
97
+ useEffect(() => {
98
+ const requestKey = String(openModeloRequest?.requestKey || '').trim()
99
+ const modeloId = String(openModeloRequest?.modeloId || '').trim()
100
+ if (!sessionId || !requestKey || !modeloId) return
101
+ if (lastOpenRequestKeyRef.current === requestKey) return
102
+ lastOpenRequestKeyRef.current = requestKey
103
+ setModeloAbertoMeta({
104
+ id: modeloId,
105
+ nome: openModeloRequest?.nomeModelo || modeloId,
106
+ observacao: '',
107
+ })
108
+ setModeloAbertoError('')
109
+ setModeloAbertoActiveTab('mapa')
110
+ void onAbrirModelo({
111
+ id: modeloId,
112
+ nome_modelo: openModeloRequest?.nomeModelo || modeloId,
113
+ arquivo: openModeloRequest?.modeloArquivo || '',
114
+ })
115
+ }, [openModeloRequest, sessionId])
116
+
117
  async function carregarModelos() {
118
  setLoading(true)
119
  setError('')
 
222
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
223
  setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
224
  setModeloAbertoMapaVar('Visualização Padrão')
225
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
226
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
227
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
228
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
 
327
  </>
328
  ) : null}
329
 
330
+ {modeloAbertoActiveTab === 'trabalhos_tecnicos' ? (
331
+ <ModeloTrabalhosTecnicosPanel trabalhos={modeloAbertoTrabalhosTecnicos} />
332
+ ) : null}
333
+
334
  {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
335
  {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
336
 
 
381
  )
382
  }
383
 
384
+ const modelosTotalPages = Math.max(1, Math.ceil(totalModelos / PAGE_SIZE))
385
+ const modelosCurrentPage = Math.min(Math.max(1, listaPage), modelosTotalPages)
386
+ const modelosStart = (modelosCurrentPage - 1) * PAGE_SIZE
387
+ const modelosPagina = modelos.slice(modelosStart, modelosStart + PAGE_SIZE)
388
+
389
  return (
390
  <div className="tab-content">
391
  <div className="repositorio-standalone-panel">
 
444
  </tr>
445
  </thead>
446
  <tbody>
447
+ {modelosPagina.map((item) => {
448
  const key = String(item.id)
449
  const emConfirmacao = confirmDeleteId === key
450
  const periodoRecency = getFaixaDataRecencyInfo(item.periodo_dados)
 
524
  </tbody>
525
  </table>
526
  </div>
527
+ <ListPagination
528
+ totalItems={totalModelos}
529
+ currentPage={modelosCurrentPage}
530
+ pageSize={PAGE_SIZE}
531
+ onPageChange={setListaPage}
532
+ loading={loading || uploading || deleting}
533
+ />
534
  </div>
535
 
536
  {confirmarSubstituicao.open ? (
frontend/src/components/TrabalhosTecnicosTab.jsx ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import { api } from '../api'
3
+ import ListPagination from './ListPagination'
4
+ import LoadingOverlay from './LoadingOverlay'
5
+
6
+ const PAGE_SIZE = 50
7
+
8
+ function normalizeSearchText(value) {
9
+ return String(value || '')
10
+ .normalize('NFD')
11
+ .replace(/[\u0300-\u036f]/g, '')
12
+ .toLowerCase()
13
+ .trim()
14
+ }
15
+
16
+ function normalizeDigitsOnly(value) {
17
+ return String(value || '').replace(/\D+/g, '')
18
+ }
19
+
20
+ function listarProcessosAdministrativos(item) {
21
+ const processosArray = Array.isArray(item?.processos_administrativos)
22
+ ? item.processos_administrativos.map((valor) => String(valor || '').trim()).filter(Boolean)
23
+ : []
24
+ if (processosArray.length) return processosArray
25
+
26
+ const processoResumo = String(
27
+ item?.processos_administrativos_resumo || item?.processo_administrativo || ''
28
+ ).trim()
29
+ if (processoResumo) return [processoResumo]
30
+
31
+ return ['Nenhum registrado']
32
+ }
33
+
34
+ export default function TrabalhosTecnicosTab({ sessionId, onAbrirModeloNoRepositorio = null }) {
35
+ const [trabalhos, setTrabalhos] = useState([])
36
+ const [loading, setLoading] = useState(false)
37
+ const [listaPage, setListaPage] = useState(1)
38
+ const [error, setError] = useState('')
39
+ const [filtroTexto, setFiltroTexto] = useState('')
40
+ const [filtroTipo, setFiltroTipo] = useState('')
41
+ const [filtroAno, setFiltroAno] = useState('')
42
+ const [trabalhoAberto, setTrabalhoAberto] = useState(null)
43
+ const [trabalhoLoading, setTrabalhoLoading] = useState(false)
44
+ const [trabalhoError, setTrabalhoError] = useState('')
45
+
46
+ useEffect(() => {
47
+ void carregarTrabalhos()
48
+ }, [])
49
+
50
+ useEffect(() => {
51
+ setListaPage(1)
52
+ }, [filtroTexto, filtroTipo, filtroAno])
53
+
54
+ async function carregarTrabalhos() {
55
+ setLoading(true)
56
+ setError('')
57
+ try {
58
+ const resp = await api.trabalhosTecnicosListar()
59
+ setTrabalhos(Array.isArray(resp?.trabalhos) ? resp.trabalhos : [])
60
+ } catch (err) {
61
+ setError(err.message || 'Falha ao carregar trabalhos técnicos.')
62
+ setTrabalhos([])
63
+ } finally {
64
+ setLoading(false)
65
+ }
66
+ }
67
+
68
+ async function onAbrirTrabalho(item) {
69
+ const trabalhoId = String(item?.id || '').trim()
70
+ if (!trabalhoId) return
71
+ setTrabalhoLoading(true)
72
+ setTrabalhoError('')
73
+ try {
74
+ const resp = await api.trabalhosTecnicosDetalhe(trabalhoId)
75
+ setTrabalhoAberto(resp?.trabalho || null)
76
+ } catch (err) {
77
+ setTrabalhoError(err.message || 'Falha ao abrir trabalho técnico.')
78
+ } finally {
79
+ setTrabalhoLoading(false)
80
+ }
81
+ }
82
+
83
+ function onVoltarLista() {
84
+ setTrabalhoAberto(null)
85
+ setTrabalhoError('')
86
+ }
87
+
88
+ function onAbrirModeloAssociado(modelo) {
89
+ if (!modelo?.disponivel_mesa || typeof onAbrirModeloNoRepositorio !== 'function') return
90
+ onAbrirModeloNoRepositorio({
91
+ modeloId: modelo?.mesa_modelo_id,
92
+ nomeModelo: modelo?.mesa_modelo_nome || modelo?.nome,
93
+ modeloArquivo: modelo?.mesa_modelo_arquivo || '',
94
+ })
95
+ }
96
+
97
+ const tiposDisponiveis = Array.from(
98
+ new Set(trabalhos.map((item) => String(item?.tipo_codigo || '').trim()).filter(Boolean))
99
+ ).sort((a, b) => a.localeCompare(b, 'pt-BR'))
100
+
101
+ const anosDisponiveis = Array.from(
102
+ new Set(trabalhos.map((item) => String(item?.ano || '').trim()).filter(Boolean))
103
+ ).sort((a, b) => Number(b) - Number(a))
104
+
105
+ const textoFiltro = normalizeSearchText(filtroTexto)
106
+ const digitosFiltro = normalizeDigitsOnly(filtroTexto)
107
+ const trabalhosFiltrados = trabalhos.filter((item) => {
108
+ if (filtroTipo && String(item?.tipo_codigo || '') !== filtroTipo) return false
109
+ if (filtroAno && String(item?.ano || '') !== filtroAno) return false
110
+ if (!textoFiltro) return true
111
+ const processosAdministrativos = listarProcessosAdministrativos(item)
112
+ const alvoTexto = [
113
+ item?.nome,
114
+ item?.nome_original,
115
+ item?.tipo_label,
116
+ item?.endereco_resumo,
117
+ item?.modelo_resumo,
118
+ item?.modelo_mesa_principal,
119
+ ...processosAdministrativos,
120
+ ...(Array.isArray(item?.modelos) ? item.modelos.map((modelo) => modelo?.nome) : []),
121
+ ]
122
+ .map((value) => normalizeSearchText(value))
123
+ .join(' ')
124
+ if (alvoTexto.includes(textoFiltro)) return true
125
+
126
+ if (!digitosFiltro) return false
127
+ const processosDigitos = processosAdministrativos
128
+ .map((value) => normalizeDigitsOnly(value))
129
+ .filter(Boolean)
130
+ .join(' ')
131
+ return processosDigitos.includes(digitosFiltro)
132
+ })
133
+ const trabalhosTotalPages = Math.max(1, Math.ceil(trabalhosFiltrados.length / PAGE_SIZE))
134
+ const trabalhosCurrentPage = Math.min(Math.max(1, listaPage), trabalhosTotalPages)
135
+ const trabalhosStart = (trabalhosCurrentPage - 1) * PAGE_SIZE
136
+ const trabalhosPagina = trabalhosFiltrados.slice(trabalhosStart, trabalhosStart + PAGE_SIZE)
137
+
138
+ if (trabalhoAberto) {
139
+ const modelos = Array.isArray(trabalhoAberto?.modelos) ? trabalhoAberto.modelos : []
140
+ const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
141
+ const imoveis = Array.isArray(trabalhoAberto?.imoveis) ? trabalhoAberto.imoveis : []
142
+ return (
143
+ <div className="tab-content">
144
+ <div className="pesquisa-opened-model-view">
145
+ <div className="pesquisa-opened-model-head">
146
+ <div className="pesquisa-opened-model-title-wrap">
147
+ <h3>{trabalhoAberto?.nome || 'Trabalho técnico'}</h3>
148
+ <p>{trabalhoAberto?.tipo_label || 'Tipo não identificado'}</p>
149
+ </div>
150
+ <button
151
+ type="button"
152
+ className="model-source-back-btn model-source-back-btn-danger"
153
+ onClick={onVoltarLista}
154
+ disabled={trabalhoLoading}
155
+ >
156
+ Voltar à lista
157
+ </button>
158
+ </div>
159
+
160
+ <div className="trabalho-tecnico-summary-grid">
161
+ <section className="trabalho-tecnico-card trabalho-tecnico-card-primary">
162
+ <h4>Dados Gerais</h4>
163
+ <div className="trabalho-tecnico-kpis">
164
+ <div className="trabalho-tecnico-kpi">
165
+ <span className="trabalho-tecnico-kpi-label">Tipo</span>
166
+ <strong>{trabalhoAberto?.tipo_label || '-'}</strong>
167
+ </div>
168
+ <div className="trabalho-tecnico-kpi">
169
+ <span className="trabalho-tecnico-kpi-label">Ano</span>
170
+ <strong>{trabalhoAberto?.ano || '-'}</strong>
171
+ </div>
172
+ <div className="trabalho-tecnico-kpi">
173
+ <span className="trabalho-tecnico-kpi-label">Imóveis</span>
174
+ <strong>{trabalhoAberto?.total_imoveis ?? 0}</strong>
175
+ </div>
176
+ <div className="trabalho-tecnico-kpi">
177
+ <span className="trabalho-tecnico-kpi-label">Modelos</span>
178
+ <strong>{trabalhoAberto?.total_modelos ?? 0}</strong>
179
+ </div>
180
+ <div className="trabalho-tecnico-kpi">
181
+ <span className="trabalho-tecnico-kpi-label">Registros</span>
182
+ <strong>{trabalhoAberto?.total_registros_planilha ?? 0}</strong>
183
+ </div>
184
+ </div>
185
+ <div className="trabalho-tecnico-note">
186
+ {trabalhoAberto?.endereco_resumo || 'Endereço não informado'}
187
+ </div>
188
+ </section>
189
+
190
+ <section className="trabalho-tecnico-card">
191
+ <h4>Imóveis Vinculados</h4>
192
+ {imoveis.length ? (
193
+ <div className="trabalho-imoveis-stack">
194
+ {imoveis.map((item, index) => (
195
+ <div key={`${trabalhoAberto?.id || 'trabalho'}-imovel-${index + 1}`} className="trabalho-imovel-card">
196
+ <strong>{item?.label || 'Imóvel sem identificação'}</strong>
197
+ {Array.isArray(item?.modelos) && item.modelos.length ? (
198
+ <span>{item.modelos.join(', ')}</span>
199
+ ) : (
200
+ <span>Sem modelo informado</span>
201
+ )}
202
+ </div>
203
+ ))}
204
+ </div>
205
+ ) : (
206
+ <div className="section1-empty-hint">Nenhum imóvel vinculado.</div>
207
+ )}
208
+ </section>
209
+ </div>
210
+
211
+ <section className="trabalho-tecnico-card">
212
+ <h4>Modelos associados</h4>
213
+ {modelos.length ? (
214
+ <div className="trabalho-model-links">
215
+ {modelos.map((item) => (
216
+ <button
217
+ key={`${trabalhoAberto?.id || 'trabalho'}-modelo-${item?.nome || ''}`}
218
+ type="button"
219
+ className={item?.disponivel_mesa ? 'trabalho-model-link-btn' : 'trabalho-model-link-btn is-disabled'}
220
+ onClick={() => onAbrirModeloAssociado(item)}
221
+ disabled={!item?.disponivel_mesa}
222
+ title={item?.disponivel_mesa ? 'Abrir modelo na MESA' : 'Modelo ainda não disponível na MESA'}
223
+ >
224
+ <span>{item?.mesa_modelo_nome || item?.nome || 'Modelo sem nome'}</span>
225
+ <strong>{item?.disponivel_mesa ? 'Abrir na MESA' : 'Não disponível'}</strong>
226
+ </button>
227
+ ))}
228
+ </div>
229
+ ) : (
230
+ <div className="section1-empty-hint">Nenhum modelo informado para este trabalho.</div>
231
+ )}
232
+ {modelosMesa.length ? (
233
+ <div className="section1-empty-hint">
234
+ Clique em um modelo disponível para abri-lo em Modelos Estatísticos &gt; Repositório de Modelos.
235
+ </div>
236
+ ) : null}
237
+ </section>
238
+
239
+ {trabalhoError ? <div className="error-line inline-error">{trabalhoError}</div> : null}
240
+ </div>
241
+ <LoadingOverlay show={trabalhoLoading} label="Carregando trabalho técnico..." />
242
+ </div>
243
+ )
244
+ }
245
+
246
+ return (
247
+ <div className="tab-content">
248
+ <div className="repositorio-standalone-panel">
249
+ <div className="repo-toolbar">
250
+ <div className="repo-summary">
251
+ <div><strong>Total:</strong> {trabalhos.length}</div>
252
+ </div>
253
+ <div className="repo-actions">
254
+ <button type="button" className="repo-refresh-btn" onClick={() => void carregarTrabalhos()} disabled={loading}>
255
+ Atualizar lista
256
+ </button>
257
+ </div>
258
+ </div>
259
+
260
+ <div className="trabalhos-filters">
261
+ <label className="trabalhos-filter-field">
262
+ Buscar
263
+ <input
264
+ type="text"
265
+ value={filtroTexto}
266
+ onChange={(event) => setFiltroTexto(event.target.value)}
267
+ placeholder="nome, endereço, modelo ou processo"
268
+ autoComplete="off"
269
+ />
270
+ </label>
271
+
272
+ <label className="trabalhos-filter-field">
273
+ Tipo
274
+ <select value={filtroTipo} onChange={(event) => setFiltroTipo(event.target.value)}>
275
+ <option value="">Todos</option>
276
+ {tiposDisponiveis.map((tipo) => (
277
+ <option key={`tipo-${tipo}`} value={tipo}>{tipo}</option>
278
+ ))}
279
+ </select>
280
+ </label>
281
+
282
+ <label className="trabalhos-filter-field">
283
+ Ano
284
+ <select value={filtroAno} onChange={(event) => setFiltroAno(event.target.value)}>
285
+ <option value="">Todos</option>
286
+ {anosDisponiveis.map((ano) => (
287
+ <option key={`ano-${ano}`} value={ano}>{ano}</option>
288
+ ))}
289
+ </select>
290
+ </label>
291
+
292
+ <div className="trabalhos-filter-result">
293
+ {trabalhosFiltrados.length} trabalho(s)
294
+ </div>
295
+ </div>
296
+
297
+ {error ? <div className="error-line">{error}</div> : null}
298
+ {trabalhoError ? <div className="error-line">{trabalhoError}</div> : null}
299
+
300
+ <div className="table-container repo-table-block">
301
+ <table className="repo-table trabalhos-repo-table">
302
+ <colgroup>
303
+ <col className="trabalhos-col-nome" />
304
+ <col className="trabalhos-col-tipo" />
305
+ <col className="trabalhos-col-ano" />
306
+ <col className="trabalhos-col-endereco" />
307
+ <col className="trabalhos-col-modelos" />
308
+ <col className="trabalhos-col-processos" />
309
+ <col className="trabalhos-col-abrir" />
310
+ </colgroup>
311
+ <thead>
312
+ <tr>
313
+ <th className="trabalhos-col-nome">Trabalho</th>
314
+ <th className="trabalhos-col-tipo">Tipo</th>
315
+ <th className="trabalhos-col-ano">Ano</th>
316
+ <th className="trabalhos-col-endereco">Endereço</th>
317
+ <th className="trabalhos-col-modelos">Modelos</th>
318
+ <th className="trabalhos-col-processos">Processos SEI</th>
319
+ <th className="repo-col-open">Abrir</th>
320
+ </tr>
321
+ </thead>
322
+ <tbody>
323
+ {trabalhosPagina.map((item) => (
324
+ <tr key={String(item?.id || '')}>
325
+ <td className="trabalhos-col-nome">{item?.nome || '-'}</td>
326
+ <td className="trabalhos-col-tipo">{item?.tipo_label || item?.tipo_codigo || '-'}</td>
327
+ <td className="trabalhos-col-ano">{item?.ano || '-'}</td>
328
+ <td className="trabalhos-col-endereco">{item?.endereco_resumo || '-'}</td>
329
+ <td className="trabalhos-col-modelos">
330
+ {Array.isArray(item?.modelos) && item.modelos.length ? (
331
+ <div className="trabalho-model-stack">
332
+ {item.modelos.map((modelo) => (
333
+ <div
334
+ key={`${item?.id || 'trabalho'}-lista-modelo-${modelo?.nome || ''}`}
335
+ className="trabalho-model-list-item"
336
+ >
337
+ {modelo?.disponivel_mesa ? (
338
+ <button
339
+ type="button"
340
+ className="trabalho-model-inline-link"
341
+ onClick={() => onAbrirModeloAssociado(modelo)}
342
+ title="Abrir modelo na MESA"
343
+ >
344
+ {modelo?.mesa_modelo_nome || modelo?.nome || '-'}
345
+ </button>
346
+ ) : (
347
+ <span>{modelo?.nome || '-'}</span>
348
+ )}
349
+ </div>
350
+ ))}
351
+ </div>
352
+ ) : (
353
+ item?.modelo_resumo || '-'
354
+ )}
355
+ </td>
356
+ <td className="trabalhos-col-processos">
357
+ <div className="trabalho-model-stack">
358
+ {listarProcessosAdministrativos(item).map((processo, index) => (
359
+ <div
360
+ key={`${item?.id || 'trabalho'}-processo-${index + 1}`}
361
+ className="trabalho-model-list-item"
362
+ >
363
+ {processo}
364
+ </div>
365
+ ))}
366
+ </div>
367
+ </td>
368
+ <td className="repo-col-open">
369
+ <button
370
+ type="button"
371
+ className="repo-open-btn"
372
+ onClick={() => void onAbrirTrabalho(item)}
373
+ title="Abrir trabalho técnico"
374
+ aria-label={`Abrir ${item?.nome || 'trabalho técnico'}`}
375
+ >
376
+
377
+ </button>
378
+ </td>
379
+ </tr>
380
+ ))}
381
+ {!trabalhosFiltrados.length ? (
382
+ <tr>
383
+ <td colSpan={7}>
384
+ {loading ? 'Carregando trabalhos técnicos...' : 'Nenhum trabalho encontrado para os filtros informados.'}
385
+ </td>
386
+ </tr>
387
+ ) : null}
388
+ </tbody>
389
+ </table>
390
+ </div>
391
+ <ListPagination
392
+ totalItems={trabalhosFiltrados.length}
393
+ currentPage={trabalhosCurrentPage}
394
+ pageSize={PAGE_SIZE}
395
+ onPageChange={setListaPage}
396
+ loading={loading}
397
+ />
398
+ </div>
399
+ <LoadingOverlay show={loading} label="Carregando trabalhos técnicos..." />
400
+ </div>
401
+ )
402
+ }
frontend/src/styles.css CHANGED
@@ -400,6 +400,16 @@ textarea {
400
  .logs-filters {
401
  grid-template-columns: 1fr;
402
  }
 
 
 
 
 
 
 
 
 
 
403
  }
404
 
405
  .repositorio-standalone-panel {
@@ -418,6 +428,12 @@ textarea {
418
  flex-wrap: wrap;
419
  }
420
 
 
 
 
 
 
 
421
  .repo-summary {
422
  display: grid;
423
  gap: 4px;
@@ -478,6 +494,30 @@ textarea {
478
  min-width: 880px;
479
  }
480
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  .repo-table th,
482
  .repo-table td {
483
  border-bottom: 1px solid #e1e9f1;
@@ -494,6 +534,25 @@ textarea {
494
  color: #48627a;
495
  }
496
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  .repo-periodo-wrap {
498
  display: inline-flex;
499
  align-items: center;
@@ -595,6 +654,231 @@ textarea {
595
  flex-wrap: wrap;
596
  }
597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  .repo-delete-typing-field {
599
  display: grid;
600
  gap: 6px;
@@ -3584,6 +3868,10 @@ button.btn-upload-select {
3584
  font-size: 0.86rem;
3585
  }
3586
 
 
 
 
 
3587
  .coords-ready-hint {
3588
  color: #1f5e3a;
3589
  font-size: 0.88rem;
 
400
  .logs-filters {
401
  grid-template-columns: 1fr;
402
  }
403
+
404
+ .trabalhos-filters {
405
+ grid-template-columns: 1fr;
406
+ }
407
+
408
+ .trabalho-tecnico-meta-row {
409
+ flex-direction: column;
410
+ align-items: flex-start;
411
+ gap: 4px;
412
+ }
413
  }
414
 
415
  .repositorio-standalone-panel {
 
428
  flex-wrap: wrap;
429
  }
430
 
431
+ .repo-actions {
432
+ display: flex;
433
+ align-items: center;
434
+ gap: 8px;
435
+ }
436
+
437
  .repo-summary {
438
  display: grid;
439
  gap: 4px;
 
494
  min-width: 880px;
495
  }
496
 
497
+ .trabalhos-repo-table {
498
+ table-layout: fixed;
499
+ }
500
+
501
+ .trabalhos-repo-table col.trabalhos-col-nome {
502
+ width: 220px;
503
+ }
504
+
505
+ .trabalhos-repo-table col.trabalhos-col-tipo {
506
+ width: 120px;
507
+ }
508
+
509
+ .trabalhos-repo-table col.trabalhos-col-ano {
510
+ width: 82px;
511
+ }
512
+
513
+ .trabalhos-repo-table col.trabalhos-col-processos {
514
+ width: 190px;
515
+ }
516
+
517
+ .trabalhos-repo-table col.trabalhos-col-abrir {
518
+ width: 68px;
519
+ }
520
+
521
  .repo-table th,
522
  .repo-table td {
523
  border-bottom: 1px solid #e1e9f1;
 
534
  color: #48627a;
535
  }
536
 
537
+ .trabalhos-repo-table th,
538
+ .trabalhos-repo-table td {
539
+ vertical-align: top;
540
+ }
541
+
542
+ .trabalhos-repo-table .trabalhos-col-nome {
543
+ white-space: normal;
544
+ overflow-wrap: anywhere;
545
+ word-break: break-word;
546
+ line-height: 1.4;
547
+ }
548
+
549
+ .trabalhos-repo-table .trabalhos-col-endereco,
550
+ .trabalhos-repo-table .trabalhos-col-modelos {
551
+ white-space: normal;
552
+ overflow-wrap: anywhere;
553
+ word-break: break-word;
554
+ }
555
+
556
  .repo-periodo-wrap {
557
  display: inline-flex;
558
  align-items: center;
 
654
  flex-wrap: wrap;
655
  }
656
 
657
+ .trabalhos-filters {
658
+ margin-top: 14px;
659
+ display: grid;
660
+ grid-template-columns: minmax(240px, 1.6fr) minmax(140px, 0.7fr) minmax(120px, 0.5fr) auto;
661
+ gap: 10px 12px;
662
+ align-items: end;
663
+ }
664
+
665
+ .trabalhos-filter-field {
666
+ display: grid;
667
+ gap: 6px;
668
+ color: #334c64;
669
+ font-size: 0.86rem;
670
+ }
671
+
672
+ .trabalhos-filter-field input,
673
+ .trabalhos-filter-field select {
674
+ min-height: 38px;
675
+ }
676
+
677
+ .trabalhos-filter-result {
678
+ align-self: end;
679
+ color: #47627b;
680
+ font-size: 0.84rem;
681
+ font-weight: 700;
682
+ padding-bottom: 8px;
683
+ white-space: nowrap;
684
+ }
685
+
686
+ .trabalho-tecnico-summary-grid {
687
+ display: grid;
688
+ grid-template-columns: repeat(2, minmax(0, 1fr));
689
+ gap: 12px;
690
+ margin-bottom: 12px;
691
+ }
692
+
693
+ .trabalho-tecnico-card {
694
+ border: 1px solid #dbe5ef;
695
+ border-radius: 12px;
696
+ background: #ffffff;
697
+ padding: 14px;
698
+ display: grid;
699
+ gap: 10px;
700
+ }
701
+
702
+ .trabalho-tecnico-card-primary {
703
+ background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
704
+ }
705
+
706
+ .trabalho-tecnico-card h4 {
707
+ margin: 0;
708
+ color: #2f4760;
709
+ font-family: 'Sora', sans-serif;
710
+ font-size: 0.92rem;
711
+ }
712
+
713
+ .trabalho-tecnico-meta-list {
714
+ display: grid;
715
+ gap: 8px;
716
+ }
717
+
718
+ .trabalho-tecnico-meta-row {
719
+ display: flex;
720
+ justify-content: space-between;
721
+ align-items: baseline;
722
+ gap: 12px;
723
+ color: #344e65;
724
+ }
725
+
726
+ .trabalho-tecnico-meta-label {
727
+ color: #65809a;
728
+ font-size: 0.74rem;
729
+ font-weight: 800;
730
+ letter-spacing: 0.04em;
731
+ text-transform: uppercase;
732
+ }
733
+
734
+ .trabalho-tecnico-note {
735
+ color: #31485f;
736
+ font-size: 0.9rem;
737
+ line-height: 1.5;
738
+ }
739
+
740
+ .trabalho-tecnico-kpis {
741
+ display: grid;
742
+ grid-template-columns: repeat(5, minmax(0, 1fr));
743
+ gap: 10px;
744
+ }
745
+
746
+ .trabalho-tecnico-kpi {
747
+ border: 1px solid #dbe6f0;
748
+ border-radius: 10px;
749
+ background: #ffffff;
750
+ padding: 10px 11px;
751
+ display: grid;
752
+ gap: 4px;
753
+ }
754
+
755
+ .trabalho-tecnico-kpi-label {
756
+ color: #6b849a;
757
+ font-size: 0.72rem;
758
+ font-weight: 800;
759
+ letter-spacing: 0.04em;
760
+ text-transform: uppercase;
761
+ }
762
+
763
+ .trabalho-imoveis-stack {
764
+ display: grid;
765
+ gap: 8px;
766
+ }
767
+
768
+ .trabalho-imovel-card {
769
+ border: 1px solid #dbe6f0;
770
+ border-radius: 10px;
771
+ background: #fbfdff;
772
+ padding: 10px 11px;
773
+ display: grid;
774
+ gap: 4px;
775
+ color: #324c64;
776
+ }
777
+
778
+ .trabalho-imovel-card span {
779
+ color: #5a7288;
780
+ font-size: 0.85rem;
781
+ line-height: 1.4;
782
+ }
783
+
784
+ .trabalho-model-links {
785
+ display: grid;
786
+ gap: 8px;
787
+ }
788
+
789
+ .trabalho-model-stack {
790
+ display: grid;
791
+ gap: 6px;
792
+ }
793
+
794
+ .trabalho-model-list-item {
795
+ color: #28465f;
796
+ line-height: 1.35;
797
+ word-break: break-word;
798
+ }
799
+
800
+ .trabalho-model-inline-link {
801
+ appearance: none;
802
+ border: none;
803
+ background: transparent;
804
+ padding: 0;
805
+ margin: 0;
806
+ color: #1f5d8d;
807
+ font: inherit;
808
+ line-height: 1.35;
809
+ text-align: left;
810
+ text-decoration: underline;
811
+ text-decoration-color: rgba(31, 93, 141, 0.28);
812
+ text-underline-offset: 2px;
813
+ cursor: pointer;
814
+ }
815
+
816
+ .trabalho-model-inline-link:hover {
817
+ color: #15486e;
818
+ text-decoration-color: currentColor;
819
+ }
820
+
821
+ .trabalho-model-inline-link:focus-visible {
822
+ outline: 2px solid rgba(31, 93, 141, 0.25);
823
+ outline-offset: 2px;
824
+ border-radius: 4px;
825
+ }
826
+
827
+ .trabalho-model-link-btn {
828
+ display: flex;
829
+ align-items: center;
830
+ justify-content: space-between;
831
+ gap: 12px;
832
+ width: 100%;
833
+ text-align: left;
834
+ border-radius: 10px;
835
+ padding: 10px 12px;
836
+ --btn-bg-start: #eef7ff;
837
+ --btn-bg-end: #e2f0ff;
838
+ --btn-border: #99bddf;
839
+ --btn-shadow-soft: rgba(73, 122, 169, 0.12);
840
+ --btn-shadow-strong: rgba(73, 122, 169, 0.18);
841
+ color: #1f4f79;
842
+ }
843
+
844
+ .trabalho-model-link-btn strong {
845
+ white-space: nowrap;
846
+ }
847
+
848
+ .trabalho-model-link-btn.is-disabled {
849
+ --btn-bg-start: #f7f9fb;
850
+ --btn-bg-end: #eef3f7;
851
+ --btn-border: #d4dee8;
852
+ --btn-shadow-soft: rgba(84, 104, 123, 0.08);
853
+ --btn-shadow-strong: rgba(84, 104, 123, 0.12);
854
+ color: #6f8598;
855
+ cursor: not-allowed;
856
+ }
857
+
858
+ @media (max-width: 980px) {
859
+ .trabalhos-filters {
860
+ grid-template-columns: repeat(2, minmax(0, 1fr));
861
+ }
862
+
863
+ .trabalhos-filter-result {
864
+ grid-column: 1 / -1;
865
+ padding-bottom: 0;
866
+ }
867
+
868
+ .trabalho-tecnico-summary-grid {
869
+ grid-template-columns: 1fr;
870
+ }
871
+
872
+ .trabalho-tecnico-kpis {
873
+ grid-template-columns: repeat(2, minmax(0, 1fr));
874
+ }
875
+
876
+ .trabalho-model-link-btn {
877
+ flex-direction: column;
878
+ align-items: flex-start;
879
+ }
880
+ }
881
+
882
  .repo-delete-typing-field {
883
  display: grid;
884
  gap: 6px;
 
3868
  font-size: 0.86rem;
3869
  }
3870
 
3871
+ .modelo-trabalhos-tecnicos-disclaimer {
3872
+ margin-bottom: 12px;
3873
+ }
3874
+
3875
  .coords-ready-hint {
3876
  color: #1f5e3a;
3877
  font-size: 0.88rem;
test-results/.last-run.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "status": "failed",
3
+ "failedTests": []
4
+ }