Roudrigus commited on
Commit
aad32ad
·
verified ·
1 Parent(s): 1af2e0e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +902 -1015
app.py CHANGED
@@ -1,1015 +1,902 @@
1
- # -*- coding: utf-8 -*-
2
- import streamlit as st
3
- from dotenv import load_dotenv
4
- from datetime import date, datetime, time
5
-
6
- # ⬇️ Import correto das utils de operação
7
- from utils_operacao import obter_grupos_disponiveis, obter_modulos_para_grupo
8
-
9
- # Usa toda a largura da página (chamar antes de qualquer outro st.*)
10
- st.set_page_config(layout="wide")
11
-
12
- # Carrega variáveis de ambiente
13
- load_dotenv()
14
-
15
- # ===============================
16
- # IMPORTAÇÃO DOS MÓDULOS
17
- # ===============================
18
- import formulario
19
- import consulta
20
- import relatorio
21
- import administracao
22
- import quiz
23
- import ranking
24
- import quiz_admin
25
- import usuarios_admin
26
- import videos
27
- import auditoria
28
- import importar_excel
29
- import calendario
30
- import auditoria_cleanup
31
- import jogos
32
- import db_tools
33
- import db_admin
34
- import db_monitor
35
- import operacao
36
- import db_export_import
37
- import resposta # 📬 Admin: Caixa de Entrada IOI‑RUN (módulo interno)
38
- import outlook_relatorio
39
- import repositorio_load
40
- import Produtividade_Especialista as produtividade_especialista
41
- import rnc
42
- import rnc_listagem
43
- import rnc_relatorio
44
- import sugestoes_usuario # 💡 Usuário: Sugestões IOI‑RUN (módulo separado)
45
- import repo_rnc
46
- import recebimento
47
-
48
- from utils_info import INFO_CONTEUDO, INFO_MODULOS, INFO_MAP_PAGINA_ID
49
- from login import login
50
- from utils_permissoes import verificar_permissao
51
- from utils_layout import exibir_logo
52
- from modules_map import MODULES
53
- from banco import engine, Base, SessionLocal
54
- from models import QuizPontuacao
55
- from models import IOIRunSugestao
56
- from models import AvisoGlobal
57
-
58
- # Extras p/ sessões ativas
59
- from uuid import uuid4
60
- from sqlalchemy import text, func, or_
61
-
62
- # 🗄️ Banco ativo (Produção/Teste/Treinamento)
63
- try:
64
- from db_router import current_db_choice, bank_label
65
- _HAS_ROUTER = True
66
- except Exception:
67
- _HAS_ROUTER = False
68
- def current_db_choice() -> str:
69
- return "prod"
70
- def bank_label(choice: str) -> str:
71
- return "🟢 Produção" if choice == "prod" else "🔴 Teste"
72
- # ❌ REMOVIDO: não chamar nenhuma página ao importar/rodar o app principal
73
- # if __name__ == "__main__":
74
- # rnc.pagina()
75
-
76
- # ===============================
77
- # RERUN por querystring (atalho ?rr=1)
78
- # ===============================
79
- def _get_query_params():
80
- """Compat: retorna query params como dict (Streamlit novo/antigo)."""
81
- try:
82
- # Streamlit >= 1.32
83
- return dict(st.query_params)
84
- except Exception:
85
- # Streamlit antigo (experimental)
86
- try:
87
- return dict(st.experimental_get_query_params())
88
- except Exception:
89
- return {}
90
-
91
- def _set_query_params(new_params: dict):
92
- """Compat: define query params (Streamlit novo/antigo)."""
93
- try:
94
- st.query_params = new_params # Streamlit >= 1.32
95
- except Exception:
96
- try:
97
- st.experimental_set_query_params(**new_params)
98
- except Exception:
99
- pass
100
-
101
- def _check_rerun_qs(pagina_atual: str = ""):
102
- """
103
- Se a URL contiver rr=1 (ou true), força um rerun e limpa o parâmetro para evitar loop.
104
- Não dispara quando estiver na página 'resposta' (Inbox Admin).
105
- Consome apenas uma vez por sessão.
106
- (PATCH) Também não dispara quando estiver em 'outlook_relatorio' para não interromper leitura COM.
107
- """
108
- try:
109
- if st.session_state.get("__qs_rr_consumed__", False):
110
- return
111
- # 🔒 Evita rr=1 em módulos sensíveis a rerun/refresh
112
- # 🟩 AJUSTE: incluir 'formulario' para não aplicar rr=1 quando o formulário estiver ativo
113
- if pagina_atual in ("resposta", "outlook_relatorio", "formulario"):
114
- return # não aplicar rr=1 dentro destes módulos (evita 'piscar' e cancelamentos)
115
-
116
- params = _get_query_params()
117
- rr_raw = params.get("rr", ["0"])
118
- rr = rr_raw[0] if isinstance(rr_raw, (list, tuple)) else str(rr_raw)
119
- if str(rr).lower() in ("1", "true"):
120
- new_params = {k: v for k, v in params.items() if k != "rr"}
121
- _set_query_params(new_params)
122
- st.session_state["__qs_rr_consumed__"] = True
123
- st.rerun()
124
- except Exception:
125
- pass
126
-
127
- # =========================================
128
- # DB helper — sessão ciente do ambiente
129
- # =========================================
130
- def _get_db_session():
131
- """Retorna uma sessão de banco consistente com o ambiente atual."""
132
- try:
133
- from db_router import get_session_for_current_db
134
- return get_session_for_current_db()
135
- except Exception:
136
- pass
137
-
138
- try:
139
- from db_router import get_engine_for_current_db
140
- from sqlalchemy.orm import sessionmaker
141
- Eng = get_engine_for_current_db()
142
- return sessionmaker(bind=Eng)()
143
- except Exception:
144
- pass
145
-
146
- return SessionLocal()
147
-
148
- # ===============================
149
- # CONFIGURAÇÃO INICIAL
150
- # ===============================
151
- Base.metadata.create_all(bind=engine)
152
-
153
- def quiz_respondido_hoje(usuario: str) -> bool:
154
- # ✅ Usar sessão ciente do ambiente
155
- db = _get_db_session()
156
- try:
157
- inicio_dia = datetime.combine(date.today(), time.min)
158
- return (
159
- db.query(QuizPontuacao)
160
- .filter(
161
- QuizPontuacao.usuario == usuario,
162
- QuizPontuacao.data >= inicio_dia
163
- )
164
- .first()
165
- is not None
166
- )
167
- finally:
168
- try:
169
- db.close()
170
- except Exception:
171
- pass
172
-
173
- # ===============================
174
- # Sessões ativas (usuários logados agora)
175
- # ===============================
176
- _SESS_TTL_MIN = 5 # janela para considerar "online"
177
-
178
- def _get_session_id() -> str:
179
- if "_sid" not in st.session_state:
180
- st.session_state["_sid"] = f"{uuid4()}"
181
- return st.session_state["_sid"]
182
-
183
- def _ensure_sessao_table(db) -> None:
184
- """Cria a tabela sessao_web caso não exista (SQLite/Postgres/MySQL)."""
185
- dialect = db.bind.dialect.name
186
- if dialect == "sqlite":
187
- db.execute(text("""
188
- CREATE TABLE IF NOT EXISTS sessao_web (
189
- id INTEGER PRIMARY KEY AUTOINCREMENT,
190
- usuario TEXT NOT NULL,
191
- session_id TEXT NOT NULL UNIQUE,
192
- last_seen TIMESTAMP NOT NULL,
193
- ativo INTEGER NOT NULL DEFAULT 1
194
- )
195
- """))
196
- elif dialect in ("postgresql", "postgres"):
197
- db.execute(text("""
198
- CREATE TABLE IF NOT EXISTS sessao_web (
199
- id SERIAL PRIMARY KEY,
200
- usuario TEXT NOT NULL,
201
- session_id TEXT NOT NULL UNIQUE,
202
- last_seen TIMESTAMPTZ NOT NULL,
203
- ativo BOOLEAN NOT NULL DEFAULT TRUE
204
- )
205
- """))
206
- else: # mysql / mariadb
207
- db.execute(text("""
208
- CREATE TABLE IF NOT EXISTS sessao_web (
209
- id INT AUTO_INCREMENT PRIMARY KEY,
210
- usuario VARCHAR(255) NOT NULL,
211
- session_id VARCHAR(255) NOT NULL UNIQUE,
212
- last_seen TIMESTAMP NOT NULL,
213
- ativo TINYINT(1) NOT NULL DEFAULT 1
214
- )
215
- """))
216
- db.commit()
217
-
218
- def _session_heartbeat(usuario: str) -> None:
219
- """Atualiza/insere a sessão ativa do usuário com last_seen = now() e faz limpeza básica."""
220
- if not usuario:
221
- return
222
- db = _get_db_session()
223
- try:
224
- _ensure_sessao_table(db)
225
- sid = _get_session_id()
226
- now_sql = "CURRENT_TIMESTAMP"
227
-
228
- upd = db.execute(
229
- text(f"UPDATE sessao_web SET last_seen = {now_sql}, ativo = 1 WHERE session_id = :sid"),
230
- {"sid": sid}
231
- )
232
- if upd.rowcount == 0:
233
- db.execute(
234
- text(f"INSERT INTO sessao_web (usuario, session_id, last_seen, ativo) "
235
- f"VALUES (:usuario, :sid, {now_sql}, 1)"),
236
- {"usuario": usuario, "sid": sid}
237
- )
238
-
239
- dialect = db.bind.dialect.name
240
- if dialect in ("postgresql", "postgres"):
241
- cleanup_sql = f"UPDATE sessao_web SET ativo = FALSE WHERE last_seen < (NOW() - INTERVAL '{_SESS_TTL_MIN * 2} minutes')"
242
- elif dialect == "sqlite":
243
- cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN * 2} minutes')"
244
- else:
245
- cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN * 2} MINUTE)"
246
- db.execute(text(cleanup_sql))
247
- db.commit()
248
- except Exception:
249
- db.rollback()
250
- finally:
251
- try:
252
- db.close()
253
- except Exception:
254
- pass
255
-
256
- def _get_active_users_count() -> int:
257
- """Conta usuários distintos com last_seen dentro da janela (_SESS_TTL_MIN) e ativo=1."""
258
- db = _get_db_session()
259
- try:
260
- _ensure_sessao_table(db)
261
- dialect = db.bind.dialect.name
262
- if dialect in ("postgresql", "postgres"):
263
- threshold = f"(NOW() - INTERVAL '{_SESS_TTL_MIN} minutes')"
264
- elif dialect == "sqlite":
265
- threshold = f"datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN} minutes')"
266
- else:
267
- threshold = f"DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN} MINUTE)"
268
- res = db.execute(
269
- text(f"SELECT COUNT(DISTINCT usuario) AS c FROM sessao_web WHERE ativo = 1 AND last_seen >= {threshold}")
270
- ).fetchone()
271
- return int(res[0] if res and res[0] is not None else 0)
272
- except Exception:
273
- return 0
274
- finally:
275
- try:
276
- db.close()
277
- except Exception:
278
- pass
279
-
280
- def _mark_session_inactive() -> None:
281
- """Marca a sessão atual como inativa (chamar no logout)."""
282
- sid = st.session_state.get("_sid")
283
- if not sid:
284
- return
285
- db = _get_db_session()
286
- try:
287
- _ensure_sessao_table(db)
288
- db.execute(text("UPDATE sessao_web SET ativo = 0 WHERE session_id = :sid"), {"sid": sid})
289
- db.commit()
290
- except Exception:
291
- db.rollback()
292
- finally:
293
- try:
294
- db.close()
295
- except Exception:
296
- pass
297
-
298
- # ===============================
299
- # Aviso Global — Util (leitura e sanitização)
300
- # ===============================
301
- def _sanitize_largura(largura_raw: str) -> str:
302
- val = (largura_raw or "").strip()
303
- if not val:
304
- return "100%"
305
- if val.endswith("%") or val.endswith("px"):
306
- return val
307
- if val.isdigit():
308
- return f"{val}px"
309
- return "100%"
310
-
311
- def obter_aviso_ativo(db):
312
- try:
313
- aviso = (
314
- db.query(AvisoGlobal)
315
- .filter(AvisoGlobal.ativo == True)
316
- .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
317
- .first()
318
- )
319
- return aviso
320
- except Exception:
321
- return None
322
-
323
- # ===============================
324
- # Aviso Global — Render do banner superior (robusto)
325
- # ===============================
326
- def _render_aviso_global_topbar():
327
- try:
328
- db = _get_db_session()
329
- except Exception as e:
330
- st.sidebar.warning(f"Aviso desativado: sessão indisponível ({e})")
331
- return
332
-
333
- aviso = None
334
- try:
335
- aviso = obter_aviso_ativo(db)
336
- except Exception as e:
337
- st.sidebar.warning(f"Aviso desativado: falha ao consultar ({e})")
338
- aviso = None
339
- finally:
340
- try:
341
- db.close()
342
- except Exception:
343
- pass
344
-
345
- if not aviso:
346
- return
347
-
348
- try:
349
- largura = _sanitize_largura(aviso.largura)
350
- bg = aviso.bg_color or "#FFF3CD"
351
- fg = aviso.text_color or "#664D03"
352
- efeito = (aviso.efeito or "marquee").lower()
353
- velocidade = int(aviso.velocidade or 20)
354
- try:
355
- font_size = max(10, min(int(getattr(aviso, "font_size", 14)), 48))
356
- except Exception:
357
- font_size = 14
358
-
359
- altura = 52 # px
360
-
361
- st.markdown(
362
- f"""
363
- <style>
364
- /* Não derrube overlays do Streamlit */
365
- .stApp::before,
366
- header[data-testid="stHeader"],
367
- [data-testid="stToolbar"],
368
- [data-testid="stDecoration"],
369
- [data-testid="collapsedControl"],
370
- .stApp [class*="stDialog"] {{
371
- z-index: 1 !important;
372
- }}
373
- /* Reserva espaço para a barra */
374
- [data-testid="stAppViewContainer"] {{
375
- padding-top: {altura + 8}px !important;
376
- }}
377
-
378
- .ag-topbar-wrap {{
379
- position: fixed;
380
- top: 0;
381
- left: 0;
382
- width: {largura};
383
- z-index: 2147483647 !important;
384
- background: {bg};
385
- color: {fg};
386
- border-bottom: 1px solid rgba(0,0,0,.12);
387
- box-shadow: 0 2px 8px rgba(0,0,0,.15);
388
- border-radius: 0 0 10px 10px;
389
- pointer-events: none;
390
- }}
391
- .ag-topbar-inner {{
392
- display: flex;
393
- align-items: center;
394
- height: {altura}px;
395
- padding: 0 14px;
396
- overflow: hidden;
397
- font-weight: 700;
398
- font-size: {font_size}px;
399
- letter-spacing: .2px;
400
- white-space: nowrap;
401
- }}
402
- .ag-topbar-marquee > span {{
403
- display: inline-block;
404
- padding-left: 100%;
405
- animation: ag-marquee {velocidade}s linear infinite;
406
- }}
407
- @keyframes ag-marquee {{
408
- 0% {{ transform: translateX(0); }}
409
- 100% {{ transform: translateX(-100%); }}
410
- }}
411
- /* Acessibilidade: reduz movimento */
412
- @media (prefers-reduced-motion: reduce) {{
413
- .ag-topbar-marquee > span {{
414
- animation: none !important;
415
- padding-left: 0;
416
- }}
417
- }}
418
- @media (max-width: 500px) {{
419
- .ag-topbar-inner {{
420
- font-size: {max(10, font_size-3)}px;
421
- padding: 0 8px;
422
- height: 44px;
423
- }}
424
- [data-testid="stAppViewContainer"] {{
425
- padding-top: 52px !important;
426
- }}
427
- }}
428
- </style>
429
-
430
- <div class="ag-topbar-wrap">
431
- <div class="ag-topbar-inner {'ag-topbar-marquee' if efeito=='marquee' else ''}">
432
- <span>{aviso.mensagem}</span>
433
- </div>
434
- </div>
435
- """,
436
- unsafe_allow_html=True
437
- )
438
- except Exception as e:
439
- st.sidebar.warning(f"Aviso desativado: erro de render ({e})")
440
- return
441
-
442
- # ===============================
443
- # Logout (utilitário)
444
- # ===============================
445
- def logout():
446
- """Finaliza a sessão do usuário, limpa estados e recarrega a aplicação."""
447
- _mark_session_inactive() # marca esta sessão como inativa
448
- st.session_state.logado = False
449
- st.session_state.usuario = None
450
- st.session_state.perfil = None
451
- st.session_state.nome = None
452
- st.session_state.email = None
453
- st.session_state.quiz_verificado = False
454
- st.rerun()
455
-
456
- # ===============================
457
- # 🎂 Banner/efeito de aniversário
458
- # ===============================
459
- def _show_birthday_banner_if_needed():
460
- if st.session_state.get("__show_birthday__"):
461
- st.session_state["__show_birthday__"] = False
462
- st.markdown(
463
- """
464
- <style>
465
- .confetti-wrapper { position: relative; width: 100%; height: 0; }
466
- .confetti-area { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
467
- pointer-events: none; z-index: 9999; }
468
- .confetti { position: absolute; top: -5%; font-size: 24px; animation-name: confetti-fall;
469
- animation-timing-function: linear; animation-iteration-count: 1; }
470
- @keyframes confetti-fall {
471
- 0% { transform: translateY(-5vh) rotate(0deg); opacity: 1; }
472
- 100% { transform: translateY(105vh) rotate(360deg); opacity: 0; }
473
- }
474
- .confetti:nth-child(1) { left: 5%; animation-duration: 3.5s; }
475
- .confetti:nth-child(2) { left: 12%; animation-duration: 4.0s; }
476
- .confetti:nth-child(3) { left: 20%; animation-duration: 3.2s; }
477
- .confetti:nth-child(4) { left: 28%; animation-duration: 4.3s; }
478
- .confetti:nth-child(5) { left: 36%; animation-duration: 3.8s; }
479
- .confetti:nth-child(6) { left: 44%; animation-duration: 4.1s; }
480
- .confetti:nth-child(7) { left: 52%; animation-duration: 3.4s; }
481
- .confetti:nth-child(8) { left: 60%; animation-duration: 4.4s; }
482
- .confetti:nth-child(9) { left: 68%; animation-duration: 3.9s; }
483
- .confetti:nth-child(10) { left: 76%; animation-duration: 4.2s; }
484
- .confetti:nth-child(11) { left: 84%; animation-duration: 3.6s; }
485
- .confetti:nth-child(12) { left: 92%; animation-duration: 4.0s; }
486
- </style>
487
- <div class="confetti-wrapper">
488
- <div class="confetti-area">
489
- <div class="confetti">🎊</div><div class="confetti">🎉</div>
490
- <div class="confetti">🎊</div><div class="confetti">🎉</div>
491
- <div class="confetti">🎊</div><div class="confetti">🎉</div>
492
- <div class="confetti">🎊</div><div class="confetti">🎉</div>
493
- <div class="confetti">🎊</div><div class="confetti">🎉</div>
494
- <div class="confetti">🎊</div><div class="confetti">🎉</div>
495
- </div>
496
- </div>
497
- """,
498
- unsafe_allow_html=True
499
- )
500
- st.balloons()
501
- nome = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário"
502
- st.markdown(
503
- f"""
504
- <div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin:40px 0;">
505
- <div style="font-size: 36px; font-weight: 800; color:#A020F0;
506
- background:linear-gradient(90deg,#FFF0F6,#F0E6FF);
507
- padding:20px 30px; border-radius:16px; box-shadow:0 4px 10px rgba(0,0,0,.08);">
508
- 🎉 Feliz Aniversário, {nome}! 🎉
509
- </div>
510
- </div>
511
- """,
512
- unsafe_allow_html=True
513
- )
514
- COR_FRASE = "#0d6efd"
515
- st.markdown(
516
- f"""
517
- <div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin-top:10px;">
518
- <div style="font-size: 20px; font-weight: 500; color:{COR_FRASE}; padding:10px;">
519
- Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜
520
- </div>
521
- </div>
522
- """,
523
- unsafe_allow_html=True
524
- )
525
-
526
- # ===============================
527
- # MAIN
528
- # ===============================
529
- def main():
530
- # Estados iniciais
531
- if "logado" not in st.session_state:
532
- st.session_state.logado = False
533
- if "usuario" not in st.session_state:
534
- st.session_state.usuario = None
535
- if "quiz_verificado" not in st.session_state:
536
- st.session_state.quiz_verificado = False
537
- if "user_responses_viewed" not in st.session_state:
538
- st.session_state.user_responses_viewed = False
539
- if "nav_target" not in st.session_state:
540
- st.session_state.nav_target = None
541
-
542
- # ✅ Estado do intervalo de autoatualização (padrão aumentado p/ 60s; 0 = desligado)
543
- st.session_state.setdefault("__auto_refresh_interval_sec__", 60)
544
-
545
- # LOGIN
546
- if not st.session_state.logado:
547
- st.session_state.quiz_verificado = False
548
- exibir_logo(top=True, sidebar=False)
549
- login()
550
- return
551
-
552
- # 👥 Heartbeat + Badge de usuários logados (APENAS ADMIN)
553
- _session_heartbeat(st.session_state.usuario)
554
- if (st.session_state.get("perfil") or "").strip().lower() == "admin":
555
- try:
556
- online_now = _get_active_users_count()
557
- except Exception:
558
- online_now = 0
559
- st.sidebar.markdown(
560
- f"""
561
- <div style="padding:8px 10px;margin-top:6px;margin-bottom:6px;border-radius:8px;
562
- background:#1e293b; color:#e2e8f0; border:1px solid #334155;">
563
- <span style="font-size:13px;">🟢 Online (últimos {_SESS_TTL_MIN} min)</span><br>
564
- <span style="font-size:22px;font-weight:800;">{online_now}</span>
565
- </div>
566
- """,
567
- unsafe_allow_html=True
568
- )
569
-
570
- # 🔄 Botão de Recarregar (mantém a sessão ativa) + ⏱️ Controle do intervalo
571
- st.sidebar.markdown("---")
572
- # Linha com botão de recarregar e popover para o intervalo
573
- col_reload, col_interval = st.sidebar.columns([1, 1])
574
- if col_reload.button("🔄 Recarregar (sem sair)", key="__btn_reload_now__"):
575
- st.rerun()
576
-
577
- # Popover (se disponível) para configurar intervalo; fallback para expander
578
- if hasattr(st, "popover"):
579
- with col_interval.popover("⏱️ Autoatualização"):
580
- new_val = st.number_input(
581
- "Intervalo (segundos) 0 desativa",
582
- min_value=0, max_value=3600,
583
- value=int(st.session_state["__auto_refresh_interval_sec__"]),
584
- step=5, key="__auto_refresh_input__"
585
- )
586
- if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
587
- st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
588
- try:
589
- if int(new_val) > 0:
590
- st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
591
- else:
592
- st.toast("Autoatualização desativada.", icon="⛔")
593
- except Exception:
594
- pass
595
- st.rerun()
596
- else:
597
- with st.sidebar.expander("⏱️ Autoatualização", expanded=False):
598
- new_val = st.number_input(
599
- "Intervalo (segundos) — 0 desativa",
600
- min_value=0, max_value=3600,
601
- value=int(st.session_state["__auto_refresh_interval_sec__"]),
602
- step=5, key="__auto_refresh_input__"
603
- )
604
- if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
605
- st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
606
- try:
607
- if int(new_val) > 0:
608
- st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
609
- else:
610
- st.toast("Autoatualização desativada.", icon="⛔")
611
- except Exception:
612
- pass
613
- st.rerun()
614
-
615
- usuario = st.session_state.usuario
616
- perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
617
-
618
- # QUIZ
619
- if not st.session_state.quiz_verificado:
620
- if not quiz_respondido_hoje(usuario):
621
- exibir_logo(top=True, sidebar=False)
622
- quiz.main()
623
- return
624
- else:
625
- st.session_state.quiz_verificado = True
626
- st.rerun()
627
-
628
- # SISTEMA LIBERADO
629
- exibir_logo(top=True, sidebar=True)
630
- _render_aviso_global_topbar()
631
- _show_birthday_banner_if_needed()
632
-
633
- st.sidebar.markdown("### Menu | 🎉 Carnaval 🎭 2026!")
634
-
635
- # Banco ativo na sidebar
636
- try:
637
- banco_label = bank_label(current_db_choice()) if _HAS_ROUTER else (
638
- "🟢 Produção" if current_db_choice() == "prod" else "🔴 Teste"
639
- )
640
- st.sidebar.caption(f"🗄️ Banco ativo: {banco_label}")
641
- except Exception:
642
- pass
643
-
644
- # =========================
645
- # Notificações no sidebar
646
- # =========================
647
-
648
- # --- Admin: pendentes ---
649
- if perfil == "admin":
650
- try:
651
- db = _get_db_session()
652
- pendentes = db.query(IOIRunSugestao).filter(func.lower(IOIRunSugestao.status) == "pendente").count()
653
- except Exception:
654
- pendentes = 0
655
- finally:
656
- try: db.close()
657
- except Exception: pass
658
-
659
- if pendentes > 0:
660
- st.sidebar.markdown(
661
- """
662
- <div style="padding:8px 10px;border-radius:8px;background:#FFF3CD;color:#664D03;
663
- border:1px solid #FFECB5;margin-bottom:6px;">
664
- <b>🔔 {pendentes} sugestão(ões) pendente(s)</b><br>
665
- <span style="font-size:12px;">Acesse a caixa de entrada para responder.</span>
666
- </div>
667
- """.format(pendentes=pendentes),
668
- unsafe_allow_html=True
669
- )
670
-
671
- # 👉 Direciona para o MESMO módulo do menu (resposta.main())
672
- if st.sidebar.button("📬 Abrir Caixa de Entrada (Admin)"):
673
- st.session_state.nav_target = "resposta"
674
- st.rerun()
675
-
676
- # --- Usuário: respostas novas (após último 'visto') ---
677
- if perfil != "admin":
678
- # Última vez que o usuário realmente abriu e visualizou as respostas
679
- last_seen_dt = st.session_state.get("__user_last_answer_seen__")
680
-
681
- try:
682
- db = _get_db_session()
683
-
684
- # Qual é a resposta mais recente existente
685
- last_answer_dt_row = (
686
- db.query(IOIRunSugestao.data_resposta)
687
- .filter(
688
- IOIRunSugestao.usuario == usuario,
689
- func.lower(IOIRunSugestao.status) == "respondida",
690
- IOIRunSugestao.data_resposta != None
691
- )
692
- .order_by(IOIRunSugestao.data_resposta.desc())
693
- .first()
694
- )
695
- last_answer_dt = last_answer_dt_row[0] if last_answer_dt_row else None
696
-
697
- # Se há algo mais novo do que o 'visto', marcamos como não visto
698
- if last_answer_dt and (not last_seen_dt or last_answer_dt > last_seen_dt):
699
- st.session_state.user_responses_viewed = False
700
-
701
- # Conta SOMENTE respostas novas (depois do 'last_seen_dt')
702
- novas_respostas = (
703
- db.query(IOIRunSugestao)
704
- .filter(
705
- IOIRunSugestao.usuario == usuario,
706
- func.lower(IOIRunSugestao.status) == "respondida",
707
- (IOIRunSugestao.data_resposta > last_seen_dt) if last_seen_dt else (IOIRunSugestao.data_resposta != None)
708
- )
709
- .count()
710
- )
711
- except Exception:
712
- novas_respostas = 0
713
- finally:
714
- try: db.close()
715
- except Exception: pass
716
-
717
- # ✅ Exibir card de nova mensagem até o usuário clicar em "Ver respostas"
718
- if novas_respostas > 0 and not st.session_state.get("user_responses_viewed", False):
719
- st.sidebar.markdown(
720
- """
721
- <div style="padding:8px 10px;border-radius:8px;background:#D1E7DD;color:#0F5132;
722
- border:1px solid #BADBCC;margin-bottom:6px;">
723
- <b>🔔 {resps} resposta(s) nova(s) para suas sugestões</b><br>
724
- <span style="font-size:12px;">Clique para ver suas respostas.</span>
725
- </div>
726
- """.format(resps=novas_respostas),
727
- unsafe_allow_html=True
728
- )
729
-
730
- # (Opcional) Toast discreto — aparece uma única vez por sessão enquanto houver novidade
731
- if not st.session_state.get("__user_toast_shown__"):
732
- try:
733
- st.toast("Você tem novas respostas do IOI‑RUN. Clique em '📥 Ver respostas'.", icon="💬")
734
- except Exception:
735
- pass
736
- st.session_state["__user_toast_shown__"] = True
737
-
738
- if st.sidebar.button("📥 Ver respostas"):
739
- # Não atualizamos last_seen aqui; isso é feito dentro do módulo do usuário
740
- st.session_state.nav_target = "sugestoes_ioirun"
741
- st.session_state.user_responses_viewed = True
742
- st.rerun()
743
- else:
744
- # Se não há novidades, libera o toast para a próxima vez que houver
745
- st.session_state["__user_toast_shown__"] = False
746
-
747
- # ------------------------- Menu lateral -------------------------
748
- termo_busca = st.sidebar.text_input("Pesquisar módulo:").strip().lower()
749
-
750
- try:
751
- ambiente_atual = current_db_choice() if _HAS_ROUTER else "prod"
752
- except Exception:
753
- ambiente_atual = "prod"
754
-
755
- grupos_disponiveis = obter_grupos_disponiveis(
756
- MODULES,
757
- perfil=st.session_state.get("perfil", "usuario"),
758
- usuario=st.session_state.get("usuario"),
759
- ambiente=ambiente_atual,
760
- verificar_permissao=verificar_permissao
761
- )
762
-
763
- if not grupos_disponiveis:
764
- st.sidebar.selectbox("Selecione a operação:", ["Em desenvolvimento"], index=0)
765
- st.warning("Nenhuma operação disponível para seu perfil/ambiente neste momento.")
766
- return
767
-
768
- grupo_escolhido = st.sidebar.selectbox("Selecione a operação:", grupos_disponiveis)
769
-
770
- opcoes = obter_modulos_para_grupo(
771
- MODULES, grupo_escolhido, termo_busca,
772
- perfil=st.session_state.get("perfil", "usuario"),
773
- usuario=st.session_state.get("usuario"),
774
- ambiente=ambiente_atual,
775
- verificar_permissao=verificar_permissao
776
- )
777
-
778
- with st.sidebar.expander("🔧 Diagnóstico do menu", expanded=False):
779
- st.caption(f"Perfil: **{st.session_state.get('perfil', '—')}** | Grupo: **{grupo_escolhido}** | Busca: **{termo_busca or '∅'}** | Ambiente: **{ambiente_atual}**")
780
- try:
781
- mods_dbg = [{"id": mid, "label": lbl} for mid, lbl in (opcoes or [])]
782
- except Exception:
783
- mods_dbg = []
784
- st.write("Módulos visíveis (após regras):", mods_dbg if mods_dbg else "—")
785
-
786
- # Failsafe outlook_relatorio
787
- try:
788
- mod_outlook = MODULES.get("outlook_relatorio")
789
- if mod_outlook:
790
- mesmo_grupo = (mod_outlook.get("grupo") == grupo_escolhido)
791
- perfil_ok = verificar_permissao(
792
- perfil=st.session_state.get("perfil", "usuario"),
793
- modulo_key="outlook_relatorio",
794
- usuario=st.session_state.get("usuario"),
795
- ambiente=ambiente_atual
796
- )
797
- ja_nas_opcoes = any(mid == "outlook_relatorio" for mid, _ in (opcoes or []))
798
- passa_busca = (not termo_busca) or (termo_busca in mod_outlook.get("label", "").strip().lower())
799
- if mesmo_grupo and perfil_ok and not ja_nas_opcoes and passa_busca:
800
- opcoes = (opcoes or []) + [("outlook_relatorio", mod_outlook.get("label", "Relatorio portaria"))]
801
- except Exception:
802
- pass
803
-
804
- # Failsafe repositorio_load
805
- try:
806
- mod_repo = MODULES.get("repositorio_load")
807
- if mod_repo:
808
- mesmo_grupo_r = (mod_repo.get("grupo") == grupo_escolhido)
809
- perfil_ok_r = verificar_permissao(
810
- perfil=st.session_state.get("perfil", "usuario"),
811
- modulo_key="repositorio_load",
812
- usuario=st.session_state.get("usuario"),
813
- ambiente=ambiente_atual
814
- )
815
- ja_nas_opcoes_r = any(mid == "repositorio_load" for mid, _ in (opcoes or []))
816
- passa_busca_r = (not termo_busca) or (termo_busca in mod_repo.get("label", "").strip().lower())
817
- if mesmo_grupo_r and perfil_ok_r and not ja_nas_opcoes_r and passa_busca_r:
818
- opcoes = (opcoes or []) + [("repositorio_load", mod_repo.get("label", "Repositório Load"))]
819
- except Exception:
820
- pass
821
-
822
- if not opcoes:
823
- st.sidebar.selectbox("Selecione o módulo:", ["Em desenvolvimento"], index=0)
824
- st.warning(f"A operação '{grupo_escolhido}' está em desenvolvimento.")
825
- return
826
-
827
- # ============================================================
828
- # 🔒 Fix: selectbox com 'key' + seleção forçada para 'resposta'
829
- # quando vier de nav_target (sidebar) ou quando já estivermos na página.
830
- # ============================================================
831
- labels = [label for _, label in opcoes]
832
-
833
- # Se foi solicitado nav_target, injeta a label alvo antes do selectbox
834
- if st.session_state.get("nav_target"):
835
- target = st.session_state["nav_target"]
836
- try:
837
- target_label = next(lbl for mid, lbl in opcoes if mid == target)
838
- st.session_state["mod_select_label"] = target_label
839
- except StopIteration:
840
- pass
841
-
842
- # Inicializa/persiste seleção
843
- if "mod_select_label" not in st.session_state or st.session_state["mod_select_label"] not in labels:
844
- st.session_state["mod_select_label"] = labels[0]
845
-
846
- escolha_label = st.sidebar.selectbox(
847
- "Selecione o módulo:",
848
- labels,
849
- index=labels.index(st.session_state["mod_select_label"]),
850
- key="mod_select_label"
851
- )
852
-
853
- pagina_id = next(mod_id for mod_id, label in opcoes if label == escolha_label)
854
-
855
- # Navegação com lock (evita disputa com outros reruns)
856
- if st.session_state.get("nav_target"):
857
- pagina_id = st.session_state.nav_target
858
- st.session_state["__nav_lock__"] = True
859
- else:
860
- st.session_state["__nav_lock__"] = False
861
-
862
- # 🔎 Agora que sabemos a página atual, tratamos rr=1 com segurança
863
- _check_rerun_qs(pagina_atual=pagina_id)
864
-
865
- # ⏱️ Auto-refresh leve do sidebar — NÃO quando em Inbox/Admin/Outlook/Formulário/Recebimento
866
- try:
867
- from streamlit_autorefresh import st_autorefresh
868
- is_inbox_admin = (pagina_id == "resposta")
869
- is_outlook_rel = (pagina_id == "outlook_relatorio")
870
- is_formulario = (pagina_id == "formulario")
871
- is_recebimento = (pagina_id == "recebimento")
872
- interval_sec = int(st.session_state.get("__auto_refresh_interval_sec__", 60))
873
- if (interval_sec > 0) and not (is_inbox_admin or is_outlook_rel or is_formulario or is_recebimento):
874
- # key dinâmica por intervalo evita conflitos ao trocar o valor
875
- st_autorefresh(interval=interval_sec * 1000, key=f"sidebar_autorefresh_{interval_sec}s")
876
- except Exception:
877
- pass
878
-
879
- # Logout
880
- st.sidebar.markdown("---")
881
- if st.session_state.get("logado"):
882
- if st.sidebar.button("🚪 Sair (Logout)"):
883
- logout()
884
-
885
- st.divider()
886
-
887
- # ------------------------- Roteamento -------------------------
888
- if pagina_id == "formulario":
889
- formulario.main()
890
- elif pagina_id == "consulta":
891
- consulta.main()
892
- elif pagina_id == "relatorio":
893
- relatorio.main()
894
- elif pagina_id == "ranking":
895
- ranking.main()
896
- elif pagina_id == "quiz":
897
- quiz.main()
898
- ranking.main()
899
- elif pagina_id == "quiz_admin":
900
- quiz_admin.main()
901
- elif pagina_id == "usuarios":
902
- usuarios_admin.main()
903
- elif pagina_id == "administracao":
904
- administracao.main()
905
- elif pagina_id == "videos":
906
- videos.main()
907
- elif pagina_id == "auditoria":
908
- auditoria.main()
909
- elif pagina_id == "auditoria_cleanup":
910
- auditoria_cleanup.main()
911
- elif pagina_id == "importacao":
912
- importar_excel.main()
913
- elif pagina_id == "calendario":
914
- calendario.main()
915
- elif pagina_id == "jogos":
916
- st.session_state.setdefault("pontuacao", 0)
917
- st.session_state.setdefault("rodadas", 0)
918
- st.session_state.setdefault("ultimo_resultado", None)
919
- jogos.main()
920
- elif pagina_id == "temporario":
921
- db_tools.main()
922
- elif pagina_id == "db_admin":
923
- db_admin.main()
924
- elif pagina_id == "db_monitor":
925
- db_monitor.main()
926
- elif pagina_id == "operacao":
927
- operacao.main()
928
- elif pagina_id == "resposta": # 📬 Admin
929
- resposta.main()
930
- elif pagina_id == "db_export_import":
931
- db_export_import.main()
932
- elif pagina_id == "produtividade_especialista":
933
- produtividade_especialista.main()
934
- elif pagina_id == "outlook_relatorio":
935
- outlook_relatorio.main()
936
- elif pagina_id == "sugestoes_ioirun": # 💡 Usuário
937
- if st.session_state.get("perfil") == "admin":
938
- st.info("Use a **📬 Caixa de Entrada (Admin)** para responder sugestões.")
939
- else:
940
- sugestoes_usuario.main()
941
- elif pagina_id == "repositorio_load":
942
- repositorio_load.main()
943
- elif pagina_id == "rnc":
944
- rnc.pagina()
945
- elif pagina_id == "rnc_listagem":
946
- rnc_listagem.pagina()
947
- elif pagina_id == "rnc_relatorio":
948
- rnc_relatorio.pagina()
949
- elif pagina_id == "repo_rnc":
950
- repo_rnc.pagina()
951
- elif pagina_id == "recebimento":
952
- recebimento.main()
953
-
954
- # ------------------------------------------------------
955
- # ℹ️ INFO — Guia passo a passo de uso (no sidebar)
956
- # ------------------------------------------------------
957
- info_mod_default = INFO_MAP_PAGINA_ID.get(pagina_id, "Geral")
958
- with st.sidebar.expander("ℹ️ Info • Como usar o sistema", expanded=False):
959
- st.markdown("""
960
- **Bem-vindo!**
961
- Este painel reúne instruções rápidas para utilizar cada módulo e seus campos.
962
- Selecione o módulo abaixo ou navegue pelo menu — o conteúdo ajusta automaticamente.
963
- """)
964
- mod_info_sel = st.selectbox(
965
- "Escolha o módulo para ver instruções:",
966
- INFO_MODULOS,
967
- index=INFO_MODULOS.index(info_mod_default) if info_mod_default in INFO_MODULOS else 0,
968
- key="info_mod_sel"
969
- )
970
- st.markdown(INFO_CONTEUDO.get(mod_info_sel, "_Conteúdo não disponível para este módulo._"))
971
-
972
- # ✅ Libera o nav_target após a 1ª render da página de destino
973
- if st.session_state.get("__nav_lock__"):
974
- st.session_state["nav_target"] = None
975
- st.session_state["__nav_lock__"] = False
976
-
977
- if __name__ == "__main__":
978
- main()
979
- # -------------------------
980
- # Desenvolvedor e versão
981
- # -------------------------
982
- if st.session_state.get("logado") and st.session_state.get("email"):
983
- st.sidebar.markdown(
984
- f"""
985
- <div style="display:inline-flex;align-items:center;gap:8px; padding:4px 8px;border-radius:8px;
986
- background:#e7f1ff;color:#0d6efd;font-size:13px;line-height:1.2;">
987
- <span style="font-size:16px;">👤</span>
988
- <span>{st.session_state.email}</span>
989
- </div>
990
- """,
991
- unsafe_allow_html=True
992
- )
993
-
994
- st.sidebar.markdown(
995
- """
996
- <hr style="margin-top: 10px; margin-bottom: 6px;">
997
- <p style="font-size: 12px; color: #6c757d;">
998
- Versão: <strong>1.0.0</strong> • Desenvolvedor: <strong>Rodrigo Silva - Ideiasystem | 2026</strong>
999
- </p>
1000
- """,
1001
- unsafe_allow_html=True
1002
- )
1003
-
1004
-
1005
-
1006
-
1007
-
1008
-
1009
-
1010
-
1011
-
1012
-
1013
-
1014
-
1015
-
 
1
+ # -*- coding: utf-8 -*-
2
+ import os
3
+ import sys
4
+ import platform
5
+ import importlib
6
+ from uuid import uuid4
7
+ from datetime import date, datetime, time
8
+
9
+ import streamlit as st
10
+ from dotenv import load_dotenv
11
+ from sqlalchemy import text, func
12
+
13
+ # ✅ Usa toda a largura da página (chamar antes de qualquer outro st.*)
14
+ st.set_page_config(layout="wide")
15
+
16
+ # Carrega variáveis de ambiente
17
+ load_dotenv()
18
+
19
+ # --------------------------------------------------------------------------------------
20
+ # Utilitários de robustez / compatibilidade
21
+ # --------------------------------------------------------------------------------------
22
+ def _module_stub(name: str, hint: str = ""):
23
+ """
24
+ Retorna um objeto com .main() e .pagina() que exibe aviso amigável
25
+ quando o módulo não puder ser importado/usar no ambiente atual.
26
+ """
27
+ class _Stub:
28
+ def __init__(self, _name, _hint):
29
+ self.__name = _name
30
+ self.__hint = _hint or "Módulo indisponível nesta hospedagem."
31
+ def main(self, *args, **kwargs):
32
+ st.warning(f"🔒 Módulo **{self.__name}** indisponível.\n\n{self.__hint}")
33
+ def pagina(self, *args, **kwargs):
34
+ self.main(*args, **kwargs)
35
+ return _Stub(name, hint)
36
+
37
+ def _try_import(module_name: str, on_fail_hint: str = ""):
38
+ """
39
+ Import "seguro": caso falhe, retorna stub que não derruba o app.
40
+ """
41
+ try:
42
+ return importlib.import_module(module_name)
43
+ except Exception as e:
44
+ return _module_stub(module_name, f"{on_fail_hint}\n\n**Detalhe técnico:** {e}")
45
+
46
+ def _ensure_db_case_alias(expected_names=("load.db", "Load.db", "LOAD.DB")):
47
+ """
48
+ Se existir um .db com caixa diferente do usado pelo código, cria uma CÓPIA
49
+ com o nome esperado, evitando erro de case-sensitive no Linux.
50
+ - Preferência: se 'load.db' não existir, mas houver 'Load.db', gera 'load.db'.
51
+ """
52
+ base = os.path.abspath(os.getcwd())
53
+ candidates = [os.path.join(base, n) for n in expected_names]
54
+ # Se existir um 'load.db', não faz nada
55
+ lower = os.path.join(base, "load.db")
56
+ if os.path.exists(lower):
57
+ return lower
58
+ # Procura qualquer outro nome por case diferente e copia para 'load.db'
59
+ for path in candidates:
60
+ if os.path.exists(path):
61
+ try:
62
+ import shutil
63
+ shutil.copy(path, lower)
64
+ return lower
65
+ except Exception:
66
+ pass
67
+ # Não encontrou nada; apenas retorna o que seria o caminho padrão
68
+ return lower
69
+
70
+ # Ajuste preventivo do case do banco (caso módulos internos usem 'load.db')
71
+ _DB_ALIAS = _ensure_db_case_alias()
72
+
73
+ # --------------------------------------------------------------------------------------
74
+ # Imports internos que DEVEM existir (são base do app)
75
+ # --------------------------------------------------------------------------------------
76
+ from utils_operacao import obter_grupos_disponiveis, obter_modulos_para_grupo
77
+ from utils_info import INFO_CONTEUDO, INFO_MODULOS, INFO_MAP_PAGINA_ID
78
+ from login import login
79
+ from utils_permissoes import verificar_permissao
80
+ from utils_layout import exibir_logo
81
+ from modules_map import MODULES
82
+ from banco import engine, Base, SessionLocal
83
+ from models import QuizPontuacao, IOIRunSugestao, AvisoGlobal
84
+
85
+ # --------------------------------------------------------------------------------------
86
+ # Imports de páginas/módulos com fallback (não derrubam o app se faltar algo)
87
+ # --------------------------------------------------------------------------------------
88
+ # Observação: alguns módulos podem depender de libs não disponíveis no Linux;
89
+ # por isso usamos _try_import e stubs.
90
+
91
+ formulario = _try_import("formulario")
92
+ consulta = _try_import("consulta")
93
+ relatorio = _try_import("relatorio")
94
+ administracao = _try_import("administracao")
95
+ quiz = _try_import("quiz")
96
+ ranking = _try_import("ranking")
97
+ quiz_admin = _try_import("quiz_admin")
98
+ usuarios_admin = _try_import("usuarios_admin", "Adicione **bcrypt** ao requirements.txt.")
99
+ videos = _try_import("videos")
100
+ auditoria = _try_import("auditoria")
101
+ importar_excel = _try_import("importar_excel")
102
+ calendario = _try_import("calendario", "Instale **streamlit-calendar** no requirements.txt.")
103
+ auditoria_cleanup = _try_import("auditoria_cleanup")
104
+ jogos = _try_import("jogos")
105
+ db_tools = _try_import("db_tools")
106
+ db_admin = _try_import("db_admin")
107
+ db_monitor = _try_import("db_monitor")
108
+ operacao = _try_import("operacao")
109
+ db_export_import = _try_import("db_export_import")
110
+ resposta = _try_import("resposta") # 📬 Admin: Caixa de Entrada IOI‑RUN
111
+ repositorio_load = _try_import("repositorio_load")
112
+ produtividade_especialista = _try_import("Produtividade_Especialista")
113
+ rnc = _try_import("rnc")
114
+ rnc_listagem = _try_import("rnc_listagem")
115
+ rnc_relatorio = _try_import("rnc_relatorio")
116
+ sugestoes_usuario = _try_import("sugestoes_usuario")
117
+ repo_rnc = _try_import("repo_rnc")
118
+ recebimento = _try_import("recebimento")
119
+
120
+ # Outlook/COM (somente Windows). Em Linux/Spaces: desativado ou stub.
121
+ _DISABLE_OUTLOOK = os.getenv("DISABLE_OUTLOOK", "0") == "1"
122
+ if platform.system().lower() != "windows" or _DISABLE_OUTLOOK:
123
+ outlook_relatorio = _module_stub(
124
+ "outlook_relatorio",
125
+ "Este módulo usa automação COM do Outlook (pywin32), disponível **apenas no Windows**.\n"
126
+ "No servidor Linux, ele foi desativado. Para manter essa função em nuvem, use **Microsoft Graph API**."
127
+ )
128
+ else:
129
+ outlook_relatorio = _try_import("outlook_relatorio")
130
+
131
+ # --------------------------------------------------------------------------------------
132
+ # Roteamento condicional de banco
133
+ # --------------------------------------------------------------------------------------
134
+ try:
135
+ from db_router import current_db_choice, bank_label
136
+ _HAS_ROUTER = True
137
+ except Exception:
138
+ _HAS_ROUTER = False
139
+ def current_db_choice() -> str:
140
+ return "prod"
141
+ def bank_label(choice: str) -> str:
142
+ return "🟢 Produção" if choice == "prod" else "🔴 Teste"
143
+
144
+ # --------------------------------------------------------------------------------------
145
+ # RERUN por querystring (?rr=1)
146
+ # --------------------------------------------------------------------------------------
147
+ def _get_query_params():
148
+ try:
149
+ return dict(st.query_params) # Streamlit >= 1.32
150
+ except Exception:
151
+ try:
152
+ return dict(st.experimental_get_query_params())
153
+ except Exception:
154
+ return {}
155
+
156
+ def _set_query_params(new_params: dict):
157
+ try:
158
+ st.query_params = new_params
159
+ except Exception:
160
+ try:
161
+ st.experimental_set_query_params(**new_params)
162
+ except Exception:
163
+ pass
164
+
165
+ def _check_rerun_qs(pagina_atual: str = ""):
166
+ try:
167
+ if st.session_state.get("__qs_rr_consumed__", False):
168
+ return
169
+ # Não aplica RR dentro de módulos sensíveis
170
+ if pagina_atual in ("resposta", "outlook_relatorio", "formulario"):
171
+ return
172
+ params = _get_query_params()
173
+ rr_raw = params.get("rr", ["0"])
174
+ rr = rr_raw[0] if isinstance(rr_raw, (list, tuple)) else str(rr_raw)
175
+ if str(rr).lower() in ("1", "true"):
176
+ new_params = {k: v for k, v in params.items() if k != "rr"}
177
+ _set_query_params(new_params)
178
+ st.session_state["__qs_rr_consumed__"] = True
179
+ st.rerun()
180
+ except Exception:
181
+ pass
182
+
183
+ # --------------------------------------------------------------------------------------
184
+ # Sessões / DB helpers
185
+ # --------------------------------------------------------------------------------------
186
+ def _get_db_session():
187
+ try:
188
+ from db_router import get_session_for_current_db
189
+ return get_session_for_current_db()
190
+ except Exception:
191
+ pass
192
+ try:
193
+ from db_router import get_engine_for_current_db
194
+ from sqlalchemy.orm import sessionmaker
195
+ Eng = get_engine_for_current_db()
196
+ return sessionmaker(bind=Eng)()
197
+ except Exception:
198
+ pass
199
+ from sqlalchemy.orm import sessionmaker
200
+ return sessionmaker(bind=engine)()
201
+
202
+ Base.metadata.create_all(bind=engine)
203
+
204
+ def quiz_respondido_hoje(usuario: str) -> bool:
205
+ db = _get_db_session()
206
+ try:
207
+ inicio_dia = datetime.combine(date.today(), time.min)
208
+ return (
209
+ db.query(QuizPontuacao)
210
+ .filter(
211
+ QuizPontuacao.usuario == usuario,
212
+ QuizPontuacao.data >= inicio_dia
213
+ )
214
+ .first()
215
+ is not None
216
+ )
217
+ finally:
218
+ try: db.close()
219
+ except Exception: pass
220
+
221
+ _SESS_TTL_MIN = 5 # janela para considerar "online"
222
+
223
+ def _get_session_id() -> str:
224
+ if "_sid" not in st.session_state:
225
+ st.session_state["_sid"] = f"{uuid4()}"
226
+ return st.session_state["_sid"]
227
+
228
+ def _ensure_sessao_table(db) -> None:
229
+ dialect = db.bind.dialect.name
230
+ if dialect == "sqlite":
231
+ db.execute(text("""
232
+ CREATE TABLE IF NOT EXISTS sessao_web (
233
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
234
+ usuario TEXT NOT NULL,
235
+ session_id TEXT NOT NULL UNIQUE,
236
+ last_seen TIMESTAMP NOT NULL,
237
+ ativo INTEGER NOT NULL DEFAULT 1
238
+ )
239
+ """))
240
+ elif dialect in ("postgresql", "postgres"):
241
+ db.execute(text("""
242
+ CREATE TABLE IF NOT EXISTS sessao_web (
243
+ id SERIAL PRIMARY KEY,
244
+ usuario TEXT NOT NULL,
245
+ session_id TEXT NOT NULL UNIQUE,
246
+ last_seen TIMESTAMPTZ NOT NULL,
247
+ ativo BOOLEAN NOT NULL DEFAULT TRUE
248
+ )
249
+ """))
250
+ else: # mysql / mariadb
251
+ db.execute(text("""
252
+ CREATE TABLE IF NOT EXISTS sessao_web (
253
+ id INT AUTO_INCREMENT PRIMARY KEY,
254
+ usuario VARCHAR(255) NOT NULL,
255
+ session_id VARCHAR(255) NOT NULL UNIQUE,
256
+ last_seen TIMESTAMP NOT NULL,
257
+ ativo TINYINT(1) NOT NULL DEFAULT 1
258
+ )
259
+ """))
260
+ db.commit()
261
+
262
+ def _session_heartbeat(usuario: str) -> None:
263
+ if not usuario:
264
+ return
265
+ db = _get_db_session()
266
+ try:
267
+ _ensure_sessao_table(db)
268
+ sid = _get_session_id()
269
+ now_sql = "CURRENT_TIMESTAMP"
270
+ upd = db.execute(
271
+ text(f"UPDATE sessao_web SET last_seen = {now_sql}, ativo = 1 WHERE session_id = :sid"),
272
+ {"sid": sid}
273
+ )
274
+ if upd.rowcount == 0:
275
+ db.execute(
276
+ text(f"INSERT INTO sessao_web (usuario, session_id, last_seen, ativo) "
277
+ f"VALUES (:usuario, :sid, {now_sql}, 1)"),
278
+ {"usuario": usuario, "sid": sid}
279
+ )
280
+ dialect = db.bind.dialect.name
281
+ if dialect in ("postgresql", "postgres"):
282
+ cleanup_sql = f"UPDATE sessao_web SET ativo = FALSE WHERE last_seen < (NOW() - INTERVAL '{_SESS_TTL_MIN * 2} minutes')"
283
+ elif dialect == "sqlite":
284
+ cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN * 2} minutes')"
285
+ else:
286
+ cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN * 2} MINUTE)"
287
+ db.execute(text(cleanup_sql))
288
+ db.commit()
289
+ except Exception:
290
+ db.rollback()
291
+ finally:
292
+ try: db.close()
293
+ except Exception: pass
294
+
295
+ def _get_active_users_count() -> int:
296
+ db = _get_db_session()
297
+ try:
298
+ _ensure_sessao_table(db)
299
+ dialect = db.bind.dialect.name
300
+ if dialect in ("postgresql", "postgres"):
301
+ threshold = f"(NOW() - INTERVAL '{_SESS_TTL_MIN} minutes')"
302
+ elif dialect == "sqlite":
303
+ threshold = f"datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN} minutes')"
304
+ else:
305
+ threshold = f"DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN} MINUTE)"
306
+ res = db.execute(
307
+ text(f"SELECT COUNT(DISTINCT usuario) AS c FROM sessao_web WHERE ativo = 1 AND last_seen >= {threshold}")
308
+ ).fetchone()
309
+ return int(res[0] if res and res[0] is not None else 0)
310
+ except Exception:
311
+ return 0
312
+ finally:
313
+ try: db.close()
314
+ except Exception: pass
315
+
316
+ def _mark_session_inactive() -> None:
317
+ sid = st.session_state.get("_sid")
318
+ if not sid:
319
+ return
320
+ db = _get_db_session()
321
+ try:
322
+ _ensure_sessao_table(db)
323
+ db.execute(text("UPDATE sessao_web SET ativo = 0 WHERE session_id = :sid"), {"sid": sid})
324
+ db.commit()
325
+ except Exception:
326
+ db.rollback()
327
+ finally:
328
+ try: db.close()
329
+ except Exception: pass
330
+
331
+ # --------------------------------------------------------------------------------------
332
+ # Aviso Global
333
+ # --------------------------------------------------------------------------------------
334
+ def _sanitize_largura(largura_raw: str) -> str:
335
+ val = (largura_raw or "").strip()
336
+ if not val:
337
+ return "100%"
338
+ if val.endswith("%") or val.endswith("px"):
339
+ return val
340
+ if val.isdigit():
341
+ return f"{val}px"
342
+ return "100%"
343
+
344
+ def obter_aviso_ativo(db):
345
+ try:
346
+ aviso = (
347
+ db.query(AvisoGlobal)
348
+ .filter(AvisoGlobal.ativo == True)
349
+ .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
350
+ .first()
351
+ )
352
+ return aviso
353
+ except Exception:
354
+ return None
355
+
356
+ def _render_aviso_global_topbar():
357
+ try:
358
+ db = _get_db_session()
359
+ except Exception as e:
360
+ st.sidebar.warning(f"Aviso desativado: sessão indisponível ({e})")
361
+ return
362
+ aviso = None
363
+ try:
364
+ aviso = obter_aviso_ativo(db)
365
+ except Exception as e:
366
+ st.sidebar.warning(f"Aviso desativado: falha ao consultar ({e})")
367
+ aviso = None
368
+ finally:
369
+ try: db.close()
370
+ except Exception: pass
371
+
372
+ if not aviso:
373
+ return
374
+ try:
375
+ largura = _sanitize_largura(getattr(aviso, "largura", "100%"))
376
+ bg = getattr(aviso, "bg_color", "#FFF3CD") or "#FFF3CD"
377
+ fg = getattr(aviso, "text_color", "#664D03") or "#664D03"
378
+ efeito = (getattr(aviso, "efeito", "marquee") or "marquee").lower()
379
+ velocidade = int(getattr(aviso, "velocidade", 20) or 20)
380
+ try:
381
+ font_size = max(10, min(int(getattr(aviso, "font_size", 14)), 48))
382
+ except Exception:
383
+ font_size = 14
384
+
385
+ altura = 52 # px
386
+ st.markdown(
387
+ f"""
388
+ &lt;style&gt;
389
+ .stApp::before,
390
+ header[data-testid="stHeader"],
391
+ [data-testid="stToolbar"],
392
+ [data-testid="stDecoration"],
393
+ [data-testid="collapsedControl"],
394
+ .stApp [class*="stDialog"] {{
395
+ z-index: 1 !important;
396
+ }}
397
+ [data-testid="stAppViewContainer"] {{
398
+ padding-top: {altura + 8}px !important;
399
+ }}
400
+
401
+ .ag-topbar-wrap {{
402
+ position: fixed;
403
+ top: 0;
404
+ left: 0;
405
+ width: {largura};
406
+ z-index: 2147483647 !important;
407
+ background: {bg};
408
+ color: {fg};
409
+ border-bottom: 1px solid rgba(0,0,0,.12);
410
+ box-shadow: 0 2px 8px rgba(0,0,0,.15);
411
+ border-radius: 0 0 10px 10px;
412
+ pointer-events: none;
413
+ }}
414
+ .ag-topbar-inner {{
415
+ display: flex;
416
+ align-items: center;
417
+ height: {altura}px;
418
+ padding: 0 14px;
419
+ overflow: hidden;
420
+ font-weight: 700;
421
+ font-size: {font_size}px;
422
+ letter-spacing: .2px;
423
+ white-space: nowrap;
424
+ }}
425
+ .ag-topbar-marquee > span {{
426
+ display: inline-block;
427
+ padding-left: 100%;
428
+ animation: ag-marquee {velocidade}s linear infinite;
429
+ }}
430
+ @keyframes ag-marquee {{
431
+ 0% {{ transform: translateX(0); }}
432
+ 100% {{ transform: translateX(-100%); }}
433
+ }}
434
+ @media (prefers-reduced-motion: reduce) {{
435
+ .ag-topbar-marquee > span {{ animation: none !important; padding-left: 0; }}
436
+ }}
437
+ @media (max-width: 500px) {{
438
+ .ag-topbar-inner {{
439
+ font-size: {max(10, font_size-3)}px;
440
+ padding: 0 8px;
441
+ height: 44px;
442
+ }}
443
+ [data-testid="stAppViewContainer"] {{ padding-top: 52px !important; }}
444
+ }}
445
+ &lt;/style&gt;
446
+
447
+ &lt;div class="ag-topbar-wrap"&gt;
448
+ &lt;div class="ag-topbar-inner {'ag-topbar-marquee' if efeito=='marquee' else ''}"&gt;
449
+ &lt;span&gt;{getattr(aviso, 'mensagem', '')}&lt;/span&gt;
450
+ &lt;/div&gt;
451
+ &lt;/div&gt;
452
+ """,
453
+ unsafe_allow_html=True
454
+ )
455
+ except Exception as e:
456
+ st.sidebar.warning(f"Aviso desativado: erro de render ({e})")
457
+ return
458
+
459
+ # --------------------------------------------------------------------------------------
460
+ # Logout
461
+ # --------------------------------------------------------------------------------------
462
+ def logout():
463
+ _mark_session_inactive()
464
+ st.session_state.logado = False
465
+ st.session_state.usuario = None
466
+ st.session_state.perfil = None
467
+ st.session_state.nome = None
468
+ st.session_state.email = None
469
+ st.session_state.quiz_verificado = False
470
+ st.rerun()
471
+
472
+ # --------------------------------------------------------------------------------------
473
+ # 🎂 Banner/efeito de aniversário
474
+ # --------------------------------------------------------------------------------------
475
+ def _show_birthday_banner_if_needed():
476
+ if st.session_state.get("__show_birthday__"):
477
+ st.session_state["__show_birthday__"] = False
478
+ st.markdown(
479
+ """
480
+ &lt;style&gt;
481
+ .confetti-wrapper { position: relative; width: 100%; height: 0; }
482
+ .confetti-area { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
483
+ pointer-events: none; z-index: 9999; }
484
+ .confetti { position: absolute; top: -5%; font-size: 24px; animation-name: confetti-fall;
485
+ animation-timing-function: linear; animation-iteration-count: 1; }
486
+ @keyframes confetti-fall {
487
+ 0% { transform: translateY(-5vh) rotate(0deg); opacity: 1; }
488
+ 100% { transform: translateY(105vh) rotate(360deg); opacity: 0; }
489
+ }
490
+ .confetti:nth-child(1) { left: 5%; animation-duration: 3.5s; }
491
+ .confetti:nth-child(2) { left: 12%; animation-duration: 4.0s; }
492
+ .confetti:nth-child(3) { left: 20%; animation-duration: 3.2s; }
493
+ .confetti:nth-child(4) { left: 28%; animation-duration: 4.3s; }
494
+ .confetti:nth-child(5) { left: 36%; animation-duration: 3.8s; }
495
+ .confetti:nth-child(6) { left: 44%; animation-duration: 4.1s; }
496
+ .confetti:nth-child(7) { left: 52%; animation-duration: 3.4s; }
497
+ .confetti:nth-child(8) { left: 60%; animation-duration: 4.4s; }
498
+ .confetti:nth-child(9) { left: 68%; animation-duration: 3.9s; }
499
+ .confetti:nth-child(10) { left: 76%; animation-duration: 4.2s; }
500
+ .confetti:nth-child(11) { left: 84%; animation-duration: 3.6s; }
501
+ .confetti:nth-child(12) { left: 92%; animation-duration: 4.0s; }
502
+ &lt;/style&gt;
503
+ &lt;div class="confetti-wrapper"&gt;
504
+ &lt;div class="confetti-area"&gt;
505
+ &lt;div class="confetti"&gt;🎊&lt;/div&gt;&lt;div class="confetti"&gt;🎉&lt;/div&gt;
506
+ &lt;div class="confetti"&gt;🎊&lt;/div&gt;&lt;div class="confetti"&gt;🎉&lt;/div&gt;
507
+ &lt;div class="confetti"&gt;🎊&lt;/div&gt;&lt;div class="confetti"&gt;🎉&lt;/div&gt;
508
+ &lt;div class="confetti"&gt;🎊&lt;/div&gt;&lt;div class="confetti"&gt;🎉&lt;/div&gt;
509
+ &lt;div class="confetti"&gt;🎊&lt;/div&gt;&lt;div class="confetti"&gt;🎉&lt;/div&gt;
510
+ &lt;div class="confetti"&gt;🎊&lt;/div&gt;&lt;div class="confetti"&gt;🎉&lt;/div&gt;
511
+ &lt;/div&gt;
512
+ &lt;/div&gt;
513
+ """,
514
+ unsafe_allow_html=True
515
+ )
516
+ st.balloons()
517
+ nome = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário"
518
+ st.markdown(
519
+ f"""
520
+ &lt;div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin:40px 0;"&gt;
521
+ &lt;div style="font-size: 36px; font-weight: 800; color:#A020F0;
522
+ background:linear-gradient(90deg,#FFF0F6,#F0E6FF);
523
+ padding:20px 30px; border-radius:16px; box-shadow:0 4px 10px rgba(0,0,0,.08);"&gt;
524
+ 🎉 Feliz Aniversário, {nome}! 🎉
525
+ &lt;/div&gt;
526
+ &lt;/div&gt;
527
+ """,
528
+ unsafe_allow_html=True
529
+ )
530
+ COR_FRASE = "#0d6efd"
531
+ st.markdown(
532
+ f"""
533
+ &lt;div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin-top:10px;"&gt;
534
+ &lt;div style="font-size: 20px; font-weight: 500; color:{COR_FRASE}; padding:10px;"&gt;
535
+ Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜
536
+ &lt;/div&gt;
537
+ &lt;/div&gt;
538
+ """,
539
+ unsafe_allow_html=True
540
+ )
541
+
542
+ # --------------------------------------------------------------------------------------
543
+ # MAIN
544
+ # --------------------------------------------------------------------------------------
545
+ def main():
546
+ # Estados iniciais
547
+ st.session_state.setdefault("logado", False)
548
+ st.session_state.setdefault("usuario", None)
549
+ st.session_state.setdefault("quiz_verificado", False)
550
+ st.session_state.setdefault("user_responses_viewed", False)
551
+ st.session_state.setdefault("nav_target", None)
552
+ st.session_state.setdefault("__auto_refresh_interval_sec__", 60)
553
+
554
+ # LOGIN
555
+ if not st.session_state.logado:
556
+ st.session_state.quiz_verificado = False
557
+ exibir_logo(top=True, sidebar=False)
558
+ login()
559
+ return
560
+
561
+ # Heartbeat + badge admin
562
+ _session_heartbeat(st.session_state.usuario)
563
+ if (st.session_state.get("perfil") or "").strip().lower() == "admin":
564
+ try:
565
+ online_now = _get_active_users_count()
566
+ except Exception:
567
+ online_now = 0
568
+ st.sidebar.markdown(
569
+ f"""
570
+ &lt;div style="padding:8px 10px;margin-top:6px;margin-bottom:6px;border-radius:8px;
571
+ background:#1e293b; color:#e2e8f0; border:1px solid #334155;"&gt;
572
+ &lt;span style="font-size:13px;"&gt;🟢 Online (últimos {_SESS_TTL_MIN} min)&lt;/span&gt;&lt;br&gt;
573
+ &lt;span style="font-size:22px;font-weight:800;"&gt;{online_now}&lt;/span&gt;
574
+ &lt;/div&gt;
575
+ """,
576
+ unsafe_allow_html=True
577
+ )
578
+
579
+ # 🔄 Recarregar + Auto-refresh
580
+ st.sidebar.markdown("---")
581
+ col_reload, col_interval = st.sidebar.columns([1, 1])
582
+ if col_reload.button("🔄 Recarregar (sem sair)", key="__btn_reload_now__"):
583
+ st.rerun()
584
+
585
+ if hasattr(st, "popover"):
586
+ with col_interval.popover("⏱️ Autoatualização"):
587
+ new_val = st.number_input(
588
+ "Intervalo (segundos) — 0 desativa",
589
+ min_value=0, max_value=3600,
590
+ value=int(st.session_state["__auto_refresh_interval_sec__"]),
591
+ step=5, key="__auto_refresh_input__"
592
+ )
593
+ if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
594
+ st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
595
+ try:
596
+ if int(new_val) > 0:
597
+ st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
598
+ else:
599
+ st.toast("Autoatualização desativada.", icon="⛔")
600
+ except Exception:
601
+ pass
602
+ st.rerun()
603
+ else:
604
+ with st.sidebar.expander("⏱️ Autoatualização", expanded=False):
605
+ new_val = st.number_input(
606
+ "Intervalo (segundos) — 0 desativa",
607
+ min_value=0, max_value=3600,
608
+ value=int(st.session_state["__auto_refresh_interval_sec__"]),
609
+ step=5, key="__auto_refresh_input__"
610
+ )
611
+ if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
612
+ st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
613
+ try:
614
+ if int(new_val) > 0:
615
+ st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
616
+ else:
617
+ st.toast("Autoatualização desativada.", icon="⛔")
618
+ except Exception:
619
+ pass
620
+ st.rerun()
621
+
622
+ usuario = st.session_state.usuario
623
+ perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
624
+
625
+ # QUIZ (gating)
626
+ if not st.session_state.quiz_verificado:
627
+ if not quiz_respondido_hoje(usuario):
628
+ exibir_logo(top=True, sidebar=False)
629
+ quiz.main()
630
+ return
631
+ else:
632
+ st.session_state.quiz_verificado = True
633
+ st.rerun()
634
+
635
+ # SISTEMA LIBERADO
636
+ exibir_logo(top=True, sidebar=True)
637
+ _render_aviso_global_topbar()
638
+ _show_birthday_banner_if_needed()
639
+
640
+ st.sidebar.markdown("### Menu | 🎉 Carnaval 🎭 2026!")
641
+
642
+ # Banco ativo
643
+ try:
644
+ banco_lbl = bank_label(current_db_choice()) if _HAS_ROUTER else (
645
+ "🟢 Produção" if current_db_choice() == "prod" else "🔴 Teste"
646
+ )
647
+ st.sidebar.caption(f"🗄️ Banco ativo: {banco_lbl}")
648
+ except Exception:
649
+ pass
650
+
651
+ # ========================= Menu =========================
652
+ termo_busca = st.sidebar.text_input("Pesquisar módulo:").strip().lower()
653
+
654
+ try:
655
+ ambiente_atual = current_db_choice() if _HAS_ROUTER else "prod"
656
+ except Exception:
657
+ ambiente_atual = "prod"
658
+
659
+ grupos_disponiveis = obter_grupos_disponiveis(
660
+ MODULES,
661
+ perfil=st.session_state.get("perfil", "usuario"),
662
+ usuario=st.session_state.get("usuario"),
663
+ ambiente=ambiente_atual,
664
+ verificar_permissao=verificar_permissao
665
+ )
666
+
667
+ if not grupos_disponiveis:
668
+ st.sidebar.selectbox("Selecione a operação:", ["Em desenvolvimento"], index=0)
669
+ st.warning("Nenhuma operação disponível para seu perfil/ambiente neste momento.")
670
+ return
671
+
672
+ grupo_escolhido = st.sidebar.selectbox("Selecione a operação:", grupos_disponiveis)
673
+
674
+ opcoes = obter_modulos_para_grupo(
675
+ MODULES, grupo_escolhido, termo_busca,
676
+ perfil=st.session_state.get("perfil", "usuario"),
677
+ usuario=st.session_state.get("usuario"),
678
+ ambiente=ambiente_atual,
679
+ verificar_permissao=verificar_permissao
680
+ )
681
+
682
+ with st.sidebar.expander("🔧 Diagnóstico do menu", expanded=False):
683
+ st.caption(f"Perfil: **{st.session_state.get('perfil', '—')}** | Grupo: **{grupo_escolhido}** | Busca: **{termo_busca or '∅'}** | Ambiente: **{ambiente_atual}**")
684
+ try:
685
+ mods_dbg = [{"id": mid, "label": lbl} for mid, lbl in (opcoes or [])]
686
+ except Exception:
687
+ mods_dbg = []
688
+ st.write("Módulos visíveis (após regras):", mods_dbg if mods_dbg else "—")
689
+
690
+ # Failsafe: Outlook
691
+ try:
692
+ mod_outlook = MODULES.get("outlook_relatorio")
693
+ if mod_outlook:
694
+ mesmo_grupo = (mod_outlook.get("grupo") == grupo_escolhido)
695
+ perfil_ok = verificar_permissao(
696
+ perfil=st.session_state.get("perfil", "usuario"),
697
+ modulo_key="outlook_relatorio",
698
+ usuario=st.session_state.get("usuario"),
699
+ ambiente=ambiente_atual
700
+ )
701
+ ja_nas_opcoes = any(mid == "outlook_relatorio" for mid, _ in (opcoes or []))
702
+ passa_busca = (not termo_busca) or (termo_busca in mod_outlook.get("label", "").strip().lower())
703
+ if mesmo_grupo and perfil_ok and not ja_nas_opcoes and passa_busca:
704
+ opcoes = (opcoes or []) + [("outlook_relatorio", mod_outlook.get("label", "Relatorio portaria"))]
705
+ except Exception:
706
+ pass
707
+
708
+ # Failsafe: Repositório Load
709
+ try:
710
+ mod_repo = MODULES.get("repositorio_load")
711
+ if mod_repo:
712
+ mesmo_grupo_r = (mod_repo.get("grupo") == grupo_escolhido)
713
+ perfil_ok_r = verificar_permissao(
714
+ perfil=st.session_state.get("perfil", "usuario"),
715
+ modulo_key="repositorio_load",
716
+ usuario=st.session_state.get("usuario"),
717
+ ambiente=ambiente_atual
718
+ )
719
+ ja_nas_opcoes_r = any(mid == "repositorio_load" for mid, _ in (opcoes or []))
720
+ passa_busca_r = (not termo_busca) or (termo_busca in mod_repo.get("label", "").strip().lower())
721
+ if mesmo_grupo_r and perfil_ok_r and not ja_nas_opcoes_r and passa_busca_r:
722
+ opcoes = (opcoes or []) + [("repositorio_load", mod_repo.get("label", "Repositório Load"))]
723
+ except Exception:
724
+ pass
725
+
726
+ if not opcoes:
727
+ st.sidebar.selectbox("Selecione o módulo:", ["Em desenvolvimento"], index=0)
728
+ st.warning(f"A operação '{grupo_escolhido}' está em desenvolvimento.")
729
+ return
730
+
731
+ # ------------------------- seleção -------------------------
732
+ labels = [label for _, label in opcoes]
733
+
734
+ if st.session_state.get("nav_target"):
735
+ target = st.session_state["nav_target"]
736
+ try:
737
+ target_label = next(lbl for mid, lbl in opcoes if mid == target)
738
+ st.session_state["mod_select_label"] = target_label
739
+ except StopIteration:
740
+ pass
741
+
742
+ if "mod_select_label" not in st.session_state or st.session_state["mod_select_label"] not in labels:
743
+ st.session_state["mod_select_label"] = labels[0]
744
+
745
+ escolha_label = st.sidebar.selectbox(
746
+ "Selecione o módulo:",
747
+ labels,
748
+ index=labels.index(st.session_state["mod_select_label"]),
749
+ key="mod_select_label"
750
+ )
751
+
752
+ pagina_id = next(mod_id for mod_id, label in opcoes if label == escolha_label)
753
+
754
+ if st.session_state.get("nav_target"):
755
+ pagina_id = st.session_state.nav_target
756
+ st.session_state["__nav_lock__"] = True
757
+ else:
758
+ st.session_state["__nav_lock__"] = False
759
+
760
+ _check_rerun_qs(pagina_atual=pagina_id)
761
+
762
+ # Auto-refresh leve do sidebar — não em módulos sensíveis
763
+ try:
764
+ from streamlit_autorefresh import st_autorefresh
765
+ sensiveis = {"resposta", "outlook_relatorio", "formulario", "recebimento"}
766
+ interval_sec = int(st.session_state.get("__auto_refresh_interval_sec__", 60))
767
+ if (interval_sec > 0) and (pagina_id not in sensiveis):
768
+ st_autorefresh(interval=interval_sec * 1000, key=f"sidebar_autorefresh_{interval_sec}s")
769
+ except Exception:
770
+ pass
771
+
772
+ # Logout
773
+ st.sidebar.markdown("---")
774
+ if st.session_state.get("logado") and st.sidebar.button("🚪 Sair (Logout)"):
775
+ logout()
776
+
777
+ st.divider()
778
+
779
+ # ------------------------- Roteamento -------------------------
780
+ if pagina_id == "formulario":
781
+ formulario.main()
782
+ elif pagina_id == "consulta":
783
+ consulta.main()
784
+ elif pagina_id == "relatorio":
785
+ relatorio.main()
786
+ elif pagina_id == "ranking":
787
+ ranking.main()
788
+ elif pagina_id == "quiz":
789
+ quiz.main()
790
+ ranking.main()
791
+ elif pagina_id == "quiz_admin":
792
+ quiz_admin.main()
793
+ elif pagina_id == "usuarios":
794
+ usuarios_admin.main()
795
+ elif pagina_id == "administracao":
796
+ administracao.main()
797
+ elif pagina_id == "videos":
798
+ videos.main()
799
+ elif pagina_id == "auditoria":
800
+ auditoria.main()
801
+ elif pagina_id == "auditoria_cleanup":
802
+ auditoria_cleanup.main()
803
+ elif pagina_id == "importacao":
804
+ importar_excel.main()
805
+ elif pagina_id == "calendario":
806
+ calendario.main()
807
+ elif pagina_id == "jogos":
808
+ st.session_state.setdefault("pontuacao", 0)
809
+ st.session_state.setdefault("rodadas", 0)
810
+ st.session_state.setdefault("ultimo_resultado", None)
811
+ jogos.main()
812
+ elif pagina_id == "temporario":
813
+ db_tools.main()
814
+ elif pagina_id == "db_admin":
815
+ db_admin.main()
816
+ elif pagina_id == "db_monitor":
817
+ db_monitor.main()
818
+ elif pagina_id == "operacao":
819
+ operacao.main()
820
+ elif pagina_id == "resposta":
821
+ resposta.main()
822
+ elif pagina_id == "db_export_import":
823
+ db_export_import.main()
824
+ elif pagina_id == "produtividade_especialista":
825
+ produtividade_especialista.main()
826
+ elif pagina_id == "outlook_relatorio":
827
+ outlook_relatorio.main()
828
+ elif pagina_id == "sugestoes_ioirun":
829
+ if st.session_state.get("perfil") == "admin":
830
+ st.info("Use a **📬 Caixa de Entrada (Admin)** para responder sugestões.")
831
+ else:
832
+ sugestoes_usuario.main()
833
+ elif pagina_id == "repositorio_load":
834
+ repositorio_load.main()
835
+ elif pagina_id == "rnc":
836
+ rnc.pagina()
837
+ elif pagina_id == "rnc_listagem":
838
+ rnc_listagem.pagina()
839
+ elif pagina_id == "rnc_relatorio":
840
+ rnc_relatorio.pagina()
841
+ elif pagina_id == "repo_rnc":
842
+ repo_rnc.pagina()
843
+ elif pagina_id == "recebimento":
844
+ recebimento.main()
845
+
846
+ # ------------------------- Info no sidebar -------------------------
847
+ info_mod_default = INFO_MAP_PAGINA_ID.get(pagina_id, "Geral")
848
+ with st.sidebar.expander("ℹ️ Info • Como usar o sistema", expanded=False):
849
+ st.markdown("""
850
+ **Bem-vindo!**
851
+ Este painel reúne instruções rápidas para utilizar cada módulo e seus campos.
852
+ Selecione o módulo abaixo ou navegue pelo menu — o conteúdo ajusta automaticamente.
853
+ """)
854
+ try:
855
+ sel_index = INFO_MODULOS.index(info_mod_default) if info_mod_default in INFO_MODULOS else 0
856
+ except Exception:
857
+ sel_index = 0
858
+ mod_info_sel = st.selectbox("Escolha o módulo para ver instruções:", INFO_MODULOS, index=sel_index, key="info_mod_sel")
859
+ st.markdown(INFO_CONTEUDO.get(mod_info_sel, "_Conteúdo não disponível para este módulo._"))
860
+
861
+ # Libera nav_target após primeira render da página destino
862
+ if st.session_state.get("__nav_lock__"):
863
+ st.session_state["nav_target"] = None
864
+ st.session_state["__nav_lock__"] = False
865
+
866
+ # --------------------------------------------------------------------------------------
867
+ if __name__ == "__main__":
868
+ main()
869
+
870
+ # Rodapé (visível quando logado)
871
+ if st.session_state.get("logado") and st.session_state.get("email"):
872
+ st.sidebar.markdown(
873
+ f"""
874
+ &lt;div style="display:inline-flex;align-items:center;gap:8px; padding:4px 8px;border-radius:8px;
875
+ background:#e7f1ff;color:#0d6efd;font-size:13px;line-height:1.2;"&gt;
876
+ &lt;span style="font-size:16px;"&gt;👤&lt;/span&gt;
877
+ &lt;span&gt;{st.session_state.email}&lt;/span&gt;
878
+ &lt;/div&gt;
879
+ """,
880
+ unsafe_allow_html=True
881
+ )
882
+
883
+ st.sidebar.markdown(
884
+ """
885
+ &lt;hr style="margin-top: 10px; margin-bottom: 6px;"&gt;
886
+ &lt;p style="font-size: 12px; color: #6c757d;"&gt;
887
+ Versão: &lt;strong&gt;1.0.0&lt;/strong&gt; Desenvolvedor: &lt;strong&gt;Rodrigo Silva - Ideiasystem | 2026&lt;/strong&gt;
888
+ &lt;/p&gt;
889
+ """,
890
+ unsafe_allow_html=True
891
+ )
892
+
893
+
894
+
895
+
896
+
897
+
898
+
899
+
900
+
901
+
902
+