Guilherme Silberfarb Costa commited on
Commit
614e632
·
1 Parent(s): 03a7ca2

alteracoes generalizadas no design

Browse files
backend/app/api/visualizacao.py CHANGED
@@ -88,6 +88,12 @@ def exibir(payload: SessionPayload) -> dict[str, Any]:
88
  return visualizacao_service.exibir_modelo(session)
89
 
90
 
 
 
 
 
 
 
91
  @router.post("/map/update")
92
  def map_update(payload: MapaPayload) -> dict[str, Any]:
93
  session = session_store.get(payload.session_id)
 
88
  return visualizacao_service.exibir_modelo(session)
89
 
90
 
91
+ @router.post("/evaluation/context")
92
+ def evaluation_context(payload: SessionPayload) -> dict[str, Any]:
93
+ session = session_store.get(payload.session_id)
94
+ return visualizacao_service.exibir_contexto_avaliacao(session)
95
+
96
+
97
  @router.post("/map/update")
98
  def map_update(payload: MapaPayload) -> dict[str, Any]:
99
  session = session_store.get(payload.session_id)
backend/app/services/model_repository.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  from io import BytesIO
4
  import os
5
  import re
 
6
  from dataclasses import dataclass
7
  from pathlib import Path
8
  from threading import Lock
@@ -272,6 +273,15 @@ def list_repository_models() -> dict[str, Any]:
272
  }
273
 
274
 
 
 
 
 
 
 
 
 
 
275
  def resolve_model_file(modelo_id: str) -> Path:
276
  resolved = resolve_model_repository()
277
  chave = str(modelo_id or "").strip()
@@ -285,6 +295,17 @@ def resolve_model_file(modelo_id: str) -> Path:
285
  candidato = by_stem.get(chave.lower()) or by_name.get(chave.lower())
286
  if candidato is None and not chave.lower().endswith(".dai"):
287
  candidato = by_name.get(f"{chave.lower()}.dai")
 
 
 
 
 
 
 
 
 
 
 
288
  if candidato is None:
289
  raise HTTPException(status_code=404, detail="Modelo nao encontrado no repositório configurado")
290
  return candidato
 
3
  from io import BytesIO
4
  import os
5
  import re
6
+ import unicodedata
7
  from dataclasses import dataclass
8
  from pathlib import Path
9
  from threading import Lock
 
273
  }
274
 
275
 
276
+ def _normalize_model_key(value: str) -> str:
277
+ text = str(value or "").strip()
278
+ if not text:
279
+ return ""
280
+ normalized = unicodedata.normalize("NFKD", text)
281
+ without_marks = "".join(ch for ch in normalized if not unicodedata.combining(ch))
282
+ return without_marks.casefold().strip()
283
+
284
+
285
  def resolve_model_file(modelo_id: str) -> Path:
286
  resolved = resolve_model_repository()
287
  chave = str(modelo_id or "").strip()
 
295
  candidato = by_stem.get(chave.lower()) or by_name.get(chave.lower())
296
  if candidato is None and not chave.lower().endswith(".dai"):
297
  candidato = by_name.get(f"{chave.lower()}.dai")
298
+ if candidato is None:
299
+ chave_norm = _normalize_model_key(chave)
300
+ chave_norm_stem = chave_norm[:-4] if chave_norm.endswith(".dai") else chave_norm
301
+ for caminho in modelos:
302
+ candidatos_norm = {
303
+ _normalize_model_key(caminho.stem),
304
+ _normalize_model_key(caminho.name),
305
+ }
306
+ if chave_norm in candidatos_norm or chave_norm_stem in candidatos_norm:
307
+ candidato = caminho
308
+ break
309
  if candidato is None:
310
  raise HTTPException(status_code=404, detail="Modelo nao encontrado no repositório configurado")
311
  return candidato
backend/app/services/serializers.py CHANGED
@@ -66,7 +66,7 @@ def sanitize_value(value: Any) -> Any:
66
  def dataframe_to_payload(
67
  df: pd.DataFrame | None,
68
  decimals: int | None = None,
69
- max_rows: int | None = 2000,
70
  ) -> dict[str, Any] | None:
71
  if df is None:
72
  return None
@@ -83,18 +83,8 @@ def dataframe_to_payload(
83
  df_work.loc[:, numeric_cols] = df_work.loc[:, numeric_cols].round(decimals)
84
 
85
  total_rows = len(df_work)
86
- max_rows_int: int | None
87
- if max_rows is None:
88
- max_rows_int = None
89
- else:
90
- try:
91
- max_rows_int = int(max_rows)
92
- except Exception:
93
- max_rows_int = 2000
94
- truncamento_ativo = max_rows_int is not None and max_rows_int > 0
95
- truncated = truncamento_ativo and total_rows > int(max_rows_int)
96
- if truncated:
97
- df_work = df_work.head(int(max_rows_int))
98
 
99
  columns = [str(c) for c in df_work.columns]
100
  rows: list[dict[str, Any]] = []
 
66
  def dataframe_to_payload(
67
  df: pd.DataFrame | None,
68
  decimals: int | None = None,
69
+ max_rows: int | None = None,
70
  ) -> dict[str, Any] | None:
71
  if df is None:
72
  return None
 
83
  df_work.loc[:, numeric_cols] = df_work.loc[:, numeric_cols].round(decimals)
84
 
85
  total_rows = len(df_work)
86
+ # Regra de integridade: payloads nunca devem suprimir linhas.
87
+ truncated = False
 
 
 
 
 
 
 
 
 
 
88
 
89
  columns = [str(c) for c in df_work.columns]
90
  rows: list[dict[str, Any]] = []
backend/app/services/visualizacao_service.py CHANGED
@@ -110,6 +110,32 @@ def _extrair_modelo_info(pacote: dict[str, Any]) -> dict[str, Any]:
110
  }
111
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  def exibir_modelo(session: SessionState) -> dict[str, Any]:
114
  pacote = session.pacote_visualizacao
115
  if pacote is None:
@@ -147,15 +173,7 @@ def exibir_modelo(session: SessionState) -> dict[str, Any]:
147
  figs = viz_app.gerar_todos_graficos(pacote)
148
 
149
  info = _extrair_modelo_info(pacote)
150
- diagnosticos = pacote.get("modelo", {}).get("diagnosticos", {}) if isinstance(pacote.get("modelo"), dict) else {}
151
- equacoes = build_equacoes_payload(
152
- modelo_sm=pacote.get("modelo", {}).get("sm"),
153
- coluna_y=info["nome_y"],
154
- transformacao_y=info["transformacao_y"],
155
- transformacoes_x=info["transformacoes_x"],
156
- colunas_x=info["colunas_x"],
157
- equacao_visual=str(diagnosticos.get("equacao") or ""),
158
- )
159
  mapa_html = viz_app.criar_mapa(dados, col_y=info["nome_y"])
160
 
161
  colunas_numericas = [
 
110
  }
111
 
112
 
113
+ def _equacoes_do_modelo(pacote: dict[str, Any], info: dict[str, Any]) -> dict[str, Any]:
114
+ diagnosticos = pacote.get("modelo", {}).get("diagnosticos", {}) if isinstance(pacote.get("modelo"), dict) else {}
115
+ return build_equacoes_payload(
116
+ modelo_sm=pacote.get("modelo", {}).get("sm"),
117
+ coluna_y=info["nome_y"],
118
+ transformacao_y=info["transformacao_y"],
119
+ transformacoes_x=info["transformacoes_x"],
120
+ colunas_x=info["colunas_x"],
121
+ equacao_visual=str(diagnosticos.get("equacao") or ""),
122
+ )
123
+
124
+
125
+ def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
126
+ pacote = session.pacote_visualizacao
127
+ if pacote is None:
128
+ raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
129
+
130
+ info = _extrair_modelo_info(pacote)
131
+ equacoes = _equacoes_do_modelo(pacote, info)
132
+ return {
133
+ "campos_avaliacao": campos_avaliacao(session),
134
+ "meta_modelo": sanitize_value(info),
135
+ "equacoes": sanitize_value(equacoes),
136
+ }
137
+
138
+
139
  def exibir_modelo(session: SessionState) -> dict[str, Any]:
140
  pacote = session.pacote_visualizacao
141
  if pacote is None:
 
173
  figs = viz_app.gerar_todos_graficos(pacote)
174
 
175
  info = _extrair_modelo_info(pacote)
176
+ equacoes = _equacoes_do_modelo(pacote, info)
 
 
 
 
 
 
 
 
177
  mapa_html = viz_app.criar_mapa(dados, col_y=info["nome_y"])
178
 
179
  colunas_numericas = [
frontend/src/App.jsx CHANGED
@@ -1,20 +1,18 @@
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'
7
  import RepositorioTab from './components/RepositorioTab'
8
- import VisualizacaoTab from './components/VisualizacaoTab'
9
 
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() {
@@ -22,7 +20,7 @@ export default function App() {
22
  const [showStartupIntro, setShowStartupIntro] = useState(true)
23
  const [sessionId, setSessionId] = useState('')
24
  const [bootError, setBootError] = useState('')
25
- const [avaliacaoBetaQuickLoad, setAvaliacaoBetaQuickLoad] = useState(null)
26
 
27
  const [authLoading, setAuthLoading] = useState(true)
28
  const [authUser, setAuthUser] = useState(null)
@@ -39,6 +37,8 @@ export default function App() {
39
  const [logsScope, setLogsScope] = useState('')
40
  const [logsUsuario, setLogsUsuario] = useState('')
41
  const [logsPage, setLogsPage] = useState(1)
 
 
42
 
43
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
44
  const logsEnabled = Boolean(logsStatus?.enabled)
@@ -162,6 +162,24 @@ export default function App() {
162
  }
163
  }, [logsEvents.length, logsPage, logsTotalPages])
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  async function onSubmitLogin(event) {
166
  event.preventDefault()
167
  setAuthError('')
@@ -185,6 +203,7 @@ export default function App() {
185
  }
186
 
187
  async function onLogout() {
 
188
  try {
189
  await api.authLogout()
190
  } catch {
@@ -245,45 +264,92 @@ export default function App() {
245
  function onUsarModeloEmAvaliacao(modelo) {
246
  const modeloId = String(modelo?.id || '').trim()
247
  if (!modeloId) return
248
- setAvaliacaoBetaQuickLoad({
249
  requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
250
  modeloId,
 
251
  nomeModelo: String(modelo?.nome_modelo || modelo?.arquivo || modeloId),
252
  })
253
- setActiveTab('Avaliação Beta')
 
254
  setShowStartupIntro(false)
255
  }
256
 
257
  return (
258
  <div className="app-shell">
259
- <header className="app-header app-header-logo-only">
260
  <div className="brand-mark" aria-hidden="true">
261
  <img src={`${import.meta.env.BASE_URL}logo_mesa.png`} alt="MESA" />
262
  </div>
263
- </header>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
- {authUser ? (
266
- <div className="auth-status-bar">
267
- <span>
268
- Usuário: <strong>{authUser.nome || authUser.usuario}</strong> ({authUser.perfil || 'viewer'})
269
- </span>
270
- <div className="auth-status-actions">
271
- {isAdmin ? (
272
  <button
273
  type="button"
274
- onClick={() => void onToggleLogs()}
275
- disabled={logsStatusLoading || (!logsEnabled && !logsOpen)}
276
- title={logsOpen ? 'Fechar visualização de logs' : !logsEnabled ? logsDisabledReason : 'Abrir leitura de logs'}
 
 
 
277
  >
278
- {logsOpen ? 'Fechar logs' : 'Logs'}
279
  </button>
280
- ) : null}
281
- <button type="button" onClick={() => void onLogout()}>
282
- Sair
283
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  </div>
285
- </div>
286
- ) : null}
287
 
288
  {authLoading ? <div className="status-line">Validando autenticação...</div> : null}
289
 
@@ -437,26 +503,6 @@ export default function App() {
437
  </section>
438
  ) : (
439
  <>
440
- <nav className="tabs" aria-label="Navegação principal">
441
- {TABS.map((tab) => {
442
- const active = tab.key === activeTab
443
- return (
444
- <button
445
- key={tab.key}
446
- className={active ? 'tab-pill active' : 'tab-pill'}
447
- onClick={() => {
448
- setActiveTab(tab.key)
449
- setShowStartupIntro(false)
450
- }}
451
- type="button"
452
- >
453
- <strong>{tab.label}</strong>
454
- <small>{tab.hint}</small>
455
- </button>
456
- )
457
- })}
458
- </nav>
459
-
460
  {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
461
 
462
  {showStartupIntro ? (
@@ -465,7 +511,7 @@ export default function App() {
465
  </div>
466
  ) : null}
467
 
468
- <div className="tab-pane" hidden={activeTab !== 'Pesquisa'}>
469
  <PesquisaTab sessionId={sessionId} onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao} />
470
  </div>
471
 
@@ -473,16 +519,12 @@ export default function App() {
473
  <ElaboracaoTab sessionId={sessionId} />
474
  </div>
475
 
476
- <div className="tab-pane" hidden={activeTab !== 'Visualização/Avaliação'}>
477
- <VisualizacaoTab sessionId={sessionId} />
478
- </div>
479
-
480
- <div className="tab-pane" hidden={activeTab !== 'Repositório'}>
481
  <RepositorioTab authUser={authUser} sessionId={sessionId} />
482
  </div>
483
 
484
- <div className="tab-pane" hidden={activeTab !== 'Avaliação Beta'}>
485
- <AvaliacaoBetaTab sessionId={sessionId} quickLoadRequest={avaliacaoBetaQuickLoad} />
486
  </div>
487
  </>
488
  )
 
1
+ import React, { useEffect, useRef, useState } from 'react'
2
  import { api, getAuthToken, setAuthToken } from './api'
3
+ import AvaliacaoTab from './components/AvaliacaoTab'
4
  import ElaboracaoTab from './components/ElaboracaoTab'
5
  import InicioTab from './components/InicioTab'
6
  import PesquisaTab from './components/PesquisaTab'
7
  import RepositorioTab from './components/RepositorioTab'
 
8
 
9
  const LOGS_PAGE_SIZE = 30
10
 
11
  const TABS = [
12
+ { key: 'Pesquisa/Visualização', label: 'Pesquisa/Visualização' },
13
+ { key: 'Elaboração/Edição', label: 'Elaboração/Edição' },
14
+ { key: 'Avaliação', label: 'Avaliação de Imóveis' },
15
+ { key: 'Repositório de Modelos', label: 'Repositório de Modelos' },
 
16
  ]
17
 
18
  export default function App() {
 
20
  const [showStartupIntro, setShowStartupIntro] = useState(true)
21
  const [sessionId, setSessionId] = useState('')
22
  const [bootError, setBootError] = useState('')
23
+ const [avaliacaoQuickLoad, setAvaliacaoQuickLoad] = useState(null)
24
 
25
  const [authLoading, setAuthLoading] = useState(true)
26
  const [authUser, setAuthUser] = useState(null)
 
37
  const [logsScope, setLogsScope] = useState('')
38
  const [logsUsuario, setLogsUsuario] = useState('')
39
  const [logsPage, setLogsPage] = useState(1)
40
+ const [settingsOpen, setSettingsOpen] = useState(false)
41
+ const settingsMenuRef = useRef(null)
42
 
43
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
44
  const logsEnabled = Boolean(logsStatus?.enabled)
 
162
  }
163
  }, [logsEvents.length, logsPage, logsTotalPages])
164
 
165
+ useEffect(() => {
166
+ if (!settingsOpen) return undefined
167
+ function onPointerDown(event) {
168
+ if (!settingsMenuRef.current) return
169
+ if (!settingsMenuRef.current.contains(event.target)) {
170
+ setSettingsOpen(false)
171
+ }
172
+ }
173
+ document.addEventListener('mousedown', onPointerDown)
174
+ return () => document.removeEventListener('mousedown', onPointerDown)
175
+ }, [settingsOpen])
176
+
177
+ useEffect(() => {
178
+ if (!authUser) {
179
+ setSettingsOpen(false)
180
+ }
181
+ }, [authUser])
182
+
183
  async function onSubmitLogin(event) {
184
  event.preventDefault()
185
  setAuthError('')
 
203
  }
204
 
205
  async function onLogout() {
206
+ setSettingsOpen(false)
207
  try {
208
  await api.authLogout()
209
  } catch {
 
264
  function onUsarModeloEmAvaliacao(modelo) {
265
  const modeloId = String(modelo?.id || '').trim()
266
  if (!modeloId) return
267
+ setAvaliacaoQuickLoad({
268
  requestKey: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
269
  modeloId,
270
+ modeloArquivo: String(modelo?.arquivo || '').trim(),
271
  nomeModelo: String(modelo?.nome_modelo || modelo?.arquivo || modeloId),
272
  })
273
+ setActiveTab('Avaliação')
274
+ setLogsOpen(false)
275
  setShowStartupIntro(false)
276
  }
277
 
278
  return (
279
  <div className="app-shell">
280
+ <header className={authUser ? 'app-header app-header-logged' : 'app-header app-header-logo-only'}>
281
  <div className="brand-mark" aria-hidden="true">
282
  <img src={`${import.meta.env.BASE_URL}logo_mesa.png`} alt="MESA" />
283
  </div>
284
+ {authUser ? (
285
+ <div className="app-top-actions">
286
+ <nav className="tabs" aria-label="Navegação principal">
287
+ {TABS.map((tab) => {
288
+ const active = tab.key === activeTab
289
+ return (
290
+ <button
291
+ key={tab.key}
292
+ className={active ? 'tab-pill active' : 'tab-pill'}
293
+ onClick={() => {
294
+ setActiveTab(tab.key)
295
+ setShowStartupIntro(false)
296
+ setLogsOpen(false)
297
+ setSettingsOpen(false)
298
+ }}
299
+ type="button"
300
+ >
301
+ <strong>{tab.label}</strong>
302
+ </button>
303
+ )
304
+ })}
305
+ </nav>
306
 
307
+ <div ref={settingsMenuRef} className={`settings-menu${settingsOpen ? ' is-open' : ''}`}>
 
 
 
 
 
 
308
  <button
309
  type="button"
310
+ className="settings-gear-btn"
311
+ aria-haspopup="menu"
312
+ aria-expanded={settingsOpen}
313
+ aria-label="Abrir configurações"
314
+ onClick={() => setSettingsOpen((prev) => !prev)}
315
+ title="Configurações"
316
  >
317
+ &#9881;
318
  </button>
319
+ {settingsOpen ? (
320
+ <div className="settings-menu-panel" role="menu" aria-label="Configurações do usuário">
321
+ <div className="settings-user-summary">
322
+ Usuário: <strong>{authUser.nome || authUser.usuario}</strong> ({authUser.perfil || 'viewer'})
323
+ </div>
324
+ <div className="settings-menu-actions">
325
+ {isAdmin ? (
326
+ <button
327
+ type="button"
328
+ className="settings-menu-btn"
329
+ onClick={() => {
330
+ void onToggleLogs()
331
+ setSettingsOpen(false)
332
+ }}
333
+ disabled={logsStatusLoading || (!logsEnabled && !logsOpen)}
334
+ title={logsOpen ? 'Fechar visualização de logs' : !logsEnabled ? logsDisabledReason : 'Abrir leitura de logs'}
335
+ >
336
+ {logsOpen ? 'Fechar logs' : 'Abrir logs'}
337
+ </button>
338
+ ) : null}
339
+ <button
340
+ type="button"
341
+ className="settings-menu-btn settings-menu-btn-danger"
342
+ onClick={() => void onLogout()}
343
+ >
344
+ Sair
345
+ </button>
346
+ </div>
347
+ </div>
348
+ ) : null}
349
+ </div>
350
  </div>
351
+ ) : null}
352
+ </header>
353
 
354
  {authLoading ? <div className="status-line">Validando autenticação...</div> : null}
355
 
 
503
  </section>
504
  ) : (
505
  <>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  {bootError ? <div className="error-line">Falha ao criar sessão: {bootError}</div> : null}
507
 
508
  {showStartupIntro ? (
 
511
  </div>
512
  ) : null}
513
 
514
+ <div className="tab-pane" hidden={activeTab !== 'Pesquisa/Visualização'}>
515
  <PesquisaTab sessionId={sessionId} onUsarModeloEmAvaliacao={onUsarModeloEmAvaliacao} />
516
  </div>
517
 
 
519
  <ElaboracaoTab sessionId={sessionId} />
520
  </div>
521
 
522
+ <div className="tab-pane" hidden={activeTab !== 'Repositório de Modelos'}>
 
 
 
 
523
  <RepositorioTab authUser={authUser} sessionId={sessionId} />
524
  </div>
525
 
526
+ <div className="tab-pane" hidden={activeTab !== 'Avaliação'}>
527
+ <AvaliacaoTab sessionId={sessionId} quickLoadRequest={avaliacaoQuickLoad} />
528
  </div>
529
  </>
530
  )
frontend/src/api.js CHANGED
@@ -249,6 +249,7 @@ export const api = {
249
  visualizacaoRepositorioModelos: () => getJson('/api/visualizacao/repositorio-modelos'),
250
  visualizacaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/visualizacao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
251
  exibirVisualizacao: (sessionId) => postJson('/api/visualizacao/exibir', { session_id: sessionId }),
 
252
  updateVisualizacaoMap: (sessionId, variavelMapa) => postJson('/api/visualizacao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
253
  evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
254
  evaluationCalculateViz: (sessionId, valoresX, indiceBase) => postJson('/api/visualizacao/evaluation/calculate', {
 
249
  visualizacaoRepositorioModelos: () => getJson('/api/visualizacao/repositorio-modelos'),
250
  visualizacaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/visualizacao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
251
  exibirVisualizacao: (sessionId) => postJson('/api/visualizacao/exibir', { session_id: sessionId }),
252
+ evaluationContextViz: (sessionId) => postJson('/api/visualizacao/evaluation/context', { session_id: sessionId }),
253
  updateVisualizacaoMap: (sessionId, variavelMapa) => postJson('/api/visualizacao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
254
  evaluationFieldsViz: (sessionId) => postJson('/api/visualizacao/evaluation/fields', { session_id: sessionId }),
255
  evaluationCalculateViz: (sessionId, valoresX, indiceBase) => postJson('/api/visualizacao/evaluation/calculate', {
frontend/src/components/{AvaliacaoBetaTab.jsx → AvaliacaoTab.jsx} RENAMED
@@ -3,6 +3,14 @@ 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 '-'
@@ -63,7 +71,7 @@ function formatarFonteRepositorio(fonte) {
63
  return 'Fonte: pasta local'
64
  }
65
 
66
- export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null }) {
67
  const [loading, setLoading] = useState(false)
68
  const [error, setError] = useState('')
69
  const [status, setStatus] = useState('')
@@ -104,6 +112,137 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
104
  [avaliacoesCards, baseCardId],
105
  )
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  useEffect(() => {
108
  let ativo = true
109
  if (!sessionId) return () => {
@@ -202,24 +341,33 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
202
  setStatus(uploadResp?.status || '')
203
  setBadgeHtml(uploadResp?.badge_html || '')
204
  const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
205
- const exibirResp = await api.exibirVisualizacao(sessionId)
206
- aplicarRespostaExibicao(exibirResp, nomeModelo)
207
  })
208
  }
209
 
210
- async function onCarregarModeloRepositorio(modeloIdOverride = '') {
211
- const alvoModeloId = String(modeloIdOverride || repoModeloSelecionado || '').trim()
 
 
 
 
 
 
 
212
  if (!sessionId || !alvoModeloId) return
213
  setModeloLoadSource('repo')
214
  setRepoModeloSelecionado(alvoModeloId)
215
  await withBusy(async () => {
216
- const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, alvoModeloId)
 
217
  setStatus(uploadResp?.status || '')
218
  setBadgeHtml(uploadResp?.badge_html || '')
219
- const modeloSelecionado = repoModeloOptions.find((item) => item.value === alvoModeloId)
 
220
  const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
221
- const exibirResp = await api.exibirVisualizacao(sessionId)
222
- aplicarRespostaExibicao(exibirResp, nomeModelo)
223
  setUploadedFile(null)
224
  })
225
  }
@@ -227,12 +375,14 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
227
  useEffect(() => {
228
  const requestKey = String(quickLoadRequest?.requestKey || '').trim()
229
  const modeloId = String(quickLoadRequest?.modeloId || '').trim()
 
 
230
  if (!sessionId || !requestKey || !modeloId) return
231
  if (quickLoadHandledRef.current === requestKey) return
232
  quickLoadHandledRef.current = requestKey
233
  setModeloLoadSource('repo')
234
  setRepoModeloSelecionado(modeloId)
235
- void onCarregarModeloRepositorio(modeloId)
236
  }, [quickLoadRequest, sessionId])
237
 
238
  function onUploadInputChange(event) {
@@ -368,16 +518,16 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
368
  })
369
 
370
  const csv = `\uFEFF${linhas.join('\n')}`
371
- downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8;' }), 'avaliacoes_beta.csv')
372
  }
373
 
374
  const modeloPronto = Boolean(camposAvaliacao.length)
375
 
376
  return (
377
  <div className="tab-content">
378
- <div className="avaliacao-beta-flow">
379
- <div className="avaliacao-beta-model-block">
380
- <h3 className="avaliacao-beta-title">Selecionar Modelo para Avaliação</h3>
381
  {!modeloLoadSource ? (
382
  <div className="model-source-choice-grid">
383
  <button
@@ -425,7 +575,7 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
425
  />
426
  </label>
427
  <div className="row compact upload-repo-actions">
428
- <button type="button" onClick={onCarregarModeloRepositorio} disabled={loading || repoModelosLoading || !repoModeloSelecionado}>
429
  Carregar do repositório
430
  </button>
431
  <button type="button" onClick={() => void carregarModelosRepositorio()} disabled={loading || repoModelosLoading}>
@@ -481,12 +631,12 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
481
  </div>
482
  </div>
483
 
484
- <div className="avaliacao-groups avaliacao-beta-groups">
485
  <div className="subpanel avaliacao-group">
486
  <h4>Parâmetros</h4>
487
- <div className="avaliacao-grid" key={`avaliacao-grid-beta-${avaliacaoFormVersion}`}>
488
  {camposAvaliacao.map((campo) => (
489
- <div key={`campo-beta-${campo.coluna}`} className="avaliacao-card">
490
  <label>{campo.coluna}</label>
491
  {campo.tipo === 'dicotomica' ? (
492
  <select
@@ -497,7 +647,7 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
497
  >
498
  <option value="">Selecione</option>
499
  {(campo.opcoes || [0, 1]).map((opcao) => (
500
- <option key={`op-beta-${campo.coluna}-${opcao}`} value={String(opcao)}>
501
  {opcao}
502
  </option>
503
  ))}
@@ -562,41 +712,41 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
562
  )}
563
  </div>
564
 
565
- <div className="avaliacao-beta-cards-grid">
566
  {avaliacoesCards.map((item, idx) => {
567
  const aval = item.avaliacao || {}
568
  const ehBase = item.id === baseCardId
569
  const variaveis = Object.keys(aval.valores_x || {})
570
  return (
571
- <article key={item.id} className="avaliacao-beta-card">
572
- <div className="avaliacao-beta-card-head">
573
- <div className="avaliacao-beta-card-title">
574
  <strong>{`Aval. ${idx + 1}`}</strong>
575
  <span>{item.modelo}</span>
576
  </div>
577
- <div className="avaliacao-beta-card-actions">
578
- <button type="button" className="avaliacao-beta-delete-btn" onClick={() => onExcluirCard(item.id)} disabled={loading}>
579
  Excluir
580
  </button>
581
  </div>
582
  </div>
583
 
584
- <div className="avaliacao-beta-card-subtitle">
585
  {formatarDataHoraIso(item.createdAt)}
586
  </div>
587
 
588
- <div className="avaliacao-beta-card-base">
589
  <strong>Estimado / Base:</strong>{' '}
590
  {ehBase ? (
591
- <span className="avaliacao-beta-base-pill">Base</span>
592
  ) : (
593
  <span>{calcularComparacaoBase(aval)}</span>
594
  )}
595
  </div>
596
 
597
- <div className="avaliacao-beta-vars-list">
598
  {variaveis.map((variavel) => (
599
- <div key={`${item.id}-var-${variavel}`} className="avaliacao-beta-vars-item">
600
  <span>{variavel}</span>
601
  <span>
602
  {formatarNumero(aval?.valores_x?.[variavel], 2)} {classificarExtrapolacao(aval?.extrapolacoes?.[variavel])}
@@ -605,7 +755,7 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
605
  ))}
606
  </div>
607
 
608
- <div className="avaliacao-beta-metrics">
609
  <div><strong>Estimado:</strong> {formatarMoeda(aval.estimado)}</div>
610
  <div><strong>CA -15%:</strong> {formatarMoeda(aval.ca_inf)}</div>
611
  <div><strong>CA +15%:</strong> {formatarMoeda(aval.ca_sup)}</div>
@@ -615,7 +765,7 @@ export default function AvaliacaoBetaTab({ sessionId, quickLoadRequest = null })
615
  <div><strong>Qtd. extrapolações:</strong> {String(aval.qtd_extrapolacoes ?? 0)}</div>
616
  </div>
617
 
618
- <div className="avaliacao-beta-graus">
619
  <span style={{ color: corGrau(aval.precisao) }}>
620
  <strong>Precisão:</strong> {String(aval.precisao || '-')}
621
  </span>
 
3
  import LoadingOverlay from './LoadingOverlay'
4
  import SinglePillAutocomplete from './SinglePillAutocomplete'
5
 
6
+ function normalizarChaveModelo(value) {
7
+ return String(value || '')
8
+ .normalize('NFD')
9
+ .replace(/[\u0300-\u036f]/g, '')
10
+ .toLowerCase()
11
+ .trim()
12
+ }
13
+
14
  function formatarNumero(valor, casas = 2) {
15
  const numero = Number(valor)
16
  if (!Number.isFinite(numero)) return '-'
 
71
  return 'Fonte: pasta local'
72
  }
73
 
74
+ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
75
  const [loading, setLoading] = useState(false)
76
  const [error, setError] = useState('')
77
  const [status, setStatus] = useState('')
 
112
  [avaliacoesCards, baseCardId],
113
  )
114
 
115
+ function resolverModeloIdRepositorio(chaveBruta, modelosOverride = null) {
116
+ const chave = String(chaveBruta || '').trim()
117
+ if (!chave) return ''
118
+ const modelosBase = Array.isArray(modelosOverride) ? modelosOverride : repoModelos
119
+
120
+ const chaveNorm = normalizarChaveModelo(chave)
121
+ const chaveNormStem = chaveNorm.endsWith('.dai') ? chaveNorm.slice(0, -4) : chaveNorm
122
+
123
+ for (const item of modelosBase || []) {
124
+ const id = String(item?.id || '').trim()
125
+ if (!id) continue
126
+ const arquivo = String(item?.arquivo || '').trim()
127
+ const nome = String(item?.nome_modelo || '').trim()
128
+
129
+ const candidatos = new Set()
130
+ const pushCandidato = (valor) => {
131
+ const bruto = String(valor || '').trim()
132
+ if (!bruto) return
133
+ candidatos.add(normalizarChaveModelo(bruto))
134
+ if (bruto.toLowerCase().endsWith('.dai')) {
135
+ candidatos.add(normalizarChaveModelo(bruto.slice(0, -4)))
136
+ }
137
+ }
138
+
139
+ pushCandidato(id)
140
+ pushCandidato(arquivo)
141
+ pushCandidato(nome)
142
+
143
+ if (candidatos.has(chaveNorm) || candidatos.has(chaveNormStem)) {
144
+ return id
145
+ }
146
+ }
147
+
148
+ if (chaveNormStem.length >= 4) {
149
+ for (const item of modelosBase || []) {
150
+ const id = String(item?.id || '').trim()
151
+ if (!id) continue
152
+ const arquivo = String(item?.arquivo || '').trim()
153
+ const nome = String(item?.nome_modelo || '').trim()
154
+ const candidatos = [id, arquivo, nome]
155
+ .map((valor) => normalizarChaveModelo(valor))
156
+ .filter(Boolean)
157
+ if (
158
+ candidatos.some((cand) => cand.includes(chaveNormStem) || chaveNormStem.includes(cand))
159
+ ) {
160
+ return id
161
+ }
162
+ }
163
+ }
164
+
165
+ return ''
166
+ }
167
+
168
+ function montarTentativas(chavesBrutas = [], modelosBase = null) {
169
+ const tentativas = []
170
+ const vistos = new Set()
171
+ const incluirTentativa = (valor) => {
172
+ const chave = String(valor || '').trim()
173
+ if (!chave) return
174
+ if (vistos.has(chave)) return
175
+ vistos.add(chave)
176
+ tentativas.push(chave)
177
+ }
178
+
179
+ ;(chavesBrutas || []).forEach((chave) => {
180
+ incluirTentativa(chave)
181
+ const resolvido = resolverModeloIdRepositorio(chave, modelosBase)
182
+ incluirTentativa(resolvido)
183
+ const texto = String(chave || '').trim()
184
+ if (texto.toLowerCase().endsWith('.dai')) {
185
+ incluirTentativa(texto.slice(0, -4))
186
+ }
187
+ })
188
+
189
+ return tentativas
190
+ }
191
+
192
+ async function tentarCarregarPelasChaves(chavesBrutas = [], modelosBase = null) {
193
+ const tentativas = montarTentativas(chavesBrutas, modelosBase)
194
+ let ultimoErro = null
195
+ for (const modeloId of tentativas) {
196
+ try {
197
+ const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, modeloId)
198
+ return { uploadResp, modeloIdUsado: modeloId }
199
+ } catch (err) {
200
+ if (Number(err?.status) === 404) {
201
+ ultimoErro = err
202
+ continue
203
+ }
204
+ throw err
205
+ }
206
+ }
207
+ return { uploadResp: null, modeloIdUsado: '', ultimoErro, tentativas }
208
+ }
209
+
210
+ async function carregarModeloRepositorioComFallback(chavesBrutas = []) {
211
+ if (!sessionId) {
212
+ throw new Error('Sessao indisponivel no momento.')
213
+ }
214
+
215
+ const primeiraTentativa = await tentarCarregarPelasChaves(chavesBrutas, repoModelos)
216
+ if (primeiraTentativa.uploadResp) {
217
+ return {
218
+ uploadResp: primeiraTentativa.uploadResp,
219
+ modeloIdUsado: primeiraTentativa.modeloIdUsado,
220
+ }
221
+ }
222
+
223
+ let modelosAtualizados = []
224
+ try {
225
+ const resposta = await api.visualizacaoRepositorioModelos()
226
+ modelosAtualizados = Array.isArray(resposta?.modelos) ? resposta.modelos : []
227
+ setRepoModelos(modelosAtualizados)
228
+ setRepoFonteModelos(formatarFonteRepositorio(resposta?.fonte || null))
229
+ } catch {
230
+ modelosAtualizados = repoModelos
231
+ }
232
+
233
+ const segundaTentativa = await tentarCarregarPelasChaves(chavesBrutas, modelosAtualizados)
234
+ if (segundaTentativa.uploadResp) {
235
+ return {
236
+ uploadResp: segundaTentativa.uploadResp,
237
+ modeloIdUsado: segundaTentativa.modeloIdUsado,
238
+ }
239
+ }
240
+
241
+ if (segundaTentativa.ultimoErro) throw segundaTentativa.ultimoErro
242
+ if (primeiraTentativa.ultimoErro) throw primeiraTentativa.ultimoErro
243
+ throw new Error(`Modelo nao encontrado no repositório configurado. Chaves testadas: ${(primeiraTentativa.tentativas || []).join(', ')}`)
244
+ }
245
+
246
  useEffect(() => {
247
  let ativo = true
248
  if (!sessionId) return () => {
 
341
  setStatus(uploadResp?.status || '')
342
  setBadgeHtml(uploadResp?.badge_html || '')
343
  const nomeModelo = uploadResp?.nome_modelo || arquivoUpload.name || ''
344
+ const contextoResp = await api.evaluationContextViz(sessionId)
345
+ aplicarRespostaExibicao(contextoResp, nomeModelo)
346
  })
347
  }
348
 
349
+ async function onCarregarModeloRepositorio(modeloIdOverride = '', fallbackChaves = []) {
350
+ const overrideNormalizado = (
351
+ modeloIdOverride
352
+ && typeof modeloIdOverride === 'object'
353
+ && typeof modeloIdOverride.preventDefault === 'function'
354
+ )
355
+ ? ''
356
+ : modeloIdOverride
357
+ const alvoModeloId = String(overrideNormalizado || repoModeloSelecionado || '').trim()
358
  if (!sessionId || !alvoModeloId) return
359
  setModeloLoadSource('repo')
360
  setRepoModeloSelecionado(alvoModeloId)
361
  await withBusy(async () => {
362
+ const tentativas = [alvoModeloId, ...(fallbackChaves || [])]
363
+ const { uploadResp, modeloIdUsado } = await carregarModeloRepositorioComFallback(tentativas)
364
  setStatus(uploadResp?.status || '')
365
  setBadgeHtml(uploadResp?.badge_html || '')
366
+ setRepoModeloSelecionado(modeloIdUsado)
367
+ const modeloSelecionado = repoModeloOptions.find((item) => item.value === modeloIdUsado)
368
  const nomeModelo = uploadResp?.nome_modelo || modeloSelecionado?.label || ''
369
+ const contextoResp = await api.evaluationContextViz(sessionId)
370
+ aplicarRespostaExibicao(contextoResp, nomeModelo)
371
  setUploadedFile(null)
372
  })
373
  }
 
375
  useEffect(() => {
376
  const requestKey = String(quickLoadRequest?.requestKey || '').trim()
377
  const modeloId = String(quickLoadRequest?.modeloId || '').trim()
378
+ const modeloArquivo = String(quickLoadRequest?.modeloArquivo || '').trim()
379
+ const nomeModelo = String(quickLoadRequest?.nomeModelo || '').trim()
380
  if (!sessionId || !requestKey || !modeloId) return
381
  if (quickLoadHandledRef.current === requestKey) return
382
  quickLoadHandledRef.current = requestKey
383
  setModeloLoadSource('repo')
384
  setRepoModeloSelecionado(modeloId)
385
+ void onCarregarModeloRepositorio(modeloId, [modeloArquivo, nomeModelo])
386
  }, [quickLoadRequest, sessionId])
387
 
388
  function onUploadInputChange(event) {
 
518
  })
519
 
520
  const csv = `\uFEFF${linhas.join('\n')}`
521
+ downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8;' }), 'avaliacoes.csv')
522
  }
523
 
524
  const modeloPronto = Boolean(camposAvaliacao.length)
525
 
526
  return (
527
  <div className="tab-content">
528
+ <div className="avaliacao-modelos-flow">
529
+ <div className="avaliacao-modelos-model-block">
530
+ <h3 className="avaliacao-modelos-title">Selecionar Modelo para Avaliação</h3>
531
  {!modeloLoadSource ? (
532
  <div className="model-source-choice-grid">
533
  <button
 
575
  />
576
  </label>
577
  <div className="row compact upload-repo-actions">
578
+ <button type="button" onClick={() => void onCarregarModeloRepositorio()} disabled={loading || repoModelosLoading || !repoModeloSelecionado}>
579
  Carregar do repositório
580
  </button>
581
  <button type="button" onClick={() => void carregarModelosRepositorio()} disabled={loading || repoModelosLoading}>
 
631
  </div>
632
  </div>
633
 
634
+ <div className="avaliacao-groups avaliacao-modelos-groups">
635
  <div className="subpanel avaliacao-group">
636
  <h4>Parâmetros</h4>
637
+ <div className="avaliacao-grid" key={`avaliacao-grid-avaliacao-${avaliacaoFormVersion}`}>
638
  {camposAvaliacao.map((campo) => (
639
+ <div key={`campo-avaliacao-${campo.coluna}`} className="avaliacao-card">
640
  <label>{campo.coluna}</label>
641
  {campo.tipo === 'dicotomica' ? (
642
  <select
 
647
  >
648
  <option value="">Selecione</option>
649
  {(campo.opcoes || [0, 1]).map((opcao) => (
650
+ <option key={`op-avaliacao-${campo.coluna}-${opcao}`} value={String(opcao)}>
651
  {opcao}
652
  </option>
653
  ))}
 
712
  )}
713
  </div>
714
 
715
+ <div className="avaliacao-modelos-cards-grid">
716
  {avaliacoesCards.map((item, idx) => {
717
  const aval = item.avaliacao || {}
718
  const ehBase = item.id === baseCardId
719
  const variaveis = Object.keys(aval.valores_x || {})
720
  return (
721
+ <article key={item.id} className="avaliacao-modelos-card">
722
+ <div className="avaliacao-modelos-card-head">
723
+ <div className="avaliacao-modelos-card-title">
724
  <strong>{`Aval. ${idx + 1}`}</strong>
725
  <span>{item.modelo}</span>
726
  </div>
727
+ <div className="avaliacao-modelos-card-actions">
728
+ <button type="button" className="avaliacao-modelos-delete-btn" onClick={() => onExcluirCard(item.id)} disabled={loading}>
729
  Excluir
730
  </button>
731
  </div>
732
  </div>
733
 
734
+ <div className="avaliacao-modelos-card-subtitle">
735
  {formatarDataHoraIso(item.createdAt)}
736
  </div>
737
 
738
+ <div className="avaliacao-modelos-card-base">
739
  <strong>Estimado / Base:</strong>{' '}
740
  {ehBase ? (
741
+ <span className="avaliacao-modelos-base-pill">Base</span>
742
  ) : (
743
  <span>{calcularComparacaoBase(aval)}</span>
744
  )}
745
  </div>
746
 
747
+ <div className="avaliacao-modelos-vars-list">
748
  {variaveis.map((variavel) => (
749
+ <div key={`${item.id}-var-${variavel}`} className="avaliacao-modelos-vars-item">
750
  <span>{variavel}</span>
751
  <span>
752
  {formatarNumero(aval?.valores_x?.[variavel], 2)} {classificarExtrapolacao(aval?.extrapolacoes?.[variavel])}
 
755
  ))}
756
  </div>
757
 
758
+ <div className="avaliacao-modelos-metrics">
759
  <div><strong>Estimado:</strong> {formatarMoeda(aval.estimado)}</div>
760
  <div><strong>CA -15%:</strong> {formatarMoeda(aval.ca_inf)}</div>
761
  <div><strong>CA +15%:</strong> {formatarMoeda(aval.ca_sup)}</div>
 
765
  <div><strong>Qtd. extrapolações:</strong> {String(aval.qtd_extrapolacoes ?? 0)}</div>
766
  </div>
767
 
768
+ <div className="avaliacao-modelos-graus">
769
  <span style={{ color: corGrau(aval.precisao) }}>
770
  <strong>Precisão:</strong> {String(aval.precisao || '-')}
771
  </span>
frontend/src/components/DataTable.jsx CHANGED
@@ -1,5 +1,10 @@
1
  import React from 'react'
2
 
 
 
 
 
 
3
  function DataTable({
4
  table,
5
  maxHeight = 320,
@@ -11,17 +16,68 @@ function DataTable({
11
  return <div className="empty-box">Sem dados.</div>
12
  }
13
 
14
- const MAX_RENDER_ROWS = 1200
15
- const rowsToRender = table.rows.length > MAX_RENDER_ROWS
16
- ? table.rows.slice(0, MAX_RENDER_ROWS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  : table.rows
18
- const renderTruncated = rowsToRender.length < table.rows.length
 
 
19
  const highlightedSet = Array.isArray(highlightedRowIndices) && highlightedRowIndices.length > 0
20
  ? new Set(highlightedRowIndices.map((item) => String(item)))
21
  : null
22
 
23
  return (
24
- <div className="table-wrapper" style={{ maxHeight }}>
25
  <table>
26
  <thead>
27
  <tr>
@@ -31,24 +87,35 @@ function DataTable({
31
  </tr>
32
  </thead>
33
  <tbody>
 
 
 
 
 
34
  {rowsToRender.map((row, i) => {
 
35
  const rowIndex = row?.[highlightIndexColumn]
36
  const rowClassName = highlightedSet && rowIndex != null && highlightedSet.has(String(rowIndex))
37
  ? highlightClassName
38
  : ''
39
  return (
40
- <tr key={i} className={rowClassName}>
41
  {table.columns.map((col) => (
42
- <td key={`${i}-${col}`}>{String(row[col] ?? '')}</td>
43
  ))}
44
  </tr>
45
  )
46
  })}
 
 
 
 
 
47
  </tbody>
48
  </table>
49
- {renderTruncated ? (
50
  <div className="table-hint">
51
- Renderizando {rowsToRender.length} de {table.rows.length} linhas recebidas para manter desempenho.
52
  </div>
53
  ) : null}
54
  {table.truncated ? (
 
1
  import React from 'react'
2
 
3
+ const LIMIAR_RENDERIZACAO_VIRTUAL = 1500
4
+ const ESTIMATIVA_ALTURA_LINHA_PX = 33
5
+ const OVERSCAN_LINHAS = 40
6
+ const MIN_JANELA_LINHAS = 220
7
+
8
  function DataTable({
9
  table,
10
  maxHeight = 320,
 
16
  return <div className="empty-box">Sem dados.</div>
17
  }
18
 
19
+ const totalRows = Array.isArray(table.rows) ? table.rows.length : 0
20
+ const colSpan = Math.max(1, Array.isArray(table.columns) ? table.columns.length : 0)
21
+ const virtualizacaoAtiva = totalRows > LIMIAR_RENDERIZACAO_VIRTUAL
22
+ const wrapperRef = React.useRef(null)
23
+ const [scrollTop, setScrollTop] = React.useState(0)
24
+ const [viewportHeight, setViewportHeight] = React.useState(Number(maxHeight) || 320)
25
+ const tableIdentity = React.useMemo(() => {
26
+ const colunas = Array.isArray(table.columns) ? table.columns.join("|") : ""
27
+ return `${colunas}::${totalRows}`
28
+ }, [table.columns, totalRows])
29
+
30
+ React.useEffect(() => {
31
+ setScrollTop(0)
32
+ if (wrapperRef.current) {
33
+ wrapperRef.current.scrollTop = 0
34
+ setViewportHeight(wrapperRef.current.clientHeight || Number(maxHeight) || 320)
35
+ }
36
+ }, [tableIdentity, maxHeight])
37
+
38
+ React.useEffect(() => {
39
+ if (!wrapperRef.current) return undefined
40
+ const target = wrapperRef.current
41
+ if (typeof ResizeObserver === 'undefined') return undefined
42
+ const observer = new ResizeObserver(() => {
43
+ setViewportHeight(target.clientHeight || Number(maxHeight) || 320)
44
+ })
45
+ observer.observe(target)
46
+ return () => observer.disconnect()
47
+ }, [maxHeight])
48
+
49
+ const onWrapperScroll = React.useCallback((event) => {
50
+ if (!virtualizacaoAtiva) return
51
+ setScrollTop(event.currentTarget.scrollTop || 0)
52
+ }, [virtualizacaoAtiva])
53
+
54
+ const alturaViewport = Math.max(1, Number(viewportHeight) || Number(maxHeight) || 320)
55
+ const linhasVisiveis = Math.max(1, Math.ceil(alturaViewport / ESTIMATIVA_ALTURA_LINHA_PX))
56
+ const janelaLinhas = Math.max(MIN_JANELA_LINHAS, linhasVisiveis + (OVERSCAN_LINHAS * 2))
57
+
58
+ let startIndex = 0
59
+ let endIndex = totalRows
60
+ if (virtualizacaoAtiva) {
61
+ const primeiraLinhaVisivel = Math.max(0, Math.floor(scrollTop / ESTIMATIVA_ALTURA_LINHA_PX))
62
+ startIndex = Math.max(0, primeiraLinhaVisivel - OVERSCAN_LINHAS)
63
+ endIndex = Math.min(totalRows, startIndex + janelaLinhas)
64
+ if (endIndex - startIndex < janelaLinhas && startIndex > 0) {
65
+ startIndex = Math.max(0, endIndex - janelaLinhas)
66
+ }
67
+ }
68
+
69
+ const rowsToRender = virtualizacaoAtiva
70
+ ? table.rows.slice(startIndex, endIndex)
71
  : table.rows
72
+ const topSpacerHeight = virtualizacaoAtiva ? startIndex * ESTIMATIVA_ALTURA_LINHA_PX : 0
73
+ const bottomSpacerHeight = virtualizacaoAtiva ? Math.max(0, (totalRows - endIndex) * ESTIMATIVA_ALTURA_LINHA_PX) : 0
74
+
75
  const highlightedSet = Array.isArray(highlightedRowIndices) && highlightedRowIndices.length > 0
76
  ? new Set(highlightedRowIndices.map((item) => String(item)))
77
  : null
78
 
79
  return (
80
+ <div ref={wrapperRef} className="table-wrapper" style={{ maxHeight }} onScroll={onWrapperScroll}>
81
  <table>
82
  <thead>
83
  <tr>
 
87
  </tr>
88
  </thead>
89
  <tbody>
90
+ {virtualizacaoAtiva && topSpacerHeight > 0 ? (
91
+ <tr className="table-virtual-spacer" aria-hidden="true">
92
+ <td colSpan={colSpan} style={{ height: `${topSpacerHeight}px` }} />
93
+ </tr>
94
+ ) : null}
95
  {rowsToRender.map((row, i) => {
96
+ const absoluteIndex = virtualizacaoAtiva ? startIndex + i : i
97
  const rowIndex = row?.[highlightIndexColumn]
98
  const rowClassName = highlightedSet && rowIndex != null && highlightedSet.has(String(rowIndex))
99
  ? highlightClassName
100
  : ''
101
  return (
102
+ <tr key={absoluteIndex} className={rowClassName}>
103
  {table.columns.map((col) => (
104
+ <td key={`${absoluteIndex}-${col}`}>{String(row[col] ?? '')}</td>
105
  ))}
106
  </tr>
107
  )
108
  })}
109
+ {virtualizacaoAtiva && bottomSpacerHeight > 0 ? (
110
+ <tr className="table-virtual-spacer" aria-hidden="true">
111
+ <td colSpan={colSpan} style={{ height: `${bottomSpacerHeight}px` }} />
112
+ </tr>
113
+ ) : null}
114
  </tbody>
115
  </table>
116
+ {virtualizacaoAtiva ? (
117
  <div className="table-hint">
118
+ Exibindo janela virtual com {rowsToRender.length} linhas de {totalRows} (ativada acima de {LIMIAR_RENDERIZACAO_VIRTUAL} linhas, sem supressão de dados).
119
  </div>
120
  ) : null}
121
  {table.truncated ? (
frontend/src/components/InicioTab.jsx CHANGED
@@ -6,11 +6,10 @@ export default function InicioTab() {
6
  <section className="inicio-card">
7
  <h3>Resumo rápido</h3>
8
  <ul className="inicio-lista">
9
- <li><strong>Pesquisa:</strong> encontra modelos compatíveis com os filtros informados.</li>
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.
 
6
  <section className="inicio-card">
7
  <h3>Resumo rápido</h3>
8
  <ul className="inicio-lista">
9
+ <li><strong>Pesquisa/Visualização:</strong> encontra modelos e abre visualização completa a partir da pesquisa.</li>
10
  <li><strong>Elaboração/Edição:</strong> cria, ajusta e exporta modelos estatísticos.</li>
11
+ <li><strong>Avaliação:</strong> compara avaliações de modelos diferentes em cards no mesmo painel.</li>
12
+ <li><strong>Repositório de Modelos:</strong> lista, adiciona e remove modelos do acervo central.</li>
 
13
  </ul>
14
  <p className="inicio-creditos">
15
  Aplicativo criado por Guilherme Silberfarb Costa e David Schuch Bertoglio.
frontend/src/styles.css CHANGED
@@ -66,23 +66,26 @@ textarea {
66
  border-radius: var(--radius-lg);
67
  background: linear-gradient(130deg, #fffdf9 0%, #ffffff 55%, #f6fbff 100%);
68
  box-shadow: var(--shadow-md);
69
- padding: 18px 22px;
70
- display: grid;
71
- grid-template-columns: 130px 1fr;
72
- gap: 16px;
73
  align-items: center;
74
- border-left: 6px solid var(--accent);
 
75
  }
76
 
77
  .app-header.app-header-logo-only {
78
  display: flex;
79
  justify-content: center;
80
  align-items: center;
81
- grid-template-columns: none;
82
  border-left: none;
83
  padding: 12px 18px;
84
  }
85
 
 
 
 
 
86
  .brand-mark {
87
  background: linear-gradient(180deg, #ffffff 0%, #fff6ea 100%);
88
  border-radius: 14px;
@@ -99,6 +102,13 @@ textarea {
99
  padding: 0;
100
  }
101
 
 
 
 
 
 
 
 
102
  .brand-mark img {
103
  width: 100%;
104
  max-width: 98px;
@@ -109,6 +119,12 @@ textarea {
109
  max-width: 520px;
110
  }
111
 
 
 
 
 
 
 
112
  .brand-copy h1 {
113
  margin: 0;
114
  font-family: 'Sora', sans-serif;
@@ -135,37 +151,39 @@ textarea {
135
  }
136
 
137
  .tabs {
138
- display: grid;
139
- grid-template-columns: repeat(5, minmax(0, 1fr));
 
140
  gap: 10px;
 
141
  }
142
 
143
  .tab-pill {
144
- text-align: left;
145
- border: 1px solid var(--border);
146
- border-radius: var(--radius-md);
147
- background: linear-gradient(180deg, #f6f9fc 0%, #edf3f8 100%);
148
- padding: 11px 12px;
149
- color: #2f4359;
150
  cursor: pointer;
151
  transition: all 0.2s ease;
152
  display: flex;
153
- flex-direction: column;
154
- gap: 2px;
 
155
  }
156
 
157
  .tab-pill strong {
158
  font-family: 'Sora', sans-serif;
159
- font-size: 0.93rem;
160
  }
161
 
162
  .tab-pill small {
163
- color: #607589;
164
- font-size: 0.76rem;
165
  }
166
 
167
  .tab-pill:hover {
168
- border-color: #c5d4e2;
169
  transform: translateY(-1px);
170
  }
171
 
@@ -179,6 +197,86 @@ textarea {
179
  color: rgba(255, 255, 255, 0.88);
180
  }
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  .auth-status-bar {
183
  border: 1px solid #d8e4f0;
184
  background: #f8fbff;
@@ -238,6 +336,27 @@ textarea {
238
  }
239
 
240
  @media (max-width: 760px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  .auth-form {
242
  grid-template-columns: 1fr;
243
  }
@@ -2077,34 +2196,34 @@ button.pesquisa-coluna-remove:hover {
2077
  margin-top: 10px;
2078
  }
2079
 
2080
- .avaliacao-beta-groups {
2081
  gap: 16px;
2082
  }
2083
 
2084
- .avaliacao-beta-flow {
2085
  display: grid;
2086
  gap: 14px;
2087
  }
2088
 
2089
- .avaliacao-beta-model-block {
2090
  display: grid;
2091
  gap: 10px;
2092
  }
2093
 
2094
- .avaliacao-beta-title {
2095
  margin: 0;
2096
  color: #2f465c;
2097
  font-family: 'Sora', sans-serif;
2098
  font-size: 1rem;
2099
  }
2100
 
2101
- .avaliacao-beta-cards-grid {
2102
  display: grid;
2103
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
2104
  gap: 12px;
2105
  }
2106
 
2107
- .avaliacao-beta-card {
2108
  border: 1px solid #d7e3ef;
2109
  border-radius: 12px;
2110
  background: #fff;
@@ -2114,42 +2233,42 @@ button.pesquisa-coluna-remove:hover {
2114
  gap: 8px;
2115
  }
2116
 
2117
- .avaliacao-beta-card-head {
2118
  display: flex;
2119
  align-items: flex-start;
2120
  justify-content: space-between;
2121
  gap: 8px;
2122
  }
2123
 
2124
- .avaliacao-beta-card-title {
2125
  display: grid;
2126
  gap: 1px;
2127
  }
2128
 
2129
- .avaliacao-beta-card-title strong {
2130
  color: #2f465c;
2131
  font-family: 'Sora', sans-serif;
2132
  font-size: 0.87rem;
2133
  }
2134
 
2135
- .avaliacao-beta-card-title span {
2136
  color: #4f667d;
2137
  font-size: 0.79rem;
2138
  font-weight: 600;
2139
  line-height: 1.2;
2140
  }
2141
 
2142
- .avaliacao-beta-card-subtitle {
2143
  color: #70869b;
2144
  font-size: 0.75rem;
2145
  }
2146
 
2147
- .avaliacao-beta-card-actions {
2148
  display: inline-flex;
2149
  align-items: center;
2150
  }
2151
 
2152
- .avaliacao-beta-delete-btn {
2153
  min-height: 28px;
2154
  padding: 3px 8px;
2155
  font-size: 0.74rem;
@@ -2161,7 +2280,7 @@ button.pesquisa-coluna-remove:hover {
2161
  color: #a63446;
2162
  }
2163
 
2164
- .avaliacao-beta-card-base {
2165
  color: #3c546a;
2166
  font-size: 0.8rem;
2167
  border: 1px solid #e3ebf3;
@@ -2170,7 +2289,7 @@ button.pesquisa-coluna-remove:hover {
2170
  padding: 6px 8px;
2171
  }
2172
 
2173
- .avaliacao-beta-base-pill {
2174
  display: inline-block;
2175
  border: 1px solid #f2cd91;
2176
  border-radius: 999px;
@@ -2181,12 +2300,12 @@ button.pesquisa-coluna-remove:hover {
2181
  font-weight: 700;
2182
  }
2183
 
2184
- .avaliacao-beta-vars-list {
2185
  display: grid;
2186
  gap: 4px;
2187
  }
2188
 
2189
- .avaliacao-beta-vars-item {
2190
  display: grid;
2191
  grid-template-columns: minmax(88px, auto) minmax(0, 1fr);
2192
  gap: 6px;
@@ -2198,24 +2317,24 @@ button.pesquisa-coluna-remove:hover {
2198
  font-size: 0.78rem;
2199
  }
2200
 
2201
- .avaliacao-beta-vars-item span:first-child {
2202
  color: #526a80;
2203
  font-weight: 700;
2204
  }
2205
 
2206
- .avaliacao-beta-vars-item span:last-child {
2207
  color: #2e475d;
2208
  text-align: right;
2209
  }
2210
 
2211
- .avaliacao-beta-metrics {
2212
  display: grid;
2213
  gap: 3px;
2214
  color: #40596f;
2215
  font-size: 0.78rem;
2216
  }
2217
 
2218
- .avaliacao-beta-graus {
2219
  display: grid;
2220
  gap: 3px;
2221
  padding-top: 2px;
@@ -2800,6 +2919,16 @@ button.btn-upload-select {
2800
  font-size: 0.8rem;
2801
  }
2802
 
 
 
 
 
 
 
 
 
 
 
2803
  .empty-box {
2804
  border: 1px dashed #bfd0e0;
2805
  border-radius: 12px;
 
66
  border-radius: var(--radius-lg);
67
  background: linear-gradient(130deg, #fffdf9 0%, #ffffff 55%, #f6fbff 100%);
68
  box-shadow: var(--shadow-md);
69
+ padding: 12px 18px;
70
+ display: flex;
71
+ justify-content: space-between;
 
72
  align-items: center;
73
+ gap: 16px;
74
+ border-left: none;
75
  }
76
 
77
  .app-header.app-header-logo-only {
78
  display: flex;
79
  justify-content: center;
80
  align-items: center;
 
81
  border-left: none;
82
  padding: 12px 18px;
83
  }
84
 
85
+ .app-header.app-header-logged {
86
+ justify-content: space-between;
87
+ }
88
+
89
  .brand-mark {
90
  background: linear-gradient(180deg, #ffffff 0%, #fff6ea 100%);
91
  border-radius: 14px;
 
102
  padding: 0;
103
  }
104
 
105
+ .app-header.app-header-logged .brand-mark {
106
+ width: min(220px, 34vw);
107
+ border: none;
108
+ background: transparent;
109
+ padding: 0;
110
+ }
111
+
112
  .brand-mark img {
113
  width: 100%;
114
  max-width: 98px;
 
119
  max-width: 520px;
120
  }
121
 
122
+ .app-header.app-header-logged .brand-mark img {
123
+ max-width: 220px;
124
+ transform: scale(1.2);
125
+ transform-origin: left center;
126
+ }
127
+
128
  .brand-copy h1 {
129
  margin: 0;
130
  font-family: 'Sora', sans-serif;
 
151
  }
152
 
153
  .tabs {
154
+ display: flex;
155
+ flex-wrap: wrap;
156
+ justify-content: flex-end;
157
  gap: 10px;
158
+ margin-left: auto;
159
  }
160
 
161
  .tab-pill {
162
+ text-align: center;
163
+ border: 1px solid #d2deea;
164
+ border-radius: 10px;
165
+ background: linear-gradient(180deg, #f7fafd 0%, #edf3f8 100%);
166
+ padding: 8px 12px;
167
+ color: #32475d;
168
  cursor: pointer;
169
  transition: all 0.2s ease;
170
  display: flex;
171
+ align-items: center;
172
+ gap: 0;
173
+ min-height: 38px;
174
  }
175
 
176
  .tab-pill strong {
177
  font-family: 'Sora', sans-serif;
178
+ font-size: 0.84rem;
179
  }
180
 
181
  .tab-pill small {
182
+ display: none;
 
183
  }
184
 
185
  .tab-pill:hover {
186
+ border-color: #c1d2e2;
187
  transform: translateY(-1px);
188
  }
189
 
 
197
  color: rgba(255, 255, 255, 0.88);
198
  }
199
 
200
+ .app-top-actions {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: flex-end;
204
+ gap: 10px;
205
+ flex: 1;
206
+ min-width: 0;
207
+ }
208
+
209
+ .settings-menu {
210
+ position: relative;
211
+ flex: 0 0 auto;
212
+ }
213
+
214
+ .settings-gear-btn {
215
+ min-width: 40px;
216
+ min-height: 38px;
217
+ border-radius: 10px;
218
+ border: 1px solid #7da9da;
219
+ background: linear-gradient(180deg, #e4f0ff 0%, #d2e6ff 100%);
220
+ color: #234d7c;
221
+ font-size: 1.05rem;
222
+ font-weight: 700;
223
+ display: inline-flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ }
227
+
228
+ .settings-gear-btn:hover {
229
+ border-color: #6d9bd1;
230
+ background: linear-gradient(180deg, #d9eaff 0%, #c8ddfb 100%);
231
+ }
232
+
233
+ .settings-menu-panel {
234
+ position: absolute;
235
+ top: calc(100% + 8px);
236
+ right: 0;
237
+ width: min(320px, 82vw);
238
+ border: 1px solid #c7d9ec;
239
+ border-radius: 12px;
240
+ background: #ffffff;
241
+ box-shadow: var(--shadow-md);
242
+ padding: 10px;
243
+ z-index: 15;
244
+ display: grid;
245
+ gap: 10px;
246
+ }
247
+
248
+ .settings-user-summary {
249
+ color: #2f4962;
250
+ font-size: 0.84rem;
251
+ padding-bottom: 8px;
252
+ border-bottom: 1px solid #e3ebf3;
253
+ }
254
+
255
+ .settings-menu-actions {
256
+ display: grid;
257
+ gap: 8px;
258
+ }
259
+
260
+ .settings-menu-btn {
261
+ border: 1px solid #c8d8e8;
262
+ border-radius: 9px;
263
+ background: linear-gradient(180deg, #f7fbff 0%, #edf3fa 100%);
264
+ color: #35536e;
265
+ font-weight: 700;
266
+ text-align: left;
267
+ padding: 8px 10px;
268
+ }
269
+
270
+ .settings-menu-btn:disabled {
271
+ opacity: 0.55;
272
+ }
273
+
274
+ .settings-menu-btn.settings-menu-btn-danger {
275
+ border-color: #e4b7bd;
276
+ background: linear-gradient(180deg, #fff5f7 0%, #feecef 100%);
277
+ color: #a12f40;
278
+ }
279
+
280
  .auth-status-bar {
281
  border: 1px solid #d8e4f0;
282
  background: #f8fbff;
 
336
  }
337
 
338
  @media (max-width: 760px) {
339
+ .app-header.app-header-logged {
340
+ flex-direction: column;
341
+ align-items: flex-start;
342
+ gap: 10px;
343
+ }
344
+
345
+ .app-top-actions {
346
+ width: 100%;
347
+ justify-content: space-between;
348
+ align-items: flex-start;
349
+ }
350
+
351
+ .tabs {
352
+ justify-content: flex-start;
353
+ }
354
+
355
+ .settings-menu-panel {
356
+ right: auto;
357
+ left: 0;
358
+ }
359
+
360
  .auth-form {
361
  grid-template-columns: 1fr;
362
  }
 
2196
  margin-top: 10px;
2197
  }
2198
 
2199
+ .avaliacao-modelos-groups {
2200
  gap: 16px;
2201
  }
2202
 
2203
+ .avaliacao-modelos-flow {
2204
  display: grid;
2205
  gap: 14px;
2206
  }
2207
 
2208
+ .avaliacao-modelos-model-block {
2209
  display: grid;
2210
  gap: 10px;
2211
  }
2212
 
2213
+ .avaliacao-modelos-title {
2214
  margin: 0;
2215
  color: #2f465c;
2216
  font-family: 'Sora', sans-serif;
2217
  font-size: 1rem;
2218
  }
2219
 
2220
+ .avaliacao-modelos-cards-grid {
2221
  display: grid;
2222
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
2223
  gap: 12px;
2224
  }
2225
 
2226
+ .avaliacao-modelos-card {
2227
  border: 1px solid #d7e3ef;
2228
  border-radius: 12px;
2229
  background: #fff;
 
2233
  gap: 8px;
2234
  }
2235
 
2236
+ .avaliacao-modelos-card-head {
2237
  display: flex;
2238
  align-items: flex-start;
2239
  justify-content: space-between;
2240
  gap: 8px;
2241
  }
2242
 
2243
+ .avaliacao-modelos-card-title {
2244
  display: grid;
2245
  gap: 1px;
2246
  }
2247
 
2248
+ .avaliacao-modelos-card-title strong {
2249
  color: #2f465c;
2250
  font-family: 'Sora', sans-serif;
2251
  font-size: 0.87rem;
2252
  }
2253
 
2254
+ .avaliacao-modelos-card-title span {
2255
  color: #4f667d;
2256
  font-size: 0.79rem;
2257
  font-weight: 600;
2258
  line-height: 1.2;
2259
  }
2260
 
2261
+ .avaliacao-modelos-card-subtitle {
2262
  color: #70869b;
2263
  font-size: 0.75rem;
2264
  }
2265
 
2266
+ .avaliacao-modelos-card-actions {
2267
  display: inline-flex;
2268
  align-items: center;
2269
  }
2270
 
2271
+ .avaliacao-modelos-delete-btn {
2272
  min-height: 28px;
2273
  padding: 3px 8px;
2274
  font-size: 0.74rem;
 
2280
  color: #a63446;
2281
  }
2282
 
2283
+ .avaliacao-modelos-card-base {
2284
  color: #3c546a;
2285
  font-size: 0.8rem;
2286
  border: 1px solid #e3ebf3;
 
2289
  padding: 6px 8px;
2290
  }
2291
 
2292
+ .avaliacao-modelos-base-pill {
2293
  display: inline-block;
2294
  border: 1px solid #f2cd91;
2295
  border-radius: 999px;
 
2300
  font-weight: 700;
2301
  }
2302
 
2303
+ .avaliacao-modelos-vars-list {
2304
  display: grid;
2305
  gap: 4px;
2306
  }
2307
 
2308
+ .avaliacao-modelos-vars-item {
2309
  display: grid;
2310
  grid-template-columns: minmax(88px, auto) minmax(0, 1fr);
2311
  gap: 6px;
 
2317
  font-size: 0.78rem;
2318
  }
2319
 
2320
+ .avaliacao-modelos-vars-item span:first-child {
2321
  color: #526a80;
2322
  font-weight: 700;
2323
  }
2324
 
2325
+ .avaliacao-modelos-vars-item span:last-child {
2326
  color: #2e475d;
2327
  text-align: right;
2328
  }
2329
 
2330
+ .avaliacao-modelos-metrics {
2331
  display: grid;
2332
  gap: 3px;
2333
  color: #40596f;
2334
  font-size: 0.78rem;
2335
  }
2336
 
2337
+ .avaliacao-modelos-graus {
2338
  display: grid;
2339
  gap: 3px;
2340
  padding-top: 2px;
 
2919
  font-size: 0.8rem;
2920
  }
2921
 
2922
+ .table-virtual-spacer td {
2923
+ border-bottom: none;
2924
+ padding: 0;
2925
+ background: transparent;
2926
+ }
2927
+
2928
+ .table-wrapper tr.table-virtual-spacer:hover td {
2929
+ background: transparent;
2930
+ }
2931
+
2932
  .empty-box {
2933
  border: 1px dashed #bfd0e0;
2934
  border-radius: 12px;