Spaces:
Running
Running
Guilherme Silberfarb Costa commited on
Commit ·
fef776b
1
Parent(s): 440f380
alteracoes nos mapas e no carregamento de planilha
Browse files- .gitignore +1 -0
- backend/app/api/anexos.py +69 -0
- backend/app/api/pesquisa.py +24 -0
- backend/app/core/anexos/__init__.py +6 -0
- backend/app/core/anexos/banco_dados.py +454 -0
- backend/app/core/anexos/calculo_valor.py +157 -0
- backend/app/core/anexos/estatisticas_utils.py +114 -0
- backend/app/core/anexos/formatters/__init__.py +63 -0
- backend/app/core/anexos/formatters/heading.py +57 -0
- backend/app/core/anexos/formatters/image.py +63 -0
- backend/app/core/anexos/formatters/paragraph.py +102 -0
- backend/app/core/anexos/formatters/section.py +59 -0
- backend/app/core/anexos/formatters/table.py +295 -0
- backend/app/core/anexos/generator.py +130 -0
- backend/app/core/anexos/graficos.py +382 -0
- backend/app/core/anexos/model_data.py +68 -0
- backend/app/core/anexos/model_loader.py +266 -0
- backend/app/core/anexos/planilha_calculo.py +196 -0
- backend/app/main.py +2 -1
- backend/app/services/anexos_service.py +155 -0
- backend/app/services/pesquisa_service.py +167 -36
- backend/app/services/serializers.py +2 -2
- backend/requirements.txt +3 -0
- frontend/src/api.js +9 -0
- frontend/src/components/AnexosModal.jsx +317 -0
- frontend/src/components/AvaliacaoTab.jsx +34 -4
- frontend/src/components/ElaboracaoTab.jsx +4 -66
- frontend/src/components/LeafletMapFrame.jsx +535 -66
- frontend/src/components/PesquisaTab.jsx +7 -32
- frontend/src/components/RepositorioTab.jsx +16 -0
- frontend/src/components/TrabalhosTecnicosTab.jsx +10 -6
- frontend/src/styles.css +259 -46
.gitignore
CHANGED
|
@@ -24,6 +24,7 @@ logs/**/*.jsonl
|
|
| 24 |
backend/local_data/*.sqlite3
|
| 25 |
backend/local_data/*.sqlite3-shm
|
| 26 |
backend/local_data/*.sqlite3-wal
|
|
|
|
| 27 |
|
| 28 |
# Local .dai model repository
|
| 29 |
backend/app/core/pesquisa/modelos_dai/*.dai
|
|
|
|
| 24 |
backend/local_data/*.sqlite3
|
| 25 |
backend/local_data/*.sqlite3-shm
|
| 26 |
backend/local_data/*.sqlite3-wal
|
| 27 |
+
backend/local_data/anexos_gerados/
|
| 28 |
|
| 29 |
# Local .dai model repository
|
| 30 |
backend/app/core/pesquisa/modelos_dai/*.dai
|
backend/app/api/anexos.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Request
|
| 6 |
+
from fastapi.responses import FileResponse
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
from app.services import anexos_service, auth_service
|
| 10 |
+
from app.services.audit_log_service import log_event
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/anexos", tags=["anexos"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class GerarAnexosPayload(BaseModel):
|
| 17 |
+
modelo_id: str = Field(..., min_length=1)
|
| 18 |
+
banco_dados_colunas: list[str] | None = None
|
| 19 |
+
avaliacao: dict[str, Any] | None = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/modelos")
|
| 23 |
+
def modelos(request: Request) -> dict[str, Any]:
|
| 24 |
+
auth_service.require_user(request)
|
| 25 |
+
return anexos_service.listar_modelos()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/modelos/{modelo_id}/columns")
|
| 29 |
+
def modelo_colunas(modelo_id: str, request: Request) -> dict[str, Any]:
|
| 30 |
+
auth_service.require_user(request)
|
| 31 |
+
return anexos_service.obter_colunas(modelo_id)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("/generate")
|
| 35 |
+
def gerar(payload: GerarAnexosPayload, request: Request) -> dict[str, Any]:
|
| 36 |
+
user = auth_service.require_user(request)
|
| 37 |
+
caminho, avisos = anexos_service.gerar_documento(
|
| 38 |
+
payload.modelo_id,
|
| 39 |
+
banco_dados_colunas=payload.banco_dados_colunas,
|
| 40 |
+
avaliacao=payload.avaliacao,
|
| 41 |
+
)
|
| 42 |
+
log_event(
|
| 43 |
+
"visualizacao",
|
| 44 |
+
"gerar_anexos_modelo",
|
| 45 |
+
user=user,
|
| 46 |
+
request=request,
|
| 47 |
+
details={
|
| 48 |
+
"modelo_id": payload.modelo_id,
|
| 49 |
+
"com_avaliacao": bool(payload.avaliacao),
|
| 50 |
+
"total_colunas": len(payload.banco_dados_colunas or []),
|
| 51 |
+
},
|
| 52 |
+
)
|
| 53 |
+
return {
|
| 54 |
+
"message": f"Anexos gerados com sucesso: {caminho.name}",
|
| 55 |
+
"filename": caminho.name,
|
| 56 |
+
"statistical_warnings": avisos,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.get("/download/{filename}")
|
| 61 |
+
def download(filename: str, request: Request) -> FileResponse:
|
| 62 |
+
auth_service.require_user(request)
|
| 63 |
+
caminho = anexos_service.resolver_download(filename)
|
| 64 |
+
return FileResponse(
|
| 65 |
+
path=caminho,
|
| 66 |
+
filename=caminho.name,
|
| 67 |
+
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 68 |
+
headers={"Cache-Control": "no-store"},
|
| 69 |
+
)
|
backend/app/api/pesquisa.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
from fastapi import APIRouter, HTTPException, Query
|
|
|
|
| 7 |
from pydantic import BaseModel
|
| 8 |
|
| 9 |
from app.services.pesquisa_service import (
|
| 10 |
PesquisaFiltros,
|
|
|
|
| 11 |
gerar_mapa_modelos,
|
| 12 |
listar_logradouros_eixos,
|
| 13 |
listar_modelos,
|
|
@@ -75,6 +78,15 @@ class PesquisaAdminConfigPayload(BaseModel):
|
|
| 75 |
campos: dict[str, list[str]] = {}
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
@router.get("/admin-config")
|
| 79 |
def pesquisar_admin_config() -> dict:
|
| 80 |
return obter_admin_config_pesquisa()
|
|
@@ -224,3 +236,15 @@ def pesquisar_mapa_modelos(payload: MapaModelosPayload) -> dict:
|
|
| 224 |
trabalhos_tecnicos_proximidade_modo=proximidade_modo,
|
| 225 |
trabalhos_tecnicos_raio_m=payload.trabalhos_tecnicos_raio_m,
|
| 226 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import os
|
| 5 |
from typing import Any
|
| 6 |
|
| 7 |
from fastapi import APIRouter, HTTPException, Query
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
from app.services.pesquisa_service import (
|
| 12 |
PesquisaFiltros,
|
| 13 |
+
exportar_indices_mapa_pesquisa_xlsx,
|
| 14 |
gerar_mapa_modelos,
|
| 15 |
listar_logradouros_eixos,
|
| 16 |
listar_modelos,
|
|
|
|
| 78 |
campos: dict[str, list[str]] = {}
|
| 79 |
|
| 80 |
|
| 81 |
+
class MapaIndicesModeloPayload(BaseModel):
|
| 82 |
+
nome: Any = None
|
| 83 |
+
indices: list[Any] = []
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class MapaIndicesExportPayload(BaseModel):
|
| 87 |
+
modelos: list[MapaIndicesModeloPayload] = []
|
| 88 |
+
|
| 89 |
+
|
| 90 |
@router.get("/admin-config")
|
| 91 |
def pesquisar_admin_config() -> dict:
|
| 92 |
return obter_admin_config_pesquisa()
|
|
|
|
| 236 |
trabalhos_tecnicos_proximidade_modo=proximidade_modo,
|
| 237 |
trabalhos_tecnicos_raio_m=payload.trabalhos_tecnicos_raio_m,
|
| 238 |
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@router.post("/mapa-indices-selecionados/export")
|
| 242 |
+
def exportar_mapa_indices_selecionados(payload: MapaIndicesExportPayload) -> FileResponse:
|
| 243 |
+
caminho, nome = exportar_indices_mapa_pesquisa_xlsx(
|
| 244 |
+
[item.model_dump() for item in payload.modelos]
|
| 245 |
+
)
|
| 246 |
+
return FileResponse(
|
| 247 |
+
path=caminho,
|
| 248 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 249 |
+
filename=os.path.basename(nome),
|
| 250 |
+
)
|
backend/app/core/anexos/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Geração dos anexos técnicos de modelos de avaliação."""
|
| 2 |
+
|
| 3 |
+
from .generator import AnexosGenerator
|
| 4 |
+
from .model_loader import load_model, model_data_from_package
|
| 5 |
+
|
| 6 |
+
__all__ = ["AnexosGenerator", "load_model", "model_data_from_package"]
|
backend/app/core/anexos/banco_dados.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gerador do Anexo de Banco de Dados.
|
| 3 |
+
Gera a tabela com os dados das amostras (Xy_preview).
|
| 4 |
+
"""
|
| 5 |
+
from datetime import date, datetime
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
from numbers import Number
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import re
|
| 10 |
+
from typing import Optional, Sequence
|
| 11 |
+
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from docx import Document
|
| 14 |
+
|
| 15 |
+
from .model_data import ModelData
|
| 16 |
+
from .formatters import (
|
| 17 |
+
add_placeholder_text,
|
| 18 |
+
add_simple_table,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Hífen que não permite quebra de linha (non-breaking hyphen)
|
| 22 |
+
HIFEN_NAO_QUEBRAVEL = '\u2011'
|
| 23 |
+
COLUNAS_COORDENADAS = {"lat", "lon"}
|
| 24 |
+
PESO_COLUNA_DICOTOMICA = 0.45
|
| 25 |
+
PESO_COLUNA_RH = 0.5
|
| 26 |
+
PESO_COLUNA_DATA = 1.45
|
| 27 |
+
PESO_COLUNA_TEXTO = 2.0
|
| 28 |
+
CASAS_DECIMAIS_COORDENADAS = 5
|
| 29 |
+
FONTE_TABELA_PT = 8
|
| 30 |
+
EMU_POR_POLEGADA = 914400
|
| 31 |
+
DPI_MEDICAO = 96
|
| 32 |
+
FOLGA_CELULA_POLEGADAS = 0.18
|
| 33 |
+
FOLGA_CABECALHO_POLEGADAS = 0.16
|
| 34 |
+
FATOR_SEGURANCA_CABECALHO = 1.1
|
| 35 |
+
LARGURA_MINIMA_COLUNA_POLEGADAS = 0.28
|
| 36 |
+
LARGURA_MINIMA_DATA_POLEGADAS = 0.74
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _formatar_numero(
|
| 40 |
+
val: float,
|
| 41 |
+
casas_decimais: int = 2,
|
| 42 |
+
separador_milhar: bool = True,
|
| 43 |
+
) -> str:
|
| 44 |
+
"""
|
| 45 |
+
Formata número em padrão brasileiro, substituindo hífen por hífen não-quebrável.
|
| 46 |
+
Evita que o Word quebre a linha entre o sinal negativo e o número.
|
| 47 |
+
"""
|
| 48 |
+
if separador_milhar:
|
| 49 |
+
texto = f"{val:,.{casas_decimais}f}".replace(',', 'X').replace('.', ',').replace('X', '.')
|
| 50 |
+
else:
|
| 51 |
+
texto = f"{val:.{casas_decimais}f}".replace('.', ',')
|
| 52 |
+
return texto.replace('-', HIFEN_NAO_QUEBRAVEL)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _polegadas_para_emu(valor: float) -> int:
|
| 56 |
+
return int(valor * EMU_POR_POLEGADA)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _valor_vazio(val) -> bool:
|
| 60 |
+
if val is None:
|
| 61 |
+
return True
|
| 62 |
+
|
| 63 |
+
vazio = pd.isna(val)
|
| 64 |
+
return bool(vazio) if isinstance(vazio, bool) else False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _valores_nao_vazios(series: pd.Series) -> list:
|
| 68 |
+
return [val for val in series.tolist() if not _valor_vazio(val)]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _nome_coluna_normalizado(col) -> str:
|
| 72 |
+
return str(col).strip().lower()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _is_coluna_coordenada(col) -> bool:
|
| 76 |
+
return _nome_coluna_normalizado(col) in COLUNAS_COORDENADAS
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _is_coluna_rh(col) -> bool:
|
| 80 |
+
return _nome_coluna_normalizado(col) == "rh"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _is_numero(val) -> bool:
|
| 84 |
+
return isinstance(val, Number) and not isinstance(val, bool)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _is_coluna_data(col, series: pd.Series) -> bool:
|
| 88 |
+
if pd.api.types.is_datetime64_any_dtype(series):
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
valores = _valores_nao_vazios(series)
|
| 92 |
+
if not valores:
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
if all(isinstance(val, (pd.Timestamp, datetime, date)) for val in valores):
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
if not all(isinstance(val, str) for val in valores):
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
nome_indica_data = "data" in _nome_coluna_normalizado(col) or "date" in _nome_coluna_normalizado(col)
|
| 102 |
+
textos_tem_separador_data = any(re.search(r"\d[/-]\d", val) for val in valores)
|
| 103 |
+
if not nome_indica_data and not textos_tem_separador_data:
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
datas = pd.to_datetime(pd.Series(valores), errors="coerce", dayfirst=True)
|
| 107 |
+
return bool(datas.notna().all())
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _is_coluna_texto_puro(col, series: pd.Series) -> bool:
|
| 111 |
+
valores = _valores_nao_vazios(series)
|
| 112 |
+
return bool(valores) and all(isinstance(val, str) for val in valores) and not _is_coluna_data(col, series)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _formatar_data(val) -> str:
|
| 116 |
+
data = pd.to_datetime(val, errors="coerce", dayfirst=True)
|
| 117 |
+
if pd.isna(data):
|
| 118 |
+
return str(val)
|
| 119 |
+
return data.strftime("%d/%m/%Y")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _formatar_valor_tabela(val, col, colunas_inteiras: set, colunas_data: set) -> str:
|
| 123 |
+
if _valor_vazio(val):
|
| 124 |
+
return ""
|
| 125 |
+
|
| 126 |
+
if col in colunas_data:
|
| 127 |
+
return _formatar_data(val)
|
| 128 |
+
|
| 129 |
+
if _is_coluna_coordenada(col) and _is_numero(val):
|
| 130 |
+
return _formatar_numero(
|
| 131 |
+
float(val),
|
| 132 |
+
casas_decimais=CASAS_DECIMAIS_COORDENADAS,
|
| 133 |
+
separador_milhar=False,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
if col in colunas_inteiras and _is_numero(val):
|
| 137 |
+
texto = str(int(val))
|
| 138 |
+
return texto.replace('-', HIFEN_NAO_QUEBRAVEL)
|
| 139 |
+
|
| 140 |
+
if _is_numero(val):
|
| 141 |
+
return _formatar_numero(float(val), casas_decimais=2)
|
| 142 |
+
|
| 143 |
+
return str(val)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@lru_cache(maxsize=1)
|
| 147 |
+
def _carregar_fonte_medicao():
|
| 148 |
+
try:
|
| 149 |
+
from PIL import ImageFont
|
| 150 |
+
except ImportError:
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
tamanho_px = round(FONTE_TABELA_PT * DPI_MEDICAO / 72)
|
| 154 |
+
caminhos = [
|
| 155 |
+
Path("/System/Library/Fonts/Supplemental/Arial.ttf"),
|
| 156 |
+
Path("/Library/Fonts/Arial.ttf"),
|
| 157 |
+
Path("/usr/share/fonts/truetype/msttcorefonts/Arial.ttf"),
|
| 158 |
+
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
for caminho in caminhos:
|
| 162 |
+
if caminho.exists():
|
| 163 |
+
try:
|
| 164 |
+
return ImageFont.truetype(str(caminho), tamanho_px)
|
| 165 |
+
except OSError:
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
return ImageFont.load_default()
|
| 170 |
+
except OSError:
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _medir_texto_emu_com_folga(texto: str, folga_polegadas: float, minimo_polegadas: float) -> int:
|
| 175 |
+
texto = str(texto)
|
| 176 |
+
fonte = _carregar_fonte_medicao()
|
| 177 |
+
|
| 178 |
+
if fonte is not None:
|
| 179 |
+
bbox = fonte.getbbox(texto)
|
| 180 |
+
largura_px = bbox[2] - bbox[0]
|
| 181 |
+
else:
|
| 182 |
+
largura_px = len(texto) * (FONTE_TABELA_PT * 0.58)
|
| 183 |
+
|
| 184 |
+
largura_polegadas = largura_px / DPI_MEDICAO + folga_polegadas
|
| 185 |
+
return max(
|
| 186 |
+
_polegadas_para_emu(minimo_polegadas),
|
| 187 |
+
_polegadas_para_emu(largura_polegadas),
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _medir_texto_emu(texto: str) -> int:
|
| 192 |
+
return _medir_texto_emu_com_folga(
|
| 193 |
+
texto,
|
| 194 |
+
FOLGA_CELULA_POLEGADAS,
|
| 195 |
+
LARGURA_MINIMA_COLUNA_POLEGADAS,
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _medir_cabecalho_emu(texto: str) -> int:
|
| 200 |
+
largura = _medir_texto_emu_com_folga(texto, FOLGA_CABECALHO_POLEGADAS, 0)
|
| 201 |
+
return int(largura * FATOR_SEGURANCA_CABECALHO)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _largura_util_pagina_emu(doc: Document) -> int:
|
| 205 |
+
section = doc.sections[-1]
|
| 206 |
+
return int(section.page_width - section.left_margin - section.right_margin)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _calcular_larguras_naturais(
|
| 210 |
+
dados: list[list[str]],
|
| 211 |
+
colunas_df: Sequence,
|
| 212 |
+
colunas_data: set,
|
| 213 |
+
) -> list[int]:
|
| 214 |
+
larguras = []
|
| 215 |
+
|
| 216 |
+
for indice, header in enumerate(dados[0]):
|
| 217 |
+
valores = [row[indice] for row in dados[1:] if row[indice]]
|
| 218 |
+
if indice == 0:
|
| 219 |
+
valores = valores or ["1"]
|
| 220 |
+
else:
|
| 221 |
+
col = colunas_df[indice - 1]
|
| 222 |
+
if col in colunas_data:
|
| 223 |
+
valores.append("00/00/0000")
|
| 224 |
+
|
| 225 |
+
largura = max(_medir_texto_emu(valor) for valor in valores) if valores else _medir_texto_emu("")
|
| 226 |
+
|
| 227 |
+
if indice > 0 and colunas_df[indice - 1] in colunas_data:
|
| 228 |
+
largura = max(largura, _polegadas_para_emu(LARGURA_MINIMA_DATA_POLEGADAS))
|
| 229 |
+
|
| 230 |
+
larguras.append(largura)
|
| 231 |
+
|
| 232 |
+
return larguras
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _largura_rh_emu() -> int:
|
| 236 |
+
return _medir_texto_emu("999")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _aplicar_larguras_especiais(
|
| 240 |
+
larguras: Sequence[int],
|
| 241 |
+
dados: list[list[str]],
|
| 242 |
+
colunas_df: Sequence,
|
| 243 |
+
) -> list[int]:
|
| 244 |
+
larguras_ajustadas = [int(largura) for largura in larguras]
|
| 245 |
+
|
| 246 |
+
valores_id = [row[0] for row in dados[1:] if row[0]]
|
| 247 |
+
if valores_id:
|
| 248 |
+
larguras_ajustadas[0] = max(
|
| 249 |
+
larguras_ajustadas[0],
|
| 250 |
+
max(_medir_texto_emu(valor) for valor in valores_id),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
for indice, col in enumerate(colunas_df, start=1):
|
| 254 |
+
if _is_coluna_rh(col):
|
| 255 |
+
larguras_ajustadas[indice] = _largura_rh_emu()
|
| 256 |
+
|
| 257 |
+
return larguras_ajustadas
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _calcular_larguras_fallback(
|
| 261 |
+
colunas_df: Sequence,
|
| 262 |
+
colunas_data: set,
|
| 263 |
+
colunas_texto_puro: set,
|
| 264 |
+
colunas_dicotomicas: set,
|
| 265 |
+
) -> list[float]:
|
| 266 |
+
larguras = [0.5]
|
| 267 |
+
for col in colunas_df:
|
| 268 |
+
if _is_coluna_rh(col):
|
| 269 |
+
larguras.append(PESO_COLUNA_RH)
|
| 270 |
+
elif col in colunas_dicotomicas:
|
| 271 |
+
larguras.append(PESO_COLUNA_DICOTOMICA)
|
| 272 |
+
elif col in colunas_texto_puro:
|
| 273 |
+
larguras.append(PESO_COLUNA_TEXTO)
|
| 274 |
+
elif col in colunas_data:
|
| 275 |
+
larguras.append(PESO_COLUNA_DATA)
|
| 276 |
+
else:
|
| 277 |
+
larguras.append(1)
|
| 278 |
+
return larguras
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _converter_pesos_para_larguras_absolutas(
|
| 282 |
+
pesos: Sequence[float],
|
| 283 |
+
largura_total: int,
|
| 284 |
+
) -> list[int]:
|
| 285 |
+
total = sum(pesos)
|
| 286 |
+
return [int(largura_total * peso / total) for peso in pesos]
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _calcular_rotacoes_cabecalho(
|
| 290 |
+
headers: Sequence[str],
|
| 291 |
+
larguras_finais: Sequence[int],
|
| 292 |
+
colunas_df: Sequence,
|
| 293 |
+
colunas_dicotomicas: set,
|
| 294 |
+
) -> list[bool]:
|
| 295 |
+
rotacoes = []
|
| 296 |
+
for indice, (header, largura) in enumerate(zip(headers, larguras_finais)):
|
| 297 |
+
if indice > 0 and (
|
| 298 |
+
colunas_df[indice - 1] in colunas_dicotomicas
|
| 299 |
+
or _is_coluna_rh(colunas_df[indice - 1])
|
| 300 |
+
):
|
| 301 |
+
rotacoes.append(True)
|
| 302 |
+
else:
|
| 303 |
+
rotacoes.append(len(str(header)) > 4 and _medir_cabecalho_emu(header) > largura)
|
| 304 |
+
return rotacoes
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _colunas_dicotomicas_na_tabela(modelo: ModelData, colunas_df: Sequence) -> set:
|
| 308 |
+
dicotomicas = {str(col) for col in modelo.variaveis_dicotomicas}
|
| 309 |
+
return {col for col in colunas_df if str(col) in dicotomicas}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _formatar_cabecalho_coluna(col) -> str:
|
| 313 |
+
if _is_coluna_coordenada(col):
|
| 314 |
+
return str(col).upper()
|
| 315 |
+
return str(col)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _filtrar_dataframe_banco_dados(
|
| 319 |
+
modelo: Optional[ModelData],
|
| 320 |
+
colunas: Optional[Sequence[str]] = None,
|
| 321 |
+
) -> Optional[pd.DataFrame]:
|
| 322 |
+
if modelo is None or modelo.xy_preview.empty:
|
| 323 |
+
return None
|
| 324 |
+
|
| 325 |
+
df = modelo.xy_preview
|
| 326 |
+
colunas_por_nome = {str(col): col for col in df.columns.tolist()}
|
| 327 |
+
colunas_disponiveis = list(colunas_por_nome.keys())
|
| 328 |
+
|
| 329 |
+
if colunas is None:
|
| 330 |
+
return df
|
| 331 |
+
|
| 332 |
+
colunas_validas = [
|
| 333 |
+
colunas_por_nome[col]
|
| 334 |
+
for col in colunas
|
| 335 |
+
if col in colunas_disponiveis
|
| 336 |
+
]
|
| 337 |
+
if not colunas_validas:
|
| 338 |
+
return df.iloc[:, 0:0]
|
| 339 |
+
|
| 340 |
+
return df.loc[:, colunas_validas]
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _montar_dados_tabela(df: pd.DataFrame) -> tuple[list[list[str]], set, set]:
|
| 344 |
+
colunas_data = {
|
| 345 |
+
col for col in df.columns if _is_coluna_data(col, df[col])
|
| 346 |
+
}
|
| 347 |
+
colunas_texto_puro = {
|
| 348 |
+
col for col in df.columns if _is_coluna_texto_puro(col, df[col])
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
# Detectar colunas que contêm apenas valores inteiros
|
| 352 |
+
colunas_inteiras = set()
|
| 353 |
+
for col in df.columns:
|
| 354 |
+
valores = _valores_nao_vazios(df[col])
|
| 355 |
+
if len(valores) > 0 and all(
|
| 356 |
+
_is_numero(v) and float(v) == int(v)
|
| 357 |
+
for v in valores
|
| 358 |
+
):
|
| 359 |
+
colunas_inteiras.add(col)
|
| 360 |
+
|
| 361 |
+
headers = ["ID"] + [_formatar_cabecalho_coluna(col) for col in df.columns.tolist()]
|
| 362 |
+
dados = [headers]
|
| 363 |
+
|
| 364 |
+
for idx, (_, row) in enumerate(df.iterrows(), start=1):
|
| 365 |
+
linha = [str(idx)] # ID da amostra (1-based)
|
| 366 |
+
for col, val in zip(df.columns, row.values):
|
| 367 |
+
linha.append(_formatar_valor_tabela(val, col, colunas_inteiras, colunas_data))
|
| 368 |
+
dados.append(linha)
|
| 369 |
+
|
| 370 |
+
return dados, colunas_data, colunas_texto_puro
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def anexo_banco_dados_cabe_na_largura(
|
| 374 |
+
modelo: Optional[ModelData],
|
| 375 |
+
colunas: Optional[Sequence[str]],
|
| 376 |
+
largura_util: int,
|
| 377 |
+
) -> bool:
|
| 378 |
+
"""Retorna True quando a tabela cabe usando larguras naturais por valor."""
|
| 379 |
+
df = _filtrar_dataframe_banco_dados(modelo, colunas)
|
| 380 |
+
if df is None or df.empty:
|
| 381 |
+
return True
|
| 382 |
+
|
| 383 |
+
dados, colunas_data, _ = _montar_dados_tabela(df)
|
| 384 |
+
larguras_naturais = _calcular_larguras_naturais(dados, df.columns.tolist(), colunas_data)
|
| 385 |
+
larguras_naturais = _aplicar_larguras_especiais(larguras_naturais, dados, df.columns.tolist())
|
| 386 |
+
return sum(larguras_naturais) <= largura_util
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def gerar_anexo_banco_dados(
|
| 390 |
+
doc: Document,
|
| 391 |
+
modelo: Optional[ModelData],
|
| 392 |
+
colunas: Optional[Sequence[str]] = None,
|
| 393 |
+
) -> None:
|
| 394 |
+
"""
|
| 395 |
+
Gera o conteúdo do Anexo de Banco de Dados.
|
| 396 |
+
|
| 397 |
+
Args:
|
| 398 |
+
doc: Documento DOCX
|
| 399 |
+
modelo: Dados do modelo carregado do .dai
|
| 400 |
+
colunas: Colunas do banco de dados a exibir, na ordem desejada.
|
| 401 |
+
Quando omitido, todas as colunas são exibidas.
|
| 402 |
+
"""
|
| 403 |
+
if modelo is None:
|
| 404 |
+
add_placeholder_text(doc, "[Modelo não selecionado - inserir banco de dados manualmente]")
|
| 405 |
+
return
|
| 406 |
+
|
| 407 |
+
if modelo.xy_preview.empty:
|
| 408 |
+
add_placeholder_text(doc, "[Banco de dados não disponível no modelo]")
|
| 409 |
+
return
|
| 410 |
+
|
| 411 |
+
df = _filtrar_dataframe_banco_dados(modelo, colunas)
|
| 412 |
+
if df is None or df.empty:
|
| 413 |
+
add_placeholder_text(doc, "[Nenhuma coluna válida selecionada para o banco de dados]")
|
| 414 |
+
return
|
| 415 |
+
|
| 416 |
+
dados, colunas_data, colunas_texto_puro = _montar_dados_tabela(df)
|
| 417 |
+
headers = dados[0]
|
| 418 |
+
colunas_df = df.columns.tolist()
|
| 419 |
+
colunas_dicotomicas = _colunas_dicotomicas_na_tabela(modelo, colunas_df)
|
| 420 |
+
|
| 421 |
+
largura_util = _largura_util_pagina_emu(doc)
|
| 422 |
+
larguras_naturais = _calcular_larguras_naturais(dados, colunas_df, colunas_data)
|
| 423 |
+
usar_largura_natural = sum(larguras_naturais) <= largura_util
|
| 424 |
+
|
| 425 |
+
if usar_largura_natural:
|
| 426 |
+
larguras_finais = larguras_naturais
|
| 427 |
+
else:
|
| 428 |
+
pesos_fallback = _calcular_larguras_fallback(
|
| 429 |
+
colunas_df,
|
| 430 |
+
colunas_data,
|
| 431 |
+
colunas_texto_puro,
|
| 432 |
+
colunas_dicotomicas,
|
| 433 |
+
)
|
| 434 |
+
larguras_finais = _converter_pesos_para_larguras_absolutas(pesos_fallback, largura_util)
|
| 435 |
+
|
| 436 |
+
larguras_finais = _aplicar_larguras_especiais(larguras_finais, dados, colunas_df)
|
| 437 |
+
|
| 438 |
+
rotacoes_cabecalho = _calcular_rotacoes_cabecalho(
|
| 439 |
+
headers,
|
| 440 |
+
larguras_finais,
|
| 441 |
+
colunas_df,
|
| 442 |
+
colunas_dicotomicas,
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
add_simple_table(
|
| 446 |
+
doc,
|
| 447 |
+
dados,
|
| 448 |
+
header_row=True,
|
| 449 |
+
largura_colunas=larguras_finais,
|
| 450 |
+
rotacao_cabecalho=rotacoes_cabecalho,
|
| 451 |
+
largura_total=largura_util,
|
| 452 |
+
largura_colunas_absolutas=True,
|
| 453 |
+
no_quebrar_linha=True,
|
| 454 |
+
)
|
backend/app/core/anexos/calculo_valor.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Geração do Anexo IV, com preenchimento opcional por uma avaliação da mesa."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from numbers import Number
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from docx import Document
|
| 8 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 9 |
+
|
| 10 |
+
from .formatters import (
|
| 11 |
+
add_body_text,
|
| 12 |
+
add_placeholder_text,
|
| 13 |
+
add_simple_table,
|
| 14 |
+
add_subsection_title,
|
| 15 |
+
criar_paragrafo_formatado,
|
| 16 |
+
)
|
| 17 |
+
from .model_data import ModelData
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _numero(value: Any) -> float | None:
|
| 21 |
+
try:
|
| 22 |
+
numero = float(value)
|
| 23 |
+
except (TypeError, ValueError):
|
| 24 |
+
return None
|
| 25 |
+
return numero
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _formatar_numero(value: Any, casas: int = 2) -> str:
|
| 29 |
+
numero = _numero(value)
|
| 30 |
+
if numero is None:
|
| 31 |
+
return "-" if value in (None, "") else str(value)
|
| 32 |
+
return f"{numero:,.{casas}f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _formatar_moeda(value: Any) -> str:
|
| 36 |
+
numero = _numero(value)
|
| 37 |
+
return "-" if numero is None else f"R$ {_formatar_numero(numero)}"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _formatar_percentual(value: Any) -> str:
|
| 41 |
+
numero = _numero(value)
|
| 42 |
+
return "-" if numero is None else f"{_formatar_numero(numero, 1)}%"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _rotulo_estimado(tipo_y: str) -> tuple[str, str]:
|
| 46 |
+
if tipo_y == "unitario":
|
| 47 |
+
return "Valor unitário estimado", "Valor total estimado"
|
| 48 |
+
if tipo_y == "total":
|
| 49 |
+
return "Valor total estimado", "Valor unitário estimado"
|
| 50 |
+
return "Valor estimado", ""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def gerar_anexo_calculo(
|
| 54 |
+
doc: Document,
|
| 55 |
+
modelo: ModelData | None,
|
| 56 |
+
avaliacao: dict[str, Any] | None = None,
|
| 57 |
+
) -> None:
|
| 58 |
+
if modelo is None:
|
| 59 |
+
add_placeholder_text(doc, "[Modelo não selecionado - inserir cálculo manualmente]")
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
if modelo.equacao:
|
| 63 |
+
add_subsection_title(doc, "", "Equação do Modelo")
|
| 64 |
+
criar_paragrafo_formatado(
|
| 65 |
+
doc,
|
| 66 |
+
modelo.equacao,
|
| 67 |
+
tamanho=11,
|
| 68 |
+
alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
|
| 69 |
+
espaco_depois=12,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
if modelo.variaveis:
|
| 73 |
+
add_subsection_title(doc, "", "Variáveis do Modelo")
|
| 74 |
+
for variavel in modelo.variaveis:
|
| 75 |
+
add_body_text(doc, f"• {variavel}")
|
| 76 |
+
|
| 77 |
+
doc.add_paragraph()
|
| 78 |
+
|
| 79 |
+
if not modelo.tabelas_coef.empty:
|
| 80 |
+
add_subsection_title(doc, "", "Coeficientes")
|
| 81 |
+
df = modelo.tabelas_coef
|
| 82 |
+
coluna_coef = next(
|
| 83 |
+
(col for col in ("coef", "Coeficiente", "coeficiente") if col in df.columns),
|
| 84 |
+
None,
|
| 85 |
+
)
|
| 86 |
+
if coluna_coef:
|
| 87 |
+
for indice, row in df.iterrows():
|
| 88 |
+
coeficiente = row[coluna_coef]
|
| 89 |
+
texto = f"{float(coeficiente):.6f}" if isinstance(coeficiente, Number) else str(coeficiente)
|
| 90 |
+
add_body_text(doc, f"• {indice}: {texto}")
|
| 91 |
+
|
| 92 |
+
doc.add_paragraph()
|
| 93 |
+
add_subsection_title(doc, "", "Cálculo do Valor do Imóvel")
|
| 94 |
+
|
| 95 |
+
if not avaliacao:
|
| 96 |
+
add_placeholder_text(doc, "[Inserir valores das variáveis para o imóvel avaliado]")
|
| 97 |
+
add_placeholder_text(doc, "[Inserir cálculo e resultado final]")
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
_adicionar_avaliacao(doc, avaliacao)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _adicionar_avaliacao(doc: Document, avaliacao: dict[str, Any]) -> None:
|
| 104 |
+
valores_x = avaliacao.get("valores_x")
|
| 105 |
+
valores_x = valores_x if isinstance(valores_x, dict) else {}
|
| 106 |
+
coluna_area = str(avaliacao.get("coluna_area") or "").strip()
|
| 107 |
+
valor_area = avaliacao.get("valor_area")
|
| 108 |
+
|
| 109 |
+
valores_tabela = [["Variável", "Valor informado"]]
|
| 110 |
+
for variavel, valor in valores_x.items():
|
| 111 |
+
valores_tabela.append([str(variavel), _formatar_numero(valor)])
|
| 112 |
+
if coluna_area and coluna_area not in valores_x and valor_area is not None:
|
| 113 |
+
valores_tabela.append([coluna_area, _formatar_numero(valor_area)])
|
| 114 |
+
|
| 115 |
+
add_body_text(doc, "Valores utilizados:", indent=False)
|
| 116 |
+
if len(valores_tabela) > 1:
|
| 117 |
+
add_simple_table(doc, valores_tabela, header_row=True)
|
| 118 |
+
|
| 119 |
+
tipo_y = str(avaliacao.get("tipo_y") or "").strip().lower()
|
| 120 |
+
rotulo_principal, rotulo_convertido = _rotulo_estimado(tipo_y)
|
| 121 |
+
resultado_tabela = [["Resultado", "Valor"]]
|
| 122 |
+
resultado_tabela.append([rotulo_principal, _formatar_moeda(avaliacao.get("estimado"))])
|
| 123 |
+
|
| 124 |
+
if rotulo_convertido:
|
| 125 |
+
chave_convertida = "estimado_total" if tipo_y == "unitario" else "estimado_unitario"
|
| 126 |
+
if avaliacao.get(chave_convertida) is not None:
|
| 127 |
+
resultado_tabela.append([rotulo_convertido, _formatar_moeda(avaliacao.get(chave_convertida))])
|
| 128 |
+
|
| 129 |
+
resultado_tabela.extend(
|
| 130 |
+
[
|
| 131 |
+
["Campo de arbítrio -15%", _formatar_moeda(avaliacao.get("ca_inf"))],
|
| 132 |
+
["Campo de arbítrio +15%", _formatar_moeda(avaliacao.get("ca_sup"))],
|
| 133 |
+
[
|
| 134 |
+
"Intervalo de confiança 80% - inferior",
|
| 135 |
+
f"{_formatar_moeda(avaliacao.get('ic_inf'))} ({_formatar_percentual(avaliacao.get('perc_inf'))})",
|
| 136 |
+
],
|
| 137 |
+
[
|
| 138 |
+
"Intervalo de confiança 80% - superior",
|
| 139 |
+
f"{_formatar_moeda(avaliacao.get('ic_sup'))} ({_formatar_percentual(avaliacao.get('perc_sup'))})",
|
| 140 |
+
],
|
| 141 |
+
["Amplitude do intervalo", _formatar_percentual(avaliacao.get("amplitude"))],
|
| 142 |
+
["Grau de precisão", str(avaliacao.get("precisao") or "-")],
|
| 143 |
+
["Grau de fundamentação", str(avaliacao.get("fundamentacao") or "-")],
|
| 144 |
+
]
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
if bool(avaliacao.get("houve_extrapolacao")):
|
| 148 |
+
resultado_tabela.append(
|
| 149 |
+
["Quantidade de extrapolações", _formatar_numero(avaliacao.get("qtd_extrapolacoes"), 0)]
|
| 150 |
+
)
|
| 151 |
+
if avaliacao.get("fronteira") is not None:
|
| 152 |
+
resultado_tabela.append(["Valor estimado na fronteira", _formatar_moeda(avaliacao.get("fronteira"))])
|
| 153 |
+
if avaliacao.get("perc_ext") is not None:
|
| 154 |
+
resultado_tabela.append(["Impacto da extrapolação", _formatar_percentual(avaliacao.get("perc_ext"))])
|
| 155 |
+
|
| 156 |
+
add_subsection_title(doc, "", "Resultado da Avaliação")
|
| 157 |
+
add_simple_table(doc, resultado_tabela, header_row=True)
|
backend/app/core/anexos/estatisticas_utils.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utilitários para processamento de estatísticas do modelo.
|
| 3 |
+
Funções compartilhadas entre interface_estatisticas e geração de documentos.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Dict, Any, Optional
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def reorganizar_modelos_resumos(resumo_original: Dict[str, Any]) -> Dict[str, Any]:
|
| 10 |
+
"""
|
| 11 |
+
Reorganiza a estrutura do dicionário modelos_resumos para uma
|
| 12 |
+
estrutura mais organizada com estatísticas gerais e testes separados.
|
| 13 |
+
|
| 14 |
+
Parâmetros
|
| 15 |
+
----------
|
| 16 |
+
resumo_original : dict
|
| 17 |
+
Dicionário original com as chaves do modelo.
|
| 18 |
+
|
| 19 |
+
Retorna
|
| 20 |
+
-------
|
| 21 |
+
dict
|
| 22 |
+
Dicionário reorganizado com a nova estrutura.
|
| 23 |
+
"""
|
| 24 |
+
if not resumo_original:
|
| 25 |
+
return {}
|
| 26 |
+
|
| 27 |
+
return {
|
| 28 |
+
"estatisticas_gerais": {
|
| 29 |
+
"n": {
|
| 30 |
+
"nome": "Número de observações",
|
| 31 |
+
"valor": resumo_original.get("n")
|
| 32 |
+
},
|
| 33 |
+
"k": {
|
| 34 |
+
"nome": "Número de variáveis independentes",
|
| 35 |
+
"valor": resumo_original.get("k")
|
| 36 |
+
},
|
| 37 |
+
"desvio_padrao_residuos": {
|
| 38 |
+
"nome": "Desvio padrão dos resíduos",
|
| 39 |
+
"valor": resumo_original.get("desvio_padrao_residuos")
|
| 40 |
+
},
|
| 41 |
+
"mse": {
|
| 42 |
+
"nome": "MSE",
|
| 43 |
+
"valor": resumo_original.get("mse")
|
| 44 |
+
},
|
| 45 |
+
"r2": {
|
| 46 |
+
"nome": "R²",
|
| 47 |
+
"valor": resumo_original.get("r2")
|
| 48 |
+
},
|
| 49 |
+
"r2_ajustado": {
|
| 50 |
+
"nome": "R² ajustado",
|
| 51 |
+
"valor": resumo_original.get("r2_ajustado")
|
| 52 |
+
},
|
| 53 |
+
"r_pearson": {
|
| 54 |
+
"nome": "Correlação Pearson",
|
| 55 |
+
"valor": resumo_original.get("r_pearson")
|
| 56 |
+
}
|
| 57 |
+
},
|
| 58 |
+
"teste_f": {
|
| 59 |
+
"nome": "Teste F",
|
| 60 |
+
"estatistica": resumo_original.get("Fc"),
|
| 61 |
+
"pvalor": resumo_original.get("p_valor_F"),
|
| 62 |
+
"interpretacao": resumo_original.get("Interpretacao_F")
|
| 63 |
+
},
|
| 64 |
+
"teste_ks": {
|
| 65 |
+
"nome": "Teste de Normalidade (Kolmogorov-Smirnov)",
|
| 66 |
+
"estatistica": resumo_original.get("ks_stat"),
|
| 67 |
+
"pvalor": resumo_original.get("ks_p"),
|
| 68 |
+
"interpretacao": resumo_original.get("Interpretacao_KS")
|
| 69 |
+
},
|
| 70 |
+
"perc_resid": {
|
| 71 |
+
"nome": "Teste de Normalidade (Comparação com a Curva Normal)",
|
| 72 |
+
"valor": resumo_original.get("perc_resid"),
|
| 73 |
+
"interpretacao": [
|
| 74 |
+
"Ideal 68% → aceitável entre 64% e 75%",
|
| 75 |
+
"Ideal 90% → aceitável entre 88% e 95%",
|
| 76 |
+
"Ideal 95% → aceitável entre 95% e 100%"
|
| 77 |
+
]
|
| 78 |
+
},
|
| 79 |
+
"teste_dw": {
|
| 80 |
+
"nome": "Teste de Autocorrelação (Durbin-Watson)",
|
| 81 |
+
"estatistica": resumo_original.get("dw"),
|
| 82 |
+
"interpretacao": resumo_original.get("Interpretacao_DW")
|
| 83 |
+
},
|
| 84 |
+
"teste_bp": {
|
| 85 |
+
"nome": "Teste de Homocedasticidade (Breusch-Pagan)",
|
| 86 |
+
"estatistica": resumo_original.get("bp_lm"),
|
| 87 |
+
"pvalor": resumo_original.get("bp_p"),
|
| 88 |
+
"interpretacao": resumo_original.get("Interpretacao_BP")
|
| 89 |
+
},
|
| 90 |
+
"equacao": resumo_original.get("equacao")
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def formatar_numero(valor: Any, casas_decimais: int = 4) -> str:
|
| 95 |
+
"""
|
| 96 |
+
Formata número com casas decimais específicas.
|
| 97 |
+
|
| 98 |
+
Parâmetros
|
| 99 |
+
----------
|
| 100 |
+
valor : Any
|
| 101 |
+
Valor a ser formatado.
|
| 102 |
+
casas_decimais : int
|
| 103 |
+
Número de casas decimais (default: 4).
|
| 104 |
+
|
| 105 |
+
Retorna
|
| 106 |
+
-------
|
| 107 |
+
str
|
| 108 |
+
Valor formatado como string.
|
| 109 |
+
"""
|
| 110 |
+
if valor is None:
|
| 111 |
+
return "N/A"
|
| 112 |
+
if isinstance(valor, (int, float, np.floating)):
|
| 113 |
+
return f"{valor:.{casas_decimais}f}"
|
| 114 |
+
return str(valor)
|
backend/app/core/anexos/formatters/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatadores de documentos DOCX.
|
| 3 |
+
"""
|
| 4 |
+
from .paragraph import (
|
| 5 |
+
aplicar_cor_run,
|
| 6 |
+
criar_paragrafo_formatado,
|
| 7 |
+
add_body_text,
|
| 8 |
+
add_placeholder_text,
|
| 9 |
+
add_bullet_text,
|
| 10 |
+
)
|
| 11 |
+
from .heading import (
|
| 12 |
+
add_heading_custom,
|
| 13 |
+
add_section_title,
|
| 14 |
+
add_subsection_title,
|
| 15 |
+
add_subsubsection_title,
|
| 16 |
+
add_subsubsubsection_title,
|
| 17 |
+
)
|
| 18 |
+
from .table import (
|
| 19 |
+
set_cell_shading,
|
| 20 |
+
formatar_celula_tabela,
|
| 21 |
+
add_simple_table,
|
| 22 |
+
configurar_linha_tabela_altura,
|
| 23 |
+
configurar_linha_cabecalho_repetir,
|
| 24 |
+
criar_celula_cabecalho_tabela,
|
| 25 |
+
criar_celula_dados_tabela,
|
| 26 |
+
)
|
| 27 |
+
from .image import (
|
| 28 |
+
inserir_imagem_de_documento,
|
| 29 |
+
add_image_placeholder,
|
| 30 |
+
)
|
| 31 |
+
from .section import (
|
| 32 |
+
iniciar_secao_paisagem,
|
| 33 |
+
iniciar_secao_retrato,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
# Parágrafos
|
| 38 |
+
'aplicar_cor_run',
|
| 39 |
+
'criar_paragrafo_formatado',
|
| 40 |
+
'add_body_text',
|
| 41 |
+
'add_placeholder_text',
|
| 42 |
+
'add_bullet_text',
|
| 43 |
+
# Títulos
|
| 44 |
+
'add_heading_custom',
|
| 45 |
+
'add_section_title',
|
| 46 |
+
'add_subsection_title',
|
| 47 |
+
'add_subsubsection_title',
|
| 48 |
+
'add_subsubsubsection_title',
|
| 49 |
+
# Tabelas
|
| 50 |
+
'set_cell_shading',
|
| 51 |
+
'formatar_celula_tabela',
|
| 52 |
+
'add_simple_table',
|
| 53 |
+
'configurar_linha_tabela_altura',
|
| 54 |
+
'configurar_linha_cabecalho_repetir',
|
| 55 |
+
'criar_celula_cabecalho_tabela',
|
| 56 |
+
'criar_celula_dados_tabela',
|
| 57 |
+
# Imagens
|
| 58 |
+
'inserir_imagem_de_documento',
|
| 59 |
+
'add_image_placeholder',
|
| 60 |
+
# Seções
|
| 61 |
+
'iniciar_secao_paisagem',
|
| 62 |
+
'iniciar_secao_retrato',
|
| 63 |
+
]
|
backend/app/core/anexos/formatters/heading.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatadores de títulos e seções.
|
| 3 |
+
"""
|
| 4 |
+
from docx import Document
|
| 5 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 6 |
+
|
| 7 |
+
from .paragraph import criar_paragrafo_formatado
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def add_heading_custom(doc: Document, text: str, level: int = 1):
|
| 11 |
+
"""Adiciona título customizado."""
|
| 12 |
+
configs = {
|
| 13 |
+
1: {'tamanho': 14, 'antes': 18, 'depois': 12},
|
| 14 |
+
2: {'tamanho': 12, 'antes': 12, 'depois': 6},
|
| 15 |
+
}
|
| 16 |
+
cfg = configs.get(level, {'tamanho': 11, 'antes': 6, 'depois': 6})
|
| 17 |
+
|
| 18 |
+
return criar_paragrafo_formatado(
|
| 19 |
+
doc, text, negrito=True, sublinhado=True, tamanho=cfg['tamanho'],
|
| 20 |
+
espaco_antes=cfg['antes'], espaco_depois=cfg['depois'],
|
| 21 |
+
alinhamento=WD_ALIGN_PARAGRAPH.LEFT
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def add_section_title(doc: Document, number: str, text: str):
|
| 26 |
+
"""Adiciona título de seção principal (ex: 1. SOLICITAÇÃO)."""
|
| 27 |
+
return criar_paragrafo_formatado(
|
| 28 |
+
doc, f"{number}. {text}", negrito=True, tamanho=12,
|
| 29 |
+
espaco_antes=18, espaco_depois=12, alinhamento=WD_ALIGN_PARAGRAPH.LEFT
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def add_subsection_title(doc: Document, number: str, text: str):
|
| 34 |
+
"""Adiciona título de subseção (ex: 1.1 Considerações)."""
|
| 35 |
+
return criar_paragrafo_formatado(
|
| 36 |
+
doc, f"{number} {text}", negrito=True, tamanho=11,
|
| 37 |
+
espaco_antes=12, espaco_depois=6, recuo_esq=0.3,
|
| 38 |
+
alinhamento=WD_ALIGN_PARAGRAPH.LEFT
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def add_subsubsection_title(doc: Document, number: str, text: str):
|
| 43 |
+
"""Adiciona título de sub-subseção (ex: 3.2.1)."""
|
| 44 |
+
return criar_paragrafo_formatado(
|
| 45 |
+
doc, f"{number} {text}", negrito=True, tamanho=11,
|
| 46 |
+
espaco_antes=10, espaco_depois=4, recuo_esq=0.5,
|
| 47 |
+
alinhamento=WD_ALIGN_PARAGRAPH.LEFT
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def add_subsubsubsection_title(doc: Document, number: str, text: str):
|
| 52 |
+
"""Adiciona título de sub-sub-subseção (ex: 3.2.1.1)."""
|
| 53 |
+
return criar_paragrafo_formatado(
|
| 54 |
+
doc, f"{number} {text}", negrito=True, tamanho=10,
|
| 55 |
+
espaco_antes=8, espaco_depois=4, recuo_esq=0.7,
|
| 56 |
+
alinhamento=WD_ALIGN_PARAGRAPH.LEFT
|
| 57 |
+
)
|
backend/app/core/anexos/formatters/image.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatadores de imagens.
|
| 3 |
+
"""
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
from docx import Document
|
| 6 |
+
from docx.shared import Inches
|
| 7 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 8 |
+
from typing import Dict, Any, Optional
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def inserir_imagem_de_documento(
|
| 12 |
+
doc: Document,
|
| 13 |
+
imagem_data: Optional[Dict[str, Any]],
|
| 14 |
+
largura_inches: float = 5.5
|
| 15 |
+
) -> bool:
|
| 16 |
+
"""
|
| 17 |
+
Insere uma imagem extraída de outro documento.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
doc: Documento destino
|
| 21 |
+
imagem_data: Dict com 'paragraph_element' e 'doc' (documento origem)
|
| 22 |
+
largura_inches: Largura da imagem
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
True se inseriu com sucesso, False caso contrário
|
| 26 |
+
"""
|
| 27 |
+
if not imagem_data:
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
para_elem = imagem_data['paragraph_element']
|
| 32 |
+
doc_origem = imagem_data['doc']
|
| 33 |
+
|
| 34 |
+
ns_a = '{http://schemas.openxmlformats.org/drawingml/2006/main}'
|
| 35 |
+
ns_r = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}'
|
| 36 |
+
|
| 37 |
+
for blip in para_elem.iter(f'{ns_a}blip'):
|
| 38 |
+
embed_id = blip.get(f'{ns_r}embed')
|
| 39 |
+
if embed_id:
|
| 40 |
+
try:
|
| 41 |
+
image_part = doc_origem.part.related_parts[embed_id]
|
| 42 |
+
image_stream = BytesIO(image_part.blob)
|
| 43 |
+
|
| 44 |
+
p = doc.add_paragraph()
|
| 45 |
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 46 |
+
run = p.add_run()
|
| 47 |
+
run.add_picture(image_stream, width=Inches(largura_inches))
|
| 48 |
+
return True
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"Erro ao copiar imagem: {e}")
|
| 51 |
+
return False
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"Erro ao processar imagem: {e}")
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def add_image_placeholder(doc: Document) -> None:
|
| 60 |
+
"""Adiciona placeholder de imagem."""
|
| 61 |
+
p = doc.add_paragraph()
|
| 62 |
+
p.add_run("[IMAGEM]").italic = True
|
| 63 |
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
backend/app/core/anexos/formatters/paragraph.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatadores de parágrafos.
|
| 3 |
+
"""
|
| 4 |
+
from docx import Document
|
| 5 |
+
from docx.shared import Pt, Inches, Cm, RGBColor
|
| 6 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 7 |
+
from typing import Optional, Tuple, Union
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def aplicar_cor_run(run, cor: Optional[Union[Tuple[int, int, int], RGBColor]]) -> None:
|
| 11 |
+
"""
|
| 12 |
+
Aplica cor RGB a um run.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
run: Run do python-docx
|
| 16 |
+
cor: tuple (r,g,b) ou RGBColor
|
| 17 |
+
"""
|
| 18 |
+
if cor:
|
| 19 |
+
if isinstance(cor, tuple):
|
| 20 |
+
run.font.color.rgb = RGBColor(cor[0], cor[1], cor[2])
|
| 21 |
+
else:
|
| 22 |
+
run.font.color.rgb = cor
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def criar_paragrafo_formatado(
|
| 26 |
+
doc: Document,
|
| 27 |
+
texto: str,
|
| 28 |
+
negrito: bool = False,
|
| 29 |
+
sublinhado: bool = False,
|
| 30 |
+
italico: bool = False,
|
| 31 |
+
tamanho: int = 11,
|
| 32 |
+
cor: Optional[Union[Tuple[int, int, int], RGBColor]] = None,
|
| 33 |
+
alinhamento=WD_ALIGN_PARAGRAPH.JUSTIFY,
|
| 34 |
+
espaco_antes: int = 0,
|
| 35 |
+
espaco_depois: int = 6,
|
| 36 |
+
recuo_esq: float = 0,
|
| 37 |
+
recuo_primeira_linha: float = 0,
|
| 38 |
+
fonte: str = 'Arial'
|
| 39 |
+
):
|
| 40 |
+
"""
|
| 41 |
+
Cria um parágrafo com formatação completa.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
doc: Documento python-docx
|
| 45 |
+
texto: Texto do parágrafo
|
| 46 |
+
negrito, sublinhado, italico: Formatação do texto
|
| 47 |
+
tamanho: Tamanho da fonte em pontos
|
| 48 |
+
cor: RGBColor ou tuple (r,g,b)
|
| 49 |
+
alinhamento: WD_ALIGN_PARAGRAPH constant
|
| 50 |
+
espaco_antes, espaco_depois: Espaçamento em pontos
|
| 51 |
+
recuo_esq: Recuo esquerdo em inches
|
| 52 |
+
recuo_primeira_linha: Recuo da primeira linha em cm
|
| 53 |
+
fonte: Nome da fonte
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Parágrafo criado
|
| 57 |
+
"""
|
| 58 |
+
p = doc.add_paragraph()
|
| 59 |
+
run = p.add_run(texto)
|
| 60 |
+
|
| 61 |
+
run.bold = negrito
|
| 62 |
+
run.underline = sublinhado
|
| 63 |
+
run.italic = italico
|
| 64 |
+
run.font.size = Pt(tamanho)
|
| 65 |
+
run.font.name = fonte
|
| 66 |
+
aplicar_cor_run(run, cor)
|
| 67 |
+
|
| 68 |
+
p.alignment = alinhamento
|
| 69 |
+
p.paragraph_format.space_before = Pt(espaco_antes)
|
| 70 |
+
p.paragraph_format.space_after = Pt(espaco_depois)
|
| 71 |
+
if recuo_esq:
|
| 72 |
+
p.paragraph_format.left_indent = Inches(recuo_esq)
|
| 73 |
+
if recuo_primeira_linha:
|
| 74 |
+
p.paragraph_format.first_line_indent = Cm(recuo_primeira_linha)
|
| 75 |
+
|
| 76 |
+
return p
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def add_body_text(doc: Document, text: str, cor=None, indent: bool = True):
|
| 80 |
+
"""Adiciona texto do corpo com formatação padrão."""
|
| 81 |
+
return criar_paragrafo_formatado(
|
| 82 |
+
doc, text, tamanho=11, cor=cor,
|
| 83 |
+
espaco_depois=6, recuo_esq=0.3 if indent else 0,
|
| 84 |
+
recuo_primeira_linha=1.25 if indent else 0
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def add_placeholder_text(doc: Document, text: str = "[Preencher informações]", indent: bool = True):
|
| 89 |
+
"""Adiciona texto placeholder em vermelho."""
|
| 90 |
+
return criar_paragrafo_formatado(
|
| 91 |
+
doc, text, tamanho=11, cor=RGBColor(255, 0, 0),
|
| 92 |
+
espaco_depois=6, recuo_esq=0.3 if indent else 0,
|
| 93 |
+
recuo_primeira_linha=1.25 if indent else 0
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def add_bullet_text(doc: Document, text: str, indent_level: int = 1):
|
| 98 |
+
"""Adiciona texto com bullet/recuo."""
|
| 99 |
+
return criar_paragrafo_formatado(
|
| 100 |
+
doc, f"• {text}", tamanho=11,
|
| 101 |
+
espaco_depois=3, recuo_esq=0.3 + (indent_level * 0.2)
|
| 102 |
+
)
|
backend/app/core/anexos/formatters/section.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatadores de seções e orientação de página.
|
| 3 |
+
"""
|
| 4 |
+
from docx import Document
|
| 5 |
+
from docx.shared import Cm
|
| 6 |
+
from docx.enum.section import WD_ORIENT
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def iniciar_secao_paisagem(doc: Document) -> None:
|
| 10 |
+
"""
|
| 11 |
+
Adiciona uma nova seção com orientação paisagem.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
doc: Documento DOCX
|
| 15 |
+
"""
|
| 16 |
+
# Adicionar quebra de seção
|
| 17 |
+
new_section = doc.add_section()
|
| 18 |
+
|
| 19 |
+
# Configurar orientação paisagem
|
| 20 |
+
new_section.orientation = WD_ORIENT.LANDSCAPE
|
| 21 |
+
|
| 22 |
+
# Trocar largura e altura para paisagem
|
| 23 |
+
new_width = new_section.page_height
|
| 24 |
+
new_height = new_section.page_width
|
| 25 |
+
new_section.page_width = new_width
|
| 26 |
+
new_section.page_height = new_height
|
| 27 |
+
|
| 28 |
+
# Ajustar margens para paisagem
|
| 29 |
+
new_section.top_margin = Cm(2)
|
| 30 |
+
new_section.bottom_margin = Cm(2)
|
| 31 |
+
new_section.left_margin = Cm(2)
|
| 32 |
+
new_section.right_margin = Cm(2)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def iniciar_secao_retrato(doc: Document) -> None:
|
| 36 |
+
"""
|
| 37 |
+
Adiciona uma nova seção com orientação retrato.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
doc: Documento DOCX
|
| 41 |
+
"""
|
| 42 |
+
# Adicionar quebra de seção
|
| 43 |
+
new_section = doc.add_section()
|
| 44 |
+
|
| 45 |
+
# Configurar orientação retrato
|
| 46 |
+
new_section.orientation = WD_ORIENT.PORTRAIT
|
| 47 |
+
|
| 48 |
+
# Trocar largura e altura para retrato (se estava em paisagem)
|
| 49 |
+
if new_section.page_width > new_section.page_height:
|
| 50 |
+
new_width = new_section.page_height
|
| 51 |
+
new_height = new_section.page_width
|
| 52 |
+
new_section.page_width = new_width
|
| 53 |
+
new_section.page_height = new_height
|
| 54 |
+
|
| 55 |
+
# Margens padrão para retrato
|
| 56 |
+
new_section.top_margin = Cm(2.5)
|
| 57 |
+
new_section.bottom_margin = Cm(2.5)
|
| 58 |
+
new_section.left_margin = Cm(3)
|
| 59 |
+
new_section.right_margin = Cm(2)
|
backend/app/core/anexos/formatters/table.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatadores de tabelas.
|
| 3 |
+
"""
|
| 4 |
+
from collections.abc import Sequence as SequenceABC
|
| 5 |
+
from docx import Document
|
| 6 |
+
from docx.shared import Pt, Inches, Cm
|
| 7 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 8 |
+
from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
|
| 9 |
+
from docx.oxml.ns import qn
|
| 10 |
+
from docx.oxml import OxmlElement
|
| 11 |
+
from typing import List, Optional, Sequence, Union, Dict, Any
|
| 12 |
+
|
| 13 |
+
from .paragraph import aplicar_cor_run
|
| 14 |
+
|
| 15 |
+
WD_ALIGN_VERTICAL = WD_CELL_VERTICAL_ALIGNMENT
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def set_cell_shading(cell, color: str) -> None:
|
| 19 |
+
"""
|
| 20 |
+
Define a cor de fundo de uma célula.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
cell: Célula da tabela
|
| 24 |
+
color: Cor em hex (ex: "E6E6E6")
|
| 25 |
+
"""
|
| 26 |
+
shading = OxmlElement('w:shd')
|
| 27 |
+
shading.set(qn('w:fill'), color)
|
| 28 |
+
cell._tc.get_or_add_tcPr().append(shading)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def set_cell_text_rotation(cell, direcao: str = 'btLr') -> None:
|
| 32 |
+
"""
|
| 33 |
+
Define a direção/rotação do texto em uma célula.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
cell: Célula da tabela
|
| 37 |
+
direcao: Direção do texto:
|
| 38 |
+
- 'btLr': bottom-to-top, left-to-right (90° - vertical)
|
| 39 |
+
- 'tbRl': top-to-bottom, right-to-left (270°)
|
| 40 |
+
"""
|
| 41 |
+
tcPr = cell._tc.get_or_add_tcPr()
|
| 42 |
+
textDirection = OxmlElement('w:textDirection')
|
| 43 |
+
textDirection.set(qn('w:val'), direcao)
|
| 44 |
+
tcPr.append(textDirection)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def set_cell_no_wrap(cell) -> None:
|
| 48 |
+
"""Evita quebra automática de linha em uma célula."""
|
| 49 |
+
tcPr = cell._tc.get_or_add_tcPr()
|
| 50 |
+
no_wrap = OxmlElement('w:noWrap')
|
| 51 |
+
no_wrap.set(qn('w:val'), '1')
|
| 52 |
+
tcPr.append(no_wrap)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def formatar_celula_tabela(cell, texto: str, negrito: bool = False, cor=None,
|
| 56 |
+
shading_color: Optional[str] = None, rotacao: bool = False,
|
| 57 |
+
no_wrap: bool = False):
|
| 58 |
+
"""
|
| 59 |
+
Formata uma célula de tabela com configurações padrão.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
cell: Célula da tabela
|
| 63 |
+
texto: Texto a inserir
|
| 64 |
+
negrito: Se o texto deve ser negrito
|
| 65 |
+
cor: Cor do texto (tuple ou RGBColor)
|
| 66 |
+
shading_color: Cor de fundo da célula (hex string)
|
| 67 |
+
rotacao: Se True, rotaciona o texto 90° (vertical)
|
| 68 |
+
no_wrap: Se True, evita quebra automática de linha
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
Run criado
|
| 72 |
+
"""
|
| 73 |
+
p = cell.paragraphs[0]
|
| 74 |
+
|
| 75 |
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 76 |
+
p.paragraph_format.left_indent = Cm(0)
|
| 77 |
+
p.paragraph_format.right_indent = Cm(0)
|
| 78 |
+
p.paragraph_format.first_line_indent = Cm(0)
|
| 79 |
+
p.paragraph_format.space_before = Pt(0)
|
| 80 |
+
p.paragraph_format.space_after = Pt(0)
|
| 81 |
+
|
| 82 |
+
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
| 83 |
+
|
| 84 |
+
run = p.add_run(texto)
|
| 85 |
+
run.font.size = Pt(8)
|
| 86 |
+
run.font.name = 'Arial'
|
| 87 |
+
run.bold = negrito
|
| 88 |
+
|
| 89 |
+
if cor:
|
| 90 |
+
aplicar_cor_run(run, cor)
|
| 91 |
+
|
| 92 |
+
if shading_color:
|
| 93 |
+
set_cell_shading(cell, shading_color)
|
| 94 |
+
|
| 95 |
+
if rotacao:
|
| 96 |
+
set_cell_text_rotation(cell)
|
| 97 |
+
|
| 98 |
+
if no_wrap:
|
| 99 |
+
set_cell_no_wrap(cell)
|
| 100 |
+
|
| 101 |
+
return run
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def add_simple_table(
|
| 105 |
+
doc: Document,
|
| 106 |
+
dados_tabela: List[List[Union[str, Dict[str, Any]]]],
|
| 107 |
+
header_row: bool = True,
|
| 108 |
+
largura_colunas: Optional[List[float]] = None,
|
| 109 |
+
rotacao_cabecalho: Union[bool, Sequence[bool]] = False,
|
| 110 |
+
largura_total: Optional[int] = None,
|
| 111 |
+
largura_colunas_absolutas: bool = False,
|
| 112 |
+
no_quebrar_linha: bool = False,
|
| 113 |
+
):
|
| 114 |
+
"""
|
| 115 |
+
Adiciona uma tabela simples ao documento.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
doc: Documento
|
| 119 |
+
dados_tabela: Lista de listas. Cada célula pode ser string ou dict {'texto', 'cor'}
|
| 120 |
+
header_row: Se True, primeira linha é cabeçalho (negrito com fundo cinza)
|
| 121 |
+
largura_colunas: Lista de inteiros representando proporção da largura de cada coluna.
|
| 122 |
+
rotacao_cabecalho: Se True, rotaciona todos os cabeçalhos 90°.
|
| 123 |
+
Também aceita lista de booleanos para controlar coluna a coluna.
|
| 124 |
+
largura_total: Largura total disponível para a tabela. Quando omitida, usa 6.5".
|
| 125 |
+
largura_colunas_absolutas: Se True, largura_colunas já contém larguras em EMUs.
|
| 126 |
+
no_quebrar_linha: Se True, evita quebra automática de linha nas células.
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Tabela criada ou None se dados vazios
|
| 130 |
+
"""
|
| 131 |
+
if not dados_tabela or not dados_tabela[0]:
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
num_colunas = len(dados_tabela[0])
|
| 135 |
+
|
| 136 |
+
if largura_colunas is None:
|
| 137 |
+
largura_colunas = [1] * num_colunas
|
| 138 |
+
elif len(largura_colunas) != num_colunas:
|
| 139 |
+
raise ValueError("largura_colunas deve ter o mesmo número de colunas de dados_tabela")
|
| 140 |
+
|
| 141 |
+
if isinstance(rotacao_cabecalho, SequenceABC) and not isinstance(rotacao_cabecalho, (str, bytes)):
|
| 142 |
+
if len(rotacao_cabecalho) != num_colunas:
|
| 143 |
+
raise ValueError("rotacao_cabecalho deve ter o mesmo número de colunas de dados_tabela")
|
| 144 |
+
rotacoes_cabecalho = [bool(valor) for valor in rotacao_cabecalho]
|
| 145 |
+
else:
|
| 146 |
+
rotacoes_cabecalho = [bool(rotacao_cabecalho)] * num_colunas
|
| 147 |
+
|
| 148 |
+
if largura_total is None:
|
| 149 |
+
largura_total = Inches(6.5)
|
| 150 |
+
|
| 151 |
+
table = doc.add_table(rows=len(dados_tabela), cols=num_colunas)
|
| 152 |
+
table.style = 'Table Grid'
|
| 153 |
+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
| 154 |
+
table.autofit = False
|
| 155 |
+
|
| 156 |
+
if largura_colunas_absolutas:
|
| 157 |
+
larguras_em_colunas = [int(largura) for largura in largura_colunas]
|
| 158 |
+
else:
|
| 159 |
+
total = sum(largura_colunas)
|
| 160 |
+
proporcoes = [x / total for x in largura_colunas]
|
| 161 |
+
larguras_em_colunas = [int(largura_total * p) for p in proporcoes]
|
| 162 |
+
|
| 163 |
+
for j, largura in enumerate(larguras_em_colunas):
|
| 164 |
+
table.columns[j].width = largura
|
| 165 |
+
|
| 166 |
+
for i, row_data in enumerate(dados_tabela):
|
| 167 |
+
row = table.rows[i]
|
| 168 |
+
for j, cell_data in enumerate(row_data):
|
| 169 |
+
if j >= len(row.cells):
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
cell = row.cells[j]
|
| 173 |
+
|
| 174 |
+
if isinstance(cell_data, dict):
|
| 175 |
+
texto = cell_data.get('texto', '')
|
| 176 |
+
cor = cell_data.get('cor')
|
| 177 |
+
else:
|
| 178 |
+
texto = str(cell_data) if cell_data else ""
|
| 179 |
+
cor = None
|
| 180 |
+
|
| 181 |
+
is_header = header_row and i == 0
|
| 182 |
+
|
| 183 |
+
formatar_celula_tabela(
|
| 184 |
+
cell, texto,
|
| 185 |
+
negrito=is_header,
|
| 186 |
+
cor=cor,
|
| 187 |
+
shading_color="E6E6E6" if is_header else None,
|
| 188 |
+
rotacao=is_header and rotacoes_cabecalho[j],
|
| 189 |
+
no_wrap=no_quebrar_linha or is_header,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
cell.width = larguras_em_colunas[j]
|
| 193 |
+
|
| 194 |
+
if header_row and len(dados_tabela) > 1:
|
| 195 |
+
configurar_linha_cabecalho_repetir(table.rows[0])
|
| 196 |
+
|
| 197 |
+
# Ajustar altura do cabeçalho para texto rotacionado
|
| 198 |
+
if header_row and any(rotacoes_cabecalho) and dados_tabela:
|
| 199 |
+
# Calcular o maior texto do cabeçalho
|
| 200 |
+
header_texts = []
|
| 201 |
+
for j, cell_data in enumerate(dados_tabela[0]):
|
| 202 |
+
if not rotacoes_cabecalho[j]:
|
| 203 |
+
continue
|
| 204 |
+
if isinstance(cell_data, dict):
|
| 205 |
+
header_texts.append(cell_data.get('texto', ''))
|
| 206 |
+
else:
|
| 207 |
+
header_texts.append(str(cell_data) if cell_data else "")
|
| 208 |
+
|
| 209 |
+
if header_texts:
|
| 210 |
+
max_len = max(len(t) for t in header_texts)
|
| 211 |
+
# Cabeçalhos rotacionados precisam de folga extra no Word; uma
|
| 212 |
+
# linha justa corta ou força quebra visual do texto renderizado.
|
| 213 |
+
altura_cm = max(2.8, max_len * 0.25 + 0.9)
|
| 214 |
+
configurar_linha_tabela_altura(table.rows[0], altura_cm)
|
| 215 |
+
|
| 216 |
+
doc.add_paragraph()
|
| 217 |
+
return table
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def configurar_linha_tabela_altura(row, altura_cm: float = 0.6) -> None:
|
| 221 |
+
"""
|
| 222 |
+
Configura altura mínima de uma linha de tabela.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
row: Linha da tabela
|
| 226 |
+
altura_cm: Altura em centímetros
|
| 227 |
+
"""
|
| 228 |
+
tr = row._tr
|
| 229 |
+
trPr = tr.get_or_add_trPr()
|
| 230 |
+
trHeight = OxmlElement('w:trHeight')
|
| 231 |
+
trHeight.set(qn('w:val'), str(int(Cm(altura_cm).twips)))
|
| 232 |
+
trHeight.set(qn('w:hRule'), 'atLeast')
|
| 233 |
+
trPr.append(trHeight)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def configurar_linha_cabecalho_repetir(row) -> None:
|
| 237 |
+
"""
|
| 238 |
+
Marca a linha como cabeçalho repetível em quebras de página.
|
| 239 |
+
|
| 240 |
+
Args:
|
| 241 |
+
row: Linha de cabeçalho da tabela
|
| 242 |
+
"""
|
| 243 |
+
tr = row._tr
|
| 244 |
+
trPr = tr.get_or_add_trPr()
|
| 245 |
+
tbl_header = OxmlElement('w:tblHeader')
|
| 246 |
+
tbl_header.set(qn('w:val'), '1')
|
| 247 |
+
trPr.append(tbl_header)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def criar_celula_cabecalho_tabela(cell, texto: str, recuo_primeira_linha_cm: float = 1.0) -> None:
|
| 251 |
+
"""
|
| 252 |
+
Formata uma célula de cabeçalho de seção na tabela principal.
|
| 253 |
+
|
| 254 |
+
Args:
|
| 255 |
+
cell: Célula da tabela
|
| 256 |
+
texto: Texto do cabeçalho
|
| 257 |
+
recuo_primeira_linha_cm: Recuo da primeira linha
|
| 258 |
+
"""
|
| 259 |
+
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
| 260 |
+
|
| 261 |
+
p = cell.paragraphs[0]
|
| 262 |
+
p.paragraph_format.left_indent = Cm(0)
|
| 263 |
+
p.paragraph_format.first_line_indent = Cm(recuo_primeira_linha_cm)
|
| 264 |
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
| 265 |
+
|
| 266 |
+
run = p.add_run(texto)
|
| 267 |
+
run.bold = True
|
| 268 |
+
run.underline = True
|
| 269 |
+
run.font.size = Pt(8)
|
| 270 |
+
run.font.name = 'Arial'
|
| 271 |
+
|
| 272 |
+
set_cell_shading(cell, "E6E6E6")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def criar_celula_dados_tabela(cell, texto: str, is_label: bool = False) -> None:
|
| 276 |
+
"""
|
| 277 |
+
Formata uma célula de dados na tabela principal.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
cell: Célula da tabela
|
| 281 |
+
texto: Texto da célula
|
| 282 |
+
is_label: Se True, formata como rótulo (negrito)
|
| 283 |
+
"""
|
| 284 |
+
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
|
| 285 |
+
|
| 286 |
+
p = cell.paragraphs[0]
|
| 287 |
+
p.paragraph_format.left_indent = Cm(0)
|
| 288 |
+
p.paragraph_format.first_line_indent = Cm(0)
|
| 289 |
+
p.paragraph_format.space_before = Pt(0)
|
| 290 |
+
p.paragraph_format.space_after = Pt(0)
|
| 291 |
+
|
| 292 |
+
run = p.add_run(texto)
|
| 293 |
+
run.bold = is_label
|
| 294 |
+
run.font.size = Pt(8)
|
| 295 |
+
run.font.name = 'Arial'
|
backend/app/core/anexos/generator.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gerador de documento contendo apenas anexos do modelo selecionado."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from io import BytesIO
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from docx import Document
|
| 10 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 11 |
+
from docx.shared import Cm
|
| 12 |
+
|
| 13 |
+
from .banco_dados import anexo_banco_dados_cabe_na_largura, gerar_anexo_banco_dados
|
| 14 |
+
from .calculo_valor import gerar_anexo_calculo
|
| 15 |
+
from .graficos import gerar_anexo_graficos
|
| 16 |
+
from .planilha_calculo import gerar_anexo_planilha
|
| 17 |
+
from .formatters import (
|
| 18 |
+
criar_paragrafo_formatado,
|
| 19 |
+
iniciar_secao_paisagem,
|
| 20 |
+
iniciar_secao_retrato,
|
| 21 |
+
)
|
| 22 |
+
from .model_data import ModelData
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AnexosGenerator:
|
| 26 |
+
"""Gera um DOCX com os anexos técnicos de um modelo .dai."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, output_dir: Path) -> None:
|
| 29 |
+
self.output_dir = output_dir
|
| 30 |
+
|
| 31 |
+
def gerar(
|
| 32 |
+
self,
|
| 33 |
+
modelo: ModelData,
|
| 34 |
+
banco_dados_colunas: list[str] | None = None,
|
| 35 |
+
avaliacao: dict | None = None,
|
| 36 |
+
) -> Path:
|
| 37 |
+
doc = Document()
|
| 38 |
+
self._configurar_margens(doc)
|
| 39 |
+
self._gerar_anexos(
|
| 40 |
+
doc,
|
| 41 |
+
modelo,
|
| 42 |
+
banco_dados_colunas=banco_dados_colunas,
|
| 43 |
+
avaliacao=avaliacao,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
output_path = self._gerar_nome_arquivo(modelo.nome)
|
| 48 |
+
doc.save(str(output_path))
|
| 49 |
+
return output_path
|
| 50 |
+
|
| 51 |
+
def gerar_em_memoria(
|
| 52 |
+
self,
|
| 53 |
+
modelo: ModelData,
|
| 54 |
+
banco_dados_colunas: list[str] | None = None,
|
| 55 |
+
avaliacao: dict | None = None,
|
| 56 |
+
) -> tuple[bytes, str]:
|
| 57 |
+
"""Gera o DOCX sem persistir o anexo no filesystem."""
|
| 58 |
+
doc = Document()
|
| 59 |
+
self._configurar_margens(doc)
|
| 60 |
+
self._gerar_anexos(
|
| 61 |
+
doc,
|
| 62 |
+
modelo,
|
| 63 |
+
banco_dados_colunas=banco_dados_colunas,
|
| 64 |
+
avaliacao=avaliacao,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
buffer = BytesIO()
|
| 68 |
+
doc.save(buffer)
|
| 69 |
+
return buffer.getvalue(), self._gerar_nome_arquivo(modelo.nome).name
|
| 70 |
+
|
| 71 |
+
def _configurar_margens(self, doc: Document) -> None:
|
| 72 |
+
for section in doc.sections:
|
| 73 |
+
section.top_margin = Cm(2.5)
|
| 74 |
+
section.bottom_margin = Cm(2.5)
|
| 75 |
+
section.left_margin = Cm(3)
|
| 76 |
+
section.right_margin = Cm(2)
|
| 77 |
+
|
| 78 |
+
def _criar_titulo_anexo(self, doc: Document, titulo: str) -> None:
|
| 79 |
+
criar_paragrafo_formatado(
|
| 80 |
+
doc,
|
| 81 |
+
titulo,
|
| 82 |
+
negrito=True,
|
| 83 |
+
sublinhado=True,
|
| 84 |
+
tamanho=10,
|
| 85 |
+
espaco_antes=18,
|
| 86 |
+
espaco_depois=12,
|
| 87 |
+
alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def _largura_util_secao_atual(self, doc: Document) -> int:
|
| 91 |
+
section = doc.sections[-1]
|
| 92 |
+
return int(section.page_width - section.left_margin - section.right_margin)
|
| 93 |
+
|
| 94 |
+
def _gerar_anexos(
|
| 95 |
+
self,
|
| 96 |
+
doc: Document,
|
| 97 |
+
modelo: ModelData,
|
| 98 |
+
banco_dados_colunas: list[str] | None = None,
|
| 99 |
+
avaliacao: dict | None = None,
|
| 100 |
+
) -> None:
|
| 101 |
+
banco_cabe_em_retrato = anexo_banco_dados_cabe_na_largura(
|
| 102 |
+
modelo,
|
| 103 |
+
banco_dados_colunas,
|
| 104 |
+
self._largura_util_secao_atual(doc),
|
| 105 |
+
)
|
| 106 |
+
if not banco_cabe_em_retrato:
|
| 107 |
+
iniciar_secao_paisagem(doc)
|
| 108 |
+
|
| 109 |
+
self._criar_titulo_anexo(doc, "ANEXO I - BANCO DE DADOS")
|
| 110 |
+
gerar_anexo_banco_dados(doc, modelo, colunas=banco_dados_colunas)
|
| 111 |
+
|
| 112 |
+
iniciar_secao_retrato(doc)
|
| 113 |
+
self._criar_titulo_anexo(
|
| 114 |
+
doc,
|
| 115 |
+
"ANEXO II - PLANILHA DE CÁLCULO E RESULTADOS ESTATÍSTICOS",
|
| 116 |
+
)
|
| 117 |
+
gerar_anexo_planilha(doc, modelo)
|
| 118 |
+
|
| 119 |
+
doc.add_page_break()
|
| 120 |
+
self._criar_titulo_anexo(doc, "ANEXO III - GRÁFICOS")
|
| 121 |
+
gerar_anexo_graficos(doc, modelo)
|
| 122 |
+
|
| 123 |
+
doc.add_page_break()
|
| 124 |
+
self._criar_titulo_anexo(doc, "ANEXO IV - CÁLCULO DO VALOR")
|
| 125 |
+
gerar_anexo_calculo(doc, modelo, avaliacao=avaliacao)
|
| 126 |
+
|
| 127 |
+
def _gerar_nome_arquivo(self, modelo_nome: str) -> Path:
|
| 128 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 129 |
+
modelo_safe = re.sub(r"[^\w\-]", "_", modelo_nome)
|
| 130 |
+
return self.output_dir / f"Anexos_{modelo_safe}_{timestamp}.docx"
|
backend/app/core/anexos/graficos.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gerador do Anexo de Gráficos.
|
| 3 |
+
Insere gráficos de diagnóstico do modelo no documento.
|
| 4 |
+
Usa Matplotlib para geração de imagens sem dependências externas.
|
| 5 |
+
"""
|
| 6 |
+
import io
|
| 7 |
+
from typing import Optional, List, Tuple
|
| 8 |
+
from docx import Document
|
| 9 |
+
from docx.shared import Inches
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from scipy import stats
|
| 14 |
+
import matplotlib
|
| 15 |
+
matplotlib.use('Agg') # Backend não-interativo
|
| 16 |
+
import matplotlib.pyplot as plt
|
| 17 |
+
|
| 18 |
+
from .model_data import ModelData
|
| 19 |
+
from .formatters import (
|
| 20 |
+
add_placeholder_text,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Cores consistentes com a interface
|
| 25 |
+
COR_PRINCIPAL = '#FF8C00' # Laranja
|
| 26 |
+
COR_LINHA = '#dc3545' # Vermelho para linhas de referência
|
| 27 |
+
COR_GRID = '#d3d3d3' # Cinza claro para grid
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _configurar_estilo_grafico(ax, titulo: str, xlabel: str, ylabel: str) -> None:
|
| 31 |
+
"""Aplica estilo consistente aos gráficos."""
|
| 32 |
+
ax.set_title(titulo, fontsize=14, fontweight='bold', color='black', pad=15)
|
| 33 |
+
ax.set_xlabel(xlabel, fontsize=11)
|
| 34 |
+
ax.set_ylabel(ylabel, fontsize=11)
|
| 35 |
+
ax.set_facecolor('white')
|
| 36 |
+
ax.grid(True, linestyle='-', linewidth=0.5, color=COR_GRID, alpha=0.7)
|
| 37 |
+
ax.spines['top'].set_visible(True)
|
| 38 |
+
ax.spines['right'].set_visible(True)
|
| 39 |
+
ax.spines['bottom'].set_color('black')
|
| 40 |
+
ax.spines['left'].set_color('black')
|
| 41 |
+
ax.spines['top'].set_color('black')
|
| 42 |
+
ax.spines['right'].set_color('black')
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _salvar_figura(fig) -> io.BytesIO:
|
| 46 |
+
"""Salva figura matplotlib em buffer de bytes."""
|
| 47 |
+
buf = io.BytesIO()
|
| 48 |
+
fig.savefig(buf, format='png', dpi=150, bbox_inches='tight',
|
| 49 |
+
facecolor='white', edgecolor='none')
|
| 50 |
+
buf.seek(0)
|
| 51 |
+
plt.close(fig)
|
| 52 |
+
return buf
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def gerar_anexo_graficos(doc: Document, modelo: Optional[ModelData]) -> None:
|
| 56 |
+
"""
|
| 57 |
+
Gera o conteúdo do Anexo de Gráficos.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
doc: Documento DOCX
|
| 61 |
+
modelo: Dados do modelo carregado do .dai
|
| 62 |
+
"""
|
| 63 |
+
if modelo is None:
|
| 64 |
+
add_placeholder_text(doc, "[Modelo não selecionado - inserir gráficos manualmente]")
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
graficos_inseridos = 0
|
| 68 |
+
|
| 69 |
+
# Tentar gerar gráficos a partir dos dados do modelo
|
| 70 |
+
if modelo.modelos_sm is not None or not modelo.tabelas_obs_calc.empty:
|
| 71 |
+
graficos = _gerar_graficos_diagnostico(modelo)
|
| 72 |
+
|
| 73 |
+
for titulo, img_buffer in graficos:
|
| 74 |
+
doc.add_picture(img_buffer, width=Inches(5.5))
|
| 75 |
+
graficos_inseridos += 1
|
| 76 |
+
|
| 77 |
+
if graficos_inseridos == 0:
|
| 78 |
+
add_placeholder_text(doc, "[Gráficos não disponíveis no modelo]")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _gerar_graficos_diagnostico(modelo: ModelData) -> List[Tuple[str, io.BytesIO]]:
|
| 82 |
+
"""
|
| 83 |
+
Gera gráficos de diagnóstico do modelo de regressão usando Matplotlib.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
modelo: Dados do modelo
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Lista de tuplas (título, buffer de imagem)
|
| 90 |
+
"""
|
| 91 |
+
graficos = []
|
| 92 |
+
|
| 93 |
+
# Obter dados para os gráficos
|
| 94 |
+
obs_calc = modelo.tabelas_obs_calc
|
| 95 |
+
modelos_sm = modelo.modelos_sm
|
| 96 |
+
|
| 97 |
+
# Identificar colunas de valores observados e calculados
|
| 98 |
+
y_obs = None
|
| 99 |
+
y_calc = None
|
| 100 |
+
residuos = None
|
| 101 |
+
|
| 102 |
+
if not obs_calc.empty:
|
| 103 |
+
# Tentar identificar colunas
|
| 104 |
+
cols_lower = {c.lower(): c for c in obs_calc.columns}
|
| 105 |
+
|
| 106 |
+
for nome_obs in ['observado', 'obs', 'y_obs', 'y', 'valor_observado']:
|
| 107 |
+
if nome_obs in cols_lower:
|
| 108 |
+
y_obs = obs_calc[cols_lower[nome_obs]].values
|
| 109 |
+
break
|
| 110 |
+
|
| 111 |
+
for nome_calc in ['calculado', 'calc', 'y_calc', 'y_hat', 'previsto', 'predicted', 'valor_calculado']:
|
| 112 |
+
if nome_calc in cols_lower:
|
| 113 |
+
y_calc = obs_calc[cols_lower[nome_calc]].values
|
| 114 |
+
break
|
| 115 |
+
|
| 116 |
+
for nome_res in ['residuo', 'residuos', 'resid', 'residual']:
|
| 117 |
+
if nome_res in cols_lower:
|
| 118 |
+
residuos = obs_calc[cols_lower[nome_res]].values
|
| 119 |
+
break
|
| 120 |
+
|
| 121 |
+
# Se temos statsmodels result, extrair dados dele
|
| 122 |
+
if modelos_sm is not None:
|
| 123 |
+
try:
|
| 124 |
+
if y_obs is None and hasattr(modelos_sm, 'model') and hasattr(modelos_sm.model, 'endog'):
|
| 125 |
+
y_obs = modelos_sm.model.endog
|
| 126 |
+
if y_calc is None and hasattr(modelos_sm, 'fittedvalues'):
|
| 127 |
+
y_calc = modelos_sm.fittedvalues
|
| 128 |
+
if residuos is None and hasattr(modelos_sm, 'resid'):
|
| 129 |
+
residuos = modelos_sm.resid
|
| 130 |
+
except Exception:
|
| 131 |
+
pass
|
| 132 |
+
|
| 133 |
+
# Calcular resíduos se temos obs e calc
|
| 134 |
+
if residuos is None and y_obs is not None and y_calc is not None:
|
| 135 |
+
residuos = np.array(y_obs) - np.array(y_calc)
|
| 136 |
+
|
| 137 |
+
# Converter para arrays numpy se necessário
|
| 138 |
+
if y_obs is not None:
|
| 139 |
+
y_obs = np.array(y_obs)
|
| 140 |
+
if y_calc is not None:
|
| 141 |
+
y_calc = np.array(y_calc)
|
| 142 |
+
if residuos is not None:
|
| 143 |
+
residuos = np.array(residuos)
|
| 144 |
+
|
| 145 |
+
# Gráfico 1: Valores Observados vs Calculados
|
| 146 |
+
if y_obs is not None and y_calc is not None:
|
| 147 |
+
graf = _criar_grafico_obs_calc(y_obs, y_calc)
|
| 148 |
+
if graf:
|
| 149 |
+
graficos.append(("Valores Observados vs Calculados", graf))
|
| 150 |
+
|
| 151 |
+
# Gráfico 2: Resíduos vs Valores Ajustados
|
| 152 |
+
if residuos is not None and y_calc is not None:
|
| 153 |
+
graf = _criar_grafico_residuos(y_calc, residuos)
|
| 154 |
+
if graf:
|
| 155 |
+
graficos.append(("Resíduos vs Valores Ajustados", graf))
|
| 156 |
+
|
| 157 |
+
# Gráfico 3: Histograma dos Resíduos
|
| 158 |
+
if residuos is not None:
|
| 159 |
+
graf = _criar_histograma_residuos(residuos)
|
| 160 |
+
if graf:
|
| 161 |
+
graficos.append(("Distribuição dos Resíduos", graf))
|
| 162 |
+
|
| 163 |
+
# Gráfico 4: Distância de Cook
|
| 164 |
+
if modelos_sm is not None:
|
| 165 |
+
graf = _criar_grafico_cook(modelos_sm)
|
| 166 |
+
if graf:
|
| 167 |
+
graficos.append(("Distância de Cook", graf))
|
| 168 |
+
|
| 169 |
+
# Gráfico 5: Matriz de Correlação
|
| 170 |
+
if modelos_sm is not None:
|
| 171 |
+
graf = _criar_grafico_correlacao(modelos_sm)
|
| 172 |
+
if graf:
|
| 173 |
+
graficos.append(("Matriz de Correlação", graf))
|
| 174 |
+
|
| 175 |
+
return graficos
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _criar_grafico_obs_calc(y_obs: np.ndarray, y_calc: np.ndarray) -> Optional[io.BytesIO]:
|
| 179 |
+
"""Cria gráfico de valores observados vs calculados."""
|
| 180 |
+
try:
|
| 181 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 182 |
+
|
| 183 |
+
# Scatter plot
|
| 184 |
+
ax.scatter(y_calc, y_obs, c=COR_PRINCIPAL, s=60, edgecolors='black',
|
| 185 |
+
linewidths=0.5, alpha=0.8, label='Dados', zorder=3)
|
| 186 |
+
|
| 187 |
+
# Linha de identidade (45 graus)
|
| 188 |
+
min_val = min(min(y_obs), min(y_calc))
|
| 189 |
+
max_val = max(max(y_obs), max(y_calc))
|
| 190 |
+
margin = (max_val - min_val) * 0.05
|
| 191 |
+
|
| 192 |
+
ax.plot([min_val - margin, max_val + margin],
|
| 193 |
+
[min_val - margin, max_val + margin],
|
| 194 |
+
color=COR_LINHA, linestyle='--', linewidth=2,
|
| 195 |
+
label='Linha de identidade', zorder=2)
|
| 196 |
+
|
| 197 |
+
ax.set_xlim(min_val - margin, max_val + margin)
|
| 198 |
+
ax.set_ylim(min_val - margin, max_val + margin)
|
| 199 |
+
|
| 200 |
+
_configurar_estilo_grafico(ax, 'Valores Observados vs Calculados',
|
| 201 |
+
'Valores Calculados', 'Valores Observados')
|
| 202 |
+
ax.legend(loc='upper left', framealpha=0.9)
|
| 203 |
+
|
| 204 |
+
return _salvar_figura(fig)
|
| 205 |
+
except Exception as e:
|
| 206 |
+
print(f"Erro ao criar gráfico obs vs calc: {e}")
|
| 207 |
+
return None
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _criar_grafico_residuos(y_calc: np.ndarray, residuos: np.ndarray) -> Optional[io.BytesIO]:
|
| 211 |
+
"""Cria gráfico de resíduos vs valores ajustados."""
|
| 212 |
+
try:
|
| 213 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 214 |
+
|
| 215 |
+
# Scatter plot
|
| 216 |
+
ax.scatter(y_calc, residuos, c=COR_PRINCIPAL, s=60, edgecolors='black',
|
| 217 |
+
linewidths=0.5, alpha=0.8, zorder=3)
|
| 218 |
+
|
| 219 |
+
# Linha horizontal em y=0
|
| 220 |
+
ax.axhline(y=0, color=COR_LINHA, linestyle='--', linewidth=2, zorder=2)
|
| 221 |
+
|
| 222 |
+
_configurar_estilo_grafico(ax, 'Resíduos vs Valores Ajustados',
|
| 223 |
+
'Valores Ajustados', 'Resíduos')
|
| 224 |
+
|
| 225 |
+
return _salvar_figura(fig)
|
| 226 |
+
except Exception as e:
|
| 227 |
+
print(f"Erro ao criar gráfico de resíduos: {e}")
|
| 228 |
+
return None
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _criar_histograma_residuos(residuos: np.ndarray) -> Optional[io.BytesIO]:
|
| 232 |
+
"""Cria histograma dos resíduos com curva normal."""
|
| 233 |
+
try:
|
| 234 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 235 |
+
|
| 236 |
+
# Histograma
|
| 237 |
+
n, bins, patches = ax.hist(residuos, bins='auto', density=True,
|
| 238 |
+
color=COR_PRINCIPAL, edgecolor='black',
|
| 239 |
+
linewidth=0.5, alpha=0.7, label='Resíduos',
|
| 240 |
+
zorder=3)
|
| 241 |
+
|
| 242 |
+
# Curva normal ajustada
|
| 243 |
+
mu, sigma = np.mean(residuos), np.std(residuos)
|
| 244 |
+
x_norm = np.linspace(min(residuos) - sigma, max(residuos) + sigma, 100)
|
| 245 |
+
y_norm = stats.norm.pdf(x_norm, mu, sigma)
|
| 246 |
+
|
| 247 |
+
ax.plot(x_norm, y_norm, color=COR_LINHA, linewidth=3,
|
| 248 |
+
label='Curva Normal', zorder=4)
|
| 249 |
+
|
| 250 |
+
_configurar_estilo_grafico(ax, 'Distribuição dos Resíduos',
|
| 251 |
+
'Resíduos', 'Densidade')
|
| 252 |
+
ax.legend(loc='upper right', framealpha=0.9)
|
| 253 |
+
|
| 254 |
+
return _salvar_figura(fig)
|
| 255 |
+
except Exception as e:
|
| 256 |
+
print(f"Erro ao criar histograma: {e}")
|
| 257 |
+
return None
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _criar_grafico_cook(modelos_sm) -> Optional[io.BytesIO]:
|
| 261 |
+
"""Cria gráfico de Distância de Cook."""
|
| 262 |
+
try:
|
| 263 |
+
from statsmodels.stats.outliers_influence import OLSInfluence
|
| 264 |
+
|
| 265 |
+
influence = OLSInfluence(modelos_sm)
|
| 266 |
+
cooks_d = influence.cooks_distance[0]
|
| 267 |
+
|
| 268 |
+
n = len(cooks_d)
|
| 269 |
+
indices = np.arange(1, n + 1)
|
| 270 |
+
|
| 271 |
+
# Limite de referência: 4/n
|
| 272 |
+
limite = 4 / n
|
| 273 |
+
|
| 274 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 275 |
+
|
| 276 |
+
# Cores baseadas no limite
|
| 277 |
+
cores = np.array([COR_LINHA if v > limite else COR_PRINCIPAL for v in cooks_d])
|
| 278 |
+
|
| 279 |
+
# Desenhar linhas verticais (stems) individualmente
|
| 280 |
+
for i, (idx, valor, cor) in enumerate(zip(indices, cooks_d, cores)):
|
| 281 |
+
ax.vlines(x=idx, ymin=0, ymax=valor, colors=cor, linewidth=1.5, zorder=2)
|
| 282 |
+
|
| 283 |
+
# Adicionar marcadores no topo
|
| 284 |
+
ax.scatter(indices, cooks_d, c=cores, s=50, edgecolors='black',
|
| 285 |
+
linewidths=0.5, zorder=3)
|
| 286 |
+
|
| 287 |
+
# Linha de referência horizontal (4/n)
|
| 288 |
+
ax.axhline(y=limite, color='gray', linestyle='--', linewidth=1.5,
|
| 289 |
+
label=f'4/n = {limite:.4f}', zorder=4)
|
| 290 |
+
|
| 291 |
+
ax.set_ylim(bottom=0)
|
| 292 |
+
ax.set_xlim(0, n + 1)
|
| 293 |
+
|
| 294 |
+
_configurar_estilo_grafico(ax, 'Distância de Cook',
|
| 295 |
+
'Observação', 'Distância de Cook')
|
| 296 |
+
ax.legend(loc='upper right', framealpha=0.9)
|
| 297 |
+
|
| 298 |
+
return _salvar_figura(fig)
|
| 299 |
+
except Exception as e:
|
| 300 |
+
print(f"Erro ao criar gráfico de Cook: {e}")
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _criar_grafico_correlacao(modelos_sm) -> Optional[io.BytesIO]:
|
| 305 |
+
"""
|
| 306 |
+
Gera um heatmap de correlação entre a variável dependente e as variáveis explicativas.
|
| 307 |
+
"""
|
| 308 |
+
try:
|
| 309 |
+
if not hasattr(modelos_sm, 'model'):
|
| 310 |
+
return None
|
| 311 |
+
|
| 312 |
+
model = modelos_sm.model
|
| 313 |
+
|
| 314 |
+
if not hasattr(model, 'exog') or not hasattr(model, 'endog'):
|
| 315 |
+
return None
|
| 316 |
+
|
| 317 |
+
X = model.exog
|
| 318 |
+
X_names = model.exog_names
|
| 319 |
+
y = model.endog
|
| 320 |
+
y_name = getattr(model, 'endog_names', 'Var. Dep.')
|
| 321 |
+
|
| 322 |
+
df_X = pd.DataFrame(X, columns=X_names)
|
| 323 |
+
|
| 324 |
+
# Remover constantes explícitas
|
| 325 |
+
df_X = df_X.drop(
|
| 326 |
+
columns=[c for c in df_X.columns if c.lower() in ('const', 'intercept')],
|
| 327 |
+
errors='ignore'
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
df_y = pd.DataFrame({y_name: y})
|
| 331 |
+
|
| 332 |
+
df = pd.concat([df_y, df_X], axis=1)
|
| 333 |
+
|
| 334 |
+
# Remover colunas realmente constantes
|
| 335 |
+
variancias = df.var(ddof=0)
|
| 336 |
+
df = df.loc[:, variancias.fillna(0) > 0]
|
| 337 |
+
|
| 338 |
+
if df.shape[1] < 2:
|
| 339 |
+
return None
|
| 340 |
+
|
| 341 |
+
corr = df.corr()
|
| 342 |
+
|
| 343 |
+
if corr.isnull().values.all():
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
# Calcular tamanho da figura baseado no número de variáveis
|
| 347 |
+
n_vars = len(corr.columns)
|
| 348 |
+
fig_size = max(8, n_vars * 0.8)
|
| 349 |
+
|
| 350 |
+
fig, ax = plt.subplots(figsize=(fig_size, fig_size))
|
| 351 |
+
|
| 352 |
+
# Heatmap
|
| 353 |
+
im = ax.imshow(corr.values, cmap='RdBu_r', vmin=-1, vmax=1, aspect='auto')
|
| 354 |
+
|
| 355 |
+
# Adicionar colorbar
|
| 356 |
+
cbar = fig.colorbar(im, ax=ax, shrink=0.8)
|
| 357 |
+
cbar.set_label('Correlação', fontsize=11)
|
| 358 |
+
|
| 359 |
+
# Configurar ticks
|
| 360 |
+
ax.set_xticks(np.arange(len(corr.columns)))
|
| 361 |
+
ax.set_yticks(np.arange(len(corr.columns)))
|
| 362 |
+
ax.set_xticklabels(corr.columns, rotation=45, ha='right', fontsize=9)
|
| 363 |
+
ax.set_yticklabels(corr.columns, fontsize=9)
|
| 364 |
+
|
| 365 |
+
# Adicionar valores nas células
|
| 366 |
+
for i in range(len(corr.columns)):
|
| 367 |
+
for j in range(len(corr.columns)):
|
| 368 |
+
valor = corr.values[i, j]
|
| 369 |
+
# Cor do texto baseada no valor
|
| 370 |
+
cor_texto = 'white' if abs(valor) > 0.5 else 'black'
|
| 371 |
+
ax.text(j, i, f'{valor:.2f}', ha='center', va='center',
|
| 372 |
+
color=cor_texto, fontsize=9)
|
| 373 |
+
|
| 374 |
+
ax.set_title('Matriz de Correlação\n(Variável Dependente e Regressoras)',
|
| 375 |
+
fontsize=14, fontweight='bold', pad=15)
|
| 376 |
+
|
| 377 |
+
plt.tight_layout()
|
| 378 |
+
|
| 379 |
+
return _salvar_figura(fig)
|
| 380 |
+
except Exception as e:
|
| 381 |
+
print(f"Erro ao criar gráfico de correlação: {e}")
|
| 382 |
+
return None
|
backend/app/core/anexos/model_data.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataclass tipada para os dados do modelo de avaliação.
|
| 3 |
+
Os dados são carregados de arquivos .dai (joblib).
|
| 4 |
+
"""
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Dict, List, Any, Optional
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class ModelData:
|
| 14 |
+
"""Representa os dados estatísticos de um modelo de avaliação."""
|
| 15 |
+
|
| 16 |
+
nome: str
|
| 17 |
+
path: Path
|
| 18 |
+
|
| 19 |
+
# Dados do banco de dados
|
| 20 |
+
xy_preview: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 21 |
+
top_x_esc: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 22 |
+
top_y_esc: pd.Series = field(default_factory=pd.Series)
|
| 23 |
+
|
| 24 |
+
# Estatísticas descritivas
|
| 25 |
+
estatisticas: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 26 |
+
|
| 27 |
+
# Modelo de regressão
|
| 28 |
+
tabelas_coef: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 29 |
+
tabelas_obs_calc: pd.DataFrame = field(default_factory=pd.DataFrame)
|
| 30 |
+
modelos_resumos: Dict[str, Any] = field(default_factory=dict)
|
| 31 |
+
modelos_sm: Any = None # RegressionResultsWrapper
|
| 32 |
+
|
| 33 |
+
# Transformações e gráficos
|
| 34 |
+
formatted_top_transformation_info: List[Any] = field(default_factory=list)
|
| 35 |
+
variaveis_dicotomicas: List[str] = field(default_factory=list)
|
| 36 |
+
graf_model: str = ""
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def r2(self) -> Optional[float]:
|
| 40 |
+
"""Retorna o coeficiente de determinação R²."""
|
| 41 |
+
return self.modelos_resumos.get('r2')
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def r2_ajustado(self) -> Optional[float]:
|
| 45 |
+
"""Retorna o R² ajustado."""
|
| 46 |
+
return self.modelos_resumos.get('r2_ajustado')
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def equacao(self) -> Optional[str]:
|
| 50 |
+
"""Retorna a equação do modelo."""
|
| 51 |
+
return self.modelos_resumos.get('equacao')
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def variaveis(self) -> List[str]:
|
| 55 |
+
"""Retorna lista de variáveis do modelo."""
|
| 56 |
+
if self.tabelas_coef.empty:
|
| 57 |
+
return []
|
| 58 |
+
return [v for v in self.tabelas_coef.index.tolist() if v != 'const']
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def n_amostras(self) -> int:
|
| 62 |
+
"""Retorna número de amostras no banco de dados."""
|
| 63 |
+
return self.modelos_resumos.get('n', 0)
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def n_variaveis(self) -> int:
|
| 67 |
+
"""Retorna número de variáveis do modelo."""
|
| 68 |
+
return self.modelos_resumos.get('k', 0)
|
backend/app/core/anexos/model_loader.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Carregador de modelos de avaliação.
|
| 3 |
+
Carrega arquivos .dai via joblib.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Optional
|
| 9 |
+
|
| 10 |
+
import joblib
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
from .model_data import ModelData
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _get_first(container: dict[str, Any], *keys: str, default: Any = None) -> Any:
|
| 17 |
+
"""Retorna o primeiro valor não-nulo encontrado nas chaves informadas."""
|
| 18 |
+
for key in keys:
|
| 19 |
+
if key in container and container[key] is not None:
|
| 20 |
+
return container[key]
|
| 21 |
+
return default
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _as_dict(value: Any) -> dict[str, Any]:
|
| 25 |
+
"""Garante retorno como dicionário."""
|
| 26 |
+
return value if isinstance(value, dict) else {}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _as_dataframe(value: Any) -> pd.DataFrame:
|
| 30 |
+
"""Converte entrada em DataFrame quando possível."""
|
| 31 |
+
if isinstance(value, pd.DataFrame):
|
| 32 |
+
return value
|
| 33 |
+
if isinstance(value, pd.Series):
|
| 34 |
+
return value.to_frame()
|
| 35 |
+
return pd.DataFrame()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _as_series(value: Any) -> pd.Series:
|
| 39 |
+
"""Converte entrada em Series quando possível."""
|
| 40 |
+
if isinstance(value, pd.Series):
|
| 41 |
+
return value
|
| 42 |
+
if isinstance(value, pd.DataFrame) and value.shape[1] == 1:
|
| 43 |
+
return value.iloc[:, 0]
|
| 44 |
+
return pd.Series(dtype=float)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _as_string_list(value: Any) -> list[str]:
|
| 48 |
+
"""Converte listas/tuplas/índices/séries em lista de strings."""
|
| 49 |
+
if value is None:
|
| 50 |
+
return []
|
| 51 |
+
if isinstance(value, pd.Series):
|
| 52 |
+
value = value.tolist()
|
| 53 |
+
elif isinstance(value, pd.Index):
|
| 54 |
+
value = value.tolist()
|
| 55 |
+
elif not isinstance(value, (list, tuple, set)):
|
| 56 |
+
return []
|
| 57 |
+
return [str(item) for item in value]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _extract_diagnostic_summary(diag: dict[str, Any]) -> dict[str, Any]:
|
| 61 |
+
"""
|
| 62 |
+
Normaliza estatísticas do bloco de diagnósticos para o formato interno.
|
| 63 |
+
|
| 64 |
+
O formato dos .dai pode variar entre versões; esta função tolera aliases
|
| 65 |
+
comuns sem quebrar o carregamento do modelo.
|
| 66 |
+
"""
|
| 67 |
+
gerais = _as_dict(_get_first(diag, "gerais", "estatisticas_gerais", default={}))
|
| 68 |
+
teste_f = _as_dict(_get_first(diag, "teste_f", "f_test", default={}))
|
| 69 |
+
teste_ks = _as_dict(_get_first(diag, "teste_ks", "ks_test", default={}))
|
| 70 |
+
teste_normalidade = _as_dict(
|
| 71 |
+
_get_first(diag, "teste_normalidade", "normalidade", default={})
|
| 72 |
+
)
|
| 73 |
+
teste_dw = _as_dict(_get_first(diag, "teste_dw", "durbin_watson", default={}))
|
| 74 |
+
teste_bp = _as_dict(_get_first(diag, "teste_bp", "breusch_pagan", default={}))
|
| 75 |
+
|
| 76 |
+
def stat_from(block: dict[str, Any]) -> Any:
|
| 77 |
+
return _get_first(block, "estatistica", "stat", "value")
|
| 78 |
+
|
| 79 |
+
def pvalue_from(block: dict[str, Any]) -> Any:
|
| 80 |
+
return _get_first(block, "p_valor", "pvalue", "p_val", "p")
|
| 81 |
+
|
| 82 |
+
def interp_from(block: dict[str, Any]) -> Any:
|
| 83 |
+
return _get_first(block, "interpretacao", "interpretation", "texto")
|
| 84 |
+
|
| 85 |
+
return {
|
| 86 |
+
**gerais,
|
| 87 |
+
"Fc": stat_from(teste_f),
|
| 88 |
+
"p_valor_F": pvalue_from(teste_f),
|
| 89 |
+
"Interpretacao_F": interp_from(teste_f),
|
| 90 |
+
"ks_stat": stat_from(teste_ks),
|
| 91 |
+
"ks_p": pvalue_from(teste_ks),
|
| 92 |
+
"Interpretacao_KS": interp_from(teste_ks),
|
| 93 |
+
"perc_resid": _get_first(
|
| 94 |
+
teste_normalidade, "percentuais", "percentis", "percentages"
|
| 95 |
+
),
|
| 96 |
+
"dw": stat_from(teste_dw),
|
| 97 |
+
"Interpretacao_DW": interp_from(teste_dw),
|
| 98 |
+
"bp_lm": stat_from(teste_bp),
|
| 99 |
+
"bp_p": pvalue_from(teste_bp),
|
| 100 |
+
"Interpretacao_BP": interp_from(teste_bp),
|
| 101 |
+
"equacao": _get_first(diag, "equacao", "equation", "formula"),
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _build_xy_preview(
|
| 106 |
+
dados: dict[str, Any], transformacoes: dict[str, Any]
|
| 107 |
+
) -> pd.DataFrame:
|
| 108 |
+
"""
|
| 109 |
+
Monta DataFrame de preview para o Anexo I.
|
| 110 |
+
|
| 111 |
+
Em versões novas, `dados['df']` pode conter o dataset completo (centenas de
|
| 112 |
+
colunas), o que degrada a geração/abertura no Word. Quando disponível, usa
|
| 113 |
+
`transformacoes['X'] + y` como preview estável da modelagem.
|
| 114 |
+
"""
|
| 115 |
+
df_preview = _as_dataframe(
|
| 116 |
+
_get_first(
|
| 117 |
+
dados,
|
| 118 |
+
"xy_preview",
|
| 119 |
+
"df_preview",
|
| 120 |
+
"df_modelagem",
|
| 121 |
+
"preview",
|
| 122 |
+
"df",
|
| 123 |
+
default=pd.DataFrame(),
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
x_df = _as_dataframe(_get_first(transformacoes, "X", "x", default=pd.DataFrame()))
|
| 128 |
+
y_series = _as_series(_get_first(transformacoes, "y", "Y", default=pd.Series(dtype=float)))
|
| 129 |
+
|
| 130 |
+
if not x_df.empty:
|
| 131 |
+
modelagem_df = x_df.copy()
|
| 132 |
+
|
| 133 |
+
if not y_series.empty:
|
| 134 |
+
y_name = str(y_series.name) if y_series.name else "y"
|
| 135 |
+
y_alinhado = y_series.reset_index(drop=True)
|
| 136 |
+
modelagem_df = modelagem_df.reset_index(drop=True)
|
| 137 |
+
tamanho = min(len(modelagem_df), len(y_alinhado))
|
| 138 |
+
if tamanho > 0:
|
| 139 |
+
modelagem_df = modelagem_df.iloc[:tamanho].copy()
|
| 140 |
+
modelagem_df[y_name] = y_alinhado.iloc[:tamanho].values
|
| 141 |
+
|
| 142 |
+
# Se o preview original veio "explodido" (df completo), prioriza modelagem.
|
| 143 |
+
if df_preview.empty or df_preview.shape[1] > max(40, modelagem_df.shape[1] + 10):
|
| 144 |
+
return modelagem_df
|
| 145 |
+
|
| 146 |
+
return df_preview
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def model_data_from_package(
|
| 150 |
+
data: Any,
|
| 151 |
+
dai_path: Path,
|
| 152 |
+
nome: str | None = None,
|
| 153 |
+
) -> Optional[ModelData]:
|
| 154 |
+
"""Converte um pacote .dai já carregado para os dados usados nos anexos."""
|
| 155 |
+
try:
|
| 156 |
+
data_dict = _as_dict(data)
|
| 157 |
+
dados = _as_dict(_get_first(data_dict, "dados", "data", default={}))
|
| 158 |
+
transformacoes = _as_dict(
|
| 159 |
+
_get_first(data_dict, "transformacoes", "transformations", default={})
|
| 160 |
+
)
|
| 161 |
+
modelo_dict = _as_dict(_get_first(data_dict, "modelo", "model", default={}))
|
| 162 |
+
diag = _as_dict(_get_first(modelo_dict, "diagnosticos", "diagnostics", default={}))
|
| 163 |
+
|
| 164 |
+
modelos_resumos = _extract_diagnostic_summary(diag)
|
| 165 |
+
xy_preview = _build_xy_preview(dados, transformacoes)
|
| 166 |
+
top_x_esc = _as_dataframe(
|
| 167 |
+
_get_first(transformacoes, "X", "x", default=pd.DataFrame())
|
| 168 |
+
)
|
| 169 |
+
top_y_esc = _as_series(
|
| 170 |
+
_get_first(transformacoes, "y", "Y", default=pd.Series(dtype=float))
|
| 171 |
+
)
|
| 172 |
+
estatisticas = _as_dataframe(
|
| 173 |
+
_get_first(dados, "estatisticas", "statistics", default=pd.DataFrame())
|
| 174 |
+
)
|
| 175 |
+
tabelas_coef = _as_dataframe(
|
| 176 |
+
_get_first(
|
| 177 |
+
modelo_dict,
|
| 178 |
+
"coeficientes",
|
| 179 |
+
"coeficients",
|
| 180 |
+
"coefficients",
|
| 181 |
+
default=pd.DataFrame(),
|
| 182 |
+
)
|
| 183 |
+
)
|
| 184 |
+
for coluna_variavel in ("Variável", "Variavel", "variable"):
|
| 185 |
+
if coluna_variavel in tabelas_coef.columns:
|
| 186 |
+
tabelas_coef = tabelas_coef.set_index(coluna_variavel)
|
| 187 |
+
break
|
| 188 |
+
|
| 189 |
+
tabelas_obs_calc = _as_dataframe(
|
| 190 |
+
_get_first(
|
| 191 |
+
modelo_dict,
|
| 192 |
+
"obs_calc",
|
| 193 |
+
"observado_calculado",
|
| 194 |
+
"observed_vs_calculated",
|
| 195 |
+
default=pd.DataFrame(),
|
| 196 |
+
)
|
| 197 |
+
)
|
| 198 |
+
modelos_sm = _get_first(
|
| 199 |
+
modelo_dict, "sm", "statsmodels", "statsmodels_result", default=None
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
return ModelData(
|
| 203 |
+
nome=str(nome or dai_path.stem),
|
| 204 |
+
path=dai_path,
|
| 205 |
+
xy_preview=xy_preview,
|
| 206 |
+
top_x_esc=top_x_esc,
|
| 207 |
+
top_y_esc=top_y_esc,
|
| 208 |
+
estatisticas=estatisticas,
|
| 209 |
+
tabelas_coef=tabelas_coef,
|
| 210 |
+
tabelas_obs_calc=tabelas_obs_calc,
|
| 211 |
+
modelos_resumos=modelos_resumos,
|
| 212 |
+
modelos_sm=modelos_sm,
|
| 213 |
+
formatted_top_transformation_info=transformacoes.get("info", []),
|
| 214 |
+
variaveis_dicotomicas=_as_string_list(
|
| 215 |
+
_get_first(
|
| 216 |
+
transformacoes,
|
| 217 |
+
"dicotomicas",
|
| 218 |
+
"dicotômicas",
|
| 219 |
+
"dummies",
|
| 220 |
+
"dummy",
|
| 221 |
+
"binarias",
|
| 222 |
+
"binárias",
|
| 223 |
+
default=[],
|
| 224 |
+
)
|
| 225 |
+
),
|
| 226 |
+
graf_model="",
|
| 227 |
+
)
|
| 228 |
+
except (KeyError, TypeError, ValueError):
|
| 229 |
+
return None
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def load_model(dai_path: Path) -> Optional[ModelData]:
|
| 233 |
+
"""
|
| 234 |
+
Carrega um modelo de avaliação a partir de um arquivo .dai.
|
| 235 |
+
|
| 236 |
+
Args:
|
| 237 |
+
dai_path: Caminho para o arquivo .dai
|
| 238 |
+
|
| 239 |
+
Returns:
|
| 240 |
+
ModelData com os dados carregados, ou None se falhar
|
| 241 |
+
"""
|
| 242 |
+
if not dai_path.exists():
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
data = joblib.load(dai_path)
|
| 247 |
+
except Exception as e:
|
| 248 |
+
print(f"Erro ao carregar {dai_path}: {e}")
|
| 249 |
+
return None
|
| 250 |
+
|
| 251 |
+
return model_data_from_package(data, dai_path)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def load_model_by_name(name: str, models_dir: Path) -> Optional[ModelData]:
|
| 255 |
+
"""
|
| 256 |
+
Carrega um modelo pelo nome.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
name: Nome do modelo (nome do arquivo .dai sem extensão)
|
| 260 |
+
models_dir: Diretório dos modelos
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
ModelData com os dados carregados, ou None se não encontrar
|
| 264 |
+
"""
|
| 265 |
+
dai_path = models_dir / f"{name}.dai"
|
| 266 |
+
return load_model(dai_path)
|
backend/app/core/anexos/planilha_calculo.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gerador do Anexo de Planilha de Cálculo e Resultados Estatísticos.
|
| 3 |
+
Gera tabelas de coeficientes, valores observados vs calculados e resumo estatístico.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Optional, Dict, Any
|
| 6 |
+
from docx import Document
|
| 7 |
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 8 |
+
|
| 9 |
+
from .model_data import ModelData
|
| 10 |
+
from .formatters import (
|
| 11 |
+
add_body_text,
|
| 12 |
+
add_placeholder_text,
|
| 13 |
+
add_subsection_title,
|
| 14 |
+
add_simple_table,
|
| 15 |
+
criar_paragrafo_formatado,
|
| 16 |
+
)
|
| 17 |
+
from .estatisticas_utils import reorganizar_modelos_resumos, formatar_numero
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def gerar_anexo_planilha(doc: Document, modelo: Optional[ModelData]) -> None:
|
| 21 |
+
"""
|
| 22 |
+
Gera o conteúdo do Anexo de Planilha de Cálculo e Resultados Estatísticos.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
doc: Documento DOCX
|
| 26 |
+
modelo: Dados do modelo carregado do .dai
|
| 27 |
+
"""
|
| 28 |
+
if modelo is None:
|
| 29 |
+
add_placeholder_text(doc, "[Modelo não selecionado - inserir planilha manualmente]")
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
conteudo_gerado = False
|
| 33 |
+
|
| 34 |
+
# 1. RESUMO ESTATÍSTICO (como na aba Resumo do interface_estatisticas)
|
| 35 |
+
if modelo.modelos_resumos:
|
| 36 |
+
_adicionar_resumo_estatistico(doc, modelo.modelos_resumos)
|
| 37 |
+
conteudo_gerado = True
|
| 38 |
+
|
| 39 |
+
# 2. Tabela de Coeficientes
|
| 40 |
+
if not modelo.tabelas_coef.empty:
|
| 41 |
+
add_subsection_title(doc, "", "Coeficientes do Modelo")
|
| 42 |
+
_adicionar_tabela_coeficientes(doc, modelo.tabelas_coef)
|
| 43 |
+
doc.add_paragraph()
|
| 44 |
+
conteudo_gerado = True
|
| 45 |
+
|
| 46 |
+
# 3. Tabela de Valores Observados vs Calculados
|
| 47 |
+
if not modelo.tabelas_obs_calc.empty:
|
| 48 |
+
add_subsection_title(doc, "", "Valores Observados vs Calculados")
|
| 49 |
+
_adicionar_tabela_obs_calc(doc, modelo.tabelas_obs_calc)
|
| 50 |
+
conteudo_gerado = True
|
| 51 |
+
|
| 52 |
+
if not conteudo_gerado:
|
| 53 |
+
add_placeholder_text(doc, "[Dados de planilha não disponíveis no modelo]")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _adicionar_resumo_estatistico(doc: Document, modelos_resumos: Dict[str, Any]) -> None:
|
| 57 |
+
"""
|
| 58 |
+
Adiciona o resumo estatístico no formato da aba Resumo do interface_estatisticas.
|
| 59 |
+
"""
|
| 60 |
+
resumo = reorganizar_modelos_resumos(modelos_resumos)
|
| 61 |
+
|
| 62 |
+
# Estatísticas Gerais
|
| 63 |
+
estat_gerais = resumo.get("estatisticas_gerais", {})
|
| 64 |
+
if estat_gerais:
|
| 65 |
+
add_subsection_title(doc, "", "Estatísticas Gerais")
|
| 66 |
+
dados = [["Indicador", "Valor"]]
|
| 67 |
+
for chave, info in estat_gerais.items():
|
| 68 |
+
nome = info.get("nome", chave)
|
| 69 |
+
valor = info.get("valor")
|
| 70 |
+
if valor is not None:
|
| 71 |
+
dados.append([nome, formatar_numero(valor)])
|
| 72 |
+
if len(dados) > 1:
|
| 73 |
+
add_simple_table(doc, dados, header_row=True)
|
| 74 |
+
doc.add_paragraph()
|
| 75 |
+
|
| 76 |
+
# Teste F
|
| 77 |
+
teste_f = resumo.get("teste_f", {})
|
| 78 |
+
if teste_f.get("estatistica") is not None:
|
| 79 |
+
add_subsection_title(doc, "", teste_f.get("nome", "Teste F"))
|
| 80 |
+
dados = [["Indicador", "Valor"]]
|
| 81 |
+
dados.append(["Estatística F", formatar_numero(teste_f["estatistica"])])
|
| 82 |
+
if teste_f.get("pvalor") is not None:
|
| 83 |
+
dados.append(["P-valor", formatar_numero(teste_f["pvalor"])])
|
| 84 |
+
add_simple_table(doc, dados, header_row=True)
|
| 85 |
+
if teste_f.get("interpretacao"):
|
| 86 |
+
_adicionar_interpretacao(doc, teste_f["interpretacao"])
|
| 87 |
+
doc.add_paragraph()
|
| 88 |
+
|
| 89 |
+
# Teste de Normalidade (Kolmogorov-Smirnov)
|
| 90 |
+
teste_ks = resumo.get("teste_ks", {})
|
| 91 |
+
if teste_ks.get("estatistica") is not None:
|
| 92 |
+
add_subsection_title(doc, "", teste_ks.get("nome", "Teste de Normalidade (Kolmogorov-Smirnov)"))
|
| 93 |
+
dados = [["Indicador", "Valor"]]
|
| 94 |
+
dados.append(["Estatística KS", formatar_numero(teste_ks["estatistica"])])
|
| 95 |
+
if teste_ks.get("pvalor") is not None:
|
| 96 |
+
dados.append(["P-valor", formatar_numero(teste_ks["pvalor"])])
|
| 97 |
+
add_simple_table(doc, dados, header_row=True)
|
| 98 |
+
if teste_ks.get("interpretacao"):
|
| 99 |
+
_adicionar_interpretacao(doc, teste_ks["interpretacao"])
|
| 100 |
+
doc.add_paragraph()
|
| 101 |
+
|
| 102 |
+
# Teste de Normalidade (Comparação com Curva Normal)
|
| 103 |
+
perc_resid = resumo.get("perc_resid", {})
|
| 104 |
+
if perc_resid.get("valor"):
|
| 105 |
+
add_subsection_title(doc, "", perc_resid.get("nome", "Teste de Normalidade (Comparação com a Curva Normal)"))
|
| 106 |
+
add_body_text(doc, f"Percentuais atingidos: {perc_resid['valor']}")
|
| 107 |
+
interpretacoes = perc_resid.get("interpretacao", [])
|
| 108 |
+
if interpretacoes:
|
| 109 |
+
add_body_text(doc, "Critérios ideais:")
|
| 110 |
+
for item in interpretacoes:
|
| 111 |
+
add_body_text(doc, f" • {item}")
|
| 112 |
+
doc.add_paragraph()
|
| 113 |
+
|
| 114 |
+
# Teste de Autocorrelação (Durbin-Watson)
|
| 115 |
+
teste_dw = resumo.get("teste_dw", {})
|
| 116 |
+
if teste_dw.get("estatistica") is not None:
|
| 117 |
+
add_subsection_title(doc, "", teste_dw.get("nome", "Teste de Autocorrelação (Durbin-Watson)"))
|
| 118 |
+
dados = [["Indicador", "Valor"]]
|
| 119 |
+
dados.append(["Estatística DW", formatar_numero(teste_dw["estatistica"])])
|
| 120 |
+
add_simple_table(doc, dados, header_row=True)
|
| 121 |
+
if teste_dw.get("interpretacao"):
|
| 122 |
+
_adicionar_interpretacao(doc, teste_dw["interpretacao"])
|
| 123 |
+
doc.add_paragraph()
|
| 124 |
+
|
| 125 |
+
# Teste de Homocedasticidade (Breusch-Pagan)
|
| 126 |
+
teste_bp = resumo.get("teste_bp", {})
|
| 127 |
+
if teste_bp.get("estatistica") is not None:
|
| 128 |
+
add_subsection_title(doc, "", teste_bp.get("nome", "Teste de Homocedasticidade (Breusch-Pagan)"))
|
| 129 |
+
dados = [["Indicador", "Valor"]]
|
| 130 |
+
dados.append(["Estatística LM", formatar_numero(teste_bp["estatistica"])])
|
| 131 |
+
if teste_bp.get("pvalor") is not None:
|
| 132 |
+
dados.append(["P-valor", formatar_numero(teste_bp["pvalor"])])
|
| 133 |
+
add_simple_table(doc, dados, header_row=True)
|
| 134 |
+
if teste_bp.get("interpretacao"):
|
| 135 |
+
_adicionar_interpretacao(doc, teste_bp["interpretacao"])
|
| 136 |
+
doc.add_paragraph()
|
| 137 |
+
|
| 138 |
+
# Equação do Modelo
|
| 139 |
+
equacao = resumo.get("equacao")
|
| 140 |
+
if equacao:
|
| 141 |
+
add_subsection_title(doc, "", "Equação do Modelo")
|
| 142 |
+
criar_paragrafo_formatado(
|
| 143 |
+
doc,
|
| 144 |
+
equacao,
|
| 145 |
+
tamanho=11,
|
| 146 |
+
alinhamento=WD_ALIGN_PARAGRAPH.CENTER,
|
| 147 |
+
espaco_antes=6,
|
| 148 |
+
espaco_depois=6,
|
| 149 |
+
)
|
| 150 |
+
doc.add_paragraph()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _adicionar_interpretacao(doc: Document, texto: str) -> None:
|
| 154 |
+
"""Adiciona texto de interpretação em itálico."""
|
| 155 |
+
criar_paragrafo_formatado(
|
| 156 |
+
doc,
|
| 157 |
+
f"Interpretação: {texto}",
|
| 158 |
+
italico=True,
|
| 159 |
+
tamanho=10,
|
| 160 |
+
espaco_antes=3,
|
| 161 |
+
espaco_depois=3,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _adicionar_tabela_coeficientes(doc: Document, df) -> None:
|
| 166 |
+
"""Adiciona tabela de coeficientes."""
|
| 167 |
+
headers = ["Variável"] + [str(col) for col in df.columns.tolist()]
|
| 168 |
+
dados = [headers]
|
| 169 |
+
|
| 170 |
+
for idx, row in df.iterrows():
|
| 171 |
+
linha = [str(idx)]
|
| 172 |
+
for val in row.values:
|
| 173 |
+
if isinstance(val, float):
|
| 174 |
+
linha.append(f"{val:.6f}")
|
| 175 |
+
else:
|
| 176 |
+
linha.append(str(val) if val is not None else "")
|
| 177 |
+
dados.append(linha)
|
| 178 |
+
|
| 179 |
+
add_simple_table(doc, dados, header_row=True, rotacao_cabecalho=True)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _adicionar_tabela_obs_calc(doc: Document, df) -> None:
|
| 183 |
+
"""Adiciona tabela de valores observados vs calculados (todos os dados com ID)."""
|
| 184 |
+
headers = ["ID"] + [str(col) for col in df.columns.tolist()]
|
| 185 |
+
dados = [headers]
|
| 186 |
+
|
| 187 |
+
for idx, (_, row) in enumerate(df.iterrows(), start=1):
|
| 188 |
+
linha = [str(idx)] # ID da amostra (1-based)
|
| 189 |
+
for val in row.values:
|
| 190 |
+
if isinstance(val, float):
|
| 191 |
+
linha.append(f"{val:.2f}")
|
| 192 |
+
else:
|
| 193 |
+
linha.append(str(val) if val is not None else "")
|
| 194 |
+
dados.append(linha)
|
| 195 |
+
|
| 196 |
+
add_simple_table(doc, dados, header_row=True, rotacao_cabecalho=True)
|
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, trabalhos_tecnicos, visualizacao
|
| 11 |
from app.runtime_paths import resolve_frontend_dist_dir
|
| 12 |
from app.services import auth_service, visualizacao_service
|
| 13 |
|
|
@@ -55,6 +55,7 @@ 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 |
|
| 60 |
@app.on_event("startup")
|
|
|
|
| 7 |
from fastapi.responses import JSONResponse
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
|
| 10 |
+
from app.api import anexos, auth, elaboracao, health, logs, pesquisa, repositorio, session, trabalhos_tecnicos, visualizacao
|
| 11 |
from app.runtime_paths import resolve_frontend_dist_dir
|
| 12 |
from app.services import auth_service, visualizacao_service
|
| 13 |
|
|
|
|
| 55 |
app.include_router(repositorio.router)
|
| 56 |
app.include_router(trabalhos_tecnicos.router)
|
| 57 |
app.include_router(logs.router)
|
| 58 |
+
app.include_router(anexos.router)
|
| 59 |
|
| 60 |
|
| 61 |
@app.on_event("startup")
|
backend/app/services/anexos_service.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any, Sequence
|
| 5 |
+
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
from joblib import load
|
| 8 |
+
|
| 9 |
+
from app.core.anexos import AnexosGenerator, model_data_from_package
|
| 10 |
+
from app.core.elaboracao.core import _migrar_pacote_v1_para_v2
|
| 11 |
+
from app.runtime_paths import resolve_local_data_path
|
| 12 |
+
from app.services import model_repository
|
| 13 |
+
from app.services.serializers import sanitize_value
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
OUTPUT_DIR = resolve_local_data_path("anexos_gerados")
|
| 17 |
+
AVALIACAO_KEYS = {
|
| 18 |
+
"valores_x",
|
| 19 |
+
"extrapolacoes",
|
| 20 |
+
"estimado",
|
| 21 |
+
"tipo_y",
|
| 22 |
+
"coluna_area",
|
| 23 |
+
"valor_area",
|
| 24 |
+
"estimado_unitario",
|
| 25 |
+
"estimado_total",
|
| 26 |
+
"conversao_disponivel",
|
| 27 |
+
"motivo_conversao_indisponivel",
|
| 28 |
+
"ca_inf",
|
| 29 |
+
"ca_sup",
|
| 30 |
+
"ic_inf",
|
| 31 |
+
"ic_sup",
|
| 32 |
+
"perc_inf",
|
| 33 |
+
"perc_sup",
|
| 34 |
+
"amplitude",
|
| 35 |
+
"precisao",
|
| 36 |
+
"houve_extrapolacao",
|
| 37 |
+
"fronteira",
|
| 38 |
+
"perc_ext",
|
| 39 |
+
"qtd_extrapolacoes",
|
| 40 |
+
"fundamentacao",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def listar_modelos() -> dict[str, Any]:
|
| 45 |
+
return sanitize_value(model_repository.list_repository_models())
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _carregar_modelo(modelo_id: str):
|
| 49 |
+
caminho = model_repository.resolve_model_file(modelo_id)
|
| 50 |
+
try:
|
| 51 |
+
pacote = load(caminho)
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
raise HTTPException(status_code=400, detail=f"Falha ao ler arquivo .dai: {exc}") from exc
|
| 54 |
+
|
| 55 |
+
if not isinstance(pacote, dict):
|
| 56 |
+
raise HTTPException(status_code=400, detail="Arquivo .dai invalido")
|
| 57 |
+
if "versao" not in pacote:
|
| 58 |
+
try:
|
| 59 |
+
pacote = _migrar_pacote_v1_para_v2(pacote)
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
raise HTTPException(status_code=400, detail=f"Falha ao migrar arquivo .dai: {exc}") from exc
|
| 62 |
+
|
| 63 |
+
modelo = model_data_from_package(pacote, caminho, nome=caminho.stem)
|
| 64 |
+
if modelo is None or modelo.xy_preview.empty:
|
| 65 |
+
raise HTTPException(status_code=400, detail="Modelo sem dados suficientes para gerar anexos")
|
| 66 |
+
return modelo
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def obter_colunas(modelo_id: str) -> dict[str, Any]:
|
| 70 |
+
modelo = _carregar_modelo(modelo_id)
|
| 71 |
+
return {
|
| 72 |
+
"modelo_id": modelo.nome,
|
| 73 |
+
"columns": [str(coluna) for coluna in modelo.xy_preview.columns.tolist()],
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _validar_colunas(modelo, colunas: Sequence[str] | None) -> list[str] | None:
|
| 78 |
+
if colunas is None:
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
disponiveis = [str(coluna) for coluna in modelo.xy_preview.columns.tolist()]
|
| 82 |
+
selecionadas: list[str] = []
|
| 83 |
+
vistas: set[str] = set()
|
| 84 |
+
for coluna in colunas:
|
| 85 |
+
nome = str(coluna)
|
| 86 |
+
if nome in vistas:
|
| 87 |
+
continue
|
| 88 |
+
vistas.add(nome)
|
| 89 |
+
selecionadas.append(nome)
|
| 90 |
+
|
| 91 |
+
invalidas = [coluna for coluna in selecionadas if coluna not in disponiveis]
|
| 92 |
+
if invalidas:
|
| 93 |
+
raise HTTPException(
|
| 94 |
+
status_code=400,
|
| 95 |
+
detail=f"Colunas inválidas para o banco de dados: {', '.join(invalidas)}",
|
| 96 |
+
)
|
| 97 |
+
if not selecionadas:
|
| 98 |
+
raise HTTPException(status_code=400, detail="Selecione ao menos uma coluna para o banco de dados")
|
| 99 |
+
return selecionadas
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _avisos_estatisticos(modelo) -> list[str]:
|
| 103 |
+
resumo = modelo.modelos_resumos or {}
|
| 104 |
+
avisos: list[str] = []
|
| 105 |
+
testes = (
|
| 106 |
+
("Teste F", resumo.get("Interpretacao_F")),
|
| 107 |
+
("Teste de Normalidade (Kolmogorov-Smirnov)", resumo.get("Interpretacao_KS")),
|
| 108 |
+
("Teste de Autocorrelação (Durbin-Watson)", resumo.get("Interpretacao_DW")),
|
| 109 |
+
("Teste de Homocedasticidade (Breusch-Pagan)", resumo.get("Interpretacao_BP")),
|
| 110 |
+
)
|
| 111 |
+
for nome, interpretacao in testes:
|
| 112 |
+
texto = str(interpretacao or "").strip()
|
| 113 |
+
if texto and ("❌" in texto or "⚠" in texto):
|
| 114 |
+
avisos.append(f"{nome}: {texto}")
|
| 115 |
+
return avisos
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _normalizar_avaliacao(avaliacao: dict[str, Any] | None) -> dict[str, Any] | None:
|
| 119 |
+
if not isinstance(avaliacao, dict):
|
| 120 |
+
return None
|
| 121 |
+
payload = {
|
| 122 |
+
chave: valor
|
| 123 |
+
for chave, valor in avaliacao.items()
|
| 124 |
+
if chave in AVALIACAO_KEYS
|
| 125 |
+
}
|
| 126 |
+
return sanitize_value(payload)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def gerar_documento(
|
| 130 |
+
modelo_id: str,
|
| 131 |
+
banco_dados_colunas: Sequence[str] | None = None,
|
| 132 |
+
avaliacao: dict[str, Any] | None = None,
|
| 133 |
+
) -> tuple[Path, list[str]]:
|
| 134 |
+
modelo = _carregar_modelo(modelo_id)
|
| 135 |
+
colunas = _validar_colunas(modelo, banco_dados_colunas)
|
| 136 |
+
avaliacao_normalizada = _normalizar_avaliacao(avaliacao)
|
| 137 |
+
try:
|
| 138 |
+
caminho = AnexosGenerator(OUTPUT_DIR).gerar(
|
| 139 |
+
modelo,
|
| 140 |
+
banco_dados_colunas=colunas,
|
| 141 |
+
avaliacao=avaliacao_normalizada,
|
| 142 |
+
)
|
| 143 |
+
except Exception as exc:
|
| 144 |
+
raise HTTPException(status_code=500, detail=f"Erro ao gerar anexos: {exc}") from exc
|
| 145 |
+
return caminho, _avisos_estatisticos(modelo)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def resolver_download(filename: str) -> Path:
|
| 149 |
+
nome_seguro = Path(str(filename or "")).name
|
| 150 |
+
if not nome_seguro or nome_seguro != filename or not nome_seguro.lower().endswith(".docx"):
|
| 151 |
+
raise HTTPException(status_code=400, detail="Nome de arquivo inválido")
|
| 152 |
+
caminho = OUTPUT_DIR / nome_seguro
|
| 153 |
+
if not caminho.is_file():
|
| 154 |
+
raise HTTPException(status_code=404, detail="Arquivo de anexos não encontrado")
|
| 155 |
+
return caminho
|
backend/app/services/pesquisa_service.py
CHANGED
|
@@ -4,13 +4,16 @@ from branca.element import Element
|
|
| 4 |
import json
|
| 5 |
from html import escape
|
| 6 |
import math
|
|
|
|
| 7 |
import re
|
|
|
|
| 8 |
import unicodedata
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from datetime import date, datetime
|
| 11 |
from pathlib import Path
|
| 12 |
from threading import Event, Lock
|
| 13 |
from typing import Any
|
|
|
|
| 14 |
|
| 15 |
import folium
|
| 16 |
from folium import plugins
|
|
@@ -18,6 +21,7 @@ import numpy as np
|
|
| 18 |
import pandas as pd
|
| 19 |
from fastapi import HTTPException
|
| 20 |
from joblib import load
|
|
|
|
| 21 |
|
| 22 |
from app.core.elaboracao import geocodificacao
|
| 23 |
from app.core.shapefile_runtime import load_attribute_records
|
|
@@ -1229,11 +1233,33 @@ def _tooltip_mapa_modelo_html(modelo: dict[str, Any]) -> str:
|
|
| 1229 |
)
|
| 1230 |
|
| 1231 |
|
| 1232 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1233 |
tooltip = _tooltip_mapa_modelo_html(modelo)
|
| 1234 |
-
|
|
|
|
|
|
|
| 1235 |
return tooltip
|
| 1236 |
-
detalhe =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1237 |
if tooltip.endswith("</div>"):
|
| 1238 |
return f"{tooltip[:-6]}{detalhe}</div>"
|
| 1239 |
return f"{tooltip}{detalhe}"
|
|
@@ -1250,23 +1276,38 @@ def _chave_coordenada_ponto(ponto: dict[str, Any], precisao: int = 7) -> tuple[f
|
|
| 1250 |
return (round(lat, precisao), round(lon, precisao))
|
| 1251 |
|
| 1252 |
|
| 1253 |
-
def
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
| 1258 |
-
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1262 |
|
| 1263 |
|
| 1264 |
def _ponto_mapa_modelo_payload_item(
|
| 1265 |
modelo: dict[str, Any],
|
| 1266 |
ponto: dict[str, Any],
|
| 1267 |
-
|
| 1268 |
) -> dict[str, Any]:
|
| 1269 |
cor = str(modelo.get("cor") or "#1f77b4")
|
|
|
|
|
|
|
|
|
|
| 1270 |
item: dict[str, Any] = {
|
| 1271 |
"lat": float(ponto["lat"]),
|
| 1272 |
"lon": float(ponto["lon"]),
|
|
@@ -1275,16 +1316,28 @@ def _ponto_mapa_modelo_payload_item(
|
|
| 1275 |
"stroke_color": cor,
|
| 1276 |
"stroke_weight": 1.0,
|
| 1277 |
"fill_opacity": 0.72,
|
| 1278 |
-
"tooltip_html": _tooltip_mapa_modelo_ponto_html(modelo,
|
|
|
|
|
|
|
|
|
|
| 1279 |
}
|
| 1280 |
if total_no_local > 1:
|
| 1281 |
item.update(
|
| 1282 |
{
|
| 1283 |
"grouped": True,
|
| 1284 |
"count": total_no_local,
|
| 1285 |
-
"
|
|
|
|
|
|
|
| 1286 |
"pane": "mesa-market-group-pane",
|
| 1287 |
"tooltip_sticky": False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
}
|
| 1289 |
)
|
| 1290 |
return item
|
|
@@ -1292,7 +1345,6 @@ def _ponto_mapa_modelo_payload_item(
|
|
| 1292 |
|
| 1293 |
def _pontos_mapa_modelo_payload(
|
| 1294 |
modelo: dict[str, Any],
|
| 1295 |
-
contagens_coordenadas: dict[tuple[float, float], int] | None = None,
|
| 1296 |
agrupar_pontos_mercado: bool = False,
|
| 1297 |
) -> list[dict[str, Any]]:
|
| 1298 |
if not agrupar_pontos_mercado:
|
|
@@ -1300,24 +1352,41 @@ def _pontos_mapa_modelo_payload(
|
|
| 1300 |
for ponto in modelo.get("pontos") or []:
|
| 1301 |
if _chave_coordenada_ponto(ponto) is None:
|
| 1302 |
continue
|
| 1303 |
-
pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto))
|
| 1304 |
return pontos_payload
|
| 1305 |
|
| 1306 |
-
grupos: dict[tuple[float, float], list[dict[str, Any]]] = {}
|
| 1307 |
-
for ponto in modelo.get("pontos") or []:
|
| 1308 |
-
chave = _chave_coordenada_ponto(ponto)
|
| 1309 |
-
if chave is None:
|
| 1310 |
-
continue
|
| 1311 |
-
grupos.setdefault(chave, []).append(ponto)
|
| 1312 |
-
|
| 1313 |
pontos_payload: list[dict[str, Any]] = []
|
| 1314 |
-
for
|
| 1315 |
ponto = pontos_no_local[0]
|
| 1316 |
-
|
| 1317 |
-
pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto, total_no_local))
|
| 1318 |
return pontos_payload
|
| 1319 |
|
| 1320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1321 |
def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 1322 |
payloads: list[dict[str, Any]] = []
|
| 1323 |
for idx, avaliando in enumerate(avaliandos_geo):
|
|
@@ -1477,15 +1546,10 @@ def _build_mapa_modelos_payload(
|
|
| 1477 |
if avaliandos_tecnicos_proximos is not None
|
| 1478 |
else []
|
| 1479 |
)
|
| 1480 |
-
contagens_pontos = (
|
| 1481 |
-
_contagens_coordenadas_modelos(modelos_plotados)
|
| 1482 |
-
if modo_exibicao == "pontos" and agrupar_pontos_mercado
|
| 1483 |
-
else {}
|
| 1484 |
-
)
|
| 1485 |
-
|
| 1486 |
for modelo in modelos_plotados:
|
|
|
|
| 1487 |
layer: dict[str, Any] = {
|
| 1488 |
-
"id":
|
| 1489 |
"label": str(modelo.get("nome") or "Modelo"),
|
| 1490 |
"show": True,
|
| 1491 |
"hover_highlight_group": "pesquisa-modelos",
|
|
@@ -1493,12 +1557,23 @@ def _build_mapa_modelos_payload(
|
|
| 1493 |
if modo_exibicao == "pontos":
|
| 1494 |
layer["points"] = _pontos_mapa_modelo_payload(
|
| 1495 |
modelo,
|
| 1496 |
-
contagens_pontos,
|
| 1497 |
agrupar_pontos_mercado=agrupar_pontos_mercado,
|
| 1498 |
)
|
| 1499 |
else:
|
| 1500 |
layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
|
| 1501 |
overlay_layers.append(layer)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1502 |
|
| 1503 |
if trabalhos_tecnicos_modelos:
|
| 1504 |
overlay_layers.append(
|
|
@@ -1561,10 +1636,66 @@ def _build_mapa_modelos_payload(
|
|
| 1561 |
|
| 1562 |
payload = build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
|
| 1563 |
if payload and isinstance(payload.get("controls"), dict):
|
| 1564 |
-
payload["controls"]["
|
|
|
|
| 1565 |
return payload
|
| 1566 |
|
| 1567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1568 |
def _renderizar_mapa_modelos(
|
| 1569 |
modelos_plotados: list[dict[str, Any]],
|
| 1570 |
bounds: list[list[float]],
|
|
|
|
| 4 |
import json
|
| 5 |
from html import escape
|
| 6 |
import math
|
| 7 |
+
import os
|
| 8 |
import re
|
| 9 |
+
import tempfile
|
| 10 |
import unicodedata
|
| 11 |
from dataclasses import dataclass
|
| 12 |
from datetime import date, datetime
|
| 13 |
from pathlib import Path
|
| 14 |
from threading import Event, Lock
|
| 15 |
from typing import Any
|
| 16 |
+
from uuid import uuid4
|
| 17 |
|
| 18 |
import folium
|
| 19 |
from folium import plugins
|
|
|
|
| 21 |
import pandas as pd
|
| 22 |
from fastapi import HTTPException
|
| 23 |
from joblib import load
|
| 24 |
+
from openpyxl import Workbook
|
| 25 |
|
| 26 |
from app.core.elaboracao import geocodificacao
|
| 27 |
from app.core.shapefile_runtime import load_attribute_records
|
|
|
|
| 1233 |
)
|
| 1234 |
|
| 1235 |
|
| 1236 |
+
def _indices_mapa_modelo_display(pontos_no_local: list[dict[str, Any]]) -> list[str]:
|
| 1237 |
+
indices: list[str] = []
|
| 1238 |
+
for ponto in pontos_no_local:
|
| 1239 |
+
indice = str(ponto.get("indice") or "").strip()
|
| 1240 |
+
if indice:
|
| 1241 |
+
indices.append(indice)
|
| 1242 |
+
return indices
|
| 1243 |
+
|
| 1244 |
+
|
| 1245 |
+
def _indices_mapa_modelo_badge(indices_no_local: list[str], limite: int = 28) -> str:
|
| 1246 |
+
texto = ", ".join(str(item).strip() for item in indices_no_local if str(item).strip())
|
| 1247 |
+
if len(texto) <= limite:
|
| 1248 |
+
return texto
|
| 1249 |
+
return texto[: max(0, limite - 1)].rstrip(" ,") + "…"
|
| 1250 |
+
|
| 1251 |
+
|
| 1252 |
+
def _tooltip_mapa_modelo_ponto_html(modelo: dict[str, Any], indices_no_local: list[str] | None = None) -> str:
|
| 1253 |
tooltip = _tooltip_mapa_modelo_html(modelo)
|
| 1254 |
+
indices = [str(item).strip() for item in (indices_no_local or []) if str(item).strip()]
|
| 1255 |
+
total_no_local = len(indices)
|
| 1256 |
+
if total_no_local <= 0:
|
| 1257 |
return tooltip
|
| 1258 |
+
detalhe = ""
|
| 1259 |
+
detalhe += (
|
| 1260 |
+
f"<br><span style='color:#555;'>{'Índices' if total_no_local > 1 else 'Índice'}:</span> "
|
| 1261 |
+
f"<b>{escape(', '.join(indices))}</b>"
|
| 1262 |
+
)
|
| 1263 |
if tooltip.endswith("</div>"):
|
| 1264 |
return f"{tooltip[:-6]}{detalhe}</div>"
|
| 1265 |
return f"{tooltip}{detalhe}"
|
|
|
|
| 1276 |
return (round(lat, precisao), round(lon, precisao))
|
| 1277 |
|
| 1278 |
|
| 1279 |
+
def _agrupar_pontos_modelo_por_coordenada(
|
| 1280 |
+
modelo: dict[str, Any],
|
| 1281 |
+
) -> list[tuple[tuple[float, float], list[dict[str, Any]]]]:
|
| 1282 |
+
grupos: dict[tuple[float, float], list[dict[str, Any]]] = {}
|
| 1283 |
+
for ponto in modelo.get("pontos") or []:
|
| 1284 |
+
chave = _chave_coordenada_ponto(ponto)
|
| 1285 |
+
if chave is None:
|
| 1286 |
+
continue
|
| 1287 |
+
grupos.setdefault(chave, []).append(ponto)
|
| 1288 |
+
return list(grupos.items())
|
| 1289 |
+
|
| 1290 |
+
|
| 1291 |
+
def _build_marker_html_indice_modelo(indices_display: list[str]) -> str:
|
| 1292 |
+
return (
|
| 1293 |
+
'<div style="transform: translate(10px, -14px);display:inline-block;background: rgba(255, 255, 255, 0.9);'
|
| 1294 |
+
+ 'border: 1px solid rgba(28, 45, 66, 0.45);border-radius: 10px;padding: 1px 6px;font-size: 11px;'
|
| 1295 |
+
+ 'font-weight: 700;line-height: 1.2;color: #1f2f44;white-space: nowrap;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);'
|
| 1296 |
+
+ 'pointer-events: none;">'
|
| 1297 |
+
+ escape(_indices_mapa_modelo_badge(indices_display))
|
| 1298 |
+
+ "</div>"
|
| 1299 |
+
)
|
| 1300 |
|
| 1301 |
|
| 1302 |
def _ponto_mapa_modelo_payload_item(
|
| 1303 |
modelo: dict[str, Any],
|
| 1304 |
ponto: dict[str, Any],
|
| 1305 |
+
pontos_no_local: list[dict[str, Any]] | None = None,
|
| 1306 |
) -> dict[str, Any]:
|
| 1307 |
cor = str(modelo.get("cor") or "#1f77b4")
|
| 1308 |
+
pontos_agrupados = pontos_no_local if pontos_no_local is not None else [ponto]
|
| 1309 |
+
indices_display = _indices_mapa_modelo_display(pontos_agrupados)
|
| 1310 |
+
total_no_local = len(indices_display) or 1
|
| 1311 |
item: dict[str, Any] = {
|
| 1312 |
"lat": float(ponto["lat"]),
|
| 1313 |
"lon": float(ponto["lon"]),
|
|
|
|
| 1316 |
"stroke_color": cor,
|
| 1317 |
"stroke_weight": 1.0,
|
| 1318 |
"fill_opacity": 0.72,
|
| 1319 |
+
"tooltip_html": _tooltip_mapa_modelo_ponto_html(modelo, indices_display),
|
| 1320 |
+
"selection_model_id": str(modelo.get("id") or ""),
|
| 1321 |
+
"selection_model_name": str(modelo.get("nome") or modelo.get("id") or "Modelo"),
|
| 1322 |
+
"selection_indices": indices_display,
|
| 1323 |
}
|
| 1324 |
if total_no_local > 1:
|
| 1325 |
item.update(
|
| 1326 |
{
|
| 1327 |
"grouped": True,
|
| 1328 |
"count": total_no_local,
|
| 1329 |
+
"hide_count": True,
|
| 1330 |
+
"group_title": "Dados neste local",
|
| 1331 |
+
"hide_group_count": True,
|
| 1332 |
"pane": "mesa-market-group-pane",
|
| 1333 |
"tooltip_sticky": False,
|
| 1334 |
+
"group_items": [
|
| 1335 |
+
{
|
| 1336 |
+
"indice": indice,
|
| 1337 |
+
"label": f"Índice {indice}",
|
| 1338 |
+
}
|
| 1339 |
+
for indice in indices_display
|
| 1340 |
+
],
|
| 1341 |
}
|
| 1342 |
)
|
| 1343 |
return item
|
|
|
|
| 1345 |
|
| 1346 |
def _pontos_mapa_modelo_payload(
|
| 1347 |
modelo: dict[str, Any],
|
|
|
|
| 1348 |
agrupar_pontos_mercado: bool = False,
|
| 1349 |
) -> list[dict[str, Any]]:
|
| 1350 |
if not agrupar_pontos_mercado:
|
|
|
|
| 1352 |
for ponto in modelo.get("pontos") or []:
|
| 1353 |
if _chave_coordenada_ponto(ponto) is None:
|
| 1354 |
continue
|
| 1355 |
+
pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto, [ponto]))
|
| 1356 |
return pontos_payload
|
| 1357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1358 |
pontos_payload: list[dict[str, Any]] = []
|
| 1359 |
+
for _chave, pontos_no_local in _agrupar_pontos_modelo_por_coordenada(modelo):
|
| 1360 |
ponto = pontos_no_local[0]
|
| 1361 |
+
pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto, pontos_no_local))
|
|
|
|
| 1362 |
return pontos_payload
|
| 1363 |
|
| 1364 |
|
| 1365 |
+
def _indices_mapa_modelo_payload(modelo: dict[str, Any]) -> list[dict[str, Any]]:
|
| 1366 |
+
payloads: list[dict[str, Any]] = []
|
| 1367 |
+
for _chave, pontos_no_local in _agrupar_pontos_modelo_por_coordenada(modelo):
|
| 1368 |
+
if not pontos_no_local:
|
| 1369 |
+
continue
|
| 1370 |
+
ponto = pontos_no_local[0]
|
| 1371 |
+
indices_display = _indices_mapa_modelo_display(pontos_no_local)
|
| 1372 |
+
if not indices_display:
|
| 1373 |
+
continue
|
| 1374 |
+
payloads.append(
|
| 1375 |
+
{
|
| 1376 |
+
"lat": float(ponto["lat"]),
|
| 1377 |
+
"lon": float(ponto["lon"]),
|
| 1378 |
+
"marker_html": _build_marker_html_indice_modelo(indices_display),
|
| 1379 |
+
"icon_size": [96 if len(indices_display) > 1 else 72, 24],
|
| 1380 |
+
"icon_anchor": [0, 0],
|
| 1381 |
+
"class_name": "mesa-indice-label",
|
| 1382 |
+
"interactive": False,
|
| 1383 |
+
"keyboard": False,
|
| 1384 |
+
"tooltip_html": _tooltip_mapa_modelo_ponto_html(modelo, indices_display),
|
| 1385 |
+
}
|
| 1386 |
+
)
|
| 1387 |
+
return payloads
|
| 1388 |
+
|
| 1389 |
+
|
| 1390 |
def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 1391 |
payloads: list[dict[str, Any]] = []
|
| 1392 |
for idx, avaliando in enumerate(avaliandos_geo):
|
|
|
|
| 1546 |
if avaliandos_tecnicos_proximos is not None
|
| 1547 |
else []
|
| 1548 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1549 |
for modelo in modelos_plotados:
|
| 1550 |
+
modelo_id = str(modelo.get("id") or "")
|
| 1551 |
layer: dict[str, Any] = {
|
| 1552 |
+
"id": modelo_id,
|
| 1553 |
"label": str(modelo.get("nome") or "Modelo"),
|
| 1554 |
"show": True,
|
| 1555 |
"hover_highlight_group": "pesquisa-modelos",
|
|
|
|
| 1557 |
if modo_exibicao == "pontos":
|
| 1558 |
layer["points"] = _pontos_mapa_modelo_payload(
|
| 1559 |
modelo,
|
|
|
|
| 1560 |
agrupar_pontos_mercado=agrupar_pontos_mercado,
|
| 1561 |
)
|
| 1562 |
else:
|
| 1563 |
layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
|
| 1564 |
overlay_layers.append(layer)
|
| 1565 |
+
if modo_exibicao == "pontos":
|
| 1566 |
+
indices_markers = _indices_mapa_modelo_payload(modelo)
|
| 1567 |
+
if indices_markers:
|
| 1568 |
+
overlay_layers.append(
|
| 1569 |
+
{
|
| 1570 |
+
"id": f"{modelo_id}__indices",
|
| 1571 |
+
"label": f"Índices • {str(modelo.get('nome') or 'Modelo')}",
|
| 1572 |
+
"show": False,
|
| 1573 |
+
"required_overlay_ids": [modelo_id],
|
| 1574 |
+
"markers": indices_markers,
|
| 1575 |
+
}
|
| 1576 |
+
)
|
| 1577 |
|
| 1578 |
if trabalhos_tecnicos_modelos:
|
| 1579 |
overlay_layers.append(
|
|
|
|
| 1636 |
|
| 1637 |
payload = build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
|
| 1638 |
if payload and isinstance(payload.get("controls"), dict):
|
| 1639 |
+
payload["controls"]["market_lasso"] = modo_exibicao == "pontos"
|
| 1640 |
+
payload["controls"]["layer_control_collapsed"] = True
|
| 1641 |
return payload
|
| 1642 |
|
| 1643 |
|
| 1644 |
+
def _normalizar_nome_aba_xlsx(nome: Any, fallback: str) -> str:
|
| 1645 |
+
texto = str(nome or "").strip() or fallback
|
| 1646 |
+
for char in ["\\", "/", "*", "?", ":", "[", "]"]:
|
| 1647 |
+
texto = texto.replace(char, " ")
|
| 1648 |
+
texto = re.sub(r"\s+", " ", texto).strip("' ").strip()
|
| 1649 |
+
return (texto or fallback)[:31]
|
| 1650 |
+
|
| 1651 |
+
|
| 1652 |
+
def exportar_indices_mapa_pesquisa_xlsx(modelos: list[dict[str, Any]] | None) -> tuple[str, str]:
|
| 1653 |
+
itens = []
|
| 1654 |
+
for idx, modelo in enumerate(modelos or [], start=1):
|
| 1655 |
+
if not isinstance(modelo, dict):
|
| 1656 |
+
continue
|
| 1657 |
+
nome = str(modelo.get("nome") or modelo.get("name") or modelo.get("modelo") or "").strip()
|
| 1658 |
+
indices = []
|
| 1659 |
+
vistos = set()
|
| 1660 |
+
for valor in modelo.get("indices") or []:
|
| 1661 |
+
texto = str(valor or "").strip()
|
| 1662 |
+
if not texto or texto in vistos:
|
| 1663 |
+
continue
|
| 1664 |
+
vistos.add(texto)
|
| 1665 |
+
indices.append(texto)
|
| 1666 |
+
if indices:
|
| 1667 |
+
itens.append({"nome": nome or f"Modelo {idx}", "indices": indices})
|
| 1668 |
+
|
| 1669 |
+
if not itens:
|
| 1670 |
+
raise HTTPException(status_code=400, detail="Nenhum indice selecionado para exportar.")
|
| 1671 |
+
|
| 1672 |
+
workbook = Workbook()
|
| 1673 |
+
worksheet = workbook.active
|
| 1674 |
+
nomes_usados: set[str] = set()
|
| 1675 |
+
for idx, item in enumerate(itens):
|
| 1676 |
+
sheet_name_base = _normalizar_nome_aba_xlsx(item["nome"], f"Modelo {idx + 1}")
|
| 1677 |
+
sheet_name = sheet_name_base
|
| 1678 |
+
suffix = 2
|
| 1679 |
+
while sheet_name.casefold() in nomes_usados:
|
| 1680 |
+
tail = f" {suffix}"
|
| 1681 |
+
sheet_name = f"{sheet_name_base[:31 - len(tail)]}{tail}"
|
| 1682 |
+
suffix += 1
|
| 1683 |
+
nomes_usados.add(sheet_name.casefold())
|
| 1684 |
+
if idx == 0:
|
| 1685 |
+
worksheet.title = sheet_name
|
| 1686 |
+
else:
|
| 1687 |
+
worksheet = workbook.create_sheet(sheet_name)
|
| 1688 |
+
worksheet.append(["INDICE"])
|
| 1689 |
+
for indice in item["indices"]:
|
| 1690 |
+
worksheet.append([indice])
|
| 1691 |
+
worksheet.column_dimensions["A"].width = 18
|
| 1692 |
+
|
| 1693 |
+
nome_arquivo = f"indices_mapa_pesquisa_{uuid4().hex[:8]}.xlsx"
|
| 1694 |
+
caminho = os.path.join(tempfile.gettempdir(), nome_arquivo)
|
| 1695 |
+
workbook.save(caminho)
|
| 1696 |
+
return caminho, nome_arquivo
|
| 1697 |
+
|
| 1698 |
+
|
| 1699 |
def _renderizar_mapa_modelos(
|
| 1700 |
modelos_plotados: list[dict[str, Any]],
|
| 1701 |
bounds: list[list[float]],
|
backend/app/services/serializers.py
CHANGED
|
@@ -27,14 +27,14 @@ def sanitize_value(value: Any) -> Any:
|
|
| 27 |
return [sanitize_value(item) for item in value.tolist()]
|
| 28 |
if isinstance(value, np.generic):
|
| 29 |
return sanitize_value(value.item())
|
|
|
|
|
|
|
| 30 |
if isinstance(value, (np.floating, float)):
|
| 31 |
if math.isnan(value) or math.isinf(value):
|
| 32 |
return None
|
| 33 |
return float(value)
|
| 34 |
if isinstance(value, (np.integer, int)):
|
| 35 |
return int(value)
|
| 36 |
-
if isinstance(value, (np.bool_, bool)):
|
| 37 |
-
return bool(value)
|
| 38 |
if isinstance(value, pd.Timestamp):
|
| 39 |
return value.isoformat()
|
| 40 |
if isinstance(value, pd.Timedelta):
|
|
|
|
| 27 |
return [sanitize_value(item) for item in value.tolist()]
|
| 28 |
if isinstance(value, np.generic):
|
| 29 |
return sanitize_value(value.item())
|
| 30 |
+
if isinstance(value, (np.bool_, bool)):
|
| 31 |
+
return bool(value)
|
| 32 |
if isinstance(value, (np.floating, float)):
|
| 33 |
if math.isnan(value) or math.isinf(value):
|
| 34 |
return None
|
| 35 |
return float(value)
|
| 36 |
if isinstance(value, (np.integer, int)):
|
| 37 |
return int(value)
|
|
|
|
|
|
|
| 38 |
if isinstance(value, pd.Timestamp):
|
| 39 |
return value.isoformat()
|
| 40 |
if isinstance(value, pd.Timedelta):
|
backend/requirements.txt
CHANGED
|
@@ -16,3 +16,6 @@ pyshp
|
|
| 16 |
geopandas
|
| 17 |
fiona
|
| 18 |
huggingface_hub>=0.30.0
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
geopandas
|
| 17 |
fiona
|
| 18 |
huggingface_hub>=0.30.0
|
| 19 |
+
python-docx
|
| 20 |
+
Pillow
|
| 21 |
+
matplotlib
|
frontend/src/api.js
CHANGED
|
@@ -407,6 +407,15 @@ export const api = {
|
|
| 407 |
}),
|
| 408 |
exportEquationViz: (sessionId, mode = 'excel') => getBlob(`/api/visualizacao/equation/export?session_id=${encodeURIComponent(sessionId)}&mode=${encodeURIComponent(String(mode || 'excel'))}`),
|
| 409 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
repositorioListar: () => getJson('/api/repositorio/modelos'),
|
| 411 |
repositorioUpload(files = [], { confirmarSubstituicao = false } = {}) {
|
| 412 |
const form = new FormData()
|
|
|
|
| 407 |
}),
|
| 408 |
exportEquationViz: (sessionId, mode = 'excel') => getBlob(`/api/visualizacao/equation/export?session_id=${encodeURIComponent(sessionId)}&mode=${encodeURIComponent(String(mode || 'excel'))}`),
|
| 409 |
|
| 410 |
+
anexosModelos: () => getJson('/api/anexos/modelos'),
|
| 411 |
+
anexosModeloColunas: (modeloId) => getJson(`/api/anexos/modelos/${encodeURIComponent(String(modeloId || ''))}/columns`),
|
| 412 |
+
anexosGerar: (modeloId, bancoDadosColunas, avaliacao = null) => postJson('/api/anexos/generate', {
|
| 413 |
+
modelo_id: modeloId,
|
| 414 |
+
banco_dados_colunas: bancoDadosColunas,
|
| 415 |
+
avaliacao,
|
| 416 |
+
}),
|
| 417 |
+
anexosDownload: (filename) => getBlob(`/api/anexos/download/${encodeURIComponent(String(filename || ''))}`),
|
| 418 |
+
|
| 419 |
repositorioListar: () => getJson('/api/repositorio/modelos'),
|
| 420 |
repositorioUpload(files = [], { confirmarSubstituicao = false } = {}) {
|
| 421 |
const form = new FormData()
|
frontend/src/components/AnexosModal.jsx
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useState } from 'react'
|
| 2 |
+
import { api, downloadBlob } from '../api'
|
| 3 |
+
|
| 4 |
+
function normalizarChave(value) {
|
| 5 |
+
return String(value || '')
|
| 6 |
+
.normalize('NFD')
|
| 7 |
+
.replace(/[\u0300-\u036f]/g, '')
|
| 8 |
+
.toLowerCase()
|
| 9 |
+
.trim()
|
| 10 |
+
.replace(/\.dai$/i, '')
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function resolverModeloId(chave, modelos) {
|
| 14 |
+
const normalizada = normalizarChave(chave)
|
| 15 |
+
if (!normalizada) return ''
|
| 16 |
+
for (const modelo of modelos || []) {
|
| 17 |
+
const candidatos = [modelo?.id, modelo?.arquivo, modelo?.nome_modelo]
|
| 18 |
+
.map(normalizarChave)
|
| 19 |
+
.filter(Boolean)
|
| 20 |
+
if (candidatos.includes(normalizada)) return String(modelo.id || '')
|
| 21 |
+
}
|
| 22 |
+
return ''
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function formatarFonte(fonte) {
|
| 26 |
+
if (!fonte || typeof fonte !== 'object') return ''
|
| 27 |
+
if (String(fonte.provider || '').toLowerCase() === 'hf_dataset') {
|
| 28 |
+
const repo = String(fonte.repo_id || '').trim()
|
| 29 |
+
const revisao = String(fonte.revision || '').trim()
|
| 30 |
+
return `HF Dataset${repo ? ` (${repo})` : ''}${revisao ? ` | revisão ${revisao.slice(0, 8)}` : ''}`
|
| 31 |
+
}
|
| 32 |
+
return 'Pasta local'
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export default function AnexosModal({
|
| 36 |
+
open,
|
| 37 |
+
defaultModeloId = '',
|
| 38 |
+
avaliacao = null,
|
| 39 |
+
lockModelo = false,
|
| 40 |
+
title = 'Gerar anexos',
|
| 41 |
+
onClose,
|
| 42 |
+
}) {
|
| 43 |
+
const [modelos, setModelos] = useState([])
|
| 44 |
+
const [fonte, setFonte] = useState(null)
|
| 45 |
+
const [modeloSelecionado, setModeloSelecionado] = useState('')
|
| 46 |
+
const [colunas, setColunas] = useState([])
|
| 47 |
+
const [loadingModelos, setLoadingModelos] = useState(false)
|
| 48 |
+
const [loadingColunas, setLoadingColunas] = useState(false)
|
| 49 |
+
const [generating, setGenerating] = useState(false)
|
| 50 |
+
const [downloading, setDownloading] = useState(false)
|
| 51 |
+
const [error, setError] = useState('')
|
| 52 |
+
const [result, setResult] = useState(null)
|
| 53 |
+
|
| 54 |
+
const colunasSelecionadas = useMemo(
|
| 55 |
+
() => colunas.filter((item) => item.selected).map((item) => item.name),
|
| 56 |
+
[colunas],
|
| 57 |
+
)
|
| 58 |
+
const busy = loadingModelos || loadingColunas || generating || downloading
|
| 59 |
+
const blocking = generating || downloading
|
| 60 |
+
const canGenerate = Boolean(modeloSelecionado && colunasSelecionadas.length && !busy)
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
if (!open) return undefined
|
| 64 |
+
let active = true
|
| 65 |
+
setLoadingModelos(true)
|
| 66 |
+
setError('')
|
| 67 |
+
setResult(null)
|
| 68 |
+
setColunas([])
|
| 69 |
+
setModeloSelecionado('')
|
| 70 |
+
|
| 71 |
+
api.anexosModelos()
|
| 72 |
+
.then((response) => {
|
| 73 |
+
if (!active) return
|
| 74 |
+
const lista = Array.isArray(response?.modelos) ? response.modelos : []
|
| 75 |
+
const resolvido = resolverModeloId(defaultModeloId, lista)
|
| 76 |
+
setModelos(lista)
|
| 77 |
+
setFonte(response?.fonte || null)
|
| 78 |
+
if (lockModelo && defaultModeloId && !resolvido) {
|
| 79 |
+
setModeloSelecionado('')
|
| 80 |
+
setError('O modelo usado nesta avaliação não foi encontrado no repositório configurado.')
|
| 81 |
+
return
|
| 82 |
+
}
|
| 83 |
+
setModeloSelecionado(resolvido || String(lista[0]?.id || ''))
|
| 84 |
+
})
|
| 85 |
+
.catch((err) => {
|
| 86 |
+
if (!active) return
|
| 87 |
+
setModelos([])
|
| 88 |
+
setModeloSelecionado('')
|
| 89 |
+
setError(err?.message || 'Não foi possível carregar os modelos.')
|
| 90 |
+
})
|
| 91 |
+
.finally(() => {
|
| 92 |
+
if (active) setLoadingModelos(false)
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
return () => {
|
| 96 |
+
active = false
|
| 97 |
+
}
|
| 98 |
+
}, [open, defaultModeloId, lockModelo])
|
| 99 |
+
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
if (!open || !modeloSelecionado) {
|
| 102 |
+
setColunas([])
|
| 103 |
+
return undefined
|
| 104 |
+
}
|
| 105 |
+
let active = true
|
| 106 |
+
setLoadingColunas(true)
|
| 107 |
+
setError('')
|
| 108 |
+
setResult(null)
|
| 109 |
+
|
| 110 |
+
api.anexosModeloColunas(modeloSelecionado)
|
| 111 |
+
.then((response) => {
|
| 112 |
+
if (!active) return
|
| 113 |
+
setColunas((response?.columns || []).map((name) => ({ name, selected: true })))
|
| 114 |
+
})
|
| 115 |
+
.catch((err) => {
|
| 116 |
+
if (!active) return
|
| 117 |
+
setColunas([])
|
| 118 |
+
setError(err?.message || 'Não foi possível carregar as colunas do modelo.')
|
| 119 |
+
})
|
| 120 |
+
.finally(() => {
|
| 121 |
+
if (active) setLoadingColunas(false)
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
return () => {
|
| 125 |
+
active = false
|
| 126 |
+
}
|
| 127 |
+
}, [open, modeloSelecionado])
|
| 128 |
+
|
| 129 |
+
if (!open) return null
|
| 130 |
+
|
| 131 |
+
function toggleColuna(nome) {
|
| 132 |
+
setColunas((current) => current.map((item) => (
|
| 133 |
+
item.name === nome ? { ...item, selected: !item.selected } : item
|
| 134 |
+
)))
|
| 135 |
+
setResult(null)
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function moverColuna(index, direction) {
|
| 139 |
+
setColunas((current) => {
|
| 140 |
+
const destino = index + direction
|
| 141 |
+
if (destino < 0 || destino >= current.length) return current
|
| 142 |
+
const next = [...current]
|
| 143 |
+
;[next[index], next[destino]] = [next[destino], next[index]]
|
| 144 |
+
return next
|
| 145 |
+
})
|
| 146 |
+
setResult(null)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async function gerar(event) {
|
| 150 |
+
event.preventDefault()
|
| 151 |
+
if (!canGenerate) return
|
| 152 |
+
setGenerating(true)
|
| 153 |
+
setError('')
|
| 154 |
+
setResult(null)
|
| 155 |
+
try {
|
| 156 |
+
const response = await api.anexosGerar(
|
| 157 |
+
modeloSelecionado,
|
| 158 |
+
colunasSelecionadas,
|
| 159 |
+
avaliacao,
|
| 160 |
+
)
|
| 161 |
+
setResult(response)
|
| 162 |
+
} catch (err) {
|
| 163 |
+
setError(err?.message || 'Falha ao gerar os anexos.')
|
| 164 |
+
} finally {
|
| 165 |
+
setGenerating(false)
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function baixar() {
|
| 170 |
+
const filename = String(result?.filename || '').trim()
|
| 171 |
+
if (!filename) return
|
| 172 |
+
setDownloading(true)
|
| 173 |
+
setError('')
|
| 174 |
+
try {
|
| 175 |
+
const blob = await api.anexosDownload(filename)
|
| 176 |
+
downloadBlob(blob, filename)
|
| 177 |
+
} catch (err) {
|
| 178 |
+
setError(err?.message || 'Falha ao baixar os anexos.')
|
| 179 |
+
} finally {
|
| 180 |
+
setDownloading(false)
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function fechar() {
|
| 185 |
+
if (!blocking && typeof onClose === 'function') onClose()
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<div className="pesquisa-modal-backdrop" onClick={(event) => {
|
| 190 |
+
if (event.target === event.currentTarget) fechar()
|
| 191 |
+
}}>
|
| 192 |
+
<div className="pesquisa-modal anexos-modal" role="dialog" aria-modal="true" aria-label={title}>
|
| 193 |
+
<div className="pesquisa-modal-head">
|
| 194 |
+
<div>
|
| 195 |
+
<h4>{title}</h4>
|
| 196 |
+
{fonte ? <p>Fonte: {formatarFonte(fonte)}</p> : null}
|
| 197 |
+
</div>
|
| 198 |
+
<button type="button" className="pesquisa-modal-close" onClick={fechar} disabled={blocking}>
|
| 199 |
+
Fechar
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<form className="pesquisa-modal-body anexos-modal-body" onSubmit={gerar}>
|
| 204 |
+
<label className="anexos-modal-model-field">
|
| 205 |
+
Modelo de avaliação
|
| 206 |
+
<select
|
| 207 |
+
value={modeloSelecionado}
|
| 208 |
+
onChange={(event) => setModeloSelecionado(event.target.value)}
|
| 209 |
+
disabled={busy || lockModelo || !modelos.length}
|
| 210 |
+
>
|
| 211 |
+
{!modelos.length ? <option value="">Nenhum modelo encontrado</option> : null}
|
| 212 |
+
{modelos.map((modelo) => (
|
| 213 |
+
<option key={String(modelo.id)} value={String(modelo.id)}>
|
| 214 |
+
{modelo.nome_modelo || modelo.arquivo || modelo.id}
|
| 215 |
+
</option>
|
| 216 |
+
))}
|
| 217 |
+
</select>
|
| 218 |
+
</label>
|
| 219 |
+
|
| 220 |
+
<section className="anexos-columns-section">
|
| 221 |
+
<div className="anexos-columns-head">
|
| 222 |
+
<div>
|
| 223 |
+
<strong>Colunas do banco de dados</strong>
|
| 224 |
+
<span>{colunasSelecionadas.length} selecionadas</span>
|
| 225 |
+
</div>
|
| 226 |
+
<div className="anexos-columns-actions">
|
| 227 |
+
<button
|
| 228 |
+
type="button"
|
| 229 |
+
onClick={() => setColunas((current) => current.map((item) => ({ ...item, selected: true })))}
|
| 230 |
+
disabled={busy}
|
| 231 |
+
>
|
| 232 |
+
Todas
|
| 233 |
+
</button>
|
| 234 |
+
<button
|
| 235 |
+
type="button"
|
| 236 |
+
onClick={() => setColunas((current) => current.map((item) => ({ ...item, selected: false })))}
|
| 237 |
+
disabled={busy}
|
| 238 |
+
>
|
| 239 |
+
Nenhuma
|
| 240 |
+
</button>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
{loadingColunas ? (
|
| 245 |
+
<div className="section1-empty-hint">Carregando colunas...</div>
|
| 246 |
+
) : (
|
| 247 |
+
<div className="anexos-columns-list">
|
| 248 |
+
{colunas.map((coluna, index) => (
|
| 249 |
+
<div className="anexos-column-row" key={coluna.name}>
|
| 250 |
+
<label>
|
| 251 |
+
<input
|
| 252 |
+
type="checkbox"
|
| 253 |
+
checked={coluna.selected}
|
| 254 |
+
onChange={() => toggleColuna(coluna.name)}
|
| 255 |
+
disabled={busy}
|
| 256 |
+
/>
|
| 257 |
+
<span>{coluna.name}</span>
|
| 258 |
+
</label>
|
| 259 |
+
<div className="anexos-reorder-actions">
|
| 260 |
+
<button
|
| 261 |
+
type="button"
|
| 262 |
+
title="Mover para cima"
|
| 263 |
+
aria-label={`Mover ${coluna.name} para cima`}
|
| 264 |
+
onClick={() => moverColuna(index, -1)}
|
| 265 |
+
disabled={busy || index === 0}
|
| 266 |
+
>
|
| 267 |
+
↑
|
| 268 |
+
</button>
|
| 269 |
+
<button
|
| 270 |
+
type="button"
|
| 271 |
+
title="Mover para baixo"
|
| 272 |
+
aria-label={`Mover ${coluna.name} para baixo`}
|
| 273 |
+
onClick={() => moverColuna(index, 1)}
|
| 274 |
+
disabled={busy || index === colunas.length - 1}
|
| 275 |
+
>
|
| 276 |
+
↓
|
| 277 |
+
</button>
|
| 278 |
+
</div>
|
| 279 |
+
</div>
|
| 280 |
+
))}
|
| 281 |
+
</div>
|
| 282 |
+
)}
|
| 283 |
+
</section>
|
| 284 |
+
|
| 285 |
+
{error ? <div className="error-line inline-error">{error}</div> : null}
|
| 286 |
+
|
| 287 |
+
{result?.statistical_warnings?.length ? (
|
| 288 |
+
<div className="anexos-warning-panel">
|
| 289 |
+
<strong>Atenção</strong>
|
| 290 |
+
<ul>
|
| 291 |
+
{result.statistical_warnings.map((warning) => <li key={warning}>{warning}</li>)}
|
| 292 |
+
</ul>
|
| 293 |
+
</div>
|
| 294 |
+
) : null}
|
| 295 |
+
|
| 296 |
+
{result ? (
|
| 297 |
+
<div className="anexos-result-panel">
|
| 298 |
+
<div>
|
| 299 |
+
<strong>{result.message}</strong>
|
| 300 |
+
<span>{result.filename}</span>
|
| 301 |
+
</div>
|
| 302 |
+
<button type="button" onClick={() => void baixar()} disabled={downloading}>
|
| 303 |
+
{downloading ? 'Baixando...' : 'Baixar DOCX'}
|
| 304 |
+
</button>
|
| 305 |
+
</div>
|
| 306 |
+
) : null}
|
| 307 |
+
|
| 308 |
+
<div className="anexos-modal-footer">
|
| 309 |
+
<button type="submit" disabled={!canGenerate}>
|
| 310 |
+
{generating ? 'Gerando...' : 'Gerar DOCX'}
|
| 311 |
+
</button>
|
| 312 |
+
</div>
|
| 313 |
+
</form>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
)
|
| 317 |
+
}
|
frontend/src/components/AvaliacaoTab.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { api, downloadBlob } from '../api'
|
|
| 3 |
import { buildCsvBlob } from '../csv'
|
| 4 |
import { buildAvaliacaoModeloLink } from '../deepLinks'
|
| 5 |
import LoadingOverlay from './LoadingOverlay'
|
|
|
|
| 6 |
import MapFrame from './MapFrame'
|
| 7 |
import ModeloObservacaoCard from './ModeloObservacaoCard'
|
| 8 |
import ShareLinkButton from './ShareLinkButton'
|
|
@@ -585,6 +586,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 585 |
const [repoFonteModelos, setRepoFonteModelos] = useState('')
|
| 586 |
|
| 587 |
const [modeloAtualNome, setModeloAtualNome] = useState('')
|
|
|
|
| 588 |
const [camposAvaliacao, setCamposAvaliacao] = useState([])
|
| 589 |
const [equacaoSab, setEquacaoSab] = useState('')
|
| 590 |
const valoresAvaliacaoRef = useRef({})
|
|
@@ -605,6 +607,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 605 |
const [confirmarExclusaoCardId, setConfirmarExclusaoCardId] = useState('')
|
| 606 |
const [avaliacaoPopup, setAvaliacaoPopup] = useState(null)
|
| 607 |
const [avaliacaoInfoModal, setAvaliacaoInfoModal] = useState(null)
|
|
|
|
| 608 |
const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
|
| 609 |
const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
|
| 610 |
const [knnDetalheErro, setKnnDetalheErro] = useState('')
|
|
@@ -921,12 +924,13 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 921 |
setAvaliacaoFormVersion((prev) => prev + 1)
|
| 922 |
}
|
| 923 |
|
| 924 |
-
function aplicarRespostaExibicao(resp, nomeModelo) {
|
| 925 |
const campos = resp?.campos_avaliacao || []
|
| 926 |
setCamposAvaliacao(campos)
|
| 927 |
resetCamposAvaliacao(campos)
|
| 928 |
setEquacaoSab(String(resp?.equacoes?.excel_sab || ''))
|
| 929 |
setModeloAtualNome(String(nomeModelo || '').trim())
|
|
|
|
| 930 |
setModeloObservacao(String(resp?.meta_modelo?.observacao_modelo || '').trim())
|
| 931 |
}
|
| 932 |
|
|
@@ -990,7 +994,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 990 |
setBadgeHtml(removerObservacaoDoBadgeHtml(uploadResp?.badge_html || ''))
|
| 991 |
const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
|
| 992 |
const contextoResp = await api.evaluationContextViz(sessionId)
|
| 993 |
-
aplicarRespostaExibicao(contextoResp, nomeModelo)
|
| 994 |
if (typeof onRouteChange === 'function') {
|
| 995 |
onRouteChange({ tab: 'avaliacao' })
|
| 996 |
}
|
|
@@ -1018,7 +1022,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 1018 |
const modeloSelecionado = repoModeloOptions.find((item) => item.value === modeloIdUsado)
|
| 1019 |
const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
|
| 1020 |
const contextoResp = await api.evaluationContextViz(sessionId)
|
| 1021 |
-
aplicarRespostaExibicao(contextoResp, nomeModelo)
|
| 1022 |
setUploadedFile(null)
|
| 1023 |
if (typeof onRouteChange === 'function') {
|
| 1024 |
onRouteChange({
|
|
@@ -1162,6 +1166,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 1162 |
const card = {
|
| 1163 |
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
| 1164 |
modelo: modeloAtualNome || 'Modelo sem nome',
|
|
|
|
| 1165 |
createdAt: new Date().toISOString(),
|
| 1166 |
avaliacao: ultima,
|
| 1167 |
}
|
|
@@ -1839,6 +1844,18 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 1839 |
<span>{item.modelo}</span>
|
| 1840 |
</div>
|
| 1841 |
<div className="avaliacao-modelos-card-actions">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1842 |
{!exclusaoPendente ? (
|
| 1843 |
<button
|
| 1844 |
type="button"
|
|
@@ -2014,7 +2031,20 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
|
|
| 2014 |
)}
|
| 2015 |
</div>
|
| 2016 |
|
| 2017 |
-
<LoadingOverlay
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2018 |
{avaliacaoPopup?.html ? (
|
| 2019 |
<div
|
| 2020 |
className="avaliacao-popup-overlay"
|
|
|
|
| 3 |
import { buildCsvBlob } from '../csv'
|
| 4 |
import { buildAvaliacaoModeloLink } from '../deepLinks'
|
| 5 |
import LoadingOverlay from './LoadingOverlay'
|
| 6 |
+
import AnexosModal from './AnexosModal'
|
| 7 |
import MapFrame from './MapFrame'
|
| 8 |
import ModeloObservacaoCard from './ModeloObservacaoCard'
|
| 9 |
import ShareLinkButton from './ShareLinkButton'
|
|
|
|
| 586 |
const [repoFonteModelos, setRepoFonteModelos] = useState('')
|
| 587 |
|
| 588 |
const [modeloAtualNome, setModeloAtualNome] = useState('')
|
| 589 |
+
const [modeloAtualId, setModeloAtualId] = useState('')
|
| 590 |
const [camposAvaliacao, setCamposAvaliacao] = useState([])
|
| 591 |
const [equacaoSab, setEquacaoSab] = useState('')
|
| 592 |
const valoresAvaliacaoRef = useRef({})
|
|
|
|
| 607 |
const [confirmarExclusaoCardId, setConfirmarExclusaoCardId] = useState('')
|
| 608 |
const [avaliacaoPopup, setAvaliacaoPopup] = useState(null)
|
| 609 |
const [avaliacaoInfoModal, setAvaliacaoInfoModal] = useState(null)
|
| 610 |
+
const [anexosCardModal, setAnexosCardModal] = useState(null)
|
| 611 |
const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
|
| 612 |
const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
|
| 613 |
const [knnDetalheErro, setKnnDetalheErro] = useState('')
|
|
|
|
| 924 |
setAvaliacaoFormVersion((prev) => prev + 1)
|
| 925 |
}
|
| 926 |
|
| 927 |
+
function aplicarRespostaExibicao(resp, nomeModelo, modeloId = '') {
|
| 928 |
const campos = resp?.campos_avaliacao || []
|
| 929 |
setCamposAvaliacao(campos)
|
| 930 |
resetCamposAvaliacao(campos)
|
| 931 |
setEquacaoSab(String(resp?.equacoes?.excel_sab || ''))
|
| 932 |
setModeloAtualNome(String(nomeModelo || '').trim())
|
| 933 |
+
setModeloAtualId(String(modeloId || nomeModelo || '').trim())
|
| 934 |
setModeloObservacao(String(resp?.meta_modelo?.observacao_modelo || '').trim())
|
| 935 |
}
|
| 936 |
|
|
|
|
| 994 |
setBadgeHtml(removerObservacaoDoBadgeHtml(uploadResp?.badge_html || ''))
|
| 995 |
const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
|
| 996 |
const contextoResp = await api.evaluationContextViz(sessionId)
|
| 997 |
+
aplicarRespostaExibicao(contextoResp, nomeModelo, uploadResp?.nome_modelo || arquivoUpload.name)
|
| 998 |
if (typeof onRouteChange === 'function') {
|
| 999 |
onRouteChange({ tab: 'avaliacao' })
|
| 1000 |
}
|
|
|
|
| 1022 |
const modeloSelecionado = repoModeloOptions.find((item) => item.value === modeloIdUsado)
|
| 1023 |
const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
|
| 1024 |
const contextoResp = await api.evaluationContextViz(sessionId)
|
| 1025 |
+
aplicarRespostaExibicao(contextoResp, nomeModelo, modeloIdUsado)
|
| 1026 |
setUploadedFile(null)
|
| 1027 |
if (typeof onRouteChange === 'function') {
|
| 1028 |
onRouteChange({
|
|
|
|
| 1166 |
const card = {
|
| 1167 |
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
| 1168 |
modelo: modeloAtualNome || 'Modelo sem nome',
|
| 1169 |
+
modeloId: modeloAtualId || modeloAtualNome,
|
| 1170 |
createdAt: new Date().toISOString(),
|
| 1171 |
avaliacao: ultima,
|
| 1172 |
}
|
|
|
|
| 1844 |
<span>{item.modelo}</span>
|
| 1845 |
</div>
|
| 1846 |
<div className="avaliacao-modelos-card-actions">
|
| 1847 |
+
<button
|
| 1848 |
+
type="button"
|
| 1849 |
+
className="avaliacao-modelos-anexo-btn"
|
| 1850 |
+
onClick={() => setAnexosCardModal({
|
| 1851 |
+
modeloId: item.modeloId || item.modelo,
|
| 1852 |
+
avaliacao: aval,
|
| 1853 |
+
titulo: `Gerar anexo da Aval. ${idx + 1}`,
|
| 1854 |
+
})}
|
| 1855 |
+
disabled={loading}
|
| 1856 |
+
>
|
| 1857 |
+
Gerar anexo
|
| 1858 |
+
</button>
|
| 1859 |
{!exclusaoPendente ? (
|
| 1860 |
<button
|
| 1861 |
type="button"
|
|
|
|
| 2031 |
)}
|
| 2032 |
</div>
|
| 2033 |
|
| 2034 |
+
<LoadingOverlay
|
| 2035 |
+
show={loading || avaliandoLocalizacaoLoading}
|
| 2036 |
+
label={avaliandoLocalizacaoLoading
|
| 2037 |
+
? (avaliandoLocalizacaoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
|
| 2038 |
+
: 'Processando dados...'}
|
| 2039 |
+
/>
|
| 2040 |
+
<AnexosModal
|
| 2041 |
+
open={Boolean(anexosCardModal)}
|
| 2042 |
+
defaultModeloId={anexosCardModal?.modeloId || ''}
|
| 2043 |
+
avaliacao={anexosCardModal?.avaliacao || null}
|
| 2044 |
+
lockModelo
|
| 2045 |
+
title={anexosCardModal?.titulo || 'Gerar anexo da avaliação'}
|
| 2046 |
+
onClose={() => setAnexosCardModal(null)}
|
| 2047 |
+
/>
|
| 2048 |
{avaliacaoPopup?.html ? (
|
| 2049 |
<div
|
| 2050 |
className="avaliacao-popup-overlay"
|
frontend/src/components/ElaboracaoTab.jsx
CHANGED
|
@@ -1596,8 +1596,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 1596 |
const [importPreview, setImportPreview] = useState(null)
|
| 1597 |
const [importSelectedColumns, setImportSelectedColumns] = useState([])
|
| 1598 |
const [requiresImportColumnSelection, setRequiresImportColumnSelection] = useState(false)
|
| 1599 |
-
const [firstColumnsCount, setFirstColumnsCount] = useState('')
|
| 1600 |
-
const [firstColumnsChecked, setFirstColumnsChecked] = useState(false)
|
| 1601 |
const [elaborador, setElaborador] = useState(null)
|
| 1602 |
const [modeloCarregadoInfo, setModeloCarregadoInfo] = useState(null)
|
| 1603 |
const [repoModelos, setRepoModelos] = useState([])
|
|
@@ -3476,16 +3474,15 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 3476 |
setSheetOptions(resp.sheets || [])
|
| 3477 |
const proximaImportPreview = resp?.import_preview || null
|
| 3478 |
const proximaSelecaoColunasPendente = Boolean(resp?.requires_column_selection)
|
|
|
|
|
|
|
|
|
|
| 3479 |
setImportPreview(proximaImportPreview)
|
| 3480 |
setRequiresImportColumnSelection(proximaSelecaoColunasPendente)
|
| 3481 |
if (proximaImportPreview && proximaSelecaoColunasPendente) {
|
| 3482 |
-
setImportSelectedColumns(
|
| 3483 |
-
setFirstColumnsCount('')
|
| 3484 |
-
setFirstColumnsChecked(false)
|
| 3485 |
} else if (!proximaImportPreview) {
|
| 3486 |
setImportSelectedColumns([])
|
| 3487 |
-
setFirstColumnsCount('')
|
| 3488 |
-
setFirstColumnsChecked(false)
|
| 3489 |
}
|
| 3490 |
const nomeModeloCarregado = String(resp?.nome_modelo || '').trim()
|
| 3491 |
if (Boolean(resp.requires_sheet)) {
|
|
@@ -3609,8 +3606,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 3609 |
setImportPreview(null)
|
| 3610 |
setImportSelectedColumns([])
|
| 3611 |
setRequiresImportColumnSelection(false)
|
| 3612 |
-
setFirstColumnsCount('')
|
| 3613 |
-
setFirstColumnsChecked(false)
|
| 3614 |
const nomeArquivo = String(arquivoUpload?.name || '').toLowerCase()
|
| 3615 |
const uploadEhDai = nomeArquivo.endsWith('.dai')
|
| 3616 |
setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
|
|
@@ -3649,8 +3644,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 3649 |
setImportPreview(null)
|
| 3650 |
setImportSelectedColumns([])
|
| 3651 |
setRequiresImportColumnSelection(false)
|
| 3652 |
-
setFirstColumnsCount('')
|
| 3653 |
-
setFirstColumnsChecked(false)
|
| 3654 |
setUploadedFile(null)
|
| 3655 |
setTipoFonteDados('tabular')
|
| 3656 |
const resp = await api.pasteElaboracaoTable(sessionId, textoColado)
|
|
@@ -3693,8 +3686,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 3693 |
setImportPreview(null)
|
| 3694 |
setImportSelectedColumns([])
|
| 3695 |
setRequiresImportColumnSelection(false)
|
| 3696 |
-
setFirstColumnsCount('')
|
| 3697 |
-
setFirstColumnsChecked(false)
|
| 3698 |
setTipoFonteDados('dai')
|
| 3699 |
const resp = await api.elaboracaoRepositorioCarregar(sessionId, modeloId)
|
| 3700 |
aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
|
|
@@ -3784,7 +3775,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 3784 |
function onToggleImportColumn(coluna) {
|
| 3785 |
const valor = String(coluna || '').trim()
|
| 3786 |
if (!valor) return
|
| 3787 |
-
setFirstColumnsChecked(false)
|
| 3788 |
setImportSelectedColumns((prev) => {
|
| 3789 |
const next = prev.includes(valor)
|
| 3790 |
? prev.filter((item) => item !== valor)
|
|
@@ -3794,40 +3784,8 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 3794 |
setRequiresImportColumnSelection(true)
|
| 3795 |
}
|
| 3796 |
|
| 3797 |
-
function getFirstImportColumnsQuantity(value = firstColumnsCount) {
|
| 3798 |
-
const limite = importPreviewColumns.length
|
| 3799 |
-
const parsed = Number.parseInt(String(value || '0'), 10)
|
| 3800 |
-
return Number.isFinite(parsed) ? Math.max(0, Math.min(limite, parsed)) : 0
|
| 3801 |
-
}
|
| 3802 |
-
|
| 3803 |
-
function onFirstColumnsCountChange(value) {
|
| 3804 |
-
setFirstColumnsCount(value)
|
| 3805 |
-
if (!firstColumnsChecked) return
|
| 3806 |
-
const quantidade = getFirstImportColumnsQuantity(value)
|
| 3807 |
-
setImportSelectedColumns(importPreviewColumns.slice(0, quantidade))
|
| 3808 |
-
setRequiresImportColumnSelection(true)
|
| 3809 |
-
setImportacaoErro('')
|
| 3810 |
-
}
|
| 3811 |
-
|
| 3812 |
-
function onToggleFirstImportColumns(event) {
|
| 3813 |
-
const checked = event.target.checked
|
| 3814 |
-
setFirstColumnsChecked(checked)
|
| 3815 |
-
const quantidade = getFirstImportColumnsQuantity()
|
| 3816 |
-
const primeiras = importPreviewColumns.slice(0, quantidade)
|
| 3817 |
-
if (checked) {
|
| 3818 |
-
setFirstColumnsCount(String(quantidade))
|
| 3819 |
-
setImportSelectedColumns(primeiras)
|
| 3820 |
-
} else {
|
| 3821 |
-
const primeirasSet = new Set(primeiras)
|
| 3822 |
-
setImportSelectedColumns((prev) => prev.filter((coluna) => !primeirasSet.has(coluna)))
|
| 3823 |
-
}
|
| 3824 |
-
setRequiresImportColumnSelection(true)
|
| 3825 |
-
setImportacaoErro('')
|
| 3826 |
-
}
|
| 3827 |
-
|
| 3828 |
function onClearImportColumns() {
|
| 3829 |
setImportSelectedColumns([])
|
| 3830 |
-
setFirstColumnsChecked(false)
|
| 3831 |
setRequiresImportColumnSelection(true)
|
| 3832 |
setImportacaoErro('')
|
| 3833 |
}
|
|
@@ -5683,26 +5641,6 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
|
|
| 5683 |
</div>
|
| 5684 |
|
| 5685 |
<div className="import-preview-tools">
|
| 5686 |
-
<label className="import-preview-first-columns-toggle">
|
| 5687 |
-
<input
|
| 5688 |
-
type="checkbox"
|
| 5689 |
-
checked={firstColumnsChecked}
|
| 5690 |
-
onChange={onToggleFirstImportColumns}
|
| 5691 |
-
disabled={loading || importPreviewColumns.length === 0}
|
| 5692 |
-
/>
|
| 5693 |
-
Selecionar primeiras
|
| 5694 |
-
</label>
|
| 5695 |
-
<label className="import-preview-first-columns-field">
|
| 5696 |
-
<input
|
| 5697 |
-
type="number"
|
| 5698 |
-
min="0"
|
| 5699 |
-
max={importPreviewColumns.length}
|
| 5700 |
-
value={firstColumnsCount}
|
| 5701 |
-
onChange={(event) => onFirstColumnsCountChange(event.target.value)}
|
| 5702 |
-
disabled={loading || importPreviewColumns.length === 0}
|
| 5703 |
-
/>
|
| 5704 |
-
colunas
|
| 5705 |
-
</label>
|
| 5706 |
<button
|
| 5707 |
type="button"
|
| 5708 |
className="import-preview-clear-btn"
|
|
|
|
| 1596 |
const [importPreview, setImportPreview] = useState(null)
|
| 1597 |
const [importSelectedColumns, setImportSelectedColumns] = useState([])
|
| 1598 |
const [requiresImportColumnSelection, setRequiresImportColumnSelection] = useState(false)
|
|
|
|
|
|
|
| 1599 |
const [elaborador, setElaborador] = useState(null)
|
| 1600 |
const [modeloCarregadoInfo, setModeloCarregadoInfo] = useState(null)
|
| 1601 |
const [repoModelos, setRepoModelos] = useState([])
|
|
|
|
| 3474 |
setSheetOptions(resp.sheets || [])
|
| 3475 |
const proximaImportPreview = resp?.import_preview || null
|
| 3476 |
const proximaSelecaoColunasPendente = Boolean(resp?.requires_column_selection)
|
| 3477 |
+
const proximaColunasImportacao = Array.isArray(proximaImportPreview?.columns)
|
| 3478 |
+
? proximaImportPreview.columns.map((item) => String(item))
|
| 3479 |
+
: []
|
| 3480 |
setImportPreview(proximaImportPreview)
|
| 3481 |
setRequiresImportColumnSelection(proximaSelecaoColunasPendente)
|
| 3482 |
if (proximaImportPreview && proximaSelecaoColunasPendente) {
|
| 3483 |
+
setImportSelectedColumns(proximaColunasImportacao)
|
|
|
|
|
|
|
| 3484 |
} else if (!proximaImportPreview) {
|
| 3485 |
setImportSelectedColumns([])
|
|
|
|
|
|
|
| 3486 |
}
|
| 3487 |
const nomeModeloCarregado = String(resp?.nome_modelo || '').trim()
|
| 3488 |
if (Boolean(resp.requires_sheet)) {
|
|
|
|
| 3606 |
setImportPreview(null)
|
| 3607 |
setImportSelectedColumns([])
|
| 3608 |
setRequiresImportColumnSelection(false)
|
|
|
|
|
|
|
| 3609 |
const nomeArquivo = String(arquivoUpload?.name || '').toLowerCase()
|
| 3610 |
const uploadEhDai = nomeArquivo.endsWith('.dai')
|
| 3611 |
setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
|
|
|
|
| 3644 |
setImportPreview(null)
|
| 3645 |
setImportSelectedColumns([])
|
| 3646 |
setRequiresImportColumnSelection(false)
|
|
|
|
|
|
|
| 3647 |
setUploadedFile(null)
|
| 3648 |
setTipoFonteDados('tabular')
|
| 3649 |
const resp = await api.pasteElaboracaoTable(sessionId, textoColado)
|
|
|
|
| 3686 |
setImportPreview(null)
|
| 3687 |
setImportSelectedColumns([])
|
| 3688 |
setRequiresImportColumnSelection(false)
|
|
|
|
|
|
|
| 3689 |
setTipoFonteDados('dai')
|
| 3690 |
const resp = await api.elaboracaoRepositorioCarregar(sessionId, modeloId)
|
| 3691 |
aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
|
|
|
|
| 3775 |
function onToggleImportColumn(coluna) {
|
| 3776 |
const valor = String(coluna || '').trim()
|
| 3777 |
if (!valor) return
|
|
|
|
| 3778 |
setImportSelectedColumns((prev) => {
|
| 3779 |
const next = prev.includes(valor)
|
| 3780 |
? prev.filter((item) => item !== valor)
|
|
|
|
| 3784 |
setRequiresImportColumnSelection(true)
|
| 3785 |
}
|
| 3786 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3787 |
function onClearImportColumns() {
|
| 3788 |
setImportSelectedColumns([])
|
|
|
|
| 3789 |
setRequiresImportColumnSelection(true)
|
| 3790 |
setImportacaoErro('')
|
| 3791 |
}
|
|
|
|
| 5641 |
</div>
|
| 5642 |
|
| 5643 |
<div className="import-preview-tools">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5644 |
<button
|
| 5645 |
type="button"
|
| 5646 |
className="import-preview-clear-btn"
|
frontend/src/components/LeafletMapFrame.jsx
CHANGED
|
@@ -43,6 +43,18 @@ function normalizeCssColor(value, fallback = '#607d8b') {
|
|
| 43 |
return fallback
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
function buildGroupedMarketMarkerHtml(item, size = 22) {
|
| 47 |
const count = Math.max(2, Number(item?.count) || 2)
|
| 48 |
const color = normalizeCssColor(item?.color || item?.fill_color || '#607d8b')
|
|
@@ -50,9 +62,12 @@ function buildGroupedMarketMarkerHtml(item, size = 22) {
|
|
| 50 |
const fontSize = Math.max(9, Math.min(12, markerSize * 0.38))
|
| 51 |
const borderSize = markerSize >= 24 ? 3 : 2
|
| 52 |
const haloSize = markerSize >= 24 ? 4 : 2
|
|
|
|
|
|
|
|
|
|
| 53 |
return (
|
| 54 |
`<div class="mesa-market-group-marker" style="--mesa-group-color:${color};--mesa-group-size:${markerSize}px;--mesa-group-font:${fontSize}px;--mesa-group-border:${borderSize}px;--mesa-group-halo:${haloSize}px;">`
|
| 55 |
-
+
|
| 56 |
+ '</div>'
|
| 57 |
)
|
| 58 |
}
|
|
@@ -318,6 +333,21 @@ function formatDistance(distanceMeters) {
|
|
| 318 |
return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} m`
|
| 319 |
}
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
function computePolygonArea(latlngs) {
|
| 322 |
if (!Array.isArray(latlngs) || latlngs.length < 3) return 0
|
| 323 |
const d2r = Math.PI / 180
|
|
@@ -1156,6 +1186,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1156 |
})
|
| 1157 |
mapRef.current = map
|
| 1158 |
let restoreMapInteractions = null
|
|
|
|
| 1159 |
const bairrosPane = map.createPane('mesa-bairros-pane')
|
| 1160 |
const marketPane = map.createPane('mesa-market-pane')
|
| 1161 |
const marketGroupPane = map.createPane('mesa-market-group-pane')
|
|
@@ -1451,26 +1482,11 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1451 |
})
|
| 1452 |
}
|
| 1453 |
|
| 1454 |
-
function updateHoverLegendState(groupKey, activeId) {
|
| 1455 |
-
const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey)
|
| 1456 |
-
metas.forEach((meta) => {
|
| 1457 |
-
const label = meta.labelElement
|
| 1458 |
-
if (!label) return
|
| 1459 |
-
const isActive = Boolean(activeId) && meta.id === activeId
|
| 1460 |
-
label.style.cursor = 'pointer'
|
| 1461 |
-
label.style.transition = 'opacity 0.16s ease, color 0.16s ease, font-weight 0.16s ease'
|
| 1462 |
-
label.style.opacity = activeId ? (isActive ? '1' : '0.42') : ''
|
| 1463 |
-
label.style.color = isActive ? '#123b63' : ''
|
| 1464 |
-
label.style.fontWeight = isActive ? '700' : ''
|
| 1465 |
-
})
|
| 1466 |
-
}
|
| 1467 |
-
|
| 1468 |
function clearHoverHighlight(groupKey) {
|
| 1469 |
const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey)
|
| 1470 |
metas.forEach((meta) => {
|
| 1471 |
applyGroupState(meta, 'normal')
|
| 1472 |
})
|
| 1473 |
-
updateHoverLegendState(groupKey, null)
|
| 1474 |
}
|
| 1475 |
|
| 1476 |
function setHoveredHighlight(groupKey, activeId) {
|
|
@@ -1488,47 +1504,6 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1488 |
}
|
| 1489 |
applyGroupState(meta, meta.id === activeId ? 'highlight' : 'dim')
|
| 1490 |
})
|
| 1491 |
-
updateHoverLegendState(groupKey, activeId)
|
| 1492 |
-
}
|
| 1493 |
-
|
| 1494 |
-
function bindLayerControlHover(layerControl) {
|
| 1495 |
-
const container = layerControl?.getContainer?.()
|
| 1496 |
-
if (!container) return
|
| 1497 |
-
const labels = Array.from(container.querySelectorAll('.leaflet-control-layers-overlays label'))
|
| 1498 |
-
if (!labels.length) return
|
| 1499 |
-
|
| 1500 |
-
const metaByLayerId = new Map()
|
| 1501 |
-
hoverHighlightMetas.forEach((meta) => {
|
| 1502 |
-
if (!meta?.layerGroup) return
|
| 1503 |
-
metaByLayerId.set(String(L.Util.stamp(meta.layerGroup)), meta)
|
| 1504 |
-
})
|
| 1505 |
-
|
| 1506 |
-
labels.forEach((label) => {
|
| 1507 |
-
const input = label.querySelector('input')
|
| 1508 |
-
const layerId = String(input?.layerId || '')
|
| 1509 |
-
const meta = metaByLayerId.get(layerId)
|
| 1510 |
-
if (!meta) return
|
| 1511 |
-
meta.labelElement = label
|
| 1512 |
-
if (label.dataset.mesaModelHoverBound === '1') return
|
| 1513 |
-
label.dataset.mesaModelHoverBound = '1'
|
| 1514 |
-
const onEnter = () => setHoveredHighlight(meta.groupKey, meta.id)
|
| 1515 |
-
const onLeave = (event) => {
|
| 1516 |
-
const nextTarget = event?.relatedTarget || null
|
| 1517 |
-
if (nextTarget && label.contains(nextTarget)) return
|
| 1518 |
-
clearHoverHighlight(meta.groupKey)
|
| 1519 |
-
}
|
| 1520 |
-
label.addEventListener('mouseenter', onEnter)
|
| 1521 |
-
label.addEventListener('mouseover', onEnter)
|
| 1522 |
-
label.addEventListener('mouseleave', onLeave)
|
| 1523 |
-
label.addEventListener('mouseout', onLeave)
|
| 1524 |
-
})
|
| 1525 |
-
|
| 1526 |
-
hoverHighlightMetas.forEach((meta) => {
|
| 1527 |
-
if (meta.labelElement) {
|
| 1528 |
-
meta.labelElement.style.cursor = 'pointer'
|
| 1529 |
-
meta.labelElement.style.transition = 'opacity 0.16s ease, color 0.16s ease, font-weight 0.16s ease'
|
| 1530 |
-
}
|
| 1531 |
-
})
|
| 1532 |
}
|
| 1533 |
|
| 1534 |
function addPointMarkers(layerGroup, items) {
|
|
@@ -1538,6 +1513,42 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1538 |
const latlng = parseLatLonPair(item?.lat, item?.lon)
|
| 1539 |
if (!latlng) return
|
| 1540 |
if (item?.grouped) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1541 |
const marker = L.marker(latlng, {
|
| 1542 |
icon: buildGroupedMarketIcon(item, 16),
|
| 1543 |
pane: String(item?.pane || 'mesa-market-group-pane'),
|
|
@@ -1561,6 +1572,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1561 |
setGroupSelection({
|
| 1562 |
title: String(item?.group_title || `${groupItems.length} dados neste local`),
|
| 1563 |
count: Number(item?.count) || groupItems.length,
|
|
|
|
| 1564 |
items: groupItems,
|
| 1565 |
})
|
| 1566 |
})
|
|
@@ -1797,7 +1809,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1797 |
if (specId) {
|
| 1798 |
overlayGroupsById.set(specId, layerGroup)
|
| 1799 |
}
|
| 1800 |
-
if (spec?.show
|
| 1801 |
layerGroup.addTo(map)
|
| 1802 |
}
|
| 1803 |
if (spec?.geojson_url || spec?.geojson_data) {
|
|
@@ -1823,7 +1835,6 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1823 |
id: specId,
|
| 1824 |
groupKey: hoverGroup,
|
| 1825 |
layerGroup,
|
| 1826 |
-
labelElement: null,
|
| 1827 |
})
|
| 1828 |
}
|
| 1829 |
})
|
|
@@ -1833,14 +1844,118 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 1833 |
map.on('overlayremove', refreshDependencyAwareOverlays)
|
| 1834 |
}
|
| 1835 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1836 |
if (payload.controls?.layer_control) {
|
| 1837 |
-
const collapsed = payload.controls?.layer_control_collapsed
|
| 1838 |
const layerControl = L.control.layers(baseLayers, overlayLayers, { collapsed }).addTo(map)
|
| 1839 |
-
|
| 1840 |
-
|
| 1841 |
-
|
| 1842 |
-
window.setTimeout(() => bindLayerControlHover(layerControl), 180)
|
| 1843 |
-
}
|
| 1844 |
}
|
| 1845 |
if (payload.controls?.measure) {
|
| 1846 |
const disableInteractionsForMeasure = () => {
|
|
@@ -2103,6 +2218,357 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 2103 |
measureControl.addTo(map)
|
| 2104 |
}
|
| 2105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2106 |
if (payload.controls?.fullscreen) {
|
| 2107 |
addFullscreenControl(map, onToggleFullscreen)
|
| 2108 |
}
|
|
@@ -2142,6 +2608,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 2142 |
}
|
| 2143 |
disposed = true
|
| 2144 |
restoreMapInteractions?.()
|
|
|
|
| 2145 |
if (popupLoaderRef.current === carregarPopupRegistro) {
|
| 2146 |
popupLoaderRef.current = null
|
| 2147 |
}
|
|
@@ -2169,7 +2636,9 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
|
|
| 2169 |
<div className="leaflet-market-group-modal-head">
|
| 2170 |
<div>
|
| 2171 |
<strong>{groupSelection.title}</strong>
|
| 2172 |
-
|
|
|
|
|
|
|
| 2173 |
</div>
|
| 2174 |
<button type="button" onClick={closeGroupSelection} aria-label="Fechar">×</button>
|
| 2175 |
</div>
|
|
|
|
| 43 |
return fallback
|
| 44 |
}
|
| 45 |
|
| 46 |
+
function isTrueFlag(value) {
|
| 47 |
+
return value === true || value === 1 || value === '1'
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function isFalseFlag(value) {
|
| 51 |
+
return value === false || value === 0 || value === '0'
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function naturalCompare(a, b) {
|
| 55 |
+
return String(a ?? '').localeCompare(String(b ?? ''), 'pt-BR', { numeric: true, sensitivity: 'base' })
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
function buildGroupedMarketMarkerHtml(item, size = 22) {
|
| 59 |
const count = Math.max(2, Number(item?.count) || 2)
|
| 60 |
const color = normalizeCssColor(item?.color || item?.fill_color || '#607d8b')
|
|
|
|
| 62 |
const fontSize = Math.max(9, Math.min(12, markerSize * 0.38))
|
| 63 |
const borderSize = markerSize >= 24 ? 3 : 2
|
| 64 |
const haloSize = markerSize >= 24 ? 4 : 2
|
| 65 |
+
const labelHtml = isTrueFlag(item?.hide_count)
|
| 66 |
+
? ''
|
| 67 |
+
: `<span>${escapeHtml(count > 99 ? '99+' : count)}</span>`
|
| 68 |
return (
|
| 69 |
`<div class="mesa-market-group-marker" style="--mesa-group-color:${color};--mesa-group-size:${markerSize}px;--mesa-group-font:${fontSize}px;--mesa-group-border:${borderSize}px;--mesa-group-halo:${haloSize}px;">`
|
| 70 |
+
+ labelHtml
|
| 71 |
+ '</div>'
|
| 72 |
)
|
| 73 |
}
|
|
|
|
| 333 |
return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} m`
|
| 334 |
}
|
| 335 |
|
| 336 |
+
function pointInPolygon(point, polygon) {
|
| 337 |
+
if (!point || !Array.isArray(polygon) || polygon.length < 3) return false
|
| 338 |
+
let inside = false
|
| 339 |
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
|
| 340 |
+
const xi = polygon[i].x
|
| 341 |
+
const yi = polygon[i].y
|
| 342 |
+
const xj = polygon[j].x
|
| 343 |
+
const yj = polygon[j].y
|
| 344 |
+
const intersects = ((yi > point.y) !== (yj > point.y))
|
| 345 |
+
&& (point.x < ((xj - xi) * (point.y - yi)) / ((yj - yi) || Number.EPSILON) + xi)
|
| 346 |
+
if (intersects) inside = !inside
|
| 347 |
+
}
|
| 348 |
+
return inside
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
function computePolygonArea(latlngs) {
|
| 352 |
if (!Array.isArray(latlngs) || latlngs.length < 3) return 0
|
| 353 |
const d2r = Math.PI / 180
|
|
|
|
| 1186 |
})
|
| 1187 |
mapRef.current = map
|
| 1188 |
let restoreMapInteractions = null
|
| 1189 |
+
let cleanupLasso = null
|
| 1190 |
const bairrosPane = map.createPane('mesa-bairros-pane')
|
| 1191 |
const marketPane = map.createPane('mesa-market-pane')
|
| 1192 |
const marketGroupPane = map.createPane('mesa-market-group-pane')
|
|
|
|
| 1482 |
})
|
| 1483 |
}
|
| 1484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1485 |
function clearHoverHighlight(groupKey) {
|
| 1486 |
const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey)
|
| 1487 |
metas.forEach((meta) => {
|
| 1488 |
applyGroupState(meta, 'normal')
|
| 1489 |
})
|
|
|
|
| 1490 |
}
|
| 1491 |
|
| 1492 |
function setHoveredHighlight(groupKey, activeId) {
|
|
|
|
| 1504 |
}
|
| 1505 |
applyGroupState(meta, meta.id === activeId ? 'highlight' : 'dim')
|
| 1506 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1507 |
}
|
| 1508 |
|
| 1509 |
function addPointMarkers(layerGroup, items) {
|
|
|
|
| 1513 |
const latlng = parseLatLonPair(item?.lat, item?.lon)
|
| 1514 |
if (!latlng) return
|
| 1515 |
if (item?.grouped) {
|
| 1516 |
+
if (isTrueFlag(item?.hide_count)) {
|
| 1517 |
+
const marker = L.circleMarker(latlng, {
|
| 1518 |
+
renderer: canvasRenderer,
|
| 1519 |
+
pane: String(item?.pane || 'mesa-market-pane'),
|
| 1520 |
+
radius: Number(item?.base_radius) || 4,
|
| 1521 |
+
color: String(item?.stroke_color || '#000000'),
|
| 1522 |
+
weight: Number.isFinite(Number(item?.stroke_weight))
|
| 1523 |
+
? Number(item.stroke_weight)
|
| 1524 |
+
: (Number(item?.base_radius) > 4 ? 1 : 0.8),
|
| 1525 |
+
fill: item?.fill !== false,
|
| 1526 |
+
fillColor: String(item?.color || item?.fill_color || '#FF8C00'),
|
| 1527 |
+
fillOpacity: Number.isFinite(Number(item?.fill_opacity)) ? Number(item.fill_opacity) : 0.68,
|
| 1528 |
+
interactive: item?.interactive !== false,
|
| 1529 |
+
bubblingMouseEvents: false,
|
| 1530 |
+
})
|
| 1531 |
+
marker.options.mesaBaseRadius = Number(item?.base_radius) || 4
|
| 1532 |
+
const tooltipHtml = String(item?.tooltip_html || '').trim()
|
| 1533 |
+
if (tooltipHtml) {
|
| 1534 |
+
marker.bindTooltip(tooltipHtml, { sticky: item?.tooltip_sticky !== false })
|
| 1535 |
+
}
|
| 1536 |
+
marker.on('click', () => {
|
| 1537 |
+
map.closePopup()
|
| 1538 |
+
marker.unbindPopup()
|
| 1539 |
+
const groupItems = Array.isArray(item?.group_items) ? item.group_items : []
|
| 1540 |
+
if (!groupItems.length) return
|
| 1541 |
+
groupSelectionMarkerRef.current = marker
|
| 1542 |
+
setGroupSelection({
|
| 1543 |
+
title: String(item?.group_title || 'Dados neste local'),
|
| 1544 |
+
count: Number(item?.count) || groupItems.length,
|
| 1545 |
+
hideCount: isTrueFlag(item?.hide_group_count),
|
| 1546 |
+
items: groupItems,
|
| 1547 |
+
})
|
| 1548 |
+
})
|
| 1549 |
+
layerGroup.addLayer(marker)
|
| 1550 |
+
return
|
| 1551 |
+
}
|
| 1552 |
const marker = L.marker(latlng, {
|
| 1553 |
icon: buildGroupedMarketIcon(item, 16),
|
| 1554 |
pane: String(item?.pane || 'mesa-market-group-pane'),
|
|
|
|
| 1572 |
setGroupSelection({
|
| 1573 |
title: String(item?.group_title || `${groupItems.length} dados neste local`),
|
| 1574 |
count: Number(item?.count) || groupItems.length,
|
| 1575 |
+
hideCount: isTrueFlag(item?.hide_group_count),
|
| 1576 |
items: groupItems,
|
| 1577 |
})
|
| 1578 |
})
|
|
|
|
| 1809 |
if (specId) {
|
| 1810 |
overlayGroupsById.set(specId, layerGroup)
|
| 1811 |
}
|
| 1812 |
+
if (!isFalseFlag(spec?.show)) {
|
| 1813 |
layerGroup.addTo(map)
|
| 1814 |
}
|
| 1815 |
if (spec?.geojson_url || spec?.geojson_data) {
|
|
|
|
| 1835 |
id: specId,
|
| 1836 |
groupKey: hoverGroup,
|
| 1837 |
layerGroup,
|
|
|
|
| 1838 |
})
|
| 1839 |
}
|
| 1840 |
})
|
|
|
|
| 1844 |
map.on('overlayremove', refreshDependencyAwareOverlays)
|
| 1845 |
}
|
| 1846 |
|
| 1847 |
+
function syncInitialOverlayVisibility(layerControl = null) {
|
| 1848 |
+
overlaySpecs.forEach((spec) => {
|
| 1849 |
+
const label = String(spec?.label || spec?.id || '').trim()
|
| 1850 |
+
if (!label) return
|
| 1851 |
+
const layerGroup = overlayLayers[label]
|
| 1852 |
+
if (!layerGroup) return
|
| 1853 |
+
if (isFalseFlag(spec?.show)) {
|
| 1854 |
+
map.removeLayer(layerGroup)
|
| 1855 |
+
}
|
| 1856 |
+
})
|
| 1857 |
+
|
| 1858 |
+
const container = layerControl?.getContainer?.()
|
| 1859 |
+
if (!container) return
|
| 1860 |
+
const inputs = Array.from(container.querySelectorAll('.leaflet-control-layers-overlays input[type="checkbox"]'))
|
| 1861 |
+
inputs.forEach((input) => {
|
| 1862 |
+
const layerId = String(input?.layerId || '')
|
| 1863 |
+
const matchingLabel = Object.entries(overlayLayers).find(([, layer]) => String(L.Util.stamp(layer)) === layerId)?.[0]
|
| 1864 |
+
if (!matchingLabel) return
|
| 1865 |
+
const spec = overlaySpecs.find((item) => String(item?.label || item?.id || '').trim() === matchingLabel)
|
| 1866 |
+
if (isFalseFlag(spec?.show)) {
|
| 1867 |
+
input.checked = false
|
| 1868 |
+
}
|
| 1869 |
+
})
|
| 1870 |
+
}
|
| 1871 |
+
|
| 1872 |
+
function addLayerControlCloseButton(layerControl) {
|
| 1873 |
+
const container = layerControl?.getContainer?.()
|
| 1874 |
+
if (!container || container.querySelector('.mesa-layer-control-close')) return
|
| 1875 |
+
const button = L.DomUtil.create('button', 'mesa-layer-control-close', container)
|
| 1876 |
+
button.type = 'button'
|
| 1877 |
+
button.setAttribute('aria-label', 'Fechar camadas')
|
| 1878 |
+
button.title = 'Fechar camadas'
|
| 1879 |
+
button.textContent = 'Fechar'
|
| 1880 |
+
L.DomEvent.disableClickPropagation(button)
|
| 1881 |
+
L.DomEvent.on(button, 'click', (event) => {
|
| 1882 |
+
L.DomEvent.stop(event)
|
| 1883 |
+
setLayerControlOpen(layerControl, false)
|
| 1884 |
+
})
|
| 1885 |
+
}
|
| 1886 |
+
|
| 1887 |
+
function updateLayerControlToggleState(layerControl) {
|
| 1888 |
+
const container = layerControl?.getContainer?.()
|
| 1889 |
+
const toggle = layerControl?._layersLink || container?.querySelector?.('.leaflet-control-layers-toggle')
|
| 1890 |
+
if (!container || !toggle) return
|
| 1891 |
+
toggle.setAttribute('aria-expanded', container.classList.contains('leaflet-control-layers-expanded') ? 'true' : 'false')
|
| 1892 |
+
}
|
| 1893 |
+
|
| 1894 |
+
function setLayerControlOpen(layerControl, open) {
|
| 1895 |
+
const container = layerControl?.getContainer?.()
|
| 1896 |
+
if (!container) return
|
| 1897 |
+
if (open && typeof layerControl.expand === 'function') {
|
| 1898 |
+
layerControl.expand()
|
| 1899 |
+
} else if (!open && typeof layerControl.collapse === 'function') {
|
| 1900 |
+
layerControl.collapse()
|
| 1901 |
+
} else {
|
| 1902 |
+
container.classList.toggle('leaflet-control-layers-expanded', !!open)
|
| 1903 |
+
}
|
| 1904 |
+
updateLayerControlToggleState(layerControl)
|
| 1905 |
+
}
|
| 1906 |
+
|
| 1907 |
+
function enableLayerControlClickToggle(layerControl) {
|
| 1908 |
+
const container = layerControl?.getContainer?.()
|
| 1909 |
+
if (!container) return
|
| 1910 |
+
container.classList.add('mesa-layer-control-click-toggle')
|
| 1911 |
+
if (layerControl?._map && typeof layerControl.collapse === 'function') {
|
| 1912 |
+
layerControl._map.off('click', layerControl.collapse, layerControl)
|
| 1913 |
+
}
|
| 1914 |
+
if (typeof layerControl?._expandSafely === 'function') {
|
| 1915 |
+
L.DomEvent.off(container, 'mouseenter', layerControl._expandSafely, layerControl)
|
| 1916 |
+
}
|
| 1917 |
+
if (typeof layerControl?.collapse === 'function') {
|
| 1918 |
+
L.DomEvent.off(container, 'mouseleave', layerControl.collapse, layerControl)
|
| 1919 |
+
}
|
| 1920 |
+
|
| 1921 |
+
const currentToggle = layerControl?._layersLink || container.querySelector('.leaflet-control-layers-toggle')
|
| 1922 |
+
if (!currentToggle || currentToggle.dataset.mesaClickToggle === '1') {
|
| 1923 |
+
updateLayerControlToggleState(layerControl)
|
| 1924 |
+
return
|
| 1925 |
+
}
|
| 1926 |
+
|
| 1927 |
+
const toggle = currentToggle.cloneNode(true)
|
| 1928 |
+
toggle.dataset.mesaClickToggle = '1'
|
| 1929 |
+
toggle.href = '#'
|
| 1930 |
+
toggle.title = 'Camadas'
|
| 1931 |
+
toggle.setAttribute('role', 'button')
|
| 1932 |
+
toggle.setAttribute('aria-label', 'Abrir ou fechar camadas')
|
| 1933 |
+
currentToggle.parentNode.replaceChild(toggle, currentToggle)
|
| 1934 |
+
layerControl._layersLink = toggle
|
| 1935 |
+
|
| 1936 |
+
const togglePanel = (event) => {
|
| 1937 |
+
L.DomEvent.stop(event)
|
| 1938 |
+
const expanded = container.classList.contains('leaflet-control-layers-expanded')
|
| 1939 |
+
setLayerControlOpen(layerControl, !expanded)
|
| 1940 |
+
}
|
| 1941 |
+
|
| 1942 |
+
L.DomEvent.disableClickPropagation(toggle)
|
| 1943 |
+
L.DomEvent.on(toggle, 'click', togglePanel)
|
| 1944 |
+
L.DomEvent.on(toggle, 'keydown', (event) => {
|
| 1945 |
+
if (event.key !== 'Enter' && event.key !== ' ' && event.keyCode !== 13 && event.keyCode !== 32) return
|
| 1946 |
+
togglePanel(event)
|
| 1947 |
+
})
|
| 1948 |
+
updateLayerControlToggleState(layerControl)
|
| 1949 |
+
}
|
| 1950 |
+
|
| 1951 |
+
syncInitialOverlayVisibility()
|
| 1952 |
+
|
| 1953 |
if (payload.controls?.layer_control) {
|
| 1954 |
+
const collapsed = !isFalseFlag(payload.controls?.layer_control_collapsed)
|
| 1955 |
const layerControl = L.control.layers(baseLayers, overlayLayers, { collapsed }).addTo(map)
|
| 1956 |
+
enableLayerControlClickToggle(layerControl)
|
| 1957 |
+
addLayerControlCloseButton(layerControl)
|
| 1958 |
+
syncInitialOverlayVisibility(layerControl)
|
|
|
|
|
|
|
| 1959 |
}
|
| 1960 |
if (payload.controls?.measure) {
|
| 1961 |
const disableInteractionsForMeasure = () => {
|
|
|
|
| 2218 |
measureControl.addTo(map)
|
| 2219 |
}
|
| 2220 |
|
| 2221 |
+
if (payload.controls?.market_lasso) {
|
| 2222 |
+
const lassoLayerGroup = L.layerGroup().addTo(map)
|
| 2223 |
+
const lassoState = {
|
| 2224 |
+
panelOpen: false,
|
| 2225 |
+
active: false,
|
| 2226 |
+
drawing: false,
|
| 2227 |
+
points: [],
|
| 2228 |
+
selectedModels: [],
|
| 2229 |
+
error: '',
|
| 2230 |
+
downloading: false,
|
| 2231 |
+
}
|
| 2232 |
+
|
| 2233 |
+
let lassoRoot = null
|
| 2234 |
+
let lassoInteraction = null
|
| 2235 |
+
let lassoHint = null
|
| 2236 |
+
let lassoResults = null
|
| 2237 |
+
let lassoStartButton = null
|
| 2238 |
+
let lassoCancelButton = null
|
| 2239 |
+
let lassoDownloadButton = null
|
| 2240 |
+
let lassoCloseButton = null
|
| 2241 |
+
|
| 2242 |
+
function disableInteractionsForLasso() {
|
| 2243 |
+
if (restoreMapInteractions) return
|
| 2244 |
+
const states = {
|
| 2245 |
+
dragging: !!map.dragging?.enabled?.(),
|
| 2246 |
+
doubleClickZoom: !!map.doubleClickZoom?.enabled?.(),
|
| 2247 |
+
boxZoom: !!map.boxZoom?.enabled?.(),
|
| 2248 |
+
keyboard: !!map.keyboard?.enabled?.(),
|
| 2249 |
+
touchZoom: !!map.touchZoom?.enabled?.(),
|
| 2250 |
+
scrollWheelZoom: !!map.scrollWheelZoom?.enabled?.(),
|
| 2251 |
+
}
|
| 2252 |
+
map.dragging?.disable?.()
|
| 2253 |
+
map.doubleClickZoom?.disable?.()
|
| 2254 |
+
map.boxZoom?.disable?.()
|
| 2255 |
+
map.keyboard?.disable?.()
|
| 2256 |
+
map.touchZoom?.disable?.()
|
| 2257 |
+
map.scrollWheelZoom?.disable?.()
|
| 2258 |
+
if (map.getContainer()) {
|
| 2259 |
+
map.getContainer().style.cursor = 'crosshair'
|
| 2260 |
+
}
|
| 2261 |
+
restoreMapInteractions = () => {
|
| 2262 |
+
if (states.dragging) map.dragging?.enable?.()
|
| 2263 |
+
if (states.doubleClickZoom) map.doubleClickZoom?.enable?.()
|
| 2264 |
+
if (states.boxZoom) map.boxZoom?.enable?.()
|
| 2265 |
+
if (states.keyboard) map.keyboard?.enable?.()
|
| 2266 |
+
if (states.touchZoom) map.touchZoom?.enable?.()
|
| 2267 |
+
if (states.scrollWheelZoom) map.scrollWheelZoom?.enable?.()
|
| 2268 |
+
if (map.getContainer()) {
|
| 2269 |
+
map.getContainer().style.cursor = ''
|
| 2270 |
+
}
|
| 2271 |
+
restoreMapInteractions = null
|
| 2272 |
+
}
|
| 2273 |
+
}
|
| 2274 |
+
|
| 2275 |
+
function renderLassoGeometry() {
|
| 2276 |
+
lassoLayerGroup.clearLayers()
|
| 2277 |
+
if (!lassoState.points.length) return
|
| 2278 |
+
if (lassoState.points.length >= 2) {
|
| 2279 |
+
L.polyline(lassoState.points, {
|
| 2280 |
+
color: '#0f766e',
|
| 2281 |
+
weight: 2.6,
|
| 2282 |
+
opacity: 0.95,
|
| 2283 |
+
dashArray: lassoState.drawing ? '7 5' : undefined,
|
| 2284 |
+
}).addTo(lassoLayerGroup)
|
| 2285 |
+
}
|
| 2286 |
+
if (!lassoState.drawing && lassoState.points.length >= 3) {
|
| 2287 |
+
L.polygon(lassoState.points, {
|
| 2288 |
+
color: '#0f766e',
|
| 2289 |
+
weight: 2,
|
| 2290 |
+
opacity: 0.95,
|
| 2291 |
+
fillColor: '#0f766e',
|
| 2292 |
+
fillOpacity: 0.12,
|
| 2293 |
+
}).addTo(lassoLayerGroup)
|
| 2294 |
+
}
|
| 2295 |
+
}
|
| 2296 |
+
|
| 2297 |
+
function getSelectedMarketModels(points) {
|
| 2298 |
+
if (!Array.isArray(points) || points.length < 3) return []
|
| 2299 |
+
const polygonPoints = points.map((latlng) => map.latLngToLayerPoint(latlng))
|
| 2300 |
+
const selectedByModel = new Map()
|
| 2301 |
+
|
| 2302 |
+
overlaySpecs.forEach((spec) => {
|
| 2303 |
+
const specId = String(spec?.id || '').trim()
|
| 2304 |
+
const modelLayer = specId ? overlayGroupsById.get(specId) : null
|
| 2305 |
+
if (!Array.isArray(spec?.points) || !spec.points.length) return
|
| 2306 |
+
if (modelLayer && !map.hasLayer(modelLayer)) return
|
| 2307 |
+
|
| 2308 |
+
spec.points.forEach((item) => {
|
| 2309 |
+
const latlng = parseLatLonPair(item?.lat, item?.lon)
|
| 2310 |
+
if (!latlng) return
|
| 2311 |
+
const layerPoint = map.latLngToLayerPoint(L.latLng(latlng[0], latlng[1]))
|
| 2312 |
+
if (!pointInPolygon(layerPoint, polygonPoints)) return
|
| 2313 |
+
|
| 2314 |
+
const modelKey = String(item?.selection_model_id || specId || spec?.label || 'modelo')
|
| 2315 |
+
const modelName = String(item?.selection_model_name || spec?.label || modelKey)
|
| 2316 |
+
const indices = Array.isArray(item?.selection_indices) && item.selection_indices.length
|
| 2317 |
+
? item.selection_indices
|
| 2318 |
+
: [item?.indice]
|
| 2319 |
+
const entry = selectedByModel.get(modelKey) || {
|
| 2320 |
+
nome: modelName,
|
| 2321 |
+
indices: [],
|
| 2322 |
+
seen: new Set(),
|
| 2323 |
+
}
|
| 2324 |
+
indices.forEach((indice) => {
|
| 2325 |
+
const text = String(indice ?? '').trim()
|
| 2326 |
+
if (!text || entry.seen.has(text)) return
|
| 2327 |
+
entry.seen.add(text)
|
| 2328 |
+
entry.indices.push(text)
|
| 2329 |
+
})
|
| 2330 |
+
selectedByModel.set(modelKey, entry)
|
| 2331 |
+
})
|
| 2332 |
+
})
|
| 2333 |
+
|
| 2334 |
+
return Array.from(selectedByModel.values())
|
| 2335 |
+
.map((entry) => ({
|
| 2336 |
+
nome: entry.nome,
|
| 2337 |
+
indices: entry.indices.sort(naturalCompare),
|
| 2338 |
+
}))
|
| 2339 |
+
.filter((entry) => entry.indices.length)
|
| 2340 |
+
}
|
| 2341 |
+
|
| 2342 |
+
function selectedTotal(models) {
|
| 2343 |
+
return (models || []).reduce((total, item) => total + (item.indices?.length || 0), 0)
|
| 2344 |
+
}
|
| 2345 |
+
|
| 2346 |
+
function renderLassoPanel() {
|
| 2347 |
+
if (!lassoRoot || !lassoInteraction || !lassoHint || !lassoResults) return
|
| 2348 |
+
lassoRoot.classList.toggle('leaflet-control-measure-expanded', lassoState.panelOpen)
|
| 2349 |
+
lassoRoot.classList.toggle('is-active', lassoState.active)
|
| 2350 |
+
lassoInteraction.style.display = lassoState.panelOpen ? 'block' : 'none'
|
| 2351 |
+
if (!lassoState.panelOpen) return
|
| 2352 |
+
|
| 2353 |
+
const total = selectedTotal(lassoState.selectedModels)
|
| 2354 |
+
const hasSelection = total > 0
|
| 2355 |
+
const hasAttempt = lassoState.points.length > 0 || !!lassoState.error
|
| 2356 |
+
|
| 2357 |
+
if (lassoState.error) {
|
| 2358 |
+
lassoHint.textContent = lassoState.error
|
| 2359 |
+
} else if (lassoState.active) {
|
| 2360 |
+
lassoHint.textContent = 'Arraste no mapa para contornar os dados. Solte para concluir.'
|
| 2361 |
+
} else if (lassoState.selectedModels.length) {
|
| 2362 |
+
lassoHint.textContent = `${total.toLocaleString('pt-BR')} índice(s) selecionado(s).`
|
| 2363 |
+
} else {
|
| 2364 |
+
lassoHint.textContent = 'Crie um laço sobre os dados de mercado.'
|
| 2365 |
+
}
|
| 2366 |
+
|
| 2367 |
+
lassoResults.innerHTML = lassoState.selectedModels.length
|
| 2368 |
+
? lassoState.selectedModels.map((item) => (
|
| 2369 |
+
'<div class="mesa-leaflet-measure-row">'
|
| 2370 |
+
+ `<strong>${escapeHtml(item.nome)}</strong>`
|
| 2371 |
+
+ `<span>${escapeHtml(String(item.indices.length))} índice(s)</span>`
|
| 2372 |
+
+ '</div>'
|
| 2373 |
+
)).join('')
|
| 2374 |
+
: ''
|
| 2375 |
+
lassoResults.style.display = lassoState.selectedModels.length ? 'block' : 'none'
|
| 2376 |
+
lassoStartButton.style.display = lassoState.active ? 'none' : 'inline-flex'
|
| 2377 |
+
lassoStartButton.textContent = hasSelection ? 'Novo laço' : (hasAttempt ? 'Tentar novamente' : 'Criar laço')
|
| 2378 |
+
lassoStartButton.disabled = lassoState.downloading
|
| 2379 |
+
lassoStartButton.style.order = hasSelection ? '2' : '1'
|
| 2380 |
+
lassoCancelButton.style.display = lassoState.active ? 'inline-flex' : 'none'
|
| 2381 |
+
lassoCancelButton.textContent = 'Cancelar'
|
| 2382 |
+
lassoCancelButton.disabled = lassoState.downloading
|
| 2383 |
+
lassoCancelButton.style.order = '1'
|
| 2384 |
+
lassoDownloadButton.style.display = hasSelection && !lassoState.active ? 'inline-flex' : 'none'
|
| 2385 |
+
lassoDownloadButton.disabled = lassoState.downloading
|
| 2386 |
+
lassoDownloadButton.textContent = lassoState.downloading ? 'Baixando...' : 'Baixar XLSX'
|
| 2387 |
+
lassoDownloadButton.style.order = '1'
|
| 2388 |
+
lassoCloseButton.style.display = !lassoState.active ? 'inline-flex' : 'none'
|
| 2389 |
+
lassoCloseButton.disabled = lassoState.downloading
|
| 2390 |
+
lassoCloseButton.style.order = '3'
|
| 2391 |
+
}
|
| 2392 |
+
|
| 2393 |
+
function resetLassoState({ closePanel = false } = {}) {
|
| 2394 |
+
lassoState.active = false
|
| 2395 |
+
lassoState.drawing = false
|
| 2396 |
+
lassoState.points = []
|
| 2397 |
+
lassoState.selectedModels = []
|
| 2398 |
+
lassoState.error = ''
|
| 2399 |
+
lassoLayerGroup.clearLayers()
|
| 2400 |
+
restoreMapInteractions?.()
|
| 2401 |
+
if (closePanel) {
|
| 2402 |
+
lassoState.panelOpen = false
|
| 2403 |
+
}
|
| 2404 |
+
renderLassoPanel()
|
| 2405 |
+
}
|
| 2406 |
+
|
| 2407 |
+
function startLasso() {
|
| 2408 |
+
lassoState.panelOpen = true
|
| 2409 |
+
lassoState.active = true
|
| 2410 |
+
lassoState.drawing = false
|
| 2411 |
+
lassoState.points = []
|
| 2412 |
+
lassoState.selectedModels = []
|
| 2413 |
+
lassoState.error = ''
|
| 2414 |
+
lassoLayerGroup.clearLayers()
|
| 2415 |
+
disableInteractionsForLasso()
|
| 2416 |
+
renderLassoPanel()
|
| 2417 |
+
}
|
| 2418 |
+
|
| 2419 |
+
function finishLasso() {
|
| 2420 |
+
if (!lassoState.active) return
|
| 2421 |
+
lassoState.active = false
|
| 2422 |
+
lassoState.drawing = false
|
| 2423 |
+
restoreMapInteractions?.()
|
| 2424 |
+
if (lassoState.points.length < 3) {
|
| 2425 |
+
lassoState.error = 'Desenhe uma área maior para selecionar.'
|
| 2426 |
+
lassoState.selectedModels = []
|
| 2427 |
+
renderLassoGeometry()
|
| 2428 |
+
renderLassoPanel()
|
| 2429 |
+
return
|
| 2430 |
+
}
|
| 2431 |
+
lassoState.selectedModels = getSelectedMarketModels(lassoState.points)
|
| 2432 |
+
if (!lassoState.selectedModels.length) {
|
| 2433 |
+
lassoState.error = 'Nenhum dado de mercado selecionado.'
|
| 2434 |
+
}
|
| 2435 |
+
renderLassoGeometry()
|
| 2436 |
+
renderLassoPanel()
|
| 2437 |
+
}
|
| 2438 |
+
|
| 2439 |
+
function onLassoMouseDown(event) {
|
| 2440 |
+
if (!lassoState.active || !event?.latlng) return
|
| 2441 |
+
L.DomEvent.stop(event)
|
| 2442 |
+
lassoState.drawing = true
|
| 2443 |
+
lassoState.points = [event.latlng]
|
| 2444 |
+
lassoState.selectedModels = []
|
| 2445 |
+
lassoState.error = ''
|
| 2446 |
+
renderLassoGeometry()
|
| 2447 |
+
renderLassoPanel()
|
| 2448 |
+
}
|
| 2449 |
+
|
| 2450 |
+
function onLassoMouseMove(event) {
|
| 2451 |
+
if (!lassoState.active || !lassoState.drawing || !event?.latlng) return
|
| 2452 |
+
const lastPoint = lassoState.points[lassoState.points.length - 1]
|
| 2453 |
+
if (lastPoint && map.distance(lastPoint, event.latlng) < 2) return
|
| 2454 |
+
lassoState.points = [...lassoState.points, event.latlng]
|
| 2455 |
+
renderLassoGeometry()
|
| 2456 |
+
}
|
| 2457 |
+
|
| 2458 |
+
function onLassoMouseUp(event) {
|
| 2459 |
+
if (!lassoState.active || !lassoState.drawing) return
|
| 2460 |
+
L.DomEvent.stop(event)
|
| 2461 |
+
finishLasso()
|
| 2462 |
+
}
|
| 2463 |
+
|
| 2464 |
+
async function downloadLassoSelection() {
|
| 2465 |
+
if (!lassoState.selectedModels.length || lassoState.downloading) return
|
| 2466 |
+
lassoState.downloading = true
|
| 2467 |
+
lassoState.error = ''
|
| 2468 |
+
renderLassoPanel()
|
| 2469 |
+
try {
|
| 2470 |
+
const response = await fetch(apiUrl('/api/pesquisa/mapa-indices-selecionados/export'), {
|
| 2471 |
+
method: 'POST',
|
| 2472 |
+
headers: {
|
| 2473 |
+
'Content-Type': 'application/json',
|
| 2474 |
+
...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}),
|
| 2475 |
+
},
|
| 2476 |
+
body: JSON.stringify({ modelos: lassoState.selectedModels }),
|
| 2477 |
+
})
|
| 2478 |
+
if (!response.ok) {
|
| 2479 |
+
const payloadResp = await readJsonSafely(response)
|
| 2480 |
+
throw new Error(String(payloadResp?.detail || 'Falha ao exportar seleção.'))
|
| 2481 |
+
}
|
| 2482 |
+
const blob = await response.blob()
|
| 2483 |
+
downloadBlob(blob, 'indices_mapa_pesquisa.xlsx')
|
| 2484 |
+
} catch (error) {
|
| 2485 |
+
lassoState.error = error?.message || 'Falha ao exportar seleção.'
|
| 2486 |
+
} finally {
|
| 2487 |
+
lassoState.downloading = false
|
| 2488 |
+
renderLassoPanel()
|
| 2489 |
+
}
|
| 2490 |
+
}
|
| 2491 |
+
|
| 2492 |
+
map.on('mousedown', onLassoMouseDown)
|
| 2493 |
+
map.on('mousemove', onLassoMouseMove)
|
| 2494 |
+
map.on('mouseup', onLassoMouseUp)
|
| 2495 |
+
cleanupLasso = () => {
|
| 2496 |
+
map.off('mousedown', onLassoMouseDown)
|
| 2497 |
+
map.off('mousemove', onLassoMouseMove)
|
| 2498 |
+
map.off('mouseup', onLassoMouseUp)
|
| 2499 |
+
lassoLayerGroup.clearLayers()
|
| 2500 |
+
}
|
| 2501 |
+
|
| 2502 |
+
const lassoControl = L.control({ position: 'topright' })
|
| 2503 |
+
lassoControl.onAdd = () => {
|
| 2504 |
+
lassoRoot = L.DomUtil.create('div', 'leaflet-control-measure mesa-leaflet-lasso leaflet-bar leaflet-control')
|
| 2505 |
+
lassoRoot.setAttribute('aria-haspopup', 'true')
|
| 2506 |
+
|
| 2507 |
+
const toggle = L.DomUtil.create('a', 'leaflet-control-measure-toggle mesa-leaflet-lasso-toggle', lassoRoot)
|
| 2508 |
+
toggle.href = '#'
|
| 2509 |
+
toggle.title = 'Selecionar dados por laço'
|
| 2510 |
+
toggle.setAttribute('aria-label', 'Selecionar dados por laço')
|
| 2511 |
+
toggle.innerHTML = '<span class="mesa-sr-only">Selecionar dados por laço</span>'
|
| 2512 |
+
|
| 2513 |
+
lassoInteraction = L.DomUtil.create('div', 'leaflet-control-measure-interaction mesa-leaflet-measure-interaction', lassoRoot)
|
| 2514 |
+
lassoInteraction.style.display = 'none'
|
| 2515 |
+
|
| 2516 |
+
const title = L.DomUtil.create('h3', 'mesa-leaflet-measure-title', lassoInteraction)
|
| 2517 |
+
title.textContent = 'Selecionar por laço'
|
| 2518 |
+
|
| 2519 |
+
lassoHint = L.DomUtil.create('p', 'mesa-leaflet-measure-hint', lassoInteraction)
|
| 2520 |
+
lassoResults = L.DomUtil.create('div', 'mesa-leaflet-measure-results', lassoInteraction)
|
| 2521 |
+
|
| 2522 |
+
const actions = L.DomUtil.create('div', 'mesa-leaflet-measure-actions', lassoInteraction)
|
| 2523 |
+
lassoStartButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions)
|
| 2524 |
+
lassoStartButton.type = 'button'
|
| 2525 |
+
lassoStartButton.textContent = 'Criar laço'
|
| 2526 |
+
|
| 2527 |
+
lassoCancelButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions)
|
| 2528 |
+
lassoCancelButton.type = 'button'
|
| 2529 |
+
lassoCancelButton.textContent = 'Cancelar'
|
| 2530 |
+
|
| 2531 |
+
lassoDownloadButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions)
|
| 2532 |
+
lassoDownloadButton.type = 'button'
|
| 2533 |
+
lassoDownloadButton.textContent = 'Baixar XLSX'
|
| 2534 |
+
|
| 2535 |
+
lassoCloseButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions)
|
| 2536 |
+
lassoCloseButton.type = 'button'
|
| 2537 |
+
lassoCloseButton.textContent = 'Fechar'
|
| 2538 |
+
|
| 2539 |
+
L.DomEvent.disableClickPropagation(lassoRoot)
|
| 2540 |
+
L.DomEvent.disableScrollPropagation(lassoRoot)
|
| 2541 |
+
L.DomEvent.on(toggle, 'click', (event) => {
|
| 2542 |
+
L.DomEvent.stop(event)
|
| 2543 |
+
if (lassoState.active) return
|
| 2544 |
+
lassoState.panelOpen = !lassoState.panelOpen
|
| 2545 |
+
renderLassoPanel()
|
| 2546 |
+
})
|
| 2547 |
+
L.DomEvent.on(lassoStartButton, 'click', (event) => {
|
| 2548 |
+
L.DomEvent.stop(event)
|
| 2549 |
+
startLasso()
|
| 2550 |
+
})
|
| 2551 |
+
L.DomEvent.on(lassoCancelButton, 'click', (event) => {
|
| 2552 |
+
L.DomEvent.stop(event)
|
| 2553 |
+
resetLassoState()
|
| 2554 |
+
})
|
| 2555 |
+
L.DomEvent.on(lassoDownloadButton, 'click', (event) => {
|
| 2556 |
+
L.DomEvent.stop(event)
|
| 2557 |
+
void downloadLassoSelection()
|
| 2558 |
+
})
|
| 2559 |
+
L.DomEvent.on(lassoCloseButton, 'click', (event) => {
|
| 2560 |
+
L.DomEvent.stop(event)
|
| 2561 |
+
if (lassoState.active) return
|
| 2562 |
+
lassoState.panelOpen = false
|
| 2563 |
+
renderLassoPanel()
|
| 2564 |
+
})
|
| 2565 |
+
|
| 2566 |
+
renderLassoPanel()
|
| 2567 |
+
return lassoRoot
|
| 2568 |
+
}
|
| 2569 |
+
lassoControl.addTo(map)
|
| 2570 |
+
}
|
| 2571 |
+
|
| 2572 |
if (payload.controls?.fullscreen) {
|
| 2573 |
addFullscreenControl(map, onToggleFullscreen)
|
| 2574 |
}
|
|
|
|
| 2608 |
}
|
| 2609 |
disposed = true
|
| 2610 |
restoreMapInteractions?.()
|
| 2611 |
+
cleanupLasso?.()
|
| 2612 |
if (popupLoaderRef.current === carregarPopupRegistro) {
|
| 2613 |
popupLoaderRef.current = null
|
| 2614 |
}
|
|
|
|
| 2636 |
<div className="leaflet-market-group-modal-head">
|
| 2637 |
<div>
|
| 2638 |
<strong>{groupSelection.title}</strong>
|
| 2639 |
+
{!groupSelection.hideCount ? (
|
| 2640 |
+
<span>{Number(groupSelection.count) || groupSelection.items?.length || 0} registros sobrepostos</span>
|
| 2641 |
+
) : null}
|
| 2642 |
</div>
|
| 2643 |
<button type="button" onClick={closeGroupSelection} aria-label="Fechar">×</button>
|
| 2644 |
</div>
|
frontend/src/components/PesquisaTab.jsx
CHANGED
|
@@ -1033,7 +1033,6 @@ export default function PesquisaTab({
|
|
| 1033 |
const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
|
| 1034 |
const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
|
| 1035 |
const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
|
| 1036 |
-
const [mapaAgruparPontosMercado, setMapaAgruparPontosMercado] = useState(false)
|
| 1037 |
const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
|
| 1038 |
const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
|
| 1039 |
const [mapaTrabalhosTecnicosRaio, setMapaTrabalhosTecnicosRaio] = useState(1000)
|
|
@@ -1163,7 +1162,6 @@ export default function PesquisaTab({
|
|
| 1163 |
setMapaStatus('')
|
| 1164 |
setMapaError('')
|
| 1165 |
setMapaModoExibicao('pontos')
|
| 1166 |
-
setMapaAgruparPontosMercado(false)
|
| 1167 |
mapaTrabalhosTecnicosConfigRef.current = ''
|
| 1168 |
}
|
| 1169 |
|
|
@@ -1194,7 +1192,6 @@ export default function PesquisaTab({
|
|
| 1194 |
async function carregarMapaPesquisa(ids, overrides = {}) {
|
| 1195 |
const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
|
| 1196 |
const modoExibicaoSolicitado = String(overrides.modoExibicao || mapaModoExibicao || 'pontos')
|
| 1197 |
-
const agruparPontosMercadoSolicitado = Boolean(overrides.agruparPontosMercado ?? mapaAgruparPontosMercado)
|
| 1198 |
const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig(overrides)
|
| 1199 |
|
| 1200 |
if (!idsValidos.length) {
|
|
@@ -1217,7 +1214,7 @@ export default function PesquisaTab({
|
|
| 1217 |
trabalhosTecnicosConfig.modelosModo,
|
| 1218 |
trabalhosTecnicosConfig.proximidadeModo,
|
| 1219 |
trabalhosTecnicosConfig.raio,
|
| 1220 |
-
|
| 1221 |
)
|
| 1222 |
const mapaHtmlSolicitado = String(
|
| 1223 |
response.mapa_html
|
|
@@ -1785,18 +1782,6 @@ export default function PesquisaTab({
|
|
| 1785 |
void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
|
| 1786 |
}
|
| 1787 |
|
| 1788 |
-
function onMapaAgrupamentoPontosChange(event) {
|
| 1789 |
-
const nextAgrupar = String(event?.target?.value || 'individual') === 'agrupado'
|
| 1790 |
-
setMapaAgruparPontosMercado(nextAgrupar)
|
| 1791 |
-
setMapaHtmls((prev) => ({ ...prev, pontos: '' }))
|
| 1792 |
-
setMapaPayloads((prev) => ({ ...prev, pontos: null }))
|
| 1793 |
-
if (!mapaFoiGerado || mapaLoading || !selectedIds.length || mapaModoExibicao !== 'pontos') return
|
| 1794 |
-
void carregarMapaPesquisa(selectedIds, {
|
| 1795 |
-
modoExibicao: 'pontos',
|
| 1796 |
-
agruparPontosMercado: nextAgrupar,
|
| 1797 |
-
})
|
| 1798 |
-
}
|
| 1799 |
-
|
| 1800 |
async function onAdminConfigSalva() {
|
| 1801 |
if (pesquisaInicializada) {
|
| 1802 |
await buscarModelos(filters, avaliandosGeolocalizados)
|
|
@@ -2576,21 +2561,6 @@ export default function PesquisaTab({
|
|
| 2576 |
</select>
|
| 2577 |
</label>
|
| 2578 |
|
| 2579 |
-
{mapaModoExibicao === 'pontos' ? (
|
| 2580 |
-
<label className="pesquisa-field pesquisa-mapa-agrupamento-field">
|
| 2581 |
-
Pontos sobrepostos
|
| 2582 |
-
<select
|
| 2583 |
-
{...buildSelectAutofillProps('mapaAgrupamentoPontos')}
|
| 2584 |
-
value={mapaAgruparPontosMercado ? 'agrupado' : 'individual'}
|
| 2585 |
-
onChange={onMapaAgrupamentoPontosChange}
|
| 2586 |
-
disabled={mapaLoading || !selectedIds.length}
|
| 2587 |
-
>
|
| 2588 |
-
<option value="individual">Mostrar individualmente</option>
|
| 2589 |
-
<option value="agrupado">Agrupar por coordenada</option>
|
| 2590 |
-
</select>
|
| 2591 |
-
</label>
|
| 2592 |
-
) : null}
|
| 2593 |
-
|
| 2594 |
<label className="pesquisa-field pesquisa-mapa-trabalhos-field">
|
| 2595 |
Exibição dos trabalhos técnicos
|
| 2596 |
<select
|
|
@@ -2649,7 +2619,12 @@ export default function PesquisaTab({
|
|
| 2649 |
</SectionBlock>
|
| 2650 |
</div>
|
| 2651 |
|
| 2652 |
-
<LoadingOverlay
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2653 |
<LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
|
| 2654 |
</div>
|
| 2655 |
)
|
|
|
|
| 1033 |
const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
|
| 1034 |
const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
|
| 1035 |
const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
|
|
|
|
| 1036 |
const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
|
| 1037 |
const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
|
| 1038 |
const [mapaTrabalhosTecnicosRaio, setMapaTrabalhosTecnicosRaio] = useState(1000)
|
|
|
|
| 1162 |
setMapaStatus('')
|
| 1163 |
setMapaError('')
|
| 1164 |
setMapaModoExibicao('pontos')
|
|
|
|
| 1165 |
mapaTrabalhosTecnicosConfigRef.current = ''
|
| 1166 |
}
|
| 1167 |
|
|
|
|
| 1192 |
async function carregarMapaPesquisa(ids, overrides = {}) {
|
| 1193 |
const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
|
| 1194 |
const modoExibicaoSolicitado = String(overrides.modoExibicao || mapaModoExibicao || 'pontos')
|
|
|
|
| 1195 |
const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig(overrides)
|
| 1196 |
|
| 1197 |
if (!idsValidos.length) {
|
|
|
|
| 1214 |
trabalhosTecnicosConfig.modelosModo,
|
| 1215 |
trabalhosTecnicosConfig.proximidadeModo,
|
| 1216 |
trabalhosTecnicosConfig.raio,
|
| 1217 |
+
true,
|
| 1218 |
)
|
| 1219 |
const mapaHtmlSolicitado = String(
|
| 1220 |
response.mapa_html
|
|
|
|
| 1782 |
void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
|
| 1783 |
}
|
| 1784 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1785 |
async function onAdminConfigSalva() {
|
| 1786 |
if (pesquisaInicializada) {
|
| 1787 |
await buscarModelos(filters, avaliandosGeolocalizados)
|
|
|
|
| 2561 |
</select>
|
| 2562 |
</label>
|
| 2563 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2564 |
<label className="pesquisa-field pesquisa-mapa-trabalhos-field">
|
| 2565 |
Exibição dos trabalhos técnicos
|
| 2566 |
<select
|
|
|
|
| 2619 |
</SectionBlock>
|
| 2620 |
</div>
|
| 2621 |
|
| 2622 |
+
<LoadingOverlay
|
| 2623 |
+
show={searchLoading || localizacaoLoading}
|
| 2624 |
+
label={localizacaoLoading
|
| 2625 |
+
? (localizacaoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
|
| 2626 |
+
: 'Pesquisando modelos...'}
|
| 2627 |
+
/>
|
| 2628 |
<LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
|
| 2629 |
</div>
|
| 2630 |
)
|
frontend/src/components/RepositorioTab.jsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
| 7 |
normalizeMapPointRadiusRange,
|
| 8 |
} from '../mapPointSizing'
|
| 9 |
import DataTable from './DataTable'
|
|
|
|
| 10 |
import EquationFormatsPanel from './EquationFormatsPanel'
|
| 11 |
import ListPagination from './ListPagination'
|
| 12 |
import LoadingOverlay from './LoadingOverlay'
|
|
@@ -211,6 +212,7 @@ export default function RepositorioTab({
|
|
| 211 |
const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
|
| 212 |
const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
|
| 213 |
const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
|
|
|
|
| 214 |
const lastOpenRequestKeyRef = useRef('')
|
| 215 |
const pendingTabRequestsRef = useRef({})
|
| 216 |
const modeloOpenVersionRef = useRef(0)
|
|
@@ -581,6 +583,7 @@ export default function RepositorioTab({
|
|
| 581 |
setModeloAbertoError('')
|
| 582 |
setModeloAbertoActiveTab('mapa')
|
| 583 |
setModeloAbertoLoading(false)
|
|
|
|
| 584 |
limparConteudoModeloAberto()
|
| 585 |
if (typeof onRouteChange === 'function') {
|
| 586 |
onRouteChange(nextIntent)
|
|
@@ -684,6 +687,14 @@ export default function RepositorioTab({
|
|
| 684 |
</div>
|
| 685 |
<div className="pesquisa-opened-model-actions">
|
| 686 |
<ShareLinkButton href={shareHref} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
{typeof onUsarModeloEmAvaliacao === 'function' ? (
|
| 688 |
<button
|
| 689 |
type="button"
|
|
@@ -879,6 +890,11 @@ export default function RepositorioTab({
|
|
| 879 |
{modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
|
| 880 |
</div>
|
| 881 |
<LoadingOverlay show={modeloAbertoLoading || activeTabLoading} label={modeloLoadingLabel} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
</div>
|
| 883 |
)
|
| 884 |
}
|
|
|
|
| 7 |
normalizeMapPointRadiusRange,
|
| 8 |
} from '../mapPointSizing'
|
| 9 |
import DataTable from './DataTable'
|
| 10 |
+
import AnexosModal from './AnexosModal'
|
| 11 |
import EquationFormatsPanel from './EquationFormatsPanel'
|
| 12 |
import ListPagination from './ListPagination'
|
| 13 |
import LoadingOverlay from './LoadingOverlay'
|
|
|
|
| 212 |
const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
|
| 213 |
const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
|
| 214 |
const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
|
| 215 |
+
const [anexosModalOpen, setAnexosModalOpen] = useState(false)
|
| 216 |
const lastOpenRequestKeyRef = useRef('')
|
| 217 |
const pendingTabRequestsRef = useRef({})
|
| 218 |
const modeloOpenVersionRef = useRef(0)
|
|
|
|
| 583 |
setModeloAbertoError('')
|
| 584 |
setModeloAbertoActiveTab('mapa')
|
| 585 |
setModeloAbertoLoading(false)
|
| 586 |
+
setAnexosModalOpen(false)
|
| 587 |
limparConteudoModeloAberto()
|
| 588 |
if (typeof onRouteChange === 'function') {
|
| 589 |
onRouteChange(nextIntent)
|
|
|
|
| 687 |
</div>
|
| 688 |
<div className="pesquisa-opened-model-actions">
|
| 689 |
<ShareLinkButton href={shareHref} />
|
| 690 |
+
<button
|
| 691 |
+
type="button"
|
| 692 |
+
className="pesquisa-opened-model-action-btn pesquisa-opened-model-action-btn-primary"
|
| 693 |
+
onClick={() => setAnexosModalOpen(true)}
|
| 694 |
+
disabled={modeloAbertoLoading}
|
| 695 |
+
>
|
| 696 |
+
Gerar anexos
|
| 697 |
+
</button>
|
| 698 |
{typeof onUsarModeloEmAvaliacao === 'function' ? (
|
| 699 |
<button
|
| 700 |
type="button"
|
|
|
|
| 890 |
{modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
|
| 891 |
</div>
|
| 892 |
<LoadingOverlay show={modeloAbertoLoading || activeTabLoading} label={modeloLoadingLabel} />
|
| 893 |
+
<AnexosModal
|
| 894 |
+
open={anexosModalOpen}
|
| 895 |
+
defaultModeloId={modeloAbertoMeta?.id || ''}
|
| 896 |
+
onClose={() => setAnexosModalOpen(false)}
|
| 897 |
+
/>
|
| 898 |
</div>
|
| 899 |
)
|
| 900 |
}
|
frontend/src/components/TrabalhosTecnicosTab.jsx
CHANGED
|
@@ -1443,10 +1443,12 @@ export default function TrabalhosTecnicosTab({
|
|
| 1443 |
) : null}
|
| 1444 |
</div>
|
| 1445 |
<LoadingOverlay
|
| 1446 |
-
show={trabalhoLoading || salvando}
|
| 1447 |
-
label={
|
|
|
|
|
|
|
| 1448 |
? (cadastrando ? 'Cadastrando trabalho técnico...' : 'Salvando trabalho técnico...')
|
| 1449 |
-
: 'Abrindo trabalho técnico...'}
|
| 1450 |
/>
|
| 1451 |
</div>
|
| 1452 |
)
|
|
@@ -1855,10 +1857,12 @@ export default function TrabalhosTecnicosTab({
|
|
| 1855 |
)}
|
| 1856 |
</div>
|
| 1857 |
<LoadingOverlay
|
| 1858 |
-
show={loading || mapaLoading || trabalhoLoading}
|
| 1859 |
-
label={
|
|
|
|
|
|
|
| 1860 |
? 'Abrindo trabalho técnico...'
|
| 1861 |
-
: (mapaLoading ? 'Gerando mapa dos trabalhos técnicos...' : 'Carregando trabalhos técnicos...')}
|
| 1862 |
/>
|
| 1863 |
</div>
|
| 1864 |
)
|
|
|
|
| 1443 |
) : null}
|
| 1444 |
</div>
|
| 1445 |
<LoadingOverlay
|
| 1446 |
+
show={trabalhoLoading || salvando || trabalhoGeoLoading}
|
| 1447 |
+
label={trabalhoGeoLoading
|
| 1448 |
+
? (trabalhoGeoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
|
| 1449 |
+
: (salvando
|
| 1450 |
? (cadastrando ? 'Cadastrando trabalho técnico...' : 'Salvando trabalho técnico...')
|
| 1451 |
+
: 'Abrindo trabalho técnico...')}
|
| 1452 |
/>
|
| 1453 |
</div>
|
| 1454 |
)
|
|
|
|
| 1857 |
)}
|
| 1858 |
</div>
|
| 1859 |
<LoadingOverlay
|
| 1860 |
+
show={loading || mapaLoading || trabalhoLoading || localizacaoLoading}
|
| 1861 |
+
label={localizacaoLoading
|
| 1862 |
+
? (localizacaoModo === 'endereco' ? 'Buscando endereço...' : 'Buscando localização...')
|
| 1863 |
+
: (trabalhoLoading
|
| 1864 |
? 'Abrindo trabalho técnico...'
|
| 1865 |
+
: (mapaLoading ? 'Gerando mapa dos trabalhos técnicos...' : 'Carregando trabalhos técnicos...'))}
|
| 1866 |
/>
|
| 1867 |
</div>
|
| 1868 |
)
|
frontend/src/styles.css
CHANGED
|
@@ -3754,6 +3754,175 @@ button.pesquisa-coluna-remove:hover {
|
|
| 3754 |
margin-top: 12px;
|
| 3755 |
}
|
| 3756 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3757 |
.pesquisa-card-values-modal {
|
| 3758 |
width: min(920px, calc(100vw - 40px));
|
| 3759 |
max-width: min(920px, calc(100vw - 40px));
|
|
@@ -4090,9 +4259,24 @@ button.pesquisa-coluna-remove:hover {
|
|
| 4090 |
.avaliacao-modelos-card-actions {
|
| 4091 |
display: inline-flex;
|
| 4092 |
align-items: center;
|
|
|
|
|
|
|
|
|
|
| 4093 |
flex: 0 0 auto;
|
| 4094 |
}
|
| 4095 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4096 |
.avaliacao-card-delete-confirm {
|
| 4097 |
display: inline-flex;
|
| 4098 |
align-items: center;
|
|
@@ -5013,38 +5197,6 @@ button.paste-excel-clear-btn {
|
|
| 5013 |
gap: 8px;
|
| 5014 |
}
|
| 5015 |
|
| 5016 |
-
.import-preview-first-columns-field {
|
| 5017 |
-
display: inline-flex;
|
| 5018 |
-
align-items: center;
|
| 5019 |
-
gap: 7px;
|
| 5020 |
-
color: #33485d;
|
| 5021 |
-
font-size: 0.86rem;
|
| 5022 |
-
font-weight: 700;
|
| 5023 |
-
}
|
| 5024 |
-
|
| 5025 |
-
.import-preview-first-columns-toggle {
|
| 5026 |
-
display: inline-flex;
|
| 5027 |
-
align-items: center;
|
| 5028 |
-
gap: 7px;
|
| 5029 |
-
min-height: 34px;
|
| 5030 |
-
border: 1px solid #c8d8e8;
|
| 5031 |
-
border-radius: 8px;
|
| 5032 |
-
background: #f6f9fc;
|
| 5033 |
-
color: #33485d;
|
| 5034 |
-
font-size: 0.86rem;
|
| 5035 |
-
font-weight: 800;
|
| 5036 |
-
padding: 6px 9px;
|
| 5037 |
-
}
|
| 5038 |
-
|
| 5039 |
-
.import-preview-first-columns-toggle input {
|
| 5040 |
-
width: auto;
|
| 5041 |
-
margin: 0;
|
| 5042 |
-
}
|
| 5043 |
-
|
| 5044 |
-
.import-preview-first-columns-field input {
|
| 5045 |
-
width: 78px;
|
| 5046 |
-
}
|
| 5047 |
-
|
| 5048 |
button.import-preview-apply-btn {
|
| 5049 |
--btn-bg-start: #ff8c00;
|
| 5050 |
--btn-bg-end: #e67900;
|
|
@@ -5952,10 +6104,6 @@ button.import-preview-clear-btn {
|
|
| 5952 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M12 2 3 7l9 5 9-5-9-5Zm-7.5 8.6L12 15l7.5-4.4L21 12l-9 5-9-5 1.5-1.4Zm0 4.8L12 19.8l7.5-4.4L21 17l-9 5-9-5 1.5-1.6Z'/%3E%3C/svg%3E");
|
| 5953 |
}
|
| 5954 |
|
| 5955 |
-
.leaflet-map-host .leaflet-control-layers-toggle:hover {
|
| 5956 |
-
background-color: #eef5fb;
|
| 5957 |
-
}
|
| 5958 |
-
|
| 5959 |
.leaflet-map-host .leaflet-control-layers-expanded {
|
| 5960 |
padding: 6px 9px;
|
| 5961 |
min-width: 220px;
|
|
@@ -5988,17 +6136,10 @@ button.import-preview-clear-btn {
|
|
| 5988 |
font-weight: 500;
|
| 5989 |
color: #27445f;
|
| 5990 |
cursor: pointer;
|
| 5991 |
-
transition: background-color 0.18s ease, color 0.18s ease;
|
| 5992 |
line-height: 1.12;
|
| 5993 |
white-space: normal;
|
| 5994 |
}
|
| 5995 |
|
| 5996 |
-
.leaflet-map-host .leaflet-control-layers label:hover {
|
| 5997 |
-
background: #f3f7fb;
|
| 5998 |
-
transform: none;
|
| 5999 |
-
color: #1f5f9f;
|
| 6000 |
-
}
|
| 6001 |
-
|
| 6002 |
.leaflet-map-host .leaflet-control-layers input {
|
| 6003 |
accent-color: #1f6fb2;
|
| 6004 |
width: 16px;
|
|
@@ -6033,6 +6174,29 @@ button.import-preview-clear-btn {
|
|
| 6033 |
border-top-color: rgba(207, 217, 227, 0.95);
|
| 6034 |
}
|
| 6035 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6036 |
.leaflet-map-host .mesa-leaflet-fullscreen,
|
| 6037 |
.leaflet-map-host .leaflet-control-measure {
|
| 6038 |
border: none;
|
|
@@ -6057,7 +6221,8 @@ button.import-preview-clear-btn {
|
|
| 6057 |
color: #24405b;
|
| 6058 |
}
|
| 6059 |
|
| 6060 |
-
.leaflet-map-host .mesa-leaflet-measure
|
|
|
|
| 6061 |
background: transparent;
|
| 6062 |
border: none;
|
| 6063 |
box-shadow: none;
|
|
@@ -6101,7 +6266,8 @@ button.import-preview-clear-btn {
|
|
| 6101 |
background: #eef5fb;
|
| 6102 |
}
|
| 6103 |
|
| 6104 |
-
.leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle
|
|
|
|
| 6105 |
width: 38px;
|
| 6106 |
height: 38px;
|
| 6107 |
position: relative;
|
|
@@ -6112,6 +6278,11 @@ button.import-preview-clear-btn {
|
|
| 6112 |
box-shadow: 0 10px 24px rgba(27, 48, 72, 0.16);
|
| 6113 |
}
|
| 6114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6115 |
.leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle::before {
|
| 6116 |
content: '';
|
| 6117 |
position: absolute;
|
|
@@ -6125,7 +6296,21 @@ button.import-preview-clear-btn {
|
|
| 6125 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25Zm2.92 2.33H5v-.92l8.12-8.12.92.92-8.12 8.12ZM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.33a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.83Z'/%3E%3C/svg%3E");
|
| 6126 |
}
|
| 6127 |
|
| 6128 |
-
.leaflet-map-host .mesa-leaflet-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6129 |
background: #eef5fb;
|
| 6130 |
}
|
| 6131 |
|
|
@@ -6202,6 +6387,15 @@ button.import-preview-clear-btn {
|
|
| 6202 |
background: #f3f7fb;
|
| 6203 |
}
|
| 6204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6205 |
.leaflet-map-host .mesa-leaflet-measure-btn.is-primary {
|
| 6206 |
background: #1f6fb2;
|
| 6207 |
border-color: #1f6fb2;
|
|
@@ -6212,6 +6406,10 @@ button.import-preview-clear-btn {
|
|
| 6212 |
background: #175a90;
|
| 6213 |
}
|
| 6214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6215 |
.table-wrapper {
|
| 6216 |
overflow: auto;
|
| 6217 |
border: 1px solid #d8e2ec;
|
|
@@ -8556,6 +8754,21 @@ button.btn-download-subtle {
|
|
| 8556 |
flex-direction: column;
|
| 8557 |
}
|
| 8558 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8559 |
.avaliacao-localizacao-map-modal-actions {
|
| 8560 |
width: 100%;
|
| 8561 |
justify-content: flex-end;
|
|
|
|
| 3754 |
margin-top: 12px;
|
| 3755 |
}
|
| 3756 |
|
| 3757 |
+
.anexos-modal {
|
| 3758 |
+
width: min(760px, 100%);
|
| 3759 |
+
}
|
| 3760 |
+
|
| 3761 |
+
.anexos-modal-body {
|
| 3762 |
+
gap: 14px;
|
| 3763 |
+
}
|
| 3764 |
+
|
| 3765 |
+
.anexos-modal-model-field {
|
| 3766 |
+
display: grid;
|
| 3767 |
+
gap: 6px;
|
| 3768 |
+
color: #334b61;
|
| 3769 |
+
font-size: 0.84rem;
|
| 3770 |
+
font-weight: 700;
|
| 3771 |
+
}
|
| 3772 |
+
|
| 3773 |
+
.anexos-modal-model-field select {
|
| 3774 |
+
width: 100%;
|
| 3775 |
+
min-width: 0;
|
| 3776 |
+
}
|
| 3777 |
+
|
| 3778 |
+
.anexos-columns-section {
|
| 3779 |
+
display: grid;
|
| 3780 |
+
gap: 8px;
|
| 3781 |
+
padding-top: 12px;
|
| 3782 |
+
border-top: 1px solid #dbe5ef;
|
| 3783 |
+
}
|
| 3784 |
+
|
| 3785 |
+
.anexos-columns-head {
|
| 3786 |
+
display: flex;
|
| 3787 |
+
align-items: center;
|
| 3788 |
+
justify-content: space-between;
|
| 3789 |
+
gap: 10px;
|
| 3790 |
+
}
|
| 3791 |
+
|
| 3792 |
+
.anexos-columns-head > div:first-child {
|
| 3793 |
+
display: flex;
|
| 3794 |
+
align-items: baseline;
|
| 3795 |
+
gap: 8px;
|
| 3796 |
+
min-width: 0;
|
| 3797 |
+
}
|
| 3798 |
+
|
| 3799 |
+
.anexos-columns-head strong {
|
| 3800 |
+
color: #334b61;
|
| 3801 |
+
font-size: 0.84rem;
|
| 3802 |
+
}
|
| 3803 |
+
|
| 3804 |
+
.anexos-columns-head span {
|
| 3805 |
+
color: #687d91;
|
| 3806 |
+
font-size: 0.75rem;
|
| 3807 |
+
}
|
| 3808 |
+
|
| 3809 |
+
.anexos-columns-actions,
|
| 3810 |
+
.anexos-reorder-actions {
|
| 3811 |
+
display: inline-flex;
|
| 3812 |
+
align-items: center;
|
| 3813 |
+
gap: 6px;
|
| 3814 |
+
}
|
| 3815 |
+
|
| 3816 |
+
.anexos-columns-actions button,
|
| 3817 |
+
.anexos-reorder-actions button {
|
| 3818 |
+
min-height: 28px;
|
| 3819 |
+
padding: 3px 8px;
|
| 3820 |
+
font-size: 0.75rem;
|
| 3821 |
+
}
|
| 3822 |
+
|
| 3823 |
+
.anexos-reorder-actions button {
|
| 3824 |
+
width: 30px;
|
| 3825 |
+
min-width: 30px;
|
| 3826 |
+
padding: 0;
|
| 3827 |
+
font-size: 0.95rem;
|
| 3828 |
+
}
|
| 3829 |
+
|
| 3830 |
+
.anexos-columns-list {
|
| 3831 |
+
display: grid;
|
| 3832 |
+
max-height: 310px;
|
| 3833 |
+
overflow: auto;
|
| 3834 |
+
border: 1px solid #d5e0ea;
|
| 3835 |
+
border-radius: 8px;
|
| 3836 |
+
background: #f7f9fb;
|
| 3837 |
+
}
|
| 3838 |
+
|
| 3839 |
+
.anexos-column-row {
|
| 3840 |
+
display: grid;
|
| 3841 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 3842 |
+
align-items: center;
|
| 3843 |
+
gap: 8px;
|
| 3844 |
+
min-height: 40px;
|
| 3845 |
+
padding: 4px 8px 4px 10px;
|
| 3846 |
+
border-bottom: 1px solid #e2e9f0;
|
| 3847 |
+
background: #fff;
|
| 3848 |
+
}
|
| 3849 |
+
|
| 3850 |
+
.anexos-column-row:last-child {
|
| 3851 |
+
border-bottom: 0;
|
| 3852 |
+
}
|
| 3853 |
+
|
| 3854 |
+
.anexos-column-row > label {
|
| 3855 |
+
display: grid;
|
| 3856 |
+
grid-template-columns: auto minmax(0, 1fr);
|
| 3857 |
+
align-items: center;
|
| 3858 |
+
gap: 9px;
|
| 3859 |
+
min-width: 0;
|
| 3860 |
+
color: #30485e;
|
| 3861 |
+
font-size: 0.82rem;
|
| 3862 |
+
font-weight: 600;
|
| 3863 |
+
}
|
| 3864 |
+
|
| 3865 |
+
.anexos-column-row input {
|
| 3866 |
+
width: 16px;
|
| 3867 |
+
height: 16px;
|
| 3868 |
+
margin: 0;
|
| 3869 |
+
}
|
| 3870 |
+
|
| 3871 |
+
.anexos-column-row span {
|
| 3872 |
+
min-width: 0;
|
| 3873 |
+
overflow-wrap: anywhere;
|
| 3874 |
+
}
|
| 3875 |
+
|
| 3876 |
+
.anexos-warning-panel,
|
| 3877 |
+
.anexos-result-panel {
|
| 3878 |
+
border-radius: 8px;
|
| 3879 |
+
padding: 10px 12px;
|
| 3880 |
+
font-size: 0.81rem;
|
| 3881 |
+
}
|
| 3882 |
+
|
| 3883 |
+
.anexos-warning-panel {
|
| 3884 |
+
border: 1px solid #e4bd55;
|
| 3885 |
+
background: #fff8df;
|
| 3886 |
+
color: #654b0c;
|
| 3887 |
+
}
|
| 3888 |
+
|
| 3889 |
+
.anexos-warning-panel ul {
|
| 3890 |
+
margin: 6px 0 0;
|
| 3891 |
+
padding-left: 18px;
|
| 3892 |
+
}
|
| 3893 |
+
|
| 3894 |
+
.anexos-result-panel {
|
| 3895 |
+
display: grid;
|
| 3896 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 3897 |
+
align-items: center;
|
| 3898 |
+
gap: 10px;
|
| 3899 |
+
border: 1px solid #aed3b8;
|
| 3900 |
+
background: #f2faf4;
|
| 3901 |
+
color: #235b32;
|
| 3902 |
+
}
|
| 3903 |
+
|
| 3904 |
+
.anexos-result-panel > div {
|
| 3905 |
+
display: grid;
|
| 3906 |
+
gap: 3px;
|
| 3907 |
+
min-width: 0;
|
| 3908 |
+
}
|
| 3909 |
+
|
| 3910 |
+
.anexos-result-panel span {
|
| 3911 |
+
color: #486b51;
|
| 3912 |
+
overflow-wrap: anywhere;
|
| 3913 |
+
}
|
| 3914 |
+
|
| 3915 |
+
.anexos-modal-footer {
|
| 3916 |
+
display: flex;
|
| 3917 |
+
justify-content: flex-end;
|
| 3918 |
+
padding-top: 12px;
|
| 3919 |
+
border-top: 1px solid #dbe5ef;
|
| 3920 |
+
}
|
| 3921 |
+
|
| 3922 |
+
.anexos-modal-footer button {
|
| 3923 |
+
min-width: 150px;
|
| 3924 |
+
}
|
| 3925 |
+
|
| 3926 |
.pesquisa-card-values-modal {
|
| 3927 |
width: min(920px, calc(100vw - 40px));
|
| 3928 |
max-width: min(920px, calc(100vw - 40px));
|
|
|
|
| 4259 |
.avaliacao-modelos-card-actions {
|
| 4260 |
display: inline-flex;
|
| 4261 |
align-items: center;
|
| 4262 |
+
justify-content: flex-end;
|
| 4263 |
+
gap: 6px;
|
| 4264 |
+
flex-wrap: wrap;
|
| 4265 |
flex: 0 0 auto;
|
| 4266 |
}
|
| 4267 |
|
| 4268 |
+
.avaliacao-modelos-anexo-btn {
|
| 4269 |
+
min-height: 28px;
|
| 4270 |
+
padding: 3px 8px;
|
| 4271 |
+
font-size: 0.74rem;
|
| 4272 |
+
--btn-bg-start: #eaf3fb;
|
| 4273 |
+
--btn-bg-end: #dceaf7;
|
| 4274 |
+
--btn-border: #afc9df;
|
| 4275 |
+
--btn-shadow-soft: rgba(47, 121, 184, 0.1);
|
| 4276 |
+
--btn-shadow-strong: rgba(47, 121, 184, 0.16);
|
| 4277 |
+
color: #285b85;
|
| 4278 |
+
}
|
| 4279 |
+
|
| 4280 |
.avaliacao-card-delete-confirm {
|
| 4281 |
display: inline-flex;
|
| 4282 |
align-items: center;
|
|
|
|
| 5197 |
gap: 8px;
|
| 5198 |
}
|
| 5199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5200 |
button.import-preview-apply-btn {
|
| 5201 |
--btn-bg-start: #ff8c00;
|
| 5202 |
--btn-bg-end: #e67900;
|
|
|
|
| 6104 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M12 2 3 7l9 5 9-5-9-5Zm-7.5 8.6L12 15l7.5-4.4L21 12l-9 5-9-5 1.5-1.4Zm0 4.8L12 19.8l7.5-4.4L21 17l-9 5-9-5 1.5-1.6Z'/%3E%3C/svg%3E");
|
| 6105 |
}
|
| 6106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6107 |
.leaflet-map-host .leaflet-control-layers-expanded {
|
| 6108 |
padding: 6px 9px;
|
| 6109 |
min-width: 220px;
|
|
|
|
| 6136 |
font-weight: 500;
|
| 6137 |
color: #27445f;
|
| 6138 |
cursor: pointer;
|
|
|
|
| 6139 |
line-height: 1.12;
|
| 6140 |
white-space: normal;
|
| 6141 |
}
|
| 6142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6143 |
.leaflet-map-host .leaflet-control-layers input {
|
| 6144 |
accent-color: #1f6fb2;
|
| 6145 |
width: 16px;
|
|
|
|
| 6174 |
border-top-color: rgba(207, 217, 227, 0.95);
|
| 6175 |
}
|
| 6176 |
|
| 6177 |
+
.leaflet-map-host .mesa-layer-control-close {
|
| 6178 |
+
display: none;
|
| 6179 |
+
width: 100%;
|
| 6180 |
+
margin: 7px 0 0;
|
| 6181 |
+
padding: 7px 10px;
|
| 6182 |
+
appearance: none;
|
| 6183 |
+
border: 1px solid #c8d6e4;
|
| 6184 |
+
border-radius: 8px;
|
| 6185 |
+
background: #fff;
|
| 6186 |
+
color: #24405b;
|
| 6187 |
+
font: inherit;
|
| 6188 |
+
font-size: 0.73rem;
|
| 6189 |
+
font-weight: 600;
|
| 6190 |
+
line-height: 1;
|
| 6191 |
+
cursor: pointer;
|
| 6192 |
+
}
|
| 6193 |
+
|
| 6194 |
+
.leaflet-map-host .leaflet-control-layers-expanded .mesa-layer-control-close {
|
| 6195 |
+
display: inline-flex;
|
| 6196 |
+
align-items: center;
|
| 6197 |
+
justify-content: center;
|
| 6198 |
+
}
|
| 6199 |
+
|
| 6200 |
.leaflet-map-host .mesa-leaflet-fullscreen,
|
| 6201 |
.leaflet-map-host .leaflet-control-measure {
|
| 6202 |
border: none;
|
|
|
|
| 6221 |
color: #24405b;
|
| 6222 |
}
|
| 6223 |
|
| 6224 |
+
.leaflet-map-host .mesa-leaflet-measure,
|
| 6225 |
+
.leaflet-map-host .mesa-leaflet-lasso {
|
| 6226 |
background: transparent;
|
| 6227 |
border: none;
|
| 6228 |
box-shadow: none;
|
|
|
|
| 6266 |
background: #eef5fb;
|
| 6267 |
}
|
| 6268 |
|
| 6269 |
+
.leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle,
|
| 6270 |
+
.leaflet-map-host .mesa-leaflet-lasso .mesa-leaflet-lasso-toggle {
|
| 6271 |
width: 38px;
|
| 6272 |
height: 38px;
|
| 6273 |
position: relative;
|
|
|
|
| 6278 |
box-shadow: 0 10px 24px rgba(27, 48, 72, 0.16);
|
| 6279 |
}
|
| 6280 |
|
| 6281 |
+
.leaflet-map-host .mesa-leaflet-lasso.is-active .mesa-leaflet-lasso-toggle {
|
| 6282 |
+
background: #e6f6f4;
|
| 6283 |
+
border-color: #91cfc6;
|
| 6284 |
+
}
|
| 6285 |
+
|
| 6286 |
.leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle::before {
|
| 6287 |
content: '';
|
| 6288 |
position: absolute;
|
|
|
|
| 6296 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25Zm2.92 2.33H5v-.92l8.12-8.12.92.92-8.12 8.12ZM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.33a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.83Z'/%3E%3C/svg%3E");
|
| 6297 |
}
|
| 6298 |
|
| 6299 |
+
.leaflet-map-host .mesa-leaflet-lasso .mesa-leaflet-lasso-toggle::before {
|
| 6300 |
+
content: '';
|
| 6301 |
+
position: absolute;
|
| 6302 |
+
inset: 0;
|
| 6303 |
+
margin: auto;
|
| 6304 |
+
width: 20px;
|
| 6305 |
+
height: 20px;
|
| 6306 |
+
background-repeat: no-repeat;
|
| 6307 |
+
background-position: center;
|
| 6308 |
+
background-size: 20px 20px;
|
| 6309 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%2324405b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M5 9c0-3 3.1-5 7-5s7 2 7 5-3.1 5-7 5-7-2-7-5Zm10.8 4.2 4.2 4.8m0 0-3.2-.5m3.2.5-.6-3.3M8 18c2.5 1.4 5.5 1.4 8 0'/%3E%3C/svg%3E");
|
| 6310 |
+
}
|
| 6311 |
+
|
| 6312 |
+
.leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle:hover,
|
| 6313 |
+
.leaflet-map-host .mesa-leaflet-lasso .mesa-leaflet-lasso-toggle:hover {
|
| 6314 |
background: #eef5fb;
|
| 6315 |
}
|
| 6316 |
|
|
|
|
| 6387 |
background: #f3f7fb;
|
| 6388 |
}
|
| 6389 |
|
| 6390 |
+
.leaflet-map-host .mesa-leaflet-measure-btn:disabled {
|
| 6391 |
+
opacity: 0.62;
|
| 6392 |
+
cursor: wait;
|
| 6393 |
+
}
|
| 6394 |
+
|
| 6395 |
+
.leaflet-map-host .mesa-leaflet-measure-btn:disabled:hover {
|
| 6396 |
+
background: #fff;
|
| 6397 |
+
}
|
| 6398 |
+
|
| 6399 |
.leaflet-map-host .mesa-leaflet-measure-btn.is-primary {
|
| 6400 |
background: #1f6fb2;
|
| 6401 |
border-color: #1f6fb2;
|
|
|
|
| 6406 |
background: #175a90;
|
| 6407 |
}
|
| 6408 |
|
| 6409 |
+
.leaflet-map-host .mesa-leaflet-measure-btn.is-primary:disabled:hover {
|
| 6410 |
+
background: #1f6fb2;
|
| 6411 |
+
}
|
| 6412 |
+
|
| 6413 |
.table-wrapper {
|
| 6414 |
overflow: auto;
|
| 6415 |
border: 1px solid #d8e2ec;
|
|
|
|
| 8754 |
flex-direction: column;
|
| 8755 |
}
|
| 8756 |
|
| 8757 |
+
.anexos-columns-head,
|
| 8758 |
+
.anexos-result-panel {
|
| 8759 |
+
grid-template-columns: 1fr;
|
| 8760 |
+
}
|
| 8761 |
+
|
| 8762 |
+
.anexos-columns-head {
|
| 8763 |
+
align-items: flex-start;
|
| 8764 |
+
flex-direction: column;
|
| 8765 |
+
}
|
| 8766 |
+
|
| 8767 |
+
.anexos-result-panel button,
|
| 8768 |
+
.anexos-modal-footer button {
|
| 8769 |
+
width: 100%;
|
| 8770 |
+
}
|
| 8771 |
+
|
| 8772 |
.avaliacao-localizacao-map-modal-actions {
|
| 8773 |
width: 100%;
|
| 8774 |
justify-content: flex-end;
|