Roudrigus commited on
Commit
5be94d3
·
verified ·
1 Parent(s): 8591822

Update calendario.py

Browse files
Files changed (1) hide show
  1. calendario.py +781 -708
calendario.py CHANGED
@@ -1,708 +1,781 @@
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()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import streamlit as st
3
+ from datetime import date, datetime, timedelta
4
+ from typing import Dict, List, Optional
5
+
6
+ from banco import SessionLocal
7
+ from models import EventoCalendario
8
+ from utils_permissoes import verificar_permissao
9
+ from utils_auditoria import registrar_log
10
+ from utils_datas import formatar_data_br
11
+
12
+ # Tentativa segura do componente de calendário
13
+ try:
14
+ from streamlit_calendar import calendar
15
+ _HAS_CAL = True
16
+ _CAL_ERR = None
17
+ except Exception as _e:
18
+ _HAS_CAL = False
19
+ _CAL_ERR = _e
20
+
21
+ # Ambiente atual (opcional)
22
+ try:
23
+ from db_router import current_db_choice
24
+ _HAS_ROUTER = True
25
+ except Exception:
26
+ _HAS_ROUTER = False
27
+ def current_db_choice() -> str:
28
+ return "prod"
29
+
30
+ # =====================================================
31
+ # 📅 CALENDÁRIO + CRONOGRAMA ANUAL (D-3 / D-2 / D-1 / D 🚢)
32
+ # =====================================================
33
+
34
+ # ------------------------------
35
+ # ⚙️ Regras de embarque (fase/seed e passo)
36
+ # ------------------------------
37
+ REGRAS_FPSO: Dict[str, Dict[str, int]] = {
38
+ "ATD": {"seed_day": 1, "step": 5},
39
+ "ADG": {"seed_day": 1, "step": 5},
40
+ "CDM": {"seed_day": 2, "step": 5},
41
+ "CDP": {"seed_day": 2, "step": 5},
42
+ "CDS": {"seed_day": 2, "step": 5},
43
+ "CDI": {"seed_day": 5, "step": 5}, # (ACDI → CDI)
44
+ "CDA": {"seed_day": 5, "step": 5},
45
+ "SEP": {"seed_day": 4, "step": 4}, # sem dia vazio
46
+ "ESS": {"seed_day": 3, "step": 7}, # blocos com pausa maior
47
+ }
48
+
49
+ # 🎨 Paleta
50
+ COLOR_MAP = {
51
+ "D-3": "#00B050", # verde
52
+ "D-2": "#FF0000", # vermelho
53
+ "D-1": "#C00000", # vermelho escuro
54
+ "D": "#7F7F7F", # cinza
55
+ }
56
+
57
+ EMOJI_NAVIO = " 🚢" # adicionado aos títulos no dia D
58
+
59
+
60
+ # ------------------------------
61
+ # Helpers de ambiente e auditoria
62
+ # ------------------------------
63
+ def _usuario_atual() -> str:
64
+ return (st.session_state.get("usuario") or "sistema")
65
+
66
+ def _audit(acao: str, registro_id: Optional[int] = None):
67
+ """Chama registrar_log com ambiente quando possível (sem quebrar a UX)."""
68
+ try:
69
+ registrar_log(
70
+ usuario=_usuario_atual(),
71
+ acao=acao,
72
+ tabela="eventos_calendario",
73
+ registro_id=registro_id,
74
+ ambiente=current_db_choice() if _HAS_ROUTER else "prod",
75
+ )
76
+ except Exception:
77
+ # Mantém a UX mesmo se auditoria falhar
78
+ pass
79
+
80
+ def _can_access(mod_key: str = "calendario") -> bool:
81
+ """
82
+ Verifica permissão com assinatura ampla (perfil/usuario/ambiente) e,
83
+ se necessário, faz fallback para assinatura simples verificar_permissao(mod_key).
84
+ """
85
+ try:
86
+ return bool(
87
+ verificar_permissao(
88
+ perfil=st.session_state.get("perfil", "usuario"),
89
+ modulo_key=mod_key,
90
+ usuario=st.session_state.get("usuario"),
91
+ ambiente=current_db_choice() if _HAS_ROUTER else "prod",
92
+ )
93
+ )
94
+ except TypeError:
95
+ # Fallback assinatura simples
96
+ try:
97
+ return bool(verificar_permissao(mod_key))
98
+ except Exception:
99
+ return False
100
+ except Exception:
101
+ return False
102
+
103
+
104
+ # ------------------------------
105
+ # Builders de eventos do cronograma
106
+ # ------------------------------
107
+ def _criar_evento_fc(title: str, dt: date, color: str, extra: Dict = None) -> dict:
108
+ """Monta um evento no formato FullCalendar/streamlit_calendar."""
109
+ ev = {
110
+ "id": f"auto::{title}::{dt.isoformat()}",
111
+ "title": title,
112
+ "start": dt.isoformat(),
113
+ "allDay": True,
114
+ "color": color,
115
+ "extendedProps": {"gerado_auto": True},
116
+ }
117
+ if extra:
118
+ ev["extendedProps"].update(extra)
119
+ return ev
120
+
121
+ def _rotulo_antes_de_d(dias: int) -> str:
122
+ """Converte o deslocamento até D para rótulo: 0->D, 1->D-1, 2->D-2, 3->D-3, outros->''"""
123
+ if dias == 0:
124
+ return "D"
125
+ if dias in (1, 2, 3):
126
+ return f"D-{dias}"
127
+ return ""
128
+
129
+ def _gerar_cronograma_ano(
130
+ ano: int,
131
+ fpsos_sel: List[str],
132
+ incluir_anteriores: bool = True,
133
+ apenas_D: bool = False,
134
+ ) -> List[dict]:
135
+ """
136
+ Gera eventos 'D-3/D-2/D-1/D 🚢' para TODO o ano.
137
+ - incluir_anteriores: inclui D-1..D-3 que caem no começo do ano (vindo do D-semente).
138
+ - apenas_D: se True, somente 'D 🚢'.
139
+ """
140
+ events: List[dict] = []
141
+ dt_ini = date(ano, 1, 1)
142
+ dt_fim = date(ano, 12, 31)
143
+
144
+ for fpso in fpsos_sel:
145
+ cfg = REGRAS_FPSO.get(fpso)
146
+ if not cfg:
147
+ continue
148
+ seed_day = max(1, min(cfg["seed_day"], 28)) # segurança (fev)
149
+ seed = date(ano, 1, seed_day)
150
+ step = int(cfg["step"])
151
+
152
+ # Todos os D do ano
153
+ d = seed
154
+ while d <= dt_fim:
155
+ if d >= dt_ini:
156
+ # D (com emoji) + cor
157
+ titulo_d = f"{fpso} D{EMOJI_NAVIO}"
158
+ events.append(
159
+ _criar_evento_fc(
160
+ titulo_d, d, COLOR_MAP["D"],
161
+ {"tipo": "D", "fpso": fpso}
162
+ )
163
+ )
164
+ if not apenas_D:
165
+ # D-1..D-3
166
+ for k in (1, 2, 3):
167
+ dk = d - timedelta(days=k)
168
+ if dt_ini <= dk <= dt_fim:
169
+ label = f"D-{k}"
170
+ events.append(
171
+ _criar_evento_fc(
172
+ f"{fpso} {label}",
173
+ dk,
174
+ COLOR_MAP[label],
175
+ {"tipo": label, "fpso": fpso},
176
+ )
177
+ )
178
+ d += timedelta(days=step)
179
+
180
+ # Cobertura no início do ano (apenas rótulos anteriores ao D-semente)
181
+ if incluir_anteriores and not apenas_D:
182
+ for k in (1, 2, 3):
183
+ dk = seed - timedelta(days=k)
184
+ if dt_ini <= dk <= dt_fim:
185
+ label = f"D-{k}"
186
+ events.append(
187
+ _criar_evento_fc(
188
+ f"{fpso} {label}",
189
+ dk,
190
+ COLOR_MAP[label],
191
+ {"tipo": label, "fpso": fpso},
192
+ )
193
+ )
194
+ return events
195
+
196
+ def _gerar_cronograma_intervalo(
197
+ ano_ini: int,
198
+ ano_fim: int,
199
+ fpsos_sel: List[str],
200
+ apenas_D: bool = False,
201
+ ) -> List[dict]:
202
+ """Gera eventos para [ano_ini..ano_fim]."""
203
+ out: List[dict] = []
204
+ for y in range(ano_ini, ano_fim + 1):
205
+ out.extend(_gerar_cronograma_ano(y, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D))
206
+ return out
207
+
208
+ def _titulo_normalizado(titulo: str) -> str:
209
+ """Remove o emoji ' 🚢' apenas para comparação/deduplicação."""
210
+ return titulo.replace(EMOJI_NAVIO, "")
211
+
212
+ def _dedup_chave(titulo: str, data_evt: date) -> str:
213
+ """Chave de de-duplicação (título normalizado + data)."""
214
+ return f"{_titulo_normalizado(titulo)}::{data_evt.isoformat()}"
215
+
216
+
217
+ # ------------------------------
218
+ # Persistência no banco
219
+ # ------------------------------
220
+ def _gravar_cronograma_no_banco(db, eventos_fc: List[dict]) -> int:
221
+ """
222
+ Grava no banco eventos 'gerado_auto' evitando duplicados (ignorando emoji).
223
+ Retorna contagem de inserções.
224
+ """
225
+ if not eventos_fc:
226
+ return 0
227
+
228
+ min_day = min(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
229
+ max_day = max(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
230
+
231
+ existentes = (
232
+ db.query(EventoCalendario)
233
+ .filter(EventoCalendario.data_evento >= min_day)
234
+ .filter(EventoCalendario.data_evento <= max_day)
235
+ .filter(EventoCalendario.ativo.is_(True))
236
+ .all()
237
+ )
238
+ idx_existentes = {
239
+ _dedup_chave(e.titulo, e.data_evento): e.id for e in existentes
240
+ }
241
+
242
+ ins = 0
243
+ for ev in eventos_fc:
244
+ if not ev.get("extendedProps", {}).get("gerado_auto"):
245
+ continue
246
+ titulo = ev["title"]
247
+ dt = date.fromisoformat(ev["start"][:10])
248
+ k = _dedup_chave(titulo, dt)
249
+ if k in idx_existentes:
250
+ continue
251
+ novo = EventoCalendario(
252
+ titulo=titulo, # mantém o emoji nos D
253
+ descricao=f"Cronograma automático ({ev['extendedProps'].get('tipo','')})",
254
+ data_evento=dt,
255
+ data_lembrete=None,
256
+ ativo=True,
257
+ usuario_criacao=_usuario_atual(),
258
+ data_criacao=datetime.now(),
259
+ )
260
+ db.add(novo)
261
+ try:
262
+ db.commit()
263
+ ins += 1
264
+ except Exception:
265
+ db.rollback()
266
+ return ins
267
+
268
+ def _remover_cronograma_do_banco_intervalo(db, fpsos_sel: List[str], ano_ini: int, ano_fim: int) -> int:
269
+ """
270
+ Remove do banco os eventos gerados por este módulo, para [ano_ini..ano_fim] e FPSOs.
271
+ Busca por títulos ('<FPSO> – D' / ' – D 🚢' / ' – D-1/2/3') e data no intervalo.
272
+ """
273
+ ini = date(ano_ini, 1, 1)
274
+ fim = date(ano_fim, 12, 31)
275
+ total = 0
276
+ for fpso in fpsos_sel:
277
+ base = [f"{fpso} – D", f"{fpso} D-1", f"{fpso} – D-2", f"{fpso} – D-3"]
278
+ # inclui com emoji para D
279
+ variantes = base + [f"{fpso} – D{EMOJI_NAVIO}"]
280
+ to_del = (
281
+ db.query(EventoCalendario)
282
+ .filter(EventoCalendario.data_evento >= ini)
283
+ .filter(EventoCalendario.data_evento <= fim)
284
+ .filter(EventoCalendario.titulo.in_(variantes))
285
+ .all()
286
+ )
287
+ for e in to_del:
288
+ db.delete(e)
289
+ total += 1
290
+ try:
291
+ db.commit()
292
+ except Exception:
293
+ db.rollback()
294
+ return total
295
+
296
+
297
+ # ------------------------------
298
+ # UI principal
299
+ # ------------------------------
300
+ def main():
301
+ # =====================================================
302
+ # 🔒 PROTEÇÃO POR PERFIL
303
+ # =====================================================
304
+ if not _can_access("calendario"):
305
+ st.error("⛔ Acesso não autorizado.")
306
+ return
307
+
308
+ if not _HAS_CAL:
309
+ st.warning(
310
+ f"O componente `streamlit-calendar` não está disponível ({_CAL_ERR}). "
311
+ f"Adicione `streamlit-calendar==1.0.0` ao requirements.txt."
312
+ )
313
+ return
314
+
315
+ st.title("📅 Calendário e Lembretes")
316
+
317
+ hoje = date.today()
318
+ db = SessionLocal()
319
+
320
+ # Helper: cor por status (eventos do banco)
321
+ def _cor_evento_db(e: "EventoCalendario") -> str:
322
+ if not e.ativo:
323
+ return "#95a5a6" # Cinza
324
+ if e.data_evento < hoje:
325
+ return "#e74c3c" # Vermelho (passado)
326
+ if e.data_lembrete and e.data_lembrete == hoje:
327
+ return "#f39c12" # Laranja (lembrete hoje)
328
+ return "#2ecc71" # Verde (ativo futuro)
329
+
330
+ # Converte EventoCalendario do banco → FullCalendar
331
+ def _to_fc_event_db(e: "EventoCalendario") -> dict:
332
+ return {
333
+ "id": str(e.id),
334
+ "title": e.titulo,
335
+ "start": e.data_evento.isoformat(),
336
+ "allDay": True,
337
+ "color": _cor_evento_db(e),
338
+ "extendedProps": {
339
+ "descricao": (e.descricao or ""),
340
+ "data_evento": e.data_evento.isoformat(),
341
+ "data_lembrete": e.data_lembrete.isoformat() if e.data_lembrete else None,
342
+ "ativo": e.ativo,
343
+ "gerado_auto": False,
344
+ },
345
+ }
346
+
347
+ try:
348
+ # =====================================================
349
+ # 🔔 LEMBRETES DO DIA
350
+ # =====================================================
351
+ st.subheader("⏰ Lembretes de Hoje | Adicione o calendário da sua embarcação")
352
+
353
+ lembretes = (
354
+ db.query(EventoCalendario)
355
+ .filter(EventoCalendario.data_lembrete == hoje)
356
+ .filter(EventoCalendario.ativo.is_(True))
357
+ .order_by(EventoCalendario.data_evento)
358
+ .all()
359
+ )
360
+
361
+ if lembretes:
362
+ for l in lembretes:
363
+ st.warning(f"🔔 **{l.titulo}** Evento em {formatar_data_br(l.data_evento)}")
364
+ else:
365
+ st.info("Nenhum lembrete para hoje.")
366
+
367
+ st.divider()
368
+
369
+ # =====================================================
370
+ # 🎛️ CONTROLES DO CRONOGRAMA
371
+ # =====================================================
372
+ st.subheader("🛠️ Cronograma de Embarques (D-3 / D-2 / D-1 / D 🚢)")
373
+
374
+ col_a, col_b, col_c = st.columns([1, 2, 2])
375
+ with col_a:
376
+ ano_sel = st.number_input(
377
+ "Ano",
378
+ min_value=2000, max_value=2100,
379
+ value=hoje.year, step=1, key="cal_ano_sel"
380
+ )
381
+
382
+ fpsos_all = list(REGRAS_FPSO.keys())
383
+ with col_b:
384
+ fpsos_sel = st.multiselect(
385
+ "FPSOs",
386
+ options=fpsos_all,
387
+ default=fpsos_all,
388
+ key="cal_fpsos_sel",
389
+ )
390
+ if not fpsos_sel:
391
+ fpsos_sel = fpsos_all
392
+
393
+ with col_c:
394
+ apenas_D = st.checkbox("Exibir apenas dias de Embarque (D)", value=False)
395
+
396
+ # Gera cronograma em memória para o ANO selecionado (visualização)
397
+ eventos_auto = _gerar_cronograma_ano(
398
+ ano_sel, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D
399
+ )
400
+
401
+ # 🔁 Ações de banco: ANO
402
+ col_b1, col_b2, col_b3, col_b4 = st.columns([1.7, 1.7, 2, 2])
403
+ with col_b1:
404
+ if st.button("💾 Gravar cronograma (ano) no banco"):
405
+ qtd = _gravar_cronograma_no_banco(db, eventos_auto)
406
+ if qtd > 0:
407
+ _audit("CRIAR", None)
408
+ st.success(f"Cronograma do ano {ano_sel} gravado/atualizado. Inserções: {qtd}.")
409
+ st.rerun()
410
+ with col_b2:
411
+ if st.button("🧹 Remover cronograma (ano) do banco"):
412
+ qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, ano_sel)
413
+ if qtd > 0:
414
+ _audit("EXCLUIR", None)
415
+ st.warning(f"Eventos removidos do banco (ano {ano_sel}): {qtd}.")
416
+ st.rerun()
417
+
418
+ # 🔁 Ações de banco: INTERVALO ATÉ 2030
419
+ with col_b3:
420
+ if st.button("💾 Gravar cronograma até 2030 (banco)"):
421
+ eventos_lote = _gerar_cronograma_intervalo(
422
+ ano_ini=ano_sel, ano_fim=2030, fpsos_sel=fpsos_sel, apenas_D=apenas_D
423
+ )
424
+ qtd = _gravar_cronograma_no_banco(db, eventos_lote)
425
+ if qtd > 0:
426
+ _audit("CRIAR", None)
427
+ st.success(f"Cronogramas {ano_sel}–2030 gravados/atualizados. Inserções: {qtd}.")
428
+ st.rerun()
429
+ with col_b4:
430
+ if st.button("🧹 Remover cronograma até 2030 (banco)"):
431
+ qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, 2030)
432
+ if qtd > 0:
433
+ _audit("EXCLUIR", None)
434
+ st.warning(f"Eventos removidos do banco ({ano_sel}–2030): {qtd}.")
435
+ st.rerun()
436
+
437
+ st.caption(
438
+ "• A geração automática **não** altera seus eventos manuais. "
439
+ "Use os botões para **gravar** ou **remover** do banco apenas os eventos criados por este módulo. "
440
+ "Nos dias de **D**, o título inclui o ícone de navio (🚢)."
441
+ )
442
+
443
+ st.divider()
444
+
445
+ # =====================================================
446
+ # ➕ NOVO EVENTO / LEMBRETE (manual)
447
+ # =====================================================
448
+ with st.expander("➕ Novo Evento / Lembrete"):
449
+ with st.form("form_evento"):
450
+ titulo = st.text_input("Título *")
451
+ descricao = st.text_area("Descrição")
452
+ data_evento = st.date_input("Data do Evento", value=hoje, format="DD/MM/YYYY")
453
+
454
+ informar_lembrete = st.checkbox("Definir lembrete?")
455
+ data_lembrete = None
456
+ if informar_lembrete:
457
+ data_lembrete = st.date_input(
458
+ "Data do Lembrete",
459
+ value=hoje,
460
+ format="DD/MM/YYYY",
461
+ key="dt_lembrete_novo"
462
+ )
463
+
464
+ ativo = st.checkbox("Evento ativo", value=True)
465
+ salvar = st.form_submit_button("💾 Salvar Evento")
466
+
467
+ if salvar:
468
+ if not titulo.strip():
469
+ st.error("⚠️ O título é obrigatório.")
470
+ elif data_lembrete and (data_lembrete > data_evento):
471
+ st.error("⚠️ O lembrete não pode ser após a data do evento.")
472
+ else:
473
+ evento = EventoCalendario(
474
+ titulo=titulo.strip(),
475
+ descricao=(descricao or "").strip(),
476
+ data_evento=data_evento,
477
+ data_lembrete=data_lembrete,
478
+ ativo=ativo,
479
+ usuario_criacao=_usuario_atual(),
480
+ data_criacao=datetime.now()
481
+ )
482
+ db.add(evento)
483
+ try:
484
+ db.commit()
485
+ except Exception as e:
486
+ db.rollback()
487
+ st.error(f"❌ Erro ao salvar evento: {e}")
488
+ else:
489
+ _audit("CRIAR", getattr(evento, "id", None))
490
+ st.success(" Evento criado com sucesso!")
491
+ st.rerun()
492
+
493
+ st.divider()
494
+
495
+ # =====================================================
496
+ # 📆 CALENDÁRIO (eventos do banco + cronograma do ANO selecionado)
497
+ # =====================================================
498
+ st.subheader("📆 Calendário (clique no dia ou no evento para ver a observação)")
499
+
500
+ # Banco (apenas ano selecionado na visualização)
501
+ ini_year = date(ano_sel, 1, 1)
502
+ end_year = date(ano_sel, 12, 31)
503
+ eventos_db = (
504
+ db.query(EventoCalendario)
505
+ .filter(EventoCalendario.data_evento >= ini_year)
506
+ .filter(EventoCalendario.data_evento <= end_year)
507
+ .order_by(EventoCalendario.data_evento.asc())
508
+ .all()
509
+ )
510
+ eventos_fc_db = [_to_fc_event_db(e) for e in eventos_db]
511
+
512
+ # Junta cronograma automático (memória) + banco (para a visualização do ano)
513
+ eventos_fc = eventos_fc_db + eventos_auto
514
+
515
+ options = {
516
+ "initialView": "dayGridMonth",
517
+ "locale": "pt-br",
518
+ "height": 700,
519
+ "firstDay": 1,
520
+ "weekNumbers": False,
521
+ "headerToolbar": {
522
+ "left": "prev,next today",
523
+ "center": "title",
524
+ "right": "dayGridMonth,dayGridWeek,listWeek"
525
+ },
526
+ "buttonText": {
527
+ "today": "Hoje",
528
+ "month": "Mês",
529
+ "week": "Semana",
530
+ "day": "Dia",
531
+ "list": "Lista"
532
+ },
533
+ "dayMaxEventRows": True,
534
+ "navLinks": True,
535
+ }
536
+
537
+ state = calendar(
538
+ events=eventos_fc,
539
+ options=options,
540
+ custom_css="",
541
+ key=f"calendario_eventos_{ano_sel}"
542
+ )
543
+
544
+ # Legenda
545
+ with st.container():
546
+ cols = st.columns([1.2, 1.2, 1.2, 1.2, 2.2, 3])
547
+ cols[0].markdown("⬛ **D** (cinza) " + EMOJI_NAVIO)
548
+ cols[1].markdown("🟥 **D‑1** (vinho)")
549
+ cols[2].markdown("🟥 **D‑2** (vermelho)")
550
+ cols[3].markdown("🟩 **D‑3** (verde)")
551
+ cols[4].markdown("🟧 **Lembrete hoje (eventos do banco)**")
552
+ cols[5].markdown("🟦 **Outros eventos (banco)**")
553
+
554
+ st.divider()
555
+
556
+ # =====================================================
557
+ # 🔎 Detalhe por clique (evento ou dia)
558
+ # =====================================================
559
+ clicked_event = None
560
+ if isinstance(state, dict):
561
+ clicked_event = (state.get("eventClick") or {}).get("event")
562
+ clicked_date_str = (state.get("dateClick") or {}).get("dateStr")
563
+ else:
564
+ clicked_date_str = None
565
+
566
+ if clicked_event:
567
+ ev_id = clicked_event.get("id")
568
+ ev_title = clicked_event.get("title")
569
+ ev_start = clicked_event.get("start")
570
+ ev_ext = clicked_event.get("extendedProps") or {}
571
+
572
+ # Se for do banco, traz detalhes atualizados
573
+ e = None
574
+ if ev_id and not str(ev_id).startswith("auto::"):
575
+ try:
576
+ # SQLAlchemy 2.x: use Session.get(Model, pk)
577
+ e = db.get(EventoCalendario, int(ev_id))
578
+ except Exception:
579
+ e = None
580
+
581
+ st.subheader(f"📌 {ev_title or 'Evento'}")
582
+ if e:
583
+ st.markdown(
584
+ f"""
585
+ **Descrição:**
586
+ {e.descricao or "_Sem descrição_"}
587
+
588
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
589
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete) if e.data_lembrete else "_Sem lembrete_"}
590
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
591
+ """
592
+ )
593
+ if _can_access("administracao"):
594
+ col1, col2 = st.columns(2)
595
+ with col1:
596
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_{e.id}"):
597
+ e.ativo = False
598
+ try:
599
+ db.commit()
600
+ except Exception as ex:
601
+ db.rollback()
602
+ st.error(f"Erro ao desativar: {ex}")
603
+ else:
604
+ _audit("DESATIVAR", e.id)
605
+ st.success("Evento desativado.")
606
+ st.rerun()
607
+ with col2:
608
+ if st.button("🗑️ Excluir", key=f"excluir_{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
+ _audit("EXCLUIR", e.id)
617
+ st.success("Evento excluído.")
618
+ st.rerun()
619
+ else:
620
+ # Evento do cronograma automático (memória)
621
+ try:
622
+ dt_evt = date.fromisoformat(ev_start[:10])
623
+ except Exception:
624
+ dt_evt = None
625
+ st.markdown(
626
+ f"""
627
+ **FPSO:** {ev_title.split(' ')[0] if ev_title and ' – ' in ev_title else '—'}
628
+ **Tipo:** {ev_ext.get('tipo', '—')}
629
+ **📅 Data:** {formatar_data_br(dt_evt) if dt_evt else ''}
630
+ **Origem:** _Cronograma automático (não gravado no banco)_
631
+ """
632
+ )
633
+
634
+ elif clicked_date_str:
635
+ try:
636
+ data_clicada = date.fromisoformat(clicked_date_str)
637
+ except Exception:
638
+ data_clicada = None
639
+
640
+ if data_clicada:
641
+ st.subheader(f"🗓️ Eventos em {formatar_data_br(data_clicada)}")
642
+
643
+ # Banco
644
+ eventos_no_dia_db = (
645
+ db.query(EventoCalendario)
646
+ .filter(EventoCalendario.data_evento == data_clicada)
647
+ .order_by(EventoCalendario.id.desc())
648
+ .all()
649
+ )
650
+ if not eventos_no_dia_db:
651
+ st.info("Nenhum evento do banco para este dia.")
652
+ else:
653
+ st.markdown("**📦 Eventos do banco**")
654
+ for e in eventos_no_dia_db:
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) if e.data_lembrete else "_Sem lembrete_"}
663
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
664
+ """
665
+ )
666
+ if _can_access("administracao"):
667
+ c1, c2 = st.columns(2)
668
+ with c1:
669
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_list_{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
+ _audit("DESATIVAR", e.id)
678
+ st.success("Evento desativado.")
679
+ st.rerun()
680
+ with c2:
681
+ if st.button("🗑️ Excluir", key=f"excluir_list_{e.id}"):
682
+ db.delete(e)
683
+ try:
684
+ db.commit()
685
+ except Exception as ex:
686
+ db.rollback()
687
+ st.error(f"Erro ao excluir: {ex}")
688
+ else:
689
+ _audit("EXCLUIR", e.id)
690
+ st.success("Evento excluído.")
691
+ st.rerun()
692
+
693
+ # Cronograma automático (memória) – ano selecionado
694
+ eventos_auto_no_dia = [
695
+ ev for ev in eventos_auto
696
+ if ev.get("start", "")[:10] == data_clicada.isoformat()
697
+ ]
698
+ if eventos_auto_no_dia:
699
+ st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
700
+ for ev in sorted(eventos_auto_no_dia, key=lambda x: x.get("title","")):
701
+ fps = ev.get("title","").split(" ")[0] if " – " in ev.get("title","") else "—"
702
+ tipo = ev.get("extendedProps", {}).get("tipo", "")
703
+ st.write(f" **{fps}** **{tipo}** ({formatar_data_br(data_clicada)})")
704
+
705
+ st.divider()
706
+
707
+ # =====================================================
708
+ # 📆 Consultar Eventos por Data (modo antigo) — inclui AUTO
709
+ # =====================================================
710
+ with st.expander("📆 Consultar Eventos por Data (modo antigo)"):
711
+ data_consulta = st.date_input("Selecione uma data",
712
+ value=hoje, format="DD/MM/YYYY",
713
+ key="consulta_antiga")
714
+
715
+ # Banco
716
+ eventos = (
717
+ db.query(EventoCalendario)
718
+ .filter(EventoCalendario.data_evento == data_consulta)
719
+ .order_by(EventoCalendario.id.desc())
720
+ .all()
721
+ )
722
+ if not eventos:
723
+ st.info("Nenhum evento do banco para esta data.")
724
+ else:
725
+ st.markdown("**📦 Eventos do banco**")
726
+ for e in eventos:
727
+ with st.expander(f"📌 {e.titulo}"):
728
+ st.markdown(
729
+ f"""
730
+ **Descrição:**
731
+ {e.descricao or "_Sem descrição_"}
732
+
733
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
734
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete) if e.data_lembrete else "_Sem lembrete_"}
735
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
736
+ """
737
+ )
738
+ if _can_access("administracao"):
739
+ col1, col2 = st.columns(2)
740
+ with col1:
741
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_old_{e.id}"):
742
+ e.ativo = False
743
+ try:
744
+ db.commit()
745
+ except Exception as ex:
746
+ db.rollback()
747
+ st.error(f"Erro ao desativar: {ex}")
748
+ else:
749
+ _audit("DESATIVAR", e.id)
750
+ st.success("Evento desativado.")
751
+ st.rerun()
752
+ with col2:
753
+ if st.button("🗑️ Excluir", key=f"excluir_old_{e.id}"):
754
+ db.delete(e)
755
+ try:
756
+ db.commit()
757
+ except Exception as ex:
758
+ db.rollback()
759
+ st.error(f"Erro ao excluir: {ex}")
760
+ else:
761
+ _audit("EXCLUIR", e.id)
762
+ st.success("Evento excluído.")
763
+ st.rerun()
764
+
765
+ # AUTO (memória) no ano selecionado
766
+ eventos_auto_antigo = [
767
+ ev for ev in eventos_auto
768
+ if ev.get("start", "")[:10] == data_consulta.isoformat()
769
+ ]
770
+ if eventos_auto_antigo:
771
+ st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
772
+ for ev in sorted(eventos_auto_antigo, key=lambda x: x.get("title","")):
773
+ fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—"
774
+ tipo = ev.get("extendedProps", {}).get("tipo", "—")
775
+ st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_consulta)})")
776
+
777
+ finally:
778
+ try:
779
+ db.close()
780
+ except Exception:
781
+ pass