Roudrigus commited on
Commit
267403a
·
verified ·
1 Parent(s): 6f19ffa

Upload 81 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.audit_report.json ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "duplicate_keys": {},
3
+ "widgets_without_key": {
4
+ ".\\app.py": {
5
+ "button_no_key": [
6
+ 445
7
+ ]
8
+ },
9
+ ".\\app_outlook.py": {
10
+ "download_no_key": [
11
+ 24,
12
+ 36,
13
+ 82
14
+ ]
15
+ },
16
+ ".\\auditoria.py": {
17
+ "download_no_key": [
18
+ 91
19
+ ]
20
+ },
21
+ ".\\auditoria_cleanup.py": {
22
+ "button_no_key": [
23
+ 65
24
+ ]
25
+ },
26
+ ".\\consulta.py": {
27
+ "download_no_key": [
28
+ 171
29
+ ]
30
+ },
31
+ ".\\db_admin.py": {
32
+ "button_no_key": [
33
+ 213,
34
+ 241,
35
+ 263,
36
+ 316,
37
+ 360
38
+ ]
39
+ },
40
+ ".\\db_export_import.py": {
41
+ "button_no_key": [
42
+ 273,
43
+ 285,
44
+ 301,
45
+ 320,
46
+ 333
47
+ ],
48
+ "download_no_key": [
49
+ 277,
50
+ 289,
51
+ 305
52
+ ]
53
+ },
54
+ ".\\db_monitor.py": {
55
+ "button_no_key": [
56
+ 261,
57
+ 265
58
+ ]
59
+ },
60
+ ".\\db_tools.py": {
61
+ "button_no_key": [
62
+ 57
63
+ ]
64
+ },
65
+ ".\\importar_excel.py": {
66
+ "download_no_key": [
67
+ 77
68
+ ]
69
+ },
70
+ ".\\jogos.py": {
71
+ "button_no_key": [
72
+ 104,
73
+ 256,
74
+ 356
75
+ ]
76
+ },
77
+ ".\\login.py": {
78
+ "button_no_key": [
79
+ 83
80
+ ]
81
+ },
82
+ ".\\operacao.py": {
83
+ "button_no_key": [
84
+ 1444,
85
+ 1452,
86
+ 1471,
87
+ 1487
88
+ ],
89
+ "download_no_key": [
90
+ 1543,
91
+ 1547
92
+ ]
93
+ },
94
+ ".\\outlook_relatorio.py": {
95
+ "download_no_key": [
96
+ 30,
97
+ 42,
98
+ 79
99
+ ]
100
+ },
101
+ ".\\Produtividade_Especialista.py": {
102
+ "button_no_key": [
103
+ 52
104
+ ],
105
+ "download_no_key": [
106
+ 506,
107
+ 518
108
+ ]
109
+ },
110
+ ".\\quiz.py": {
111
+ "button_no_key": [
112
+ 107
113
+ ]
114
+ },
115
+ ".\\quiz_admin.py": {
116
+ "button_no_key": [
117
+ 57
118
+ ]
119
+ },
120
+ ".\\ranking.py": {
121
+ "download_no_key": [
122
+ 109
123
+ ]
124
+ },
125
+ ".\\repositorio_load.py": {
126
+ "download_no_key": [
127
+ 251,
128
+ 344
129
+ ]
130
+ },
131
+ ".\\videos.py": {
132
+ "button_no_key": [
133
+ 65,
134
+ 85
135
+ ]
136
+ }
137
+ },
138
+ "missing_imports_in_app": [],
139
+ "routing_vs_modules": {
140
+ "routes_without_modules_entry": [],
141
+ "modules_entry_without_route": [
142
+ "administracao",
143
+ "auditoria",
144
+ "auditoria_cleanup",
145
+ "backload_consulta",
146
+ "calendario",
147
+ "calendario_mensal",
148
+ "consulta",
149
+ "db_admin",
150
+ "db_export_import",
151
+ "db_monitor",
152
+ "formulario",
153
+ "importacao",
154
+ "indicadores",
155
+ "jogos",
156
+ "operacao",
157
+ "outlook_relatorio",
158
+ "produtividade_especialista",
159
+ "quiz",
160
+ "quiz_admin",
161
+ "ranking",
162
+ "relatorio",
163
+ "repositorio_load",
164
+ "resposta",
165
+ "sugestoes_ioirun",
166
+ "terceiros_gestao",
167
+ "usuarios",
168
+ "videos"
169
+ ]
170
+ },
171
+ "module_files_missing": [],
172
+ "modules_without_main": [],
173
+ "unused_imports": {
174
+ ".\\auto_capture.py": [
175
+ "TimeoutError"
176
+ ],
177
+ ".\\calendario_mensal.py": [
178
+ "formatar_data_br"
179
+ ],
180
+ ".\\db_admin.py": [
181
+ "SessionLocal"
182
+ ],
183
+ ".\\db_export_import.py": [
184
+ "SessionLocal"
185
+ ],
186
+ ".\\db_monitor.py": [
187
+ "time",
188
+ "SessionLocal",
189
+ "verificar_permissao"
190
+ ],
191
+ ".\\env_audit.py": [
192
+ "Tuple"
193
+ ],
194
+ ".\\init_db.py": [
195
+ "models"
196
+ ],
197
+ ".\\modules_map.py": [
198
+ "calendario",
199
+ "calendario_mensal"
200
+ ],
201
+ ".\\utils_auditoria.py": [
202
+ "db_info"
203
+ ],
204
+ ".\\utils_campos.py": [
205
+ "Equipamento"
206
+ ],
207
+ ".\\utils_datas.py": [
208
+ "date"
209
+ ],
210
+ ".\\utils_lembretes.py": [
211
+ "date"
212
+ ],
213
+ ".\\utils_operacao.py": [
214
+ "annotations",
215
+ "st"
216
+ ]
217
+ },
218
+ "import_cycles": []
219
+ }
.env ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # --- Mayasuite API (Operação) ---
3
+ OP_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc2ODQ4NjI5NywianRpIjoiNTQ1NjdkYmUtZGUxZi00ZDAxLTkzYzktZGRiYzk4MGJmYWNlIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6IjgzNDAxM2QzLWJhNTMtNDQ1MC1hZmJlLTc4ODZhZjQ5MjJiNCIsIm5iZiI6MTc2ODQ4NjI5NywiY3NyZiI6IjQ3OGNjM2RiLTU5ZGItNDU5NS04ZjdjLWQzM2RhMDAzMjZhMCIsImV4cCI6MTc2ODUyOTQ5N30.R3Bi6c9uxjv8ehvT6JqIshgQqiTJIP8Lm4XlmY-bStg
4
+ # Alternativas (opcionais, usadas só se a primária falhar)
5
+ OP_LOGIN_EMAIL_ALT=api@armmatriz.com.br
6
+ OP_LOGIN_PASSWORD_ALT=Arm@2025
7
+
8
+ # Ativa logs de corpo de erro (apenas DIAGNÓSTICO TEMPORÁRIO)
9
+ OP_LOGIN_DEBUG=true
10
+ # Configurações de requisição
11
+ OP_READ_TIMEOUT=60 # p.ex., 60s (alguns endpoints demoram mais)
12
+ OP_RATE_DELAY_SEC=0.5 # atraso menor entre páginas
13
+ OP_MAX_PAGES=1 # padrão apenas 1 página (você controla na UI)
14
+ OP_MAX_RETRIES_5XX=3 # menos tentativas para 5xx
15
+ OP_5XX_BACKOFF_BASE=2 # backoff mais curto
16
+ OP_RETRY_TIMEOUT_TOTAL=90 # timeout total menor para retries
17
+ # --- Fim Mayasuite API (Operação) ---
18
+
19
+
20
+ # ================================
21
+ # 🔀 Bancos (Multi‑ambiente SQLite)
22
+ # ================================
23
+ # Utilize estes URLs caso deseje ler os caminhos pelo .env.
24
+ # Para ativar no db_router.py, DESCOMENTE o bloco de dotenv nele.
25
+ DB1_PROD_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load.db
26
+ DB2_TEST_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_teste.db
27
+ DB3_TREINAMENTO_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_treinamento.db
28
+
29
+ # (Opcional) rótulos amigáveis por ambiente (se quiser ler via .env)
30
+ DB1_LABEL=Banco 1 (📗 Produção)
31
+ DB2_LABEL=Banco 2 (📕 Teste)
32
+ DB3_LABEL=Banco 3 (📘 Treinamento)
33
+
34
+
35
+ # ==================================
36
+ # 🤖 Automação de captura/apresentação
37
+ # ==================================
38
+ # Usado pelo script auto_capture.py (Playwright + python-pptx)
39
+ APP_URL=http://localhost:8501
40
+
41
+ # Usuário/senha para login automático (recomendado perfil admin em Teste/Treinamento)
42
+ LOGIN_USER=admin
43
+ LOGIN_PASS=admin123
44
+
45
+ # Ambiente alvo para captura: prod | test | treinamento
46
+ BANK_CHOICE=prod
47
+
48
+ # Saídas de captura e apresentação
49
+ SCREEN_DIR=./screenshots
50
+ OUTPUT_PPTX=./demo_funcionalidades.pptx
51
+
52
+ # (Opcional) parâmetros da captura
53
+ AUTOCAPTURE_HEADLESS=false # true = sem abrir janela; false = visível
54
+ AUTOCAPTURE_VIEWPORT_W=1440
55
+ AUTOCAPTURE_VIEWPORT_H=900
56
+
57
+ # (Opcional) pular quiz durante captura (se seu login exigir quiz)
58
+ AUTOCAPTURE_SKIP_QUIZ=true
59
+
60
+
61
+ # ==========================
62
+ # 🧰 Monitor/Backup do banco
63
+ # ==========================
64
+ # Diretório padrão de backups (db_monitor.py)
65
+ BACKUP_DIR=./backups
66
+ BACKUP_RETAIN=10 # manter N arquivos mais recentes
67
+ BACKUP_FREQ_DAYS=7 # frequência "prevista" em dias
68
+
69
+ # (Opcional) mostrar URL do engine na sidebar (se usar no app.py)
70
+ SHOW_ENGINE_URL_IN_SIDEBAR=true
71
+
72
+
73
+ # ==========================
74
+ # 🔧 Streamlit (opcional)
75
+ # ==========================
76
+ # STREAMLIT_SERVER_ADDRESS=0.0.0.0
77
+ # STREAMLIT_SERVER_PORT=8501
78
+ # STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
79
+ # STREAMLIT_THEME_BASE="light"
.gitattributes CHANGED
@@ -32,3 +32,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
32
  *.zip filter=lfs diff=lfs merge=lfs -text
33
  *.zst filter=lfs diff=lfs merge=lfs -text
34
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
32
  *.zip filter=lfs diff=lfs merge=lfs -text
33
  *.zst filter=lfs diff=lfs merge=lfs -text
34
  *tfevents* filter=lfs diff=lfs merge=lfs -text
35
+ Load.db filter=lfs diff=lfs merge=lfs -text
36
+ Load.db.bak filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,132 +1,42 @@
1
- # Byte-compiled / optimized / DLL files
2
- __pycache__/
3
- *.py[cod]
4
- *$py.class
5
- # *.html
6
- private/
7
- .vscode/
8
-
9
- # C extensions
10
- *.so
11
-
12
- # Distribution / packaging
13
- .Python
14
- build/
15
- develop-eggs/
16
- dist/
17
- downloads/
18
- eggs/
19
- .eggs/
20
- lib/
21
- lib64/
22
- parts/
23
- sdist/
24
- var/
25
- wheels/
26
- pip-wheel-metadata/
27
- share/python-wheels/
28
- *.egg-info/
29
- .installed.cfg
30
- *.egg
31
- MANIFEST
32
-
33
- # PyInstaller
34
- # Usually these files are written by a python script from a template
35
- # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
- *.manifest
37
- *.spec
38
-
39
- # Installer logs
40
- pip-log.txt
41
- pip-delete-this-directory.txt
42
-
43
- # Unit test / coverage reports
44
- htmlcov/
45
- .tox/
46
- .nox/
47
- .coverage
48
- .coverage.*
49
- .cache
50
- nosetests.xml
51
- coverage.xml
52
- *.cover
53
- *.py,cover
54
- .hypothesis/
55
- .pytest_cache/
56
-
57
- # Translations
58
- *.mo
59
- *.pot
60
-
61
- # Django stuff:
62
- *.log
63
- local_settings.py
64
- db.sqlite3
65
- db.sqlite3-journal
66
-
67
- # Flask stuff:
68
- instance/
69
- .webassets-cache
70
-
71
- # Scrapy stuff:
72
- .scrapy
73
-
74
- # Sphinx documentation
75
- docs/_build/
76
-
77
- # PyBuilder
78
- target/
79
-
80
- # Jupyter Notebook
81
- .ipynb_checkpoints
82
-
83
- # IPython
84
- profile_default/
85
- ipython_config.py
86
-
87
- # pyenv
88
- .python-version
89
-
90
- # pipenv
91
- # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
- # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
- # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
- # install all needed dependencies.
95
- #Pipfile.lock
96
-
97
- # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98
- __pypackages__/
99
-
100
- # Celery stuff
101
- celerybeat-schedule
102
- celerybeat.pid
103
-
104
- # SageMath parsed files
105
- *.sage.py
106
-
107
- # Environments
108
- .env
109
- .venv
110
- env/
111
- venv/
112
- ENV/
113
- env.bak/
114
- venv.bak/
115
-
116
- # Spyder project settings
117
- .spyderproject
118
- .spyproject
119
-
120
- # Rope project settings
121
- .ropeproject
122
-
123
- # mkdocs documentation
124
- /site
125
-
126
- # mypy
127
- .mypy_cache/
128
- .dmypy.json
129
- dmypy.json
130
-
131
- # Pyre type checker
132
- .pyre/
 
1
+ # ========================
2
+ # Python
3
+ # ========================
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.pyd
8
+
9
+ # ========================
10
+ # Ambiente virtual
11
+ # ========================
12
+ venv/
13
+ env/
14
+ .venv/
15
+
16
+ # ========================
17
+ # Variáveis de ambiente
18
+ # ========================
19
+ .env
20
+
21
+ # ========================
22
+ # Banco de dados
23
+ # ========================
24
+ *.db
25
+ *.sqlite
26
+ *.sqlite3
27
+
28
+ # ========================
29
+ # Streamlit
30
+ # ========================
31
+ .streamlit/
32
+
33
+ # ========================
34
+ # Logs
35
+ # ========================
36
+ *.log
37
+
38
+ # ========================
39
+ # Sistema operacional
40
+ # ========================
41
+ .DS_Store
42
+ Thumbs.db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ DATABASE_URL=postgresql://...
2
+ SENHA_ADMIN=admin123
20260129_add_po_alt_pn_lot_batch.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add po_alt, pn, lot_batch to recebimento_registros
2
+
3
+ Revision ID: 8f7c3e5a9b21
4
+ Revises: <COLOQUE_AQUI_O_REVISION_ID_ANTERIOR>
5
+ Create Date: 2026-01-29 13:20:00.000000
6
+ """
7
+
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+ # Revisão atual e anterior
12
+ revision = '8f7c3e5a9b21'
13
+ down_revision = '<COLOQUE_AQUI_O_REVISION_ID_ANTERIOR>'
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade():
19
+ # Usamos batch_alter_table p/ compatibilidade (SQLite etc.)
20
+ with op.batch_alter_table('recebimento_registros', schema=None) as batch_op:
21
+ batch_op.add_column(sa.Column('po_alt', sa.String(length=60), nullable=True))
22
+ batch_op.add_column(sa.Column('pn', sa.String(length=120), nullable=True))
23
+ batch_op.add_column(sa.Column('lot_batch', sa.String(length=120), nullable=True))
24
+
25
+ # Se desejar índices (opcionais), descomente:
26
+ # batch_op.create_index('ix_receb_po_alt', ['po_alt'])
27
+ # batch_op.create_index('ix_receb_pn', ['pn'])
28
+ # batch_op.create_index('ix_receb_lot_batch', ['lot_batch'])
29
+
30
+
31
+ def downgrade():
32
+ with op.batch_alter_table('recebimento_registros', schema=None) as batch_op:
33
+ # Se criou índices acima, primeiro drope-os:
34
+ # batch_op.drop_index('ix_receb_lot_batch')
35
+ # batch_op.drop_index('ix_receb_pn')
36
+ # batch_op.drop_index('ix_receb_po_alt')
37
+
38
+ batch_op.drop_column('lot_batch')
39
+ batch_op.drop_column('pn')
40
+ batch_op.drop_column('po_alt')
Inbox_Admin.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # pages/Inbox_Admin.py
3
+ # -*- coding: utf-8 -*-
4
+ import streamlit as st
5
+ from datetime import datetime
6
+ from sqlalchemy import func
7
+
8
+ # Model
9
+ from models import IOIRunSugestao
10
+
11
+ # (Opcional) auditoria
12
+ try:
13
+ from utils_auditoria import registrar_log
14
+ except Exception:
15
+ registrar_log = None
16
+
17
+ # ------------- CONFIG BÁSICA -------------
18
+ st.set_page_config(page_title="📬 Inbox Admin • IOI-RUN", layout="wide")
19
+
20
+ STATUS_PENDENTE = "pendente"
21
+ STATUS_RESPONDIDA = "respondida"
22
+
23
+
24
+ # ------------- Sessão de banco ciente do ambiente -------------
25
+ def _get_db_session():
26
+ """
27
+ Retorna uma sessão de banco consistente com o ambiente atual.
28
+ Tenta usar o db_router (se presente); senão, cai para SessionLocal().
29
+ """
30
+ try:
31
+ from db_router import get_session_for_current_db
32
+ return get_session_for_current_db()
33
+ except Exception:
34
+ pass
35
+ try:
36
+ from banco import SessionLocal
37
+ return SessionLocal()
38
+ except Exception as e:
39
+ st.error(f"Banco indisponível: {e}")
40
+ raise
41
+
42
+
43
+ def _debug_banco_caption():
44
+ """Mostra em qual banco estamos (Produção/Teste/Treinamento)."""
45
+ try:
46
+ from db_router import current_db_choice, bank_label
47
+ choice = current_db_choice()
48
+ label = bank_label(choice)
49
+ st.caption(f"🗄️ Banco ativo: **{label}**")
50
+ except Exception:
51
+ st.caption("🗄️ Banco ativo: **default**")
52
+
53
+
54
+ # ------------- Guarda de rota (somente admin) -------------
55
+ def _ensure_admin():
56
+ perfil = (st.session_state.get("perfil") or "").strip().lower()
57
+ if perfil != "admin":
58
+ st.error("Acesso negado. Esta página é restrita a administradores.")
59
+ st.stop()
60
+
61
+
62
+ # ------------- Página -------------
63
+ def main():
64
+ _ensure_admin()
65
+
66
+ st.title("📬 Caixa de Entrada • IOI‑RUN (Admin)")
67
+ st.caption("Responda sugestões dos usuários em uma página separada, sem interferência do app principal.")
68
+ _debug_banco_caption()
69
+
70
+ # Estados persistentes exclusivos desta página (prefixo 'adm_inbox_')
71
+ st.session_state.setdefault("adm_inbox_area", "todos")
72
+ st.session_state.setdefault("adm_inbox_status", STATUS_PENDENTE)
73
+ st.session_state.setdefault("adm_inbox_usuario", "")
74
+ st.session_state.setdefault("adm_inbox_nonce", 0)
75
+
76
+ AREAS = ["todos", "WMS", "FPSO", "UI/UX", "Relatórios", "Integrações", "Performance", "Segurança", "Outros"]
77
+ STATUS = [STATUS_PENDENTE, STATUS_RESPONDIDA, "todos"]
78
+
79
+ # ------------- Filtros -------------
80
+ col_f1, col_f2, col_f3, col_f4 = st.columns([1, 1, 1, 0.6])
81
+ col_f1.selectbox(
82
+ "Área/Tema",
83
+ AREAS,
84
+ key="adm_inbox_area",
85
+ index=AREAS.index(st.session_state["adm_inbox_area"]) if st.session_state["adm_inbox_area"] in AREAS else 0
86
+ )
87
+ col_f2.selectbox(
88
+ "Status",
89
+ STATUS,
90
+ key="adm_inbox_status",
91
+ index=STATUS.index(st.session_state["adm_inbox_status"]) if st.session_state["adm_inbox_status"] in STATUS else 0
92
+ )
93
+ col_f3.text_input(
94
+ "Filtrar por usuário (login exato)",
95
+ key="adm_inbox_usuario",
96
+ value=st.session_state["adm_inbox_usuario"]
97
+ )
98
+
99
+ if col_f4.button("🔄 Atualizar lista"):
100
+ st.session_state["adm_inbox_nonce"] += 1
101
+ st.rerun()
102
+
103
+ # ------------- Consulta -------------
104
+ db = _get_db_session()
105
+ try:
106
+ q = db.query(IOIRunSugestao)
107
+ if st.session_state["adm_inbox_area"] != "todos":
108
+ q = q.filter(IOIRunSugestao.area == st.session_state["adm_inbox_area"])
109
+ if st.session_state["adm_inbox_status"] != "todos":
110
+ q = q.filter(func.lower(IOIRunSugestao.status) == st.session_state["adm_inbox_status"])
111
+ if (st.session_state["adm_inbox_usuario"] or "").strip():
112
+ q = q.filter(IOIRunSugestao.usuario == (st.session_state["adm_inbox_usuario"] or "").strip())
113
+
114
+ sugestoes = q.order_by(IOIRunSugestao.data_envio.desc()).all()
115
+ except Exception as e:
116
+ st.error(f"Erro ao consultar sugestões: {e}")
117
+ sugestoes = []
118
+
119
+ # ------------- Lista / Edição -------------
120
+ if not sugestoes:
121
+ st.info("Nenhuma sugestão encontrada para os filtros aplicados.")
122
+ else:
123
+ for s in sugestoes:
124
+ dt_envio = s.data_envio.strftime("%d/%m/%Y %H:%M") if s.data_envio else "—"
125
+ titulo = f"📩 {dt_envio} — {s.usuario} — Status: {s.status or '—'}"
126
+ if s.area:
127
+ titulo += f" — Área: {s.area}"
128
+
129
+ with st.expander(titulo, expanded=False):
130
+ st.markdown("**Sugestão:**")
131
+ st.write(s.mensagem or "—")
132
+
133
+ with st.form(key=f"adm_inbox_form_{s.id}", clear_on_submit=False):
134
+ resposta_txt = st.text_area(
135
+ f"Responder ao usuário ({s.usuario}) — ID {s.id}",
136
+ value=s.resposta or "",
137
+ key=f"adm_inbox_resposta_{s.id}",
138
+ placeholder="Digite sua resposta para este usuário…",
139
+ height=140
140
+ )
141
+ col_a1, col_a2 = st.columns([1, 1])
142
+ enviar = col_a1.form_submit_button("📤 Enviar resposta")
143
+ pendenciar = col_a2.form_submit_button("⏳ Marcar como pendente")
144
+
145
+ if enviar:
146
+ try:
147
+ s.resposta = (resposta_txt or "").strip()
148
+ s.status = STATUS_RESPONDIDA if s.resposta else STATUS_PENDENTE
149
+ s.data_resposta = datetime.now() if s.resposta else None
150
+ s.responsavel = st.session_state.get("usuario")
151
+
152
+ db.add(s)
153
+ db.commit()
154
+
155
+ # Auditoria (opcional)
156
+ if registrar_log and s.resposta:
157
+ try:
158
+ registrar_log(
159
+ usuario=st.session_state.get("usuario"),
160
+ acao=f"Respondeu sugestão IOI‑RUN (ID {s.id}) para {s.usuario}",
161
+ tabela="ioirun_sugestao",
162
+ registro_id=s.id
163
+ )
164
+ except Exception:
165
+ pass
166
+
167
+ st.success("Resposta registrada com sucesso! (Agora em 'respondida')")
168
+ st.rerun()
169
+ except Exception as e:
170
+ db.rollback()
171
+ st.error(f"Erro ao salvar resposta: {e}")
172
+
173
+ if pendenciar:
174
+ try:
175
+ s.status = STATUS_PENDENTE
176
+ s.resposta = None
177
+ s.data_resposta = None
178
+ s.responsavel = None
179
+ db.add(s)
180
+ db.commit()
181
+ st.info("Sugestão marcada como pendente novamente.")
182
+ st.rerun()
183
+ except Exception as e:
184
+ db.rollback()
185
+ st.error(f"Erro ao alterar status: {e}")
186
+
187
+ st.markdown("---")
188
+ st.caption("Use o **menu lateral** para navegar para outros módulos.")
189
+
190
+ try:
191
+ db.close()
192
+ except Exception:
193
+ pass
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
Info.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LoadApp/
2
+
3
+ ├── app.py # Arquivo principal
4
+ ├── login.py # Login
5
+ ├── administracao.py # Área admin
6
+ ├── formulario.py # Inclusão
7
+ ├── consulta.py # Consulta
8
+ ├── relatorios.py # Relatórios
9
+
10
+ ├── banco.py # Conexão com banco
11
+ ├── models.py # Modelos SQLAlchemy
12
+ ├── utils_fpso.py
13
+ ├── utils_permissoes.py
14
+
15
+ ├── assets/
16
+ │ └── logo.png # Logo do sistema
17
+
18
+ ├── requirements.txt
19
+ ├── .gitignore
20
+ └── README.md
Load.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1b624b9d0c5160a67fb95de8a6d21ebe03389aa8029273d8bc86216e51eec470
3
+ size 9220096
Load.db.bak ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40613ff0f3898cf9307261a0b2bc2ec4a393e8315395ef0e1c116c7d6f49bde9
3
+ size 1196032
Load.py ADDED
File without changes
Produtividade_Especialista.py ADDED
@@ -0,0 +1,778 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ import pandas as pd
5
+ from io import BytesIO
6
+ from banco import SessionLocal
7
+ from models import Equipamento
8
+
9
+ # Auto-refresh
10
+ from streamlit_autorefresh import st_autorefresh
11
+ from datetime import datetime, timedelta
12
+
13
+ # SQL util
14
+ from sqlalchemy import text
15
+
16
+ # ====== Gráficos: Altair (preferência) + fallback Matplotlib ======
17
+ ALT_AVAILABLE = True
18
+ try:
19
+ import altair as alt
20
+ try:
21
+ alt.data_transformers.disable_max_rows()
22
+ except Exception:
23
+ pass
24
+ except Exception:
25
+ ALT_AVAILABLE = False
26
+
27
+ import matplotlib
28
+ matplotlib.use("Agg")
29
+ import matplotlib.pyplot as plt
30
+
31
+ # NumPy para cálculos numéricos robustos
32
+ import numpy as np
33
+
34
+
35
+ # ===============================
36
+ # Fotos de Responsáveis — Helpers (DB)
37
+ # ===============================
38
+ def _ensure_foto_table(db) -> None:
39
+ """Cria a tabela responsavel_foto se não existir (SQLite/PostgreSQL/MySQL)."""
40
+ dialect = db.bind.dialect.name
41
+
42
+ if dialect == "sqlite":
43
+ sql = """
44
+ CREATE TABLE IF NOT EXISTS responsavel_foto (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ tipo TEXT NOT NULL, -- 'especialista' | 'conferente'
47
+ nome TEXT NOT NULL,
48
+ imagem BLOB NOT NULL, -- bytes
49
+ mimetype TEXT,
50
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
51
+ UNIQUE (tipo, nome)
52
+ )
53
+ """
54
+ elif dialect in ("postgresql", "postgres"):
55
+ sql = """
56
+ CREATE TABLE IF NOT EXISTS responsavel_foto (
57
+ id SERIAL PRIMARY KEY,
58
+ tipo TEXT NOT NULL, -- 'especialista' | 'conferente'
59
+ nome TEXT NOT NULL,
60
+ imagem BYTEA NOT NULL, -- bytes
61
+ mimetype TEXT,
62
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
63
+ UNIQUE (tipo, nome)
64
+ )
65
+ """
66
+ else: # mysql/mariadb
67
+ sql = """
68
+ CREATE TABLE IF NOT EXISTS responsavel_foto (
69
+ id INT AUTO_INCREMENT PRIMARY KEY,
70
+ tipo VARCHAR(32) NOT NULL,
71
+ nome VARCHAR(255) NOT NULL,
72
+ imagem LONGBLOB NOT NULL,
73
+ mimetype VARCHAR(64),
74
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
75
+ UNIQUE KEY uq_tipo_nome (tipo, nome)
76
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
77
+ """
78
+ db.execute(text(sql))
79
+ db.commit()
80
+
81
+
82
+ def _get_foto(db, tipo: str, nome: str):
83
+ """Retorna (bytes_imagem, mimetype, updated_at) ou (None, None, None)."""
84
+ if not (tipo and nome):
85
+ return None, None, None
86
+ _ensure_foto_table(db)
87
+ row = db.execute(
88
+ text(
89
+ "SELECT imagem, mimetype, updated_at "
90
+ "FROM responsavel_foto WHERE tipo = :t AND nome = :n LIMIT 1"
91
+ ),
92
+ {"t": tipo, "n": nome},
93
+ ).fetchone()
94
+ if row:
95
+ return row[0], (row[1] or "image/jpeg"), row[2]
96
+ return None, None, None
97
+
98
+
99
+ def _set_foto(db, tipo: str, nome: str, content: bytes, mimetype: str) -> None:
100
+ """Upsert simples por (tipo, nome)."""
101
+ if not (tipo and nome and content):
102
+ return
103
+ _ensure_foto_table(db)
104
+ upd = db.execute(
105
+ text(
106
+ "UPDATE responsavel_foto "
107
+ "SET imagem=:img, mimetype=:mt, updated_at=CURRENT_TIMESTAMP "
108
+ "WHERE tipo=:t AND nome=:n"
109
+ ),
110
+ {"img": content, "mt": mimetype or "image/jpeg", "t": tipo, "n": nome},
111
+ )
112
+ if upd.rowcount == 0:
113
+ db.execute(
114
+ text(
115
+ "INSERT INTO responsavel_foto (tipo, nome, imagem, mimetype) "
116
+ "VALUES (:t, :n, :img, :mt)"
117
+ ),
118
+ {"t": tipo, "n": nome, "img": content, "mt": mimetype or "image/jpeg"},
119
+ )
120
+ db.commit()
121
+
122
+
123
+ def _del_foto(db, tipo: str, nome: str) -> None:
124
+ if not (tipo and nome):
125
+ return
126
+ _ensure_foto_table(db)
127
+ db.execute(text("DELETE FROM responsavel_foto WHERE tipo=:t AND nome=:n"), {"t": tipo, "n": nome})
128
+ db.commit()
129
+
130
+
131
+ # ===============================
132
+ # Estado
133
+ # ===============================
134
+ def limpar_estado_prod_esp():
135
+ """Remove do session_state qualquer dado do módulo Produtividade_Especialista."""
136
+ for key in list(st.session_state.keys()):
137
+ if key.startswith("prod_esp_"):
138
+ del st.session_state[key]
139
+
140
+
141
+ # ===============================
142
+ # UI – Gerenciar fotos de responsáveis
143
+ # ===============================
144
+ def _ui_fotos_responsaveis(df: pd.DataFrame):
145
+ """Bloco para cadastrar/atualizar/remover fotos de Especialistas e Conferentes."""
146
+ st.subheader("📸 Fotos dos Responsáveis")
147
+
148
+ especialistas = sorted([x for x in df["Especialista"].dropna().astype(str).unique() if x.strip()])
149
+ conferentes = sorted([x for x in df["Conferente"].dropna().astype(str).unique() if x.strip()])
150
+
151
+ tab_esp, tab_conf = st.tabs(["Especialista", "Conferente"])
152
+
153
+ # ---------- Especialista ----------
154
+ with tab_esp:
155
+ col_e1, col_e2 = st.columns([1, 2])
156
+ with col_e1:
157
+ nome_esp = st.selectbox("Especialista", options=["(selecione)"] + especialistas, index=0, key="prod_esp_foto_esp_sel")
158
+ file_esp = st.file_uploader(
159
+ "Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Especialista",
160
+ type=["png", "jpg", "jpeg", "gif", "webp"],
161
+ key="prod_esp_foto_esp_up"
162
+ )
163
+ salvar_esp = st.button("💾 Salvar/Atualizar foto (Especialista)", key="prod_esp_foto_esp_salvar")
164
+ remover_esp = st.button("🗑️ Remover foto (Especialista)", key="prod_esp_foto_esp_remover")
165
+
166
+ with col_e2:
167
+ db = SessionLocal()
168
+ try:
169
+ if nome_esp and nome_esp != "(selecione)":
170
+ img_bytes, mt, updt = _get_foto(db, "especialista", nome_esp)
171
+ if img_bytes:
172
+ st.caption(f"Foto atual de **{nome_esp}** (atualizada em {updt})")
173
+ st.image(img_bytes, caption=nome_esp, use_container_width=False, width=220)
174
+ else:
175
+ st.info("Nenhuma foto cadastrada para este Especialista.")
176
+ finally:
177
+ db.close()
178
+
179
+ if salvar_esp:
180
+ if not (nome_esp and nome_esp != "(selecione)"):
181
+ st.warning("Selecione um Especialista.")
182
+ elif not file_esp:
183
+ st.warning("Escolha um arquivo de imagem para enviar.")
184
+ else:
185
+ content = file_esp.read()
186
+ mt = file_esp.type or "image/jpeg"
187
+ db = SessionLocal()
188
+ try:
189
+ _set_foto(db, "especialista", nome_esp, content, mt)
190
+ st.success("Foto salva/atualizada com sucesso!")
191
+ st.rerun()
192
+ except Exception as e:
193
+ db.rollback()
194
+ st.error(f"Erro ao salvar foto: {e}")
195
+ finally:
196
+ db.close()
197
+
198
+ if remover_esp:
199
+ if not (nome_esp and nome_esp != "(selecione)"):
200
+ st.warning("Selecione um Especialista.")
201
+ else:
202
+ db = SessionLocal()
203
+ try:
204
+ _del_foto(db, "especialista", nome_esp)
205
+ st.info("Foto removida.")
206
+ st.rerun()
207
+ except Exception as e:
208
+ db.rollback()
209
+ st.error(f"Erro ao remover foto: {e}")
210
+ finally:
211
+ db.close()
212
+
213
+ # ---------- Conferente ----------
214
+ with tab_conf:
215
+ col_c1, col_c2 = st.columns([1, 2])
216
+ with col_c1:
217
+ nome_conf = st.selectbox("Conferente", options=["(selecione)"] + conferentes, index=0, key="prod_esp_foto_conf_sel")
218
+ file_conf = st.file_uploader(
219
+ "Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Conferente",
220
+ type=["png", "jpg", "jpeg", "gif", "webp"],
221
+ key="prod_esp_foto_conf_up"
222
+ )
223
+ salvar_conf = st.button("💾 Salvar/Atualizar foto (Conferente)", key="prod_esp_foto_conf_salvar")
224
+ remover_conf = st.button("🗑️ Remover foto (Conferente)", key="prod_esp_foto_conf_remover")
225
+
226
+ with col_c2:
227
+ db = SessionLocal()
228
+ try:
229
+ if nome_conf and nome_conf != "(selecione)":
230
+ img_bytes, mt, updt = _get_foto(db, "conferente", nome_conf)
231
+ if img_bytes:
232
+ st.caption(f"Foto atual de **{nome_conf}** (atualizada em {updt})")
233
+ st.image(img_bytes, caption=nome_conf, use_container_width=False, width=220)
234
+ else:
235
+ st.info("Nenhuma foto cadastrada para este Conferente.")
236
+ finally:
237
+ db.close()
238
+
239
+ if salvar_conf:
240
+ if not (nome_conf and nome_conf != "(selecione)"):
241
+ st.warning("Selecione um Conferente.")
242
+ elif not file_conf:
243
+ st.warning("Escolha um arquivo de imagem para enviar.")
244
+ else:
245
+ content = file_conf.read()
246
+ mt = file_conf.type or "image/jpeg"
247
+ db = SessionLocal()
248
+ try:
249
+ _set_foto(db, "conferente", nome_conf, content, mt)
250
+ st.success("Foto salva/atualizada com sucesso!")
251
+ st.rerun()
252
+ except Exception as e:
253
+ db.rollback()
254
+ st.error(f"Erro ao salvar foto: {e}")
255
+ finally:
256
+ db.close()
257
+
258
+ if remover_conf:
259
+ if not (nome_conf and nome_conf != "(selecione)"):
260
+ st.warning("Selecione um Conferente.")
261
+ else:
262
+ db = SessionLocal()
263
+ try:
264
+ _del_foto(db, "conferente", nome_conf)
265
+ st.info("Foto removida.")
266
+ st.rerun()
267
+ except Exception as e:
268
+ db.rollback()
269
+ st.error(f"Erro ao remover foto: {e}")
270
+ finally:
271
+ db.close()
272
+
273
+
274
+ # ===============================
275
+ # Mini-gráfico mensal (% acertos) — Helpers
276
+ # ===============================
277
+ def _normalize_responsaveis(df: pd.DataFrame) -> pd.DataFrame:
278
+ """Normaliza nomes (remove espaços/None) para evitar falhas de comparação."""
279
+ for col in ["Especialista", "Conferente"]:
280
+ df[col] = df[col].astype(str).fillna("").str.strip()
281
+ df[col] = df[col].replace({"None": ""})
282
+ return df
283
+
284
+ def _month_labels_last_n(n: int) -> pd.DataFrame:
285
+ """Retorna DataFrame com os últimos n meses e rótulos MES/AA, em ordem cronológica."""
286
+ base = pd.Timestamp(datetime.now().replace(day=1))
287
+ months = [base - pd.DateOffset(months=i) for i in range(n-1, -1, -1)]
288
+ return pd.DataFrame({
289
+ "YM": [pd.Period(m, freq="M") for m in months],
290
+ "mes": [m.strftime("%b/%y").upper() for m in months]
291
+ })
292
+
293
+ def _serie_pct_mensal(df: pd.DataFrame, resp_col: str, nome: str, months: int = 6) -> pd.DataFrame:
294
+ """
295
+ Série mensal (últimos 'months' meses) de % acertos (MROB) para um responsável.
296
+ Retorna DataFrame com ['mes', 'pct', 'MROB', 'ERROS'] (meses sem dados => 0).
297
+ Corrige dtype para evitar TypeError: Expected numeric dtype, got object instead.
298
+ """
299
+ if not (nome and resp_col in df.columns and "Data Coleta (dt)" in df.columns):
300
+ return pd.DataFrame(columns=["mes", "pct", "MROB", "ERROS"])
301
+
302
+ nome = str(nome).strip()
303
+ d = df[df[resp_col].astype(str).str.strip() == nome].copy()
304
+ d = d.dropna(subset=["Data Coleta (dt)"])
305
+
306
+ # Linha do tempo alvo (sempre haverá N meses)
307
+ base = _month_labels_last_n(months)
308
+
309
+ # Se não há dados, devolve zeros
310
+ if d.empty:
311
+ base["MROB"] = 0.0
312
+ base["ERROS"] = 0.0
313
+ base["pct"] = 0.0
314
+ return base[["mes", "pct", "MROB", "ERROS"]]
315
+
316
+ d["YM"] = d["Data Coleta (dt)"].dt.to_period("M")
317
+
318
+ g = (
319
+ d.groupby("YM", as_index=False)
320
+ .agg(MROB=("Linhas MROB", "sum"), ERROS=("Linhas Erros MROB", "sum"))
321
+ )
322
+
323
+ # Merge garante a linha do tempo completa — aqui o dtype pode virar 'object'
324
+ m = base.merge(g, on="YM", how="left")
325
+
326
+ # Coerção numérica robusta pós-merge (evita object -> round error)
327
+ m["MROB"] = pd.to_numeric(m["MROB"], errors="coerce").fillna(0).astype("float64")
328
+ m["ERROS"] = pd.to_numeric(m["ERROS"], errors="coerce").fillna(0).astype("float64")
329
+
330
+ # % acertos (evita divisão por zero, resultado sempre float)
331
+ m["pct"] = np.where(
332
+ m["MROB"] > 0,
333
+ ((m["MROB"] - m["ERROS"]) / m["MROB"]) * 100.0,
334
+ 0.0
335
+ )
336
+ m["pct"] = pd.to_numeric(m["pct"], errors="coerce").fillna(0).astype("float64").round(2)
337
+
338
+ # Seleciona e ordena colunas finais
339
+ out = m[["mes", "pct", "MROB", "ERROS"]].copy()
340
+
341
+ # Garantia de dtype correto (evita regressões futuras)
342
+ out["MROB"] = out["MROB"].astype("float64")
343
+ out["ERROS"] = out["ERROS"].astype("float64")
344
+ out["pct"] = out["pct"].astype("float64")
345
+
346
+ return out
347
+
348
+ def _mini_grafico_pct_mensal(df_m: pd.DataFrame, meta: float, chart_type: str = "Linha", show_meta: bool = True, titulo: str = "% Acertos por mês"):
349
+ """
350
+ Renderiza mini‑gráfico compacto (% acertos | 0–100) com fallback:
351
+ 1) Altair (linha/barras + meta) 2) Matplotlib 3) Tabela
352
+ """
353
+ if df_m.empty:
354
+ st.caption("Sem dados mensais para o período/seleção atual.")
355
+ return
356
+
357
+ # 1) ALTair
358
+ if ALT_AVAILABLE:
359
+ try:
360
+ base = alt.Chart(df_m).encode(
361
+ x=alt.X("mes:N", title="Mês"),
362
+ y=alt.Y("pct:Q", title="% Acertos", scale=alt.Scale(domain=[0, 100])),
363
+ tooltip=[
364
+ alt.Tooltip("mes:N", title="Mês"),
365
+ alt.Tooltip("pct:Q", title="% Acertos (%)"),
366
+ alt.Tooltip("MROB:Q", title="MROB (Σ)"),
367
+ alt.Tooltip("ERROS:Q", title="Erros MROB (Σ)")
368
+ ]
369
+ )
370
+ chart = base.mark_line(point=True, interpolate="monotone", color="#0d6efd") if chart_type == "Linha" \
371
+ else base.mark_bar(size=18, color="#0d6efd")
372
+
373
+ final = chart.properties(width=260, height=150, title=titulo)
374
+
375
+ if show_meta:
376
+ meta_df = pd.DataFrame({"y": [meta]})
377
+ meta_rule = alt.Chart(meta_df).mark_rule(color="#16a34a", strokeDash=[6, 4]).encode(y="y:Q")
378
+ final = final + meta_rule
379
+
380
+ st.altair_chart(final, use_container_width=False)
381
+ return
382
+ except Exception as e:
383
+ st.info(f"Render ALTair indisponível, usando fallback (detalhe: {e})")
384
+
385
+ # 2) Matplotlib fallback
386
+ try:
387
+ fig, ax = plt.subplots(figsize=(3.2, 1.6), dpi=150)
388
+ x = list(range(len(df_m["mes"])))
389
+ if chart_type == "Linha":
390
+ ax.plot(x, df_m["pct"].values, marker="o", color="#0d6efd", linewidth=1.5)
391
+ else:
392
+ ax.bar(x, df_m["pct"].values, color="#0d6efd", width=0.6)
393
+ if show_meta:
394
+ ax.axhline(y=meta, color="#16a34a", linestyle="--", linewidth=1)
395
+
396
+ ax.set_ylim(0, 100)
397
+ ax.set_xticks(x)
398
+ ax.set_xticklabels(df_m["mes"].tolist(), rotation=0, fontsize=7)
399
+ ax.set_yticks([0, 20, 40, 60, 80, 100])
400
+ ax.set_title(titulo, fontsize=9)
401
+ ax.grid(alpha=0.15, axis="y")
402
+
403
+ plt.tight_layout()
404
+ st.pyplot(fig, use_container_width=False)
405
+ plt.close(fig)
406
+ return
407
+ except Exception as e:
408
+ st.warning(f"Não foi possível renderizar o mini‑gráfico (fallback MPL): {e}")
409
+
410
+ # 3) Último recurso
411
+ st.caption("Exibindo dados da série por impossibilidade de gráfico:")
412
+ st.dataframe(df_m, use_container_width=True)
413
+
414
+
415
+ # ===============================
416
+ # MAIN
417
+ # ===============================
418
+ def main():
419
+
420
+ # 🧹 LIMPA ESTADO AO ENTRAR
421
+ if not st.session_state.get("_prod_esp_inicializado"):
422
+ limpar_estado_prod_esp()
423
+ st.session_state["_prod_esp_inicializado"] = True
424
+
425
+ st.title("🏆 Produtividade por Especialista e Conferente")
426
+
427
+ # 🔧 CONTROLES NA SIDEBAR
428
+ with st.sidebar:
429
+ st.markdown("### 🔄 Atualização automática")
430
+ auto_on = st.checkbox("Ativar atualização automática", value=True, key="prod_esp_auto_on")
431
+ auto_interval_s = st.slider("Intervalo (segundos)", min_value=10, max_value=300, value=30, step=5, key="prod_esp_auto_int")
432
+
433
+ if "prod_esp_auto_int_effective" not in st.session_state:
434
+ st.session_state["prod_esp_auto_int_effective"] = auto_interval_s
435
+
436
+ if st.button("✅ Aplicar intervalo"):
437
+ st.session_state["prod_esp_auto_int_effective"] = auto_interval_s
438
+ st.success(f"Intervalo atualizado para {auto_interval_s}s")
439
+ st.rerun()
440
+
441
+ intervalo_efetivo = st.session_state.get("prod_esp_auto_int_effective", auto_interval_s)
442
+ st.caption(f"⏲️ Intervalo atual: **{intervalo_efetivo}s**")
443
+
444
+ st.markdown("---")
445
+ st.markdown("### 🎯 Metas e Série")
446
+ meta_pct_especialistas = st.number_input("Meta (% MROB/Geral) — Especialistas", min_value=0.0, max_value=100.0, value=98.8, step=0.5, key="prod_esp_meta_pct_esp")
447
+ meta_pct_conferentes = st.number_input("Meta (% MROB/Geral) — Conferentes", min_value=0.0, max_value=100.0, value=98.8, step=0.5, key="prod_esp_meta_pct_conf")
448
+ serie_meses = st.slider("Meses no mini‑gráfico", min_value=3, max_value=12, value=6, step=1, key="prod_esp_serie_meses")
449
+ tipo_grafico = st.selectbox("Tipo do mini‑gráfico", ["Linha", "Barras"], index=0, key="prod_esp_tipo_grafico")
450
+ linha_meta = st.checkbox("Mostrar linha de meta", value=True, key="prod_esp_show_meta")
451
+
452
+ st.markdown("---")
453
+ last_dt = st.session_state.get("prod_esp_last_update_dt")
454
+ if last_dt:
455
+ last_str = last_dt.strftime("%d/%m/%Y %H:%M:%S")
456
+ st.caption(f"🕒 Última atualização: **{last_str}**")
457
+ delta = datetime.now() - last_dt
458
+ if delta < timedelta(minutes=1):
459
+ ago_str = f"{delta.seconds}s"
460
+ elif delta < timedelta(hours=1):
461
+ mins = delta.seconds // 60
462
+ secs = delta.seconds % 60
463
+ ago_str = f"{mins}min {secs}s"
464
+ else:
465
+ hours = delta.seconds // 3600
466
+ mins = (delta.seconds % 3600) // 60
467
+ ago_str = f"{hours}h {mins}min"
468
+ st.caption(f"⏱️ Atualizado há **{ago_str}**")
469
+ if auto_on:
470
+ try:
471
+ nxt = (datetime.now() + timedelta(seconds=intervalo_efetivo)).strftime("%d/%m/%Y %H:%M:%S")
472
+ st.caption(f"🔁 Próximo refresh: **{nxt}**")
473
+ except Exception:
474
+ pass
475
+ else:
476
+ st.caption("🕒 Última atualização: **—**")
477
+
478
+ if auto_on:
479
+ st_autorefresh(interval=intervalo_efetivo * 1000, limit=None, key="prod_esp_autorefresh")
480
+
481
+ db = SessionLocal()
482
+ try:
483
+ registros = db.query(Equipamento).all()
484
+ st.session_state["prod_esp_last_update_dt"] = datetime.now()
485
+
486
+ if not registros:
487
+ st.info("Nenhum registro encontrado.")
488
+ return
489
+
490
+ # ========== BASE DF ==========
491
+ df = pd.DataFrame([{
492
+ "FPSO": getattr(r, "fpso", None),
493
+ "Data Coleta": getattr(r, "data_coleta", None),
494
+ "Modal": getattr(r, "modal", None),
495
+ "Especialista": getattr(r, "especialista", None),
496
+ "Conferente": getattr(r, "conferente", None),
497
+ "Linhas OSM": getattr(r, "linhas_osm", 0),
498
+ "Linhas MROB": getattr(r, "linhas_mrob", 0),
499
+ "Linhas Erros MROB": getattr(r, "linhas_erros_mrob", None),
500
+ "Linhas Erros (Genérico)": getattr(r, "linhas_erros", None),
501
+ } for r in registros])
502
+
503
+ # Conversão robusta de datas
504
+ df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=True)
505
+ if df["Data Coleta (dt)"].isna().all():
506
+ # tenta novamente sem dayfirst
507
+ df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=False)
508
+
509
+ # Tipos numéricos
510
+ for col in ["Linhas OSM", "Linhas MROB", "Linhas Erros MROB", "Linhas Erros (Genérico)"]:
511
+ if col in df.columns:
512
+ df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
513
+
514
+ # Fallback de erros MROB
515
+ if ("Linhas Erros MROB" not in df.columns) or (df["Linhas Erros MROB"].sum() == 0 and df["Linhas Erros (Genérico)"].sum() > 0):
516
+ df["Linhas Erros MROB"] = df.get("Linhas Erros (Genérico)", pd.Series([0] * len(df)))
517
+
518
+ # Normaliza nomes
519
+ df = _normalize_responsaveis(df)
520
+
521
+ # ======== Fotos (cadastro/visualização) ========
522
+ _ui_fotos_responsaveis(df)
523
+
524
+ # ========== FILTROS ==========
525
+ st.subheader("🔎 Filtros")
526
+ col1, col2, col3 = st.columns(3)
527
+ with col1:
528
+ filtro_fpso = st.multiselect("FPSO", sorted(df["FPSO"].dropna().unique()), key="prod_esp_fpso")
529
+ with col2:
530
+ filtro_modal = st.multiselect("Modal", sorted(df["Modal"].dropna().unique()), key="prod_esp_modal")
531
+ with col3:
532
+ periodo = st.date_input("Período de Coleta", value=None, key="prod_esp_periodo")
533
+
534
+ df_filt = df.copy()
535
+ if filtro_fpso:
536
+ df_filt = df_filt[df_filt["FPSO"].isin(filtro_fpso)]
537
+ if filtro_modal:
538
+ df_filt = df_filt[df_filt["Modal"].isin(filtro_modal)]
539
+ if isinstance(periodo, (list, tuple)) and len(periodo) == 2:
540
+ data_inicio, data_fim = periodo
541
+ if pd.notna(data_inicio):
542
+ df_filt = df_filt[df_filt["Data Coleta (dt)"] >= pd.to_datetime(data_inicio)]
543
+ if pd.notna(data_fim):
544
+ df_filt = df_filt[df_filt["Data Coleta (dt)"] <= pd.to_datetime(data_fim) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)]
545
+
546
+ # ======== Mapeamentos por responsável ========
547
+ fpsos_por_especialista = (
548
+ df_filt.groupby("Especialista", dropna=False)["FPSO"]
549
+ .apply(lambda x: ", ".join(sorted(set(x.dropna()))))
550
+ .to_dict()
551
+ )
552
+ fpsos_por_conferente = (
553
+ df_filt.groupby("Conferente", dropna=False)["FPSO"]
554
+ .apply(lambda x: ", ".join(sorted(set(x.dropna()))))
555
+ .to_dict()
556
+ )
557
+
558
+ # ======== Agregações ========
559
+ grp_esp = (df_filt.groupby("Especialista", dropna=False)
560
+ .agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"})
561
+ .reset_index())
562
+ grp_esp["FPSO Responsável"] = grp_esp["Especialista"].map(lambda e: fpsos_por_especialista.get(e, ""))
563
+ grp_esp["Especialista (FPSO)"] = grp_esp.apply(
564
+ lambda r: f"{r['Especialista']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Especialista"]), axis=1)
565
+ grp_esp["Total de Erros (MROB - Erros MROB)"] = (grp_esp["Linhas MROB"] - grp_esp["Linhas Erros MROB"]).clip(lower=0)
566
+
567
+ # ✅ Denominador numérico (float) para evitar dtype object
568
+ denom_mrob_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64")
569
+ num_acertos_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce")
570
+ num_erros_esp = pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce")
571
+
572
+ grp_esp["% Acertos (MROB)"] = (num_acertos_esp / denom_mrob_esp * 100.0).round(2)
573
+ grp_esp["% Erros (MROB)"] = (num_erros_esp / denom_mrob_esp * 100.0).round(2)
574
+
575
+ grp_esp = grp_esp.sort_values(by="Linhas OSM", ascending=False)
576
+ grp_esp = grp_esp[[
577
+ "Especialista (FPSO)","Especialista","FPSO Responsável",
578
+ "Linhas OSM","Linhas MROB","Linhas Erros MROB",
579
+ "Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)"
580
+ ]]
581
+
582
+ grp_conf = (df_filt.groupby("Conferente", dropna=False)
583
+ .agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"})
584
+ .reset_index())
585
+ grp_conf["FPSO Responsável"] = grp_conf["Conferente"].map(lambda c: fpsos_por_conferente.get(c, ""))
586
+ grp_conf["Conferente (FPSO)"] = grp_conf.apply(
587
+ lambda r: f"{r['Conferente']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Conferente"]), axis=1)
588
+ grp_conf["Total de Erros (MROB - Erros MROB)"] = (grp_conf["Linhas MROB"] - grp_conf["Linhas Erros MROB"]).clip(lower=0)
589
+
590
+ # ✅ Denominador numérico (float) para evitar dtype object
591
+ denom_mrob_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64")
592
+ num_acertos_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce")
593
+ num_erros_conf = pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce")
594
+
595
+ grp_conf["% Acertos (MROB)"] = (num_acertos_conf / denom_mrob_conf * 100.0).round(2)
596
+ grp_conf["% Erros (MROB)"] = (num_erros_conf / denom_mrob_conf * 100.0).round(2)
597
+
598
+ grp_conf = grp_conf.sort_values(by="Linhas OSM", ascending=False)
599
+ grp_conf = grp_conf[[
600
+ "Conferente (FPSO)","Conferente","FPSO Responsável",
601
+ "Linhas OSM","Linhas MROB","Linhas Erros MROB",
602
+ "Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)"
603
+ ]]
604
+
605
+ # ======== KPIs Gerais ========
606
+ st.subheader("📈 KPIs (dados filtrados) — Geral (Todos)")
607
+ total_especialistas = grp_esp["Especialista"].nunique()
608
+ total_conferentes = grp_conf["Conferente"].nunique()
609
+ total_osm_geral = int(df_filt["Linhas OSM"].sum())
610
+ total_mrob_geral = int(df_filt["Linhas MROB"].sum())
611
+ total_erros_mrob_geral = int(df_filt["Linhas Erros MROB"].sum())
612
+ total_acertos_mrob_geral = (total_mrob_geral - total_erros_mrob_geral)
613
+ pct_acertos_geral = round((total_acertos_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0
614
+ pct_erros_geral = round((total_erros_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0
615
+
616
+ k1,k2,k3,k4,k5 = st.columns(5)
617
+ k1.metric("Especialistas", f"{total_especialistas}")
618
+ k2.metric("Conferentes", f"{total_conferentes}")
619
+ k3.metric("Linhas OSM (Σ)", f"{total_osm_geral:,}".replace(",", "."))
620
+ k4.metric("Linhas MROB (Σ)", f"{total_mrob_geral:,}".replace(",", "."))
621
+ color_geral = "#198754" if pct_acertos_geral >= meta_pct_especialistas else "#dc3545"
622
+ k5.metric("% Acertos (MROB/Geral)", f"{pct_acertos_geral}%")
623
+ # 🔧 HTML deve usar <span>...<span>, não entidades &lt;&gt;
624
+ st.markdown(
625
+ f"<span style='color:{color_geral}'>Meta (Especialistas): {meta_pct_especialistas}% • "
626
+ f"{'✅ Dentro da meta' if pct_acertos_geral >= meta_pct_especialistas else '⚠️ Abaixo da meta'}</span>",
627
+ unsafe_allow_html=True
628
+ )
629
+ st.markdown(f"<span style='color:#dc3545'>% Erros (MROB/Geral): {pct_erros_geral}%</span>", unsafe_allow_html=True)
630
+ st.divider()
631
+
632
+ # ======== KPIs por Especialista (foto + mini‑gráfico) ========
633
+ st.subheader("🎯 KPIs por Especialista")
634
+ especialistas_lista = ["(selecione)"] + list(grp_esp["Especialista"].astype(str).unique())
635
+ esp_sel = st.selectbox("Especialista:", especialistas_lista, index=0, key="prod_esp_kpi_esp")
636
+
637
+ if esp_sel and esp_sel != "(selecione)":
638
+ linha_esp = grp_esp[grp_esp["Especialista"] == esp_sel]
639
+ if not linha_esp.empty:
640
+ le_osm = int(linha_esp["Linhas OSM"].iloc[0])
641
+ le_mrob = int(linha_esp["Linhas MROB"].iloc[0])
642
+ le_err_mrob = int(linha_esp["Linhas Erros MROB"].iloc[0])
643
+ le_total_err = int(linha_esp["Total de Erros (MROB - Erros MROB)"].iloc[0])
644
+ le_pct_acertos = float(linha_esp["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_esp["% Acertos (MROB)"].iloc[0]) else 0.0
645
+ le_pct_erros = float(linha_esp["% Erros (MROB)"].iloc[0]) if pd.notna(linha_esp["% Erros (MROB)"].iloc[0]) else 0.0
646
+
647
+ col_pic, col_chart, col_metrics = st.columns([1, 1.4, 3])
648
+ with col_pic:
649
+ dbp = SessionLocal()
650
+ try:
651
+ img_b, mt, updt = _get_foto(dbp, "especialista", esp_sel)
652
+ if img_b:
653
+ st.image(img_b, caption=f"{esp_sel}", use_container_width=False, width=220)
654
+ else:
655
+ st.caption("Sem foto cadastrada.")
656
+ finally:
657
+ dbp.close()
658
+ with col_chart:
659
+ serie = _serie_pct_mensal(df_filt, "Especialista", esp_sel, months=serie_meses)
660
+ _mini_grafico_pct_mensal(serie, meta=meta_pct_especialistas, chart_type=tipo_grafico, show_meta=linha_meta)
661
+ with st.expander("🔧 Diagnóstico da série (Especialista)", expanded=False):
662
+ st.dataframe(serie, use_container_width=True)
663
+ with col_metrics:
664
+ s1,s2,s3,s4,s5 = st.columns(5)
665
+ s1.metric("Linhas OSM", f"{le_osm:,}".replace(",", "."))
666
+ s2.metric("Linhas MROB", f"{le_mrob:,}".replace(",", "."))
667
+ s3.metric("Erros MROB", f"{le_err_mrob:,}".replace(",", "."))
668
+ s4.metric("Total Erros (MROB−Erros)", f"{le_total_err:,}".replace(",", "."))
669
+ s5.metric("% Acertos (MROB)", f"{le_pct_acertos}%")
670
+ # 🔧 HTML deve usar <span>...<span>, não entidades &lt;&gt;
671
+ st.markdown(f"<span style='color:#dc3545'>% Erros (MROB): {le_pct_erros}%</span>", unsafe_allow_html=True)
672
+ st.divider()
673
+
674
+ # ======== KPIs por Conferente (foto + mini‑gráfico) ========
675
+ st.subheader("🎯 KPIs por Conferente")
676
+ conferentes_lista = ["(selecione)"] + list(grp_conf["Conferente"].astype(str).unique())
677
+ conf_sel = st.selectbox("Conferente:", conferentes_lista, index=0, key="prod_esp_kpi_conf")
678
+
679
+ if conf_sel and conf_sel != "(selecione)":
680
+ linha_conf = grp_conf[grp_conf["Conferente"] == conf_sel]
681
+ if not linha_conf.empty:
682
+ lc_osm = int(linha_conf["Linhas OSM"].iloc[0])
683
+ lc_mrob = int(linha_conf["Linhas MROB"].iloc[0])
684
+ lc_err_mrob = int(linha_conf["Linhas Erros MROB"].iloc[0])
685
+ lc_total_err = int(linha_conf["Total de Erros (MROB - Erros MROB)"].iloc[0])
686
+ lc_pct_acertos = float(linha_conf["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_conf["% Acertos (MROB)"].iloc[0]) else 0.0
687
+ lc_pct_erros = float(linha_conf["% Erros (MROB)"].iloc[0]) if pd.notna(linha_conf["% Erros (MROB)"].iloc[0]) else 0.0
688
+
689
+ col_pic2, col_chart2, col_metrics2 = st.columns([1, 1.4, 3])
690
+ with col_pic2:
691
+ dbp = SessionLocal()
692
+ try:
693
+ img_b, mt, updt = _get_foto(dbp, "conferente", conf_sel)
694
+ if img_b:
695
+ st.image(img_b, caption=f"{conf_sel}", use_container_width=False, width=220)
696
+ else:
697
+ st.caption("Sem foto cadastrada.")
698
+ finally:
699
+ dbp.close()
700
+ with col_chart2:
701
+ serie2 = _serie_pct_mensal(df_filt, "Conferente", conf_sel, months=serie_meses)
702
+ _mini_grafico_pct_mensal(serie2, meta=meta_pct_conferentes, chart_type=tipo_grafico, show_meta=linha_meta)
703
+ with st.expander("🔧 Diagnóstico da série (Conferente)", expanded=False):
704
+ st.dataframe(serie2, use_container_width=True)
705
+ with col_metrics2:
706
+ d1,d2,d3,d4,d5 = st.columns(5)
707
+ d1.metric("Linhas OSM", f"{lc_osm:,}".replace(",", "."))
708
+ d2.metric("Linhas MROB", f"{lc_mrob:,}".replace(",", "."))
709
+ d3.metric("Erros MROB", f"{lc_err_mrob:,}".replace(",", "."))
710
+ d4.metric("Total Erros (MROB−Erros)", f"{lc_total_err:,}".replace(",", "."))
711
+ d5.metric("% Acertos (MROB)", f"{lc_pct_acertos}%")
712
+ # 🔧 HTML deve usar <span>...<span>, não entidades &lt;&gt;
713
+ st.markdown(f"<span style='color:#dc3545'>% Erros (MROB): {lc_pct_erros}%</span>", unsafe_allow_html=True)
714
+
715
+ st.divider()
716
+
717
+ # ======== Listas e Gráficos maiores ========
718
+ st.subheader("🧾 Lista por Especialista (com métricas)")
719
+ st.dataframe(grp_esp, use_container_width=True)
720
+
721
+ st.subheader("🧾 Lista por Conferente (com métricas)")
722
+ st.dataframe(grp_conf, use_container_width=True)
723
+
724
+ st.subheader("📊 Gráficos")
725
+ try:
726
+ st.caption("Linhas OSM por Especialista (FPSO)")
727
+ st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas OSM"])
728
+ st.caption("Linhas MROB por Especialista (FPSO)")
729
+ st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas MROB"])
730
+ st.caption("Linhas de Erros MROB por Especialista (FPSO)")
731
+ st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas Erros MROB"])
732
+ st.caption("Linhas OSM por Conferente (FPSO)")
733
+ st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas OSM"])
734
+ st.caption("Linhas MROB por Conferente (FPSO)")
735
+ st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas MROB"])
736
+ st.caption("Linhas de Erros MROB por Conferente (FPSO)")
737
+ st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas Erros MROB"])
738
+ except Exception as e:
739
+ st.warning(f"Não foi possível renderizar alguns gráficos: {e}")
740
+
741
+ st.divider()
742
+
743
+ # ======== Exportação ========
744
+ st.subheader("⬇️ Exportar")
745
+ buffer_esp = BytesIO()
746
+ with pd.ExcelWriter(buffer_esp, engine="openpyxl") as writer:
747
+ grp_esp.to_excel(writer, index=False, sheet_name="Prod_Especialista")
748
+ buffer_esp.seek(0)
749
+ st.download_button(
750
+ label="⬇️ Exportar produtividade por Especialista (Excel)",
751
+ data=buffer_esp,
752
+ file_name="produtividade_especialista.xlsx",
753
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
754
+ key="prod_esp_export"
755
+ )
756
+
757
+ buffer_conf = BytesIO()
758
+ with pd.ExcelWriter(buffer_conf, engine="openpyxl") as writer:
759
+ grp_conf.to_excel(writer, index=False, sheet_name="Prod_Conferente")
760
+ buffer_conf.seek(0)
761
+ st.download_button(
762
+ label="⬇️ Exportar produtividade por Conferente (Excel)",
763
+ data=buffer_conf,
764
+ file_name="produtividade_conferente.xlsx",
765
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
766
+ key="prod_conf_export"
767
+ )
768
+
769
+ finally:
770
+ db.close()
771
+
772
+
773
+
774
+
775
+
776
+
777
+
778
+
add_pergunta.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from banco import SessionLocal
2
+ from models import QuizPergunta, QuizResposta
3
+
4
+ def adicionar_pergunta(pergunta_texto, respostas, correta_index):
5
+ db = SessionLocal()
6
+ try:
7
+ # Cria a pergunta
8
+ pergunta = QuizPergunta(pergunta=pergunta_texto)
9
+ db.add(pergunta)
10
+ db.commit() # Gera o ID da pergunta para usar nas respostas
11
+ db.refresh(pergunta) # Atualiza o objeto com o ID do banco
12
+
13
+ # Cria as respostas vinculadas à pergunta
14
+ for i, texto in enumerate(respostas):
15
+ resposta = QuizResposta(
16
+ pergunta_id=pergunta.id,
17
+ texto=texto,
18
+ correta=(i == correta_index)
19
+ )
20
+ db.add(resposta)
21
+
22
+ db.commit()
23
+ print(f"Pergunta '{pergunta_texto}' adicionada com sucesso.")
24
+ except Exception as e:
25
+ db.rollback()
26
+ print(f"Erro ao adicionar pergunta: {e}")
27
+ finally:
28
+ db.close()
29
+
30
+ if __name__ == "__main__":
31
+ adicionar_pergunta(
32
+ "O que significa FPSO?",
33
+ [
34
+ "Floating Production Storage and Offloading",
35
+ "Fixed Production Storage Offshore",
36
+ "Floating Processing Supply Operation"
37
+ ],
38
+ 0
39
+ )
40
+
41
+ adicionar_pergunta(
42
+ "Qual é a principal função de um FPSO?",
43
+ [
44
+ "Armazenar contêineres",
45
+ "Produzir, armazenar e transferir petróleo",
46
+ "Transporte de passageiros"
47
+ ],
48
+ 1
49
+ )
50
+
51
+ adicionar_pergunta(
52
+ "Onde normalmente um FPSO opera?",
53
+ [
54
+ "Em portos comerciais",
55
+ "Em rios navegáveis",
56
+ "Em águas profundas e ultraprofundas"
57
+ ],
58
+ 2
59
+ )
60
+
61
+ adicionar_pergunta(
62
+ "Qual produto NÃO é normalmente processado em um FPSO?",
63
+ [
64
+ "Petróleo",
65
+ "Gás natural",
66
+ "Carvão mineral"
67
+ ],
68
+ 2
69
+ )
70
+
71
+ adicionar_pergunta(
72
+ "Qual etapa vem após a produção do petróleo em um FPSO?",
73
+ [
74
+ "Refino completo",
75
+ "Armazenamento e offloading",
76
+ "Transporte rodoviário"
77
+ ],
78
+ 1
79
+ )
80
+
81
+ adicionar_pergunta(
82
+ "O que significa o termo 'offloading'?",
83
+ [
84
+ "Processo de perfuração",
85
+ "Transferência de petróleo para navios aliviadores",
86
+ "Separação de óleo e gás"
87
+ ],
88
+ 1
89
+ )
90
+
91
+ adicionar_pergunta(
92
+ "Qual profissional é mais associado à operação diária de um FPSO?",
93
+ [
94
+ "Piloto de avião",
95
+ "Operador de produção offshore",
96
+ "Motorista de caminhão"
97
+ ],
98
+ 1
99
+ )
100
+
101
+ adicionar_pergunta(
102
+ "Qual risco é mais comum em operações offshore?",
103
+ [
104
+ "Congestionamento urbano",
105
+ "Derramamento de óleo",
106
+ "Falta de energia elétrica urbana"
107
+ ],
108
+ 1
109
+ )
110
+
111
+ adicionar_pergunta(
112
+ "Por que FPSOs são preferidos em campos distantes da costa?",
113
+ [
114
+ "Menor custo de construção",
115
+ "Dispensam oleodutos longos",
116
+ "Exigem menos tripulação"
117
+ ],
118
+ 1
119
+ )
120
+
121
+ adicionar_pergunta(
122
+ "Qual é um requisito essencial de segurança em FPSOs?",
123
+ [
124
+ "Plano de evacuação e emergência",
125
+ "Seguro veicular",
126
+ "Licença rodoviária"
127
+ ],
128
+ 0
129
+ )
administracao.py ADDED
@@ -0,0 +1,883 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ from datetime import datetime, date
5
+ from banco import SessionLocal
6
+ from models import Equipamento
7
+ from log import registrar_log
8
+ from utils_fpso import campo_fpso
9
+ from utils_permissoes import verificar_permissao
10
+
11
+ # 🔎 Utilitários SQLAlchemy para diagnóstico e migração simples
12
+ from sqlalchemy import inspect, text
13
+
14
+ # ⬇️ Import seguro do modelo AvisoGlobal (não quebra se ainda não existir)
15
+ try:
16
+ from models import AvisoGlobal
17
+ _HAS_AVISO_GLOBAL = True
18
+ except Exception:
19
+ _HAS_AVISO_GLOBAL = False
20
+
21
+ # =====================================================
22
+ # LISTAS FIXAS
23
+ # =====================================================
24
+ MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"]
25
+
26
+
27
+ # =====================================================
28
+ # MENU INFO (DOCUMENTAÇÃO INTERNA DO SISTEMA)
29
+ # =====================================================
30
+ def menu_info():
31
+
32
+ # ✅ Apêndice de documentação: novas funcionalidades e módulos (adicional)
33
+ doc_appendix()
34
+
35
+ st.info("📌 Documentação interna do sistema. Acesso restrito a administradores.")
36
+
37
+
38
+
39
+ # =====================================================
40
+ # APÊNDICE DE DOCUMENTAÇÃO (NOVAS FUNCIONALIDADES)
41
+ # =====================================================
42
+ def doc_appendix():
43
+ """
44
+ Adendo de documentação profissional que descreve
45
+ as novas funcionalidades, módulos e diretrizes sem
46
+ alterar o comportamento existente.
47
+ """
48
+ st.divider()
49
+ st.subheader("📘 Atualizações e Diretrizes Profissionais")
50
+
51
+ # ✅ NOVO: documentação padronizada do Módulo Formulário dentro do apêndice
52
+ with st.expander("🧾 Módulo Formulário (padrão)", expanded=False):
53
+ st.markdown("""
54
+ **Objetivo**
55
+ Registrar, de forma padronizada, os dados operacionais de equipamentos (FPSO, Modal, OSM, MROB, métricas e administrativos), garantindo rastreabilidade e qualidade das informações.
56
+
57
+ **Funcionalidades**
58
+ - Sugestões para **FPSO** e **FPSO1** via `campo_fpso`
59
+ - Campo controlado **“Outro”** quando aplicável
60
+ - Validação de **campos obrigatórios** (ex.: FPSO, Modal, OSM, MROB)
61
+ - Registro automático de **data/hora** (`data_hora_input`)
62
+ - Persistência completa em **banco de dados** (tabela `equipamentos`)
63
+ - **Auditoria**: ações de criação/edição/exclusão registradas
64
+
65
+ **Campos Principais (Operacionais)**
66
+ - **FPSO / FPSO1**: identificação
67
+ - **Data de Coleta**
68
+ - **Especialista / Conferente / OSM**
69
+ - **Modal / Quantidade de Equipamentos / MROB**
70
+ - **Métricas**: Linhas OSM, Linhas MROB, Linhas com Erro
71
+ - **Erros**: Storekeeper, Operação WH, Especialista WH, Outros
72
+ - **Inclusão / Exclusão** (D1, D2, D3)
73
+
74
+ **Dados Administrativos**
75
+ - **PO**, **Part Number**, **Material**, **Nota Fiscal**
76
+ - **Solicitante / Requisitante**
77
+ - **Impacto / Dimensão**
78
+ - **Motivo** (Inclusão/Exclusão)
79
+ - **Observações** (campo livre)
80
+
81
+ **Validações**
82
+ - Checagem de obrigatoriedade em campos críticos
83
+ - Tratamento de valores ausentes (fallback seguro)
84
+ - Índices/sugestões pré-carregados (FPSO/Modal/OSM)
85
+
86
+ **Fluxo de Dados**
87
+ 1. Usuário preenche o formulário com apoio de listas/sugestões
88
+ 2. Sistema valida campos e persiste em `equipamentos`
89
+ 3. Ação administrativa é registrada em **auditoria** (`log_acesso`)
90
+ 4. Registros editáveis posteriormente via **Administração de Registros**
91
+
92
+ **Perfis / Permissões**
93
+ - Acesso controlado por **perfil** (admin / usuario / consulta) via `verificar_permissao`
94
+
95
+ **Impacto**
96
+ - **Padronização** dos cadastros
97
+ - **Redução de erros** operacionais
98
+ - **Rastreabilidade** completa (auditoria + carimbo de data/hora)
99
+ """)
100
+
101
+ with st.expander("📚 Estrutura de Módulos e Grupos (modules_map.py)", expanded=False):
102
+ st.markdown("""
103
+ - **Grupos suportados**: Operação Load, Backload, Operação, Terceiros, BI.
104
+ - Cada módulo deve ter: `key`, `label`, `descricao`, `perfis`, `grupo`.
105
+ - O **menu lateral** exibe: `Pesquisar módulo` → `Selecione a operação (grupo)` → `Selecione o módulo`.
106
+ - Grupos **sem módulos** (ou sem permissão) exibem: _“Em desenvolvimento”_.
107
+ - **Boas práticas**: labels padronizados, `key` único (sem acentos e espaços), controle de acesso via `perfis`.
108
+ """)
109
+
110
+ with st.expander("🧭 Navegação e UI (menu lateral)", expanded=False):
111
+ st.markdown("""
112
+ - **Pesquisa**: filtra módulos pelo `label`.
113
+ - **Selectbox de Operação**: lista grupos disponíveis.
114
+ - **Selectbox de Módulo**: exibe módulos filtrados por grupo e permissões.
115
+ - **Rodapé da sidebar**: apresenta **e-mail do usuário logado** (badge alinhado) e bloco de **versão + desenvolvedor**.
116
+ - **Layout**: `st.set_page_config(layout="wide")` habilitado, área de conteúdo responsiva.
117
+ """)
118
+
119
+ with st.expander("📧 E-mail do Usuário Logado (login + sidebar)", expanded=False):
120
+ st.markdown("""
121
+ - `login.py` grava na sessão: `st.session_state.email` e `st.session_state.nome` (se disponíveis).
122
+ - Rodapé da sidebar exibe o e-mail em **formato badge** com ícone e alinhamento (`inline-flex`).
123
+ - Caso o e-mail não apareça: verifique se o usuário possui e-mail cadastrado e/ou revalide o login.
124
+ """)
125
+
126
+ with st.expander("🧾 Auditoria com E-mail", expanded=False):
127
+ st.markdown("""
128
+ - O módulo de auditoria realiza **JOIN** com `Usuario` e agora inclui **E-mail** na consulta.
129
+ - Exportação para Excel também leva a coluna **E-mail**.
130
+ - Observação: `JOIN` padrão é interno; para logs órfãos, use `outerjoin` (se necessário).
131
+ """)
132
+
133
+ with st.expander("🛠️ Banco de Dados e Ferramentas (db_tools)", expanded=False):
134
+ st.markdown("""
135
+ - Em **SQLite** e **PostgreSQL**, as alterações (ex.: adicionar `nome` e `email` em `usuarios`) podem ser aplicadas via módulo **`db_tools`** com `ALTER TABLE` e criação de índice único (`email`).
136
+ - **Atenção**: `Base.metadata.create_all()` **não migra** tabelas existentes; para mudanças de esquema use `ALTER TABLE`, **Alembic** (recomendado) ou recrie o banco (backup antes).
137
+ - **Verificação de colunas**: `PRAGMA table_info(usuarios)` (SQLite) ou `information_schema.columns` (Postgres/MySQL).
138
+ """)
139
+
140
+ with st.expander("🎮 Jogos / Treinamento (módulo jogos)", expanded=False):
141
+ st.markdown("""
142
+ - **Jogo da Forca (Treinamento)**: perguntas por categoria, avanço de nível, contagem de tentativas.
143
+ - **Caça ao Tesouro (Níveis)**: pistas Sim/Não com feedback visual e avanço até o limite de perguntas.
144
+ - **Dado (Curiosidades)**: número de lados configurável, curiosidades de FPSO/Estoque/Óleo e Gás.
145
+ - **Pontuação e balões**: opção de efeitos visuais e pontuação acumulada.
146
+ """)
147
+
148
+ with st.expander("🧠 Quiz e Ranking", expanded=False):
149
+ st.markdown("""
150
+ - **Quiz**: perguntas dinâmicas via banco; fluxo ajustável (sem limitadores) e com opção de **Voltar ao sistema**.
151
+ - **Ranking**: consolida pontuação por rodada/período e oferece exportação.
152
+ """)
153
+
154
+ with st.expander("🎨 Diretrizes de Layout e Acessibilidade", expanded=False):
155
+ st.markdown("""
156
+ - **Responsividade**: usar `use_container_width=True` em tabelas/gráficos.
157
+ - **Colunas fluidas**: `st.columns()` para KPIs (ajuste automático em telas menores).
158
+ - **Expansores**: `st.expander()` para reduzir poluição visual.
159
+ - **Temas**: arquivo `.streamlit/config.toml` pode definir `primaryColor`, `secondaryBackgroundColor`, etc.
160
+ """)
161
+
162
+ with st.expander("🔐 Segurança e Boas Práticas", expanded=False):
163
+ st.markdown("""
164
+ - **Senhas**: sempre criptografadas (ex.: `utils_seguranca`), nunca armazenar em texto claro.
165
+ - **Perfis**: `verificar_permissao(mod_id)` controla acesso; mantenha perfis atualizados.
166
+ - **Auditoria**: registrar ações administrativas via `registrar_log(...)`.
167
+ """)
168
+
169
+ with st.expander("📦 Versionamento e Suporte", expanded=False):
170
+ st.markdown("""
171
+ - **Versão atual**: exibida no rodapé da sidebar.
172
+ - **Desenvolvedor**: contato visível na sidebar | Rodrigo Silva.
173
+ - **Próximos passos**: documentação dos novos grupos/módulos, criação de migrations com Alembic, e manuais por equipe (Operação, Backload, Terceiros, BI).
174
+ """)
175
+
176
+
177
+ # =====================================================
178
+ # 🔔 Aviso Global — helpers
179
+ # =====================================================
180
+ def _get_db_session_admin():
181
+ """
182
+ Sessão ciente do ambiente atual (via db_router, quando disponível).
183
+ Fallback para SessionLocal().
184
+ """
185
+ try:
186
+ from db_router import get_session_for_current_db # ajuste o nome se necessário
187
+ return get_session_for_current_db()
188
+ except Exception:
189
+ return SessionLocal()
190
+
191
+ def _sanitize_largura(largura_raw: str) -> str:
192
+ val = (largura_raw or "").strip()
193
+ if not val:
194
+ return "100%"
195
+ if val.endswith("%") or val.endswith("px"):
196
+ return val
197
+ if val.isdigit():
198
+ return f"{val}px"
199
+ return "100%"
200
+
201
+ def _obter_aviso_ativo_admin():
202
+ if not _HAS_AVISO_GLOBAL:
203
+ return None
204
+ db = _get_db_session_admin()
205
+ try:
206
+ return (
207
+ db.query(AvisoGlobal)
208
+ .filter(AvisoGlobal.ativo == True)
209
+ .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
210
+ .first()
211
+ )
212
+ except Exception:
213
+ return None
214
+ finally:
215
+ try:
216
+ db.close()
217
+ except Exception:
218
+ pass
219
+
220
+ # 🔧 Diagnóstico e correção de schema (colunas) da tabela aviso_global
221
+ def _verificar_schema_aviso_global(show_ui: bool = True) -> bool:
222
+ """
223
+ Retorna True se o schema está OK (inclui font_size).
224
+ Se show_ui=True, exibe UI com botão para criar coluna ausente.
225
+ """
226
+ if not _HAS_AVISO_GLOBAL:
227
+ if show_ui:
228
+ st.error("Modelo AvisoGlobal não encontrado.")
229
+ return False
230
+
231
+ db = _get_db_session_admin()
232
+ try:
233
+ insp = inspect(db.bind)
234
+ cols = [c["name"] for c in insp.get_columns("aviso_global")]
235
+ falta_font = "font_size" not in cols
236
+
237
+ if show_ui:
238
+ with st.expander("🧪 Diagnóstico do schema (aviso_global)", expanded=False):
239
+ st.caption("Colunas atuais: " + (", ".join(cols) if cols else "—"))
240
+ if falta_font:
241
+ st.warning("A coluna **font_size** não existe neste banco/ambiente.")
242
+ col_btn1, col_btn2 = st.columns([1, 3])
243
+ if col_btn1.button("⚙️ Criar coluna font_size (DEFAULT 14)"):
244
+ try:
245
+ dialect = db.bind.dialect.name
246
+ if dialect == "sqlite":
247
+ sql = "ALTER TABLE aviso_global ADD COLUMN font_size INTEGER DEFAULT 14"
248
+ elif dialect == "postgresql":
249
+ sql = "ALTER TABLE aviso_global ADD COLUMN font_size integer DEFAULT 14"
250
+ elif dialect in ("mysql", "mariadb"):
251
+ sql = "ALTER TABLE aviso_global ADD COLUMN font_size INT DEFAULT 14"
252
+ else:
253
+ st.error(f"Dialeto não suportado para criação automática: {dialect}")
254
+ return False
255
+ db.execute(text(sql))
256
+ db.commit()
257
+ st.success("Coluna 'font_size' criada com sucesso. Recarregando...")
258
+ st.rerun()
259
+ except Exception as e:
260
+ db.rollback()
261
+ st.error(f"Erro ao criar coluna: {e}")
262
+ else:
263
+ st.success("Schema OK ✔ (coluna 'font_size' presente).")
264
+ return not falta_font
265
+
266
+ except Exception as e:
267
+ if show_ui:
268
+ st.error(f"Falha ao inspecionar o schema: {e}")
269
+ return False
270
+ finally:
271
+ try:
272
+ db.close()
273
+ except Exception:
274
+ pass
275
+
276
+ def _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size) -> bool:
277
+ if not _HAS_AVISO_GLOBAL:
278
+ return False
279
+ db = _get_db_session_admin()
280
+ try:
281
+ # desativa os ativos
282
+ db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True).update({AvisoGlobal.ativo: False})
283
+ novo = AvisoGlobal(
284
+ mensagem=(mensagem or "").strip(),
285
+ bg_color=(bg_color or "#FFF3CD").strip(),
286
+ text_color=(text_color or "#664D03").strip(),
287
+ largura=_sanitize_largura(largura),
288
+ efeito=efeito if efeito in ("marquee", "fixo") else "marquee",
289
+ velocidade=max(5, min(int(velocidade or 20), 120)),
290
+ ativo=True,
291
+ updated_at=datetime.now(),
292
+ )
293
+ # salva font_size quando o atributo/coluna existir (fallback seguro)
294
+ try:
295
+ setattr(novo, "font_size", max(10, min(int(font_size or 14), 48)))
296
+ except Exception:
297
+ pass
298
+
299
+ db.add(novo)
300
+ db.commit()
301
+ db.expire_all()
302
+ return True
303
+ except Exception as e:
304
+ db.rollback()
305
+ # Diagnóstico visível para o admin
306
+ st.error(f"Falha ao publicar o aviso: {e}")
307
+ try:
308
+ insp = inspect(db.bind)
309
+ cols = [c["name"] for c in insp.get_columns("aviso_global")]
310
+ st.caption("Colunas em aviso_global: " + ", ".join(cols))
311
+ except Exception:
312
+ pass
313
+ return False
314
+ finally:
315
+ try:
316
+ db.close()
317
+ except Exception:
318
+ pass
319
+
320
+ def _desativar_aviso_admin() -> bool:
321
+ if not _HAS_AVISO_GLOBAL:
322
+ return False
323
+ db = _get_db_session_admin()
324
+ try:
325
+ db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True)\
326
+ .update({AvisoGlobal.ativo: False, AvisoGlobal.updated_at: datetime.now()})
327
+ db.commit()
328
+ db.expire_all()
329
+ return True
330
+ except Exception:
331
+ db.rollback()
332
+ return False
333
+ finally:
334
+ try:
335
+ db.close()
336
+ except Exception:
337
+ pass
338
+
339
+
340
+ # ===============================
341
+ # 🔎 Pré-visualização do Aviso Global (somente render local)
342
+ # ===============================
343
+ def _render_preview_aviso_topbar(mensagem: str, bg_color: str, text_color: str, largura: str, efeito: str, velocidade: int, font_size: int):
344
+ largura = _sanitize_largura(largura)
345
+ bg = (bg_color or "#FFF3CD").strip()
346
+ fg = (text_color or "#664D03").strip()
347
+ efeito = (efeito or "marquee").lower()
348
+ try:
349
+ velocidade = int(velocidade or 20)
350
+ except Exception:
351
+ velocidade = 20
352
+ try:
353
+ font_size = max(10, min(int(font_size or 14), 48))
354
+ except Exception:
355
+ font_size = 14
356
+
357
+ st.markdown(
358
+ f"""
359
+ <style>
360
+ .ag-topbar-wrap-preview {{
361
+ position: relative; /* preview não fixa no topo global */
362
+ width: {largura};
363
+ margin: 8px auto 10px auto;
364
+ z-index: 10;
365
+ background: {bg}; color: {fg};
366
+ border: 1px solid rgba(0,0,0,.08);
367
+ box-shadow: 0 2px 6px rgba(0,0,0,.06);
368
+ border-radius: 10px;
369
+ }}
370
+ .ag-topbar-inner-preview {{
371
+ display: flex; align-items: center;
372
+ min-height: 44px; padding: 8px 14px; overflow: hidden;
373
+ font-weight: 700; font-size: {font_size}px; letter-spacing: .2px;
374
+ white-space: nowrap;
375
+ }}
376
+ .ag-topbar-marquee-preview > span {{
377
+ display: inline-block; padding-left: 100%;
378
+ animation: ag-marquee-preview {velocidade}s linear infinite;
379
+ }}
380
+ @keyframes ag-marquee-preview {{
381
+ 0% {{ transform: translateX(0); }}
382
+ 100% {{ transform: translateX(-100%); }}
383
+ }}
384
+ </style>
385
+ <div class="ag-topbar-wrap-preview">
386
+ <div class="ag-topbar-inner-preview {'ag-topbar-marquee-preview' if efeito=='marquee' else ''}">
387
+ <span>{mensagem}</span>
388
+ </div>
389
+ </div>
390
+ """,
391
+ unsafe_allow_html=True
392
+ )
393
+
394
+
395
+ # =====================================================
396
+ # 🔔 Menu: Aviso Global (Topo)
397
+ # =====================================================
398
+ def menu_aviso_global():
399
+ st.subheader("📣 Aviso Global (Topo)")
400
+ st.caption("Envie um aviso global exibido no topo para todos os usuários.")
401
+
402
+ perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
403
+ if perfil != "admin":
404
+ st.warning("Apenas administradores podem publicar avisos globais.")
405
+ return
406
+
407
+ if not _HAS_AVISO_GLOBAL:
408
+ st.error(
409
+ "O modelo `AvisoGlobal` não foi encontrado em `models.py`."
410
+ )
411
+ with st.expander("📄 Modelo necessário (copie para models.py)"):
412
+ st.code(
413
+ """from banco import Base
414
+ from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
415
+ from sqlalchemy.sql import func
416
+
417
+ class AvisoGlobal(Base):
418
+ __tablename__ = "aviso_global"
419
+ id = Column(Integer, primary_key=True, index=True)
420
+ mensagem = Column(Text, nullable=False)
421
+ bg_color = Column(String(32), default="#FFF3CD")
422
+ text_color = Column(String(32), default="#664D03")
423
+ largura = Column(String(16), default="100%")
424
+ efeito = Column(String(16), default="marquee")
425
+ velocidade = Column(Integer, default=20)
426
+ font_size = Column(Integer, default=14) # tamanho da fonte (px)
427
+ ativo = Column(Boolean, default=True, index=True)
428
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
429
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())""",
430
+ language="python",
431
+ )
432
+ return
433
+
434
+ # 🔎 Diagnóstico/migração simples do schema (font_size)
435
+ _verificar_schema_aviso_global(show_ui=True)
436
+
437
+ aviso_atual = _obter_aviso_ativo_admin()
438
+
439
+ msg_default = aviso_atual.mensagem if aviso_atual else ""
440
+ bg_default = aviso_atual.bg_color if aviso_atual else "#FFF3CD"
441
+ fg_default = aviso_atual.text_color if aviso_atual else "#664D03"
442
+ w_default = aviso_atual.largura if aviso_atual else "100%"
443
+ ef_default = (aviso_atual.efeito if aviso_atual else "marquee")
444
+ vel_default = int(aviso_atual.velocidade if aviso_atual else 20)
445
+ fs_default = int(getattr(aviso_atual, "font_size", 14)) if aviso_atual else 14 # ⬅️ NOVO
446
+
447
+ mensagem = st.text_input("Mensagem do aviso:", value=msg_default, placeholder="Ex.: Manutenção hoje às 18h...")
448
+ colc1, colc2 = st.columns(2)
449
+ bg_color = colc1.color_picker("Cor de fundo", value=bg_default)
450
+ text_color = colc2.color_picker("Cor do texto", value=fg_default)
451
+
452
+ colw1, colw2 = st.columns([2,1])
453
+ largura = colw1.text_input("Largura (ex.: 100% ou 1200px)", value=w_default)
454
+ efeito = colw2.selectbox("Efeito", ["marquee", "fixo"], index=(0 if ef_default=="marquee" else 1))
455
+
456
+ colv1, colv2 = st.columns(2)
457
+ velocidade = colv1.slider("Velocidade (segundos por ciclo)", min_value=5, max_value=120, value=vel_default, step=1, help="Usado apenas no modo 'marquee'.")
458
+ font_size = colv2.slider("Tamanho da fonte (px)", min_value=10, max_value=48, value=fs_default, step=1) # ⬅️ NOVO
459
+
460
+ # --- Pré-visualização ao vivo (sem salvar) ---
461
+ st.markdown("**Pré-visualização:**")
462
+ if (mensagem or "").strip():
463
+ _render_preview_aviso_topbar(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
464
+ else:
465
+ st.info("Digite a mensagem para ver a pré-visualização aqui.")
466
+
467
+ colb1, colb2, colb3 = st.columns(3)
468
+ publicar = colb1.button("📢 Publicar/Atualizar aviso")
469
+ desativar = colb2.button("🛑 Desativar aviso atual")
470
+ atualizar_preview = colb3.button("🔄 Atualizar prévia")
471
+
472
+ # Botão opcional de refresh da prévia (não salva nada; rerenderiza a página).
473
+ if atualizar_preview:
474
+ st.rerun()
475
+
476
+ if publicar:
477
+ if not (mensagem or "").strip():
478
+ st.warning("Digite a mensagem do aviso.")
479
+ else:
480
+ ok = _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
481
+ if ok:
482
+ try:
483
+ registrar_log(
484
+ usuario=st.session_state.get("usuario"),
485
+ acao="PUBLICAR_AVISO_GLOBAL",
486
+ tabela="aviso_global",
487
+ registro_id=None
488
+ )
489
+ except Exception:
490
+ pass
491
+ st.success("Aviso publicado/atualizado!")
492
+ st.rerun()
493
+ else:
494
+ st.error("Não foi possível publicar o aviso. Verifique o banco/logs.")
495
+
496
+ if desativar:
497
+ ok = _desativar_aviso_admin()
498
+ if ok:
499
+ try:
500
+ registrar_log(
501
+ usuario=st.session_state.get("usuario"),
502
+ acao="DESATIVAR_AVISO_GLOBAL",
503
+ tabela="aviso_global",
504
+ registro_id=None
505
+ )
506
+ except Exception:
507
+ pass
508
+ st.info("Aviso desativado.")
509
+ st.rerun()
510
+ else:
511
+ st.error("Não foi possível desativar o aviso.")
512
+
513
+
514
+ # =====================================================
515
+ # ADMINISTRAÇÃO (variação com abas/tabs)
516
+ # =====================================================
517
+ def main():
518
+
519
+ # ✅ Detecta se usuário é admin; abas administrativas aparecem apenas para admin.
520
+ is_admin = verificar_permissao("administracao")
521
+
522
+ # Título conforme perfil
523
+ if is_admin:
524
+ st.title("🔒 Administração")
525
+ # Admin vê todas as abas
526
+ tab_editar, tab_aviso, tab_info = st.tabs([
527
+ "✏️ Editar / Excluir Registros",
528
+ "📣 Aviso Global (Topo)",
529
+ "📘 Info do Sistema"
530
+ ])
531
+ else:
532
+ st.title("✏️ Edição de Registros")
533
+ # Não-admin vê apenas a aba de edição
534
+ (tab_editar,) = st.tabs(["✏️ Editar Registros"])
535
+
536
+ # =====================================================
537
+ # BLOCO: INFO DO SISTEMA (apenas admin)
538
+ # =====================================================
539
+ if is_admin:
540
+ with tab_info:
541
+ menu_info()
542
+
543
+ # =====================================================
544
+ # BLOCO: AVISO GLOBAL (apenas admin)
545
+ # =====================================================
546
+ with tab_aviso:
547
+ menu_aviso_global()
548
+
549
+ # =====================================================
550
+ # BLOCO: EDIÇÃO / EXCLUSÃO (excluir só admin)
551
+ # =====================================================
552
+ with tab_editar:
553
+
554
+ # =====================================================
555
+ # FUNÇÃO UTILITÁRIA
556
+ # =====================================================
557
+ def safe_index(lista, valor):
558
+ """Evita erro quando o valor salvo no banco não existe na lista"""
559
+ try:
560
+ return lista.index(valor)
561
+ except ValueError:
562
+ return 0
563
+
564
+ db = SessionLocal()
565
+ try:
566
+ # =====================================================
567
+ # 🔎 FILTROS OPCIONAIS COM SUGESTÕES DO BANCO
568
+ # (disponível para todos os perfis)
569
+ # =====================================================
570
+ st.subheader("🔎 Filtro de Busca (opcional)")
571
+
572
+ # IMPORTANTE: usar .distinct() sobre a coluna, como já estava
573
+ fpsos = [""] + sorted({r.fpso for r in db.query(Equipamento.fpso).distinct() if r.fpso})
574
+ modais = [""] + sorted({r.modal for r in db.query(Equipamento.modal).distinct() if r.modal})
575
+ osms = [""] + sorted({r.osm for r in db.query(Equipamento.osm).distinct() if r.osm})
576
+ # 🟩 NOVO: lista de Nota Fiscal para multiselect assistido
577
+ notas_dist = [""] + sorted({str(r.nota_fiscal) for r in db.query(Equipamento.nota_fiscal).distinct() if r.nota_fiscal})
578
+
579
+ col1, col2, col3, col4 = st.columns(4)
580
+
581
+ with col1:
582
+ filtro_fpso = st.selectbox("FPSO", fpsos)
583
+
584
+ with col2:
585
+ filtro_modal = st.selectbox("Modal", modais)
586
+
587
+ with col3:
588
+ filtro_osm = st.selectbox("OSM", osms)
589
+
590
+ with col4:
591
+ filtro_data = st.date_input("Data Coleta", value=None)
592
+
593
+ # 🟩 NOVO: filtros de Nota Fiscal + opção de ver só duplicadas
594
+ st.markdown("**🧾 Filtro por Nota Fiscal**")
595
+ nf_col1, nf_col2, nf_col3 = st.columns([2, 2, 1.2])
596
+ with nf_col1:
597
+ filtro_nf_text = st.text_input(
598
+ "Digite uma ou mais NFs (separadas por vírgula)",
599
+ value=""
600
+ )
601
+ with nf_col2:
602
+ filtro_nf_multi = st.multiselect(
603
+ "Ou selecione",
604
+ options=[x for x in notas_dist if x != ""]
605
+ )
606
+ with nf_col3:
607
+ mostrar_apenas_nf_duplicadas = st.checkbox(
608
+ "Somente duplicadas",
609
+ value=False
610
+ )
611
+
612
+ # =====================================================
613
+ # QUERY BASE (COMPORTAMENTO ORIGINAL) + NOVO FILTRO NF
614
+ # =====================================================
615
+ query = db.query(Equipamento)
616
+
617
+ if filtro_fpso:
618
+ query = query.filter(Equipamento.fpso == filtro_fpso)
619
+
620
+ if filtro_modal:
621
+ query = query.filter(Equipamento.modal == filtro_modal)
622
+
623
+ if filtro_osm:
624
+ query = query.filter(Equipamento.osm == filtro_osm)
625
+
626
+ if filtro_data:
627
+ query = query.filter(Equipamento.data_coleta == filtro_data)
628
+
629
+ # 🟩 NOVO: aplica filtro de Nota Fiscal (tratando como string)
630
+ notas_escolhidas = set()
631
+ if filtro_nf_text.strip():
632
+ partes = [p.strip() for p in filtro_nf_text.split(",") if p.strip()]
633
+ notas_escolhidas.update(partes)
634
+ if filtro_nf_multi:
635
+ notas_escolhidas.update([str(x).strip() for x in filtro_nf_multi if str(x).strip()])
636
+
637
+ if notas_escolhidas:
638
+ # Como a coluna é do tipo texto no modelo, filtramos por igualdade textual.
639
+ # Para outros dialetos/formatos numéricos, garantir cast adequado.
640
+ query = query.filter(Equipamento.nota_fiscal.in_(list(notas_escolhidas)))
641
+
642
+ registros = query.order_by(Equipamento.id.desc()).all()
643
+
644
+ if not registros:
645
+ st.info("Nenhum registro encontrado.")
646
+ return
647
+
648
+ # =====================================================
649
+ # 🧭 SINALIZAÇÃO DE NF DUPLICADA (no conjunto filtrado)
650
+ # =====================================================
651
+ # Monta DF auxiliar só com campos relevantes para contagem de NF
652
+ import pandas as pd
653
+ df_aux = pd.DataFrame([{
654
+ "ID": r.id,
655
+ "Nota Fiscal": ("" if r.nota_fiscal is None else str(r.nota_fiscal).strip())
656
+ } for r in registros])
657
+
658
+ # Contagem de ocorrências por NF (string, ignorando vazias)
659
+ if not df_aux.empty:
660
+ contagem = df_aux.loc[df_aux["Nota Fiscal"] != "", "Nota Fiscal"].value_counts()
661
+ notas_duplicadas = contagem[contagem > 1]
662
+ else:
663
+ notas_duplicadas = pd.Series(dtype=int)
664
+
665
+ # Aviso e expander com a lista das duplicadas
666
+ if len(notas_duplicadas.index) > 0:
667
+ total_ocorrencias = int(notas_duplicadas.sum())
668
+ st.warning(
669
+ f"⚠️ Foram encontradas **{total_ocorrencias}** ocorrências em **{len(notas_duplicadas)}** "
670
+ f"números de Nota Fiscal duplicados no resultado filtrado."
671
+ )
672
+ with st.expander("Ver lista de notas duplicadas"):
673
+ st.dataframe(
674
+ notas_duplicadas.rename("Ocorrências").reset_index().rename(columns={"index": "Nota Fiscal"}),
675
+ use_container_width=True
676
+ )
677
+
678
+ # Se marcado: mantém na lista apenas as duplicadas
679
+ if mostrar_apenas_nf_duplicadas:
680
+ set_dup = set(notas_duplicadas.index.tolist())
681
+ registros = [r for r in registros if (r.nota_fiscal is not None and str(r.nota_fiscal).strip() in set_dup)]
682
+
683
+ if not registros:
684
+ st.info("Nenhum registro duplicado após aplicar o filtro de 'Somente duplicadas'.")
685
+ return
686
+
687
+ else:
688
+ if mostrar_apenas_nf_duplicadas:
689
+ st.info("Não há notas duplicadas no conjunto filtrado.")
690
+ return
691
+
692
+ # =====================================================
693
+ # SELECTBOX DE ESCOLHA E FORMULÁRIO
694
+ # =====================================================
695
+ mapa = {
696
+ f"ID {r.id} | FPSO {r.fpso} | {r.modal} | {r.osm} | {r.data_coleta} | NF: {r.nota_fiscal or '—'}": r.id
697
+ for r in registros
698
+ }
699
+
700
+ escolha = st.selectbox("Selecione o registro", list(mapa.keys()))
701
+ registro = db.get(Equipamento, mapa[escolha])
702
+
703
+ st.divider()
704
+ st.subheader("✏️ Editar Registro")
705
+
706
+ # =====================================================
707
+ # FORMULÁRIO COMPLETO (MESMO DO MÓDULO FORMULÁRIO)
708
+ # =====================================================
709
+ with st.form("form_edicao"):
710
+
711
+ # ================== DADOS OPERACIONAIS ==================
712
+ st.subheader("📦 Dados Operacionais")
713
+
714
+ col1, col2, col3 = st.columns(3)
715
+
716
+ with col1:
717
+ fpso1 = campo_fpso("FPSO1", registro.fpso1)
718
+ fpso = campo_fpso("FPSO", registro.fpso)
719
+ data_coleta = st.date_input("Data de Coleta", registro.data_coleta)
720
+ especialista = st.text_input("Especialista", registro.especialista or "")
721
+ conferente = st.text_input("Conferente", registro.conferente or "")
722
+ osm = st.text_input("OSM", registro.osm or "")
723
+
724
+ with col2:
725
+ modal = st.selectbox(
726
+ "Modal",
727
+ MODAL_LISTA,
728
+ index=safe_index(MODAL_LISTA, registro.modal)
729
+ )
730
+ quant_equip = st.number_input(
731
+ "Quantidade de Equipamentos",
732
+ min_value=0,
733
+ value=registro.quant_equip or 0
734
+ )
735
+ mrob = st.text_input("MROB", registro.mrob or "")
736
+
737
+ with col3:
738
+ linhas_osm = st.number_input("Total de Linhas OSM", value=registro.linhas_osm or 0)
739
+ linhas_mrob = st.number_input("Total de Linhas MROB", value=registro.linhas_mrob or 0)
740
+ linhas_erros = st.number_input("Total de Linhas com Erro", value=registro.linhas_erros or 0)
741
+
742
+ st.divider()
743
+
744
+ # ================== ANÁLISE DE ERROS ==================
745
+ st.subheader("⚠️ Análise de Erros")
746
+
747
+ op_sim_nao = ["", "Sim", "Não"]
748
+
749
+ col_e1, col_e2, col_e3, col_e4 = st.columns(4)
750
+
751
+ with col_e1:
752
+ erro_storekeeper = st.selectbox(
753
+ "Storekeeper", op_sim_nao,
754
+ index=safe_index(op_sim_nao, registro.erro_storekeeper)
755
+ )
756
+
757
+ with col_e2:
758
+ erro_operacao = st.selectbox(
759
+ "Operação WH", op_sim_nao,
760
+ index=safe_index(op_sim_nao, registro.erro_operacao)
761
+ )
762
+
763
+ with col_e3:
764
+ erro_especialista = st.selectbox(
765
+ "Especialista WH", op_sim_nao,
766
+ index=safe_index(op_sim_nao, registro.erro_especialista)
767
+ )
768
+
769
+ with col_e4:
770
+ erro_outros = st.selectbox(
771
+ "Outros", op_sim_nao,
772
+ index=safe_index(op_sim_nao, registro.erro_outros)
773
+ )
774
+
775
+ op_inc_exc = ["", "INCLUSÃO", "EXCLUSÃO"]
776
+
777
+ inclusao_exclusao = st.selectbox(
778
+ "Inclusão / Exclusão",
779
+ op_inc_exc,
780
+ index=safe_index(op_inc_exc, registro.inclusao_exclusao)
781
+ )
782
+
783
+ st.divider()
784
+
785
+ # ================== DADOS ADMINISTRATIVOS ==================
786
+ st.subheader("🧾 Dados Administrativos")
787
+
788
+ col_a1, col_a2, col_a3 = st.columns(3)
789
+
790
+ with col_a1:
791
+ po = st.text_input("PO", registro.po or "")
792
+ part_number = st.text_input("Part Number", registro.part_number or "")
793
+
794
+ with col_a2:
795
+ material = st.text_input("Material", registro.material or "")
796
+ nota_fiscal = st.text_input("Nota Fiscal", registro.nota_fiscal or "")
797
+
798
+ with col_a3:
799
+ solicitante = st.text_input("Solicitante", registro.solicitante or "")
800
+ requisitante = st.text_input("Requisitante", registro.requisitante or "")
801
+
802
+ impacto = st.text_input("Impacto", registro.impacto or "")
803
+ dimensao = st.text_input("Dimensão", registro.dimensao or "")
804
+
805
+ # ✅ AJUSTE: corrigido para 'motivo'
806
+ motivo = st.text_input("Motivo da Inclusão / Exclusão", registro.motivo or "")
807
+
808
+ observacoes = st.text_area(
809
+ "Observações",
810
+ registro.observacoes or "",
811
+ height=120
812
+ )
813
+
814
+ op_dia = ["", "D1", "D2", "D3"]
815
+
816
+ dia_inclusao = st.selectbox(
817
+ "Dia de Inclusão (D)",
818
+ op_dia,
819
+ index=safe_index(op_dia, registro.dia_inclusao)
820
+ )
821
+
822
+ # ================== AÇÃO ==================
823
+ # 🔐 Apenas admin pode excluir
824
+ opcoes_acao = ["Salvar Alterações"] + (["Excluir Registro"] if is_admin else [])
825
+ acao = st.radio(
826
+ "Ação",
827
+ opcoes_acao,
828
+ horizontal=True
829
+ )
830
+
831
+ submit = st.form_submit_button("Confirmar")
832
+
833
+ # =====================================================
834
+ # AÇÕES
835
+ # =====================================================
836
+ if submit:
837
+
838
+ if acao == "Salvar Alterações":
839
+ # Atualiza todos os campos dinamicamente (exceto id)
840
+ for campo in registro.__table__.columns.keys():
841
+ if campo != "id":
842
+ setattr(registro, campo, locals().get(campo, getattr(registro, campo)))
843
+
844
+ registro.data_hora_input = datetime.now()
845
+ db.commit()
846
+
847
+ try:
848
+ registrar_log(
849
+ usuario=st.session_state.get("usuario"),
850
+ acao="EDITAR",
851
+ tabela="equipamentos",
852
+ registro_id=registro.id
853
+ )
854
+ except Exception:
855
+ pass
856
+
857
+ st.success("✅ Registro atualizado com sucesso!")
858
+ st.rerun()
859
+
860
+ elif acao == "Excluir Registro" and is_admin:
861
+ db.delete(registro)
862
+ db.commit()
863
+
864
+ try:
865
+ registrar_log(
866
+ usuario=st.session_state.get("usuario"),
867
+ acao="EXCLUIR",
868
+ tabela="equipamentos",
869
+ registro_id=registro.id
870
+ )
871
+ except Exception:
872
+ pass
873
+
874
+ st.success("🗑️ Registro excluído com sucesso!")
875
+ st.rerun()
876
+
877
+ finally:
878
+ try:
879
+ db.close()
880
+ except Exception:
881
+ pass
882
+
883
+
app.py ADDED
@@ -0,0 +1,1015 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
app_outlook.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ import pandas as pd
5
+ from datetime import datetime, timedelta, date
6
+ import io
7
+ import pythoncom # ✅ necessário para inicializar/finalizar COM em cada operação
8
+
9
+ st.set_page_config(page_title="Relatório de E-mails • Outlook Desktop", layout="wide")
10
+
11
+
12
+ # ==============================
13
+ # Utilitários de exportação/indicadores
14
+ # ==============================
15
+ def build_downloads(df: pd.DataFrame, base_name: str):
16
+ """Cria botões de download (CSV, Excel e PDF) para o DataFrame."""
17
+ if df.empty:
18
+ st.warning("Nenhum dado para exportar.")
19
+ return
20
+
21
+ # CSV
22
+ csv_buf = io.StringIO()
23
+ df.to_csv(csv_buf, index=False, encoding="utf-8-sig")
24
+ st.download_button(
25
+ "⬇️ Baixar CSV",
26
+ data=csv_buf.getvalue(),
27
+ file_name=f"{base_name}.csv",
28
+ mime="text/csv",
29
+ )
30
+
31
+ # Excel
32
+ xlsx_buf = io.BytesIO()
33
+ with pd.ExcelWriter(xlsx_buf, engine="openpyxl") as writer:
34
+ df.to_excel(writer, index=False, sheet_name="Relatorio")
35
+ xlsx_buf.seek(0)
36
+ st.download_button(
37
+ "⬇️ Baixar Excel",
38
+ data=xlsx_buf,
39
+ file_name=f"{base_name}.xlsx",
40
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
41
+ )
42
+
43
+ # PDF (resumo com até 100 linhas para leitura confortável)
44
+ try:
45
+ from reportlab.lib.pagesizes import A4, landscape
46
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
47
+ from reportlab.lib import colors
48
+ from reportlab.lib.styles import getSampleStyleSheet
49
+
50
+ pdf_buf = io.BytesIO()
51
+ doc = SimpleDocTemplate(
52
+ pdf_buf,
53
+ pagesize=landscape(A4),
54
+ rightMargin=20, leftMargin=20, topMargin=20, bottomMargin=20
55
+ )
56
+ styles = getSampleStyleSheet()
57
+ story = []
58
+
59
+ title = Paragraph(f"Relatório de E-mails — {base_name}", styles["Title"])
60
+ story.append(title)
61
+ story.append(Spacer(1, 12))
62
+
63
+ # Limita tabela para evitar PDFs gigantes
64
+ df_show = df.copy().head(100)
65
+ data_table = [list(df_show.columns)] + df_show.astype(str).values.tolist()
66
+ table = Table(data_table, repeatRows=1)
67
+ table.setStyle(TableStyle([
68
+ ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#E9ECEF")),
69
+ ("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#212529")),
70
+ ("GRID", (0,0), (-1,-1), 0.25, colors.HexColor("#ADB5BD")),
71
+ ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
72
+ ("FONTNAME", (0,1), (-1,-1), "Helvetica"),
73
+ ("FONTSIZE", (0,0), (-1,-1), 9),
74
+ ("ALIGN", (0,0), (-1,-1), "LEFT"),
75
+ ("VALIGN", (0,0), (-1,-1), "MIDDLE"),
76
+ ]))
77
+ story.append(table)
78
+
79
+ doc.build(story)
80
+ pdf_buf.seek(0)
81
+
82
+ st.download_button(
83
+ "⬇️ Baixar PDF",
84
+ data=pdf_buf,
85
+ file_name=f"{base_name}.pdf",
86
+ mime="application/pdf",
87
+ )
88
+ except Exception as e:
89
+ st.info(f"PDF: não foi possível gerar o arquivo (ReportLab). Detalhe: {e}")
90
+
91
+
92
+ def render_indicators(df: pd.DataFrame, dt_col_name: str):
93
+ """Exibe indicadores simples (top remetentes, distribuição por dia)."""
94
+ if df.empty:
95
+ return
96
+ st.subheader("📊 Indicadores")
97
+ col1, col2 = st.columns(2)
98
+ with col1:
99
+ st.write("**Top Remetentes (Top 10)**")
100
+ st.dataframe(
101
+ df["Remetente"].value_counts().head(10).rename("Qtd").to_frame(),
102
+ use_container_width=True,
103
+ )
104
+ with col2:
105
+ st.write("**Mensagens por Dia**")
106
+ if dt_col_name in df.columns:
107
+ _dt = pd.to_datetime(df[dt_col_name], errors="coerce")
108
+ por_dia = _dt.dt.date.value_counts().sort_index().rename("Qtd")
109
+ st.dataframe(por_dia.to_frame(), use_container_width=True)
110
+
111
+
112
+ # ==============================
113
+ # Outlook Desktop (Windows) — COM-safe helpers
114
+ # ==============================
115
+ def _list_folders_desktop(root_folder, prefix=""):
116
+ """Recursão local (já com root_folder pronto) — retorna caminhos completos de subpastas."""
117
+ paths = []
118
+ try:
119
+ for i in range(1, root_folder.Folders.Count + 1):
120
+ f = root_folder.Folders.Item(i)
121
+ full_path = prefix + f.Name
122
+ paths.append(full_path)
123
+ # recursão
124
+ try:
125
+ paths.extend(_list_folders_desktop(f, prefix=full_path + "\\"))
126
+ except Exception:
127
+ pass
128
+ except Exception:
129
+ pass
130
+ return paths
131
+
132
+
133
+ def safe_list_all_folders():
134
+ """
135
+ ✅ Inicializa COM, conecta no Outlook e retorna TODOS os caminhos de pastas
136
+ da caixa padrão. Finaliza COM ao terminar. Evita 'CoInitialize não foi chamado'.
137
+ """
138
+ try:
139
+ import win32com.client
140
+ pythoncom.CoInitialize() # inicializa COM
141
+ outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
142
+ root_mailbox = outlook.Folders.Item(1) # índice da caixa de correio padrão
143
+ return _list_folders_desktop(root_mailbox, prefix="")
144
+ except Exception as e:
145
+ st.sidebar.info(f"Não foi possível listar pastas automaticamente ({e}). Informe manualmente abaixo.")
146
+ return []
147
+ finally:
148
+ try:
149
+ pythoncom.CoUninitialize() # finaliza COM
150
+ except Exception:
151
+ pass
152
+
153
+
154
+ def _get_folder_by_path(root_folder, path: str):
155
+ parts = [p for p in path.split("\\") if p]
156
+ folder = root_folder.Folders.Item(parts[0])
157
+ for p in parts[1:]:
158
+ folder = folder.Folders.Item(p)
159
+ return folder
160
+
161
+
162
+ def _read_folder_items(folder, dias: int, filtro_remetente: str = "") -> pd.DataFrame:
163
+ """Lê e-mails de uma pasta específica e retorna DataFrame."""
164
+ items = folder.Items
165
+ items.Sort("[ReceivedTime]", True) # decrescente
166
+ dt_from = (datetime.now() - timedelta(days=dias)).strftime("%m/%d/%Y %H:%M %p")
167
+ try:
168
+ items = items.Restrict(f"[ReceivedTime] >= '{dt_from}'")
169
+ except Exception:
170
+ # Alguns ambientes podem falhar no Restrict; segue sem filtro temporal
171
+ pass
172
+
173
+ rows = []
174
+ for mail in items:
175
+ try:
176
+ if getattr(mail, "Class", None) != 43: # 43 = MailItem
177
+ continue
178
+ try:
179
+ sender = mail.SenderEmailAddress or mail.Sender.Name
180
+ except Exception:
181
+ sender = getattr(mail, "SenderName", None)
182
+
183
+ # Filtro opcional por remetente
184
+ if filtro_remetente and sender:
185
+ if filtro_remetente.lower() not in str(sender).lower():
186
+ continue
187
+
188
+ anexos = mail.Attachments.Count if hasattr(mail, "Attachments") else 0
189
+ tamanho_kb = round(mail.Size / 1024, 1) if hasattr(mail, "Size") else None
190
+
191
+ rows.append({
192
+ "Pasta": folder.Name,
193
+ "Assunto": mail.Subject,
194
+ "Remetente": sender,
195
+ "RecebidoEm": mail.ReceivedTime.strftime("%Y-%m-%d %H:%M"),
196
+ "Anexos": anexos,
197
+ "TamanhoKB": tamanho_kb,
198
+ "Importancia": str(getattr(mail, "Importance", "")), # 0 baixa, 1 normal, 2 alta
199
+ "Categoria": getattr(mail, "Categories", "") or "",
200
+ "Lido": bool(getattr(mail, "UnRead", False) == False),
201
+ })
202
+ except Exception as e:
203
+ rows.append({
204
+ "Pasta": folder.Name, "Assunto": f"[ERRO] {e}", "Remetente": "",
205
+ "RecebidoEm": "", "Anexos": "", "TamanhoKB": "", "Importancia": "", "Categoria": "", "Lido": ""
206
+ })
207
+ return pd.DataFrame(rows)
208
+
209
+
210
+ def gerar_relatorio_outlook_desktop_multi(pastas: list[str], dias: int, filtro_remetente: str = "") -> pd.DataFrame:
211
+ """
212
+ ✅ Envolve toda operação COM: inicializa, lê e finaliza.
213
+ Evita o erro 'CoInitialize não foi chamado.'
214
+ """
215
+ try:
216
+ import win32com.client
217
+ pythoncom.CoInitialize() # inicializa COM
218
+ outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
219
+ root = outlook.Folders.Item(1) # ajuste o índice se tiver múltiplas caixas
220
+ except Exception as e:
221
+ st.error(f"Falha ao conectar ao Outlook/pywin32: {e}")
222
+ return pd.DataFrame()
223
+
224
+ frames = []
225
+ try:
226
+ for path in pastas:
227
+ try:
228
+ folder = _get_folder_by_path(root, path)
229
+ df = _read_folder_items(folder, dias, filtro_remetente=filtro_remetente)
230
+ df["PastaPath"] = path
231
+ frames.append(df)
232
+ except Exception as e:
233
+ st.warning(f"Não foi possível ler a pasta '{path}': {e}")
234
+ return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
235
+ finally:
236
+ try:
237
+ pythoncom.CoUninitialize() # finaliza COM
238
+ except Exception:
239
+ pass
240
+
241
+
242
+ # ==============================
243
+ # UI — Streamlit (seleção de múltiplas pastas)
244
+ # ==============================
245
+ st.title("📧 Relatório de E-mails • Outlook Desktop (Windows)")
246
+ st.caption("Escolha **uma ou várias pastas** da sua Caixa de Entrada, defina o período, filtre por remetente (opcional) e gere o relatório.")
247
+
248
+ st.sidebar.header("Configurações")
249
+ dias = st.sidebar.slider("Período (últimos N dias)", min_value=1, max_value=365, value=30)
250
+ filtro_remetente = st.sidebar.text_input(
251
+ "Filtrar por remetente (opcional)",
252
+ value="",
253
+ placeholder='Ex.: "@fornecedor.com" ou "Fulano"'
254
+ )
255
+ apenas_inbox = st.sidebar.checkbox("Mostrar somente pastas sob Inbox", value=True)
256
+
257
+ # Tentar listar todas as pastas (COM-safe)
258
+ todas_pastas = safe_list_all_folders()
259
+
260
+ # Filtrar apenas pastas sob Inbox (Caixa de Entrada), se marcado
261
+ if todas_pastas:
262
+ if apenas_inbox:
263
+ opcoes_base = [p for p in todas_pastas if p.lower().startswith("inbox")]
264
+ else:
265
+ opcoes_base = todas_pastas
266
+ else:
267
+ opcoes_base = []
268
+
269
+ # Busca por nome
270
+ filtro_pasta = st.sidebar.text_input("Pesquisar pasta por nome:", value="")
271
+ if filtro_pasta and opcoes_base:
272
+ opcoes = [p for p in opcoes_base if filtro_pasta.lower() in p.lower()]
273
+ else:
274
+ opcoes = opcoes_base or []
275
+
276
+ # Multiselect de pastas
277
+ pastas_escolhidas = st.sidebar.multiselect(
278
+ "Selecione uma ou mais pastas:",
279
+ options=opcoes if opcoes else ["Inbox"],
280
+ default=(opcoes[:1] if opcoes else ["Inbox"]),
281
+ help="Use '\\' para subpastas. Ex.: Inbox\\Financeiro\\Notas"
282
+ )
283
+
284
+ # Campo manual adicional (para quem quer escrever um caminho específico não listado)
285
+ pasta_manual_extra = st.sidebar.text_input(
286
+ "Adicionar caminho manual (opcional)",
287
+ value="",
288
+ placeholder="Inbox\\Financeiro\\Notas"
289
+ )
290
+ if pasta_manual_extra.strip():
291
+ pastas_escolhidas = list(set(pastas_escolhidas + [pasta_manual_extra.strip()]))
292
+
293
+ # Botão gerar
294
+ if st.sidebar.button("🔍 Gerar relatório"):
295
+ if not pastas_escolhidas:
296
+ st.error("Selecione ao menos uma pasta.")
297
+ else:
298
+ with st.spinner("Lendo e-mails do Outlook..."):
299
+ df = gerar_relatorio_outlook_desktop_multi(
300
+ pastas_escolhidas,
301
+ dias,
302
+ filtro_remetente=filtro_remetente
303
+ )
304
+
305
+ st.success(f"Relatório gerado ({len(df)} registros) a partir de {len(pastas_escolhidas)} pasta(s).")
306
+
307
+ st.subheader("📄 Resultado")
308
+ st.dataframe(df, use_container_width=True)
309
+ render_indicators(df, dt_col_name="RecebidoEm")
310
+
311
+ base_name = f"relatorio_outlook_desktop_{date.today()}"
312
+ build_downloads(df, base_name=base_name)
313
+
314
+ st.markdown("---")
315
+ st.caption("Dica: se você tem várias caixas postais, troque o índice em `outlook.Folders.Item(1)` para a caixa correta.")
audit_streamlit_project.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Auditor de projeto Streamlit — chaves duplicadas, estrutura e relacionamentos.
5
+
6
+ Verifica:
7
+ 1) Chaves duplicadas em st.form/st.button/st.download_button.
8
+ 2) Widgets sem 'key' (risco em loops).
9
+ 3) Imports faltantes no app.py para módulos usados no roteamento.
10
+ 4) Cobertura MODULES ↔ Roteamento (entries sem rota e rotas sem entry).
11
+ 5) Arquivos de módulos inexistentes e módulos sem main().
12
+ 6) Imports não usados.
13
+ 7) Ciclos de importação entre arquivos .py (somente locais).
14
+ 8) Emite relatório em console e JSON.
15
+
16
+ Uso:
17
+ python audit_streamlit_project.py
18
+ python audit_streamlit_project.py --root . --app app.py --modules modules_map.py --exclude venv .venv .git
19
+
20
+ Saída JSON:
21
+ .audit_report.json (na raiz especificada)
22
+ """
23
+ import os
24
+ import re
25
+ import ast
26
+ import json
27
+ import argparse
28
+ from collections import defaultdict
29
+
30
+ # -----------------------
31
+ # Util — File discovery
32
+ # -----------------------
33
+ def find_python_files(root, exclude_dirs=None):
34
+ exclude_dirs = set(exclude_dirs or [])
35
+ for dirpath, dirnames, filenames in os.walk(root):
36
+ # filtra diretorios ignorados
37
+ dirnames[:] = [
38
+ d for d in dirnames
39
+ if os.path.join(dirpath, d) not in {os.path.join(root, ex) for ex in exclude_dirs}
40
+ and d not in exclude_dirs
41
+ ]
42
+ for fn in filenames:
43
+ if fn.endswith(".py"):
44
+ yield os.path.join(dirpath, fn)
45
+
46
+ def read_text(path):
47
+ try:
48
+ with open(path, "r", encoding="utf-8") as f:
49
+ return f.read()
50
+ except Exception:
51
+ try:
52
+ with open(path, "r", encoding="latin-1") as f:
53
+ return f.read()
54
+ except Exception:
55
+ return ""
56
+
57
+ def parse_ast(path):
58
+ src = read_text(path)
59
+ if not src:
60
+ return None, ""
61
+ try:
62
+ tree = ast.parse(src, filename=path)
63
+ return tree, src
64
+ except Exception:
65
+ return None, src
66
+
67
+ # -----------------------
68
+ # Scan — Streamlit keys
69
+ # -----------------------
70
+ KEY_PATTERNS = {
71
+ "form_literal": re.compile(r'st\.form\(\s*\'"[\'"]'),
72
+ "button_key": re.compile(r'st\.button\([^)]*key\s*=\s*\'"[\'"]'),
73
+ "download_key": re.compile(r'st\.download_button\([^)]*key\s*=\s*\'"[\'"]'),
74
+ }
75
+ # widgets sem key (para alertar)
76
+ MISSING_KEY_PATTERNS = {
77
+ "button_no_key": re.compile(r'st\.button\((?![^)]*key\s*=)'),
78
+ "download_no_key": re.compile(r'st\.download_button\((?![^)]*key\s*=)'),
79
+ }
80
+
81
+ def scan_duplicate_and_missing_keys(file_path):
82
+ dups = defaultdict(list)
83
+ missing = defaultdict(list)
84
+ try:
85
+ with open(file_path, "r", encoding="utf-8") as f:
86
+ for i, line in enumerate(f, 1):
87
+ # dup keys
88
+ for _, pat in KEY_PATTERNS.items():
89
+ for m in pat.finditer(line):
90
+ dups[m.group(1)].append(i)
91
+ # missing key warnings
92
+ for name, pat in MISSING_KEY_PATTERNS.items():
93
+ if pat.search(line):
94
+ missing[name].append(i)
95
+ except Exception:
96
+ pass
97
+ dup_filtered = {k: v for k, v in dups.items() if len(v) > 1}
98
+ return dup_filtered, missing
99
+
100
+ # -----------------------
101
+ # AST helpers — imports
102
+ # -----------------------
103
+ def extract_imports_defs_calls(tree):
104
+ """
105
+ Retorna:
106
+ imports: { alias_ou_nome -> modulo_base }
107
+ used_names: set de nomes referenciados
108
+ defs: set de nomes de funções definidas
109
+ calls_main: set de nomes/lvalues em chamadas *.main()
110
+ """
111
+ imports = {} # alias -> base_module
112
+ used_names = set()
113
+ defs = set()
114
+ calls_main = set()
115
+
116
+ class V(ast.NodeVisitor):
117
+ def visit_Import(self, node):
118
+ for alias in node.names:
119
+ base = alias.name.split(".")[0]
120
+ asname = alias.asname or alias.name
121
+ asname = asname.split(".")[0]
122
+ imports[asname] = base
123
+
124
+ def visit_ImportFrom(self, node):
125
+ if node.module:
126
+ base = node.module.split(".")[0]
127
+ for alias in node.names:
128
+ asname = alias.asname or alias.name
129
+ imports[asname] = base
130
+
131
+ def visit_FunctionDef(self, node):
132
+ defs.add(node.name)
133
+ self.generic_visit(node)
134
+
135
+ def visit_Name(self, node):
136
+ used_names.add(node.id)
137
+
138
+ def visit_Attribute(self, node):
139
+ # captura padrão X.main(...)
140
+ if isinstance(node.ctx, ast.Load) and getattr(node, "attr", None) == "main":
141
+ if isinstance(node.value, ast.Name):
142
+ calls_main.add(node.value.id)
143
+ else:
144
+ # pkg.sub.main -> tenta achar o nome raiz
145
+ root = node.value
146
+ while isinstance(root, ast.Attribute):
147
+ root = root.value
148
+ if isinstance(root, ast.Name):
149
+ calls_main.add(root.id)
150
+ self.generic_visit(node)
151
+
152
+ if tree:
153
+ V().visit(tree)
154
+ return imports, used_names, defs, calls_main
155
+
156
+ # -----------------------
157
+ # modules_map.py — parse
158
+ # -----------------------
159
+ def load_modules_map(modules_map_path):
160
+ """
161
+ Extrai:
162
+ - route_keys: chaves top-level do dict MODULES (ex.: "consulta", "operacao"...)
163
+ - internal_keys: valores do campo "key" dentro de cada entrada
164
+ """
165
+ route_keys = set()
166
+ internal_keys = set()
167
+ src = read_text(modules_map_path)
168
+ if not src:
169
+ return route_keys, internal_keys
170
+ # chaves top-level (aproximação): linhas com " \"nome\": {"
171
+ for m in re.finditer(r'^[ \t]*"([^"]+)"\s*:\s*\{', src, re.MULTILINE):
172
+ route_keys.add(m.group(1))
173
+ # field "key": "valor"
174
+ for m in re.finditer(r'"key"\s*:\s*"([^"]+)"', src):
175
+ internal_keys.add(m.group(1))
176
+ return route_keys, internal_keys
177
+
178
+ # -----------------------
179
+ # Roteamento em app.py
180
+ # -----------------------
181
+ def extract_routing(app_src):
182
+ """
183
+ Busca padrões:
184
+ if/elif pagina_id == "consulta":
185
+ consulta.main()
186
+ Retorna lista de tuplas: (route_key, called_module_name)
187
+ """
188
+ routes = []
189
+
190
+ # bloco "if" inicial
191
+ m_if = re.search(
192
+ r'if\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)',
193
+ app_src, re.DOTALL
194
+ )
195
+ if m_if:
196
+ route = m_if.group(1)
197
+ block = m_if.group(2)
198
+ called = None
199
+ cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block)
200
+ if cm:
201
+ called = cm.group(1)
202
+ routes.append((route, called))
203
+
204
+ # blocos "elif"
205
+ for m in re.finditer(
206
+ r'elif\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)',
207
+ app_src, re.DOTALL
208
+ ):
209
+ route = m.group(1)
210
+ block = m.group(2)
211
+ called = None
212
+ cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block)
213
+ if cm:
214
+ called = cm.group(1)
215
+ routes.append((route, called))
216
+
217
+ return routes
218
+
219
+ # -----------------------
220
+ # Import graph & cycles
221
+ # -----------------------
222
+ def build_local_import_graph(py_files):
223
+ """
224
+ Monta grafo de importações locais: base_name -> { base_names importados }
225
+ """
226
+ # mapeia base_name -> arquivo
227
+ base_to_file = {}
228
+ for f in py_files:
229
+ base = os.path.splitext(os.path.basename(f))[0]
230
+ base_to_file[base] = f
231
+
232
+ graph = defaultdict(set)
233
+ for f in py_files:
234
+ base = os.path.splitext(os.path.basename(f))[0]
235
+ tree, _ = parse_ast(f)
236
+ imports, _, _, _ = extract_imports_defs_calls(tree)
237
+ for alias, base_mod in imports.items():
238
+ # se alias ou base_mod mapeia para arquivo local, considera aresta
239
+ target = None
240
+ if alias in base_to_file:
241
+ target = alias
242
+ elif base_mod in base_to_file:
243
+ target = base_mod
244
+ if target and target != base:
245
+ graph[base].add(target)
246
+ return graph
247
+
248
+ def find_cycles(graph):
249
+ """
250
+ Detecta ciclos no grafo (lista de ciclos) — sem mutar o dicionário durante a iteração.
251
+ """
252
+ # Conjunto estático de nós (origens + destinos)
253
+ nodes = set(graph.keys())
254
+ for vs in graph.values():
255
+ nodes.update(vs)
256
+
257
+ visited = set()
258
+ stack = set()
259
+ cycles = []
260
+ path = []
261
+
262
+ def dfs(u):
263
+ visited.add(u)
264
+ stack.add(u)
265
+ path.append(u)
266
+ for v in graph.get(u, set()): # <- sem criar chaves novas
267
+ if v not in visited:
268
+ dfs(v)
269
+ elif v in stack:
270
+ # ciclo encontrado — extrai subpath (v até fim) + fecha em v
271
+ if v in path:
272
+ idx = len(path) - 1
273
+ while idx >= 0 and path[idx] != v:
274
+ idx -= 1
275
+ if idx >= 0:
276
+ cycle = path[idx:] + [v]
277
+ cycles.append(cycle)
278
+ stack.remove(u)
279
+ path.pop()
280
+
281
+ for node in list(nodes): # <- lista estática
282
+ if node not in visited:
283
+ dfs(node)
284
+
285
+ # Deduplicar ciclos por forma canônica (rotação mínima)
286
+ def canonical(cyc):
287
+ core = cyc[:-1] # remove a repetição final
288
+ if not core:
289
+ return tuple()
290
+ rots = [tuple(core[i:] + core[:i]) for i in range(len(core))]
291
+ return min(rots)
292
+
293
+ seen = set()
294
+ unique = []
295
+ for cyc in cycles:
296
+ can = canonical(cyc)
297
+ if can and can not in seen:
298
+ seen.add(can)
299
+ unique.append(cyc)
300
+ return unique
301
+
302
+ # -----------------------
303
+ # Unused imports (aprox)
304
+ # -----------------------
305
+ def find_unused_imports(tree, imports, used_names):
306
+ """
307
+ Aproximação: se o alias importado não aparece em used_names -> não usado.
308
+ Não detecta usos por getattr/reflection; serve como guia inicial.
309
+ """
310
+ unused = []
311
+ for alias in imports.keys():
312
+ if alias not in used_names:
313
+ unused.append(alias)
314
+ return unused
315
+
316
+ # -----------------------
317
+ # Auditor principal
318
+ # -----------------------
319
+ def audit(root, app_path, modules_map_path, exclude_dirs=None, output_json=".audit_report.json"):
320
+ report = {
321
+ "duplicate_keys": {}, # file -> {key: [lines]}
322
+ "widgets_without_key": {}, # file -> {pattern: [lines]}
323
+ "missing_imports_in_app": [], # [(route_key, called_module, reason)]
324
+ "routing_vs_modules": {
325
+ "routes_without_modules_entry": [], # [route_key]
326
+ "modules_entry_without_route": [], # [modules_map_key]
327
+ },
328
+ "module_files_missing": [], # [module_name]
329
+ "modules_without_main": [], # [module_name]
330
+ "unused_imports": {}, # file -> [alias]
331
+ "import_cycles": [], # [[mod_a, mod_b, ..., mod_a]]
332
+ }
333
+
334
+ # 1) varrer arquivos
335
+ py_files = list(find_python_files(root, exclude_dirs=exclude_dirs))
336
+ # mapa base_name -> file
337
+ base_to_file = {os.path.splitext(os.path.basename(f))[0]: f for f in py_files}
338
+
339
+ # 2) chaves duplicadas e widgets sem key
340
+ for f in py_files:
341
+ dups, missing = scan_duplicate_and_missing_keys(f)
342
+ if dups:
343
+ report["duplicate_keys"][f] = dups
344
+ if any(missing.values()):
345
+ report["widgets_without_key"][f] = {k: v for k, v in missing.items() if v}
346
+
347
+ # 3) carrega app.py e modules_map.py
348
+ app_full = os.path.join(root, app_path)
349
+ modules_map_full = os.path.join(root, modules_map_path)
350
+ app_tree, app_src = parse_ast(app_full)
351
+ routes = extract_routing(app_src) if app_src else []
352
+
353
+ # imports e defs do app
354
+ app_imports, app_used, app_defs, app_calls_main = extract_imports_defs_calls(app_tree)
355
+
356
+ # 4) MODULES
357
+ route_keys_in_map, internal_keys_in_map = load_modules_map(modules_map_full)
358
+
359
+ # 5) checar import para cada rota
360
+ routes_set = set()
361
+ for route_key, called_module in routes:
362
+ routes_set.add(route_key)
363
+ if not called_module:
364
+ report["missing_imports_in_app"].append((route_key, None, "Bloco da rota não chama *.main()"))
365
+ continue
366
+ # foi importado?
367
+ imported_aliases = set(app_imports.keys()) # aliases disponíveis
368
+ if called_module not in imported_aliases:
369
+ report["missing_imports_in_app"].append((route_key, called_module, "Módulo não importado no app.py"))
370
+ # arquivo existe?
371
+ if called_module not in base_to_file:
372
+ # talvez seja alias de import (base module)
373
+ base_mod = app_imports.get(called_module)
374
+ if not (base_mod and base_mod in base_to_file):
375
+ report["module_files_missing"].append(called_module)
376
+ else:
377
+ # checar main()
378
+ t, _ = parse_ast(base_to_file[called_module])
379
+ _, _, defs, _ = extract_imports_defs_calls(t)
380
+ if "main" not in defs:
381
+ report["modules_without_main"].append(called_module)
382
+
383
+ # 6) cobertura rota vs modules_map
384
+ # - rotas no app que não existem no modules_map
385
+ for r in routes_set:
386
+ if r not in route_keys_in_map and r not in internal_keys_in_map:
387
+ report["routing_vs_modules"]["routes_without_modules_entry"].append(r)
388
+ # - entries no modules_map que não têm rota no app
389
+ for m in route_keys_in_map:
390
+ if m not in routes_set:
391
+ report["routing_vs_modules"]["modules_entry_without_route"].append(m)
392
+
393
+ # 7) unused imports por arquivo
394
+ for f in py_files:
395
+ t, _ = parse_ast(f)
396
+ imp, used, defs, calls_main = extract_imports_defs_calls(t)
397
+ unused = find_unused_imports(t, imp, used)
398
+ if unused:
399
+ report["unused_imports"][f] = unused
400
+
401
+ # 8) ciclos de import local
402
+ graph = build_local_import_graph(py_files)
403
+ cycles = find_cycles(graph)
404
+ report["import_cycles"] = cycles
405
+
406
+ # 9) remover duplicidades simples nas listas
407
+ report["missing_imports_in_app"] = list(dict.fromkeys(report["missing_imports_in_app"]))
408
+ report["module_files_missing"] = sorted(set(report["module_files_missing"]))
409
+ report["modules_without_main"] = sorted(set(report["modules_without_main"]))
410
+ report["routing_vs_modules"]["routes_without_modules_entry"] = sorted(
411
+ set(report["routing_vs_modules"]["routes_without_modules_entry"]))
412
+ report["routing_vs_modules"]["modules_entry_without_route"] = sorted(
413
+ set(report["routing_vs_modules"]["modules_entry_without_route"]))
414
+
415
+ # 10) saída
416
+ print("\n=== RELATÓRIO DE AUDITORIA — Streamlit Project ===")
417
+ # chaves duplicadas
418
+ print("\n[Chaves duplicadas]")
419
+ if not report["duplicate_keys"]:
420
+ print(" ✔ Nenhuma chave duplicada literal encontrada.")
421
+ else:
422
+ for file, dups in report["duplicate_keys"].items():
423
+ print(f" - {file}")
424
+ for key, lines in dups.items():
425
+ print(f" * key='{key}' duplicada em linhas {lines}")
426
+
427
+ # widgets sem key
428
+ print("\n[Widgets sem 'key' (atenção em loops)]")
429
+ if not report["widgets_without_key"]:
430
+ print(" ✔ Nenhum potencial widget sem key encontrado.")
431
+ else:
432
+ for file, miss in report["widgets_without_key"].items():
433
+ print(f" - {file}")
434
+ for kind, lines in miss.items():
435
+ print(f" * {kind}: linhas {lines}")
436
+
437
+ # imports faltantes e módulos
438
+ print("\n[Imports faltantes no app e módulos]")
439
+ if not report["missing_imports_in_app"]:
440
+ print(" ✔ Nenhum import faltante detectado no app.py (para rotas).")
441
+ else:
442
+ for route_key, called_module, reason in report["missing_imports_in_app"]:
443
+ print(f" - rota='{route_key}' -> módulo='{called_module}' • {reason}")
444
+ if not report["module_files_missing"]:
445
+ print(" ✔ Nenhum arquivo de módulo ausente detectado.")
446
+ else:
447
+ print(" Arquivos de módulo não encontrados:", report["module_files_missing"])
448
+ if not report["modules_without_main"]:
449
+ print(" ✔ Todos os módulos localizados possuem main().")
450
+ else:
451
+ print(" Módulos sem main():", report["modules_without_main"])
452
+
453
+ # cobertura MODULES ↔ Roteamento
454
+ print("\n[Consistência: MODULES x Roteamento]")
455
+ rwm = report["routing_vs_modules"]
456
+ if not rwm["routes_without_modules_entry"]:
457
+ print(" ✔ Todas as rotas possuem entrada em modules_map.py (ou 'key' interna).")
458
+ else:
459
+ print(" Rotas sem entrada no modules_map.py:", rwm["routes_without_modules_entry"])
460
+ if not rwm["modules_entry_without_route"]:
461
+ print(" ✔ Todas as entradas do modules_map.py possuem rota no app.py.")
462
+ else:
463
+ print(" Entradas do modules_map.py sem rota no app.py:", rwm["modules_entry_without_route"])
464
+
465
+ # imports não usados
466
+ print("\n[Imports não usados (aprox.)]")
467
+ if not report["unused_imports"]:
468
+ print(" ✔ Nenhum import potencialmente não usado encontrado.")
469
+ else:
470
+ for file, unused in report["unused_imports"].items():
471
+ print(f" - {file}: {unused}")
472
+
473
+ # ciclos
474
+ print("\n[Ciclos de importação]")
475
+ if not report["import_cycles"]:
476
+ print(" ✔ Nenhum ciclo de importação detectado.")
477
+ else:
478
+ for cyc in report["import_cycles"]:
479
+ print(" - ciclo:", " -> ".join(cyc))
480
+
481
+ # salvar JSON
482
+ out_path = os.path.join(root, output_json)
483
+ with open(out_path, "w", encoding="utf-8") as f:
484
+ json.dump(report, f, ensure_ascii=False, indent=2)
485
+ print(f"\n📄 Relatório JSON salvo em: {out_path}")
486
+
487
+ return report
488
+
489
+ # -----------------------
490
+ # CLI
491
+ # -----------------------
492
+ def cli():
493
+ p = argparse.ArgumentParser(description="Auditor de projeto Streamlit")
494
+ p.add_argument("--root", default=".", help="Raiz do projeto (default: .)")
495
+ p.add_argument("--app", default="app.py", help="Caminho do app.py (relativo à raiz)")
496
+ p.add_argument("--modules", default="modules_map.py", help="Caminho do modules_map.py (relativo à raiz)")
497
+ p.add_argument("--exclude", nargs="*", default=[".git", ".venv", "venv", "__pycache__", ".streamlit"],
498
+ help="Pastas a excluir da varredura")
499
+ p.add_argument("--json", default=".audit_report.json", help="Nome do arquivo JSON de saída")
500
+ args = p.parse_args()
501
+
502
+ audit(
503
+ root=args.root,
504
+ app_path=args.app,
505
+ modules_map_path=args.modules,
506
+ exclude_dirs=args.exclude,
507
+ output_json=args.json
508
+ )
509
+
510
+ if __name__ == "__main__":
511
+ cli()
512
+
auditoria.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ from banco import SessionLocal
4
+ from models import LogAcesso, Usuario
5
+ import pandas as pd
6
+ from io import BytesIO
7
+ import os
8
+
9
+ # Debug opcional – confirma o banco em uso
10
+ print("📂 BANCO LIDO NA AUDITORIA:", os.path.abspath("load.db"))
11
+
12
+
13
+ def main():
14
+ st.title("🧾 Auditoria do Sistema Load")
15
+
16
+ db = SessionLocal()
17
+
18
+ try:
19
+ # =========================
20
+ # FILTRO POR PERFIL
21
+ # =========================
22
+ perfis = (
23
+ db.query(Usuario.perfil)
24
+ .distinct()
25
+ .order_by(Usuario.perfil)
26
+ .all()
27
+ )
28
+
29
+ lista_perfis = ["Todos"] + [p[0] for p in perfis]
30
+
31
+ perfil_selecionado = st.selectbox(
32
+ "Filtrar por perfil:",
33
+ lista_perfis
34
+ )
35
+
36
+ # =========================
37
+ # CONSULTA COM JOIN
38
+ # =========================
39
+ # ✅ Incluímos o e-mail do usuário na seleção
40
+ query = (
41
+ db.query(
42
+ LogAcesso.usuario,
43
+ Usuario.perfil,
44
+ Usuario.email, # <-- novo
45
+ LogAcesso.acao,
46
+ LogAcesso.tabela,
47
+ LogAcesso.registro_id,
48
+ LogAcesso.data_hora
49
+ )
50
+ .join(Usuario, Usuario.usuario == LogAcesso.usuario)
51
+ .order_by(LogAcesso.data_hora.desc())
52
+ )
53
+
54
+ if perfil_selecionado != "Todos":
55
+ query = query.filter(Usuario.perfil == perfil_selecionado)
56
+
57
+ logs = query.all()
58
+
59
+ if not logs:
60
+ st.info("Nenhum registro encontrado.")
61
+ return
62
+
63
+ # =========================
64
+ # DATAFRAME FORMATADO
65
+ # =========================
66
+ dados = []
67
+ for l in logs:
68
+ # l = (usuario, perfil, email, acao, tabela, registro_id, data_hora)
69
+ dados.append({
70
+ "Usuário": l[0],
71
+ "Perfil": l[1],
72
+ "E-mail": l[2] or "—", # ✅ e-mail pode ser nulo
73
+ "Ação": l[3],
74
+ "Tabela": l[4],
75
+ "Registro": l[5],
76
+ "Data": l[6].strftime("%d/%m/%Y"),
77
+ "Hora": l[6].strftime("%H:%M:%S"),
78
+ })
79
+
80
+ df = pd.DataFrame(dados)
81
+
82
+ st.dataframe(df, use_container_width=True)
83
+
84
+ # =========================
85
+ # EXPORTAÇÃO PARA EXCEL
86
+ # =========================
87
+ buffer = BytesIO()
88
+ with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
89
+ df.to_excel(writer, index=False, sheet_name="Auditoria")
90
+
91
+ st.download_button(
92
+ label="📥 Exportar Auditoria para Excel",
93
+ data=buffer.getvalue(),
94
+ file_name="auditoria_sistema.xlsx",
95
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
96
+ )
97
+
98
+ finally:
99
+ db.close()
100
+
auditoria_cleanup.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ from datetime import datetime, timedelta
4
+ from banco import SessionLocal
5
+ from models import LogAcesso # ✅ usar a tabela correta
6
+ from utils_auditoria import registrar_log
7
+
8
+
9
+ def main():
10
+ st.title("🧹 Limpeza de Logs de Auditoria")
11
+
12
+ st.markdown(
13
+ """
14
+ Este módulo permite excluir registros antigos de auditoria
15
+ para suavizar o banco de dados.
16
+ """
17
+ )
18
+
19
+ opcoes = {
20
+ "Último mês": 30,
21
+ "Últimos 2 meses": 60,
22
+ "Últimos 6 meses": 180,
23
+ "Últimos 12 meses": 365,
24
+ "Personalizado": None,
25
+ }
26
+
27
+ escolha = st.selectbox("📅 Escolha o período:", list(opcoes.keys()))
28
+
29
+ data_inicio = None
30
+ data_fim = datetime.now()
31
+
32
+ if escolha != "Personalizado":
33
+ dias = opcoes[escolha]
34
+ data_inicio = datetime.now() - timedelta(days=dias)
35
+ else:
36
+ col1, col2 = st.columns(2)
37
+ with col1:
38
+ data_inicio = st.date_input("Data inicial")
39
+ with col2:
40
+ data_fim = st.date_input("Data final")
41
+ data_inicio = datetime.combine(data_inicio, datetime.min.time())
42
+ data_fim = datetime.combine(data_fim, datetime.max.time())
43
+
44
+ st.info(f"🗓️ Registros de auditoria entre {data_inicio.date()} e {data_fim.date()} serão excluídos.")
45
+
46
+ st.divider()
47
+
48
+ # ✅ Prévia do total (opcional, ajuda na decisão)
49
+ with SessionLocal() as db:
50
+ total_prev = (
51
+ db.query(LogAcesso)
52
+ .filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim)
53
+ .count()
54
+ )
55
+ st.info(f"🔎 Prévia: {total_prev} registro(s) serão removidos no período selecionado.")
56
+
57
+ # ✅ Etapa de confirmação via caixa de seleção
58
+ st.warning(
59
+ "⚠️ **Atenção:** Todos os registros de auditoria no período selecionado serão apagados.\n\n"
60
+ "Confirme abaixo para prosseguir."
61
+ )
62
+ confirmacao = st.selectbox("Confirmar exclusão?", ["Não", "SIM"], index=0)
63
+
64
+ # Botão de exclusão (só prossegue se confirmação for SIM)
65
+ if st.button("❌ Excluir registros de auditoria"):
66
+ if confirmacao != "SIM":
67
+ st.error("Operação cancelada. Se desejar prosseguir, selecione **SIM** na confirmação.")
68
+ return
69
+
70
+ with SessionLocal() as db:
71
+ try:
72
+ registros = (
73
+ db.query(LogAcesso)
74
+ .filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim)
75
+ .all()
76
+ )
77
+
78
+ total = len(registros)
79
+
80
+ if total == 0:
81
+ st.warning("Nenhum registro encontrado para exclusão.")
82
+ return
83
+
84
+ for r in registros:
85
+ db.delete(r)
86
+
87
+ db.commit()
88
+
89
+ registrar_log(
90
+ usuario=st.session_state.get("usuario"),
91
+ acao=f"Excluiu {total} registros de auditoria entre {data_inicio.date()} e {data_fim.date()}",
92
+ tabela="log_acesso",
93
+ registro_id=None
94
+ )
95
+
96
+ st.success(f"🎉 {total} registro(s) de auditoria foram excluídos com sucesso!")
97
+
98
+ except Exception as e:
99
+ db.rollback()
100
+ st.error(f"❌ Erro ao excluir registros: {e}")
101
+
102
+
103
+
auto_capture.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ auto_capture.py — Captura screenshots de todas as telas do app Streamlit e monta um PPTX.
5
+
6
+ Recursos:
7
+ • Login automático (usuário/senha + escolha do banco)
8
+ • Bypass do Quiz (clica: “Voltar ao sistema”, “Finalizar”, “Continuar”, se visível)
9
+ • Seletores robustos para st.selectbox (procura pelo label visível)
10
+ • Captura pós-login/pós-quiz, por grupo e por módulo
11
+ • Artefatos de debug (HTML + PNG) quando algo falha
12
+ • Sanitização de nomes de arquivo (compatível com Windows)
13
+ • Geração de PPTX com um slide por módulo capturado
14
+
15
+ Requisitos:
16
+ pip install playwright python-pptx python-dotenv
17
+ playwright install
18
+ """
19
+
20
+ import os
21
+ import re
22
+ import traceback
23
+ from datetime import datetime
24
+ from dotenv import load_dotenv
25
+
26
+ # Carrega .env
27
+ load_dotenv()
28
+
29
+ APP_URL = os.getenv("APP_URL", "http://localhost:8501")
30
+ LOGIN_USER = os.getenv("LOGIN_USER", "admin")
31
+ LOGIN_PASS = os.getenv("LOGIN_PASS", "admin123")
32
+ BANK_CHOICE = os.getenv("BANK_CHOICE", "prod") # prod | test | treinamento
33
+
34
+ SCREEN_DIR = os.getenv("SCREEN_DIR", "./screenshots")
35
+ OUTPUT_PPTX = os.getenv("OUTPUT_PPTX", "./demo_funcionalidades.pptx")
36
+
37
+ HEADLESS = os.getenv("AUTOCAPTURE_HEADLESS", "false").lower() == "true"
38
+ VIEWPORT_W = int(os.getenv("AUTOCAPTURE_VIEWPORT_W", "1440"))
39
+ VIEWPORT_H = int(os.getenv("AUTOCAPTURE_VIEWPORT_H", "900"))
40
+
41
+ # Importa seu mapa de módulos (aproveita rótulos e grupos)
42
+ try:
43
+ from modules_map import MODULES
44
+ except Exception:
45
+ MODULES = {}
46
+ print("⚠️ Não consegui importar modules_map.py. Ele deve estar no mesmo diretório do script.")
47
+
48
+ # PowerPoint
49
+ from pptx import Presentation
50
+ from pptx.util import Inches, Pt
51
+ from pptx.dml.color import RGBColor
52
+
53
+ # Playwright
54
+ from playwright.sync_api import sync_playwright
55
+
56
+
57
+ # -----------------------------------------------------------------------------
58
+ # Helpers
59
+ # -----------------------------------------------------------------------------
60
+ def ensure_dir(path: str):
61
+ os.makedirs(path, exist_ok=True)
62
+
63
+ def sanitize(s: str) -> str:
64
+ """Remove/normaliza caracteres inválidos de nomes (Windows-safe)."""
65
+ s = re.sub(r"[\\/:*?\"<>|]", "_", s) # remove proibidos
66
+ s = re.sub(r"\s+", "_", s.strip()) # espaços -> _
67
+ return s
68
+
69
+ def bank_label(choice: str) -> str:
70
+ return {
71
+ "prod": "Banco 1 (📗 Produção)",
72
+ "test": "Banco 2 (📕 Teste)",
73
+ "treinamento": "Banco 3 (📘 Treinamento)",
74
+ }.get(choice, choice)
75
+
76
+ def save_artifacts_on_fail(page, tag="fail"):
77
+ """Salva HTML e screenshot quando algo dá errado."""
78
+ ensure_dir(SCREEN_DIR)
79
+ tag = sanitize(tag)
80
+ try:
81
+ html_path = os.path.join(SCREEN_DIR, f"{tag}_page.html")
82
+ img_path = os.path.join(SCREEN_DIR, f"{tag}_page.png")
83
+ with open(html_path, "w", encoding="utf-8") as f:
84
+ f.write(page.content())
85
+ page.screenshot(path=img_path, full_page=True)
86
+ print(f"📝 Artefatos salvos: {html_path}, {img_path}")
87
+ except Exception as e:
88
+ print(f"⚠️ Falha ao salvar artefatos de erro: {e}")
89
+
90
+ def select_by_label(page, select_label: str, option_text: str):
91
+ """
92
+ Seleciona uma opção em um st.selectbox, procurando pelo label (texto visível).
93
+ • Varre todos os elementos com data-testid="stSelectbox"
94
+ • Encontra o que contém o label desejado (case-insensitive)
95
+ • Abre o combobox e clica na opção exata
96
+ """
97
+ boxes = page.locator('[data-testid="stSelectbox"]')
98
+ count = boxes.count()
99
+ if count == 0:
100
+ raise RuntimeError("Nenhum stSelectbox encontrado na página.")
101
+
102
+ found = False
103
+ for i in range(count):
104
+ box = boxes.nth(i)
105
+ try:
106
+ txt = box.inner_text().strip()
107
+ except Exception:
108
+ continue
109
+ if select_label.lower() in txt.lower():
110
+ box.locator('div[role="combobox"]').first.click()
111
+ page.locator('div[role="listbox"]').get_by_text(option_text, exact=True).click()
112
+ found = True
113
+ break
114
+
115
+ if not found:
116
+ raise RuntimeError(f"Selectbox com label '{select_label}' não encontrado.")
117
+
118
+ def bypass_quiz(page):
119
+ """
120
+ Tenta sair da tela de Quiz, caso esteja bloqueando a navegação.
121
+ Procura ações típicas: 'Voltar ao sistema', 'Finalizar', 'Continuar'.
122
+ """
123
+ # 1) Voltar ao sistema
124
+ try:
125
+ if page.get_by_text("Voltar ao sistema").count() > 0:
126
+ page.get_by_text("Voltar ao sistema").click()
127
+ page.wait_for_timeout(600)
128
+ return
129
+ except Exception:
130
+ pass
131
+
132
+ # 2) Finalizar
133
+ try:
134
+ if page.get_by_role("button", name="Finalizar").count() > 0:
135
+ page.get_by_role("button", name="Finalizar").click()
136
+ page.wait_for_timeout(600)
137
+ return
138
+ except Exception:
139
+ pass
140
+
141
+ # 3) Continuar
142
+ try:
143
+ if page.get_by_role("button", name="Continuar").count() > 0:
144
+ page.get_by_role("button", name="Continuar").click()
145
+ page.wait_for_timeout(600)
146
+ return
147
+ except Exception:
148
+ pass
149
+
150
+ # 4) Se nada funcionar, salva artefatos para analisarmos o DOM real
151
+ save_artifacts_on_fail(page, "quiz_bypass")
152
+
153
+
154
+ def do_login(page):
155
+ page.goto(APP_URL, timeout=60000)
156
+ page.wait_for_load_state("networkidle")
157
+ page.wait_for_timeout(800)
158
+
159
+ # Seleciona Banco (selectbox "Usar banco:")
160
+ try:
161
+ select_by_label(page, "Usar banco:", bank_label(BANK_CHOICE))
162
+ except Exception as e:
163
+ print(f"⚠️ Falha ao selecionar banco: {e}")
164
+ save_artifacts_on_fail(page, "select_bank")
165
+ # Fallback por texto simples (última tentativa)
166
+ try:
167
+ page.get_by_text("Usar banco:").click()
168
+ page.get_by_text(bank_label(BANK_CHOICE), exact=True).click()
169
+ except Exception:
170
+ pass
171
+
172
+ # Preenche credenciais
173
+ try:
174
+ page.get_by_label("Usuário").fill(LOGIN_USER)
175
+ except Exception:
176
+ page.locator('label:has-text("Usuário")').locator("xpath=..").locator('input').fill(LOGIN_USER)
177
+
178
+ try:
179
+ page.get_by_label("Senha").fill(LOGIN_PASS)
180
+ except Exception:
181
+ page.locator('label:has-text("Senha")').locator("xpath=..").locator('input').fill(LOGIN_PASS)
182
+
183
+ # Entrar
184
+ try:
185
+ page.get_by_role("button", name="Entrar").click()
186
+ except Exception:
187
+ page.get_by_text("Entrar").click()
188
+
189
+ page.wait_for_load_state("networkidle")
190
+ page.wait_for_timeout(1000)
191
+
192
+ # Captura pós-login
193
+ page.screenshot(path=os.path.join(SCREEN_DIR, "00_pos_login.png"), full_page=True)
194
+
195
+ # Bypass do Quiz (se existir)
196
+ bypass_quiz(page)
197
+
198
+ # Captura pós-quiz
199
+ page.screenshot(path=os.path.join(SCREEN_DIR, "01_pos_quiz.png"), full_page=True)
200
+
201
+
202
+ def clear_search(page):
203
+ """Limpa campo 'Pesquisar módulo:' para não filtrar nada (opcional)."""
204
+ try:
205
+ page.get_by_label("Pesquisar módulo:").fill("")
206
+ page.wait_for_timeout(200)
207
+ except Exception:
208
+ # Fallback: tenta input na sidebar
209
+ try:
210
+ sb = page.locator('[data-testid="stSidebar"]').first
211
+ sb.locator('input').first.fill("")
212
+ except Exception:
213
+ pass
214
+
215
+
216
+ def capture_all_screens():
217
+ ensure_dir(SCREEN_DIR)
218
+ screenshots = []
219
+
220
+ from playwright.sync_api import TimeoutError
221
+
222
+ with sync_playwright() as pw:
223
+ browser = pw.chromium.launch(headless=HEADLESS)
224
+ context = browser.new_context(viewport={"width": VIEWPORT_W, "height": VIEWPORT_H})
225
+ page = context.new_page()
226
+
227
+ # Login
228
+ do_login(page)
229
+
230
+ # Grupos
231
+ grupos = sorted({MODULES[mid].get("grupo", "Outros") for mid in MODULES}) if MODULES else []
232
+ if not grupos:
233
+ print("⚠️ MODULES está vazio. Não há módulos para capturar.")
234
+ save_artifacts_on_fail(page, "no_modules")
235
+ context.close(); browser.close()
236
+ return screenshots
237
+
238
+ for grupo in grupos:
239
+ try:
240
+ clear_search(page)
241
+ select_by_label(page, "Selecione a operação:", grupo)
242
+ page.wait_for_timeout(500)
243
+
244
+ gshot = os.path.join(SCREEN_DIR, f"{BANK_CHOICE}_grupo_{sanitize(grupo)}.png")
245
+ page.screenshot(path=gshot, full_page=True)
246
+ print(f"📸 Grupo: {grupo} → {gshot}")
247
+ except Exception as e:
248
+ print(f"⚠️ Falha ao selecionar grupo '{grupo}': {e}")
249
+ save_artifacts_on_fail(page, f"grupo_{grupo}")
250
+ continue
251
+
252
+ # Módulos do grupo
253
+ mod_ids = [mid for mid in MODULES if MODULES[mid].get("grupo", "Outros") == grupo]
254
+ for mid in mod_ids:
255
+ label = MODULES[mid].get("label", mid)
256
+ try:
257
+ select_by_label(page, "Selecione o módulo:", label)
258
+ page.wait_for_load_state("networkidle")
259
+ page.wait_for_timeout(800)
260
+
261
+ fname = f"{BANK_CHOICE}_{sanitize(grupo)}_{sanitize(mid)}.png"
262
+ fpath = os.path.join(SCREEN_DIR, fname)
263
+ page.screenshot(path=fpath, full_page=True)
264
+ screenshots.append((mid, label, grupo, fpath))
265
+ print(f"📸 Módulo: {label} → {fpath}")
266
+ except Exception as e:
267
+ print(f"❌ Falha ao capturar módulo '{label}': {e}")
268
+ save_artifacts_on_fail(page, f"mod_{mid}")
269
+ traceback.print_exc()
270
+ continue
271
+
272
+ context.close()
273
+ browser.close()
274
+
275
+ return screenshots
276
+
277
+
278
+ def build_pptx(screens, out_path):
279
+ prs = Presentation()
280
+
281
+ # Slide de título
282
+ slide = prs.slides.add_slide(prs.slide_layouts[0])
283
+ slide.shapes.title.text = "Apresentação do Sistema (ARM LoadApp)"
284
+ subtitle = slide.placeholders[1].text_frame
285
+ subtitle.clear()
286
+ p = subtitle.paragraphs[0]
287
+ p.text = f"Ambiente: {bank_label(BANK_CHOICE)} | Gerado em {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}"
288
+ p.font.size = Pt(14)
289
+
290
+ # Slides por módulo
291
+ for mid, label, grupo, fpath in screens:
292
+ layout = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only
293
+ layout.shapes.title.text = f"{label} • {grupo}"
294
+ left, top, width = Inches(0.5), Inches(1.2), Inches(9)
295
+ try:
296
+ layout.shapes.add_picture(fpath, left, top, width=width)
297
+ except Exception:
298
+ tx = layout.shapes.add_textbox(left, top, Inches(9), Inches(1))
299
+ tf = tx.text_frame
300
+ tf.text = f"(Falha ao inserir imagem: {os.path.basename(fpath)})"
301
+ tf.paragraphs[0].font.color.rgb = RGBColor(200, 0, 0)
302
+
303
+ prs.save(out_path)
304
+ print(f"🎉 PPTX gerado: {out_path}")
305
+
306
+
307
+ def main():
308
+ print(f"🚀 Captura em {APP_URL} | Banco: {BANK_CHOICE} ({bank_label(BANK_CHOICE)}) | headless={HEADLESS}")
309
+ ensure_dir(SCREEN_DIR)
310
+ screens = capture_all_screens()
311
+ if not screens:
312
+ print("⚠️ Nenhuma captura gerada. Veja os artefatos na pasta e revise seletores/menus.")
313
+ return
314
+ build_pptx(screens, OUTPUT_PPTX)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
319
+
banco.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker, declarative_base
4
+ import os
5
+ from dotenv import load_dotenv
6
+ import importlib
7
+
8
+ # 🔒 Caminho absoluto do projeto
9
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
10
+
11
+ # Carrega variáveis de ambiente (.env) antes de ler DATABASE_URL
12
+ load_dotenv()
13
+
14
+ # ============================================================
15
+ # 🔀 SUPORTE A DOIS BANCOS (Produção/Teste) COM FALLBACK
16
+ # ============================================================
17
+ # Tentamos usar o roteador (db_router.py). Se não existir ainda,
18
+ # caímos no comportamento original usando apenas DATABASE_URL.
19
+ try:
20
+ from db_router import (
21
+ get_engine as _router_get_engine,
22
+ get_session_factory as _router_get_session_factory,
23
+ SessionLocal as _router_SessionLocal,
24
+ )
25
+ _HAS_ROUTER = True
26
+ except Exception:
27
+ _HAS_ROUTER = False
28
+
29
+ # 🔧 Fallback: mesma lógica do seu módulo original — um único DATABASE_URL
30
+ DATABASE_URL = os.getenv(
31
+ "DATABASE_URL",
32
+ f"sqlite:///{os.path.join(BASE_DIR, 'load.db')}"
33
+ )
34
+
35
+ engine_args = {
36
+ "echo": False,
37
+ "pool_pre_ping": True,
38
+ }
39
+
40
+ # Parâmetros específicos para SQLite (apenas se o fallback estiver ativo)
41
+ if DATABASE_URL.startswith("sqlite"):
42
+ engine_args["connect_args"] = {"check_same_thread": False}
43
+
44
+ # ============================================================
45
+ # Engine / SessionLocal (com ou sem roteador)
46
+ # ============================================================
47
+ if _HAS_ROUTER:
48
+ # ✅ Usa engine e SessionLocal do banco ATIVO (Produção/Teste), conforme escolha no login
49
+ def get_engine():
50
+ return _router_get_engine()
51
+
52
+ def _session_factory():
53
+ return _router_get_session_factory()
54
+
55
+ # A SessionLocal do roteador já entrega sessões no banco ativo
56
+ SessionLocal = _router_SessionLocal
57
+
58
+ else:
59
+ # ✅ Fallback: comportamento original com DATABASE_URL único
60
+ _engine = create_engine(DATABASE_URL, **engine_args)
61
+
62
+ def get_engine():
63
+ return _engine
64
+
65
+ _SessionFactory = sessionmaker(
66
+ autocommit=False,
67
+ autoflush=False,
68
+ bind=_engine,
69
+ )
70
+
71
+ def _session_factory():
72
+ return _SessionFactory
73
+
74
+ # Compatível com seu uso atual: SessionLocal() -> sessão
75
+ SessionLocal = _SessionFactory
76
+
77
+ # ⚠️ Compatibilidade: expõe 'engine' resolvendo via get_engine()
78
+ # Observação importante:
79
+ # - Se trocar o banco após a importação deste módulo (via login),
80
+ # prefira sempre chamar get_engine() ou criar sessões com SessionLocal(),
81
+ # pois 'engine' abaixo é resolvido apenas uma vez (na importação).
82
+ engine = get_engine()
83
+
84
+ # ORM Base
85
+ Base = declarative_base()
86
+
87
+ # ============================================================
88
+ # 🛠️ Utilitários (opcionais)
89
+ # ============================================================
90
+ def init_schema():
91
+ """
92
+ Cria/atualiza as tabelas no banco ATIVO.
93
+ • Com roteador: aplica no banco escolhido (Produção/Teste).
94
+ • Sem roteador: aplica no DATABASE_URL padrão.
95
+ Use em DEV/TESTE; em produção, prefira migrações (ex.: Alembic).
96
+ """
97
+ # Importa 'models' de forma tardia e segura (sem wildcard) para registrar todos os mapeamentos
98
+ # antes de criar as tabelas. Isso evita import circular no topo.
99
+ try:
100
+ importlib.import_module("models")
101
+ except ModuleNotFoundError:
102
+ # Se seus modelos estiverem em outro pacote/caminho, ajuste aqui:
103
+ # importlib.import_module("app.models") # exemplo
104
+ raise
105
+
106
+ Base.metadata.create_all(bind=get_engine())
107
+
108
+ def db_info() -> dict:
109
+ """
110
+ Retorna informações básicas do banco ativo (para debug/UX).
111
+ """
112
+ eng = get_engine()
113
+ try:
114
+ url = str(eng.url)
115
+ except Exception:
116
+ url = DATABASE_URL
117
+ return {
118
+ "url": url,
119
+ "using_router": _HAS_ROUTER,
120
+ }
121
+
bi.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import streamlit.components.v1 as components
3
+ from banco import SessionLocal
4
+ from models import LogAcesso
5
+ from datetime import datetime
6
+
7
+
8
+ # =====================================================
9
+ # AUDITORIA
10
+ # =====================================================
11
+ def registrar_auditoria(acao):
12
+ db = SessionLocal()
13
+ try:
14
+ db.add(LogAcesso(
15
+ usuario=st.session_state.get("usuario", "desconhecido"),
16
+ acao=acao,
17
+ tabela="bi",
18
+ data_hora=datetime.now()
19
+ ))
20
+ db.commit()
21
+ finally:
22
+ db.close()
23
+
24
+
25
+ # =====================================================
26
+ # APP PRINCIPAL
27
+ # =====================================================
28
+ def main():
29
+ st.title("📊 Business Intelligence")
30
+
31
+ st.caption("Indicadores e dashboards oficiais")
32
+
33
+ # 🔐 Auditoria de acesso
34
+ registrar_auditoria("ACESSO_BI")
35
+
36
+ # =====================================================
37
+ # SELEÇÃO DE DASHBOARD
38
+ # =====================================================
39
+ dashboards = {
40
+ "📈 Performance Operacional": {
41
+ "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
42
+ "height": 800
43
+ },
44
+ "📊 Qualidade e Erros": {
45
+ "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
46
+ "height": 800
47
+ },
48
+ "📦 Produtividade FPSO": {
49
+ "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
50
+ "height": 900
51
+ }
52
+ }
53
+
54
+ opcao = st.selectbox("Selecione o Dashboard", dashboards.keys())
55
+
56
+ dash = dashboards[opcao]
57
+
58
+ st.divider()
59
+
60
+ # =====================================================
61
+ # EMBED DO POWER BI
62
+ # =====================================================
63
+ components.html(
64
+ f"""
65
+ <iframe
66
+ width="100%"
67
+ height="{dash['height']}"
68
+ src="{dash['url']}"
69
+ frameborder="0"
70
+ allowfullscreen="true">
71
+ </iframe>
72
+ """,
73
+ height=dash["height"] + 20
74
+ )
cadastro_py.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from banco import SessionLocal
2
+ from models import FPSO
3
+
4
+ FPSO_PADRAO = [
5
+ "CDA", "CDP", "CDM", "ADG",
6
+ "ESS", "SEP", "CDI", "ATD", "CDS"
7
+ ]
8
+
9
+ def main():
10
+ db = SessionLocal()
11
+ try:
12
+ existentes = {f.nome for f in db.query(FPSO).all()}
13
+
14
+ for nome in FPSO_PADRAO:
15
+ if nome not in existentes:
16
+ db.add(FPSO(nome=nome))
17
+
18
+ db.commit()
19
+ print("✅ FPSOs padrão inseridos com sucesso!")
20
+
21
+ finally:
22
+ db.close()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
27
+
28
+
calendario.py ADDED
@@ -0,0 +1,708 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ from datetime import date, datetime, timedelta
5
+ from typing import Dict, List
6
+ from banco import SessionLocal
7
+ from models import EventoCalendario
8
+ from utils_permissoes import verificar_permissao
9
+ from log import registrar_log
10
+ from utils_datas import formatar_data_br
11
+
12
+ # ⬇️ Componente de calendário
13
+ from streamlit_calendar import calendar
14
+
15
+ # =====================================================
16
+ # 📅 CALENDÁRIO + CRONOGRAMA ANUAL (D-3 / D-2 / D-1 / D 🚢)
17
+ # =====================================================
18
+
19
+ # ------------------------------
20
+ # ⚙️ Regras de embarque (fase/seed e passo)
21
+ # ------------------------------
22
+ # seed_day = dia (de Janeiro) usado como "D" inicial para o ano selecionado
23
+ # step = dias entre embarques (D → próximo D)
24
+ REGRAS_FPSO = {
25
+ "ATD": {"seed_day": 1, "step": 5},
26
+ "ADG": {"seed_day": 1, "step": 5},
27
+ "CDM": {"seed_day": 2, "step": 5},
28
+ "CDP": {"seed_day": 2, "step": 5},
29
+ "CDS": {"seed_day": 2, "step": 5},
30
+ "CDI": {"seed_day": 5, "step": 5}, # (ACDI → CDI)
31
+ "CDA": {"seed_day": 5, "step": 5},
32
+ "SEP": {"seed_day": 4, "step": 4}, # sem dia vazio
33
+ "ESS": {"seed_day": 3, "step": 7}, # blocos com pausa maior
34
+ }
35
+
36
+ # 🎨 Paleta
37
+ COLOR_MAP = {
38
+ "D-3": "#00B050", # verde
39
+ "D-2": "#FF0000", # vermelho
40
+ "D-1": "#C00000", # vermelho escuro
41
+ "D": "#7F7F7F", # cinza
42
+ }
43
+
44
+ EMOJI_NAVIO = " 🚢" # adicionado aos títulos no dia D
45
+
46
+
47
+ def _usuario_atual() -> str:
48
+ return (st.session_state.get("usuario") or "sistema")
49
+
50
+
51
+ def _criar_evento_fc(title: str, dt: date, color: str, extra: Dict = None) -> dict:
52
+ """Monta um evento no formato FullCalendar/streamlit_calendar."""
53
+ ev = {
54
+ "id": f"auto::{title}::{dt.isoformat()}",
55
+ "title": title,
56
+ "start": dt.isoformat(),
57
+ "allDay": True,
58
+ "color": color,
59
+ "extendedProps": {"gerado_auto": True},
60
+ }
61
+ if extra:
62
+ ev["extendedProps"].update(extra)
63
+ return ev
64
+
65
+
66
+ def _rotulo_antes_de_d(dias: int) -> str:
67
+ """Converte o deslocamento até D para rótulo: 0->D, 1->D-1, 2->D-2, 3->D-3, outros->''"""
68
+ if dias == 0:
69
+ return "D"
70
+ if dias in (1, 2, 3):
71
+ return f"D-{dias}"
72
+ return ""
73
+
74
+
75
+ def _gerar_cronograma_ano(
76
+ ano: int,
77
+ fpsos_sel: List[str],
78
+ incluir_anteriores: bool = True,
79
+ apenas_D: bool = False,
80
+ ) -> List[dict]:
81
+ """
82
+ Gera eventos 'D-3/D-2/D-1/D 🚢' para TODO o ano.
83
+ - incluir_anteriores: inclui D-1..D-3 que caem no começo do ano (vindo do D-semente).
84
+ - apenas_D: se True, somente 'D 🚢'.
85
+ """
86
+ events = []
87
+ dt_ini = date(ano, 1, 1)
88
+ dt_fim = date(ano, 12, 31)
89
+
90
+ for fpso in fpsos_sel:
91
+ cfg = REGRAS_FPSO.get(fpso)
92
+ if not cfg:
93
+ continue
94
+ seed_day = max(1, min(cfg["seed_day"], 28)) # segurança (fev)
95
+ seed = date(ano, 1, seed_day)
96
+ step = int(cfg["step"])
97
+
98
+ # Todos os D do ano
99
+ d = seed
100
+ while d <= dt_fim:
101
+ if d >= dt_ini:
102
+ # D (com emoji) + cor
103
+ titulo_d = f"{fpso} – D{EMOJI_NAVIO}"
104
+ events.append(
105
+ _criar_evento_fc(
106
+ titulo_d, d, COLOR_MAP["D"],
107
+ {"tipo": "D", "fpso": fpso}
108
+ )
109
+ )
110
+ if not apenas_D:
111
+ # D-1..D-3
112
+ for k in (1, 2, 3):
113
+ dk = d - timedelta(days=k)
114
+ if dt_ini <= dk <= dt_fim:
115
+ label = f"D-{k}"
116
+ events.append(
117
+ _criar_evento_fc(
118
+ f"{fpso} – {label}",
119
+ dk,
120
+ COLOR_MAP[label],
121
+ {"tipo": label, "fpso": fpso},
122
+ )
123
+ )
124
+ d += timedelta(days=step)
125
+
126
+ # Cobertura no início do ano (apenas rótulos anteriores ao D-semente)
127
+ if incluir_anteriores and not apenas_D:
128
+ for k in (1, 2, 3):
129
+ dk = seed - timedelta(days=k)
130
+ if dt_ini <= dk <= dt_fim:
131
+ label = f"D-{k}"
132
+ events.append(
133
+ _criar_evento_fc(
134
+ f"{fpso} – {label}",
135
+ dk,
136
+ COLOR_MAP[label],
137
+ {"tipo": label, "fpso": fpso},
138
+ )
139
+ )
140
+ return events
141
+
142
+
143
+ def _gerar_cronograma_intervalo(
144
+ ano_ini: int,
145
+ ano_fim: int,
146
+ fpsos_sel: List[str],
147
+ apenas_D: bool = False,
148
+ ) -> List[dict]:
149
+ """Gera eventos para [ano_ini..ano_fim]."""
150
+ out = []
151
+ for y in range(ano_ini, ano_fim + 1):
152
+ out.extend(_gerar_cronograma_ano(y, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D))
153
+ return out
154
+
155
+
156
+ def _titulo_normalizado(titulo: str) -> str:
157
+ """Remove o emoji ' 🚢' apenas para comparação/deduplicação."""
158
+ return titulo.replace(EMOJI_NAVIO, "")
159
+
160
+
161
+ def _dedup_chave(titulo: str, data_evt: date) -> str:
162
+ """Chave de de-duplicação (título normalizado + data)."""
163
+ return f"{_titulo_normalizado(titulo)}::{data_evt.isoformat()}"
164
+
165
+
166
+ def _gravar_cronograma_no_banco(db, eventos_fc: List[dict]) -> int:
167
+ """
168
+ Grava no banco eventos 'gerado_auto' evitando duplicados (ignorando emoji).
169
+ Retorna contagem de inserções.
170
+ """
171
+ # Pré-carregar existentes no intervalo abrangido
172
+ if not eventos_fc:
173
+ return 0
174
+ min_day = min(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
175
+ max_day = max(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
176
+
177
+ existentes = (
178
+ db.query(EventoCalendario)
179
+ .filter(EventoCalendario.data_evento >= min_day)
180
+ .filter(EventoCalendario.data_evento <= max_day)
181
+ .filter(EventoCalendario.ativo.is_(True))
182
+ .all()
183
+ )
184
+ idx_existentes = {
185
+ _dedup_chave(e.titulo, e.data_evento): e.id for e in existentes
186
+ }
187
+
188
+ ins = 0
189
+ for ev in eventos_fc:
190
+ if not ev.get("extendedProps", {}).get("gerado_auto"):
191
+ continue
192
+ titulo = ev["title"]
193
+ dt = date.fromisoformat(ev["start"][:10])
194
+ k = _dedup_chave(titulo, dt)
195
+ if k in idx_existentes:
196
+ continue
197
+ novo = EventoCalendario(
198
+ titulo=titulo, # mantém o emoji nos D
199
+ descricao=f"Cronograma automático ({ev['extendedProps'].get('tipo','')})",
200
+ data_evento=dt,
201
+ data_lembrete=None,
202
+ ativo=True,
203
+ usuario_criacao=_usuario_atual(),
204
+ data_criacao=datetime.now(),
205
+ )
206
+ db.add(novo)
207
+ try:
208
+ db.commit()
209
+ ins += 1
210
+ except Exception:
211
+ db.rollback()
212
+ return ins
213
+
214
+
215
+ def _remover_cronograma_do_banco_intervalo(db, fpsos_sel: List[str], ano_ini: int, ano_fim: int) -> int:
216
+ """
217
+ Remove do banco os eventos gerados por este módulo, para [ano_ini..ano_fim] e FPSOs.
218
+ Busca por títulos ('<FPSO> – D' / ' – D 🚢' / ' – D-1/2/3') e data no intervalo.
219
+ """
220
+ ini = date(ano_ini, 1, 1)
221
+ fim = date(ano_fim, 12, 31)
222
+ total = 0
223
+ for fpso in fpsos_sel:
224
+ base = [f"{fpso} – D", f"{fpso} – D-1", f"{fpso} – D-2", f"{fpso} – D-3"]
225
+ # inclui com emoji para D
226
+ variantes = base + [f"{fpso} – D{EMOJI_NAVIO}"]
227
+ to_del = (
228
+ db.query(EventoCalendario)
229
+ .filter(EventoCalendario.data_evento >= ini)
230
+ .filter(EventoCalendario.data_evento <= fim)
231
+ .filter(EventoCalendario.titulo.in_(variantes))
232
+ .all()
233
+ )
234
+ for e in to_del:
235
+ db.delete(e)
236
+ total += 1
237
+ try:
238
+ db.commit()
239
+ except Exception:
240
+ db.rollback()
241
+ return total
242
+
243
+
244
+ def main():
245
+
246
+ # =====================================================
247
+ # 🔒 PROTEÇÃO POR PERFIL
248
+ # =====================================================
249
+ if not verificar_permissao("calendario"):
250
+ st.error("⛔ Acesso não autorizado.")
251
+ return
252
+
253
+ st.title("📅 Calendário e Lembretes")
254
+
255
+ hoje = date.today()
256
+ db = SessionLocal()
257
+
258
+ # Helper: cor por status (eventos do banco)
259
+ def _cor_evento_db(e: "EventoCalendario") -> str:
260
+ if not e.ativo:
261
+ return "#95a5a6" # Cinza
262
+ if e.data_evento < hoje:
263
+ return "#e74c3c" # Vermelho (passado)
264
+ if e.data_lembrete and e.data_lembrete == hoje:
265
+ return "#f39c12" # Laranja (lembrete hoje)
266
+ return "#2ecc71" # Verde (ativo futuro)
267
+
268
+ # Converte EventoCalendario do banco → FullCalendar
269
+ def _to_fc_event_db(e: "EventoCalendario") -> dict:
270
+ return {
271
+ "id": str(e.id),
272
+ "title": e.titulo,
273
+ "start": e.data_evento.isoformat(),
274
+ "allDay": True,
275
+ "color": _cor_evento_db(e),
276
+ "extendedProps": {
277
+ "descricao": (e.descricao or ""),
278
+ "data_evento": e.data_evento.isoformat(),
279
+ "data_lembrete": e.data_lembrete.isoformat() if e.data_lembrete else None,
280
+ "ativo": e.ativo,
281
+ "gerado_auto": False,
282
+ },
283
+ }
284
+
285
+ try:
286
+ # =====================================================
287
+ # 🔔 LEMBRETES DO DIA
288
+ # =====================================================
289
+ st.subheader("⏰ Lembretes de Hoje | Adicione o calendário da sua embarcação")
290
+
291
+ lembretes = (
292
+ db.query(EventoCalendario)
293
+ .filter(EventoCalendario.data_lembrete == hoje)
294
+ .filter(EventoCalendario.ativo.is_(True))
295
+ .order_by(EventoCalendario.data_evento)
296
+ .all()
297
+ )
298
+
299
+ if lembretes:
300
+ for l in lembretes:
301
+ st.warning(f"🔔 **{l.titulo}** — Evento em {formatar_data_br(l.data_evento)}")
302
+ else:
303
+ st.info("Nenhum lembrete para hoje.")
304
+
305
+ st.divider()
306
+
307
+ # =====================================================
308
+ # 🎛️ CONTROLES DO CRONOGRAMA
309
+ # =====================================================
310
+ st.subheader("🛠️ Cronograma de Embarques (D-3 / D-2 / D-1 / D 🚢)")
311
+
312
+ col_a, col_b, col_c = st.columns([1, 2, 2])
313
+ with col_a:
314
+ ano_sel = st.number_input(
315
+ "Ano",
316
+ min_value=2000, max_value=2100,
317
+ value=hoje.year, step=1, key="cal_ano_sel"
318
+ )
319
+
320
+ fpsos_all = list(REGRAS_FPSO.keys())
321
+ with col_b:
322
+ fpsos_sel = st.multiselect(
323
+ "FPSOs",
324
+ options=fpsos_all,
325
+ default=fpsos_all,
326
+ key="cal_fpsos_sel",
327
+ )
328
+ if not fpsos_sel:
329
+ fpsos_sel = fpsos_all
330
+
331
+ with col_c:
332
+ apenas_D = st.checkbox("Exibir apenas dias de Embarque (D)", value=False)
333
+
334
+ # Gera cronograma em memória para o ANO selecionado (visualização)
335
+ eventos_auto = _gerar_cronograma_ano(
336
+ ano_sel, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D
337
+ )
338
+
339
+ # 🔁 Ações de banco: ANO
340
+ col_b1, col_b2, col_b3, col_b4 = st.columns([1.7, 1.7, 2, 2])
341
+ with col_b1:
342
+ if st.button("💾 Gravar cronograma (ano) no banco"):
343
+ qtd = _gravar_cronograma_no_banco(db, eventos_auto)
344
+ if qtd > 0:
345
+ registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None)
346
+ st.success(f"Cronograma do ano {ano_sel} gravado/atualizado. Inserções: {qtd}.")
347
+ st.rerun()
348
+ with col_b2:
349
+ if st.button("🧹 Remover cronograma (ano) do banco"):
350
+ qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, ano_sel)
351
+ if qtd > 0:
352
+ registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None)
353
+ st.warning(f"Eventos removidos do banco (ano {ano_sel}): {qtd}.")
354
+ st.rerun()
355
+
356
+ # 🔁 Ações de banco: INTERVALO ATÉ 2030
357
+ with col_b3:
358
+ if st.button("💾 Gravar cronograma até 2030 (banco)"):
359
+ eventos_lote = _gerar_cronograma_intervalo(
360
+ ano_ini=ano_sel, ano_fim=2030, fpsos_sel=fpsos_sel, apenas_D=apenas_D
361
+ )
362
+ qtd = _gravar_cronograma_no_banco(db, eventos_lote)
363
+ if qtd > 0:
364
+ registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None)
365
+ st.success(f"Cronogramas {ano_sel}–2030 gravados/atualizados. Inserções: {qtd}.")
366
+ st.rerun()
367
+ with col_b4:
368
+ if st.button("🧹 Remover cronograma até 2030 (banco)"):
369
+ qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, 2030)
370
+ if qtd > 0:
371
+ registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None)
372
+ st.warning(f"Eventos removidos do banco ({ano_sel}–2030): {qtd}.")
373
+ st.rerun()
374
+
375
+ st.caption(
376
+ "• A geração automática **não** altera seus eventos manuais. "
377
+ "Use os botões para **gravar** ou **remover** do banco apenas os eventos criados por este módulo. "
378
+ "Nos dias de **D**, o título inclui o ícone de navio (🚢)."
379
+ )
380
+
381
+ st.divider()
382
+
383
+ # =====================================================
384
+ # ➕ NOVO EVENTO / LEMBRETE (manual)
385
+ # =====================================================
386
+ with st.expander("➕ Novo Evento / Lembrete"):
387
+ with st.form("form_evento"):
388
+ titulo = st.text_input("Título *")
389
+ descricao = st.text_area("Descrição")
390
+ data_evento = st.date_input("Data do Evento", value=hoje, format="DD/MM/YYYY")
391
+ data_lembrete = st.date_input("Data do Lembrete (opcional)", value=None, format="DD/MM/YYYY")
392
+ ativo = st.checkbox("Evento ativo", value=True)
393
+ salvar = st.form_submit_button("💾 Salvar Evento")
394
+
395
+ if salvar:
396
+ if not titulo.strip():
397
+ st.error("⚠️ O título é obrigatório.")
398
+ elif data_lembrete and (data_lembrete > data_evento):
399
+ st.error("⚠️ O lembrete não pode ser após a data do evento.")
400
+ else:
401
+ evento = EventoCalendario(
402
+ titulo=titulo.strip(),
403
+ descricao=(descricao or "").strip(),
404
+ data_evento=data_evento,
405
+ data_lembrete=data_lembrete,
406
+ ativo=ativo,
407
+ usuario_criacao=_usuario_atual(),
408
+ data_criacao=datetime.now()
409
+ )
410
+ db.add(evento)
411
+ try:
412
+ db.commit()
413
+ except Exception as e:
414
+ db.rollback()
415
+ st.error(f"❌ Erro ao salvar evento: {e}")
416
+ else:
417
+ registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", evento.id)
418
+ st.success("✅ Evento criado com sucesso!")
419
+ st.rerun()
420
+
421
+ st.divider()
422
+
423
+ # =====================================================
424
+ # 📆 CALENDÁRIO (eventos do banco + cronograma do ANO selecionado)
425
+ # =====================================================
426
+ st.subheader("📆 Calendário (clique no dia ou no evento para ver a observação)")
427
+
428
+ # Banco (apenas ano selecionado na visualização)
429
+ ini_year = date(ano_sel, 1, 1)
430
+ end_year = date(ano_sel, 12, 31)
431
+ eventos_db = (
432
+ db.query(EventoCalendario)
433
+ .filter(EventoCalendario.data_evento >= ini_year)
434
+ .filter(EventoCalendario.data_evento <= end_year)
435
+ .order_by(EventoCalendario.data_evento.asc())
436
+ .all()
437
+ )
438
+ eventos_fc_db = [_to_fc_event_db(e) for e in eventos_db]
439
+
440
+ # Junta cronograma automático (memória) + banco (para a visualização do ano)
441
+ eventos_fc = eventos_fc_db + eventos_auto
442
+
443
+ options = {
444
+ "initialView": "dayGridMonth",
445
+ "locale": "pt-br",
446
+ "height": 700,
447
+ "firstDay": 1,
448
+ "weekNumbers": False,
449
+ "headerToolbar": {
450
+ "left": "prev,next today",
451
+ "center": "title",
452
+ "right": "dayGridMonth,dayGridWeek,listWeek"
453
+ },
454
+ "buttonText": {
455
+ "today": "Hoje",
456
+ "month": "Mês",
457
+ "week": "Semana",
458
+ "day": "Dia",
459
+ "list": "Lista"
460
+ },
461
+ "dayMaxEventRows": True,
462
+ "navLinks": True,
463
+ }
464
+
465
+ state = calendar(
466
+ events=eventos_fc,
467
+ options=options,
468
+ custom_css="",
469
+ key=f"calendario_eventos_{ano_sel}"
470
+ )
471
+
472
+ # Legenda
473
+ with st.container():
474
+ cols = st.columns([1.2, 1.2, 1.2, 1.2, 2.2, 3])
475
+ cols[0].markdown("⬛ **D** (cinza) " + EMOJI_NAVIO)
476
+ cols[1].markdown("🟥 **D‑1** (vinho)")
477
+ cols[2].markdown("🟥 **D‑2** (vermelho)")
478
+ cols[3].markdown("🟩 **D‑3** (verde)")
479
+ cols[4].markdown("🟧 **Lembrete hoje (eventos do banco)**")
480
+ cols[5].markdown("🟦 **Outros eventos (banco)**")
481
+
482
+ st.divider()
483
+
484
+ # =====================================================
485
+ # 🔎 Detalhe por clique (evento ou dia)
486
+ # =====================================================
487
+ clicked_event = None
488
+ if state and isinstance(state, dict):
489
+ clicked_event = (state.get("eventClick") or {}).get("event")
490
+ clicked_date_str = (state.get("dateClick") or {}).get("dateStr")
491
+ else:
492
+ clicked_date_str = None
493
+
494
+ if clicked_event:
495
+ ev_id = clicked_event.get("id")
496
+ ev_title = clicked_event.get("title")
497
+ ev_start = clicked_event.get("start")
498
+ ev_ext = clicked_event.get("extendedProps") or {}
499
+
500
+ # Se for do banco, traz detalhes atualizados
501
+ e = None
502
+ if ev_id and not str(ev_id).startswith("auto::"):
503
+ try:
504
+ e = db.query(EventoCalendario).get(int(ev_id))
505
+ except Exception:
506
+ e = None
507
+
508
+ st.subheader(f"📌 {ev_title or 'Evento'}")
509
+ if e:
510
+ st.markdown(
511
+ f"""
512
+ **Descrição:**
513
+ {e.descricao or "_Sem descrição_"}
514
+
515
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
516
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete) if e.data_lembrete else "_Sem lembrete_"}
517
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
518
+ """
519
+ )
520
+ if verificar_permissao("administracao"):
521
+ col1, col2 = st.columns(2)
522
+ with col1:
523
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_{e.id}"):
524
+ e.ativo = False
525
+ try:
526
+ db.commit()
527
+ except Exception as ex:
528
+ db.rollback()
529
+ st.error(f"Erro ao desativar: {ex}")
530
+ else:
531
+ registrar_log(_usuario_atual(), "DESATIVAR",
532
+ "eventos_calendario", e.id)
533
+ st.success("Evento desativado.")
534
+ st.rerun()
535
+ with col2:
536
+ if st.button("🗑️ Excluir", key=f"excluir_{e.id}"):
537
+ db.delete(e)
538
+ try:
539
+ db.commit()
540
+ except Exception as ex:
541
+ db.rollback()
542
+ st.error(f"Erro ao excluir: {ex}")
543
+ else:
544
+ registrar_log(_usuario_atual(), "EXCLUIR",
545
+ "eventos_calendario", e.id)
546
+ st.success("Evento excluído.")
547
+ st.rerun()
548
+ else:
549
+ # Evento do cronograma automático (memória)
550
+ dt_evt = date.fromisoformat(ev_start[:10])
551
+ st.markdown(
552
+ f"""
553
+ **FPSO:** {ev_title.split(' – ')[0] if ' – ' in (ev_title or '') else '—'}
554
+ **Tipo:** {ev_ext.get('tipo', '—')}
555
+ **📅 Data:** {formatar_data_br(dt_evt)}
556
+ **Origem:** _Cronograma automático (não gravado no banco)_
557
+ """
558
+ )
559
+
560
+ elif clicked_date_str:
561
+ try:
562
+ data_clicada = date.fromisoformat(clicked_date_str)
563
+ except Exception:
564
+ data_clicada = None
565
+
566
+ if data_clicada:
567
+ st.subheader(f"🗓️ Eventos em {formatar_data_br(data_clicada)}")
568
+
569
+ # Banco
570
+ eventos_no_dia_db = (
571
+ db.query(EventoCalendario)
572
+ .filter(EventoCalendario.data_evento == data_clicada)
573
+ .order_by(EventoCalendario.id.desc())
574
+ .all()
575
+ )
576
+ if not eventos_no_dia_db:
577
+ st.info("Nenhum evento do banco para este dia.")
578
+ else:
579
+ st.markdown("**📦 Eventos do banco**")
580
+ for e in eventos_no_dia_db:
581
+ with st.expander(f"📌 {e.titulo}"):
582
+ st.markdown(
583
+ f"""
584
+ **Descrição:**
585
+ {e.descricao or "_Sem descrição_"}
586
+
587
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
588
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)}
589
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
590
+ """
591
+ )
592
+ if verificar_permissao("administracao"):
593
+ c1, c2 = st.columns(2)
594
+ with c1:
595
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_list_{e.id}"):
596
+ e.ativo = False
597
+ try:
598
+ db.commit()
599
+ except Exception as ex:
600
+ db.rollback()
601
+ st.error(f"Erro ao desativar: {ex}")
602
+ else:
603
+ registrar_log(_usuario_atual(), "DESATIVAR",
604
+ "eventos_calendario", e.id)
605
+ st.success("Evento desativado.")
606
+ st.rerun()
607
+ with c2:
608
+ if st.button("🗑️ Excluir", key=f"excluir_list_{e.id}"):
609
+ db.delete(e)
610
+ try:
611
+ db.commit()
612
+ except Exception as ex:
613
+ db.rollback()
614
+ st.error(f"Erro ao excluir: {ex}")
615
+ else:
616
+ registrar_log(_usuario_atual(), "EXCLUIR",
617
+ "eventos_calendario", e.id)
618
+ st.success("Evento excluído.")
619
+ st.rerun()
620
+
621
+ # Cronograma automático (memória) – ano selecionado
622
+ eventos_auto_no_dia = [
623
+ ev for ev in eventos_auto
624
+ if ev.get("start", "")[:10] == data_clicada.isoformat()
625
+ ]
626
+ if eventos_auto_no_dia:
627
+ st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
628
+ for ev in sorted(eventos_auto_no_dia, key=lambda x: x.get("title","")):
629
+ fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—"
630
+ tipo = ev.get("extendedProps", {}).get("tipo", "—")
631
+ st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_clicada)})")
632
+
633
+ st.divider()
634
+
635
+ # =====================================================
636
+ # 📆 Consultar Eventos por Data (modo antigo) — inclui AUTO
637
+ # =====================================================
638
+ with st.expander("📆 Consultar Eventos por Data (modo antigo)"):
639
+ data_consulta = st.date_input("Selecione uma data",
640
+ value=hoje, format="DD/MM/YYYY",
641
+ key="consulta_antiga")
642
+
643
+ # Banco
644
+ eventos = (
645
+ db.query(EventoCalendario)
646
+ .filter(EventoCalendario.data_evento == data_consulta)
647
+ .order_by(EventoCalendario.id.desc())
648
+ .all()
649
+ )
650
+ if not eventos:
651
+ st.info("Nenhum evento do banco para esta data.")
652
+ else:
653
+ st.markdown("**📦 Eventos do banco**")
654
+ for e in eventos:
655
+ with st.expander(f"📌 {e.titulo}"):
656
+ st.markdown(
657
+ f"""
658
+ **Descrição:**
659
+ {e.descricao or "_Sem descrição_"}
660
+
661
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
662
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)}
663
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
664
+ """
665
+ )
666
+ if verificar_permissao("administracao"):
667
+ col1, col2 = st.columns(2)
668
+ with col1:
669
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_old_{e.id}"):
670
+ e.ativo = False
671
+ try:
672
+ db.commit()
673
+ except Exception as ex:
674
+ db.rollback()
675
+ st.error(f"Erro ao desativar: {ex}")
676
+ else:
677
+ registrar_log(_usuario_atual(), "DESATIVAR",
678
+ "eventos_calendario", e.id)
679
+ st.success("Evento desativado.")
680
+ st.rerun()
681
+ with col2:
682
+ if st.button("🗑️ Excluir", key=f"excluir_old_{e.id}"):
683
+ db.delete(e)
684
+ try:
685
+ db.commit()
686
+ except Exception as ex:
687
+ db.rollback()
688
+ st.error(f"Erro ao excluir: {ex}")
689
+ else:
690
+ registrar_log(_usuario_atual(), "EXCLUIR",
691
+ "eventos_calendario", e.id)
692
+ st.success("Evento excluído.")
693
+ st.rerun()
694
+
695
+ # AUTO (memória) no ano selecionado
696
+ eventos_auto_antigo = [
697
+ ev for ev in eventos_auto
698
+ if ev.get("start", "")[:10] == data_consulta.isoformat()
699
+ ]
700
+ if eventos_auto_antigo:
701
+ st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
702
+ for ev in sorted(eventos_auto_antigo, key=lambda x: x.get("title","")):
703
+ fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—"
704
+ tipo = ev.get("extendedProps", {}).get("tipo", "—")
705
+ st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_consulta)})")
706
+
707
+ finally:
708
+ db.close()
calendario_mensal.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import calendar
3
+ from datetime import date
4
+ from banco import SessionLocal
5
+ from models import EventoCalendario
6
+ from utils_permissoes import verificar_permissao
7
+ from utils_datas import formatar_data_br
8
+
9
+
10
+ def main():
11
+
12
+ if not verificar_permissao("calendario"):
13
+ st.error("⛔ Acesso não autorizado.")
14
+ return
15
+
16
+ usuario = st.session_state.get("usuario")
17
+ if not usuario:
18
+ st.error("Usuário não autenticado.")
19
+ return
20
+
21
+ st.title("📆 Agenda Mensal")
22
+
23
+ hoje = date.today()
24
+
25
+ col1, col2 = st.columns(2)
26
+
27
+ with col1:
28
+ ano = st.selectbox("Ano", range(hoje.year - 2, hoje.year + 3), index=2)
29
+
30
+ with col2:
31
+ mes = st.selectbox("Mês", range(1, 13), index=hoje.month - 1)
32
+
33
+ db = SessionLocal()
34
+ try:
35
+ eventos = (
36
+ db.query(EventoCalendario)
37
+ .filter(EventoCalendario.usuario_criacao == usuario)
38
+ .filter(EventoCalendario.data_evento.between(
39
+ date(ano, mes, 1),
40
+ date(ano, mes, calendar.monthrange(ano, mes)[1])
41
+ ))
42
+ .filter(EventoCalendario.ativo.is_(True))
43
+ .all()
44
+ )
45
+ finally:
46
+ db.close()
47
+
48
+ eventos_por_dia = {}
49
+ for e in eventos:
50
+ eventos_por_dia.setdefault(e.data_evento.day, []).append(e)
51
+
52
+ st.divider()
53
+
54
+ semanas = calendar.monthcalendar(ano, mes)
55
+
56
+ dias_semana = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"]
57
+ st.columns(7)
58
+ for d in dias_semana:
59
+ st.markdown(f"**{d}**")
60
+
61
+ for semana in semanas:
62
+ cols = st.columns(7)
63
+ for idx, dia in enumerate(semana):
64
+ with cols[idx]:
65
+ if dia == 0:
66
+ st.write("")
67
+ else:
68
+ st.markdown(f"### {dia}")
69
+ for ev in eventos_por_dia.get(dia, []):
70
+ st.caption(f"📌 {ev.titulo}")
componentes.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+
4
+ FPSO_PADRAO = [
5
+ "CDA",
6
+ "CDP",
7
+ "CDM",
8
+ "ADG",
9
+ "ESS",
10
+ "SEP",
11
+ "CDI",
12
+ "ATD",
13
+ "CDS"
14
+ ]
15
+
16
+
17
+ def campo_fpso(label, key):
18
+ """
19
+ Campo FPSO com sugestões + opção de texto livre
20
+ """
21
+ opcoes = [""] + FPSO_PADRAO + ["Outro"]
22
+
23
+ escolha = st.selectbox(
24
+ label,
25
+ opcoes,
26
+ key=f"{key}_select"
27
+ )
28
+
29
+ if escolha == "Outro":
30
+ return st.text_input(
31
+ f"{label} (digite)",
32
+ key=f"{key}_texto"
33
+ ).strip()
34
+
35
+ return escolha
consulta.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ import pandas as pd
4
+ from io import BytesIO
5
+ from datetime import date
6
+ from banco import SessionLocal
7
+ from models import Equipamento
8
+
9
+
10
+ def limpar_estado_consulta():
11
+ """
12
+ Remove do session_state qualquer dado
13
+ relacionado ao módulo Consulta
14
+ """
15
+ for key in list(st.session_state.keys()):
16
+ if key.startswith("consulta_"):
17
+ del st.session_state[key]
18
+
19
+
20
+ def _coerce_date(x):
21
+ """Garante que valores sejam datas (date) ou NaT para comparação."""
22
+ if pd.isna(x):
23
+ return pd.NaT
24
+ if isinstance(x, (pd.Timestamp, )):
25
+ return x.date()
26
+ if isinstance(x, date):
27
+ return x
28
+ try:
29
+ return pd.to_datetime(x).date()
30
+ except Exception:
31
+ return pd.NaT
32
+
33
+
34
+ def main():
35
+
36
+ # =====================================================
37
+ # 🧹 LIMPA ESTADO AO ENTRAR NO MÓDULO
38
+ # =====================================================
39
+ if not st.session_state.get("_consulta_inicializado"):
40
+ limpar_estado_consulta()
41
+ st.session_state["_consulta_inicializado"] = True
42
+
43
+ st.title("🔍 Consulta de Registros")
44
+
45
+ db = SessionLocal()
46
+
47
+ try:
48
+ registros = db.query(Equipamento).all()
49
+
50
+ if not registros:
51
+ st.info("Nenhum registro encontrado.")
52
+ return
53
+
54
+ # =====================================================
55
+ # 🔄 CONVERTE REGISTROS EM DATAFRAME (TODOS OS CAMPOS)
56
+ # =====================================================
57
+ df = pd.DataFrame([
58
+ {
59
+ "ID": r.id,
60
+
61
+ # Identificação
62
+ "FPSO1": r.fpso1,
63
+ "FPSO": r.fpso,
64
+ "Data Coleta": r.data_coleta,
65
+
66
+ # Responsáveis
67
+ "Especialista": r.especialista,
68
+ "Conferente": r.conferente,
69
+ "OSM": r.osm,
70
+
71
+ # Operacional
72
+ "Modal": r.modal,
73
+ "Quantidade Equip.": r.quant_equip,
74
+ "MROB": r.mrob,
75
+
76
+ # Métricas
77
+ "Linhas OSM": r.linhas_osm,
78
+ "Linhas MROB": r.linhas_mrob,
79
+ "Linhas Erros": r.linhas_erros,
80
+
81
+ # Erros
82
+ "Erro Storekeeper": r.erro_storekeeper,
83
+ "Erro Operação": r.erro_operacao,
84
+ "Erro Especialista": r.erro_especialista,
85
+ "Erro Outros": r.erro_outros,
86
+
87
+ # Dados complementares
88
+ "Inclusão / Exclusão": r.inclusao_exclusao,
89
+ "PO": r.po,
90
+ "Part Number": r.part_number,
91
+ "Material": r.material,
92
+
93
+ "Solicitante": r.solicitante,
94
+ "Motivo": getattr(r, "motivo", None),
95
+ "Requisitante": r.requisitante,
96
+ "Nota Fiscal": r.nota_fiscal,
97
+ "Impacto": r.impacto,
98
+ "Dimensão": r.dimensao,
99
+
100
+ "Observações": r.observacoes,
101
+ "Dia Inclusão": r.dia_inclusao,
102
+
103
+ # Auditoria
104
+ "Data/Hora Input": r.data_hora_input,
105
+ }
106
+ for r in registros
107
+ ])
108
+
109
+ # Normaliza a coluna de data para comparação correta
110
+ if "Data Coleta" in df.columns:
111
+ df["Data Coleta"] = df["Data Coleta"].apply(_coerce_date)
112
+
113
+ # =====================================================
114
+ # 🔎 FILTROS
115
+ # =====================================================
116
+ st.subheader("🔎 Filtros")
117
+
118
+ col1, col2, col3 = st.columns(3)
119
+
120
+ with col1:
121
+ filtro_fpso = st.multiselect(
122
+ "FPSO",
123
+ sorted(df["FPSO"].dropna().unique()),
124
+ key="consulta_fpso"
125
+ )
126
+
127
+ filtro_dia = st.multiselect(
128
+ "Dia de Inclusão (D1 / D2 / D3)",
129
+ sorted(df["Dia Inclusão"].dropna().unique()),
130
+ key="consulta_dia"
131
+ )
132
+
133
+ with col2:
134
+ filtro_modal = st.multiselect(
135
+ "Modal",
136
+ sorted(df["Modal"].dropna().unique()),
137
+ key="consulta_modal"
138
+ )
139
+
140
+ filtro_especialista = st.multiselect(
141
+ "Especialista",
142
+ sorted(df["Especialista"].dropna().unique()),
143
+ key="consulta_especialista"
144
+ )
145
+
146
+ # 🔵 FILTRO OSM
147
+ filtro_osm = st.multiselect(
148
+ "OSM",
149
+ sorted(df["OSM"].dropna().unique()),
150
+ key="consulta_osm"
151
+ )
152
+
153
+ with col3:
154
+ periodo = st.date_input(
155
+ "Período de Coleta",
156
+ value=None,
157
+ key="consulta_periodo"
158
+ )
159
+
160
+ # 🟩 NOVO: FILTRO DE NOTA FISCAL
161
+ st.markdown("**Nota Fiscal**")
162
+ nota_input_text = st.text_input(
163
+ "Digite um ou mais números (separados por vírgula)",
164
+ value="",
165
+ key="consulta_nf_text"
166
+ )
167
+ # Alternativamente (opcional) oferecer multiselect pelos valores existentes
168
+ filtro_nf_multi = st.multiselect(
169
+ "Ou selecione",
170
+ sorted([str(x) for x in df["Nota Fiscal"].dropna().unique()]),
171
+ key="consulta_nf_multi"
172
+ )
173
+ mostrar_apenas_duplicadas = st.checkbox(
174
+ "Mostrar apenas notas duplicadas",
175
+ value=False,
176
+ key="consulta_mostrar_dup_nf"
177
+ )
178
+
179
+ # =====================================================
180
+ # 🔄 APLICA FILTROS
181
+ # =====================================================
182
+ # Filtros simples
183
+ if filtro_fpso:
184
+ df = df[df["FPSO"].isin(filtro_fpso)]
185
+
186
+ if filtro_modal:
187
+ df = df[df["Modal"].isin(filtro_modal)]
188
+
189
+ if filtro_especialista:
190
+ df = df[df["Especialista"].isin(filtro_especialista)]
191
+
192
+ if filtro_dia:
193
+ df = df[df["Dia Inclusão"].isin(filtro_dia)]
194
+
195
+ if filtro_osm:
196
+ df = df[df["OSM"].isin(filtro_osm)]
197
+
198
+ # Filtro de período (intervalo)
199
+ if isinstance(periodo, (list, tuple)) and len(periodo) == 2 and all(periodo):
200
+ data_inicio, data_fim = periodo
201
+ df = df[
202
+ (df["Data Coleta"] >= data_inicio) &
203
+ (df["Data Coleta"] <= data_fim)
204
+ ]
205
+
206
+ # -----------------------------------------------------
207
+ # Filtro de Nota Fiscal (texto e/ou multiselect)
208
+ # -----------------------------------------------------
209
+ # Consolida as notas informadas via texto (separadas por vírgula)
210
+ notas_texto = []
211
+ if nota_input_text.strip():
212
+ notas_texto = [x.strip() for x in nota_input_text.split(",") if x.strip()]
213
+
214
+ # Concatena com o multiselect (transformando em string)
215
+ notas_escolhidas = set([str(x) for x in filtro_nf_multi] + [str(x) for x in notas_texto])
216
+
217
+ if notas_escolhidas:
218
+ # Comparar sempre como string para evitar problemas com zeros à esquerda ou tipos heterogêneos
219
+ df = df[df["Nota Fiscal"].astype(str).isin(notas_escolhidas)]
220
+
221
+ # =====================================================
222
+ # 🧭 SINALIZA DUPLICIDADE DE NOTA FISCAL
223
+ # =====================================================
224
+ # Conta ocorrências por número (string) ignorando NaN
225
+ nf_series = df["Nota Fiscal"].astype(str).fillna("")
226
+ contagem_nf = nf_series.value_counts(dropna=False)
227
+ # Duplicadas são as que tem contagem > 1 (e não vazias)
228
+ notas_duplicadas = contagem_nf[(contagem_nf > 1) & (contagem_nf.index != "")]
229
+
230
+ # Coluna booleana marcando duplicidade no DF atual
231
+ df["Duplicidade Nota"] = df["Nota Fiscal"].astype(str).isin(notas_duplicadas.index)
232
+
233
+ # Aviso resumido
234
+ if len(notas_duplicadas) > 0:
235
+ st.warning(
236
+ f"⚠️ Foram encontradas **{int(notas_duplicadas.sum())}** ocorrências em **{len(notas_duplicadas)}** "
237
+ f"números de Nota Fiscal duplicados no resultado."
238
+ )
239
+ with st.expander("Ver lista de notas duplicadas"):
240
+ dup_df = pd.DataFrame({
241
+ "Nota Fiscal": notas_duplicadas.index,
242
+ "Ocorrências": notas_duplicadas.values
243
+ }).sort_values(by="Ocorrências", ascending=False)
244
+ st.dataframe(dup_df, use_container_width=True)
245
+
246
+ # Mostrar apenas duplicadas, caso marcado
247
+ if mostrar_apenas_duplicadas:
248
+ df = df[df["Duplicidade Nota"] == True]
249
+
250
+ # =====================================================
251
+ # 📊 RESULTADOS
252
+ # =====================================================
253
+ st.subheader("📊 Resultados")
254
+ st.caption("A coluna **Duplicidade Nota** indica se há mais de um registro com o mesmo número de Nota Fiscal no resultado atual.")
255
+ st.dataframe(df, use_container_width=True)
256
+
257
+ # =====================================================
258
+ # 📥 EXPORTAÇÃO EXCEL
259
+ # =====================================================
260
+ buffer = BytesIO()
261
+ with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
262
+ df.to_excel(writer, index=False, sheet_name="Consulta")
263
+
264
+ buffer.seek(0)
265
+
266
+ st.download_button(
267
+ label="⬇️ Exportar para Excel",
268
+ data=buffer,
269
+ file_name="consulta_equipamentos.xlsx",
270
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
271
+ )
272
+
273
+ finally:
274
+ db.close()
275
+
276
+
277
+
db_admin.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # db_admin.py
3
+ import streamlit as st
4
+ import os
5
+ import shutil
6
+ from sqlalchemy import text
7
+ from banco import engine, SessionLocal
8
+ from utils_permissoes import verificar_permissao
9
+ from utils_auditoria import registrar_log
10
+
11
+ # =====================================================
12
+ # MÓDULO GERAL DE ADMINISTRAÇÃO DE BANCO (SCHEMA)
13
+ # =====================================================
14
+ # Objetivo:
15
+ # - Permitir adicionar, renomear, excluir e alterar tipo de colunas via UI
16
+ # - Funciona com SQLite, PostgreSQL e MySQL (com diferenças por dialeto)
17
+ # - Em SQLite, oferece reconstrução assistida quando DDL não é suportado
18
+ #
19
+ # Segurança e boas práticas:
20
+ # - Recomendado fazer backup antes de operações (botão disponível para SQLite)
21
+ # - Operações DDL são críticas: exigir confirmação explícita
22
+ # - Acesso restrito ao perfil "admin"
23
+ #
24
+ # Logs:
25
+ # - registrar_log(...) é chamado em todas as operações
26
+
27
+
28
+ # -------------------------
29
+ # Utilitário: Dialeto e versão
30
+ # -------------------------
31
+ def _dialeto():
32
+ try:
33
+ return engine.url.get_backend_name()
34
+ except Exception:
35
+ return "desconhecido"
36
+
37
+ def _sqlite_version():
38
+ if _dialeto() != "sqlite":
39
+ return None
40
+ try:
41
+ with engine.begin() as conn:
42
+ rv = conn.execute(text("select sqlite_version()")).scalar()
43
+ return rv
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ # -------------------------
49
+ # Utilitário: Listar tabelas e colunas
50
+ # -------------------------
51
+ def _listar_tabelas():
52
+ d = _dialeto()
53
+ with engine.begin() as conn:
54
+ if d == "sqlite":
55
+ rows = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")).fetchall()
56
+ return [r[0] for r in rows]
57
+ else:
58
+ q = text("""
59
+ SELECT table_name
60
+ FROM information_schema.tables
61
+ WHERE table_schema NOT IN ('pg_catalog','information_schema')
62
+ ORDER BY table_name
63
+ """)
64
+ rows = conn.execute(q).fetchall()
65
+ return [r[0] for r in rows]
66
+
67
+ def _listar_colunas(tabela: str):
68
+ d = _dialeto()
69
+ with engine.begin() as conn:
70
+ if d == "sqlite":
71
+ rows = conn.execute(text(f"PRAGMA table_info({tabela})")).fetchall()
72
+ # PRAGMA: (cid, name, type, notnull, dflt_value, pk)
73
+ return [{"name": r[1], "type": r[2], "notnull": bool(r[3]), "default": r[4], "pk": bool(r[5])} for r in rows]
74
+ else:
75
+ q = text("""
76
+ SELECT column_name, data_type, is_nullable, column_default
77
+ FROM information_schema.columns
78
+ WHERE table_name = :tbl
79
+ ORDER BY ordinal_position
80
+ """)
81
+ rows = conn.execute(q, {"tbl": tabela}).fetchall()
82
+ return [{"name": r[0], "type": r[1], "notnull": (str(r[2]).upper() == "NO"), "default": r[3], "pk": False} for r in rows]
83
+
84
+
85
+ # -------------------------
86
+ # Backup rápido (SQLite)
87
+ # -------------------------
88
+ def _sqlite_backup():
89
+ if _dialeto() != "sqlite":
90
+ st.info("Backup automático só disponível para SQLite via cópia de arquivo.")
91
+ return
92
+ db_path = engine.url.database
93
+ if not db_path or not os.path.exists(db_path):
94
+ st.error("Arquivo de banco SQLite não encontrado.")
95
+ return
96
+ dest = db_path + ".bak"
97
+ shutil.copyfile(db_path, dest)
98
+ st.success(f"Backup criado: {dest}")
99
+
100
+
101
+ # -------------------------
102
+ # DDL: Gerar comandos por dialeto
103
+ # -------------------------
104
+ def _ddl_add_column_sql(tabela, col_nome, col_tipo, notnull=False, default=None):
105
+ d = _dialeto()
106
+ nn = "NOT NULL" if notnull else "NULL"
107
+ def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else ""
108
+ if d == "sqlite":
109
+ # SQLite aceita tipo textual; notnull e default são respeitados no schema
110
+ return f"ALTER TABLE {tabela} ADD COLUMN {col_nome} {col_tipo} {nn}{def_clause};"
111
+ elif d in ("postgresql", "postgres"):
112
+ base = f'ALTER TABLE "{tabela}" ADD COLUMN "{col_nome}" {col_tipo}'
113
+ if default is not None and str(default).strip() != "":
114
+ base += f" DEFAULT {default}"
115
+ if notnull:
116
+ base += " NOT NULL"
117
+ return base + ";"
118
+ elif d in ("mysql", "mariadb"):
119
+ base = f"ALTER TABLE `{tabela}` ADD COLUMN `{col_nome}` {col_tipo}"
120
+ if default is not None and str(default).strip() != "":
121
+ base += f" DEFAULT {default}"
122
+ base += " NOT NULL" if notnull else " NULL"
123
+ return base + ";"
124
+ return None
125
+
126
+ def _ddl_rename_column_sql(tabela, old, new):
127
+ d = _dialeto()
128
+ if d == "sqlite":
129
+ return f"ALTER TABLE {tabela} RENAME COLUMN {old} TO {new};"
130
+ elif d in ("postgresql", "postgres"):
131
+ return f'ALTER TABLE "{tabela}" RENAME COLUMN "{old}" TO "{new}";'
132
+ elif d in ("mysql", "mariadb"):
133
+ # MySQL requer tipo na renomeação; esta função não cobre tipo -> usar CHANGE COLUMN via UI de "Alterar tipo/renomear"
134
+ return None
135
+ return None
136
+
137
+ def _ddl_drop_column_sql(tabela, col):
138
+ d = _dialeto()
139
+ if d == "sqlite":
140
+ return f"ALTER TABLE {tabela} DROP COLUMN {col};"
141
+ elif d in ("postgresql", "postgres"):
142
+ return f'ALTER TABLE "{tabela}" DROP COLUMN "{col}";'
143
+ elif d in ("mysql", "mariadb"):
144
+ return f"ALTER TABLE `{tabela}` DROP COLUMN `{col}`;"
145
+ return None
146
+
147
+ def _ddl_alter_type_sql(tabela, col, new_type):
148
+ d = _dialeto()
149
+ if d == "sqlite":
150
+ # SQLite não altera type declarado via ALTER TYPE. Necessário reconstruir tabela.
151
+ return None
152
+ elif d in ("postgresql", "postgres"):
153
+ return f'ALTER TABLE "{tabela}" ALTER COLUMN "{col}" TYPE {new_type};'
154
+ elif d in ("mysql", "mariadb"):
155
+ return f"ALTER TABLE `{tabela}` MODIFY COLUMN `{col}` {new_type};"
156
+ return None
157
+
158
+
159
+ # -------------------------
160
+ # Reconstrução assistida (SQLite)
161
+ # -------------------------
162
+ def _sqlite_reconstruir_tabela(tabela, novas_colunas):
163
+ """
164
+ Reconstrói tabela SQLite com "novas_colunas" (lista de dicts):
165
+ [{"name":..., "type":..., "notnull":bool, "default":..., "pk":bool}, ...]
166
+ - Cria tabela __tmp_<tabela> com o novo schema
167
+ - Copia dados das colunas compatíveis (mesmos nomes)
168
+ - Drop da tabela original e rename da temporária
169
+ """
170
+ cols_def = []
171
+ copy_cols = []
172
+ pk_cols = [c["name"] for c in novas_colunas if c.get("pk")]
173
+ for c in novas_colunas:
174
+ nn = "NOT NULL" if c.get("notnull") else ""
175
+ default = c.get("default")
176
+ def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else ""
177
+ cols_def.append(f'{c["name"]} {c["type"]} {nn}{def_clause}'.strip())
178
+ copy_cols.append(c["name"])
179
+ pk_clause = f", PRIMARY KEY ({', '.join(pk_cols)})" if pk_cols else ""
180
+
181
+ create_sql = f"CREATE TABLE __tmp_{tabela} ({', '.join(cols_def)}{pk_clause});"
182
+ copy_sql = f"INSERT INTO __tmp_{tabela} ({', '.join(copy_cols)}) SELECT {', '.join(copy_cols)} FROM {tabela};"
183
+ drop_sql = f"DROP TABLE {tabela};"
184
+ rename_sql= f"ALTER TABLE __tmp_{tabela} RENAME TO {tabela};"
185
+
186
+ with engine.begin() as conn:
187
+ conn.execute(text(create_sql))
188
+ conn.execute(text(copy_sql))
189
+ conn.execute(text(drop_sql))
190
+ conn.execute(text(rename_sql))
191
+
192
+
193
+ # -------------------------
194
+ # UI principal (admin)
195
+ # -------------------------
196
+ def main():
197
+ st.title("🛠️ Administração de Banco (Schema)")
198
+
199
+ # 🔐 Proteção por perfil
200
+ if not verificar_permissao("db_admin") and st.session_state.get("perfil") != "admin":
201
+ st.error("⛔ Acesso não autorizado.")
202
+ return
203
+
204
+ # Info do banco
205
+ dial = _dialeto()
206
+ st.caption(f"Dialeto: **{dial}**")
207
+ ver = _sqlite_version()
208
+ if ver:
209
+ st.caption(f"SQLite version: **{ver}**")
210
+
211
+ # Backup (SQLite)
212
+ if dial == "sqlite":
213
+ if st.button("💾 Backup rápido (SQLite)"):
214
+ _sqlite_backup()
215
+
216
+ # Tabelas disponíveis
217
+ tabelas = _listar_tabelas()
218
+ if not tabelas:
219
+ st.warning("Nenhuma tabela encontrada.")
220
+ return
221
+
222
+ tabela = st.selectbox("Tabela alvo:", tabelas, index=0)
223
+ colunas = _listar_colunas(tabela)
224
+
225
+ st.divider()
226
+ st.subheader("📋 Colunas atuais")
227
+ st.write(pd.DataFrame(colunas)) if 'pd' in globals() else st.write(colunas) # mostra estrutura atual
228
+
229
+ st.divider()
230
+ tabs = st.tabs(["➕ Adicionar coluna", "✏️ Renomear coluna", "🗑️ Excluir coluna", "♻️ Alterar tipo"])
231
+
232
+ # ----------------- Adicionar coluna -----------------
233
+ with tabs[0]:
234
+ st.markdown("**Adicionar uma nova coluna à tabela selecionada**")
235
+ novo_nome = st.text_input("Nome da nova coluna")
236
+ novo_tipo = st.text_input("Tipo (ex.: TEXT, INTEGER, VARCHAR(255))")
237
+ novo_notnull = st.checkbox("NOT NULL", value=False)
238
+ novo_default = st.text_input("DEFAULT (opcional)")
239
+
240
+ confirmar_add = st.checkbox("Confirmo a adição desta coluna (DDL).")
241
+ if st.button("Executar ADD COLUMN", type="primary") and confirmar_add:
242
+ sql = _ddl_add_column_sql(tabela, novo_nome, novo_tipo, notnull=novo_notnull, default=novo_default)
243
+ if not sql:
244
+ st.error("Dialeto não suportado para ADD COLUMN.")
245
+ else:
246
+ try:
247
+ with engine.begin() as conn:
248
+ conn.execute(text(sql))
249
+ registrar_log(st.session_state.get("usuario"), f"ADD COLUMN {novo_nome} {novo_tipo} em {tabela}", "schema", None)
250
+ st.success("✅ Coluna adicionada com sucesso.")
251
+ st.rerun()
252
+ except Exception as e:
253
+ st.error(f"Erro ao adicionar coluna: {e}")
254
+
255
+ # ----------------- Renomear coluna -----------------
256
+ with tabs[1]:
257
+ st.markdown("**Renomear uma coluna existente**")
258
+ col_nomes = [c["name"] for c in colunas]
259
+ antigo = st.selectbox("Coluna atual:", col_nomes) if col_nomes else ""
260
+ novo = st.text_input("Novo nome da coluna")
261
+
262
+ confirmar_ren = st.checkbox("Confirmo a renomeação desta coluna (DDL).")
263
+ if st.button("Executar RENAME COLUMN") and confirmar_ren:
264
+ d = _dialeto()
265
+ if d == "sqlite":
266
+ # Verifica suporte na versão
267
+ ver = _sqlite_version() or "0.0.0"
268
+ suportado = tuple(map(int, ver.split("."))) >= (3, 25, 0)
269
+ if suportado:
270
+ sql = _ddl_rename_column_sql(tabela, antigo, novo)
271
+ try:
272
+ with engine.begin() as conn:
273
+ conn.execute(text(sql))
274
+ registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None)
275
+ st.success("✅ Coluna renomeada com sucesso.")
276
+ st.rerun()
277
+ except Exception as e:
278
+ st.error(f"Erro ao renomear: {e}")
279
+ else:
280
+ st.warning("SQLite < 3.25 não suporta RENAME COLUMN. Oferecendo reconstrução assistida.")
281
+ # Reconstrução: atualiza metadados e recria tabela
282
+ novas = []
283
+ for c in colunas:
284
+ nm = novo if c["name"] == antigo else c["name"]
285
+ novas.append({"name": nm, "type": c["type"], "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]})
286
+ try:
287
+ _sqlite_reconstruir_tabela(tabela, novas)
288
+ registrar_log(st.session_state.get("usuario"), f"RENAME (rebuild) {antigo}→{novo} em {tabela}", "schema", None)
289
+ st.success("✅ Reconstrução concluída com sucesso.")
290
+ st.rerun()
291
+ except Exception as e:
292
+ st.error(f"Erro na reconstrução: {e}")
293
+ elif d in ("postgresql", "postgres"):
294
+ sql = _ddl_rename_column_sql(tabela, antigo, novo)
295
+ if not sql:
296
+ st.error("Renomeação não suportada.")
297
+ else:
298
+ try:
299
+ with engine.begin() as conn:
300
+ conn.execute(text(sql))
301
+ registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None)
302
+ st.success("✅ Coluna renomeada com sucesso.")
303
+ st.rerun()
304
+ except Exception as e:
305
+ st.error(f"Erro ao renomear: {e}")
306
+ elif d in ("mysql", "mariadb"):
307
+ st.info("MySQL/MariaDB exigem 'CHANGE COLUMN' informando o novo tipo; use a aba 'Alterar tipo' para renomear junto com tipo.")
308
+
309
+ # ----------------- Excluir coluna -----------------
310
+ with tabs[2]:
311
+ st.markdown("**Excluir uma coluna existente**")
312
+ col_nomes = [c["name"] for c in colunas]
313
+ col_drop = st.selectbox("Coluna a excluir:", col_nomes) if col_nomes else ""
314
+
315
+ confirmar_drop = st.checkbox("Confirmo a exclusão desta coluna (DDL) e entendo que é irreversível.")
316
+ if st.button("Executar DROP COLUMN", type="secondary") and confirmar_drop:
317
+ d = _dialeto()
318
+ if d == "sqlite":
319
+ ver = _sqlite_version() or "0.0.0"
320
+ suportado = tuple(map(int, ver.split("."))) >= (3, 35, 0)
321
+ if suportado:
322
+ sql = _ddl_drop_column_sql(tabela, col_drop)
323
+ try:
324
+ with engine.begin() as conn:
325
+ conn.execute(text(sql))
326
+ registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None)
327
+ st.success("✅ Coluna excluída com sucesso.")
328
+ st.rerun()
329
+ except Exception as e:
330
+ st.error(f"Erro ao excluir: {e}")
331
+ else:
332
+ st.warning("SQLite < 3.35 não suporta DROP COLUMN. Oferecendo reconstrução assistida.")
333
+ novas = [c for c in colunas if c["name"] != col_drop]
334
+ try:
335
+ _sqlite_reconstruir_tabela(tabela, novas)
336
+ registrar_log(st.session_state.get("usuario"), f"DROP (rebuild) {col_drop} em {tabela}", "schema", None)
337
+ st.success("✅ Reconstrução concluída e coluna removida.")
338
+ st.rerun()
339
+ except Exception as e:
340
+ st.error(f"Erro na reconstrução: {e}")
341
+ elif d in ("postgresql", "postgres", "mysql", "mariadb"):
342
+ sql = _ddl_drop_column_sql(tabela, col_drop)
343
+ try:
344
+ with engine.begin() as conn:
345
+ conn.execute(text(sql))
346
+ registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None)
347
+ st.success("✅ Coluna excluída com sucesso.")
348
+ st.rerun()
349
+ except Exception as e:
350
+ st.error(f"Erro ao excluir: {e}")
351
+
352
+ # ----------------- Alterar tipo -----------------
353
+ with tabs[3]:
354
+ st.markdown("**Alterar tipo declarado de uma coluna**")
355
+ col_nomes = [c["name"] for c in colunas]
356
+ alvo = st.selectbox("Coluna alvo:", col_nomes) if col_nomes else ""
357
+ novo_tipo = st.text_input("Novo tipo (ex.: TEXT, INTEGER, VARCHAR(255))")
358
+
359
+ confirmar_type = st.checkbox("Confirmo a alteração de tipo (DDL).")
360
+ if st.button("Executar ALTER TYPE") and confirmar_type:
361
+ d = _dialeto()
362
+ if d == "sqlite":
363
+ st.warning("SQLite não suporta ALTER TYPE direto; oferecemos reconstrução assistida.")
364
+ novas = []
365
+ for c in colunas:
366
+ typ = novo_tipo if c["name"] == alvo else c["type"]
367
+ novas.append({"name": c["name"], "type": typ, "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]})
368
+ try:
369
+ _sqlite_reconstruir_tabela(tabela, novas)
370
+ registrar_log(st.session_state.get("usuario"), f"ALTER TYPE (rebuild) {alvo}→{novo_tipo} em {tabela}", "schema", None)
371
+ st.success("✅ Tipo alterado com sucesso via reconstrução.")
372
+ st.rerun()
373
+ except Exception as e:
374
+ st.error(f"Erro na reconstrução: {e}")
375
+ elif d in ("postgresql", "postgres", "mysql", "mariadb"):
376
+ sql = _ddl_alter_type_sql(tabela, alvo, novo_tipo)
377
+ if not sql:
378
+ st.error("Dialeto não suportado para ALTER TYPE.")
379
+ else:
380
+ try:
381
+ with engine.begin() as conn:
382
+ conn.execute(text(sql))
383
+ registrar_log(st.session_state.get("usuario"), f"ALTER TYPE {alvo}→{novo_tipo} em {tabela}", "schema", None)
384
+ st.success("✅ Tipo alterado com sucesso.")
385
+ st.rerun()
386
+ except Exception as e:
387
+ st.error(f"Erro ao alterar tipo: {e}")
db_export_import.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ db_export_import.py — Backup & Restore (Export/Import) do banco ATIVO (Produção/Teste)
5
+
6
+ Recursos:
7
+ • Exibe banco ativo (prod/test) e URL do engine
8
+ • Exporta todas as tabelas para:
9
+ - ZIP (CSV por tabela + manifest.json)
10
+ - Excel (.xlsx) (1 aba por tabela + manifest sheet)
11
+ • Importa (upload) de:
12
+ - ZIP (CSV por tabela)
13
+ - Excel (.xlsx)
14
+ • Modos de import: APPEND ou REPLACE (cuidado com FK)
15
+ • Snapshot físico para SQLite: cópia do arquivo (.db) — backup/restore rápido
16
+
17
+ Dependências:
18
+ - pandas, openpyxl, sqlalchemy, zipfile, io, json, datetime
19
+ """
20
+
21
+ import os
22
+ import io
23
+ import json
24
+ import zipfile
25
+ from datetime import datetime
26
+
27
+ import streamlit as st
28
+ import pandas as pd
29
+ from sqlalchemy import inspect, text
30
+
31
+ from banco import get_engine, db_info, SessionLocal
32
+ from utils_auditoria import registrar_log
33
+
34
+ # Ambiente (prod/test) — se db_router não existir, fallback para 'prod'
35
+ try:
36
+ from db_router import current_db_choice
37
+ _HAS_ROUTER = True
38
+ except Exception:
39
+ _HAS_ROUTER = False
40
+
41
+ def current_db_choice() -> str:
42
+ return "prod"
43
+
44
+
45
+ # =========================
46
+ # Helpers: tabelas e I/O
47
+ # =========================
48
+ def list_tables(engine) -> list[str]:
49
+ """Retorna nomes de todas as tabelas via SQLAlchemy inspection."""
50
+ inspector = inspect(engine)
51
+ return inspector.get_table_names()
52
+
53
+
54
+ def _read_table_df(engine, table_name: str) -> pd.DataFrame:
55
+ """Lê toda a tabela como DataFrame."""
56
+ try:
57
+ # pandas + SQLAlchemy: lê tabela diretamente
58
+ return pd.read_sql_table(table_name, con=engine)
59
+ except Exception:
60
+ # fallback: SELECT com aspas (útil para SQLite com nomes case-sensitive)
61
+ return pd.read_sql(f'SELECT * FROM "{table_name}"', con=engine)
62
+
63
+
64
+ def _write_table_df(engine, table_name: str, df: pd.DataFrame, mode: str = "append"):
65
+ """
66
+ Escreve DataFrame em tabela.
67
+ mode: "append" (adiciona) ou "replace" (sobrescreve todos os dados).
68
+ Observação: nos fluxos de import, quando 'replace' foi selecionado,
69
+ todas as tabelas são truncadas previamente, e aqui usamos 'append'.
70
+ """
71
+ if mode not in ("append", "replace"):
72
+ mode = "append"
73
+ df.to_sql(table_name, con=engine, if_exists=mode, index=False)
74
+
75
+
76
+ # =========================
77
+ # Export: ZIP (CSV) & Excel
78
+ # =========================
79
+ def export_zip(engine, ambiente: str) -> bytes:
80
+ """
81
+ Exporta todas as tabelas para um ZIP:
82
+ - 1 CSV por tabela (UTF-8-BOM)
83
+ - manifest.json com metadados (ambiente, timestamp, url, tabelas)
84
+ """
85
+ tables = list_tables(engine)
86
+ buf = io.BytesIO()
87
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
88
+ for t in tables:
89
+ df = _read_table_df(engine, t)
90
+ csv_bytes = df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig")
91
+ z.writestr(f"{t}.csv", csv_bytes)
92
+
93
+ manifest = {
94
+ "ambiente": ambiente,
95
+ "timestamp": datetime.now().isoformat(),
96
+ "engine_url": str(engine.url),
97
+ "tables": tables,
98
+ "format": "zip/csv",
99
+ "version": "1.0",
100
+ }
101
+ z.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
102
+ buf.seek(0)
103
+ return buf.getvalue()
104
+
105
+
106
+ def export_excel(engine, ambiente: str) -> bytes:
107
+ """
108
+ Exporta todas as tabelas para um Excel (.xlsx):
109
+ - 1 aba por tabela (limitada a 31 caracteres)
110
+ - "manifest" com metadados
111
+ """
112
+ tables = list_tables(engine)
113
+ buf = io.BytesIO()
114
+ with pd.ExcelWriter(buf, engine="openpyxl") as writer:
115
+ # manifest
116
+ manifest = pd.DataFrame([{
117
+ "ambiente": ambiente,
118
+ "timestamp": datetime.now().isoformat(),
119
+ "engine_url": str(engine.url),
120
+ "tables": ", ".join(tables),
121
+ "format": "xlsx",
122
+ "version": "1.0",
123
+ }])
124
+ manifest.to_excel(writer, sheet_name="manifest", index=False)
125
+
126
+ # tabelas → 1 aba por tabela
127
+ for t in tables:
128
+ df = _read_table_df(engine, t)
129
+ sheet = t[:31] if len(t) > 31 else t
130
+ df.to_excel(writer, sheet_name=sheet, index=False)
131
+ buf.seek(0)
132
+ return buf.getvalue()
133
+
134
+
135
+ # =========================
136
+ # Import: ZIP (CSV) & Excel
137
+ # =========================
138
+ def import_zip(engine, file_bytes: bytes, mode: str = "append") -> dict:
139
+ """
140
+ Importa dados de um ZIP (CSV por tabela).
141
+ mode: "append" ou "replace".
142
+ Retorna um relatório {table: {"rows": int, "mode": str}}.
143
+ """
144
+ report = {}
145
+ zbuf = io.BytesIO(file_bytes)
146
+ with zipfile.ZipFile(zbuf, "r") as z:
147
+ # Se replace, limpar tabelas (cuidado com FK)
148
+ if mode == "replace":
149
+ _truncate_all(engine)
150
+
151
+ for name in z.namelist():
152
+ if not name.lower().endswith(".csv"):
153
+ continue
154
+ table = os.path.splitext(os.path.basename(name))[0]
155
+ csv_bytes = z.read(name)
156
+ df = pd.read_csv(io.BytesIO(csv_bytes), dtype=str) # dtype=str para evitar coercões agressivas
157
+ # Conversões leves de datetime (best-effort)
158
+ for col in df.columns:
159
+ if "data" in col.lower() or "date" in col.lower():
160
+ try:
161
+ df[col] = pd.to_datetime(df[col], errors="ignore")
162
+ except Exception:
163
+ pass
164
+ # replace já truncou; aqui fazemos append
165
+ _write_table_df(engine, table, df, mode="append")
166
+ report[table] = {"rows": int(len(df)), "mode": mode}
167
+ return report
168
+
169
+
170
+ def import_excel(engine, file_bytes: bytes, mode: str = "append") -> dict:
171
+ """
172
+ Importa dados de um Excel (.xlsx) com múltiplas abas (1 por tabela).
173
+ mode: "append" ou "replace".
174
+ """
175
+ report = {}
176
+ xbuf = io.BytesIO(file_bytes)
177
+ xls = pd.ExcelFile(xbuf, engine="openpyxl")
178
+ sheets = [s for s in xls.sheet_names if s.lower() != "manifest"]
179
+
180
+ if mode == "replace":
181
+ _truncate_all(engine)
182
+
183
+ for sheet in sheets:
184
+ df = xls.parse(sheet_name=sheet, dtype=str)
185
+ # best-effort para datas
186
+ for col in df.columns:
187
+ if "data" in col.lower() or "date" in col.lower():
188
+ try:
189
+ df[col] = pd.to_datetime(df[col], errors="ignore")
190
+ except Exception:
191
+ pass
192
+ table = sheet
193
+ _write_table_df(engine, table, df, mode="append")
194
+ report[table] = {"rows": int(len(df)), "mode": mode}
195
+ return report
196
+
197
+
198
+ # =========================
199
+ # Truncate (REPLACE mode)
200
+ # =========================
201
+ def _truncate_all(engine):
202
+ """
203
+ Limpa todas as tabelas do banco ativo (cuidado!).
204
+ • Para SQLite: desabilita FK temporariamente, apaga, e reabilita.
205
+ • Para outros bancos: executa DELETE tabela; considere ordem por FK se necessário.
206
+ """
207
+ insp = inspect(engine)
208
+ tables = insp.get_table_names()
209
+
210
+ with engine.begin() as conn:
211
+ url = str(engine.url)
212
+ is_sqlite = url.startswith("sqlite")
213
+ if is_sqlite:
214
+ conn.execute(text("PRAGMA foreign_keys=OFF"))
215
+
216
+ # Apaga conteúdo (sem considerar ordem de FK — OK para SQLite com FK OFF)
217
+ for t in tables:
218
+ conn.execute(text(f'DELETE FROM "{t}"'))
219
+
220
+ if is_sqlite:
221
+ conn.execute(text("PRAGMA foreign_keys=ON"))
222
+
223
+
224
+ # =========================
225
+ # Snapshot físico (SQLite)
226
+ # =========================
227
+ def snapshot_sqlite(engine, ambiente: str) -> bytes:
228
+ """
229
+ Cria um snapshot (cópia física) do arquivo SQLite do banco ativo.
230
+ Retorna o conteúdo do arquivo para download.
231
+ """
232
+ url = str(engine.url)
233
+ if not url.startswith("sqlite:///"):
234
+ raise RuntimeError("Snapshot físico disponível apenas para SQLite.")
235
+ db_path = url.replace("sqlite:///", "")
236
+ if not os.path.isfile(db_path):
237
+ raise FileNotFoundError(f"Arquivo SQLite não encontrado: {db_path}")
238
+
239
+ with open(db_path, "rb") as f:
240
+ data = f.read()
241
+
242
+ # auditoria
243
+ try:
244
+ registrar_log(usuario=st.session_state.get("usuario"),
245
+ acao=f"Snapshot SQLite ({ambiente})",
246
+ tabela="backup",
247
+ registro_id=None)
248
+ except Exception:
249
+ pass
250
+
251
+ return data
252
+
253
+
254
+ # =========================
255
+ # UI (Streamlit)
256
+ # =========================
257
+ def main():
258
+ st.title("🗄️ Backup & Restore | Export/Import de Banco")
259
+
260
+ # Banco ativo e info
261
+ ambiente = current_db_choice()
262
+ info = db_info()
263
+ st.caption(f"🧭 Ambiente: {'Produção' if ambiente == 'prod' else 'Teste'}")
264
+ st.caption(f"🔗 Engine URL: {info.get('url')}")
265
+
266
+ engine = get_engine()
267
+
268
+ st.divider()
269
+ st.subheader("⬇️ Exportar dados")
270
+
271
+ colA, colB, colC = st.columns(3)
272
+ with colA:
273
+ if st.button("Exportar ZIP (CSV por tabela)", type="primary"):
274
+ try:
275
+ zip_bytes = export_zip(engine, ambiente)
276
+ fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
277
+ st.download_button("📥 Baixar ZIP", data=zip_bytes, file_name=fname, mime="application/zip")
278
+ registrar_log(usuario=st.session_state.get("usuario"),
279
+ acao=f"Export ZIP (ambiente={ambiente})",
280
+ tabela="backup", registro_id=None)
281
+ except Exception as e:
282
+ st.error(f"Falha ao exportar ZIP: {e}")
283
+
284
+ with colB:
285
+ if st.button("Exportar Excel (.xlsx)", type="primary"):
286
+ try:
287
+ xlsx_bytes = export_excel(engine, ambiente)
288
+ fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
289
+ st.download_button("📥 Baixar Excel", data=xlsx_bytes, file_name=fname,
290
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
291
+ registrar_log(usuario=st.session_state.get("usuario"),
292
+ acao=f"Export XLSX (ambiente={ambiente})",
293
+ tabela="backup", registro_id=None)
294
+ except Exception as e:
295
+ st.error(f"Falha ao exportar Excel: {e}")
296
+
297
+ with colC:
298
+ # Snapshot físico apenas para SQLite
299
+ url = str(engine.url)
300
+ if url.startswith("sqlite:///"):
301
+ if st.button("Snapshot físico (SQLite)", type="secondary"):
302
+ try:
303
+ snap_bytes = snapshot_sqlite(engine, ambiente)
304
+ fname = f"snapshot_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
305
+ st.download_button("📥 Baixar Snapshot (.db)", data=snap_bytes, file_name=fname, mime="application/octet-stream")
306
+ except Exception as e:
307
+ st.error(f"Falha ao criar snapshot: {e}")
308
+ else:
309
+ st.caption("ℹ️ Snapshot físico disponível apenas para SQLite.")
310
+
311
+ st.divider()
312
+ st.subheader("⬆️ Importar dados")
313
+
314
+ mode = st.radio("Modo de importação:", ["APPEND (adicionar)", "REPLACE (substituir tudo)"], horizontal=True)
315
+ mode_val = "append" if "APPEND" in mode else "replace"
316
+
317
+ up_col1, up_col2 = st.columns(2)
318
+ with up_col1:
319
+ zip_file = st.file_uploader("Upload ZIP (CSV por tabela)", type=["zip"])
320
+ if zip_file is not None and st.button("Importar do ZIP", type="primary"):
321
+ try:
322
+ report = import_zip(engine, zip_file.read(), mode=mode_val)
323
+ st.success(f"Import ZIP concluído ({mode_val}).")
324
+ st.json(report)
325
+ registrar_log(usuario=st.session_state.get("usuario"),
326
+ acao=f"Import ZIP ({mode_val}, ambiente={ambiente})",
327
+ tabela="restore", registro_id=None)
328
+ except Exception as e:
329
+ st.error(f"Falha ao importar ZIP: {e}")
330
+
331
+ with up_col2:
332
+ xls_file = st.file_uploader("Upload Excel (.xlsx)", type=["xlsx"])
333
+ if xls_file is not None and st.button("Importar do Excel", type="primary"):
334
+ try:
335
+ report = import_excel(engine, xls_file.read(), mode=mode_val)
336
+ st.success(f"Import Excel concluído ({mode_val}).")
337
+ st.json(report)
338
+ registrar_log(usuario=st.session_state.get("usuario"),
339
+ acao=f"Import XLSX ({mode_val}, ambiente={ambiente})",
340
+ tabela="restore", registro_id=None)
341
+ except Exception as e:
342
+ st.error(f"Falha ao importar Excel: {e}")
343
+
344
+ st.divider()
345
+ st.info("⚠️ Recomendações:\n"
346
+ "• Para restore completo com integridade referencial, prefira snapshot físico no SQLite, ou migrações controladas em bancos como Postgres/SQL Server.\n"
347
+ "• O modo REPLACE desabilita FK temporariamente no SQLite para permitir limpeza; use com cautela.\n"
348
+ "• Em produção, considere gerar backups com versionamento e retenção (ex.: timestamp no nome do arquivo).")
349
+
350
+
351
+ def render():
352
+ # compatível com seu roteador/menu
353
+ main()
354
+
355
+
356
+ if __name__ == "__main__":
357
+ st.set_page_config(page_title="Backup & Restore | ARM", layout="wide")
358
+ main()
359
+
db_monitor.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # db_monitor.py
3
+ import streamlit as st
4
+ import os
5
+ import shutil
6
+ import time
7
+ from datetime import datetime, timedelta
8
+ from sqlalchemy import text
9
+ # ✅ Use sempre o engine do BANCO ATIVO (em vez de um engine fixo)
10
+ from banco import get_engine, SessionLocal, db_info
11
+ from utils_permissoes import verificar_permissao
12
+ from utils_auditoria import registrar_log
13
+
14
+ # ===============================
15
+ # MONITOR & BACKUP DO BANCO
16
+ # ===============================
17
+ # Objetivo:
18
+ # - Mostrar estatísticas do banco (tamanho, páginas, espaço em disco)
19
+ # - Definir limiar/capacidade alvo e exibir ocupação (%)
20
+ # - Planejar backup (frequência em dias) e retenção (N arquivos)
21
+ # - Executar backup e limpar antigos com confirmação
22
+ # - Acesso restrito por perfil admin
23
+ #
24
+ # Observações:
25
+ # - Em SQLite: usa PRAGMA page_count/page_size + arquivo .db
26
+ # - Em outros dialetos: exibe dialeto e recomenda backup externo
27
+ # - Pasta padrão de backup: ./backups (pode alterar na UI)
28
+ # - Auditoria: registrar_log(usuario, acao="BACKUP/CLEAN/MONITOR", tabela="schema")
29
+
30
+ # (Opcional) rótulo amigável do ambiente atual (Produção/Teste/Treinamento)
31
+ try:
32
+ from db_router import current_db_choice, bank_label
33
+ _HAS_ROUTER = True
34
+ except Exception:
35
+ _HAS_ROUTER = False
36
+ def current_db_choice() -> str:
37
+ return "prod"
38
+ def bank_label(choice: str) -> str:
39
+ return {"prod": "Banco 1 (Produção)", "test": "Banco 2 (Teste)", "treinamento": "Banco 3 (Treinamento)"}\
40
+ .get(choice, choice)
41
+
42
+ # -------------------------
43
+ # Auxiliares de dialeto
44
+ # -------------------------
45
+ def _engine():
46
+ """Retorna o engine do banco ATIVO (de acordo com a escolha no login)."""
47
+ return get_engine()
48
+
49
+ def _dialeto():
50
+ try:
51
+ return _engine().url.get_backend_name()
52
+ except Exception:
53
+ return "desconhecido"
54
+
55
+ def _sqlite_version():
56
+ if _dialeto() != "sqlite":
57
+ return None
58
+ try:
59
+ with _engine().begin() as conn:
60
+ return conn.execute(text("select sqlite_version()")).scalar()
61
+ except Exception:
62
+ return None
63
+
64
+ # -------------------------
65
+ # Info do banco
66
+ # -------------------------
67
+ def _db_file_path():
68
+ # Para SQLite, engine.url.database aponta para o arquivo .db
69
+ try:
70
+ eng = _engine()
71
+ return eng.url.database if eng.url.get_backend_name() == "sqlite" else None
72
+ except Exception:
73
+ return None
74
+
75
+ def _sqlite_stats():
76
+ # Retorna dict com stats do SQLite
77
+ db_path = _db_file_path()
78
+ if not db_path or not os.path.exists(db_path):
79
+ return None
80
+
81
+ size_bytes = os.path.getsize(db_path)
82
+ dir_path = os.path.dirname(os.path.abspath(db_path)) or "."
83
+ total, used, free = shutil.disk_usage(dir_path)
84
+
85
+ with _engine().begin() as conn:
86
+ page_count = conn.execute(text("PRAGMA page_count")).scalar()
87
+ page_size = conn.execute(text("PRAGMA page_size")).scalar()
88
+
89
+ return {
90
+ "db_path": db_path,
91
+ "size_bytes": size_bytes,
92
+ "page_count": page_count,
93
+ "page_size": page_size,
94
+ "calc_bytes": (page_count or 0) * (page_size or 0),
95
+ "disk_total": total,
96
+ "disk_free": free,
97
+ "disk_used": used,
98
+ "sqlite_version": _sqlite_version(),
99
+ }
100
+
101
+ # -------------------------
102
+ # Backup
103
+ # -------------------------
104
+ def _ensure_dir(path: str):
105
+ os.makedirs(path, exist_ok=True)
106
+
107
+ def _fmt_bytes(b: int) -> str:
108
+ # Formata bytes em unidades legíveis
109
+ for unit in ["B","KB","MB","GB","TB"]:
110
+ if b < 1024.0:
111
+ return f"{b:,.2f} {unit}".replace(",", ".")
112
+ b /= 1024.0
113
+ return f"{b:,.2f} PB".replace(",", ".")
114
+
115
+ def _listar_backups(backup_dir: str, base_name: str):
116
+ """Lista backups para o banco atual. Formato: base_name-YYYYMMDD-HHMMSS.db (ou .zip futuramente)"""
117
+ if not os.path.isdir(backup_dir):
118
+ return []
119
+ files = []
120
+ for f in os.listdir(backup_dir):
121
+ if f.startswith(base_name + "-") and f.endswith(".db"):
122
+ full = os.path.join(backup_dir, f)
123
+ files.append((f, full, os.path.getmtime(full)))
124
+ return sorted(files, key=lambda x: x[2], reverse=True) # ordem decrescente
125
+
126
+ def _executar_backup(backup_dir: str):
127
+ """Copia o .db para backups/ com timestamp. Registra auditoria."""
128
+ db_path = _db_file_path()
129
+ if not db_path or not os.path.exists(db_path):
130
+ st.error("Arquivo de banco SQLite não encontrado.")
131
+ return False
132
+
133
+ _ensure_dir(backup_dir)
134
+ base_name = os.path.splitext(os.path.basename(db_path))[0]
135
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
136
+ dest = os.path.join(backup_dir, f"{base_name}-{stamp}.db")
137
+
138
+ try:
139
+ shutil.copyfile(db_path, dest)
140
+ registrar_log(st.session_state.get("usuario"), f"BACKUP criado: {os.path.basename(dest)}", "schema", None)
141
+ st.success(f"✅ Backup criado: {dest}")
142
+ return True
143
+ except Exception as e:
144
+ st.error(f"Erro ao criar backup: {e}")
145
+ return False
146
+
147
+ def _limpar_antigos(backup_dir: str, base_name: str, manter: int):
148
+ """Remove backups antigos, mantendo N mais recentes. Registra auditoria."""
149
+ lst = _listar_backups(backup_dir, base_name)
150
+ if len(lst) <= manter:
151
+ st.info("Nada para remover: já dentro da retenção.")
152
+ return 0
153
+ remover = lst[manter:]
154
+ count = 0
155
+ for _, full, _ in remover:
156
+ try:
157
+ os.remove(full)
158
+ count += 1
159
+ except Exception as e:
160
+ st.error(f"Erro ao remover {full}: {e}")
161
+ if count > 0:
162
+ registrar_log(st.session_state.get("usuario"), f"CLEAN backups antigos: {count} removidos (retain={manter})", "schema", None)
163
+ st.success(f"🧹 {count} backup(s) antigo(s) removido(s).")
164
+ return count
165
+
166
+ # -------------------------
167
+ # UI principal
168
+ # -------------------------
169
+ def main():
170
+ st.title("🗄️ Monitor e Backup do Banco")
171
+
172
+ # 🔐 Proteção: apenas admin
173
+ if st.session_state.get("perfil") != "admin":
174
+ st.error("⛔ Acesso restrito ao administrador.")
175
+ return
176
+
177
+ # Badge/URL do banco ativo (opcional)
178
+ try:
179
+ amb = current_db_choice()
180
+ st.caption(f"🧭 Ambiente: {bank_label(amb)}")
181
+ except Exception:
182
+ pass
183
+ try:
184
+ info = db_info()
185
+ st.caption(f"🔗 Engine URL: {info.get('url')}")
186
+ except Exception:
187
+ pass
188
+
189
+ dial = _dialeto()
190
+ st.caption(f"Dialeto do banco: **{dial}**")
191
+
192
+ # Estatísticas
193
+ stats = _sqlite_stats() if dial == "sqlite" else None
194
+
195
+ # Se não for SQLite, exibe recomendações
196
+ if dial != "sqlite":
197
+ st.info("Este monitor está otimizado para SQLite. Para PostgreSQL/MySQL, configure backup via ferramenta da plataforma (pg_dump/mysqldump) e agendamento externo.")
198
+ st.stop()
199
+
200
+ if not stats:
201
+ st.error("Banco SQLite não encontrado ou inacessível. Verifique o arquivo do banco ativo.")
202
+ return
203
+
204
+ # Painel de estatísticas
205
+ st.subheader("📊 Estatísticas")
206
+ colA, colB, colC = st.columns(3)
207
+ with colA:
208
+ st.metric("Arquivo", os.path.basename(stats["db_path"]))
209
+ st.metric("Tamanho do banco (arquivo)", _fmt_bytes(stats["size_bytes"]))
210
+ with colB:
211
+ st.metric("Páginas (PRAGMA)", f'{stats["page_count"]} × {stats["page_size"]} B')
212
+ st.metric("Cálculo (page_count×page_size)", _fmt_bytes(stats["calc_bytes"]))
213
+ with colC:
214
+ st.metric("Espaço livre no disco", _fmt_bytes(stats["disk_free"]))
215
+ st.metric("SQLite version", stats["sqlite_version"] or "—")
216
+
217
+ st.divider()
218
+
219
+ # Capacidade alvo e ocupação
220
+ st.subheader("🎯 Capacidade & Ocupação")
221
+ capacidade_gb = st.number_input("Capacidade alvo (GB) — alerta quando ultrapassar", min_value=0.1, value=1.0, step=0.1)
222
+ ocupacao_perc = min(100.0, (stats["size_bytes"] / (capacidade_gb * 1024**3)) * 100.0) if capacidade_gb > 0 else 0.0
223
+
224
+ st.progress(min(1.0, ocupacao_perc / 100.0))
225
+ st.caption(f"Ocupação estimada: **{ocupacao_perc:,.2f}%** de {capacidade_gb} GB")
226
+
227
+ if ocupacao_perc >= 80.0:
228
+ st.warning("⚠️ Ocupação acima de 80%. Considere backup/arquivamento.")
229
+
230
+ st.divider()
231
+
232
+ # Planejamento de backup
233
+ st.subheader("🗓️ Planejamento de Backup")
234
+ backup_dir = st.text_input("Pasta de backups", value="backups")
235
+ _ensure_dir(backup_dir) # garante a pasta
236
+ base_name = os.path.splitext(os.path.basename(stats["db_path"]))[0]
237
+ backups = _listar_backups(backup_dir, base_name)
238
+
239
+ # Último e próximo
240
+ ultimo_backup_dt = datetime.fromtimestamp(backups[0][2]) if backups else None
241
+ freq_dias = st.number_input("Frequência (dias)", min_value=1, value=7)
242
+ retencao = st.number_input("Retenção máx. de backups (arquivos)", min_value=1, value=10)
243
+ proximo_backup_dt = (ultimo_backup_dt + timedelta(days=freq_dias)) if ultimo_backup_dt else (datetime.now() + timedelta(days=freq_dias))
244
+
245
+ col1, col2, col3 = st.columns(3)
246
+ with col1:
247
+ st.metric("Último backup", ultimo_backup_dt.strftime("%d/%m/%Y %H:%M:%S") if ultimo_backup_dt else "—")
248
+ with col2:
249
+ st.metric("Próximo previsto", proximo_backup_dt.strftime("%d/%m/%Y %H:%M:%S"))
250
+ with col3:
251
+ st.metric("Backups atuais", len(backups))
252
+
253
+ # Aviso se vencido
254
+ if ultimo_backup_dt and datetime.now() >= proximo_backup_dt:
255
+ st.warning("⏰ Backup previsto já venceu. Execute agora para manter o plano.")
256
+
257
+ # Ações
258
+ st.subheader("⚙️ Ações")
259
+ colX, colY, colZ = st.columns(3)
260
+ with colX:
261
+ if st.button("💾 Backup agora"):
262
+ if _executar_backup(backup_dir):
263
+ st.rerun()
264
+ with colY:
265
+ if st.button("🧹 Limpar antigos (manter retenção)"):
266
+ _limpar_antigos(backup_dir, base_name, int(retencao))
267
+ st.rerun()
268
+ with colZ:
269
+ # Apenas mostra lista dos últimos backups
270
+ if backups:
271
+ st.write("Últimos backups:")
272
+ for f, full, mtime in backups[:5]:
273
+ dt = datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S")
274
+ st.caption(f"• {f} ({dt})")
275
+
276
+ # Auditoria de visualização (opcional)
277
+ registrar_log(st.session_state.get("usuario"), "MONITOR DB", "schema", None)
278
+
db_router.py ADDED
@@ -0,0 +1,152 @@