Spaces:
Running
Running
Guilherme Silberfarb Costa commited on
Commit ·
1426bed
1
Parent(s): 3e958a5
repositorio de trabalhos tecncios e implicacoes
Browse files- .gitignore +5 -0
- README.md +17 -0
- backend/app/api/trabalhos_tecnicos.py +46 -0
- backend/app/api/visualizacao.py +40 -6
- backend/app/core/map_layers.py +350 -5
- backend/app/core/visualizacao/app.py +93 -22
- backend/app/main.py +2 -1
- backend/app/services/audit_log_service.py +1 -1
- backend/app/services/pesquisa_service.py +26 -23
- backend/app/services/trabalhos_tecnicos_importer.py +527 -0
- backend/app/services/trabalhos_tecnicos_repository.py +138 -0
- backend/app/services/trabalhos_tecnicos_service.py +557 -0
- backend/app/services/visualizacao_service.py +210 -15
- backend/scripts/build_trabalhos_tecnicos_db.py +39 -0
- frontend/src/App.jsx +33 -8
- frontend/src/api.js +8 -2
- frontend/src/components/AvaliacaoTab.jsx +295 -2
- frontend/src/components/InicioTab.jsx +2 -2
- frontend/src/components/ListPagination.jsx +39 -0
- frontend/src/components/ModeloTrabalhosTecnicosPanel.jsx +59 -0
- frontend/src/components/ModelosEstatisticosTab.jsx +52 -0
- frontend/src/components/PesquisaTab.jsx +10 -48
- frontend/src/components/RepositorioTab.jsx +53 -3
- frontend/src/components/TrabalhosTecnicosTab.jsx +402 -0
- frontend/src/styles.css +288 -0
- test-results/.last-run.json +4 -0
.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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, '&')
|
| 483 |
+
.replace(/</g, '<')
|
| 484 |
+
.replace(/>/g, '>');
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 1039 |
fill=True,
|
| 1040 |
fillColor=cor,
|
| 1041 |
-
fillOpacity=
|
| 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
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 690 |
-
|
| 691 |
-
tooltip_modelo = f'{tooltip_modelo} • Distancia: {modelo["distancia_label"]}'
|
| 692 |
|
| 693 |
-
if renderizar_pontos
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 706 |
marcador.options["mesaBaseRadius"] = 3.0
|
| 707 |
-
if mostrar_indices and
|
| 708 |
add_indice_marker(
|
| 709 |
-
|
| 710 |
lat=float(ponto["lat"]),
|
| 711 |
lon=float(ponto["lon"]),
|
| 712 |
indice=ponto["indice"],
|
| 713 |
)
|
| 714 |
|
| 715 |
-
if renderizar_cobertura
|
| 716 |
-
_adicionar_geometria_modelo_no_mapa(
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 377 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
|
| 411 |
colunas_numericas = [
|
| 412 |
str(col)
|
| 413 |
-
for col in
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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
|
| 7 |
-
import
|
| 8 |
|
| 9 |
const LOGS_PAGE_SIZE = 30
|
| 10 |
|
| 11 |
const TABS = [
|
| 12 |
-
{ key: '
|
| 13 |
{ key: 'Elaboração/Edição', label: 'Elaboração/Edição' },
|
| 14 |
{ key: 'Avaliação', label: 'Avaliação de Imóveis' },
|
| 15 |
-
{ key: '
|
| 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 !== '
|
| 586 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 !== '
|
| 594 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 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>
|
| 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
|
| 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
|
| 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 |
-
{
|
| 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 > 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 |
+
}
|