Guilherme Silberfarb Costa commited on
Commit
3c854d0
·
1 Parent(s): 614e632

inclusao de menu na lateral esquerda

Browse files
frontend/src/App.jsx CHANGED
@@ -38,6 +38,9 @@ export default function App() {
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'
@@ -180,6 +183,51 @@ export default function App() {
180
  }
181
  }, [authUser])
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  async function onSubmitLogin(event) {
184
  event.preventDefault()
185
  setAuthError('')
@@ -275,9 +323,17 @@ export default function App() {
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>
@@ -351,6 +407,21 @@ export default function App() {
351
  ) : null}
352
  </header>
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  {authLoading ? <div className="status-line">Validando autenticação...</div> : null}
355
 
356
  {!authLoading && !authUser ? (
 
38
  const [logsUsuario, setLogsUsuario] = useState('')
39
  const [logsPage, setLogsPage] = useState(1)
40
  const [settingsOpen, setSettingsOpen] = useState(false)
41
+ const [showScrollHomeBtn, setShowScrollHomeBtn] = useState(false)
42
+ const [scrollHomeBtnLeft, setScrollHomeBtnLeft] = useState(8)
43
+ const headerRef = useRef(null)
44
  const settingsMenuRef = useRef(null)
45
 
46
  const isAdmin = String(authUser?.perfil || '').toLowerCase() === 'admin'
 
183
  }
184
  }, [authUser])
185
 
186
+ useEffect(() => {
187
+ if (typeof window === 'undefined') return undefined
188
+ if (!authUser) {
189
+ setShowScrollHomeBtn(false)
190
+ setScrollHomeBtnLeft(8)
191
+ return undefined
192
+ }
193
+
194
+ function resolveHomeButtonLeft() {
195
+ const buttonSize = 42
196
+ const navAnchor = document.querySelector('.elaboracao-side-nav-item')
197
+ if (navAnchor && typeof navAnchor.getBoundingClientRect === 'function') {
198
+ const rect = navAnchor.getBoundingClientRect()
199
+ return Math.max(8, rect.left + ((rect.width - buttonSize) / 2))
200
+ }
201
+ const shell = document.querySelector('.app-shell')
202
+ if (shell && typeof shell.getBoundingClientRect === 'function') {
203
+ const rect = shell.getBoundingClientRect()
204
+ return Math.max(8, rect.left)
205
+ }
206
+ return 8
207
+ }
208
+
209
+ function updateScrollHomeVisibility() {
210
+ const headerEl = headerRef.current
211
+ if (!headerEl) {
212
+ setShowScrollHomeBtn(false)
213
+ return
214
+ }
215
+ const rect = headerEl.getBoundingClientRect()
216
+ const shouldShow = rect.bottom <= 0
217
+ const nextLeft = resolveHomeButtonLeft()
218
+ setShowScrollHomeBtn((current) => (current === shouldShow ? current : shouldShow))
219
+ setScrollHomeBtnLeft((current) => (Math.abs(current - nextLeft) < 0.5 ? current : nextLeft))
220
+ }
221
+
222
+ updateScrollHomeVisibility()
223
+ window.addEventListener('scroll', updateScrollHomeVisibility, { passive: true })
224
+ window.addEventListener('resize', updateScrollHomeVisibility)
225
+ return () => {
226
+ window.removeEventListener('scroll', updateScrollHomeVisibility)
227
+ window.removeEventListener('resize', updateScrollHomeVisibility)
228
+ }
229
+ }, [authUser])
230
+
231
  async function onSubmitLogin(event) {
232
  event.preventDefault()
233
  setAuthError('')
 
323
  setShowStartupIntro(false)
324
  }
325
 
326
+ function onScrollToHeader() {
327
+ if (typeof window === 'undefined') return
328
+ const headerEl = headerRef.current
329
+ if (!headerEl) return
330
+ const top = Math.max(0, window.scrollY + headerEl.getBoundingClientRect().top - 8)
331
+ window.scrollTo({ top, behavior: 'smooth' })
332
+ }
333
+
334
  return (
335
  <div className="app-shell">
336
+ <header ref={headerRef} className={authUser ? 'app-header app-header-logged' : 'app-header app-header-logo-only'}>
337
  <div className="brand-mark" aria-hidden="true">
338
  <img src={`${import.meta.env.BASE_URL}logo_mesa.png`} alt="MESA" />
339
  </div>
 
407
  ) : null}
408
  </header>
409
 
410
+ {authUser && showScrollHomeBtn ? (
411
+ <button
412
+ type="button"
413
+ className="scroll-home-btn"
414
+ style={{ left: `${scrollHomeBtnLeft}px` }}
415
+ onClick={onScrollToHeader}
416
+ aria-label="Voltar ao cabeçalho"
417
+ title="Voltar ao cabeçalho"
418
+ >
419
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
420
+ <path d="M11.49 2.26a.75.75 0 0 1 1.02 0l9 8.25a.75.75 0 0 1-1.02 1.1L20 11.12V20a2 2 0 0 1-2 2h-3.75a.75.75 0 0 1-.75-.75V16a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25v5.25a.75.75 0 0 1-.75.75H6a2 2 0 0 1-2-2v-8.88l-.49.49a.75.75 0 0 1-1.02-1.1l9-8.25Z" />
421
+ </svg>
422
+ </button>
423
+ ) : null}
424
+
425
  {authLoading ? <div className="status-line">Validando autenticação...</div> : null}
426
 
427
  {!authLoading && !authUser ? (
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -37,6 +37,27 @@ const MAPA_RESIDUOS_EXTREMO_LIVRE = 'livre'
37
  const MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT = MAPA_RESIDUOS_EXTREMO_LIVRE
38
  const MAPA_RESIDUOS_EXTREMO_ABS_OPTIONS = [2, 3, 4, 5, 6, 7, 8, 10]
39
  const OUTLIER_RECURSIVO_TOOLTIP = 'Aplicar com recursividade executa os mesmos filtros em ciclos sucessivos: nos bastidores, simula a exclusão dos índices encontrados, recalcula o ajuste do modelo e as métricas de outlier e reaplica os filtros, repetindo até não surgir nenhum índice novo. Para você, o resultado prático é que o campo "A excluir" é preenchido automaticamente com o conjunto total de índices encontrados nessa simulação recursiva.'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  function grauBadgeClass(value) {
42
  const grau = Number(value)
@@ -141,6 +162,22 @@ function parseIndicesFromText(value) {
141
  return Array.from(indices)
142
  }
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  function toFiniteNumber(value) {
145
  if (typeof value === 'number') {
146
  return Number.isFinite(value) ? value : null
@@ -837,6 +874,10 @@ export default function ElaboracaoTab({ sessionId }) {
837
  const elaboracaoRootRef = useRef(null)
838
  const [disabledHint, setDisabledHint] = useState(null)
839
  const [sectionsMountKey, setSectionsMountKey] = useState(0)
 
 
 
 
840
 
841
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
842
  const mapaModoDisponivel = mapaVariavel !== MAPA_VARIAVEL_PADRAO
@@ -1248,6 +1289,8 @@ export default function ElaboracaoTab({ sessionId }) {
1248
  ),
1249
  )
1250
  const baseCarregada = Boolean(dados)
 
 
1251
 
1252
  const hideDisabledHint = useCallback(() => {
1253
  setDisabledHint(null)
@@ -1302,6 +1345,200 @@ export default function ElaboracaoTab({ sessionId }) {
1302
  })
1303
  }, [])
1304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1305
  useEffect(() => {
1306
  if (coordsInfo && !coordsInfo.tem_coords) {
1307
  setCoordsMode('menu')
@@ -3055,9 +3292,48 @@ export default function ElaboracaoTab({ sessionId }) {
3055
  setPercentuais([])
3056
  }
3057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3058
  return (
3059
  <div ref={elaboracaoRootRef} className="tab-content">
3060
- <div key={`sections-${sectionsMountKey}`} className="workflow-sections-stack">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3061
  <SectionBlock step="1" title="Importar Dados" subtitle="Upload de CSV, Excel ou .dai com recuperação do fluxo.">
3062
  <div className="section1-groups">
3063
  <div className="subpanel section1-group">
@@ -5139,6 +5415,7 @@ export default function ElaboracaoTab({ sessionId }) {
5139
  </SectionBlock>
5140
  </>
5141
  ) : null}
 
5142
  </div>
5143
 
5144
  {disabledHint ? (
 
37
  const MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT = MAPA_RESIDUOS_EXTREMO_LIVRE
38
  const MAPA_RESIDUOS_EXTREMO_ABS_OPTIONS = [2, 3, 4, 5, 6, 7, 8, 10]
39
  const OUTLIER_RECURSIVO_TOOLTIP = 'Aplicar com recursividade executa os mesmos filtros em ciclos sucessivos: nos bastidores, simula a exclusão dos índices encontrados, recalcula o ajuste do modelo e as métricas de outlier e reaplica os filtros, repetindo até não surgir nenhum índice novo. Para você, o resultado prático é que o campo "A excluir" é preenchido automaticamente com o conjunto total de índices encontrados nessa simulação recursiva.'
40
+ const ELABORACAO_SECOES_NAV = [
41
+ { step: '1', title: 'Importar Dados' },
42
+ { step: '2', title: 'Resolver Coordenadas' },
43
+ { step: '3', title: 'Visualizar Mapa dos Dados Importados' },
44
+ { step: '4', title: 'Aplicar Filtros' },
45
+ { step: '5', title: 'Selecionar Data de Mercado' },
46
+ { step: '6', title: 'Selecionar Variável Dependente' },
47
+ { step: '7', title: 'Selecionar Variáveis Independentes' },
48
+ { step: '8', title: 'Estatísticas das Variáveis Selecionadas' },
49
+ { step: '9', title: 'Teste de Micronumerosidade' },
50
+ { step: '10', title: 'Gráficos de Dispersão das Variáveis Independentes' },
51
+ { step: '11', title: 'Transformações Sugeridas' },
52
+ { step: '12', title: 'Aplicação das Transformações' },
53
+ { step: '13', title: 'Visualizar Mapa dos Dados de Mercado' },
54
+ { step: '14', title: 'Diagnóstico de Modelo' },
55
+ { step: '15', title: 'Gráficos de Diagnóstico do Modelo' },
56
+ { step: '16', title: 'Analisar Resíduos' },
57
+ { step: '17', title: 'Exclusão ou Reinclusão de Outliers' },
58
+ { step: '18', title: 'Avaliação de Imóvel' },
59
+ { step: '19', title: 'Exportar Modelo' },
60
+ ]
61
 
62
  function grauBadgeClass(value) {
63
  const grau = Number(value)
 
162
  return Array.from(indices)
163
  }
164
 
165
+ function normalizeSectionSteps(values) {
166
+ if (!Array.isArray(values)) return []
167
+ return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))]
168
+ .sort((a, b) => Number(a) - Number(b))
169
+ }
170
+
171
+ function isSameSectionStepList(currentList, nextList) {
172
+ if (currentList === nextList) return true
173
+ if (!Array.isArray(currentList) || !Array.isArray(nextList)) return false
174
+ if (currentList.length !== nextList.length) return false
175
+ for (let index = 0; index < currentList.length; index += 1) {
176
+ if (currentList[index] !== nextList[index]) return false
177
+ }
178
+ return true
179
+ }
180
+
181
  function toFiniteNumber(value) {
182
  if (typeof value === 'number') {
183
  return Number.isFinite(value) ? value : null
 
874
  const elaboracaoRootRef = useRef(null)
875
  const [disabledHint, setDisabledHint] = useState(null)
876
  const [sectionsMountKey, setSectionsMountKey] = useState(0)
877
+ const [renderedSectionSteps, setRenderedSectionSteps] = useState(() => ['1'])
878
+ const [visibleSectionSteps, setVisibleSectionSteps] = useState(() => ['1'])
879
+ const visibleSectionStepsRef = useRef(new Set(['1']))
880
+ const [sideNavDynamicStyle, setSideNavDynamicStyle] = useState({})
881
 
882
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
883
  const mapaModoDisponivel = mapaVariavel !== MAPA_VARIAVEL_PADRAO
 
1289
  ),
1290
  )
1291
  const baseCarregada = Boolean(dados)
1292
+ const renderedSectionStepsSet = useMemo(() => new Set(renderedSectionSteps), [renderedSectionSteps])
1293
+ const visibleSectionStepsSet = useMemo(() => new Set(visibleSectionSteps), [visibleSectionSteps])
1294
 
1295
  const hideDisabledHint = useCallback(() => {
1296
  setDisabledHint(null)
 
1345
  })
1346
  }, [])
1347
 
1348
+ useEffect(() => {
1349
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
1350
+ setRenderedSectionSteps(['1'])
1351
+ setVisibleSectionSteps(['1'])
1352
+ visibleSectionStepsRef.current = new Set(['1'])
1353
+ return undefined
1354
+ }
1355
+
1356
+ const root = elaboracaoRootRef.current || document
1357
+ const sections = Array.from(root.querySelectorAll('.workflow-section[data-section-step]'))
1358
+ const renderedSteps = normalizeSectionSteps(sections.map((section) => section.getAttribute('data-section-step')))
1359
+ const nextRenderedSteps = renderedSteps.length > 0 ? renderedSteps : ['1']
1360
+
1361
+ setRenderedSectionSteps((current) => (isSameSectionStepList(current, nextRenderedSteps) ? current : nextRenderedSteps))
1362
+
1363
+ if (sections.length === 0) {
1364
+ setVisibleSectionSteps(['1'])
1365
+ visibleSectionStepsRef.current = new Set(['1'])
1366
+ return undefined
1367
+ }
1368
+
1369
+ const getClosestStepToTop = () => {
1370
+ let closestStep = nextRenderedSteps[0] || '1'
1371
+ let closestDistance = Number.POSITIVE_INFINITY
1372
+ const topOffset = 96
1373
+ sections.forEach((section) => {
1374
+ const step = String(section.getAttribute('data-section-step') || '').trim()
1375
+ if (!step) return
1376
+ const rect = section.getBoundingClientRect()
1377
+ const distance = Math.abs(rect.top - topOffset)
1378
+ if (distance < closestDistance) {
1379
+ closestDistance = distance
1380
+ closestStep = step
1381
+ }
1382
+ })
1383
+ return closestStep
1384
+ }
1385
+
1386
+ const addLastStepWhenNearBottom = (steps) => {
1387
+ const normalized = normalizeSectionSteps(steps)
1388
+ const maxScrollY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
1389
+ const nearBottom = maxScrollY > 0 && window.scrollY >= maxScrollY - 2
1390
+ if (!nearBottom) return normalized
1391
+ const lastStep = nextRenderedSteps[nextRenderedSteps.length - 1]
1392
+ if (!lastStep) return normalized
1393
+ return normalizeSectionSteps([...normalized, lastStep])
1394
+ }
1395
+
1396
+ const computeVisibleSteps = () => {
1397
+ const topOffset = 96
1398
+ const viewportBottom = window.innerHeight || document.documentElement.clientHeight || 0
1399
+ const nextVisible = addLastStepWhenNearBottom(
1400
+ sections
1401
+ .filter((section) => {
1402
+ const rect = section.getBoundingClientRect()
1403
+ return rect.bottom > topOffset && rect.top < viewportBottom
1404
+ })
1405
+ .map((section) => section.getAttribute('data-section-step')),
1406
+ )
1407
+ const normalizedVisible = nextVisible.length > 0 ? nextVisible : [getClosestStepToTop()]
1408
+ visibleSectionStepsRef.current = new Set(normalizedVisible)
1409
+ setVisibleSectionSteps((current) => (isSameSectionStepList(current, normalizedVisible) ? current : normalizedVisible))
1410
+ }
1411
+
1412
+ computeVisibleSteps()
1413
+
1414
+ const observer = new IntersectionObserver(
1415
+ () => {
1416
+ computeVisibleSteps()
1417
+ },
1418
+ {
1419
+ root: null,
1420
+ rootMargin: '-96px 0px 0px 0px',
1421
+ threshold: [0, 0.05, 0.2, 0.45, 0.7],
1422
+ },
1423
+ )
1424
+
1425
+ sections.forEach((section) => observer.observe(section))
1426
+
1427
+ let frameId = 0
1428
+ const onScroll = () => {
1429
+ if (frameId) return
1430
+ frameId = window.requestAnimationFrame(() => {
1431
+ frameId = 0
1432
+ computeVisibleSteps()
1433
+ })
1434
+ }
1435
+
1436
+ const onResize = () => {
1437
+ computeVisibleSteps()
1438
+ }
1439
+
1440
+ window.addEventListener('scroll', onScroll, { passive: true })
1441
+ window.addEventListener('resize', onResize)
1442
+
1443
+ return () => {
1444
+ window.removeEventListener('scroll', onScroll)
1445
+ window.removeEventListener('resize', onResize)
1446
+ if (frameId) {
1447
+ window.cancelAnimationFrame(frameId)
1448
+ }
1449
+ observer.disconnect()
1450
+ }
1451
+ }, [sectionsMountKey, baseCarregada])
1452
+
1453
+ useEffect(() => {
1454
+ if (typeof window === 'undefined') return undefined
1455
+
1456
+ const totalSections = ELABORACAO_SECOES_NAV.length
1457
+ let frameId = 0
1458
+
1459
+ const applyMetrics = () => {
1460
+ if (window.innerWidth <= 760) {
1461
+ setSideNavDynamicStyle((current) => (Object.keys(current || {}).length === 0 ? current : {}))
1462
+ return
1463
+ }
1464
+
1465
+ const topOffset = 96
1466
+ const bottomOffset = 14
1467
+ const availableHeight = Math.max(120, window.innerHeight - topOffset - bottomOffset)
1468
+ const maxSize = 34
1469
+ const minSize = 8
1470
+ const maxGap = 8
1471
+ const minGap = 0
1472
+ const totalGapSlots = Math.max(0, totalSections - 1)
1473
+
1474
+ let size = Math.floor((availableHeight - (minGap * totalGapSlots)) / totalSections)
1475
+ if (!Number.isFinite(size)) size = maxSize
1476
+ size = Math.max(minSize, Math.min(maxSize, size))
1477
+
1478
+ let gap = minGap
1479
+ let usedHeight = (size * totalSections) + (gap * totalGapSlots)
1480
+ let remainingHeight = availableHeight - usedHeight
1481
+
1482
+ if (remainingHeight > 0 && totalGapSlots > 0) {
1483
+ const extraGap = Math.min(maxGap - gap, Math.floor(remainingHeight / totalGapSlots))
1484
+ gap += Math.max(0, extraGap)
1485
+ usedHeight = (size * totalSections) + (gap * totalGapSlots)
1486
+ remainingHeight = availableHeight - usedHeight
1487
+ }
1488
+
1489
+ if (remainingHeight > 0) {
1490
+ const extraSize = Math.min(maxSize - size, Math.floor(remainingHeight / totalSections))
1491
+ size += Math.max(0, extraSize)
1492
+ }
1493
+
1494
+ while ((size * totalSections) + (gap * totalGapSlots) > availableHeight && (gap > minGap || size > minSize)) {
1495
+ if (gap > minGap) {
1496
+ gap -= 1
1497
+ } else {
1498
+ size -= 1
1499
+ }
1500
+ }
1501
+
1502
+ const numericFontSize = Math.max(8, Math.round(size * 0.37))
1503
+ const labelFontSize = Math.max(10, Math.round(size * 0.34))
1504
+ const labelOffset = Math.max(8, Math.round(size * 0.3))
1505
+ const nextStyle = {
1506
+ '--elab-nav-size': `${size}px`,
1507
+ '--elab-nav-gap': `${gap}px`,
1508
+ '--elab-nav-font-size': `${numericFontSize}px`,
1509
+ '--elab-nav-label-font-size': `${labelFontSize}px`,
1510
+ '--elab-nav-label-offset': `${labelOffset}px`,
1511
+ }
1512
+
1513
+ setSideNavDynamicStyle((current) => {
1514
+ const currentStyle = current || {}
1515
+ const currentKeys = Object.keys(currentStyle)
1516
+ const nextKeys = Object.keys(nextStyle)
1517
+ const unchanged = currentKeys.length === nextKeys.length && nextKeys.every((key) => currentStyle[key] === nextStyle[key])
1518
+ return unchanged ? currentStyle : nextStyle
1519
+ })
1520
+ }
1521
+
1522
+ const onResize = () => {
1523
+ if (frameId) {
1524
+ window.cancelAnimationFrame(frameId)
1525
+ }
1526
+ frameId = window.requestAnimationFrame(() => {
1527
+ frameId = 0
1528
+ applyMetrics()
1529
+ })
1530
+ }
1531
+
1532
+ applyMetrics()
1533
+ window.addEventListener('resize', onResize)
1534
+ return () => {
1535
+ window.removeEventListener('resize', onResize)
1536
+ if (frameId) {
1537
+ window.cancelAnimationFrame(frameId)
1538
+ }
1539
+ }
1540
+ }, [])
1541
+
1542
  useEffect(() => {
1543
  if (coordsInfo && !coordsInfo.tem_coords) {
1544
  setCoordsMode('menu')
 
3292
  setPercentuais([])
3293
  }
3294
 
3295
+ function onScrollToSecao(step) {
3296
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
3297
+ const targetStep = String(step || '').trim()
3298
+ if (!targetStep) return
3299
+
3300
+ const root = elaboracaoRootRef.current || document
3301
+ const secao = root.querySelector(`.workflow-section[data-section-step="${targetStep}"]`)
3302
+ if (!secao) return
3303
+
3304
+ const offsetTopo = 96
3305
+ const alvo = Math.max(0, window.scrollY + secao.getBoundingClientRect().top - offsetTopo)
3306
+ window.scrollTo({ top: alvo, behavior: 'smooth' })
3307
+ }
3308
+
3309
  return (
3310
  <div ref={elaboracaoRootRef} className="tab-content">
3311
+ <div className="elaboracao-layout" style={sideNavDynamicStyle}>
3312
+ <aside className="elaboracao-side-nav" aria-label="Navegação de seções da elaboração">
3313
+ <ol className="elaboracao-side-nav-list">
3314
+ {ELABORACAO_SECOES_NAV.map((secao) => {
3315
+ const rendered = renderedSectionStepsSet.has(secao.step)
3316
+ const visible = visibleSectionStepsSet.has(secao.step)
3317
+ return (
3318
+ <li key={`elab-nav-${secao.step}`}>
3319
+ <button
3320
+ type="button"
3321
+ className={`elaboracao-side-nav-item${visible ? ' is-active' : ''}${rendered ? '' : ' is-unavailable'}`}
3322
+ onClick={() => onScrollToSecao(secao.step)}
3323
+ title={`${secao.step} - ${secao.title}`}
3324
+ aria-current={visible ? 'true' : undefined}
3325
+ aria-label={`Seção ${secao.step}: ${secao.title}`}
3326
+ >
3327
+ <span className="elaboracao-side-nav-index" aria-hidden="true">{secao.step}</span>
3328
+ <span className="elaboracao-side-nav-label" aria-hidden="true">{secao.title}</span>
3329
+ </button>
3330
+ </li>
3331
+ )
3332
+ })}
3333
+ </ol>
3334
+ </aside>
3335
+
3336
+ <div key={`sections-${sectionsMountKey}`} className="workflow-sections-stack elaboracao-sections-stack">
3337
  <SectionBlock step="1" title="Importar Dados" subtitle="Upload de CSV, Excel ou .dai com recuperação do fluxo.">
3338
  <div className="section1-groups">
3339
  <div className="subpanel section1-group">
 
5415
  </SectionBlock>
5416
  </>
5417
  ) : null}
5418
+ </div>
5419
  </div>
5420
 
5421
  {disabledHint ? (
frontend/src/styles.css CHANGED
@@ -139,6 +139,35 @@ textarea {
139
  font-size: 1.02rem;
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  .session-id {
143
  display: inline-block;
144
  padding: 5px 10px;
@@ -816,12 +845,118 @@ textarea {
816
  color: var(--ok);
817
  }
818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  .workflow-section {
820
  border: 2px solid #aebfd0;
821
  border-radius: var(--radius-lg);
822
  background: var(--bg-2);
823
  min-width: 0;
824
  max-width: 100%;
 
825
  box-shadow:
826
  0 8px 22px rgba(20, 28, 36, 0.1),
827
  inset 0 0 0 1px #dfe9f3;
@@ -4555,6 +4690,34 @@ button.btn-download-subtle {
4555
  }
4556
 
4557
  @media (max-width: 760px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4558
  .tabs {
4559
  grid-template-columns: 1fr;
4560
  }
 
139
  font-size: 1.02rem;
140
  }
141
 
142
+ .scroll-home-btn {
143
+ position: fixed;
144
+ top: 8px;
145
+ left: 8px;
146
+ width: 42px;
147
+ height: 42px;
148
+ border-radius: 999px;
149
+ border: 1px solid #c96c00;
150
+ background: linear-gradient(180deg, #ffb350 0%, #e67900 100%);
151
+ color: #ffffff;
152
+ display: inline-flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ box-shadow:
156
+ 0 8px 18px rgba(122, 64, 0, 0.36),
157
+ inset 0 0 0 1px rgba(255, 255, 255, 0.24);
158
+ z-index: 120;
159
+ }
160
+
161
+ .scroll-home-btn:hover {
162
+ transform: translateY(-1px) scale(1.04);
163
+ }
164
+
165
+ .scroll-home-btn svg {
166
+ width: 19px;
167
+ height: 19px;
168
+ fill: currentColor;
169
+ }
170
+
171
  .session-id {
172
  display: inline-block;
173
  padding: 5px 10px;
 
845
  color: var(--ok);
846
  }
847
 
848
+ .elaboracao-layout {
849
+ display: grid;
850
+ grid-template-columns: calc(var(--elab-nav-size, 34px) + 8px) minmax(0, 1fr);
851
+ align-items: start;
852
+ gap: 14px;
853
+ }
854
+
855
+ .elaboracao-side-nav {
856
+ position: sticky;
857
+ top: 96px;
858
+ align-self: start;
859
+ z-index: 4;
860
+ }
861
+
862
+ .elaboracao-side-nav-list {
863
+ list-style: none;
864
+ margin: 0;
865
+ padding: 0;
866
+ display: flex;
867
+ flex-direction: column;
868
+ gap: var(--elab-nav-gap, 8px);
869
+ }
870
+
871
+ .elaboracao-side-nav-item {
872
+ position: relative;
873
+ width: var(--elab-nav-size, 34px);
874
+ height: var(--elab-nav-size, 34px);
875
+ padding: 0;
876
+ border-radius: 999px;
877
+ border: 1px solid #c3d2e1;
878
+ background: linear-gradient(180deg, #f8fbff 0%, #edf4fa 100%);
879
+ color: #5b7188;
880
+ display: inline-flex;
881
+ align-items: center;
882
+ justify-content: center;
883
+ font-family: 'Sora', sans-serif;
884
+ font-size: var(--elab-nav-font-size, 12px);
885
+ font-weight: 700;
886
+ box-shadow: 0 4px 10px rgba(23, 37, 50, 0.14);
887
+ transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
888
+ }
889
+
890
+ .elaboracao-side-nav-item .elaboracao-side-nav-index {
891
+ line-height: 1;
892
+ }
893
+
894
+ .elaboracao-side-nav-item.is-active {
895
+ border-color: #bf6500;
896
+ background: linear-gradient(180deg, #ff9f31 0%, #e67900 100%);
897
+ color: #ffffff;
898
+ box-shadow:
899
+ 0 6px 14px rgba(230, 121, 0, 0.34),
900
+ inset 0 0 0 1px rgba(255, 255, 255, 0.25);
901
+ }
902
+
903
+ .elaboracao-side-nav-item.is-unavailable {
904
+ opacity: 0.58;
905
+ border-style: dashed;
906
+ }
907
+
908
+ .elaboracao-side-nav-item:hover,
909
+ .elaboracao-side-nav-item:focus-visible {
910
+ transform: translateX(3px) scale(1.24);
911
+ border-color: #d06f00;
912
+ color: #6c3900;
913
+ z-index: 2;
914
+ }
915
+
916
+ .elaboracao-side-nav-item.is-active:hover,
917
+ .elaboracao-side-nav-item.is-active:focus-visible {
918
+ color: #ffffff;
919
+ }
920
+
921
+ .elaboracao-side-nav-label {
922
+ position: absolute;
923
+ left: calc(100% + var(--elab-nav-label-offset, 10px));
924
+ top: 50%;
925
+ transform: translate(-14px, -50%) scale(0.92);
926
+ transform-origin: left center;
927
+ opacity: 0;
928
+ pointer-events: none;
929
+ white-space: nowrap;
930
+ border-radius: 999px;
931
+ border: 1px solid #cf6f00;
932
+ background: linear-gradient(90deg, #fff4e3 0%, #ffe2bb 100%);
933
+ color: #7a3f00;
934
+ box-shadow: 0 10px 24px rgba(112, 62, 9, 0.24);
935
+ padding: 6px 11px;
936
+ font-family: 'Sora', sans-serif;
937
+ font-size: var(--elab-nav-label-font-size, 11px);
938
+ font-weight: 700;
939
+ letter-spacing: 0.01em;
940
+ transition: opacity 0.18s ease, transform 0.18s ease;
941
+ }
942
+
943
+ .elaboracao-side-nav-item:hover .elaboracao-side-nav-label,
944
+ .elaboracao-side-nav-item:focus-visible .elaboracao-side-nav-label {
945
+ opacity: 1;
946
+ transform: translate(0, -50%) scale(1.24);
947
+ }
948
+
949
+ .elaboracao-sections-stack {
950
+ min-width: 0;
951
+ }
952
+
953
  .workflow-section {
954
  border: 2px solid #aebfd0;
955
  border-radius: var(--radius-lg);
956
  background: var(--bg-2);
957
  min-width: 0;
958
  max-width: 100%;
959
+ scroll-margin-top: 102px;
960
  box-shadow:
961
  0 8px 22px rgba(20, 28, 36, 0.1),
962
  inset 0 0 0 1px #dfe9f3;
 
4690
  }
4691
 
4692
  @media (max-width: 760px) {
4693
+ .elaboracao-layout {
4694
+ grid-template-columns: 1fr;
4695
+ gap: 10px;
4696
+ }
4697
+
4698
+ .elaboracao-side-nav {
4699
+ top: 68px;
4700
+ width: 100%;
4701
+ overflow-x: auto;
4702
+ overflow-y: visible;
4703
+ padding-bottom: 4px;
4704
+ }
4705
+
4706
+ .elaboracao-side-nav-list {
4707
+ flex-direction: row;
4708
+ gap: 6px;
4709
+ }
4710
+
4711
+ .elaboracao-side-nav-label {
4712
+ display: none;
4713
+ }
4714
+
4715
+ .elaboracao-side-nav-item {
4716
+ width: 32px;
4717
+ height: 32px;
4718
+ flex: 0 0 auto;
4719
+ }
4720
+
4721
  .tabs {
4722
  grid-template-columns: 1fr;
4723
  }