Guilherme Silberfarb Costa commited on
Commit
c88d3e9
·
1 Parent(s): 89bdcd5

criação de nova aba de avaliação e alterações gerais

Browse files
backend/app/api/repositorio.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from typing import Any
4
 
5
- from fastapi import APIRouter, File, HTTPException, Request, UploadFile
6
  from pydantic import BaseModel, Field
7
 
8
  from app.services import auth_service, repositorio_service
@@ -32,7 +32,11 @@ def listar_modelos(request: Request) -> dict[str, Any]:
32
 
33
 
34
  @router.post("/upload")
35
- async def upload_modelos(request: Request, files: list[UploadFile] = File(...)) -> dict[str, Any]:
 
 
 
 
36
  user = auth_service.require_admin(request)
37
 
38
  arquivos: list[tuple[str, bytes]] = []
@@ -48,7 +52,11 @@ async def upload_modelos(request: Request, files: list[UploadFile] = File(...))
48
  if not arquivos:
49
  raise HTTPException(status_code=400, detail="Nenhum arquivo .dai valido foi enviado")
50
 
51
- resposta = repositorio_service.upload_modelos(arquivos, actor=user.get("usuario"))
 
 
 
 
52
  log_event(
53
  "repositorio",
54
  "upload_modelos",
@@ -57,6 +65,8 @@ async def upload_modelos(request: Request, files: list[UploadFile] = File(...))
57
  request=request,
58
  details={
59
  "arquivos": [nome for nome, _ in arquivos],
 
 
60
  "total_modelos": resposta.get("total_modelos"),
61
  },
62
  )
 
2
 
3
  from typing import Any
4
 
5
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
6
  from pydantic import BaseModel, Field
7
 
8
  from app.services import auth_service, repositorio_service
 
32
 
33
 
34
  @router.post("/upload")
35
+ async def upload_modelos(
36
+ request: Request,
37
+ files: list[UploadFile] = File(...),
38
+ confirmar_substituicao: bool = Form(False),
39
+ ) -> dict[str, Any]:
40
  user = auth_service.require_admin(request)
41
 
42
  arquivos: list[tuple[str, bytes]] = []
 
52
  if not arquivos:
53
  raise HTTPException(status_code=400, detail="Nenhum arquivo .dai valido foi enviado")
54
 
55
+ resposta = repositorio_service.upload_modelos(
56
+ arquivos,
57
+ actor=user.get("usuario"),
58
+ confirmar_substituicao=confirmar_substituicao,
59
+ )
60
  log_event(
61
  "repositorio",
62
  "upload_modelos",
 
65
  request=request,
66
  details={
67
  "arquivos": [nome for nome, _ in arquivos],
68
+ "confirmar_substituicao": bool(confirmar_substituicao),
69
+ "substituidos": resposta.get("resultado_upload", {}).get("substituidos", []),
70
  "total_modelos": resposta.get("total_modelos"),
71
  },
72
  )
backend/app/core/elaboracao/charts.py CHANGED
@@ -1208,12 +1208,18 @@ def criar_mapa(
1208
  if not np.isfinite(lon_min) or not np.isfinite(lon_max):
1209
  lon_min, lon_max = float(df_mapa[lon_real].min()), float(df_mapa[lon_real].max())
1210
 
1211
- # Fallback para mapa muito concentrado (mesma coordenada / quase mesma área).
1212
- if np.isclose(lat_min, lat_max) and np.isclose(lon_min, lon_max):
1213
- m.location = [float(lat_min), float(lon_min)]
1214
- else:
1215
- bounds = [[lat_min, lon_min], [lat_max, lon_max]]
1216
- m.fit_bounds(bounds, padding=(20, 20), max_zoom=17)
 
 
 
 
 
 
1217
 
1218
  if houve_amostragem:
1219
  aviso_html = (
 
1208
  if not np.isfinite(lon_min) or not np.isfinite(lon_max):
1209
  lon_min, lon_max = float(df_mapa[lon_real].min()), float(df_mapa[lon_real].max())
1210
 
1211
+ # Ajusta bounds para aproximar o máximo possível sem colar os pontos nas bordas.
1212
+ if np.isclose(lat_min, lat_max):
1213
+ lat_delta = 0.0008
1214
+ lat_min = float(lat_min) - lat_delta
1215
+ lat_max = float(lat_max) + lat_delta
1216
+ if np.isclose(lon_min, lon_max):
1217
+ lon_delta = 0.0008
1218
+ lon_min = float(lon_min) - lon_delta
1219
+ lon_max = float(lon_max) + lon_delta
1220
+
1221
+ bounds = [[float(lat_min), float(lon_min)], [float(lat_max), float(lon_max)]]
1222
+ m.fit_bounds(bounds, padding=(48, 48), max_zoom=18)
1223
 
1224
  if houve_amostragem:
1225
  aviso_html = (
backend/app/core/visualizacao/app.py CHANGED
@@ -932,11 +932,17 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
932
  if not np.isfinite(lon_min) or not np.isfinite(lon_max):
933
  lon_min, lon_max = float(df_mapa[lon_key].min()), float(df_mapa[lon_key].max())
934
 
935
- if np.isclose(lat_min, lat_max) and np.isclose(lon_min, lon_max):
936
- m.location = [float(lat_min), float(lon_min)]
937
- else:
938
- bounds = [[lat_min, lon_min], [lat_max, lon_max]]
939
- m.fit_bounds(bounds, padding=(20, 20), max_zoom=17)
 
 
 
 
 
 
940
 
941
  # Evita o wrapper de notebook (_repr_html_), que pode falhar dentro de iframe srcDoc.
942
  return m.get_root().render()
 
932
  if not np.isfinite(lon_min) or not np.isfinite(lon_max):
933
  lon_min, lon_max = float(df_mapa[lon_key].min()), float(df_mapa[lon_key].max())
934
 
935
+ if np.isclose(lat_min, lat_max):
936
+ lat_delta = 0.0008
937
+ lat_min = float(lat_min) - lat_delta
938
+ lat_max = float(lat_max) + lat_delta
939
+ if np.isclose(lon_min, lon_max):
940
+ lon_delta = 0.0008
941
+ lon_min = float(lon_min) - lon_delta
942
+ lon_max = float(lon_max) + lon_delta
943
+
944
+ bounds = [[float(lat_min), float(lon_min)], [float(lat_max), float(lon_max)]]
945
+ m.fit_bounds(bounds, padding=(48, 48), max_zoom=18)
946
 
947
  # Evita o wrapper de notebook (_repr_html_), que pode falhar dentro de iframe srcDoc.
948
  return m.get_root().render()
backend/app/services/model_repository.py CHANGED
@@ -307,7 +307,11 @@ def _normalizar_nome_arquivo(nome_arquivo: str) -> str:
307
  return safe
308
 
309
 
310
- def upsert_repository_models(arquivos: list[tuple[str, bytes]], actor: str | None = None) -> dict[str, Any]:
 
 
 
 
311
  if not arquivos:
312
  raise HTTPException(status_code=400, detail="Nenhum arquivo foi informado")
313
 
@@ -328,6 +332,15 @@ def upsert_repository_models(arquivos: list[tuple[str, bytes]], actor: str | Non
328
  existentes = {item.name for item in resolved.modelos_dir.glob("*.dai")}
329
  substituidos = sorted([nome for nome in payload if nome in existentes])
330
  adicionados = sorted([nome for nome in payload if nome not in existentes])
 
 
 
 
 
 
 
 
 
331
 
332
  if resolved.provider == "local":
333
  resolved.modelos_dir.mkdir(parents=True, exist_ok=True)
@@ -375,6 +388,7 @@ def upsert_repository_models(arquivos: list[tuple[str, bytes]], actor: str | Non
375
  "arquivos": sorted(payload.keys()),
376
  "adicionados": adicionados,
377
  "substituidos": substituidos,
 
378
  "fonte": resolved.as_payload(),
379
  }
380
 
 
307
  return safe
308
 
309
 
310
+ def upsert_repository_models(
311
+ arquivos: list[tuple[str, bytes]],
312
+ actor: str | None = None,
313
+ confirmar_substituicao: bool = False,
314
+ ) -> dict[str, Any]:
315
  if not arquivos:
316
  raise HTTPException(status_code=400, detail="Nenhum arquivo foi informado")
317
 
 
332
  existentes = {item.name for item in resolved.modelos_dir.glob("*.dai")}
333
  substituidos = sorted([nome for nome in payload if nome in existentes])
334
  adicionados = sorted([nome for nome in payload if nome not in existentes])
335
+ if substituidos and not confirmar_substituicao:
336
+ raise HTTPException(
337
+ status_code=409,
338
+ detail={
339
+ "code": "repositorio_modelo_duplicado",
340
+ "message": "Ja existe modelo com o mesmo nome no repositorio. Confirme para substituir.",
341
+ "substituidos": substituidos,
342
+ },
343
+ )
344
 
345
  if resolved.provider == "local":
346
  resolved.modelos_dir.mkdir(parents=True, exist_ok=True)
 
388
  "arquivos": sorted(payload.keys()),
389
  "adicionados": adicionados,
390
  "substituidos": substituidos,
391
+ "confirmar_substituicao": bool(confirmar_substituicao),
392
  "fonte": resolved.as_payload(),
393
  }
394
 
backend/app/services/pesquisa_service.py CHANGED
@@ -486,7 +486,19 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 4
486
  folium.LayerControl(collapsed=False).add_to(mapa)
487
  add_zoom_responsive_circle_markers(mapa)
488
  if bounds:
489
- mapa.fit_bounds(bounds, padding=(20, 20))
 
 
 
 
 
 
 
 
 
 
 
 
490
 
491
  return sanitize_value(
492
  {
 
486
  folium.LayerControl(collapsed=False).add_to(mapa)
487
  add_zoom_responsive_circle_markers(mapa)
488
  if bounds:
489
+ lat_values = [float(coord[0]) for coord in bounds]
490
+ lon_values = [float(coord[1]) for coord in bounds]
491
+ lat_min, lat_max = min(lat_values), max(lat_values)
492
+ lon_min, lon_max = min(lon_values), max(lon_values)
493
+ if math.isclose(lat_min, lat_max):
494
+ lat_delta = 0.0008
495
+ lat_min -= lat_delta
496
+ lat_max += lat_delta
497
+ if math.isclose(lon_min, lon_max):
498
+ lon_delta = 0.0008
499
+ lon_min -= lon_delta
500
+ lon_max += lon_delta
501
+ mapa.fit_bounds([[lat_min, lon_min], [lat_max, lon_max]], padding=(48, 48), max_zoom=18)
502
 
503
  return sanitize_value(
504
  {
backend/app/services/repositorio_service.py CHANGED
@@ -98,8 +98,16 @@ def listar_modelos() -> dict[str, Any]:
98
  )
99
 
100
 
101
- def upload_modelos(arquivos: list[tuple[str, bytes]], actor: str | None = None) -> dict[str, Any]:
102
- resultado = model_repository.upsert_repository_models(arquivos, actor=actor)
 
 
 
 
 
 
 
 
103
  contexto = listar_modelos()
104
  return sanitize_value(
105
  {
 
98
  )
99
 
100
 
101
+ def upload_modelos(
102
+ arquivos: list[tuple[str, bytes]],
103
+ actor: str | None = None,
104
+ confirmar_substituicao: bool = False,
105
+ ) -> dict[str, Any]:
106
+ resultado = model_repository.upsert_repository_models(
107
+ arquivos,
108
+ actor=actor,
109
+ confirmar_substituicao=confirmar_substituicao,
110
+ )
111
  contexto = listar_modelos()
112
  return sanitize_value(
113
  {
frontend/src/App.jsx CHANGED
@@ -1,5 +1,6 @@
1
  import React, { useEffect, useState } from 'react'
2
  import { api, getAuthToken, setAuthToken } from './api'
 
3
  import ElaboracaoTab from './components/ElaboracaoTab'
4
  import InicioTab from './components/InicioTab'
5
  import PesquisaTab from './components/PesquisaTab'
@@ -9,15 +10,16 @@ import VisualizacaoTab from './components/VisualizacaoTab'
9
  const LOGS_PAGE_SIZE = 30
10
 
11
  const TABS = [
12
- { key: 'Início', label: 'Início', hint: 'Visão geral rápida do aplicativo' },
13
  { key: 'Pesquisa', label: 'Pesquisa', hint: 'Busca inicial de modelos .dai' },
14
  { key: 'Elaboração/Edição', label: 'Elaboração/Edição', hint: 'Fluxo completo de modelagem' },
15
  { key: 'Visualização/Avaliação', label: 'Visualização/Avaliação', hint: 'Leitura e avaliação de modelos .dai' },
16
  { key: 'Repositório', label: 'Repositório', hint: 'Gestão e consulta dos modelos .dai' },
 
17
  ]
18
 
19
  export default function App() {
20
  const [activeTab, setActiveTab] = useState(TABS[0].key)
 
21
  const [sessionId, setSessionId] = useState('')
22
  const [bootError, setBootError] = useState('')
23
 
@@ -60,6 +62,7 @@ export default function App() {
60
  setLogsError('')
61
  setLogsPage(1)
62
  setActiveTab(TABS[0].key)
 
63
  setAuthError(message)
64
  }
65
 
@@ -428,7 +431,10 @@ export default function App() {
428
  <button
429
  key={tab.key}
430
  className={active ? 'tab-pill active' : 'tab-pill'}
431
- onClick={() => setActiveTab(tab.key)}
 
 
 
432
  type="button"
433
  >
434
  <strong>{tab.label}</strong>
@@ -440,12 +446,14 @@ export default function App() {
440
 
441
  {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
442
 
443
- <div className="tab-pane" hidden={activeTab !== 'Início'}>
444
- <InicioTab />
445
- </div>
 
 
446
 
447
  <div className="tab-pane" hidden={activeTab !== 'Pesquisa'}>
448
- <PesquisaTab />
449
  </div>
450
 
451
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
@@ -457,7 +465,11 @@ export default function App() {
457
  </div>
458
 
459
  <div className="tab-pane" hidden={activeTab !== 'Repositório'}>
460
- <RepositorioTab authUser={authUser} />
 
 
 
 
461
  </div>
462
  </>
463
  )
 
1
  import React, { useEffect, useState } from 'react'
2
  import { api, getAuthToken, setAuthToken } from './api'
3
+ import AvaliacaoBetaTab from './components/AvaliacaoBetaTab'
4
  import ElaboracaoTab from './components/ElaboracaoTab'
5
  import InicioTab from './components/InicioTab'
6
  import PesquisaTab from './components/PesquisaTab'
 
10
  const LOGS_PAGE_SIZE = 30
11
 
12
  const TABS = [
 
13
  { key: 'Pesquisa', label: 'Pesquisa', hint: 'Busca inicial de modelos .dai' },
14
  { key: 'Elaboração/Edição', label: 'Elaboração/Edição', hint: 'Fluxo completo de modelagem' },
15
  { key: 'Visualização/Avaliação', label: 'Visualização/Avaliação', hint: 'Leitura e avaliação de modelos .dai' },
16
  { key: 'Repositório', label: 'Repositório', hint: 'Gestão e consulta dos modelos .dai' },
17
+ { key: 'Avaliação Beta', label: 'Avaliação Beta', hint: 'Comparação por cards entre modelos e cenários' },
18
  ]
19
 
20
  export default function App() {
21
  const [activeTab, setActiveTab] = useState(TABS[0].key)
22
+ const [showStartupIntro, setShowStartupIntro] = useState(true)
23
  const [sessionId, setSessionId] = useState('')
24
  const [bootError, setBootError] = useState('')
25
 
 
62
  setLogsError('')
63
  setLogsPage(1)
64
  setActiveTab(TABS[0].key)
65
+ setShowStartupIntro(true)
66
  setAuthError(message)
67
  }
68
 
 
431
  <button
432
  key={tab.key}
433
  className={active ? 'tab-pill active' : 'tab-pill'}
434
+ onClick={() => {
435
+ setActiveTab(tab.key)
436
+ setShowStartupIntro(false)
437
+ }}
438
  type="button"
439
  >
440
  <strong>{tab.label}</strong>
 
446
 
447
  {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
448
 
449
+ {showStartupIntro ? (
450
+ <div className="tab-pane">
451
+ <InicioTab />
452
+ </div>
453
+ ) : null}
454
 
455
  <div className="tab-pane" hidden={activeTab !== 'Pesquisa'}>
456
+ <PesquisaTab sessionId={sessionId} />
457
  </div>
458
 
459
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
 
465
  </div>
466
 
467
  <div className="tab-pane" hidden={activeTab !== 'Repositório'}>
468
+ <RepositorioTab authUser={authUser} sessionId={sessionId} />
469
+ </div>
470
+
471
+ <div className="tab-pane" hidden={activeTab !== 'Avaliação Beta'}>
472
+ <AvaliacaoBetaTab sessionId={sessionId} />
473
  </div>
474
  </>
475
  )
frontend/src/api.js CHANGED
@@ -42,13 +42,18 @@ async function handleResponse(response, path = '') {
42
  } catch {
43
  detail = response.statusText || detail
44
  }
 
 
 
 
45
  if (response.status === 401 && path !== '/api/auth/login') {
46
  setAuthToken('')
47
- emitAuthRequired(detail)
48
  }
49
- const error = new Error(detail)
50
  error.status = response.status
51
  error.path = path
 
52
  throw error
53
  }
54
  return response
@@ -265,11 +270,12 @@ export const api = {
265
  clearVisualizacao: (sessionId) => postJson('/api/visualizacao/clear', { session_id: sessionId }),
266
 
267
  repositorioListar: () => getJson('/api/repositorio/modelos'),
268
- repositorioUpload(files = []) {
269
  const form = new FormData()
270
  files.forEach((file) => {
271
  form.append('files', file)
272
  })
 
273
  return postForm('/api/repositorio/upload', form)
274
  },
275
  repositorioDelete: (modelosIds = []) => postJson('/api/repositorio/delete', { modelos_ids: modelosIds }),
 
42
  } catch {
43
  detail = response.statusText || detail
44
  }
45
+ const detailMessage = typeof detail === 'string'
46
+ ? detail
47
+ : String(detail?.message || response.statusText || 'Erro inesperado')
48
+
49
  if (response.status === 401 && path !== '/api/auth/login') {
50
  setAuthToken('')
51
+ emitAuthRequired(detailMessage)
52
  }
53
+ const error = new Error(detailMessage)
54
  error.status = response.status
55
  error.path = path
56
+ error.detail = detail
57
  throw error
58
  }
59
  return response
 
270
  clearVisualizacao: (sessionId) => postJson('/api/visualizacao/clear', { session_id: sessionId }),
271
 
272
  repositorioListar: () => getJson('/api/repositorio/modelos'),
273
+ repositorioUpload(files = [], { confirmarSubstituicao = false } = {}) {
274
  const form = new FormData()
275
  files.forEach((file) => {
276
  form.append('files', file)
277
  })
278
+ form.append('confirmar_substituicao', confirmarSubstituicao ? 'true' : 'false')
279
  return postForm('/api/repositorio/upload', form)
280
  },
281
  repositorioDelete: (modelosIds = []) => postJson('/api/repositorio/delete', { modelos_ids: modelosIds }),
frontend/src/components/AvaliacaoBetaTab.jsx ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { api, downloadBlob } from '../api'
3
+ import LoadingOverlay from './LoadingOverlay'
4
+ import SinglePillAutocomplete from './SinglePillAutocomplete'
5
+
6
+ function formatarNumero(valor, casas = 2) {
7
+ const numero = Number(valor)
8
+ if (!Number.isFinite(numero)) return '-'
9
+ return numero.toLocaleString('pt-BR', {
10
+ minimumFractionDigits: casas,
11
+ maximumFractionDigits: casas,
12
+ })
13
+ }
14
+
15
+ function formatarMoeda(valor) {
16
+ const numero = Number(valor)
17
+ if (!Number.isFinite(numero)) return '-'
18
+ return numero.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
19
+ }
20
+
21
+ function formatarDataHoraIso(iso) {
22
+ if (!iso) return '-'
23
+ const data = new Date(String(iso))
24
+ if (Number.isNaN(data.getTime())) return '-'
25
+ return data.toLocaleString('pt-BR')
26
+ }
27
+
28
+ function classificarExtrapolacao(info) {
29
+ const status = String(info?.status || 'ok')
30
+ const percentual = Number(info?.percentual || 0)
31
+ if (status === 'ok') return 'ok'
32
+ if (status === 'warning') return `⚠ ${formatarNumero(percentual, 1)}%`
33
+ if (status === 'grave') return `✖ ${formatarNumero(percentual, 1)}%`
34
+ if (status === 'dicotomica' || status === 'codigo_alocado' || status === 'percentual') return '—'
35
+ return status
36
+ }
37
+
38
+ function corGrau(valor) {
39
+ const texto = String(valor || '').toLowerCase()
40
+ if (texto.includes('grau iii')) return '#1f7a40'
41
+ if (texto.includes('grau ii')) return '#2d8dbf'
42
+ if (texto.includes('grau i')) return '#c97400'
43
+ if (texto.includes('sem enquadramento')) return '#b22f40'
44
+ return '#48627a'
45
+ }
46
+
47
+ function escaparCsv(valor) {
48
+ const texto = String(valor ?? '')
49
+ if (!/[;"\n\r]/.test(texto)) return texto
50
+ return `"${texto.replaceAll('"', '""')}"`
51
+ }
52
+
53
+ function formatarFonteRepositorio(fonte) {
54
+ if (!fonte || typeof fonte !== 'object') return ''
55
+ const provider = String(fonte.provider || '').toLowerCase()
56
+ if (provider === 'hf_dataset') {
57
+ const repo = String(fonte.repo_id || '').trim()
58
+ const revision = String(fonte.revision || '').trim()
59
+ const degradado = Boolean(fonte.degraded)
60
+ const sufixo = degradado ? ' (modo contingência)' : ''
61
+ return `Fonte: HF Dataset${repo ? ` (${repo})` : ''}${revision ? ` | revisão ${revision.slice(0, 8)}` : ''}${sufixo}`
62
+ }
63
+ return 'Fonte: pasta local'
64
+ }
65
+
66
+ export default function AvaliacaoBetaTab({ sessionId }) {
67
+ const [loading, setLoading] = useState(false)
68
+ const [error, setError] = useState('')
69
+ const [status, setStatus] = useState('')
70
+ const [badgeHtml, setBadgeHtml] = useState('')
71
+
72
+ const [uploadedFile, setUploadedFile] = useState(null)
73
+ const [uploadDragOver, setUploadDragOver] = useState(false)
74
+ const [modeloLoadSource, setModeloLoadSource] = useState('')
75
+ const [repoModelos, setRepoModelos] = useState([])
76
+ const [repoModeloSelecionado, setRepoModeloSelecionado] = useState('')
77
+ const [repoModelosLoading, setRepoModelosLoading] = useState(false)
78
+ const [repoFonteModelos, setRepoFonteModelos] = useState('')
79
+
80
+ const [modeloAtualNome, setModeloAtualNome] = useState('')
81
+ const [camposAvaliacao, setCamposAvaliacao] = useState([])
82
+ const [equacaoSab, setEquacaoSab] = useState('')
83
+ const valoresAvaliacaoRef = useRef({})
84
+ const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
85
+
86
+ const [avaliacoesCards, setAvaliacoesCards] = useState([])
87
+ const [baseCardId, setBaseCardId] = useState('')
88
+ const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
89
+
90
+ const uploadInputRef = useRef(null)
91
+
92
+ const repoModeloOptions = useMemo(
93
+ () => (repoModelos || []).map((item) => ({
94
+ value: String(item?.id || ''),
95
+ label: String(item?.nome_modelo || item?.arquivo || item?.id || ''),
96
+ secondary: String(item?.arquivo || ''),
97
+ })).filter((item) => item.value && item.label),
98
+ [repoModelos],
99
+ )
100
+
101
+ const baseCard = useMemo(
102
+ () => avaliacoesCards.find((item) => item.id === baseCardId) || null,
103
+ [avaliacoesCards, baseCardId],
104
+ )
105
+
106
+ useEffect(() => {
107
+ let ativo = true
108
+ if (!sessionId) return () => {
109
+ ativo = false
110
+ }
111
+
112
+ setRepoModelosLoading(true)
113
+ api.visualizacaoRepositorioModelos()
114
+ .then((resp) => {
115
+ if (!ativo) return
116
+ const modelos = Array.isArray(resp?.modelos) ? resp.modelos : []
117
+ setRepoModelos(modelos)
118
+ setRepoFonteModelos(formatarFonteRepositorio(resp?.fonte || null))
119
+ })
120
+ .catch(() => {
121
+ if (!ativo) return
122
+ setRepoModelos([])
123
+ setRepoFonteModelos('')
124
+ })
125
+ .finally(() => {
126
+ if (!ativo) return
127
+ setRepoModelosLoading(false)
128
+ })
129
+
130
+ return () => {
131
+ ativo = false
132
+ }
133
+ }, [sessionId])
134
+
135
+ useEffect(() => {
136
+ if (!avaliacoesCards.length) {
137
+ if (baseCardId) setBaseCardId('')
138
+ return
139
+ }
140
+ if (!baseCardId || !avaliacoesCards.some((item) => item.id === baseCardId)) {
141
+ setBaseCardId(avaliacoesCards[0].id)
142
+ }
143
+ }, [avaliacoesCards, baseCardId])
144
+
145
+ function resetCamposAvaliacao(campos = camposAvaliacao) {
146
+ const limpo = {}
147
+ ;(campos || []).forEach((campo) => {
148
+ limpo[campo.coluna] = ''
149
+ })
150
+ valoresAvaliacaoRef.current = limpo
151
+ setAvaliacaoFormVersion((prev) => prev + 1)
152
+ }
153
+
154
+ function aplicarRespostaExibicao(resp, nomeModelo) {
155
+ const campos = resp?.campos_avaliacao || []
156
+ setCamposAvaliacao(campos)
157
+ resetCamposAvaliacao(campos)
158
+ setEquacaoSab(String(resp?.equacoes?.excel_sab || ''))
159
+ setModeloAtualNome(String(nomeModelo || '').trim())
160
+ }
161
+
162
+ async function withBusy(fn) {
163
+ setLoading(true)
164
+ setError('')
165
+ try {
166
+ await fn()
167
+ } catch (err) {
168
+ setError(err?.message || 'Falha ao executar operação.')
169
+ } finally {
170
+ setLoading(false)
171
+ }
172
+ }
173
+
174
+ async function carregarModelosRepositorio() {
175
+ setRepoModelosLoading(true)
176
+ try {
177
+ const resp = await api.visualizacaoRepositorioModelos()
178
+ const modelos = Array.isArray(resp?.modelos) ? resp.modelos : []
179
+ setRepoModelos(modelos)
180
+ setRepoFonteModelos(formatarFonteRepositorio(resp?.fonte || null))
181
+ setRepoModeloSelecionado((prev) => {
182
+ if (prev && modelos.some((item) => String(item.id) === String(prev))) return prev
183
+ return ''
184
+ })
185
+ } catch (err) {
186
+ setError(err?.message || 'Falha ao carregar modelos do repositório.')
187
+ setRepoModelos([])
188
+ setRepoModeloSelecionado('')
189
+ setRepoFonteModelos('')
190
+ } finally {
191
+ setRepoModelosLoading(false)
192
+ }
193
+ }
194
+
195
+ async function onUploadModel(arquivo = null) {
196
+ const arquivoUpload = arquivo || uploadedFile
197
+ if (!sessionId || !arquivoUpload) return
198
+ setModeloLoadSource('upload')
199
+ await withBusy(async () => {
200
+ const uploadResp = await api.uploadVisualizacaoFile(sessionId, arquivoUpload)
201
+ setStatus(uploadResp?.status || '')
202
+ setBadgeHtml(uploadResp?.badge_html || '')
203
+ const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
204
+ const exibirResp = await api.exibirVisualizacao(sessionId)
205
+ aplicarRespostaExibicao(exibirResp, nomeModelo)
206
+ })
207
+ }
208
+
209
+ async function onCarregarModeloRepositorio() {
210
+ if (!sessionId || !repoModeloSelecionado) return
211
+ setModeloLoadSource('repo')
212
+ await withBusy(async () => {
213
+ const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, repoModeloSelecionado)
214
+ setStatus(uploadResp?.status || '')
215
+ setBadgeHtml(uploadResp?.badge_html || '')
216
+ const modeloSelecionado = repoModeloOptions.find((item) => item.value === repoModeloSelecionado)
217
+ const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
218
+ const exibirResp = await api.exibirVisualizacao(sessionId)
219
+ aplicarRespostaExibicao(exibirResp, nomeModelo)
220
+ setUploadedFile(null)
221
+ })
222
+ }
223
+
224
+ function onUploadInputChange(event) {
225
+ const input = event.target
226
+ const file = input.files?.[0] ?? null
227
+ setModeloLoadSource('upload')
228
+ setUploadedFile(file)
229
+ input.value = ''
230
+ if (!file || loading) return
231
+ void onUploadModel(file)
232
+ }
233
+
234
+ function onUploadDropZoneDragOver(event) {
235
+ event.preventDefault()
236
+ event.dataTransfer.dropEffect = 'copy'
237
+ setUploadDragOver(true)
238
+ }
239
+
240
+ function onUploadDropZoneDragLeave(event) {
241
+ event.preventDefault()
242
+ if (!event.currentTarget.contains(event.relatedTarget)) {
243
+ setUploadDragOver(false)
244
+ }
245
+ }
246
+
247
+ function onUploadDropZoneDrop(event) {
248
+ event.preventDefault()
249
+ setUploadDragOver(false)
250
+ const file = event.dataTransfer?.files?.[0]
251
+ if (!file || loading) return
252
+ setModeloLoadSource('upload')
253
+ setUploadedFile(file)
254
+ void onUploadModel(file)
255
+ }
256
+
257
+ async function onIncluirAvaliacao() {
258
+ if (!sessionId || !camposAvaliacao.length) return
259
+ await withBusy(async () => {
260
+ const resp = await api.evaluationCalculateViz(sessionId, valoresAvaliacaoRef.current, null)
261
+ const lista = Array.isArray(resp?.avaliacoes) ? resp.avaliacoes : []
262
+ const ultima = lista.length ? lista[lista.length - 1] : null
263
+ if (!ultima || typeof ultima !== 'object') {
264
+ throw new Error('Não foi possível obter o resultado da avaliação.')
265
+ }
266
+ const card = {
267
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
268
+ modelo: modeloAtualNome || 'Modelo sem nome',
269
+ createdAt: new Date().toISOString(),
270
+ avaliacao: ultima,
271
+ }
272
+ setAvaliacoesCards((prev) => [...prev, card])
273
+ setBaseCardId((prev) => prev || card.id)
274
+ setConfirmarLimpezaAvaliacoes(false)
275
+ })
276
+ }
277
+
278
+ function onResetCamposAvaliacao() {
279
+ resetCamposAvaliacao()
280
+ }
281
+
282
+ function onExcluirCard(cardId) {
283
+ setAvaliacoesCards((prev) => prev.filter((item) => item.id !== cardId))
284
+ }
285
+
286
+ function onLimparAvaliacoes() {
287
+ setAvaliacoesCards([])
288
+ setBaseCardId('')
289
+ setConfirmarLimpezaAvaliacoes(false)
290
+ }
291
+
292
+ function calcularComparacaoBase(avaliacao) {
293
+ if (!baseCard || !baseCard.avaliacao) return '—'
294
+ const baseEstimado = Number(baseCard.avaliacao.estimado)
295
+ const atual = Number(avaliacao?.estimado)
296
+ if (!Number.isFinite(baseEstimado) || baseEstimado === 0 || !Number.isFinite(atual)) return '—'
297
+ const diff = ((atual / baseEstimado) - 1) * 100
298
+ const sinal = diff >= 0 ? '+' : '−'
299
+ return `${sinal}${formatarNumero(Math.abs(diff), 1)}%`
300
+ }
301
+
302
+ function onExportAvaliacoes() {
303
+ if (!avaliacoesCards.length) return
304
+ const variaveis = Array.from(
305
+ new Set(
306
+ avaliacoesCards.flatMap((item) => Object.keys(item?.avaliacao?.valores_x || {})),
307
+ ),
308
+ )
309
+
310
+ const cabecalho = [
311
+ 'Avaliacao',
312
+ 'Modelo',
313
+ 'DataHora',
314
+ 'Estimado',
315
+ 'EstimadoVsBase',
316
+ 'CA-15%',
317
+ 'CA+15%',
318
+ 'IC80Inf',
319
+ 'IC80Sup',
320
+ 'PercInf',
321
+ 'PercSup',
322
+ 'Amplitude',
323
+ 'Precisao',
324
+ 'Fundamentacao',
325
+ 'QtdExtrapolacoes',
326
+ ...variaveis.map((item) => `X_${item}`),
327
+ ]
328
+ const linhas = [cabecalho.join(';')]
329
+ avaliacoesCards.forEach((item, idx) => {
330
+ const aval = item?.avaliacao || {}
331
+ const baseTexto = item.id === baseCardId ? 'Base' : calcularComparacaoBase(aval)
332
+ const camposFixos = [
333
+ String(idx + 1),
334
+ String(item.modelo || ''),
335
+ formatarDataHoraIso(item.createdAt),
336
+ formatarNumero(aval.estimado, 2),
337
+ baseTexto,
338
+ formatarNumero(aval.ca_inf, 2),
339
+ formatarNumero(aval.ca_sup, 2),
340
+ formatarNumero(aval.ic_inf, 2),
341
+ formatarNumero(aval.ic_sup, 2),
342
+ formatarNumero(aval.perc_inf, 2),
343
+ formatarNumero(aval.perc_sup, 2),
344
+ formatarNumero(aval.amplitude, 2),
345
+ String(aval.precisao || ''),
346
+ String(aval.fundamentacao || ''),
347
+ String(aval.qtd_extrapolacoes ?? ''),
348
+ ]
349
+ const camposVars = variaveis.map((variavel) => {
350
+ const valor = aval?.valores_x?.[variavel]
351
+ return Number.isFinite(Number(valor)) ? formatarNumero(valor, 2) : String(valor ?? '')
352
+ })
353
+ linhas.push([...camposFixos, ...camposVars].map(escaparCsv).join(';'))
354
+ })
355
+
356
+ const csv = `\uFEFF${linhas.join('\n')}`
357
+ downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8;' }), 'avaliacoes_beta.csv')
358
+ }
359
+
360
+ const modeloPronto = Boolean(camposAvaliacao.length)
361
+
362
+ return (
363
+ <div className="tab-content">
364
+ <div className="avaliacao-beta-flow">
365
+ <div className="avaliacao-beta-model-block">
366
+ <h3 className="avaliacao-beta-title">Selecionar Modelo para Avaliação</h3>
367
+ {!modeloLoadSource ? (
368
+ <div className="model-source-choice-grid">
369
+ <button
370
+ type="button"
371
+ className="model-source-choice-btn model-source-choice-btn-primary"
372
+ onClick={() => setModeloLoadSource('repo')}
373
+ disabled={loading}
374
+ >
375
+ Carregar modelo do repositório
376
+ </button>
377
+ <button
378
+ type="button"
379
+ className="model-source-choice-btn model-source-choice-btn-secondary"
380
+ onClick={() => setModeloLoadSource('upload')}
381
+ disabled={loading}
382
+ >
383
+ Fazer upload de modelo
384
+ </button>
385
+ </div>
386
+ ) : (
387
+ <div className="model-source-flow">
388
+ <div className="model-source-flow-head">
389
+ <button
390
+ type="button"
391
+ className="model-source-back-btn"
392
+ onClick={() => setModeloLoadSource('')}
393
+ disabled={loading}
394
+ >
395
+ Voltar
396
+ </button>
397
+ </div>
398
+
399
+ {modeloLoadSource === 'repo' ? (
400
+ <div className="row upload-repo-row">
401
+ <label className="upload-repo-field">
402
+ Modelo do repositório
403
+ <SinglePillAutocomplete
404
+ value={repoModeloSelecionado}
405
+ onChange={setRepoModeloSelecionado}
406
+ options={repoModeloOptions}
407
+ placeholder={repoModelosLoading ? 'Carregando lista...' : repoModeloOptions.length > 0 ? 'Digite para buscar modelo' : 'Nenhum modelo disponível'}
408
+ emptyMessage={repoModeloOptions.length > 0 ? 'Nenhum modelo encontrado.' : 'Nenhum modelo disponível.'}
409
+ loading={repoModelosLoading}
410
+ disabled={loading || repoModelosLoading || repoModeloOptions.length === 0}
411
+ />
412
+ </label>
413
+ <div className="row compact upload-repo-actions">
414
+ <button type="button" onClick={onCarregarModeloRepositorio} disabled={loading || repoModelosLoading || !repoModeloSelecionado}>
415
+ Carregar do repositório
416
+ </button>
417
+ <button type="button" onClick={() => void carregarModelosRepositorio()} disabled={loading || repoModelosLoading}>
418
+ Atualizar lista
419
+ </button>
420
+ </div>
421
+ {repoFonteModelos ? <div className="section1-empty-hint">{repoFonteModelos}</div> : null}
422
+ </div>
423
+ ) : null}
424
+
425
+ {modeloLoadSource === 'upload' ? (
426
+ <div
427
+ className={`upload-dropzone${uploadDragOver ? ' is-dragover' : ''}`}
428
+ onDragOver={onUploadDropZoneDragOver}
429
+ onDragEnter={onUploadDropZoneDragOver}
430
+ onDragLeave={onUploadDropZoneDragLeave}
431
+ onDrop={onUploadDropZoneDrop}
432
+ >
433
+ <input
434
+ ref={uploadInputRef}
435
+ type="file"
436
+ className="upload-hidden-input"
437
+ accept=".dai"
438
+ onChange={onUploadInputChange}
439
+ />
440
+ <div className="row upload-dropzone-main">
441
+ <button
442
+ type="button"
443
+ className="btn-upload-select"
444
+ onClick={() => uploadInputRef.current?.click()}
445
+ disabled={loading}
446
+ >
447
+ Selecionar arquivo
448
+ </button>
449
+ </div>
450
+ <div className="upload-dropzone-hint">Ou arraste e solte aqui para carregar automaticamente.</div>
451
+ </div>
452
+ ) : null}
453
+ </div>
454
+ )}
455
+ {status ? <div className="status-line">{status}</div> : null}
456
+ {badgeHtml ? <div className="upload-badge-block" dangerouslySetInnerHTML={{ __html: badgeHtml }} /> : null}
457
+ </div>
458
+
459
+ {!modeloPronto ? (
460
+ <div className="empty-box">Selecione um modelo acima para liberar os campos de avaliação.</div>
461
+ ) : (
462
+ <>
463
+ <div className="equation-formats-section avaliacao-equacao-section">
464
+ <h5>Equação (estilo SAB)</h5>
465
+ <div className="equation-box equation-box-plain">
466
+ {equacaoSab || 'Equação indisponível.'}
467
+ </div>
468
+ </div>
469
+
470
+ <div className="avaliacao-groups avaliacao-beta-groups">
471
+ <div className="subpanel avaliacao-group">
472
+ <h4>Parâmetros</h4>
473
+ <div className="avaliacao-grid" key={`avaliacao-grid-beta-${avaliacaoFormVersion}`}>
474
+ {camposAvaliacao.map((campo) => (
475
+ <div key={`campo-beta-${campo.coluna}`} className="avaliacao-card">
476
+ <label>{campo.coluna}</label>
477
+ {campo.tipo === 'dicotomica' ? (
478
+ <select
479
+ defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
480
+ onChange={(event) => {
481
+ valoresAvaliacaoRef.current[campo.coluna] = event.target.value
482
+ }}
483
+ >
484
+ <option value="">Selecione</option>
485
+ {(campo.opcoes || [0, 1]).map((opcao) => (
486
+ <option key={`op-beta-${campo.coluna}-${opcao}`} value={String(opcao)}>
487
+ {opcao}
488
+ </option>
489
+ ))}
490
+ </select>
491
+ ) : (
492
+ <input
493
+ type="number"
494
+ defaultValue={valoresAvaliacaoRef.current[campo.coluna] ?? ''}
495
+ placeholder={campo.placeholder || ''}
496
+ onChange={(event) => {
497
+ valoresAvaliacaoRef.current[campo.coluna] = event.target.value
498
+ }}
499
+ />
500
+ )}
501
+ </div>
502
+ ))}
503
+ </div>
504
+ <div className="row-wrap avaliacao-actions-row">
505
+ <button type="button" onClick={() => void onIncluirAvaliacao()} disabled={loading || !modeloPronto}>
506
+ Incluir avaliação
507
+ </button>
508
+ <button type="button" onClick={onResetCamposAvaliacao} disabled={loading || !modeloPronto}>
509
+ Resetar campos
510
+ </button>
511
+ </div>
512
+ </div>
513
+
514
+ {avaliacoesCards.length ? (
515
+ <div className="subpanel avaliacao-group">
516
+ <h4>Avaliações</h4>
517
+ <div className="row avaliacao-base-row">
518
+ <label>Base comparação</label>
519
+ <select value={baseCardId} onChange={(event) => setBaseCardId(event.target.value)}>
520
+ {avaliacoesCards.map((item, idx) => (
521
+ <option key={`base-card-${item.id}`} value={item.id}>
522
+ {`Aval. ${idx + 1} - ${item.modelo}`}
523
+ </option>
524
+ ))}
525
+ </select>
526
+ <button type="button" className="btn-avaliacao-export" onClick={onExportAvaliacoes} disabled={loading || !avaliacoesCards.length}>
527
+ Exportar avaliações
528
+ </button>
529
+ {!confirmarLimpezaAvaliacoes ? (
530
+ <button
531
+ type="button"
532
+ className="btn-avaliacao-clear"
533
+ onClick={() => setConfirmarLimpezaAvaliacoes(true)}
534
+ disabled={loading || !avaliacoesCards.length}
535
+ >
536
+ Limpar avaliações
537
+ </button>
538
+ ) : (
539
+ <div className="avaliacao-clear-confirm avaliacao-clear-confirm-inline">
540
+ <span>Confirmar limpeza?</span>
541
+ <button type="button" className="btn-avaliacao-clear" onClick={onLimparAvaliacoes} disabled={loading}>
542
+ Confirmar
543
+ </button>
544
+ <button type="button" onClick={() => setConfirmarLimpezaAvaliacoes(false)} disabled={loading}>
545
+ Cancelar
546
+ </button>
547
+ </div>
548
+ )}
549
+ </div>
550
+
551
+ <div className="avaliacao-beta-cards-grid">
552
+ {avaliacoesCards.map((item, idx) => {
553
+ const aval = item.avaliacao || {}
554
+ const ehBase = item.id === baseCardId
555
+ const variaveis = Object.keys(aval.valores_x || {})
556
+ return (
557
+ <article key={item.id} className="avaliacao-beta-card">
558
+ <div className="avaliacao-beta-card-head">
559
+ <div className="avaliacao-beta-card-title">
560
+ <strong>{`Aval. ${idx + 1}`}</strong>
561
+ <span>{item.modelo}</span>
562
+ </div>
563
+ <div className="avaliacao-beta-card-actions">
564
+ <button type="button" className="avaliacao-beta-delete-btn" onClick={() => onExcluirCard(item.id)} disabled={loading}>
565
+ Excluir
566
+ </button>
567
+ </div>
568
+ </div>
569
+
570
+ <div className="avaliacao-beta-card-subtitle">
571
+ {formatarDataHoraIso(item.createdAt)}
572
+ </div>
573
+
574
+ <div className="avaliacao-beta-card-base">
575
+ <strong>Estimado / Base:</strong>{' '}
576
+ {ehBase ? (
577
+ <span className="avaliacao-beta-base-pill">Base</span>
578
+ ) : (
579
+ <span>{calcularComparacaoBase(aval)}</span>
580
+ )}
581
+ </div>
582
+
583
+ <div className="avaliacao-beta-vars-list">
584
+ {variaveis.map((variavel) => (
585
+ <div key={`${item.id}-var-${variavel}`} className="avaliacao-beta-vars-item">
586
+ <span>{variavel}</span>
587
+ <span>
588
+ {formatarNumero(aval?.valores_x?.[variavel], 2)} {classificarExtrapolacao(aval?.extrapolacoes?.[variavel])}
589
+ </span>
590
+ </div>
591
+ ))}
592
+ </div>
593
+
594
+ <div className="avaliacao-beta-metrics">
595
+ <div><strong>Estimado:</strong> {formatarMoeda(aval.estimado)}</div>
596
+ <div><strong>CA -15%:</strong> {formatarMoeda(aval.ca_inf)}</div>
597
+ <div><strong>CA +15%:</strong> {formatarMoeda(aval.ca_sup)}</div>
598
+ <div><strong>IC 80% Inf.:</strong> {formatarMoeda(aval.ic_inf)} ({`-${formatarNumero(aval.perc_inf, 1)}%`})</div>
599
+ <div><strong>IC 80% Sup.:</strong> {formatarMoeda(aval.ic_sup)} ({`+${formatarNumero(aval.perc_sup, 1)}%`})</div>
600
+ <div><strong>Amplitude:</strong> {formatarNumero(aval.amplitude, 1)}%</div>
601
+ <div><strong>Qtd. extrapolações:</strong> {String(aval.qtd_extrapolacoes ?? 0)}</div>
602
+ </div>
603
+
604
+ <div className="avaliacao-beta-graus">
605
+ <span style={{ color: corGrau(aval.precisao) }}>
606
+ <strong>Precisão:</strong> {String(aval.precisao || '-')}
607
+ </span>
608
+ <span style={{ color: corGrau(aval.fundamentacao) }}>
609
+ <strong>Fundamentação:</strong> {String(aval.fundamentacao || '-')}
610
+ </span>
611
+ </div>
612
+ </article>
613
+ )
614
+ })}
615
+ </div>
616
+ </div>
617
+ ) : null}
618
+ </div>
619
+ </>
620
+ )}
621
+ </div>
622
+
623
+ <LoadingOverlay show={loading} label="Processando dados..." />
624
+ {error ? <div className="error-line">{error}</div> : null}
625
+ </div>
626
+ )
627
+ }
frontend/src/components/InicioTab.jsx CHANGED
@@ -10,6 +10,7 @@ export default function InicioTab() {
10
  <li><strong>Elaboração/Edição:</strong> cria, ajusta e exporta modelos estatísticos.</li>
11
  <li><strong>Visualização/Avaliação:</strong> abre modelos `.dai`, mostra diagnósticos e permite avaliação.</li>
12
  <li><strong>Repositório:</strong> lista, adiciona e remove modelos do acervo central.</li>
 
13
  </ul>
14
  <p className="inicio-creditos">
15
  Aplicativo criado por Guilherme Silberfarb Costa e David Schuch Bertoglio.
 
10
  <li><strong>Elaboração/Edição:</strong> cria, ajusta e exporta modelos estatísticos.</li>
11
  <li><strong>Visualização/Avaliação:</strong> abre modelos `.dai`, mostra diagnósticos e permite avaliação.</li>
12
  <li><strong>Repositório:</strong> lista, adiciona e remove modelos do acervo central.</li>
13
+ <li><strong>Avaliação Beta:</strong> compara avaliações de modelos diferentes em cards no mesmo painel.</li>
14
  </ul>
15
  <p className="inicio-creditos">
16
  Aplicativo criado por Guilherme Silberfarb Costa e David Schuch Bertoglio.
frontend/src/components/MapFrame.jsx CHANGED
@@ -40,31 +40,50 @@ export default function MapFrame({ html }) {
40
  if (!map) return
41
 
42
  map.invalidateSize(true)
43
- const bounds = win.L.latLngBounds([])
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  map.eachLayer((layer) => {
46
  try {
47
- if (typeof layer.getLatLng === 'function') {
 
 
 
48
  const latlng = layer.getLatLng()
49
  if (latlng && Number.isFinite(latlng.lat) && Number.isFinite(latlng.lng)) {
50
- bounds.extend(latlng)
51
  }
52
  return
53
  }
54
 
55
- if (typeof layer.getBounds === 'function') {
56
- const layerBounds = layer.getBounds()
57
- if (layerBounds && typeof layerBounds.isValid === 'function' && layerBounds.isValid()) {
58
- bounds.extend(layerBounds)
59
  }
 
60
  }
61
  } catch {
62
  // no-op
63
  }
64
  })
65
 
66
- if (bounds.isValid()) {
67
- map.fitBounds(bounds, { padding: [20, 20], maxZoom: 17, animate: false })
 
 
 
 
68
  }
69
  } catch {
70
  // no-op
 
40
  if (!map) return
41
 
42
  map.invalidateSize(true)
43
+ const dataBounds = win.L.latLngBounds([])
44
+
45
+ const isBairroLayer = (layer) => {
46
+ try {
47
+ const nome = String(layer?.options?.name || layer?.layerName || '').toLowerCase()
48
+ if (nome.includes('bairro')) return true
49
+ const featureName = String(layer?.feature?.properties?.NOME || layer?.feature?.properties?.BAIRRO || '').toLowerCase()
50
+ return featureName.length > 0
51
+ } catch {
52
+ return false
53
+ }
54
+ }
55
 
56
  map.eachLayer((layer) => {
57
  try {
58
+ if (layer instanceof win.L.TileLayer) return
59
+ if (isBairroLayer(layer)) return
60
+
61
+ if (layer instanceof win.L.CircleMarker || layer instanceof win.L.Marker) {
62
  const latlng = layer.getLatLng()
63
  if (latlng && Number.isFinite(latlng.lat) && Number.isFinite(latlng.lng)) {
64
+ dataBounds.extend(latlng)
65
  }
66
  return
67
  }
68
 
69
+ if (layer instanceof win.L.Rectangle) {
70
+ const rectBounds = layer.getBounds()
71
+ if (rectBounds && typeof rectBounds.isValid === 'function' && rectBounds.isValid()) {
72
+ dataBounds.extend(rectBounds)
73
  }
74
+ return
75
  }
76
  } catch {
77
  // no-op
78
  }
79
  })
80
 
81
+ if (dataBounds.isValid()) {
82
+ const size = map.getSize ? map.getSize() : null
83
+ const basePadding = size
84
+ ? Math.max(34, Math.min(84, Math.round(Math.min(size.x, size.y) * 0.085)))
85
+ : 48
86
+ map.fitBounds(dataBounds, { padding: [basePadding, basePadding], maxZoom: 18, animate: false })
87
  }
88
  } catch {
89
  // no-op
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -1,6 +1,10 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
- import { api } from '../api'
 
 
 
3
  import MapFrame from './MapFrame'
 
4
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
5
  import SectionBlock from './SectionBlock'
6
 
@@ -24,6 +28,17 @@ const RESULT_INITIAL = {
24
  total_geral: 0,
25
  }
26
 
 
 
 
 
 
 
 
 
 
 
 
27
  const TIPO_SIGLAS = {
28
  RECOND: 'Residencia em condominio',
29
  RCOMD: 'Residencia em condominio',
@@ -437,7 +452,7 @@ function ChipAutocompleteInput({
437
  )
438
  }
439
 
440
- export default function PesquisaTab() {
441
  const [loading, setLoading] = useState(false)
442
  const [error, setError] = useState('')
443
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
@@ -447,7 +462,6 @@ export default function PesquisaTab() {
447
  const [result, setResult] = useState(RESULT_INITIAL)
448
 
449
  const [selectedIds, setSelectedIds] = useState([])
450
- const [detailModelId, setDetailModelId] = useState('')
451
  const selectAllRef = useRef(null)
452
 
453
  const [mapaLoading, setMapaLoading] = useState(false)
@@ -456,6 +470,27 @@ export default function PesquisaTab() {
456
  const [mapaHtml, setMapaHtml] = useState('')
457
  const [mapaLegendas, setMapaLegendas] = useState([])
458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  const sugestoes = result.sugestoes || {}
460
  const opcoesTipoModelo = useMemo(
461
  () => [...new Set((sugestoes.tipos_modelo || []).map((item) => String(item || '').trim()).filter(Boolean))]
@@ -463,7 +498,7 @@ export default function PesquisaTab() {
463
  [sugestoes.tipos_modelo],
464
  )
465
  const resultIds = useMemo(() => (result.modelos || []).map((modelo) => modelo.id), [result.modelos])
466
- const detalheModelo = useMemo(() => (result.modelos || []).find((modelo) => modelo.id === detailModelId) || null, [result.modelos, detailModelId])
467
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
468
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
469
 
@@ -530,24 +565,6 @@ export default function PesquisaTab() {
530
  selectAllRef.current.indeterminate = algunsSelecionados && !todosSelecionados
531
  }, [algunsSelecionados, todosSelecionados])
532
 
533
- useEffect(() => {
534
- if (!detailModelId) return
535
- if (!detalheModelo) {
536
- setDetailModelId('')
537
- }
538
- }, [detailModelId, detalheModelo])
539
-
540
- useEffect(() => {
541
- if (!detailModelId) return undefined
542
- function onEsc(event) {
543
- if (event.key === 'Escape') {
544
- setDetailModelId('')
545
- }
546
- }
547
- window.addEventListener('keydown', onEsc)
548
- return () => window.removeEventListener('keydown', onEsc)
549
- }, [detailModelId])
550
-
551
  function onFieldChange(event) {
552
  const { value, dataset, name } = event.target
553
  const field = dataset.field || name
@@ -581,12 +598,77 @@ export default function PesquisaTab() {
581
  })
582
  }
583
 
584
- function onAbrirDetalhes(modeloId) {
585
- setDetailModelId(modeloId)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  }
587
 
588
- function onFecharDetalhes() {
589
- setDetailModelId('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
  }
591
 
592
  async function onGerarMapaSelecionados() {
@@ -617,6 +699,103 @@ export default function PesquisaTab() {
617
  await carregarContextoInicial()
618
  }
619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  return (
621
  <div className="tab-content">
622
  <SectionBlock
@@ -791,27 +970,21 @@ export default function PesquisaTab() {
791
  <article key={modelo.id} className={`pesquisa-card${selecionado ? ' is-selected' : ''}`}>
792
  <div className="pesquisa-card-top">
793
  <div className="pesquisa-card-head">
794
- <div className="pesquisa-card-head-main">
795
- <h4>{modelo.nome_modelo || modelo.arquivo}</h4>
796
- <div className="pesquisa-card-head-actions">
797
- <label className="pesquisa-select-toggle">
798
- <input
799
- type="checkbox"
800
- checked={selecionado}
801
- onChange={() => onToggleSelecionado(modelo.id)}
802
- />
803
- Selecionar
804
- </label>
805
- <button type="button" className="btn-pesquisa-expand" onClick={() => onAbrirDetalhes(modelo.id)}>
806
- Ver mais
807
- </button>
808
- </div>
809
  </div>
810
  </div>
811
-
812
- <div className="pesquisa-card-status-row">
813
- <div className="status-pill done">Aceito para o avaliando ({modelo.avaliando?.campos_informados || 0} campo(s) validado(s))</div>
814
- </div>
815
  <div className="pesquisa-card-body">
816
  <div className="pesquisa-card-dados-list">
817
  <div><strong>Finalidades no modelo:</strong> {(modelo.finalidades || []).length ? modelo.finalidades.join(', ') : '-'}</div>
@@ -862,62 +1035,7 @@ export default function PesquisaTab() {
862
  {mapaHtml ? <MapFrame html={mapaHtml} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
863
  </SectionBlock>
864
 
865
- {detalheModelo ? (
866
- <div className="pesquisa-modal-backdrop" role="presentation" onClick={onFecharDetalhes}>
867
- <div className="pesquisa-modal" role="dialog" aria-modal="true" aria-labelledby={`pesquisa-modal-title-${detalheModelo.id}`} onClick={(event) => event.stopPropagation()}>
868
- <div className="pesquisa-modal-head">
869
- <div>
870
- <h4 id={`pesquisa-modal-title-${detalheModelo.id}`}>{detalheModelo.nome_modelo || detalheModelo.arquivo}</h4>
871
- <p>{detalheModelo.arquivo}</p>
872
- </div>
873
- <button type="button" className="pesquisa-modal-close" onClick={onFecharDetalhes}>
874
- Fechar
875
- </button>
876
- </div>
877
-
878
- <div className="pesquisa-modal-body">
879
- <div className="pesquisa-card-kpis">
880
- <span><strong>Faixa area:</strong> {formatRange(detalheModelo.faixa_area)}</span>
881
- <span><strong>Faixa RH:</strong> {formatRange(detalheModelo.faixa_rh)}</span>
882
- <span><strong>Faixa data:</strong> {formatRange(detalheModelo.faixa_data)}</span>
883
- </div>
884
-
885
- <div className="pesquisa-compare-block">
886
- <h5>Equacao</h5>
887
- <div className="equation-box">{detalheModelo.equacao || 'Nao disponivel no modelo.'}</div>
888
- </div>
889
-
890
- <div className="pesquisa-compare-block">
891
- <h5>Faixas de variaveis (min/max)</h5>
892
- {detalheModelo.variaveis_resumo?.length ? (
893
- <div className="table-wrapper pesquisa-variaveis-table">
894
- <table>
895
- <thead>
896
- <tr>
897
- <th>Variavel</th>
898
- <th>Min</th>
899
- <th>Max</th>
900
- </tr>
901
- </thead>
902
- <tbody>
903
- {detalheModelo.variaveis_resumo.map((item) => (
904
- <tr key={`${detalheModelo.id}-${item.variavel}`}>
905
- <td>{item.variavel}</td>
906
- <td>{item.min ?? '-'}</td>
907
- <td>{item.max ?? '-'}</td>
908
- </tr>
909
- ))}
910
- </tbody>
911
- </table>
912
- </div>
913
- ) : (
914
- <div className="empty-box">Sem estatisticas suficientes para listar variaveis.</div>
915
- )}
916
- </div>
917
- </div>
918
- </div>
919
- </div>
920
- ) : null}
921
  </div>
922
  )
923
  }
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { api, downloadBlob } from '../api'
3
+ import DataTable from './DataTable'
4
+ import EquationFormatsPanel from './EquationFormatsPanel'
5
+ import LoadingOverlay from './LoadingOverlay'
6
  import MapFrame from './MapFrame'
7
+ import PlotFigure from './PlotFigure'
8
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
9
  import SectionBlock from './SectionBlock'
10
 
 
28
  total_geral: 0,
29
  }
30
 
31
+ const PESQUISA_INNER_TABS = [
32
+ { key: 'mapa', label: 'Mapa' },
33
+ { key: 'dados_mercado', label: 'Dados de Mercado' },
34
+ { key: 'metricas', label: 'Métricas' },
35
+ { key: 'transformacoes', label: 'Transformações' },
36
+ { key: 'resumo', label: 'Resumo' },
37
+ { key: 'coeficientes', label: 'Coeficientes' },
38
+ { key: 'obs_calc', label: 'Obs x Calc' },
39
+ { key: 'graficos', label: 'Gráficos' },
40
+ ]
41
+
42
  const TIPO_SIGLAS = {
43
  RECOND: 'Residencia em condominio',
44
  RCOMD: 'Residencia em condominio',
 
452
  )
453
  }
454
 
455
+ export default function PesquisaTab({ sessionId }) {
456
  const [loading, setLoading] = useState(false)
457
  const [error, setError] = useState('')
458
  const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
 
462
  const [result, setResult] = useState(RESULT_INITIAL)
463
 
464
  const [selectedIds, setSelectedIds] = useState([])
 
465
  const selectAllRef = useRef(null)
466
 
467
  const [mapaLoading, setMapaLoading] = useState(false)
 
470
  const [mapaHtml, setMapaHtml] = useState('')
471
  const [mapaLegendas, setMapaLegendas] = useState([])
472
 
473
+ const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
474
+ const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
475
+ const [modeloAbertoError, setModeloAbertoError] = useState('')
476
+ const [modeloAbertoActiveTab, setModeloAbertoActiveTab] = useState('mapa')
477
+ const [modeloAbertoDados, setModeloAbertoDados] = useState(null)
478
+ const [modeloAbertoEstatisticas, setModeloAbertoEstatisticas] = useState(null)
479
+ const [modeloAbertoEscalasHtml, setModeloAbertoEscalasHtml] = useState('')
480
+ const [modeloAbertoDadosTransformados, setModeloAbertoDadosTransformados] = useState(null)
481
+ const [modeloAbertoResumoHtml, setModeloAbertoResumoHtml] = useState('')
482
+ const [modeloAbertoEquacoes, setModeloAbertoEquacoes] = useState(null)
483
+ const [modeloAbertoCoeficientes, setModeloAbertoCoeficientes] = useState(null)
484
+ const [modeloAbertoObsCalc, setModeloAbertoObsCalc] = useState(null)
485
+ const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
486
+ const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
487
+ const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
488
+ const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
489
+ const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
490
+ const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
491
+ const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
492
+ const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
493
+
494
  const sugestoes = result.sugestoes || {}
495
  const opcoesTipoModelo = useMemo(
496
  () => [...new Set((sugestoes.tipos_modelo || []).map((item) => String(item || '').trim()).filter(Boolean))]
 
498
  [sugestoes.tipos_modelo],
499
  )
500
  const resultIds = useMemo(() => (result.modelos || []).map((modelo) => modelo.id), [result.modelos])
501
+ const modoModeloAberto = Boolean(modeloAbertoMeta)
502
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
503
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
504
 
 
565
  selectAllRef.current.indeterminate = algunsSelecionados && !todosSelecionados
566
  }, [algunsSelecionados, todosSelecionados])
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  function onFieldChange(event) {
569
  const { value, dataset, name } = event.target
570
  const field = dataset.field || name
 
598
  })
599
  }
600
 
601
+ function preencherModeloAberto(resp) {
602
+ setModeloAbertoDados(resp?.dados || null)
603
+ setModeloAbertoEstatisticas(resp?.estatisticas || null)
604
+ setModeloAbertoEscalasHtml(resp?.escalas_html || '')
605
+ setModeloAbertoDadosTransformados(resp?.dados_transformados || null)
606
+ setModeloAbertoResumoHtml(resp?.resumo_html || '')
607
+ setModeloAbertoEquacoes(resp?.equacoes || null)
608
+ setModeloAbertoCoeficientes(resp?.coeficientes || null)
609
+ setModeloAbertoObsCalc(resp?.obs_calc || null)
610
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
611
+ setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
612
+ setModeloAbertoMapaVar('Visualização Padrão')
613
+ setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
614
+ setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
615
+ setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
616
+ setModeloAbertoPlotCook(resp?.grafico_cook || null)
617
+ setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
618
  }
619
 
620
+ async function onAbrirModelo(modelo) {
621
+ if (!sessionId) {
622
+ setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
623
+ return
624
+ }
625
+ setModeloAbertoLoading(true)
626
+ setModeloAbertoError('')
627
+ try {
628
+ await api.visualizacaoRepositorioCarregar(sessionId, modelo.id)
629
+ const resp = await api.exibirVisualizacao(sessionId)
630
+ preencherModeloAberto(resp)
631
+ setModeloAbertoActiveTab('mapa')
632
+ setModeloAbertoMeta({
633
+ id: modelo.id,
634
+ nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
635
+ })
636
+ } catch (err) {
637
+ setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
638
+ } finally {
639
+ setModeloAbertoLoading(false)
640
+ }
641
+ }
642
+
643
+ function onVoltarPesquisa() {
644
+ setModeloAbertoMeta(null)
645
+ setModeloAbertoError('')
646
+ setModeloAbertoActiveTab('mapa')
647
+ }
648
+
649
+ async function onModeloAbertoMapChange(nextVar) {
650
+ setModeloAbertoMapaVar(nextVar)
651
+ if (!sessionId) return
652
+ try {
653
+ const resp = await api.updateVisualizacaoMap(sessionId, nextVar)
654
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
655
+ } catch (err) {
656
+ setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
657
+ }
658
+ }
659
+
660
+ async function onDownloadEquacaoModeloAberto(mode) {
661
+ if (!sessionId || !mode) return
662
+ setModeloAbertoLoading(true)
663
+ try {
664
+ const blob = await api.exportEquationViz(sessionId, mode)
665
+ const sufixo = String(mode) === 'excel_sab' ? 'estilo_sab' : 'excel'
666
+ downloadBlob(blob, `equacao_modelo_${sufixo}.xlsx`)
667
+ } catch (err) {
668
+ setModeloAbertoError(err.message || 'Falha ao exportar equação.')
669
+ } finally {
670
+ setModeloAbertoLoading(false)
671
+ }
672
  }
673
 
674
  async function onGerarMapaSelecionados() {
 
699
  await carregarContextoInicial()
700
  }
701
 
702
+ if (modoModeloAberto) {
703
+ return (
704
+ <div className="tab-content">
705
+ <div className="pesquisa-opened-model-view">
706
+ <div className="pesquisa-opened-model-head">
707
+ <div className="pesquisa-opened-model-title-wrap">
708
+ <h3>{modeloAbertoMeta?.nome || 'Modelo'}</h3>
709
+ <p>Aceito para o avaliando</p>
710
+ </div>
711
+ <button
712
+ type="button"
713
+ className="model-source-back-btn model-source-back-btn-danger"
714
+ onClick={onVoltarPesquisa}
715
+ disabled={modeloAbertoLoading}
716
+ >
717
+ Voltar para pesquisa
718
+ </button>
719
+ </div>
720
+
721
+ <div className="inner-tabs" role="tablist" aria-label="Abas internas do modelo aberto na pesquisa">
722
+ {PESQUISA_INNER_TABS.map((tab) => (
723
+ <button
724
+ key={tab.key}
725
+ type="button"
726
+ className={modeloAbertoActiveTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
727
+ onClick={() => setModeloAbertoActiveTab(tab.key)}
728
+ >
729
+ {tab.label}
730
+ </button>
731
+ ))}
732
+ </div>
733
+
734
+ <div className="inner-tab-panel">
735
+ {modeloAbertoActiveTab === 'mapa' ? (
736
+ <>
737
+ <div className="row compact visualizacao-mapa-controls">
738
+ <label>Variável no mapa</label>
739
+ <select value={modeloAbertoMapaVar} onChange={(event) => void onModeloAbertoMapChange(event.target.value)}>
740
+ {modeloAbertoMapaChoices.map((choice) => (
741
+ <option key={`modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
742
+ ))}
743
+ </select>
744
+ </div>
745
+ <MapFrame html={modeloAbertoMapaHtml} />
746
+ </>
747
+ ) : null}
748
+
749
+ {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
750
+ {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
751
+
752
+ {modeloAbertoActiveTab === 'transformacoes' ? (
753
+ <>
754
+ <div dangerouslySetInnerHTML={{ __html: modeloAbertoEscalasHtml }} />
755
+ <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
756
+ <DataTable table={modeloAbertoDadosTransformados} />
757
+ </>
758
+ ) : null}
759
+
760
+ {modeloAbertoActiveTab === 'resumo' ? (
761
+ <>
762
+ <div className="equation-formats-section">
763
+ <h4>Equações do Modelo</h4>
764
+ <EquationFormatsPanel
765
+ equacoes={modeloAbertoEquacoes}
766
+ onDownload={(mode) => void onDownloadEquacaoModeloAberto(mode)}
767
+ disabled={modeloAbertoLoading}
768
+ />
769
+ </div>
770
+ <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
771
+ </>
772
+ ) : null}
773
+
774
+ {modeloAbertoActiveTab === 'coeficientes' ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : null}
775
+ {modeloAbertoActiveTab === 'obs_calc' ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : null}
776
+
777
+ {modeloAbertoActiveTab === 'graficos' ? (
778
+ <>
779
+ <div className="plot-grid-2-fixed">
780
+ <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
781
+ <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
782
+ <PlotFigure figure={modeloAbertoPlotHistograma} title="Histograma" />
783
+ <PlotFigure figure={modeloAbertoPlotCook} title="Cook" forceHideLegend />
784
+ </div>
785
+ <div className="plot-full-width">
786
+ <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
787
+ </div>
788
+ </>
789
+ ) : null}
790
+ </div>
791
+
792
+ {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
793
+ </div>
794
+ <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
795
+ </div>
796
+ )
797
+ }
798
+
799
  return (
800
  <div className="tab-content">
801
  <SectionBlock
 
970
  <article key={modelo.id} className={`pesquisa-card${selecionado ? ' is-selected' : ''}`}>
971
  <div className="pesquisa-card-top">
972
  <div className="pesquisa-card-head">
973
+ <h4>{modelo.nome_modelo || modelo.arquivo}</h4>
974
+ <div className="pesquisa-card-controls">
975
+ <label className="pesquisa-select-toggle pesquisa-select-toggle-compact">
976
+ <input
977
+ type="checkbox"
978
+ checked={selecionado}
979
+ onChange={() => onToggleSelecionado(modelo.id)}
980
+ />
981
+ Selecionar
982
+ </label>
983
+ <button type="button" className="btn-pesquisa-open" onClick={() => void onAbrirModelo(modelo)}>
984
+ Abrir
985
+ </button>
 
 
986
  </div>
987
  </div>
 
 
 
 
988
  <div className="pesquisa-card-body">
989
  <div className="pesquisa-card-dados-list">
990
  <div><strong>Finalidades no modelo:</strong> {(modelo.finalidades || []).length ? modelo.finalidades.join(', ') : '-'}</div>
 
1035
  {mapaHtml ? <MapFrame html={mapaHtml} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
1036
  </SectionBlock>
1037
 
1038
+ <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1039
  </div>
1040
  )
1041
  }
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -1,6 +1,21 @@
1
- import React, { useEffect, useMemo, useState } from 'react'
2
- import { api } from '../api'
3
- import SectionBlock from './SectionBlock'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  function formatarFonte(fonte) {
6
  if (!fonte || typeof fonte !== 'object') return 'Fonte não informada'
@@ -14,7 +29,7 @@ function formatarFonte(fonte) {
14
  return 'Pasta local'
15
  }
16
 
17
- export default function RepositorioTab({ authUser }) {
18
  const [modelos, setModelos] = useState([])
19
  const [fonte, setFonte] = useState(null)
20
  const [loading, setLoading] = useState(false)
@@ -22,27 +37,49 @@ export default function RepositorioTab({ authUser }) {
22
  const [deleting, setDeleting] = useState(false)
23
  const [error, setError] = useState('')
24
  const [status, setStatus] = useState('')
25
- const [selecionados, setSelecionados] = useState([])
26
- const [arquivos, setArquivos] = useState([])
27
- const [confirmDelete, setConfirmDelete] = useState(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
30
  const totalModelos = modelos.length
31
- const totalSelecionados = selecionados.length
32
-
33
- const todosSelecionados = useMemo(() => {
34
- if (!modelos.length) return false
35
- return modelos.every((item) => selecionados.includes(String(item.id)))
36
- }, [modelos, selecionados])
37
 
38
  useEffect(() => {
39
  void carregarModelos()
40
  }, [])
41
 
42
- useEffect(() => {
43
- setSelecionados((prev) => prev.filter((id) => modelos.some((item) => String(item.id) === String(id))))
44
- }, [modelos])
45
-
46
  async function carregarModelos() {
47
  setLoading(true)
48
  setError('')
@@ -60,61 +97,253 @@ export default function RepositorioTab({ authUser }) {
60
  }
61
  }
62
 
63
- function onToggleSelecionado(id) {
64
- const key = String(id)
65
- setSelecionados((prev) => (prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key]))
66
- setConfirmDelete(false)
 
 
 
67
  }
68
 
69
- function onToggleTodos(event) {
70
- if (event.target.checked) {
71
- setSelecionados(modelos.map((item) => String(item.id)))
72
- } else {
73
- setSelecionados([])
74
- }
75
- setConfirmDelete(false)
76
- }
77
-
78
- async function onUploadArquivos() {
79
- if (!isAdmin || arquivos.length === 0) return
80
  setUploading(true)
81
  setError('')
82
  setStatus('')
83
  try {
84
- const resp = await api.repositorioUpload(arquivos)
85
  setModelos(Array.isArray(resp?.modelos) ? resp.modelos : [])
86
  setFonte(resp?.fonte || null)
87
- setStatus(resp?.status || 'Upload concluído.')
88
- setArquivos([])
 
89
  } catch (err) {
90
- setError(err.message || 'Falha no upload dos modelos.')
 
 
 
 
 
 
 
91
  } finally {
92
  setUploading(false)
93
  }
94
  }
95
 
96
- async function onExcluirSelecionados() {
97
- if (!isAdmin || selecionados.length === 0) return
98
  setDeleting(true)
99
  setError('')
100
  setStatus('')
101
  try {
102
- const resp = await api.repositorioDelete(selecionados)
103
  setModelos(Array.isArray(resp?.modelos) ? resp.modelos : [])
104
  setFonte(resp?.fonte || null)
105
- setStatus(resp?.status || 'Modelos removidos.')
106
- setSelecionados([])
107
- setConfirmDelete(false)
108
  } catch (err) {
109
- setError(err.message || 'Falha na exclusão dos modelos.')
110
  } finally {
111
  setDeleting(false)
112
  }
113
  }
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  return (
116
  <div className="tab-content">
117
- <SectionBlock step="1" title="Repositório de Modelos" subtitle="Visualização, upload e remoção de modelos .dai.">
118
  <div className="repo-toolbar">
119
  <div className="repo-summary">
120
  <div><strong>Total:</strong> {totalModelos}</div>
@@ -122,7 +351,12 @@ export default function RepositorioTab({ authUser }) {
122
  <div><strong>Perfil:</strong> {isAdmin ? 'Administrador' : 'Leitura'}</div>
123
  </div>
124
  <div className="repo-actions">
125
- <button type="button" onClick={() => void carregarModelos()} disabled={loading || uploading || deleting}>
 
 
 
 
 
126
  Atualizar lista
127
  </button>
128
  </div>
@@ -130,50 +364,30 @@ export default function RepositorioTab({ authUser }) {
130
 
131
  {isAdmin ? (
132
  <div className="repo-admin-controls">
133
- <div className="repo-upload-row">
134
  <input
135
  type="file"
136
- multiple
137
  accept=".dai"
138
- onChange={(event) => setArquivos(Array.from(event.target.files || []))}
139
  disabled={uploading || deleting}
140
  />
141
- <button type="button" onClick={() => void onUploadArquivos()} disabled={uploading || deleting || arquivos.length === 0}>
142
- Enviar modelos
143
  </button>
144
  </div>
145
- <div className="repo-delete-row">
146
- <button
147
- type="button"
148
- onClick={() => setConfirmDelete((prev) => !prev)}
149
- disabled={uploading || deleting || totalSelecionados === 0}
150
- >
151
- Excluir selecionados
152
- </button>
153
- {confirmDelete ? (
154
- <div className="repo-delete-confirm">
155
- <span>Confirmar exclusão de {totalSelecionados} modelo(s)?</span>
156
- <button type="button" className="btn-danger" onClick={() => void onExcluirSelecionados()} disabled={deleting}>
157
- Confirmar exclusão
158
- </button>
159
- </div>
160
- ) : null}
161
- </div>
162
  </div>
163
  ) : (
164
- <div className="section1-empty-hint">Perfil de leitura: upload e exclusão disponíveis apenas para administradores.</div>
165
  )}
166
 
167
  {status ? <div className="status-line">{status}</div> : null}
168
  {error ? <div className="error-line">{error}</div> : null}
169
 
170
- <div className="table-container">
171
  <table className="repo-table">
172
  <thead>
173
  <tr>
174
- <th>
175
- <input type="checkbox" checked={todosSelecionados} onChange={onToggleTodos} aria-label="Selecionar todos" />
176
- </th>
177
  <th>Modelo</th>
178
  <th>Tipo</th>
179
  <th>Finalidade</th>
@@ -182,16 +396,16 @@ export default function RepositorioTab({ authUser }) {
182
  <th>Dados</th>
183
  <th>APP</th>
184
  <th>Status</th>
 
 
185
  </tr>
186
  </thead>
187
  <tbody>
188
  {modelos.map((item) => {
189
  const key = String(item.id)
 
190
  return (
191
  <tr key={key}>
192
- <td>
193
- <input type="checkbox" checked={selecionados.includes(key)} onChange={() => onToggleSelecionado(key)} />
194
- </td>
195
  <td>{item.nome_modelo || item.arquivo || key}</td>
196
  <td>{item.tipo_imovel || '-'}</td>
197
  <td>{item.finalidade || '-'}</td>
@@ -200,18 +414,172 @@ export default function RepositorioTab({ authUser }) {
200
  <td>{item.total_dados ?? '-'}</td>
201
  <td>{item.tem_app ? 'Sim' : 'Não'}</td>
202
  <td>{item.status || '-'}</td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  </tr>
204
  )
205
  })}
206
  {!modelos.length ? (
207
  <tr>
208
- <td colSpan={9}>{loading ? 'Carregando modelos...' : 'Nenhum modelo encontrado no repositório.'}</td>
 
 
209
  </tr>
210
  ) : null}
211
  </tbody>
212
  </table>
213
  </div>
214
- </SectionBlock>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  </div>
216
  )
217
  }
 
1
+ import React, { useEffect, useState } from 'react'
2
+ import { api, downloadBlob } from '../api'
3
+ import DataTable from './DataTable'
4
+ import EquationFormatsPanel from './EquationFormatsPanel'
5
+ import LoadingOverlay from './LoadingOverlay'
6
+ import MapFrame from './MapFrame'
7
+ import PlotFigure from './PlotFigure'
8
+
9
+ const REPO_INNER_TABS = [
10
+ { key: 'mapa', label: 'Mapa' },
11
+ { key: 'dados_mercado', label: 'Dados de Mercado' },
12
+ { key: 'metricas', label: 'Métricas' },
13
+ { key: 'transformacoes', label: 'Transformações' },
14
+ { key: 'resumo', label: 'Resumo' },
15
+ { key: 'coeficientes', label: 'Coeficientes' },
16
+ { key: 'obs_calc', label: 'Obs x Calc' },
17
+ { key: 'graficos', label: 'Gráficos' },
18
+ ]
19
 
20
  function formatarFonte(fonte) {
21
  if (!fonte || typeof fonte !== 'object') return 'Fonte não informada'
 
29
  return 'Pasta local'
30
  }
31
 
32
+ export default function RepositorioTab({ authUser, sessionId }) {
33
  const [modelos, setModelos] = useState([])
34
  const [fonte, setFonte] = useState(null)
35
  const [loading, setLoading] = useState(false)
 
37
  const [deleting, setDeleting] = useState(false)
38
  const [error, setError] = useState('')
39
  const [status, setStatus] = useState('')
40
+ const [arquivoUpload, setArquivoUpload] = useState(null)
41
+ const [confirmDeleteId, setConfirmDeleteId] = useState('')
42
+ const [confirmarSubstituicao, setConfirmarSubstituicao] = useState({ open: false, substituidos: [] })
43
+ const [confirmarExclusaoModal, setConfirmarExclusaoModal] = useState({
44
+ open: false,
45
+ modeloId: '',
46
+ nomeModelo: '',
47
+ digitado: '',
48
+ })
49
+
50
+ const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
51
+ const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
52
+ const [modeloAbertoError, setModeloAbertoError] = useState('')
53
+ const [modeloAbertoActiveTab, setModeloAbertoActiveTab] = useState('mapa')
54
+ const [modeloAbertoDados, setModeloAbertoDados] = useState(null)
55
+ const [modeloAbertoEstatisticas, setModeloAbertoEstatisticas] = useState(null)
56
+ const [modeloAbertoEscalasHtml, setModeloAbertoEscalasHtml] = useState('')
57
+ const [modeloAbertoDadosTransformados, setModeloAbertoDadosTransformados] = useState(null)
58
+ const [modeloAbertoResumoHtml, setModeloAbertoResumoHtml] = useState('')
59
+ const [modeloAbertoEquacoes, setModeloAbertoEquacoes] = useState(null)
60
+ const [modeloAbertoCoeficientes, setModeloAbertoCoeficientes] = useState(null)
61
+ const [modeloAbertoObsCalc, setModeloAbertoObsCalc] = useState(null)
62
+ const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
63
+ const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
64
+ const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
65
+ const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
66
+ const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
67
+ const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
68
+ const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
69
+ const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
70
 
71
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
72
  const totalModelos = modelos.length
73
+ const modoModeloAberto = Boolean(modeloAbertoMeta)
74
+ const nomesSubstituidos = Array.isArray(confirmarSubstituicao.substituidos)
75
+ ? confirmarSubstituicao.substituidos.filter(Boolean)
76
+ : []
77
+ const exclusaoDigitadaCorreta = confirmarExclusaoModal.digitado === confirmarExclusaoModal.nomeModelo
 
78
 
79
  useEffect(() => {
80
  void carregarModelos()
81
  }, [])
82
 
 
 
 
 
83
  async function carregarModelos() {
84
  setLoading(true)
85
  setError('')
 
97
  }
98
  }
99
 
100
+ function parseSubstituidosErro(err) {
101
+ if (!err || typeof err !== 'object') return []
102
+ const detail = err.detail
103
+ if (!detail || typeof detail !== 'object') return []
104
+ const lista = detail.substituidos
105
+ if (!Array.isArray(lista)) return []
106
+ return lista.map((item) => String(item || '').trim()).filter(Boolean)
107
  }
108
 
109
+ async function onUploadArquivo(confirmar = false) {
110
+ if (!isAdmin || !arquivoUpload) return
 
 
 
 
 
 
 
 
 
111
  setUploading(true)
112
  setError('')
113
  setStatus('')
114
  try {
115
+ const resp = await api.repositorioUpload([arquivoUpload], { confirmarSubstituicao: confirmar })
116
  setModelos(Array.isArray(resp?.modelos) ? resp.modelos : [])
117
  setFonte(resp?.fonte || null)
118
+ setStatus(resp?.status || 'Modelo incluído no repositório.')
119
+ setArquivoUpload(null)
120
+ setConfirmarSubstituicao({ open: false, substituidos: [] })
121
  } catch (err) {
122
+ const substituidos = parseSubstituidosErro(err)
123
+ const erroDuplicado = Number(err?.status) === 409 && substituidos.length > 0
124
+ if (!confirmar && erroDuplicado) {
125
+ setConfirmarSubstituicao({ open: true, substituidos })
126
+ setError('')
127
+ return
128
+ }
129
+ setError(err.message || 'Falha ao incluir modelo no repositório.')
130
  } finally {
131
  setUploading(false)
132
  }
133
  }
134
 
135
+ async function onExcluirModelo(modeloId) {
136
+ if (!isAdmin || !modeloId) return
137
  setDeleting(true)
138
  setError('')
139
  setStatus('')
140
  try {
141
+ const resp = await api.repositorioDelete([String(modeloId)])
142
  setModelos(Array.isArray(resp?.modelos) ? resp.modelos : [])
143
  setFonte(resp?.fonte || null)
144
+ setStatus(resp?.status || 'Modelo removido do repositório.')
145
+ setConfirmDeleteId('')
146
+ setConfirmarExclusaoModal({ open: false, modeloId: '', nomeModelo: '', digitado: '' })
147
  } catch (err) {
148
+ setError(err.message || 'Falha na exclusão do modelo.')
149
  } finally {
150
  setDeleting(false)
151
  }
152
  }
153
 
154
+ function onAbrirModalExclusao(item) {
155
+ const id = String(item?.id || '').trim()
156
+ if (!id) return
157
+ const nomeModelo = String(item?.nome_modelo || item?.arquivo || id).trim()
158
+ setConfirmDeleteId('')
159
+ setConfirmarExclusaoModal({
160
+ open: true,
161
+ modeloId: id,
162
+ nomeModelo,
163
+ digitado: '',
164
+ })
165
+ }
166
+
167
+ function onCancelarModalExclusao() {
168
+ if (deleting) return
169
+ setConfirmarExclusaoModal({ open: false, modeloId: '', nomeModelo: '', digitado: '' })
170
+ }
171
+
172
+ async function onConfirmarExclusaoModal() {
173
+ const modeloId = String(confirmarExclusaoModal.modeloId || '').trim()
174
+ if (!modeloId) return
175
+ if (confirmarExclusaoModal.digitado !== confirmarExclusaoModal.nomeModelo) return
176
+ await onExcluirModelo(modeloId)
177
+ }
178
+
179
+ function preencherModeloAberto(resp) {
180
+ setModeloAbertoDados(resp?.dados || null)
181
+ setModeloAbertoEstatisticas(resp?.estatisticas || null)
182
+ setModeloAbertoEscalasHtml(resp?.escalas_html || '')
183
+ setModeloAbertoDadosTransformados(resp?.dados_transformados || null)
184
+ setModeloAbertoResumoHtml(resp?.resumo_html || '')
185
+ setModeloAbertoEquacoes(resp?.equacoes || null)
186
+ setModeloAbertoCoeficientes(resp?.coeficientes || null)
187
+ setModeloAbertoObsCalc(resp?.obs_calc || null)
188
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
189
+ setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
190
+ setModeloAbertoMapaVar('Visualização Padrão')
191
+ setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
192
+ setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
193
+ setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
194
+ setModeloAbertoPlotCook(resp?.grafico_cook || null)
195
+ setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
196
+ }
197
+
198
+ async function onAbrirModelo(item) {
199
+ if (!sessionId) {
200
+ setError('Sessão indisponível no momento. Aguarde e tente novamente.')
201
+ return
202
+ }
203
+ setModeloAbertoLoading(true)
204
+ setModeloAbertoError('')
205
+ try {
206
+ await api.visualizacaoRepositorioCarregar(sessionId, String(item?.id || ''))
207
+ const resp = await api.exibirVisualizacao(sessionId)
208
+ preencherModeloAberto(resp)
209
+ setModeloAbertoActiveTab('mapa')
210
+ setModeloAbertoMeta({
211
+ id: String(item?.id || ''),
212
+ nome: item?.nome_modelo || item?.arquivo || String(item?.id || ''),
213
+ })
214
+ } catch (err) {
215
+ setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
216
+ } finally {
217
+ setModeloAbertoLoading(false)
218
+ }
219
+ }
220
+
221
+ function onVoltarRepositorio() {
222
+ setModeloAbertoMeta(null)
223
+ setModeloAbertoError('')
224
+ setModeloAbertoActiveTab('mapa')
225
+ }
226
+
227
+ async function onModeloAbertoMapChange(nextVar) {
228
+ setModeloAbertoMapaVar(nextVar)
229
+ if (!sessionId) return
230
+ try {
231
+ const resp = await api.updateVisualizacaoMap(sessionId, nextVar)
232
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
233
+ } catch (err) {
234
+ setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
235
+ }
236
+ }
237
+
238
+ async function onDownloadEquacaoModeloAberto(mode) {
239
+ if (!sessionId || !mode) return
240
+ setModeloAbertoLoading(true)
241
+ try {
242
+ const blob = await api.exportEquationViz(sessionId, mode)
243
+ const sufixo = String(mode) === 'excel_sab' ? 'estilo_sab' : 'excel'
244
+ downloadBlob(blob, `equacao_modelo_${sufixo}.xlsx`)
245
+ } catch (err) {
246
+ setModeloAbertoError(err.message || 'Falha ao exportar equação.')
247
+ } finally {
248
+ setModeloAbertoLoading(false)
249
+ }
250
+ }
251
+
252
+ if (modoModeloAberto) {
253
+ return (
254
+ <div className="tab-content">
255
+ <div className="pesquisa-opened-model-view">
256
+ <div className="pesquisa-opened-model-head">
257
+ <div className="pesquisa-opened-model-title-wrap">
258
+ <h3>{modeloAbertoMeta?.nome || 'Modelo'}</h3>
259
+ <p>Visualização do modelo do repositório</p>
260
+ </div>
261
+ <button type="button" className="model-source-back-btn" onClick={onVoltarRepositorio} disabled={modeloAbertoLoading}>
262
+ Voltar ao repositório
263
+ </button>
264
+ </div>
265
+
266
+ <div className="inner-tabs" role="tablist" aria-label="Abas internas do modelo aberto no repositório">
267
+ {REPO_INNER_TABS.map((tab) => (
268
+ <button
269
+ key={tab.key}
270
+ type="button"
271
+ className={modeloAbertoActiveTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
272
+ onClick={() => setModeloAbertoActiveTab(tab.key)}
273
+ >
274
+ {tab.label}
275
+ </button>
276
+ ))}
277
+ </div>
278
+
279
+ <div className="inner-tab-panel">
280
+ {modeloAbertoActiveTab === 'mapa' ? (
281
+ <>
282
+ <div className="row compact visualizacao-mapa-controls">
283
+ <label>Variável no mapa</label>
284
+ <select value={modeloAbertoMapaVar} onChange={(event) => void onModeloAbertoMapChange(event.target.value)}>
285
+ {modeloAbertoMapaChoices.map((choice) => (
286
+ <option key={`repo-modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
287
+ ))}
288
+ </select>
289
+ </div>
290
+ <MapFrame html={modeloAbertoMapaHtml} />
291
+ </>
292
+ ) : null}
293
+
294
+ {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
295
+ {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
296
+
297
+ {modeloAbertoActiveTab === 'transformacoes' ? (
298
+ <>
299
+ <div dangerouslySetInnerHTML={{ __html: modeloAbertoEscalasHtml }} />
300
+ <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
301
+ <DataTable table={modeloAbertoDadosTransformados} />
302
+ </>
303
+ ) : null}
304
+
305
+ {modeloAbertoActiveTab === 'resumo' ? (
306
+ <>
307
+ <div className="equation-formats-section">
308
+ <h4>Equações do Modelo</h4>
309
+ <EquationFormatsPanel
310
+ equacoes={modeloAbertoEquacoes}
311
+ onDownload={(mode) => void onDownloadEquacaoModeloAberto(mode)}
312
+ disabled={modeloAbertoLoading}
313
+ />
314
+ </div>
315
+ <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
316
+ </>
317
+ ) : null}
318
+
319
+ {modeloAbertoActiveTab === 'coeficientes' ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : null}
320
+ {modeloAbertoActiveTab === 'obs_calc' ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : null}
321
+
322
+ {modeloAbertoActiveTab === 'graficos' ? (
323
+ <>
324
+ <div className="plot-grid-2-fixed">
325
+ <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
326
+ <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
327
+ <PlotFigure figure={modeloAbertoPlotHistograma} title="Histograma" />
328
+ <PlotFigure figure={modeloAbertoPlotCook} title="Cook" forceHideLegend />
329
+ </div>
330
+ <div className="plot-full-width">
331
+ <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
332
+ </div>
333
+ </>
334
+ ) : null}
335
+ </div>
336
+
337
+ {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
338
+ </div>
339
+ <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
340
+ </div>
341
+ )
342
+ }
343
+
344
  return (
345
  <div className="tab-content">
346
+ <div className="repositorio-standalone-panel">
347
  <div className="repo-toolbar">
348
  <div className="repo-summary">
349
  <div><strong>Total:</strong> {totalModelos}</div>
 
351
  <div><strong>Perfil:</strong> {isAdmin ? 'Administrador' : 'Leitura'}</div>
352
  </div>
353
  <div className="repo-actions">
354
+ <button
355
+ type="button"
356
+ className="repo-refresh-btn"
357
+ onClick={() => void carregarModelos()}
358
+ disabled={loading || uploading || deleting}
359
+ >
360
  Atualizar lista
361
  </button>
362
  </div>
 
364
 
365
  {isAdmin ? (
366
  <div className="repo-admin-controls">
367
+ <div className="repo-upload-row repo-upload-row-single">
368
  <input
369
  type="file"
 
370
  accept=".dai"
371
+ onChange={(event) => setArquivoUpload(event.target.files?.[0] || null)}
372
  disabled={uploading || deleting}
373
  />
374
+ <button type="button" onClick={() => void onUploadArquivo()} disabled={uploading || deleting || !arquivoUpload}>
375
+ Incluir modelo no repositório
376
  </button>
377
  </div>
378
+ {arquivoUpload ? <div className="section1-empty-hint">Arquivo selecionado: {arquivoUpload.name}</div> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  </div>
380
  ) : (
381
+ <div className="section1-empty-hint">Perfil de leitura: inclusão e exclusão disponíveis apenas para administradores.</div>
382
  )}
383
 
384
  {status ? <div className="status-line">{status}</div> : null}
385
  {error ? <div className="error-line">{error}</div> : null}
386
 
387
+ <div className="table-container repo-table-block">
388
  <table className="repo-table">
389
  <thead>
390
  <tr>
 
 
 
391
  <th>Modelo</th>
392
  <th>Tipo</th>
393
  <th>Finalidade</th>
 
396
  <th>Dados</th>
397
  <th>APP</th>
398
  <th>Status</th>
399
+ <th className="repo-col-open">Abrir</th>
400
+ {isAdmin ? <th className="repo-col-delete">Excluir</th> : null}
401
  </tr>
402
  </thead>
403
  <tbody>
404
  {modelos.map((item) => {
405
  const key = String(item.id)
406
+ const emConfirmacao = confirmDeleteId === key
407
  return (
408
  <tr key={key}>
 
 
 
409
  <td>{item.nome_modelo || item.arquivo || key}</td>
410
  <td>{item.tipo_imovel || '-'}</td>
411
  <td>{item.finalidade || '-'}</td>
 
414
  <td>{item.total_dados ?? '-'}</td>
415
  <td>{item.tem_app ? 'Sim' : 'Não'}</td>
416
  <td>{item.status || '-'}</td>
417
+ <td className="repo-col-open">
418
+ <button
419
+ type="button"
420
+ className="repo-open-btn"
421
+ onClick={() => void onAbrirModelo(item)}
422
+ title="Abrir modelo"
423
+ aria-label={`Abrir ${item.nome_modelo || item.arquivo || key}`}
424
+ >
425
+
426
+ </button>
427
+ </td>
428
+ {isAdmin ? (
429
+ <td className="repo-col-delete">
430
+ {!emConfirmacao ? (
431
+ <button
432
+ type="button"
433
+ className="repo-delete-icon-btn"
434
+ onClick={() => setConfirmDeleteId(key)}
435
+ disabled={deleting}
436
+ title="Excluir modelo"
437
+ aria-label={`Excluir ${item.nome_modelo || item.arquivo || key}`}
438
+ >
439
+ 🗑
440
+ </button>
441
+ ) : (
442
+ <div className="repo-delete-inline-confirm">
443
+ <button
444
+ type="button"
445
+ className="btn-danger repo-delete-confirm-btn"
446
+ onClick={() => onAbrirModalExclusao(item)}
447
+ disabled={deleting}
448
+ >
449
+ Excluir
450
+ </button>
451
+ <button
452
+ type="button"
453
+ className="repo-delete-cancel-btn"
454
+ onClick={() => setConfirmDeleteId('')}
455
+ disabled={deleting}
456
+ >
457
+ Cancelar
458
+ </button>
459
+ </div>
460
+ )}
461
+ </td>
462
+ ) : null}
463
  </tr>
464
  )
465
  })}
466
  {!modelos.length ? (
467
  <tr>
468
+ <td colSpan={isAdmin ? 10 : 9}>
469
+ {loading ? 'Carregando modelos...' : 'Nenhum modelo encontrado no repositório.'}
470
+ </td>
471
  </tr>
472
  ) : null}
473
  </tbody>
474
  </table>
475
  </div>
476
+ </div>
477
+
478
+ {confirmarSubstituicao.open ? (
479
+ <div className="pesquisa-modal-backdrop">
480
+ <div className="pesquisa-modal repo-confirm-modal">
481
+ <div className="pesquisa-modal-head">
482
+ <div>
483
+ <h4>Confirmar substituição de modelo</h4>
484
+ <p>Já existe modelo com o mesmo nome no repositório.</p>
485
+ </div>
486
+ <button
487
+ type="button"
488
+ className="pesquisa-modal-close"
489
+ onClick={() => setConfirmarSubstituicao({ open: false, substituidos: [] })}
490
+ disabled={uploading}
491
+ >
492
+ Fechar
493
+ </button>
494
+ </div>
495
+ <div className="repo-confirm-modal-body">
496
+ <div className="repo-confirm-text">
497
+ Se você continuar, o modelo existente será substituído.
498
+ </div>
499
+ <ul className="repo-replace-list">
500
+ {(nomesSubstituidos.length ? nomesSubstituidos : [String(arquivoUpload?.name || '')]).map((nome) => (
501
+ <li key={`substituir-${nome}`}>{nome}</li>
502
+ ))}
503
+ </ul>
504
+ <div className="repo-confirm-actions">
505
+ <button
506
+ type="button"
507
+ className="repo-delete-cancel-btn"
508
+ onClick={() => setConfirmarSubstituicao({ open: false, substituidos: [] })}
509
+ disabled={uploading}
510
+ >
511
+ Cancelar
512
+ </button>
513
+ <button
514
+ type="button"
515
+ className="btn-danger"
516
+ onClick={() => void onUploadArquivo(true)}
517
+ disabled={uploading}
518
+ >
519
+ {uploading ? 'Substituindo...' : 'Confirmar substituição'}
520
+ </button>
521
+ </div>
522
+ </div>
523
+ </div>
524
+ </div>
525
+ ) : null}
526
+
527
+ {confirmarExclusaoModal.open ? (
528
+ <div className="pesquisa-modal-backdrop">
529
+ <div className="pesquisa-modal repo-confirm-modal">
530
+ <div className="pesquisa-modal-head">
531
+ <div>
532
+ <h4>{confirmarExclusaoModal.nomeModelo || 'Confirmar exclusão'}</h4>
533
+ <p>Digite o nome completo do modelo para confirmar a exclusão.</p>
534
+ </div>
535
+ <button
536
+ type="button"
537
+ className="pesquisa-modal-close"
538
+ onClick={onCancelarModalExclusao}
539
+ disabled={deleting}
540
+ >
541
+ Fechar
542
+ </button>
543
+ </div>
544
+ <form
545
+ className="repo-confirm-modal-body"
546
+ onSubmit={(event) => {
547
+ event.preventDefault()
548
+ void onConfirmarExclusaoModal()
549
+ }}
550
+ >
551
+ <label className="repo-delete-typing-field">
552
+ Nome do modelo
553
+ <input
554
+ type="text"
555
+ value={confirmarExclusaoModal.digitado}
556
+ onChange={(event) => setConfirmarExclusaoModal((prev) => ({ ...prev, digitado: event.target.value }))}
557
+ placeholder="Digite exatamente como no título"
558
+ autoComplete="off"
559
+ disabled={deleting}
560
+ />
561
+ </label>
562
+ {confirmarExclusaoModal.digitado && !exclusaoDigitadaCorreta ? (
563
+ <div className="repo-delete-typing-hint repo-delete-typing-hint-error">
564
+ O texto digitado não corresponde ao nome do modelo.
565
+ </div>
566
+ ) : (
567
+ <div className="repo-delete-typing-hint">
568
+ A exclusão será concluída somente quando o nome for digitado exatamente.
569
+ </div>
570
+ )}
571
+ <div className="repo-confirm-actions">
572
+ <button type="button" className="repo-delete-cancel-btn" onClick={onCancelarModalExclusao} disabled={deleting}>
573
+ Cancelar
574
+ </button>
575
+ <button type="submit" className="btn-danger" disabled={deleting || !exclusaoDigitadaCorreta}>
576
+ {deleting ? 'Excluindo...' : 'Excluir modelo'}
577
+ </button>
578
+ </div>
579
+ </form>
580
+ </div>
581
+ </div>
582
+ ) : null}
583
  </div>
584
  )
585
  }
frontend/src/styles.css CHANGED
@@ -253,6 +253,14 @@ textarea {
253
  }
254
  }
255
 
 
 
 
 
 
 
 
 
256
  .repo-toolbar {
257
  display: flex;
258
  justify-content: space-between;
@@ -274,6 +282,19 @@ textarea {
274
  gap: 10px;
275
  }
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  .repo-upload-row,
278
  .repo-delete-row {
279
  display: flex;
@@ -282,6 +303,10 @@ textarea {
282
  gap: 10px;
283
  }
284
 
 
 
 
 
285
  .repo-delete-confirm {
286
  display: inline-flex;
287
  align-items: center;
@@ -320,6 +345,121 @@ textarea {
320
  color: #48627a;
321
  }
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  .logs-panel {
324
  border: 1px solid #d8e4f0;
325
  border-radius: 14px;
@@ -1371,15 +1511,6 @@ button.pesquisa-coluna-remove:hover {
1371
  margin-bottom: 14px;
1372
  }
1373
 
1374
- .btn-pesquisa-expand {
1375
- --btn-bg-start: #f7fbff;
1376
- --btn-bg-end: #ecf3fa;
1377
- --btn-border: #c7d7e6;
1378
- --btn-shadow-soft: rgba(73, 102, 128, 0.08);
1379
- --btn-shadow-strong: rgba(73, 102, 128, 0.13);
1380
- color: #415c74;
1381
- }
1382
-
1383
  .pesquisa-status {
1384
  margin-top: 2px;
1385
  }
@@ -1428,6 +1559,36 @@ button.pesquisa-coluna-remove:hover {
1428
  align-items: stretch;
1429
  }
1430
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1431
  .pesquisa-card {
1432
  border: 1px solid #dbe7f2;
1433
  border-radius: 14px;
@@ -1472,18 +1633,7 @@ button.pesquisa-coluna-remove:hover {
1472
  display: flex;
1473
  align-items: flex-start;
1474
  justify-content: space-between;
1475
- gap: 10px;
1476
- min-width: 0;
1477
- }
1478
-
1479
- .pesquisa-card-head > div {
1480
- min-width: 0;
1481
- flex: 1 1 auto;
1482
- }
1483
-
1484
- .pesquisa-card-head-main {
1485
- display: grid;
1486
- gap: 6px;
1487
  min-width: 0;
1488
  }
1489
 
@@ -1494,6 +1644,7 @@ button.pesquisa-coluna-remove:hover {
1494
  font-size: 0.93rem;
1495
  line-height: 1.32;
1496
  overflow-wrap: anywhere;
 
1497
  }
1498
 
1499
  .pesquisa-card-head p {
@@ -1504,25 +1655,26 @@ button.pesquisa-coluna-remove:hover {
1504
  overflow-wrap: anywhere;
1505
  }
1506
 
1507
- .btn-pesquisa-expand {
 
 
 
 
1508
  flex: 0 0 auto;
1509
- min-width: 84px;
1510
- padding: 6px 10px;
1511
- }
1512
-
1513
- button.btn-pesquisa-expand:hover {
1514
- transform: none;
1515
- box-shadow: 0 4px 10px var(--btn-shadow-strong);
1516
  }
1517
 
1518
- .pesquisa-card-head-actions {
1519
- display: flex;
1520
- flex-wrap: wrap;
1521
- align-items: center;
1522
- justify-content: flex-start;
1523
- gap: 8px;
1524
- min-width: 0;
1525
- margin-top: 2px;
 
 
 
1526
  }
1527
 
1528
  .pesquisa-select-toggle {
@@ -1540,6 +1692,12 @@ button.btn-pesquisa-expand:hover {
1540
  color: #48627b;
1541
  }
1542
 
 
 
 
 
 
 
1543
  .pesquisa-select-toggle input {
1544
  margin: 0;
1545
  }
@@ -1883,6 +2041,152 @@ button.btn-pesquisa-expand:hover {
1883
  margin-top: 10px;
1884
  }
1885
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1886
  label {
1887
  font-weight: 700;
1888
  color: #394a5e;
@@ -2038,6 +2342,15 @@ button.model-source-back-btn {
2038
  color: #3f5973;
2039
  }
2040
 
 
 
 
 
 
 
 
 
 
2041
  .upload-dropzone {
2042
  border: 1px dashed #c5d5e5;
2043
  border-radius: 12px;
@@ -4186,9 +4499,10 @@ button.btn-download-subtle {
4186
  flex-direction: column;
4187
  }
4188
 
4189
- .pesquisa-card-head-actions {
4190
  width: 100%;
4191
  justify-content: flex-start;
 
4192
  }
4193
 
4194
  .pesquisa-results-toolbar {
 
253
  }
254
  }
255
 
256
+ .repositorio-standalone-panel {
257
+ border: 1px solid #d1deec;
258
+ border-radius: 14px;
259
+ background: #fff;
260
+ box-shadow: var(--shadow-sm);
261
+ padding: 14px;
262
+ }
263
+
264
  .repo-toolbar {
265
  display: flex;
266
  justify-content: space-between;
 
282
  gap: 10px;
283
  }
284
 
285
+ .repo-table-block {
286
+ margin-top: 26px;
287
+ }
288
+
289
+ .repo-refresh-btn {
290
+ --btn-bg-start: #82bdf0;
291
+ --btn-bg-end: #66aae8;
292
+ --btn-border: #5597d3;
293
+ --btn-shadow-soft: rgba(85, 151, 211, 0.22);
294
+ --btn-shadow-strong: rgba(85, 151, 211, 0.3);
295
+ color: #ffffff;
296
+ }
297
+
298
  .repo-upload-row,
299
  .repo-delete-row {
300
  display: flex;
 
303
  gap: 10px;
304
  }
305
 
306
+ .repo-upload-row-single input[type="file"] {
307
+ min-width: 320px;
308
+ }
309
+
310
  .repo-delete-confirm {
311
  display: inline-flex;
312
  align-items: center;
 
345
  color: #48627a;
346
  }
347
 
348
+ .repo-col-open,
349
+ .repo-col-delete {
350
+ width: 68px;
351
+ text-align: center !important;
352
+ }
353
+
354
+ .repo-open-btn {
355
+ min-width: 28px;
356
+ min-height: 28px;
357
+ padding: 2px 7px;
358
+ font-size: 0.85rem;
359
+ border-radius: 8px;
360
+ --btn-bg-start: #e9f8ee;
361
+ --btn-bg-end: #dff3e6;
362
+ --btn-border: #8ec9a0;
363
+ --btn-shadow-soft: rgba(46, 138, 83, 0.14);
364
+ --btn-shadow-strong: rgba(46, 138, 83, 0.2);
365
+ color: #1b7a40;
366
+ }
367
+
368
+ .repo-delete-icon-btn {
369
+ min-width: 28px;
370
+ min-height: 28px;
371
+ padding: 2px 6px;
372
+ border-radius: 8px;
373
+ --btn-bg-start: #fff4f6;
374
+ --btn-bg-end: #fee9ed;
375
+ --btn-border: #e3adb8;
376
+ --btn-shadow-soft: rgba(178, 47, 64, 0.1);
377
+ --btn-shadow-strong: rgba(178, 47, 64, 0.16);
378
+ color: #a63446;
379
+ font-size: 0.85rem;
380
+ }
381
+
382
+ .repo-delete-inline-confirm {
383
+ display: inline-flex;
384
+ align-items: center;
385
+ gap: 6px;
386
+ justify-content: center;
387
+ flex-wrap: wrap;
388
+ }
389
+
390
+ .repo-delete-confirm-btn {
391
+ min-height: 28px;
392
+ min-width: 64px;
393
+ padding: 4px 8px;
394
+ font-size: 0.72rem;
395
+ }
396
+
397
+ .repo-delete-cancel-btn {
398
+ min-height: 28px;
399
+ min-width: 64px;
400
+ padding: 4px 8px;
401
+ font-size: 0.72rem;
402
+ --btn-bg-start: #f7fbff;
403
+ --btn-bg-end: #edf3fa;
404
+ --btn-border: #c8d8e8;
405
+ --btn-shadow-soft: rgba(53, 83, 114, 0.1);
406
+ --btn-shadow-strong: rgba(53, 83, 114, 0.16);
407
+ color: #3f5973;
408
+ }
409
+
410
+ .repo-confirm-modal {
411
+ width: min(620px, 100%);
412
+ }
413
+
414
+ .repo-confirm-modal-body {
415
+ display: grid;
416
+ gap: 12px;
417
+ margin-top: 12px;
418
+ }
419
+
420
+ .repo-confirm-text {
421
+ color: #445d74;
422
+ font-size: 0.9rem;
423
+ line-height: 1.4;
424
+ }
425
+
426
+ .repo-replace-list {
427
+ margin: 0;
428
+ padding-left: 18px;
429
+ display: grid;
430
+ gap: 4px;
431
+ color: #2f4760;
432
+ font-size: 0.88rem;
433
+ }
434
+
435
+ .repo-confirm-actions {
436
+ display: flex;
437
+ gap: 8px;
438
+ justify-content: flex-end;
439
+ flex-wrap: wrap;
440
+ }
441
+
442
+ .repo-delete-typing-field {
443
+ display: grid;
444
+ gap: 6px;
445
+ color: #334c64;
446
+ font-size: 0.88rem;
447
+ }
448
+
449
+ .repo-delete-typing-field input {
450
+ min-height: 38px;
451
+ }
452
+
453
+ .repo-delete-typing-hint {
454
+ color: #5a7288;
455
+ font-size: 0.82rem;
456
+ }
457
+
458
+ .repo-delete-typing-hint-error {
459
+ color: #a63446;
460
+ font-weight: 700;
461
+ }
462
+
463
  .logs-panel {
464
  border: 1px solid #d8e4f0;
465
  border-radius: 14px;
 
1511
  margin-bottom: 14px;
1512
  }
1513
 
 
 
 
 
 
 
 
 
 
1514
  .pesquisa-status {
1515
  margin-top: 2px;
1516
  }
 
1559
  align-items: stretch;
1560
  }
1561
 
1562
+ .pesquisa-opened-model-view {
1563
+ border: 1px solid var(--section-border);
1564
+ border-radius: var(--radius-lg);
1565
+ background: linear-gradient(180deg, #ffffff 0%, #fdfefe 100%);
1566
+ padding: 14px;
1567
+ display: grid;
1568
+ gap: 12px;
1569
+ box-shadow: var(--shadow-sm);
1570
+ }
1571
+
1572
+ .pesquisa-opened-model-head {
1573
+ display: flex;
1574
+ justify-content: space-between;
1575
+ align-items: flex-start;
1576
+ gap: 12px;
1577
+ }
1578
+
1579
+ .pesquisa-opened-model-title-wrap h3 {
1580
+ margin: 0;
1581
+ color: #2e4358;
1582
+ font-family: 'Sora', sans-serif;
1583
+ font-size: 1rem;
1584
+ }
1585
+
1586
+ .pesquisa-opened-model-title-wrap p {
1587
+ margin: 4px 0 0;
1588
+ color: #5f758b;
1589
+ font-size: 0.83rem;
1590
+ }
1591
+
1592
  .pesquisa-card {
1593
  border: 1px solid #dbe7f2;
1594
  border-radius: 14px;
 
1633
  display: flex;
1634
  align-items: flex-start;
1635
  justify-content: space-between;
1636
+ gap: 14px;
 
 
 
 
 
 
 
 
 
 
 
1637
  min-width: 0;
1638
  }
1639
 
 
1644
  font-size: 0.93rem;
1645
  line-height: 1.32;
1646
  overflow-wrap: anywhere;
1647
+ flex: 1 1 auto;
1648
  }
1649
 
1650
  .pesquisa-card-head p {
 
1655
  overflow-wrap: anywhere;
1656
  }
1657
 
1658
+ .pesquisa-card-controls {
1659
+ display: inline-flex;
1660
+ align-items: center;
1661
+ justify-content: flex-end;
1662
+ gap: 8px;
1663
  flex: 0 0 auto;
1664
+ align-self: center;
 
 
 
 
 
 
1665
  }
1666
 
1667
+ .btn-pesquisa-open {
1668
+ --btn-bg-start: #2ea94f;
1669
+ --btn-bg-end: #238a40;
1670
+ --btn-border: #1b7435;
1671
+ --btn-shadow-soft: rgba(35, 138, 64, 0.22);
1672
+ --btn-shadow-strong: rgba(35, 138, 64, 0.3);
1673
+ min-width: 74px;
1674
+ min-height: 32px;
1675
+ padding: 6px 10px;
1676
+ font-size: 0.78rem;
1677
+ letter-spacing: 0.02em;
1678
  }
1679
 
1680
  .pesquisa-select-toggle {
 
1692
  color: #48627b;
1693
  }
1694
 
1695
+ .pesquisa-select-toggle-compact {
1696
+ min-height: 32px;
1697
+ padding: 4px 8px;
1698
+ font-size: 0.78rem;
1699
+ }
1700
+
1701
  .pesquisa-select-toggle input {
1702
  margin: 0;
1703
  }
 
2041
  margin-top: 10px;
2042
  }
2043
 
2044
+ .avaliacao-beta-groups {
2045
+ gap: 16px;
2046
+ }
2047
+
2048
+ .avaliacao-beta-flow {
2049
+ display: grid;
2050
+ gap: 14px;
2051
+ }
2052
+
2053
+ .avaliacao-beta-model-block {
2054
+ display: grid;
2055
+ gap: 10px;
2056
+ }
2057
+
2058
+ .avaliacao-beta-title {
2059
+ margin: 0;
2060
+ color: #2f465c;
2061
+ font-family: 'Sora', sans-serif;
2062
+ font-size: 1rem;
2063
+ }
2064
+
2065
+ .avaliacao-beta-cards-grid {
2066
+ display: grid;
2067
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
2068
+ gap: 12px;
2069
+ }
2070
+
2071
+ .avaliacao-beta-card {
2072
+ border: 1px solid #d7e3ef;
2073
+ border-radius: 12px;
2074
+ background: #fff;
2075
+ box-shadow: 0 2px 6px rgba(34, 52, 70, 0.08);
2076
+ padding: 10px 11px;
2077
+ display: grid;
2078
+ gap: 8px;
2079
+ }
2080
+
2081
+ .avaliacao-beta-card-head {
2082
+ display: flex;
2083
+ align-items: flex-start;
2084
+ justify-content: space-between;
2085
+ gap: 8px;
2086
+ }
2087
+
2088
+ .avaliacao-beta-card-title {
2089
+ display: grid;
2090
+ gap: 1px;
2091
+ }
2092
+
2093
+ .avaliacao-beta-card-title strong {
2094
+ color: #2f465c;
2095
+ font-family: 'Sora', sans-serif;
2096
+ font-size: 0.87rem;
2097
+ }
2098
+
2099
+ .avaliacao-beta-card-title span {
2100
+ color: #4f667d;
2101
+ font-size: 0.79rem;
2102
+ font-weight: 600;
2103
+ line-height: 1.2;
2104
+ }
2105
+
2106
+ .avaliacao-beta-card-subtitle {
2107
+ color: #70869b;
2108
+ font-size: 0.75rem;
2109
+ }
2110
+
2111
+ .avaliacao-beta-card-actions {
2112
+ display: inline-flex;
2113
+ align-items: center;
2114
+ }
2115
+
2116
+ .avaliacao-beta-delete-btn {
2117
+ min-height: 28px;
2118
+ padding: 3px 8px;
2119
+ font-size: 0.74rem;
2120
+ --btn-bg-start: #fff4f6;
2121
+ --btn-bg-end: #fee9ed;
2122
+ --btn-border: #e3adb8;
2123
+ --btn-shadow-soft: rgba(178, 47, 64, 0.1);
2124
+ --btn-shadow-strong: rgba(178, 47, 64, 0.16);
2125
+ color: #a63446;
2126
+ }
2127
+
2128
+ .avaliacao-beta-card-base {
2129
+ color: #3c546a;
2130
+ font-size: 0.8rem;
2131
+ border: 1px solid #e3ebf3;
2132
+ border-radius: 8px;
2133
+ background: #fbfdff;
2134
+ padding: 6px 8px;
2135
+ }
2136
+
2137
+ .avaliacao-beta-base-pill {
2138
+ display: inline-block;
2139
+ border: 1px solid #f2cd91;
2140
+ border-radius: 999px;
2141
+ background: #fff6e8;
2142
+ color: #9a5a00;
2143
+ padding: 1px 8px;
2144
+ font-size: 0.74rem;
2145
+ font-weight: 700;
2146
+ }
2147
+
2148
+ .avaliacao-beta-vars-list {
2149
+ display: grid;
2150
+ gap: 4px;
2151
+ }
2152
+
2153
+ .avaliacao-beta-vars-item {
2154
+ display: grid;
2155
+ grid-template-columns: minmax(88px, auto) minmax(0, 1fr);
2156
+ gap: 6px;
2157
+ align-items: center;
2158
+ border: 1px solid #edf2f7;
2159
+ border-radius: 7px;
2160
+ background: #fcfdff;
2161
+ padding: 5px 7px;
2162
+ font-size: 0.78rem;
2163
+ }
2164
+
2165
+ .avaliacao-beta-vars-item span:first-child {
2166
+ color: #526a80;
2167
+ font-weight: 700;
2168
+ }
2169
+
2170
+ .avaliacao-beta-vars-item span:last-child {
2171
+ color: #2e475d;
2172
+ text-align: right;
2173
+ }
2174
+
2175
+ .avaliacao-beta-metrics {
2176
+ display: grid;
2177
+ gap: 3px;
2178
+ color: #40596f;
2179
+ font-size: 0.78rem;
2180
+ }
2181
+
2182
+ .avaliacao-beta-graus {
2183
+ display: grid;
2184
+ gap: 3px;
2185
+ padding-top: 2px;
2186
+ border-top: 1px solid #ecf2f8;
2187
+ font-size: 0.79rem;
2188
+ }
2189
+
2190
  label {
2191
  font-weight: 700;
2192
  color: #394a5e;
 
2342
  color: #3f5973;
2343
  }
2344
 
2345
+ button.model-source-back-btn.model-source-back-btn-danger {
2346
+ --btn-bg-start: #cf3d4f;
2347
+ --btn-bg-end: #b22f40;
2348
+ --btn-border: #a22b3a;
2349
+ --btn-shadow-soft: rgba(162, 43, 58, 0.2);
2350
+ --btn-shadow-strong: rgba(162, 43, 58, 0.26);
2351
+ color: #fff;
2352
+ }
2353
+
2354
  .upload-dropzone {
2355
  border: 1px dashed #c5d5e5;
2356
  border-radius: 12px;
 
4499
  flex-direction: column;
4500
  }
4501
 
4502
+ .pesquisa-card-controls {
4503
  width: 100%;
4504
  justify-content: flex-start;
4505
+ align-self: flex-start;
4506
  }
4507
 
4508
  .pesquisa-results-toolbar {