Roudrigus commited on
Commit
0033c7b
·
verified ·
1 Parent(s): 4019e91

Update app.py

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