IOI-RUN / app.py
Roudrigus's picture
Upload 82 files
0f0ef8d verified
raw
history blame
38 kB
# -*- coding: utf-8 -*-
import streamlit as st
from dotenv import load_dotenv
from datetime import date, datetime, time
# ⬇️ Import correto das utils de operação
from utils_operacao import obter_grupos_disponiveis, obter_modulos_para_grupo
# ✅ Usa toda a largura da página (chamar antes de qualquer outro st.*)
st.set_page_config(layout="wide")
# Carrega variáveis de ambiente
load_dotenv()
# ===============================
# IMPORTAÇÃO DOS MÓDULOS
# ===============================
import formulario
import consulta
import relatorio
import administracao
import quiz
import ranking
import quiz_admin
import usuarios_admin
import videos
import auditoria
import importar_excel
import calendario
import auditoria_cleanup
import jogos
import db_tools
import db_admin
import db_monitor
import operacao
import db_export_import
import resposta # 📬 Admin: Caixa de Entrada IOI‑RUN (módulo interno)
import outlook_relatorio
import repositorio_load
import Produtividade_Especialista as produtividade_especialista
import rnc
import rnc_listagem
import rnc_relatorio
import sugestoes_usuario # 💡 Usuário: Sugestões IOI‑RUN (módulo separado)
import repo_rnc
import recebimento
from utils_info import INFO_CONTEUDO, INFO_MODULOS, INFO_MAP_PAGINA_ID
from login import login
from utils_permissoes import verificar_permissao
from utils_layout import exibir_logo
from modules_map import MODULES
from banco import engine, Base, SessionLocal
from models import QuizPontuacao
from models import IOIRunSugestao
from models import AvisoGlobal
# Extras p/ sessões ativas
from uuid import uuid4
from sqlalchemy import text, func, or_
# 🗄️ Banco ativo (Produção/Teste/Treinamento)
try:
from db_router import current_db_choice, bank_label
_HAS_ROUTER = True
except Exception:
_HAS_ROUTER = False
def current_db_choice() -> str:
return "prod"
def bank_label(choice: str) -> str:
return "🟢 Produção" if choice == "prod" else "🔴 Teste"
# ❌ REMOVIDO: não chamar nenhuma página ao importar/rodar o app principal
# if __name__ == "__main__":
# rnc.pagina()
# ===============================
# RERUN por querystring (atalho ?rr=1)
# ===============================
def _get_query_params():
"""Compat: retorna query params como dict (Streamlit novo/antigo)."""
try:
# Streamlit >= 1.32
return dict(st.query_params)
except Exception:
# Streamlit antigo (experimental)
try:
return dict(st.experimental_get_query_params())
except Exception:
return {}
def _set_query_params(new_params: dict):
"""Compat: define query params (Streamlit novo/antigo)."""
try:
st.query_params = new_params # Streamlit >= 1.32
except Exception:
try:
st.experimental_set_query_params(**new_params)
except Exception:
pass
def _check_rerun_qs(pagina_atual: str = ""):
"""
Se a URL contiver rr=1 (ou true), força um rerun e limpa o parâmetro para evitar loop.
✅ Não dispara quando estiver na página 'resposta' (Inbox Admin).
✅ Consome apenas uma vez por sessão.
✅ (PATCH) Também não dispara quando estiver em 'outlook_relatorio' para não interromper leitura COM.
"""
try:
if st.session_state.get("__qs_rr_consumed__", False):
return
# 🔒 Evita rr=1 em módulos sensíveis a rerun/refresh
# 🟩 AJUSTE: incluir 'formulario' para não aplicar rr=1 quando o formulário estiver ativo
if pagina_atual in ("resposta", "outlook_relatorio", "formulario"):
return # não aplicar rr=1 dentro destes módulos (evita 'piscar' e cancelamentos)
params = _get_query_params()
rr_raw = params.get("rr", ["0"])
rr = rr_raw[0] if isinstance(rr_raw, (list, tuple)) else str(rr_raw)
if str(rr).lower() in ("1", "true"):
new_params = {k: v for k, v in params.items() if k != "rr"}
_set_query_params(new_params)
st.session_state["__qs_rr_consumed__"] = True
st.rerun()
except Exception:
pass
# =========================================
# DB helper — sessão ciente do ambiente
# =========================================
def _get_db_session():
"""Retorna uma sessão de banco consistente com o ambiente atual."""
try:
from db_router import get_session_for_current_db
return get_session_for_current_db()
except Exception:
pass
try:
from db_router import get_engine_for_current_db
from sqlalchemy.orm import sessionmaker
Eng = get_engine_for_current_db()
return sessionmaker(bind=Eng)()
except Exception:
pass
return SessionLocal()
# ===============================
# CONFIGURAÇÃO INICIAL
# ===============================
Base.metadata.create_all(bind=engine)
def quiz_respondido_hoje(usuario: str) -> bool:
# ✅ Usar sessão ciente do ambiente
db = _get_db_session()
try:
inicio_dia = datetime.combine(date.today(), time.min)
return (
db.query(QuizPontuacao)
.filter(
QuizPontuacao.usuario == usuario,
QuizPontuacao.data >= inicio_dia
)
.first()
is not None
)
finally:
try:
db.close()
except Exception:
pass
# ===============================
# Sessões ativas (usuários logados agora)
# ===============================
_SESS_TTL_MIN = 5 # janela para considerar "online"
def _get_session_id() -> str:
if "_sid" not in st.session_state:
st.session_state["_sid"] = f"{uuid4()}"
return st.session_state["_sid"]
def _ensure_sessao_table(db) -> None:
"""Cria a tabela sessao_web caso não exista (SQLite/Postgres/MySQL)."""
dialect = db.bind.dialect.name
if dialect == "sqlite":
db.execute(text("""
CREATE TABLE IF NOT EXISTS sessao_web (
id INTEGER PRIMARY KEY AUTOINCREMENT,
usuario TEXT NOT NULL,
session_id TEXT NOT NULL UNIQUE,
last_seen TIMESTAMP NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
)
"""))
elif dialect in ("postgresql", "postgres"):
db.execute(text("""
CREATE TABLE IF NOT EXISTS sessao_web (
id SERIAL PRIMARY KEY,
usuario TEXT NOT NULL,
session_id TEXT NOT NULL UNIQUE,
last_seen TIMESTAMPTZ NOT NULL,
ativo BOOLEAN NOT NULL DEFAULT TRUE
)
"""))
else: # mysql / mariadb
db.execute(text("""
CREATE TABLE IF NOT EXISTS sessao_web (
id INT AUTO_INCREMENT PRIMARY KEY,
usuario VARCHAR(255) NOT NULL,
session_id VARCHAR(255) NOT NULL UNIQUE,
last_seen TIMESTAMP NOT NULL,
ativo TINYINT(1) NOT NULL DEFAULT 1
)
"""))
db.commit()
def _session_heartbeat(usuario: str) -> None:
"""Atualiza/insere a sessão ativa do usuário com last_seen = now() e faz limpeza básica."""
if not usuario:
return
db = _get_db_session()
try:
_ensure_sessao_table(db)
sid = _get_session_id()
now_sql = "CURRENT_TIMESTAMP"
upd = db.execute(
text(f"UPDATE sessao_web SET last_seen = {now_sql}, ativo = 1 WHERE session_id = :sid"),
{"sid": sid}
)
if upd.rowcount == 0:
db.execute(
text(f"INSERT INTO sessao_web (usuario, session_id, last_seen, ativo) "
f"VALUES (:usuario, :sid, {now_sql}, 1)"),
{"usuario": usuario, "sid": sid}
)
dialect = db.bind.dialect.name
if dialect in ("postgresql", "postgres"):
cleanup_sql = f"UPDATE sessao_web SET ativo = FALSE WHERE last_seen < (NOW() - INTERVAL '{_SESS_TTL_MIN * 2} minutes')"
elif dialect == "sqlite":
cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN * 2} minutes')"
else:
cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN * 2} MINUTE)"
db.execute(text(cleanup_sql))
db.commit()
except Exception:
db.rollback()
finally:
try:
db.close()
except Exception:
pass
def _get_active_users_count() -> int:
"""Conta usuários distintos com last_seen dentro da janela (_SESS_TTL_MIN) e ativo=1."""
db = _get_db_session()
try:
_ensure_sessao_table(db)
dialect = db.bind.dialect.name
if dialect in ("postgresql", "postgres"):
threshold = f"(NOW() - INTERVAL '{_SESS_TTL_MIN} minutes')"
elif dialect == "sqlite":
threshold = f"datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN} minutes')"
else:
threshold = f"DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN} MINUTE)"
res = db.execute(
text(f"SELECT COUNT(DISTINCT usuario) AS c FROM sessao_web WHERE ativo = 1 AND last_seen >= {threshold}")
).fetchone()
return int(res[0] if res and res[0] is not None else 0)
except Exception:
return 0
finally:
try:
db.close()
except Exception:
pass
def _mark_session_inactive() -> None:
"""Marca a sessão atual como inativa (chamar no logout)."""
sid = st.session_state.get("_sid")
if not sid:
return
db = _get_db_session()
try:
_ensure_sessao_table(db)
db.execute(text("UPDATE sessao_web SET ativo = 0 WHERE session_id = :sid"), {"sid": sid})
db.commit()
except Exception:
db.rollback()
finally:
try:
db.close()
except Exception:
pass
# ===============================
# Aviso Global — Util (leitura e sanitização)
# ===============================
def _sanitize_largura(largura_raw: str) -> str:
val = (largura_raw or "").strip()
if not val:
return "100%"
if val.endswith("%") or val.endswith("px"):
return val
if val.isdigit():
return f"{val}px"
return "100%"
def obter_aviso_ativo(db):
try:
aviso = (
db.query(AvisoGlobal)
.filter(AvisoGlobal.ativo == True)
.order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
.first()
)
return aviso
except Exception:
return None
# ===============================
# Aviso Global — Render do banner superior (robusto)
# ===============================
def _render_aviso_global_topbar():
try:
db = _get_db_session()
except Exception as e:
st.sidebar.warning(f"Aviso desativado: sessão indisponível ({e})")
return
aviso = None
try:
aviso = obter_aviso_ativo(db)
except Exception as e:
st.sidebar.warning(f"Aviso desativado: falha ao consultar ({e})")
aviso = None
finally:
try:
db.close()
except Exception:
pass
if not aviso:
return
try:
largura = _sanitize_largura(aviso.largura)
bg = aviso.bg_color or "#FFF3CD"
fg = aviso.text_color or "#664D03"
efeito = (aviso.efeito or "marquee").lower()
velocidade = int(aviso.velocidade or 20)
try:
font_size = max(10, min(int(getattr(aviso, "font_size", 14)), 48))
except Exception:
font_size = 14
altura = 52 # px
st.markdown(
f"""
<style>
/* Não derrube overlays do Streamlit */
.stApp::before,
header[data-testid="stHeader"],
[data-testid="stToolbar"],
[data-testid="stDecoration"],
[data-testid="collapsedControl"],
.stApp [class*="stDialog"] {{
z-index: 1 !important;
}}
/* Reserva espaço para a barra */
[data-testid="stAppViewContainer"] {{
padding-top: {altura + 8}px !important;
}}
.ag-topbar-wrap {{
position: fixed;
top: 0;
left: 0;
width: {largura};
z-index: 2147483647 !important;
background: {bg};
color: {fg};
border-bottom: 1px solid rgba(0,0,0,.12);
box-shadow: 0 2px 8px rgba(0,0,0,.15);
border-radius: 0 0 10px 10px;
pointer-events: none;
}}
.ag-topbar-inner {{
display: flex;
align-items: center;
height: {altura}px;
padding: 0 14px;
overflow: hidden;
font-weight: 700;
font-size: {font_size}px;
letter-spacing: .2px;
white-space: nowrap;
}}
.ag-topbar-marquee > span {{
display: inline-block;
padding-left: 100%;
animation: ag-marquee {velocidade}s linear infinite;
}}
@keyframes ag-marquee {{
0% {{ transform: translateX(0); }}
100% {{ transform: translateX(-100%); }}
}}
/* Acessibilidade: reduz movimento */
@media (prefers-reduced-motion: reduce) {{
.ag-topbar-marquee > span {{
animation: none !important;
padding-left: 0;
}}
}}
@media (max-width: 500px) {{
.ag-topbar-inner {{
font-size: {max(10, font_size-3)}px;
padding: 0 8px;
height: 44px;
}}
[data-testid="stAppViewContainer"] {{
padding-top: 52px !important;
}}
}}
</style>
<div class="ag-topbar-wrap">
<div class="ag-topbar-inner {'ag-topbar-marquee' if efeito=='marquee' else ''}">
<span>{aviso.mensagem}</span>
</div>
</div>
""",
unsafe_allow_html=True
)
except Exception as e:
st.sidebar.warning(f"Aviso desativado: erro de render ({e})")
return
# ===============================
# Logout (utilitário)
# ===============================
def logout():
"""Finaliza a sessão do usuário, limpa estados e recarrega a aplicação."""
_mark_session_inactive() # marca esta sessão como inativa
st.session_state.logado = False
st.session_state.usuario = None
st.session_state.perfil = None
st.session_state.nome = None
st.session_state.email = None
st.session_state.quiz_verificado = False
st.rerun()
# ===============================
# 🎂 Banner/efeito de aniversário
# ===============================
def _show_birthday_banner_if_needed():
if st.session_state.get("__show_birthday__"):
st.session_state["__show_birthday__"] = False
st.markdown(
"""
<style>
.confetti-wrapper { position: relative; width: 100%; height: 0; }
.confetti-area { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 9999; }
.confetti { position: absolute; top: -5%; font-size: 24px; animation-name: confetti-fall;
animation-timing-function: linear; animation-iteration-count: 1; }
@keyframes confetti-fall {
0% { transform: translateY(-5vh) rotate(0deg); opacity: 1; }
100% { transform: translateY(105vh) rotate(360deg); opacity: 0; }
}
.confetti:nth-child(1) { left: 5%; animation-duration: 3.5s; }
.confetti:nth-child(2) { left: 12%; animation-duration: 4.0s; }
.confetti:nth-child(3) { left: 20%; animation-duration: 3.2s; }
.confetti:nth-child(4) { left: 28%; animation-duration: 4.3s; }
.confetti:nth-child(5) { left: 36%; animation-duration: 3.8s; }
.confetti:nth-child(6) { left: 44%; animation-duration: 4.1s; }
.confetti:nth-child(7) { left: 52%; animation-duration: 3.4s; }
.confetti:nth-child(8) { left: 60%; animation-duration: 4.4s; }
.confetti:nth-child(9) { left: 68%; animation-duration: 3.9s; }
.confetti:nth-child(10) { left: 76%; animation-duration: 4.2s; }
.confetti:nth-child(11) { left: 84%; animation-duration: 3.6s; }
.confetti:nth-child(12) { left: 92%; animation-duration: 4.0s; }
</style>
<div class="confetti-wrapper">
<div class="confetti-area">
<div class="confetti">🎊</div><div class="confetti">🎉</div>
<div class="confetti">🎊</div><div class="confetti">🎉</div>
<div class="confetti">🎊</div><div class="confetti">🎉</div>
<div class="confetti">🎊</div><div class="confetti">🎉</div>
<div class="confetti">🎊</div><div class="confetti">🎉</div>
<div class="confetti">🎊</div><div class="confetti">🎉</div>
</div>
</div>
""",
unsafe_allow_html=True
)
st.balloons()
nome = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário"
st.markdown(
f"""
<div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin:40px 0;">
<div style="font-size: 36px; font-weight: 800; color:#A020F0;
background:linear-gradient(90deg,#FFF0F6,#F0E6FF);
padding:20px 30px; border-radius:16px; box-shadow:0 4px 10px rgba(0,0,0,.08);">
🎉 Feliz Aniversário, {nome}! 🎉
</div>
</div>
""",
unsafe_allow_html=True
)
COR_FRASE = "#0d6efd"
st.markdown(
f"""
<div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin-top:10px;">
<div style="font-size: 20px; font-weight: 500; color:{COR_FRASE}; padding:10px;">
Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜
</div>
</div>
""",
unsafe_allow_html=True
)
# ===============================
# MAIN
# ===============================
def main():
# Estados iniciais
if "logado" not in st.session_state:
st.session_state.logado = False
if "usuario" not in st.session_state:
st.session_state.usuario = None
if "quiz_verificado" not in st.session_state:
st.session_state.quiz_verificado = False
if "user_responses_viewed" not in st.session_state:
st.session_state.user_responses_viewed = False
if "nav_target" not in st.session_state:
st.session_state.nav_target = None
# ✅ Estado do intervalo de autoatualização (padrão aumentado p/ 60s; 0 = desligado)
st.session_state.setdefault("__auto_refresh_interval_sec__", 60)
# LOGIN
if not st.session_state.logado:
st.session_state.quiz_verificado = False
exibir_logo(top=True, sidebar=False)
login()
return
# 👥 Heartbeat + Badge de usuários logados (APENAS ADMIN)
_session_heartbeat(st.session_state.usuario)
if (st.session_state.get("perfil") or "").strip().lower() == "admin":
try:
online_now = _get_active_users_count()
except Exception:
online_now = 0
st.sidebar.markdown(
f"""
<div style="padding:8px 10px;margin-top:6px;margin-bottom:6px;border-radius:8px;
background:#1e293b; color:#e2e8f0; border:1px solid #334155;">
<span style="font-size:13px;">🟢 Online (últimos {_SESS_TTL_MIN} min)</span><br>
<span style="font-size:22px;font-weight:800;">{online_now}</span>
</div>
""",
unsafe_allow_html=True
)
# 🔄 Botão de Recarregar (mantém a sessão ativa) + ⏱️ Controle do intervalo
st.sidebar.markdown("---")
# Linha com botão de recarregar e popover para o intervalo
col_reload, col_interval = st.sidebar.columns([1, 1])
if col_reload.button("🔄 Recarregar (sem sair)", key="__btn_reload_now__"):
st.rerun()
# Popover (se disponível) para configurar intervalo; fallback para expander
if hasattr(st, "popover"):
with col_interval.popover("⏱️ Autoatualização"):
new_val = st.number_input(
"Intervalo (segundos) — 0 desativa",
min_value=0, max_value=3600,
value=int(st.session_state["__auto_refresh_interval_sec__"]),
step=5, key="__auto_refresh_input__"
)
if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
try:
if int(new_val) > 0:
st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
else:
st.toast("Autoatualização desativada.", icon="⛔")
except Exception:
pass
st.rerun()
else:
with st.sidebar.expander("⏱️ Autoatualização", expanded=False):
new_val = st.number_input(
"Intervalo (segundos) — 0 desativa",
min_value=0, max_value=3600,
value=int(st.session_state["__auto_refresh_interval_sec__"]),
step=5, key="__auto_refresh_input__"
)
if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
try:
if int(new_val) > 0:
st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
else:
st.toast("Autoatualização desativada.", icon="⛔")
except Exception:
pass
st.rerun()
usuario = st.session_state.usuario
perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
# QUIZ
if not st.session_state.quiz_verificado:
if not quiz_respondido_hoje(usuario):
exibir_logo(top=True, sidebar=False)
quiz.main()
return
else:
st.session_state.quiz_verificado = True
st.rerun()
# SISTEMA LIBERADO
exibir_logo(top=True, sidebar=True)
_render_aviso_global_topbar()
_show_birthday_banner_if_needed()
st.sidebar.markdown("### Menu | 🎉 Carnaval 🎭 2026!")
# Banco ativo na sidebar
try:
banco_label = bank_label(current_db_choice()) if _HAS_ROUTER else (
"🟢 Produção" if current_db_choice() == "prod" else "🔴 Teste"
)
st.sidebar.caption(f"🗄️ Banco ativo: {banco_label}")
except Exception:
pass
# =========================
# Notificações no sidebar
# =========================
# --- Admin: pendentes ---
if perfil == "admin":
try:
db = _get_db_session()
pendentes = db.query(IOIRunSugestao).filter(func.lower(IOIRunSugestao.status) == "pendente").count()
except Exception:
pendentes = 0
finally:
try: db.close()
except Exception: pass
if pendentes > 0:
st.sidebar.markdown(
"""
<div style="padding:8px 10px;border-radius:8px;background:#FFF3CD;color:#664D03;
border:1px solid #FFECB5;margin-bottom:6px;">
<b>🔔 {pendentes} sugestão(ões) pendente(s)</b><br>
<span style="font-size:12px;">Acesse a caixa de entrada para responder.</span>
</div>
""".format(pendentes=pendentes),
unsafe_allow_html=True
)
# 👉 Direciona para o MESMO módulo do menu (resposta.main())
if st.sidebar.button("📬 Abrir Caixa de Entrada (Admin)"):
st.session_state.nav_target = "resposta"
st.rerun()
# --- Usuário: respostas novas (após último 'visto') ---
if perfil != "admin":
# Última vez que o usuário realmente abriu e visualizou as respostas
last_seen_dt = st.session_state.get("__user_last_answer_seen__")
try:
db = _get_db_session()
# Qual é a resposta mais recente existente
last_answer_dt_row = (
db.query(IOIRunSugestao.data_resposta)
.filter(
IOIRunSugestao.usuario == usuario,
func.lower(IOIRunSugestao.status) == "respondida",
IOIRunSugestao.data_resposta != None
)
.order_by(IOIRunSugestao.data_resposta.desc())
.first()
)
last_answer_dt = last_answer_dt_row[0] if last_answer_dt_row else None
# Se há algo mais novo do que o 'visto', marcamos como não visto
if last_answer_dt and (not last_seen_dt or last_answer_dt > last_seen_dt):
st.session_state.user_responses_viewed = False
# ✅ Conta SOMENTE respostas novas (depois do 'last_seen_dt')
novas_respostas = (
db.query(IOIRunSugestao)
.filter(
IOIRunSugestao.usuario == usuario,
func.lower(IOIRunSugestao.status) == "respondida",
(IOIRunSugestao.data_resposta > last_seen_dt) if last_seen_dt else (IOIRunSugestao.data_resposta != None)
)
.count()
)
except Exception:
novas_respostas = 0
finally:
try: db.close()
except Exception: pass
# ✅ Exibir card de nova mensagem até o usuário clicar em "Ver respostas"
if novas_respostas > 0 and not st.session_state.get("user_responses_viewed", False):
st.sidebar.markdown(
"""
<div style="padding:8px 10px;border-radius:8px;background:#D1E7DD;color:#0F5132;
border:1px solid #BADBCC;margin-bottom:6px;">
<b>🔔 {resps} resposta(s) nova(s) para suas sugestões</b><br>
<span style="font-size:12px;">Clique para ver suas respostas.</span>
</div>
""".format(resps=novas_respostas),
unsafe_allow_html=True
)
# (Opcional) Toast discreto — aparece uma única vez por sessão enquanto houver novidade
if not st.session_state.get("__user_toast_shown__"):
try:
st.toast("Você tem novas respostas do IOI‑RUN. Clique em '📥 Ver respostas'.", icon="💬")
except Exception:
pass
st.session_state["__user_toast_shown__"] = True
if st.sidebar.button("📥 Ver respostas"):
# Não atualizamos last_seen aqui; isso é feito dentro do módulo do usuário
st.session_state.nav_target = "sugestoes_ioirun"
st.session_state.user_responses_viewed = True
st.rerun()
else:
# Se não há novidades, libera o toast para a próxima vez que houver
st.session_state["__user_toast_shown__"] = False
# ------------------------- Menu lateral -------------------------
termo_busca = st.sidebar.text_input("Pesquisar módulo:").strip().lower()
try:
ambiente_atual = current_db_choice() if _HAS_ROUTER else "prod"
except Exception:
ambiente_atual = "prod"
grupos_disponiveis = obter_grupos_disponiveis(
MODULES,
perfil=st.session_state.get("perfil", "usuario"),
usuario=st.session_state.get("usuario"),
ambiente=ambiente_atual,
verificar_permissao=verificar_permissao
)
if not grupos_disponiveis:
st.sidebar.selectbox("Selecione a operação:", ["Em desenvolvimento"], index=0)
st.warning("Nenhuma operação disponível para seu perfil/ambiente neste momento.")
return
grupo_escolhido = st.sidebar.selectbox("Selecione a operação:", grupos_disponiveis)
opcoes = obter_modulos_para_grupo(
MODULES, grupo_escolhido, termo_busca,
perfil=st.session_state.get("perfil", "usuario"),
usuario=st.session_state.get("usuario"),
ambiente=ambiente_atual,
verificar_permissao=verificar_permissao
)
with st.sidebar.expander("🔧 Diagnóstico do menu", expanded=False):
st.caption(f"Perfil: **{st.session_state.get('perfil', '—')}** | Grupo: **{grupo_escolhido}** | Busca: **{termo_busca or '∅'}** | Ambiente: **{ambiente_atual}**")
try:
mods_dbg = [{"id": mid, "label": lbl} for mid, lbl in (opcoes or [])]
except Exception:
mods_dbg = []
st.write("Módulos visíveis (após regras):", mods_dbg if mods_dbg else "—")
# Failsafe outlook_relatorio
try:
mod_outlook = MODULES.get("outlook_relatorio")
if mod_outlook:
mesmo_grupo = (mod_outlook.get("grupo") == grupo_escolhido)
perfil_ok = verificar_permissao(
perfil=st.session_state.get("perfil", "usuario"),
modulo_key="outlook_relatorio",
usuario=st.session_state.get("usuario"),
ambiente=ambiente_atual
)
ja_nas_opcoes = any(mid == "outlook_relatorio" for mid, _ in (opcoes or []))
passa_busca = (not termo_busca) or (termo_busca in mod_outlook.get("label", "").strip().lower())
if mesmo_grupo and perfil_ok and not ja_nas_opcoes and passa_busca:
opcoes = (opcoes or []) + [("outlook_relatorio", mod_outlook.get("label", "Relatorio portaria"))]
except Exception:
pass
# Failsafe repositorio_load
try:
mod_repo = MODULES.get("repositorio_load")
if mod_repo:
mesmo_grupo_r = (mod_repo.get("grupo") == grupo_escolhido)
perfil_ok_r = verificar_permissao(
perfil=st.session_state.get("perfil", "usuario"),
modulo_key="repositorio_load",
usuario=st.session_state.get("usuario"),
ambiente=ambiente_atual
)
ja_nas_opcoes_r = any(mid == "repositorio_load" for mid, _ in (opcoes or []))
passa_busca_r = (not termo_busca) or (termo_busca in mod_repo.get("label", "").strip().lower())
if mesmo_grupo_r and perfil_ok_r and not ja_nas_opcoes_r and passa_busca_r:
opcoes = (opcoes or []) + [("repositorio_load", mod_repo.get("label", "Repositório Load"))]
except Exception:
pass
if not opcoes:
st.sidebar.selectbox("Selecione o módulo:", ["Em desenvolvimento"], index=0)
st.warning(f"A operação '{grupo_escolhido}' está em desenvolvimento.")
return
# ============================================================
# 🔒 Fix: selectbox com 'key' + seleção forçada para 'resposta'
# quando vier de nav_target (sidebar) ou quando já estivermos na página.
# ============================================================
labels = [label for _, label in opcoes]
# Se foi solicitado nav_target, injeta a label alvo antes do selectbox
if st.session_state.get("nav_target"):
target = st.session_state["nav_target"]
try:
target_label = next(lbl for mid, lbl in opcoes if mid == target)
st.session_state["mod_select_label"] = target_label
except StopIteration:
pass
# Inicializa/persiste seleção
if "mod_select_label" not in st.session_state or st.session_state["mod_select_label"] not in labels:
st.session_state["mod_select_label"] = labels[0]
escolha_label = st.sidebar.selectbox(
"Selecione o módulo:",
labels,
index=labels.index(st.session_state["mod_select_label"]),
key="mod_select_label"
)
pagina_id = next(mod_id for mod_id, label in opcoes if label == escolha_label)
# ✅ Navegação com lock (evita disputa com outros reruns)
if st.session_state.get("nav_target"):
pagina_id = st.session_state.nav_target
st.session_state["__nav_lock__"] = True
else:
st.session_state["__nav_lock__"] = False
# 🔎 Agora que sabemos a página atual, tratamos rr=1 com segurança
_check_rerun_qs(pagina_atual=pagina_id)
# ⏱️ Auto-refresh leve do sidebar — NÃO quando em Inbox/Admin/Outlook/Formulário/Recebimento
try:
from streamlit_autorefresh import st_autorefresh
is_inbox_admin = (pagina_id == "resposta")
is_outlook_rel = (pagina_id == "outlook_relatorio")
is_formulario = (pagina_id == "formulario")
is_recebimento = (pagina_id == "recebimento")
interval_sec = int(st.session_state.get("__auto_refresh_interval_sec__", 60))
if (interval_sec > 0) and not (is_inbox_admin or is_outlook_rel or is_formulario or is_recebimento):
# key dinâmica por intervalo evita conflitos ao trocar o valor
st_autorefresh(interval=interval_sec * 1000, key=f"sidebar_autorefresh_{interval_sec}s")
except Exception:
pass
# Logout
st.sidebar.markdown("---")
if st.session_state.get("logado"):
if st.sidebar.button("🚪 Sair (Logout)"):
logout()
st.divider()
# ------------------------- Roteamento -------------------------
if pagina_id == "formulario":
formulario.main()
elif pagina_id == "consulta":
consulta.main()
elif pagina_id == "relatorio":
relatorio.main()
elif pagina_id == "ranking":
ranking.main()
elif pagina_id == "quiz":
quiz.main()
ranking.main()
elif pagina_id == "quiz_admin":
quiz_admin.main()
elif pagina_id == "usuarios":
usuarios_admin.main()
elif pagina_id == "administracao":
administracao.main()
elif pagina_id == "videos":
videos.main()
elif pagina_id == "auditoria":
auditoria.main()
elif pagina_id == "auditoria_cleanup":
auditoria_cleanup.main()
elif pagina_id == "importacao":
importar_excel.main()
elif pagina_id == "calendario":
calendario.main()
elif pagina_id == "jogos":
st.session_state.setdefault("pontuacao", 0)
st.session_state.setdefault("rodadas", 0)
st.session_state.setdefault("ultimo_resultado", None)
jogos.main()
elif pagina_id == "temporario":
db_tools.main()
elif pagina_id == "db_admin":
db_admin.main()
elif pagina_id == "db_monitor":
db_monitor.main()
elif pagina_id == "operacao":
operacao.main()
elif pagina_id == "resposta": # 📬 Admin
resposta.main()
elif pagina_id == "db_export_import":
db_export_import.main()
elif pagina_id == "produtividade_especialista":
produtividade_especialista.main()
elif pagina_id == "outlook_relatorio":
outlook_relatorio.main()
elif pagina_id == "sugestoes_ioirun": # 💡 Usuário
if st.session_state.get("perfil") == "admin":
st.info("Use a **📬 Caixa de Entrada (Admin)** para responder sugestões.")
else:
sugestoes_usuario.main()
elif pagina_id == "repositorio_load":
repositorio_load.main()
elif pagina_id == "rnc":
rnc.pagina()
elif pagina_id == "rnc_listagem":
rnc_listagem.pagina()
elif pagina_id == "rnc_relatorio":
rnc_relatorio.pagina()
elif pagina_id == "repo_rnc":
repo_rnc.pagina()
elif pagina_id == "recebimento":
recebimento.main()
# ------------------------------------------------------
# ℹ️ INFO — Guia passo a passo de uso (no sidebar)
# ------------------------------------------------------
info_mod_default = INFO_MAP_PAGINA_ID.get(pagina_id, "Geral")
with st.sidebar.expander("ℹ️ Info • Como usar o sistema", expanded=False):
st.markdown("""
**Bem-vindo!**
Este painel reúne instruções rápidas para utilizar cada módulo e seus campos.
Selecione o módulo abaixo ou navegue pelo menu — o conteúdo ajusta automaticamente.
""")
mod_info_sel = st.selectbox(
"Escolha o módulo para ver instruções:",
INFO_MODULOS,
index=INFO_MODULOS.index(info_mod_default) if info_mod_default in INFO_MODULOS else 0,
key="info_mod_sel"
)
st.markdown(INFO_CONTEUDO.get(mod_info_sel, "_Conteúdo não disponível para este módulo._"))
# ✅ Libera o nav_target após a 1ª render da página de destino
if st.session_state.get("__nav_lock__"):
st.session_state["nav_target"] = None
st.session_state["__nav_lock__"] = False
if __name__ == "__main__":
main()
# -------------------------
# Desenvolvedor e versão
# -------------------------
if st.session_state.get("logado") and st.session_state.get("email"):
st.sidebar.markdown(
f"""
<div style="display:inline-flex;align-items:center;gap:8px; padding:4px 8px;border-radius:8px;
background:#e7f1ff;color:#0d6efd;font-size:13px;line-height:1.2;">
<span style="font-size:16px;">👤</span>
<span>{st.session_state.email}</span>
</div>
""",
unsafe_allow_html=True
)
st.sidebar.markdown(
"""
<hr style="margin-top: 10px; margin-bottom: 6px;">
<p style="font-size: 12px; color: #6c757d;">
Versão: <strong>1.0.0</strong> • Desenvolvedor: <strong>Rodrigo Silva - Ideiasystem | 2026</strong>
</p>
""",
unsafe_allow_html=True
)