eptm_dashboard/eptm_dashboard/pages/fiche.py
Julien Balet 7d3b6e9136 v1.1.0 — fixes sync + UX dev/prod
Sync push_then_sync : préserve les absences 'publiee_escada' contre
écrasement/orphelines après push (PDF Escada stale). UI reconnaît le
statut (calendrier, éditeur, KPIs) au lieu d'afficher 'présent'.

Sync_esacada : timeout grille 20s → 45s + retry après reload (AUTOMAT 1
échouait à la 1re classe après changement de langue).

Telegram : ajoute liste d'erreurs + tail du log dans les notifs d'échec
même en mode normal — avant on avait juste 'a échoué (code 1)'.

UX :
- Calendrier toujours visible (même sans absences) et démarre sur le
  mois courant (pas sur le 1er mois d'absence) ; tous les jours
  cliquables pour pouvoir ajouter une absence.
- Date du jour pré-sélectionnée aussi via navigate_to (clic depuis
  /classe).
- KPIs cards taggées kpi-card/kpi-value pour CSS responsive mobile.
- Badge 'DEV' dans la sidebar (APP_ENV=dev) — invisible en prod.
- Badge 'Built with Reflex' masqué.
- KPIs retirés du dashboard /accueil.

Prod :
- Dockerfile.prod multi-stage (Reflex export bundle + runtime slim).
- docker-compose.prod.yml séparé (port 3002, projet eptm-dashboard-prod).
- .gitignore + .dockerignore nettoyés.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:11:39 +02:00

2229 lines
97 KiB
Python

import base64
import calendar
import io
import json
import os
import reflex as rx
from datetime import date, datetime, timedelta
from pathlib import Path
from sqlalchemy import select
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
_SETTINGS_FILE = DATA_DIR / "settings.json"
from ..state import AuthState
from ..sidebar import layout
from src.db import (
get_session, Apprenti, Absence, ApprentiFiche,
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
ApprentiNotice,
upsert_escada_pending,
)
from src.stats import nb_blocs_absences
from src.parser_bn import sem_short_label, sem_year_only
from src.email_sender import build_template_vars, render_template
from src.logger import app_log
from src.user_access import get_allowed_classes, is_class_allowed
from ..components import empty_state
from .retenue import RetenueState, retenue_modal
from .sanction import SanctionState, sanction_modal
MOIS_FR = [
"janvier", "fevrier", "mars", "avril", "mai", "juin",
"juillet", "aout", "septembre", "octobre", "novembre", "decembre",
]
QUOTA = 5
_GROUP_LABELS = {
"CG": "Culture Gen.",
"BP": "Branches Prof.",
"TP": "Trav. Pratiques",
}
_GROUP_ORDER = {"DUAL": ["CG", "BP"], "EM": ["BP", "TP"]}
def _read_settings() -> dict:
if _SETTINGS_FILE.exists():
try:
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
# ── HTML generators ───────────────────────────────────────────────────────────
def _bn_fmt(v) -> str:
if v is None:
return ""
try:
return f"{float(v):.1f}".replace(".", ",")
except (TypeError, ValueError):
return ""
def _bn_cell_style(v) -> str:
base = "border:1px solid #dee2e6;padding:5px 10px;text-align:center"
if v is None:
return f"{base};color:#bbb"
try:
if float(v) < 4.0:
return f"{base};background:#ffcccc;color:#B71C1C;font-weight:bold"
except (TypeError, ValueError):
pass
return base
def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
N = 8
TD = "border:1px solid #dee2e6;padding:5px 10px"
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
SEP = ";border-top:3px solid #9e9e9e"
# Fond pour les lignes "Moyenne ..." — pas gris (déjà utilisé par les
# en-têtes de groupe), juste un bleu très pâle pour les distinguer.
MOY_BG = "background:#f0f7ff"
header = f'<th style="{TH};text-align:left;min-width:230px"></th>'
for i in range(N):
raw = sem_labels[i] if i < len(sem_labels) else None
short = sem_short_label(raw, i)
year = sem_year_only(raw)
year_html = (
f'<div style="font-size:0.78em;color:#666;font-weight:normal;margin-top:1px">{year}</div>'
if year else ""
)
header += f'<th style="{TH}"><div style="font-weight:700">{short}</div>{year_html}</th>'
def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
cells += f'<td style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for year_start in range(0, N, 2):
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
cells += f'<td colspan="2" style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _branch_row(branche, sep=False):
s = SEP if sep else ""
cells = f'<td style="{TD}{s}">{branche["nom"]}</td>'
notes = branche.get("notes") or [None] * N
for i in range(N):
v = notes[i] if i < len(notes) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _group_header_row(label, sep=False):
s = SEP if sep else ""
return (
f'<tr><td colspan="{N + 1}" style="{TD};font-weight:bold;'
f'background:#f0f0f0{s}">{label}</td></tr>'
)
body = ""
for grp in groups_order:
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
lbl = _GROUP_LABELS.get(grp, grp)
# En-tête du groupe — séparation visuelle au-dessus (y compris du 1er,
# pour le détacher de la ligne d'en-tête des semestres).
body += _group_header_row(lbl, sep=True)
# Branches individuelles du groupe (Anglais, Automatisation, …)
for br in gd.get("branches", []) or []:
body += _branch_row(br)
# Moyennes du groupe
body += _moy_sem_row("Moyenne semestrielle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
body += _moy_ann_row("Moyenne annuelle globale", d["globale"], f"{TD};font-weight:bold")
return (
f'<div style="overflow-x:auto;max-width:100%;margin-bottom:16px">'
f'<table style="min-width:560px;width:100%;border-collapse:collapse;font-size:0.875em">'
f"<thead><tr>{header}</tr></thead>"
f"<tbody>{body}</tbody>"
f"</table></div>"
)
def _matu_html_table(nm) -> str:
TD = "border:1px solid #dee2e6;padding:5px 10px"
TDc = f"{TD};text-align:center"
def _cell_moy(v):
if v is None:
return f'<td style="{TDc};color:#bbb">&#8212;</td>'
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if v < 4.0 else TDc
return f'<td style="{style}">{_bn_fmt(v)}</td>'
def _cell_prom(p, info):
if not p:
return f'<td style="{TDc};color:#bbb">&#8212;</td><td style="{TD}"></td>'
red = p == "NB"
style = f"{TDc};background:#ffcccc;color:#B71C1C;font-weight:bold" if red else TDc
return (
f'<td style="{style}">{p}</td>'
f'<td style="{TD};color:#555;font-style:italic">{info or ""}</td>'
)
rows = (
f'<tr><td style="{TD}">Moyenne du semestre</td>{_cell_moy(nm.moy)}<td style="{TD}"></td></tr>'
f'<tr><td style="{TD}">Promotion</td>{_cell_prom(nm.promotion, nm.prom_info)}</tr>'
)
header_div = (
f'<div style="font-size:0.95em;font-weight:bold;padding:6px 12px;'
f'background:#f9fbe7;border-radius:4px 4px 0 0;border:1px solid #dee2e6;'
f'border-bottom:none">Matu &#8212; {nm.classe_mp} &#8212; {nm.sem_label}</div>'
)
return (
f'<div style="overflow-x:auto;margin-bottom:16px">{header_div}'
f'<table style="border-collapse:collapse;font-size:0.875em;border-top:none">'
f"<tbody>{rows}</tbody></table></div>"
)
def _render_notes_html(notes_data: list) -> str:
html = (
"<style>"
".nt table{border-collapse:collapse;width:100%;font-size:0.82rem;margin-bottom:2px}"
".nt th{background:#f0f2f6;padding:5px 10px;text-align:left;font-size:0.78rem;"
"color:#555;font-weight:600;letter-spacing:.02em}"
".nt td{padding:5px 10px;border-bottom:1px solid #f0f0f0;vertical-align:middle}"
".nt tr:last-child td{border-bottom:none}"
".nt .bh{display:flex;justify-content:space-between;align-items:center;"
"margin:18px 0 0;padding:7px 12px;border-radius:6px 6px 0 0;"
"background:#e8eaf6;border-left:4px solid #3949ab}"
".nt .bh.insuf{background:#ffebee;border-left:4px solid #c62828}"
".nt .bn{font-weight:700;font-size:0.9rem;color:#1a237e;letter-spacing:.01em}"
".nt .bh.insuf .bn{color:#b71c1c}"
".nt .moy{font-size:0.85rem;color:#555}"
"</style><div class='nt'>"
)
for _br in notes_data:
_moy = _br.get("moy_arr")
_moy_prov = _br.get("moy_prov")
_insuf = _moy is not None and float(_moy) < 4.0
_mc = "#c62828" if _insuf else ("#e65100" if _moy and float(_moy) < 5.0 else "#2e7d32")
_br_name = ("&#9888; " if _insuf else "") + _br["branche"]
_moy_html = (
f'<span style="font-weight:700;color:{_mc}">{_moy}</span>'
+ (f'&nbsp;<span style="font-size:0.8em;color:#888">({_moy_prov})</span>'
if _moy_prov is not None else "")
) if _moy is not None else "&#8212;"
_insuf_cls = " insuf" if _insuf else ""
html += (
f'<div class="bh{_insuf_cls}">'
f'<span class="bn">{_br_name}</span>'
f'<span class="moy">Moyenne&nbsp;: {_moy_html}</span></div>'
'<div style="overflow-x:auto;max-width:100%">'
'<table style="table-layout:fixed;min-width:520px;width:100%">'
'<colgroup>'
'<col style="width:90px">'
'<col>'
'<col style="width:90px">'
'<col style="width:55px">'
'<col style="width:110px">'
'<col style="width:65px">'
'</colgroup>'
"<tr><th>Date</th><th>Examen</th><th>Enseignant</th>"
"<th>Coeff</th><th>Type</th><th>Note</th></tr>"
)
for _ex in _br.get("examens", []):
_n = _ex["note"]
if _n is None:
_note_html = '<span style="color:#bbb">&#8212;</span>'
elif _n == "disp.":
_note_html = '<span style="color:#888;font-style:italic">disp.</span>'
else:
_nc = "#c62828" if float(_n) < 4.0 else ("#e65100" if float(_n) < 5.0 else "#2e7d32")
_disp_tag = (
' <span style="font-size:0.75em;color:#888">[disp.]</span>'
if _ex.get("dispensed") else ""
)
_note_html = f'<span style="font-weight:700;color:{_nc}">{_n}</span>{_disp_tag}'
html += (
f'<tr><td>{_ex["date"]}</td>'
f'<td>{_ex["description"]}</td>'
f'<td style="color:#555">{_ex["enseignant"]}</td>'
f'<td style="text-align:center">{_ex["coefficient"] or ""}</td>'
f'<td style="color:#777">{_ex["type"]}</td>'
f'<td style="text-align:center">{_note_html}</td></tr>'
)
html += "</table></div>"
html += "</div>"
return html
def _absence_pdf_apprenti(sess, apprenti) -> bytes:
from reportlab.lib import colors as _rl_colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
absences = sess.execute(
select(Absence)
.where(Absence.apprenti_id == apprenti.id)
.order_by(Absence.date, Absence.periode)
).scalars().all()
by_date: dict = {}
for ab in absences:
# publiee_escada conserve son type via type_origine ; sinon les abs
# pushées seraient toutes marquées "N" même si elles étaient excusées.
is_e = ab.statut == "excusee" or (
ab.statut == "publiee_escada" and ab.type_origine == "E"
)
by_date.setdefault(ab.date, {})[ab.periode] = "E" if is_e else "N"
sorted_dates = sorted(by_date)
blocs: list = []
if sorted_dates:
dates_set = set(sorted_dates)
cur = [sorted_dates[0]]
for d in sorted_dates[1:]:
check = cur[-1] + timedelta(days=1)
gap_ok = True
while check < d:
if check.weekday() < 5 and check not in dates_set:
gap_ok = False
break
check += timedelta(days=1)
if gap_ok:
cur.append(d)
else:
blocs.append(cur)
cur = [d]
blocs.append(cur)
DARK = _rl_colors.HexColor("#37474F")
BLUE_BG = _rl_colors.HexColor("#E3F2FD")
BLUE_FG = _rl_colors.HexColor("#0D47A1")
RED_BG = _rl_colors.HexColor("#FFEBEE")
RED_FG = _rl_colors.HexColor("#B71C1C")
GREY_BG = _rl_colors.HexColor("#F5F5F5")
FOOT_BG = _rl_colors.HexColor("#ECEFF1")
data = [["Abs.", "Date"] + [f"P{i}" for i in range(1, 11)]]
styles_tbl = [
("BACKGROUND", (0, 0), (-1, 0), DARK),
("TEXTCOLOR", (0, 0), (-1, 0), _rl_colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, _rl_colors.HexColor("#CCCCCC")),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [_rl_colors.white, GREY_BG]),
]
total_e = total_n = 0
row_idx = 1
for bloc_num, bloc_dates in enumerate(blocs, start=1):
first_row = row_idx
for i, d in enumerate(bloc_dates):
periods = by_date[d]
row = [str(bloc_num) if i == 0 else "", d.strftime("%d.%m.%Y")]
for p in range(1, 11):
val = periods.get(p, "")
row.append(val)
if val == "E":
total_e += 1
styles_tbl += [
("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), BLUE_BG),
("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), BLUE_FG),
("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
]
elif val == "N":
total_n += 1
styles_tbl += [
("BACKGROUND", (p + 1, row_idx), (p + 1, row_idx), RED_BG),
("TEXTCOLOR", (p + 1, row_idx), (p + 1, row_idx), RED_FG),
("FONTNAME", (p + 1, row_idx), (p + 1, row_idx), "Helvetica-Bold"),
]
data.append(row)
row_idx += 1
if row_idx - first_row > 1:
styles_tbl.append(("SPAN", (0, first_row), (0, row_idx - 1)))
total_row = row_idx
total_periodes = total_e + total_n
footer_label = (
f"{len(blocs)} absence(s) | "
f"{total_periodes} période(s) | "
f"{total_e} excusee(s) | "
f"{total_n} non excusee(s)"
)
data.append([footer_label] + [""] * 11)
styles_tbl += [
("SPAN", (0, total_row), (-1, total_row)),
("BACKGROUND", (0, total_row), (-1, total_row), FOOT_BG),
("FONTNAME", (0, total_row), (-1, total_row), "Helvetica-Bold"),
("ALIGN", (0, total_row), (-1, total_row), "LEFT"),
("LEFTPADDING", (0, total_row), (-1, total_row), 8),
]
col_w = [1.5 * cm, 2.8 * cm] + [2.24 * cm] * 10
t = Table(data if len(data) > 1 else [data[0]], colWidths=col_w, repeatRows=1)
t.setStyle(TableStyle(styles_tbl))
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf, pagesize=landscape(A4),
leftMargin=1.5 * cm, rightMargin=1.5 * cm,
topMargin=1.5 * cm, bottomMargin=1.5 * cm,
)
styles = getSampleStyleSheet()
title = Paragraph(
f"<b>Absences - {apprenti.nom} {apprenti.prenom}</b>"
f" Classe : {apprenti.classe}",
styles["Normal"],
)
doc.build([title, Spacer(1, 0.5 * cm), t])
return buf.getvalue()
def _extract_bn_pages(pdf_path, nom: str, prenom: str) -> bytes | None:
try:
import pdfplumber
from pypdf import PdfWriter, PdfReader
except ImportError:
return None
try:
pages_to_extract = []
with pdfplumber.open(str(pdf_path)) as pdf:
for i, page in enumerate(pdf.pages):
text = page.extract_text() or ""
if nom.upper() in text.upper() and prenom.upper() in text.upper():
pages_to_extract.append(i)
if not pages_to_extract:
return None
reader = PdfReader(str(pdf_path))
writer = PdfWriter()
for i in pages_to_extract:
writer.add_page(reader.pages[i])
buf = io.BytesIO()
writer.write(buf)
return buf.getvalue()
except Exception:
return None
# ── State ─────────────────────────────────────────────────────────────────────
class FicheState(AuthState):
# ── Apprenti selector ────────────────────────────────────────────────────
apprenti_labels: list[str] = []
apprenti_ids: list[int] = []
selected_label: str = ""
selected_id: int = 0
has_apprentis: bool = False
apprenti_search: str = ""
apprenti_select_open: bool = False
@rx.var
def filtered_apprenti_labels(self) -> list[str]:
q = self.apprenti_search.lower().strip()
if not q:
return self.apprenti_labels
return [l for l in self.apprenti_labels if q in l.lower()]
# ── KPIs ─────────────────────────────────────────────────────────────────
kpi_total: int = 0
kpi_excusees: int = 0
kpi_non_excusees: int = 0
kpi_blocs: int = 0
quota_atteint: bool = False
# ── Calendar ─────────────────────────────────────────────────────────────
cal_year: int = 0
cal_month: int = 0
cal_month_name: str = ""
cal_prev_name: str = ""
cal_next_name: str = ""
cal_days: list[dict] = []
# ── Calendar day edit ─────────────────────────────────────────────────────
edit_date: str = ""
edit_date_label: str = ""
edit_day_type: str = "" # "theorie" | "pratique" | "matu" | ""
edit_day_type_label: str = "" # "Théorie" | "Pratique" | "Matu" | ""
edit_day_has_schedule: bool = False # True si périodes configurées pour ce jour
edit_p1: str = "present"
edit_p2: str = "present"
edit_p3: str = "present"
edit_p4: str = "present"
edit_p5: str = "present"
edit_p6: str = "present"
edit_p7: str = "present"
edit_p8: str = "present"
edit_p9: str = "present"
edit_p10: str = "present"
# Snapshot des choix au chargement (pour détecter les modifications non
# enregistrées). Mis à jour par _load_day_choices().
initial_p1: str = "present"
initial_p2: str = "present"
initial_p3: str = "present"
initial_p4: str = "present"
initial_p5: str = "present"
initial_p6: str = "present"
initial_p7: str = "present"
initial_p8: str = "present"
initial_p9: str = "present"
initial_p10: str = "present"
# ── Escada fiche ─────────────────────────────────────────────────────────
fiche_available: bool = False
fiche_adresse: str = ""
fiche_cp_localite: str = ""
fiche_telephone: str = ""
fiche_email_val: str = ""
fiche_date_naissance: str = ""
fiche_majeur: str = ""
fiche_compensation: str = ""
# Représentant légal (mineurs)
fiche_resp_legal_nom: str = ""
fiche_resp_legal_adresse: str = ""
fiche_resp_legal_cp_localite: str = ""
fiche_resp_legal_tel_p: str = "" # numéro brut
fiche_resp_legal_tel_n: str = "" # numéro brut
# URLs Google Maps construites depuis adresse+CP+localité
fiche_map_url: str = ""
fiche_entreprise_map_url: str = ""
fiche_resp_legal_map_url: str = ""
fiche_entreprise_nom: str = ""
fiche_entreprise_adresse: str = ""
fiche_entreprise_cp_localite: str = ""
fiche_entreprise_telephone: str = ""
fiche_entreprise_email: str = ""
fiche_formateur_nom: str = ""
fiche_formateur_email: str = ""
fiche_updated_at: str = ""
# ── Bulletin de notes ─────────────────────────────────────────────────────
has_bn: bool = False
bn_html: str = ""
bn_caption: str = ""
has_notes: bool = False
notes_html: str = ""
bn_pdf_fichier: str = ""
has_pdf_bn: bool = False
has_pdf_notes: bool = False
# ── Notices Escada ────────────────────────────────────────────────────────
has_notices: bool = False
notices_data: list[dict] = []
# ── Email ─────────────────────────────────────────────────────────────────
smtp_ok: bool = False
email_dest: str = "apprenti"
email_custom: str = ""
email_subject: str = ""
email_body: str = ""
email_attach_abs: bool = True
email_attach_bn: bool = False
email_attach_notes: bool = False
email_sending: bool = False
email_sent: bool = False
email_error: str = ""
# ── Setters (edit periods) ────────────────────────────────────────────────
# Note: rx.segmented_control passe str | list[str] — on coerce.
@staticmethod
def _coerce_period(v) -> str:
if isinstance(v, list):
return v[0] if v else "present"
return v or "present"
def set_edit_p1(self, v): self.edit_p1 = self._coerce_period(v)
def set_edit_p2(self, v): self.edit_p2 = self._coerce_period(v)
def set_edit_p3(self, v): self.edit_p3 = self._coerce_period(v)
def set_edit_p4(self, v): self.edit_p4 = self._coerce_period(v)
def set_edit_p5(self, v): self.edit_p5 = self._coerce_period(v)
def set_edit_p6(self, v): self.edit_p6 = self._coerce_period(v)
def set_edit_p7(self, v): self.edit_p7 = self._coerce_period(v)
def set_edit_p8(self, v): self.edit_p8 = self._coerce_period(v)
def set_edit_p9(self, v): self.edit_p9 = self._coerce_period(v)
def set_edit_p10(self, v): self.edit_p10 = self._coerce_period(v)
# ── Setters (email) ───────────────────────────────────────────────────────
def set_email_dest(self, v: str): self.email_dest = v
def set_email_custom(self, v: str): self.email_custom = v
def set_email_subject(self, v: str): self.email_subject = v
def set_email_body(self, v: str): self.email_body = v
def toggle_attach_abs(self, v: bool): self.email_attach_abs = v
def toggle_attach_bn(self, v: bool): self.email_attach_bn = v
def toggle_attach_notes(self, v: bool): self.email_attach_notes = v
# ── Page load ─────────────────────────────────────────────────────────────
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
sess = get_session()
allowed = get_allowed_classes(self.username)
q = select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom)
if allowed is not None:
q = q.where(Apprenti.classe.in_(allowed))
apprentis = sess.execute(q).scalars().all()
if not apprentis:
self.has_apprentis = False
self.apprenti_labels = []
self.apprenti_ids = []
return
self.has_apprentis = True
self.apprenti_labels = [
f"{a.nom} {a.prenom} ({a.classe})" for a in apprentis
]
self.apprenti_ids = [a.id for a in apprentis]
if self.selected_id == 0 or self.selected_id not in self.apprenti_ids:
self.selected_id = self.apprenti_ids[0]
self.selected_label = self.apprenti_labels[0]
self._reload(reset_email=True)
self._select_today()
def handle_select(self, label: str):
self.selected_label = label
try:
idx = self.apprenti_labels.index(label)
self.selected_id = self.apprenti_ids[idx]
except ValueError:
pass
self.edit_date = ""
self.apprenti_select_open = False
self.apprenti_search = ""
self._reload(reset_email=True)
self._select_today()
def _select_today(self):
"""Pré-sélectionne la date du jour dans le panneau d'édition."""
today_iso = date.today().isoformat()
self._load_day_choices(today_iso)
self.edit_date = today_iso
def set_apprenti_search(self, v: str):
self.apprenti_search = v
def set_apprenti_select_open(self, v: bool):
self.apprenti_select_open = v
if not v:
self.apprenti_search = ""
def apprenti_search_keydown(self, key: str):
"""Enter → sélectionne le 1er résultat. Esc → ferme."""
if key == "Enter":
results = self.filtered_apprenti_labels
if results:
return FicheState.handle_select(results[0])
elif key == "Escape":
self.apprenti_select_open = False
self.apprenti_search = ""
def navigate_to(self, apprenti_id: int):
# Garde-fou : revérifie que l'apprenti est dans le scope autorisé
sess = get_session()
try:
ap = sess.get(Apprenti, apprenti_id)
if ap is None or not is_class_allowed(self.username, ap.classe):
return
finally:
sess.close()
# Si l'apprenti n'est pas dans la liste actuelle (ex: liste pas encore
# chargée), on la recharge — load_data filtre déjà selon les droits.
if apprenti_id not in self.apprenti_ids:
self.load_data()
if apprenti_id in self.apprenti_ids:
idx = self.apprenti_ids.index(apprenti_id)
self.selected_id = apprenti_id
self.selected_label = self.apprenti_labels[idx]
self._reload(reset_email=True)
self._select_today()
# ── Calendar navigation ───────────────────────────────────────────────────
def prev_month(self):
if self.cal_month == 1:
self.cal_month = 12
self.cal_year -= 1
else:
self.cal_month -= 1
self._rebuild_calendar()
def next_month(self):
if self.cal_month == 12:
self.cal_month = 1
self.cal_year += 1
else:
self.cal_month += 1
self._rebuild_calendar()
# ── Calendar day edit ─────────────────────────────────────────────────────
def _load_day_choices(self, date_str: str):
"""Met à jour edit_p1..p10 + edit_date_label pour la date donnée."""
sess = get_session()
d = date.fromisoformat(date_str)
absences = sess.execute(
select(Absence).where(
Absence.apprenti_id == self.selected_id,
Absence.date == d,
)
).scalars().all()
# Horaire de classe (settings.json) : type + périodes pour ce jour.
ap = sess.get(Apprenti, self.selected_id) if self.selected_id else None
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
day_key = day_names[d.weekday()]
d_type = ""
d_periods: list[int] = []
if ap:
settings = _read_settings()
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
entry = class_sch.get(day_key)
if isinstance(entry, dict):
d_type = (entry.get("type") or "").strip()
d_periods = list(entry.get("periods") or [])
elif isinstance(entry, list):
d_periods = list(entry)
self.edit_day_type = d_type
self.edit_day_type_label = {
"theorie": "Théorie", "pratique": "Pratique", "matu": "Matu",
}.get(d_type, "")
self.edit_day_has_schedule = bool(d_periods)
# On garde statut + type_origine pour pouvoir distinguer une absence
# déjà publiée sur Escada (statut="publiee_escada") qui doit s'afficher
# selon son type d'origine (E ou N), sinon elle apparaîtrait comme
# "présent" après chaque push_then_sync.
pm = {ab.periode: (ab.statut, ab.type_origine) for ab in absences}
def _choice(p: int) -> str:
item = pm.get(p)
if item is None:
return "present"
s, t = item
if s == "excusee":
return "excusee"
if s == "a_traiter":
return "non_excusee"
if s == "publiee_escada":
return "excusee" if t == "E" else "non_excusee"
return "present"
self.edit_p1 = _choice(1)
self.edit_p2 = _choice(2)
self.edit_p3 = _choice(3)
self.edit_p4 = _choice(4)
self.edit_p5 = _choice(5)
self.edit_p6 = _choice(6)
self.edit_p7 = _choice(7)
self.edit_p8 = _choice(8)
self.edit_p9 = _choice(9)
self.edit_p10 = _choice(10)
# Snapshot des choix initiaux (pour détecter les modifs)
self.initial_p1 = self.edit_p1
self.initial_p2 = self.edit_p2
self.initial_p3 = self.edit_p3
self.initial_p4 = self.edit_p4
self.initial_p5 = self.edit_p5
self.initial_p6 = self.edit_p6
self.initial_p7 = self.edit_p7
self.initial_p8 = self.edit_p8
self.initial_p9 = self.edit_p9
self.initial_p10 = self.edit_p10
self.edit_date_label = d.strftime("%d.%m.%Y")
def select_day(self, date_str: str):
if not date_str:
return
if self.edit_date == date_str:
self.edit_date = ""
return
self._load_day_choices(date_str)
self.edit_date = date_str
@rx.var
def edit_has_changes(self) -> bool:
"""True si au moins une période diffère de l'état chargé en DB."""
return (
self.edit_p1 != self.initial_p1 or
self.edit_p2 != self.initial_p2 or
self.edit_p3 != self.initial_p3 or
self.edit_p4 != self.initial_p4 or
self.edit_p5 != self.initial_p5 or
self.edit_p6 != self.initial_p6 or
self.edit_p7 != self.initial_p7 or
self.edit_p8 != self.initial_p8 or
self.edit_p9 != self.initial_p9 or
self.edit_p10 != self.initial_p10
)
@rx.var
def edit_has_non_excusee(self) -> bool:
"""True si au moins une période est en N (non excusée)."""
return (
self.edit_p1 == "non_excusee" or
self.edit_p2 == "non_excusee" or
self.edit_p3 == "non_excusee" or
self.edit_p4 == "non_excusee" or
self.edit_p5 == "non_excusee" or
self.edit_p6 == "non_excusee" or
self.edit_p7 == "non_excusee" or
self.edit_p8 == "non_excusee" or
self.edit_p9 == "non_excusee" or
self.edit_p10 == "non_excusee"
)
def mark_school_day_absent(self):
"""Marque toutes les périodes de cours de la journée comme N (non excusées)
dans le panneau. Utilise le mapping classe / jour / périodes configuré
dans /params. Ne touche pas la DB — l'enregistrement passe par
« Enregistrer »."""
if not self.edit_date or not self.selected_id:
return
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
finally:
sess.close()
if not ap:
return rx.toast.error("Apprenti introuvable.")
d = date.fromisoformat(self.edit_date)
day_names = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
day_key = day_names[d.weekday()]
settings = _read_settings()
class_sch = (settings.get("class_schedule") or {}).get(ap.classe) or {}
entry = class_sch.get(day_key)
# Nouveau format {type, periods} ; ancien format = list[int] (compat).
if isinstance(entry, dict):
periods = set(entry.get("periods") or [])
elif isinstance(entry, list):
periods = set(entry)
else:
periods = set()
if not periods:
return rx.toast.warning(
f"Aucun horaire configuré pour {ap.classe} le {day_key}. "
f"Configure-le dans Paramètres → Horaires de classe."
)
if 1 in periods: self.edit_p1 = "non_excusee"
if 2 in periods: self.edit_p2 = "non_excusee"
if 3 in periods: self.edit_p3 = "non_excusee"
if 4 in periods: self.edit_p4 = "non_excusee"
if 5 in periods: self.edit_p5 = "non_excusee"
if 6 in periods: self.edit_p6 = "non_excusee"
if 7 in periods: self.edit_p7 = "non_excusee"
if 8 in periods: self.edit_p8 = "non_excusee"
if 9 in periods: self.edit_p9 = "non_excusee"
if 10 in periods: self.edit_p10 = "non_excusee"
def excuse_all_visual(self):
"""Bascule toutes les N → E dans le panneau (sans toucher la DB).
L'enregistrement passe par le bouton « Enregistrer »."""
if self.edit_p1 == "non_excusee": self.edit_p1 = "excusee"
if self.edit_p2 == "non_excusee": self.edit_p2 = "excusee"
if self.edit_p3 == "non_excusee": self.edit_p3 = "excusee"
if self.edit_p4 == "non_excusee": self.edit_p4 = "excusee"
if self.edit_p5 == "non_excusee": self.edit_p5 = "excusee"
if self.edit_p6 == "non_excusee": self.edit_p6 = "excusee"
if self.edit_p7 == "non_excusee": self.edit_p7 = "excusee"
if self.edit_p8 == "non_excusee": self.edit_p8 = "excusee"
if self.edit_p9 == "non_excusee": self.edit_p9 = "excusee"
if self.edit_p10 == "non_excusee": self.edit_p10 = "excusee"
def cancel_edit(self):
self.edit_date = ""
def save_day_edit(self):
if not self.edit_date:
return
sess = get_session()
d = date.fromisoformat(self.edit_date)
apprenti = sess.get(Apprenti, self.selected_id)
appr_label = (
f"{apprenti.nom} {apprenti.prenom} ({apprenti.classe})"
if apprenti else f"id={self.selected_id}"
)
user = self.username or "?"
existing = sess.execute(
select(Absence).where(
Absence.apprenti_id == self.selected_id,
Absence.date == d,
)
).scalars().all()
pm = {ab.periode: ab for ab in existing}
choices = {
1: self.edit_p1, 2: self.edit_p2, 3: self.edit_p3,
4: self.edit_p4, 5: self.edit_p5, 6: self.edit_p6,
7: self.edit_p7, 8: self.edit_p8, 9: self.edit_p9,
10: self.edit_p10,
}
d_str = d.strftime("%d.%m.%Y")
nb_changes = 0
for p, choice in choices.items():
ab = pm.get(p)
if choice == "present":
if ab:
upsert_escada_pending(sess, self.selected_id, d, p, "clear")
sess.delete(ab)
nb_changes += 1
app_log(
f"[abs] {user} : {appr_label}{d_str} P{p} : "
f"{ab.type_origine} → présent (suppression)"
)
else:
type_o = "E" if choice == "excusee" else "N"
statut = "excusee" if choice == "excusee" else "a_traiter"
if ab:
if ab.statut != statut:
old_type = ab.type_origine
ab.type_origine = type_o
ab.statut = statut
ab.updated_by = self.username
upsert_escada_pending(sess, self.selected_id, d, p, type_o)
nb_changes += 1
app_log(
f"[abs] {user} : {appr_label}{d_str} P{p} : "
f"{old_type}{type_o}"
)
else:
sess.add(Absence(
apprenti_id=self.selected_id,
date=d, periode=p,
type_origine=type_o, statut=statut,
updated_by=self.username, import_id=None,
))
upsert_escada_pending(sess, self.selected_id, d, p, type_o)
nb_changes += 1
app_log(
f"[abs] {user} : {appr_label}{d_str} P{p} : "
f"présent → {type_o} (création)"
)
sess.commit()
self._reload(reset_email=False)
# Resync du snapshot pour que edit_has_changes reparte à False
# tant qu'aucune nouvelle modif n'est faite.
if self.edit_date:
self._load_day_choices(self.edit_date)
if nb_changes == 0:
return rx.toast.info("Aucune modification")
msg = (
f"{nb_changes} période modifiée pour {d_str}"
if nb_changes == 1
else f"{nb_changes} périodes modifiées pour {d_str}"
)
return rx.toast.success(msg)
# ── Quick excuse ──────────────────────────────────────────────────────────
def excuse_day(self, date_str: str):
sess = get_session()
d = date.fromisoformat(date_str)
apprenti = sess.get(Apprenti, self.selected_id)
appr_label = (
f"{apprenti.nom} {apprenti.prenom} ({apprenti.classe})"
if apprenti else f"id={self.selected_id}"
)
user = self.username or "?"
d_str = d.strftime("%d.%m.%Y")
absences = sess.execute(
select(Absence).where(
Absence.apprenti_id == self.selected_id,
Absence.date == d,
Absence.statut == "a_traiter",
)
).scalars().all()
nb = 0
for ab in absences:
old_type = ab.type_origine
ab.statut = "excusee"
ab.type_origine = "E"
ab.updated_by = self.username
upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E")
nb += 1
app_log(
f"[abs] {user} : {appr_label}{d_str} P{ab.periode} : "
f"{old_type} → E (excuse rapide)"
)
sess.commit()
self._reload(reset_email=False)
# Rester sur la date sélectionnée et rafraîchir les choix du panneau.
if self.edit_date == date_str:
self._load_day_choices(date_str)
if nb == 0:
return rx.toast.info("Aucune absence à excuser")
msg = (
f"1 période excusée pour {d_str}"
if nb == 1
else f"{nb} périodes excusées pour {d_str}"
)
return rx.toast.success(msg)
# ── Downloads ─────────────────────────────────────────────────────────────
def download_abs_pdf(self):
sess = get_session()
apprenti = sess.get(Apprenti, self.selected_id)
if apprenti is None:
return
pdf_bytes = _absence_pdf_apprenti(sess, apprenti)
filename = f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf"
return rx.download(data=pdf_bytes, filename=filename)
def download_bn_pdf(self):
if not self.bn_pdf_fichier:
return
sess = get_session()
apprenti = sess.get(Apprenti, self.selected_id)
if apprenti is None:
return
pdf_path = DATA_DIR / "pdfs" / self.bn_pdf_fichier
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
if pdf_bytes is None:
return
filename = f"BN_{apprenti.nom}_{apprenti.prenom}.pdf"
return rx.download(data=pdf_bytes, filename=filename)
def download_notes_pdf(self):
sess = get_session()
apprenti = sess.get(Apprenti, self.selected_id)
if apprenti is None:
return
notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
pdf_path = DATA_DIR / "pdfs" / notes_fname
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
if pdf_bytes is None:
return
filename = f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"
return rx.download(data=pdf_bytes, filename=filename)
# ── Email send (background task) ──────────────────────────────────────────
async def send_email_action(self):
from src.email_sender import send_email as _send_email
async with self:
self.email_sending = True
self.email_error = ""
self.email_sent = False
try:
s = _read_settings()
smtp_host = s.get("smtp_host", "smtp-relay.brevo.com")
smtp_port = int(s.get("smtp_port", 587))
smtp_login = s.get("smtp_login", "")
smtp_password = s.get("smtp_password", "")
smtp_sender = s.get("smtp_sender", "")
if self.email_dest == "apprenti":
recipients = [self.fiche_email_val] if self.fiche_email_val else []
elif self.email_dest == "formateur":
recipients = [self.fiche_formateur_email] if self.fiche_formateur_email else []
else:
recipients = [e.strip() for e in self.email_custom.split(",") if e.strip()]
if not recipients:
async with self:
self.email_error = "Aucune adresse email valide."
self.email_sending = False
return
sess = get_session()
apprenti = sess.get(Apprenti, self.selected_id)
attachments = []
if self.email_attach_abs and apprenti:
pdf_bytes = _absence_pdf_apprenti(sess, apprenti)
attachments.append((pdf_bytes, f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf"))
if self.email_attach_bn and self.has_pdf_bn and apprenti:
pdf_path = DATA_DIR / "pdfs" / self.bn_pdf_fichier
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
if pdf_bytes:
attachments.append((pdf_bytes, f"BN_{apprenti.nom}_{apprenti.prenom}.pdf"))
if self.email_attach_notes and self.has_pdf_notes and apprenti:
notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
pdf_path = DATA_DIR / "pdfs" / notes_fname
pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
if pdf_bytes:
attachments.append((pdf_bytes, f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"))
if not attachments:
async with self:
self.email_error = "Sélectionnez au moins un document à joindre."
self.email_sending = False
return
errors = []
for to in recipients:
try:
_send_email(
smtp_host=smtp_host, smtp_port=smtp_port,
smtp_login=smtp_login, smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=to, subject=self.email_subject, body=self.email_body,
attachments=attachments,
)
except Exception as e:
errors.append(f"{to}: {e}")
async with self:
if errors:
self.email_error = "; ".join(errors)
else:
self.email_sent = True
self.email_sending = False
except Exception as e:
async with self:
self.email_error = str(e)
self.email_sending = False
send_email_action._reflex_background_task = True
# ── Internal helpers ──────────────────────────────────────────────────────
def _reload(self, reset_email: bool = True):
sess = get_session()
absences = sess.execute(
select(Absence)
.where(Absence.apprenti_id == self.selected_id)
.order_by(Absence.date, Absence.periode)
).scalars().all()
# Une absence "publiee_escada" garde sa nature E/N via type_origine
# (sinon disparaît des compteurs après push_then_sync).
def _is_e(a):
return a.statut == "excusee" or (
a.statut == "publiee_escada" and a.type_origine == "E"
)
def _is_n(a):
return a.statut == "a_traiter" or (
a.statut == "publiee_escada" and a.type_origine == "N"
)
self.kpi_total = len(absences)
self.kpi_excusees = sum(1 for a in absences if _is_e(a))
self.kpi_non_excusees = sum(1 for a in absences if _is_n(a))
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
# Le quota de 5 absences ne s'applique qu'aux classes EM.
apprenti = sess.get(Apprenti, self.selected_id)
_is_em = bool(apprenti and (apprenti.classe or "").startswith("EM"))
self.quota_atteint = _is_em and self.kpi_blocs >= QUOTA
# Fiche
fiche = sess.execute(
select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
).scalar_one_or_none()
if fiche:
self.fiche_available = True
self.fiche_adresse = fiche.adresse or ""
self.fiche_cp_localite = (
f"{fiche.code_postal or ''} {fiche.localite or ''}".strip()
)
self.fiche_telephone = fiche.telephone or ""
self.fiche_email_val = fiche.email or ""
self.fiche_date_naissance = fiche.date_naissance or ""
self.fiche_majeur = (
("Majeur : oui" if fiche.majeur else "Majeur : non")
if fiche.majeur is not None else ""
)
self.fiche_compensation = (
("Compensation des désavantages : oui"
if fiche.compensation_desavantages
else "Compensation des désavantages : non")
if fiche.compensation_desavantages is not None else ""
)
self.fiche_resp_legal_nom = fiche.resp_legal_nom or ""
self.fiche_resp_legal_adresse = fiche.resp_legal_adresse or ""
self.fiche_resp_legal_cp_localite = (
f"{fiche.resp_legal_code_postal or ''} "
f"{fiche.resp_legal_localite or ''}".strip()
)
self.fiche_resp_legal_tel_p = fiche.resp_legal_telephone_p or ""
self.fiche_resp_legal_tel_n = fiche.resp_legal_telephone_n or ""
self.fiche_entreprise_nom = fiche.entreprise_nom or ""
self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
self.fiche_entreprise_cp_localite = (
f"{fiche.entreprise_code_postal or ''} "
f"{fiche.entreprise_localite or ''}".strip()
)
self.fiche_entreprise_telephone = fiche.entreprise_telephone or ""
self.fiche_entreprise_email = fiche.entreprise_email or ""
self.fiche_formateur_nom = fiche.formateur_nom or ""
self.fiche_formateur_email = fiche.formateur_email or ""
self.fiche_updated_at = (
fiche.updated_at.strftime("%d.%m.%Y %H:%M") if fiche.updated_at else ""
)
# URLs Google Maps construites APRÈS l'assignation de tous les
# champs (sinon on utiliserait les valeurs de l'apprenti précédent).
# Pour l'entreprise on inclut le nom → Maps trouve la fiche
# établissement si elle existe.
from urllib.parse import quote_plus as _qp
def _maps(*parts: str) -> str:
q = ", ".join(p.strip() for p in parts if p and p.strip())
return f"https://www.google.com/maps/search/?api=1&query={_qp(q)}" if q else ""
self.fiche_map_url = _maps(self.fiche_adresse, self.fiche_cp_localite)
self.fiche_entreprise_map_url = _maps(
self.fiche_entreprise_nom,
self.fiche_entreprise_adresse,
self.fiche_entreprise_cp_localite,
)
self.fiche_resp_legal_map_url = _maps(
self.fiche_resp_legal_adresse, self.fiche_resp_legal_cp_localite,
)
else:
self.fiche_available = False
for attr in [
"fiche_adresse", "fiche_cp_localite", "fiche_telephone",
"fiche_email_val", "fiche_date_naissance", "fiche_majeur",
"fiche_compensation",
"fiche_resp_legal_nom", "fiche_resp_legal_adresse",
"fiche_resp_legal_cp_localite",
"fiche_resp_legal_tel_p", "fiche_resp_legal_tel_n",
"fiche_map_url", "fiche_entreprise_map_url",
"fiche_resp_legal_map_url",
"fiche_entreprise_nom", "fiche_entreprise_adresse",
"fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
"fiche_entreprise_email", "fiche_formateur_nom",
"fiche_formateur_email", "fiche_updated_at",
]:
setattr(self, attr, "")
self._build_bn(sess)
# Toujours démarrer sur le mois courant (et non sur le 1er mois d'abs)
# pour que la date du jour soit immédiatement visible + sélectionnable.
today = date.today()
self.cal_year = today.year
self.cal_month = today.month
self._build_calendar_from(absences)
if reset_email:
s = _read_settings()
self.smtp_ok = bool(
s.get("smtp_host") and s.get("smtp_login") and s.get("smtp_password")
)
if self.smtp_ok:
apprenti = sess.get(Apprenti, self.selected_id)
if apprenti:
tvars = build_template_vars(apprenti, list(absences))
# Mêmes valeurs par défaut que la page Paramètres
# (DEFAULT_TEMPLATE_SUBJ / DEFAULT_TEMPLATE_BODY).
_def_subj = "Document EPTM — {nom_complet} ({classe})"
_def_body = (
"Bonjour {prenom},\n\n"
"Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n"
"Cordialement,\nL'équipe EPTM"
)
self.email_subject = render_template(
s.get("email_subject", _def_subj), tvars
)
self.email_body = render_template(
s.get("email_body", _def_body), tvars
)
self.email_sent = False
self.email_error = ""
self.email_dest = "apprenti"
self.email_custom = ""
self.email_attach_abs = True
self.email_attach_bn = False
self.email_attach_notes = False
def _build_bn(self, sess):
bn_records = sess.execute(
select(NotesBulletin, ImportBN)
.join(ImportBN, ImportBN.id == NotesBulletin.import_id)
.where(NotesBulletin.apprenti_id == self.selected_id)
.order_by(ImportBN.date_import.desc())
).all()
if not bn_records:
self.has_bn = False
self.bn_html = ""
self.bn_caption = ""
self.bn_pdf_fichier = ""
else:
bn, imp = bn_records[0]
sem_labels = json.loads(bn.sem_labels_json)
d = json.loads(bn.donnees_json)
groups_order = _GROUP_ORDER.get(bn.type_classe, ["BP"])
html = _bn_html_table(d, sem_labels, groups_order)
nm = sess.execute(
select(NotesMatu)
.join(ImportMatu, ImportMatu.id == NotesMatu.import_id)
.where(NotesMatu.apprenti_id == self.selected_id)
.order_by(ImportMatu.date_import.desc())
.limit(1)
).scalar_one_or_none()
if nm:
html += _matu_html_table(nm)
self.has_bn = True
self.bn_html = html
self.bn_caption = (
f"Import du {imp.date_import.strftime('%d.%m.%Y %H:%M')}{imp.imported_by}"
)
self.bn_pdf_fichier = imp.fichier or ""
ne_rec = sess.execute(
select(NotesExamen).where(NotesExamen.apprenti_id == self.selected_id)
).scalar_one_or_none()
if ne_rec:
notes_data = json.loads(ne_rec.donnees_json)
if notes_data:
self.has_notes = True
self.notes_html = _render_notes_html(notes_data)
else:
self.has_notes = False
self.notes_html = ""
else:
self.has_notes = False
self.notes_html = ""
# ── Notices Escada ──────────────────────────────────────────────────
notices_list = sess.execute(
select(ApprentiNotice)
.where(ApprentiNotice.apprenti_id == self.selected_id)
.order_by(ApprentiNotice.date_event.desc())
).scalars().all()
self.has_notices = len(notices_list) > 0
self.notices_data = [
{
"date": n.date_event.strftime("%d.%m.%Y") if n.date_event else "",
"type": n.type_notice or "",
"auteur": n.auteur or "",
"titre": n.titre or "",
"remarque": n.remarque or "",
"matiere": n.matiere or "",
}
for n in notices_list
]
pdf_dir = DATA_DIR / "pdfs"
self.has_pdf_bn = bool(self.bn_pdf_fichier) and (pdf_dir / self.bn_pdf_fichier).exists()
apprenti = sess.get(Apprenti, self.selected_id)
if apprenti:
notes_pdf_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
self.has_pdf_notes = (pdf_dir / notes_pdf_fname).exists()
else:
self.has_pdf_notes = False
def _rebuild_calendar(self):
sess = get_session()
absences = sess.execute(
select(Absence)
.where(Absence.apprenti_id == self.selected_id)
.order_by(Absence.date, Absence.periode)
).scalars().all()
self._build_calendar_from(absences)
def _build_calendar_from(self, absences):
self.cal_month_name = (
f"{MOIS_FR[self.cal_month - 1].capitalize()} {self.cal_year}"
)
prev_m = self.cal_month - 1 if self.cal_month > 1 else 12
self.cal_prev_name = MOIS_FR[prev_m - 1].capitalize()
next_m = self.cal_month + 1 if self.cal_month < 12 else 1
self.cal_next_name = MOIS_FR[next_m - 1].capitalize()
abs_by_date: dict = {}
for ab in absences:
if ab.date.year == self.cal_year and ab.date.month == self.cal_month:
key = ab.date.isoformat()
if key not in abs_by_date:
abs_by_date[key] = {"excusees": 0, "non_excusees": 0}
if ab.statut == "excusee":
abs_by_date[key]["excusees"] += 1
else:
abs_by_date[key]["non_excusees"] += 1
today = date.today()
weeks = calendar.monthcalendar(self.cal_year, self.cal_month)
days: list[dict] = []
for week in weeks:
for day_num in week:
if day_num == 0:
days.append({
"day": 0, "is_empty": True, "has_abs": False,
"has_non_exc": False, "excusees": 0, "non_excusees": 0,
"is_today": False, "date_str": "", "label": "",
})
else:
d = date(self.cal_year, self.cal_month, day_num)
key = d.isoformat()
info = abs_by_date.get(key, {})
has_abs = key in abs_by_date
exc = info.get("excusees", 0)
non = info.get("non_excusees", 0)
has_non_exc = non > 0
if has_abs:
parts = [str(day_num)]
if non > 0:
parts.append(f"{non}")
if exc > 0:
parts.append(f"{exc}")
lbl = " ".join(parts)
else:
lbl = str(day_num)
days.append({
"day": day_num, "is_empty": False, "has_abs": has_abs,
"has_non_exc": has_non_exc, "excusees": exc, "non_excusees": non,
"is_today": d == today, "date_str": key, "label": lbl,
})
self.cal_days = days
# ── UI components ─────────────────────────────────────────────────────────────
def _apprenti_option(label: rx.Var) -> rx.Component:
return rx.box(
rx.text(label, size="2"),
padding="0.45rem 0.75rem",
cursor="pointer",
on_click=FicheState.handle_select(label),
_hover={"background_color": "var(--gray-3)"},
width="100%",
)
def _apprenti_searchable_select() -> rx.Component:
return rx.popover.root(
rx.popover.trigger(
rx.box(
rx.flex(
rx.cond(
FicheState.selected_label != "",
rx.text(FicheState.selected_label, size="2"),
rx.text("Sélectionner un apprenti…", size="2", color="var(--gray-9)"),
),
rx.spacer(),
rx.icon("chevron-down", size=18, color="var(--gray-9)"),
align="center",
width="100%",
),
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "apprenti-search"},
),
),
rx.popover.content(
rx.vstack(
rx.input(
placeholder="Rechercher un apprenti…",
value=FicheState.apprenti_search,
on_change=FicheState.set_apprenti_search,
on_key_down=FicheState.apprenti_search_keydown,
size="2",
width="100%",
auto_focus=True,
),
rx.cond(
FicheState.filtered_apprenti_labels.length() > 0,
rx.box(
rx.foreach(FicheState.filtered_apprenti_labels, _apprenti_option),
max_height="280px",
overflow_y="auto",
width="100%",
),
rx.box(
rx.text("Aucun résultat", size="2", color="var(--gray-9)"),
padding="0.5rem 0.75rem",
),
),
spacing="2",
width="100%",
),
min_width="320px",
max_width="500px",
padding="0.5rem",
),
open=FicheState.apprenti_select_open,
on_open_change=FicheState.set_apprenti_select_open,
)
def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
return rx.box(
rx.text(label, size="1", color="#666", class_name="kpi-label"),
rx.text(value, size="7", font_weight="700", color=color,
class_name="tabular kpi-value"),
padding="1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
flex="1",
min_width="120px",
class_name="hover-lift kpi-card",
)
def _info_line(icon: str, value) -> rx.Component:
return rx.cond(
value != "",
rx.hstack(
rx.icon(icon, size=14, color="#9e9e9e"),
rx.text(value, size="2", color="#555"),
spacing="2",
align="center",
),
)
def _info_line_email(icon: str, value) -> rx.Component:
"""Ligne info avec lien mailto: cliquable."""
return rx.cond(
value != "",
rx.hstack(
rx.icon(icon, size=14, color="#9e9e9e"),
rx.link(
value, href="mailto:" + value,
size="2", color="var(--brand-accent)",
text_decoration="none",
_hover={"text_decoration": "underline"},
),
spacing="2", align="center",
),
)
def _info_line_tel(icon: str, value, label_prefix: str = "") -> rx.Component:
"""Ligne info avec lien tel: cliquable (cliquable depuis un smartphone)."""
return rx.cond(
value != "",
rx.hstack(
rx.icon(icon, size=14, color="#9e9e9e"),
rx.link(
label_prefix + value,
href="tel:" + value.replace(" ", ""),
size="2", color="var(--brand-accent)",
text_decoration="none",
_hover={"text_decoration": "underline"},
),
spacing="2", align="center",
),
)
def _info_line_map(line1, line2, map_url) -> rx.Component:
"""Bloc adresse : une seule icône cliquable + 2 lignes de texte (rue puis CP localité)."""
return rx.cond(
(line1 != "") | (line2 != ""),
rx.hstack(
rx.link(
rx.icon("map-pin", size=14, color="var(--brand-accent)"),
href=map_url, is_external=True,
_hover={"opacity": "0.7"},
title="Voir sur Google Maps",
),
rx.vstack(
rx.cond(line1 != "", rx.text(line1, size="2", color="#555")),
rx.cond(line2 != "", rx.text(line2, size="2", color="#555")),
spacing="0", align="start",
),
spacing="2", align="start",
),
)
def _cal_day_cell(d) -> rx.Component:
is_selected = d["date_str"] == FicheState.edit_date
return rx.cond(
d["is_empty"],
rx.box(min_height="36px", border_radius="4px"),
rx.box(
rx.text(
d["label"],
size="1",
font_weight=rx.cond(d["is_today"], "700", "400"),
color=rx.cond(
is_selected, "var(--brand-accent)",
rx.cond(
d["has_non_exc"], "#c62828",
rx.cond(d["has_abs"], "#2e7d32", "#333"),
),
),
text_align="center",
),
min_height="36px",
border_radius="4px",
background_color=rx.cond(
is_selected, "#dbeafe",
rx.cond(
d["has_non_exc"], "#ffebee",
rx.cond(
d["has_abs"], "#e8f5e9",
rx.cond(d["is_today"], "#e3f2fd", "white"),
),
),
),
border=rx.cond(
is_selected, "2px solid var(--brand-accent)",
rx.cond(d["is_today"], "2px solid #90caf9", "1px solid #eee"),
),
display="flex",
align_items="center",
justify_content="center",
cursor="pointer",
on_click=FicheState.select_day(d["date_str"]),
class_name="smooth-transition",
_hover={"transform": "scale(1.05)", "box_shadow": "0 2px 6px rgba(0,0,0,0.1)"},
),
)
def _period_select(p_num: int, val, setter) -> rx.Component:
return rx.hstack(
rx.text(f"P{p_num}", size="2", weight="medium", color="#555",
min_width="28px", text_align="right"),
rx.segmented_control.root(
rx.segmented_control.item("Présent", value="present"),
rx.segmented_control.item("E", value="excusee"),
rx.segmented_control.item("N", value="non_excusee"),
value=val,
on_change=setter,
size="1",
color_scheme=rx.match(
val,
("excusee", "orange"),
("non_excusee", "red"),
"gray",
),
radius="medium",
),
spacing="2",
align="center",
)
def _edit_panel() -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
rx.icon("pencil", size=15, color="var(--brand-accent)"),
rx.text(
"Édition du ", FicheState.edit_date_label,
size="3", weight="bold", color="var(--text-strong)",
),
rx.cond(
FicheState.edit_day_type_label != "",
rx.badge(
FicheState.edit_day_type_label,
color_scheme=rx.match(
FicheState.edit_day_type,
("theorie", "blue"),
("pratique", "orange"),
("matu", "violet"),
"gray",
),
variant="soft", size="1",
),
),
rx.spacer(),
rx.button(
rx.icon("x", size=14),
on_click=FicheState.cancel_edit,
variant="ghost", color_scheme="gray", size="1",
),
width="100%", align="center",
),
rx.divider(),
rx.flex(
rx.vstack(
_period_select(1, FicheState.edit_p1, FicheState.set_edit_p1),
_period_select(2, FicheState.edit_p2, FicheState.set_edit_p2),
_period_select(3, FicheState.edit_p3, FicheState.set_edit_p3),
_period_select(4, FicheState.edit_p4, FicheState.set_edit_p4),
_period_select(5, FicheState.edit_p5, FicheState.set_edit_p5),
spacing="2", flex="1 1 240px", min_width="240px",
),
rx.vstack(
_period_select(6, FicheState.edit_p6, FicheState.set_edit_p6),
_period_select(7, FicheState.edit_p7, FicheState.set_edit_p7),
_period_select(8, FicheState.edit_p8, FicheState.set_edit_p8),
_period_select(9, FicheState.edit_p9, FicheState.set_edit_p9),
_period_select(10, FicheState.edit_p10, FicheState.set_edit_p10),
spacing="2", flex="1 1 240px", min_width="240px",
),
gap="0.6rem",
flex_wrap="wrap",
width="100%",
),
# Actions rapides : marquer toute la journée N (selon horaire classe)
# ou excuser toutes les N → E. Aucune touche la DB — l'enregistrement
# passe par « Enregistrer ».
rx.flex(
rx.button(
rx.icon("calendar-x", size=14),
rx.cond(
FicheState.edit_day_has_schedule,
rx.text("Absent toute la journée"),
rx.text("Absent toute la journée (Données chronoplan manquantes)"),
),
on_click=FicheState.mark_school_day_absent,
disabled=~FicheState.edit_day_has_schedule,
variant="soft", color_scheme="red", size="2",
),
rx.button(
rx.icon("check-check", size=14),
"Excuser toutes les périodes",
on_click=FicheState.excuse_all_visual,
disabled=~FicheState.edit_has_non_excusee,
variant="soft", color_scheme="green", size="2",
),
gap="0.5rem", flex_wrap="wrap", width="100%",
),
rx.divider(),
rx.flex(
rx.button(
rx.icon("save", size=14), "Enregistrer",
on_click=FicheState.save_day_edit,
disabled=~FicheState.edit_has_changes,
color_scheme="blue", size="2",
),
rx.button(
"Annuler",
on_click=FicheState.cancel_edit,
disabled=~FicheState.edit_has_changes,
variant="outline", color_scheme="gray", size="2",
),
gap="0.75rem", flex_wrap="wrap",
),
spacing="3", width="100%",
),
padding="1rem",
background_color="var(--brand-accent-soft)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
class_name="anim-slide-down",
)
def _actions_row() -> rx.Component:
"""Bandeau d'actions sous les KPIs : exports PDF + création d'avis."""
return rx.box(
rx.flex(
# Exports PDF (avec icône download partout)
rx.button(
rx.icon("download", size=13),
"PDF absences",
on_click=FicheState.download_abs_pdf,
variant="outline", color_scheme="gray", size="2",
),
rx.cond(
FicheState.has_pdf_bn,
rx.button(
rx.icon("download", size=13),
"PDF bulletin",
on_click=FicheState.download_bn_pdf,
variant="outline", color_scheme="blue", size="2",
),
),
rx.cond(
FicheState.has_pdf_notes,
rx.button(
rx.icon("download", size=13),
"PDF notes",
on_click=FicheState.download_notes_pdf,
variant="outline", color_scheme="violet", size="2",
),
),
# Séparateur visuel
rx.box(
width="1px",
background_color="var(--gray-6)",
margin_x="0.25rem",
align_self="stretch",
),
# Création d'avis
rx.button(
rx.icon("file-warning", size=14),
"Créer un avis de retenue",
on_click=RetenueState.preload_apprenti(
FicheState.selected_id, FicheState.selected_label,
),
color_scheme="orange", variant="soft", size="2",
),
rx.button(
rx.icon("triangle-alert", size=14),
"Créer un avis de sanction",
on_click=SanctionState.preload_apprenti(
FicheState.selected_id, FicheState.selected_label,
),
color_scheme="red", variant="soft", size="2",
),
gap="0.5rem",
flex_wrap="wrap",
align="center",
width="100%",
),
padding="0.75rem 1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
def _notice_row(item) -> rx.Component:
return rx.table.row(
rx.table.cell(item["date"], white_space="nowrap"),
rx.table.cell(rx.text(item["type"], size="1")),
rx.table.cell(rx.text(item["auteur"], size="1", color="#666")),
rx.table.cell(rx.text(item["titre"], size="1", weight="medium")),
rx.table.cell(rx.text(item["remarque"], size="1", color="#444")),
rx.table.cell(rx.text(item["matiere"], size="1", color="#666")),
)
def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
spacing="2", align="center",
),
rx.divider(),
rx.flex(
# ── Left: destinataire + pièces jointes ──────────────────────
rx.vstack(
rx.text("Destinataire", size="2", weight="bold", color="#555"),
rx.radio_group.root(
rx.vstack(
rx.radio_group.item(
rx.cond(
FicheState.fiche_email_val != "",
rx.text("Apprenti — ", FicheState.fiche_email_val, size="2"),
rx.text("Apprenti (email inconnu)", size="2", color="#999"),
),
value="apprenti",
disabled=FicheState.fiche_email_val == "",
),
rx.radio_group.item(
rx.cond(
FicheState.fiche_formateur_email != "",
rx.text("Formateur — ", FicheState.fiche_formateur_email, size="2"),
rx.text("Formateur (email inconnu)", size="2", color="#999"),
),
value="formateur",
disabled=FicheState.fiche_formateur_email == "",
),
rx.radio_group.item(
rx.text("Autre adresse", size="2"),
value="autre",
),
spacing="2",
),
value=FicheState.email_dest,
on_change=FicheState.set_email_dest,
),
rx.cond(
FicheState.email_dest == "autre",
rx.input(
value=FicheState.email_custom,
on_change=FicheState.set_email_custom,
placeholder="email1@ex.com, email2@ex.com",
size="1", width="100%",
),
),
rx.text("Pièces jointes", size="2", weight="bold",
color="#555", margin_top="0.5rem"),
rx.vstack(
rx.checkbox(
"Tableau des absences",
checked=FicheState.email_attach_abs,
on_change=FicheState.toggle_attach_abs,
size="1",
),
rx.checkbox(
rx.cond(
FicheState.has_pdf_bn,
"Bulletin de notes",
"Bulletin de notes (indisponible)",
),
checked=FicheState.email_attach_bn,
on_change=FicheState.toggle_attach_bn,
disabled=~FicheState.has_pdf_bn,
size="1",
),
rx.checkbox(
rx.cond(
FicheState.has_pdf_notes,
"Notes d'examen",
"Notes d'examen (indisponible)",
),
checked=FicheState.email_attach_notes,
on_change=FicheState.toggle_attach_notes,
disabled=~FicheState.has_pdf_notes,
size="1",
),
spacing="2",
),
spacing="2", flex="1", min_width="220px",
),
# ── Right: message + envoyer ──────────────────────────────────
rx.vstack(
rx.vstack(
rx.text("Objet", size="2", weight="bold", color="#555"),
rx.input(
value=FicheState.email_subject,
on_change=FicheState.set_email_subject,
width="100%", size="1",
),
spacing="1", width="100%",
),
rx.vstack(
rx.text("Corps du message", size="2", weight="bold", color="#555"),
rx.text_area(
value=FicheState.email_body,
on_change=FicheState.set_email_body,
rows="7", width="100%",
font_family="monospace",
font_size="0.82rem",
resize="vertical",
),
spacing="1", width="100%",
),
rx.button(
rx.cond(
FicheState.email_sending,
rx.hstack(
rx.spinner(size="1"),
rx.text("Envoi en cours…"),
spacing="2", align="center",
),
rx.hstack(
rx.icon("send", size=14),
rx.text("Envoyer"),
spacing="2", align="center",
),
),
on_click=FicheState.send_email_action,
color_scheme="blue", size="2", width="100%",
disabled=FicheState.email_sending,
),
rx.cond(
FicheState.email_sent,
rx.callout.root(
rx.callout.icon(rx.icon("check", size=14)),
rx.callout.text("Email envoyé."),
color_scheme="green", variant="soft", size="1",
),
),
rx.cond(
FicheState.email_error != "",
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=14)),
rx.callout.text(FicheState.email_error),
color_scheme="red", variant="soft", size="1",
),
),
spacing="3", flex="2", min_width="260px",
),
gap="1.5rem", flex_wrap="wrap", width="100%", align="start",
),
spacing="3", width="100%",
),
padding="1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
_DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
def fiche_page() -> rx.Component:
return layout(
rx.vstack(
# Modals (rendus une fois, contrôlés par leur state respectif)
retenue_modal(),
sanction_modal(),
rx.heading("Apprentis", size="7"),
rx.cond(
FicheState.has_apprentis,
rx.vstack(
# ── Sélecteur apprenti (recherche intégrée) ───────────────
_apprenti_searchable_select(),
# ── KPI cards ─────────────────────────────────────────────
rx.flex(
_kpi_card("Périodes d'absence", FicheState.kpi_total),
_kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "var(--brand-primary-dark)"),
rx.box(
rx.text("Absences", size="1", color="#666",
class_name="kpi-label"),
rx.text(
FicheState.kpi_blocs,
size="7", font_weight="700",
color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"),
class_name="tabular kpi-value",
),
rx.cond(
FicheState.quota_atteint,
rx.text(
"Avis de sanction",
size="1", weight="bold", color="#c62828",
),
),
padding="1rem",
background_color=rx.cond(FicheState.quota_atteint, "#fff0f0", "white"),
border_radius="8px",
border=rx.cond(
FicheState.quota_atteint,
"1px solid #ffcdd2",
"1px solid #e0e0e0",
),
flex="1",
min_width="120px",
class_name="kpi-card",
),
gap="1rem", flex_wrap="wrap", width="100%",
),
# ── Actions (PDF exports + créations d'avis) ───────────────
_actions_row(),
# ── Fiche détaillée Escada ────────────────────────────────
rx.box(
rx.cond(
FicheState.fiche_available,
rx.vstack(
rx.flex(
rx.vstack(
rx.text("Élève", size="2", font_weight="700", color="var(--text-strong)"),
_info_line_map(FicheState.fiche_adresse, FicheState.fiche_cp_localite, FicheState.fiche_map_url),
_info_line_tel("phone", FicheState.fiche_telephone),
_info_line_email("mail", FicheState.fiche_email_val),
_info_line("cake", FicheState.fiche_date_naissance),
_info_line("user-check", FicheState.fiche_majeur),
_info_line("scale", FicheState.fiche_compensation),
spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Entreprise", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("building-2", FicheState.fiche_entreprise_nom),
_info_line_map(FicheState.fiche_entreprise_adresse, FicheState.fiche_entreprise_cp_localite, FicheState.fiche_entreprise_map_url),
_info_line_tel("phone", FicheState.fiche_entreprise_telephone),
_info_line_email("mail", FicheState.fiche_entreprise_email),
spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Formateur", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("user", FicheState.fiche_formateur_nom),
_info_line_email("mail", FicheState.fiche_formateur_email),
spacing="1", align="start", flex="1", min_width="200px",
),
# Représentant légal (mineurs uniquement)
rx.cond(
FicheState.fiche_resp_legal_nom != "",
rx.vstack(
rx.text("Représentant légal", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("user", FicheState.fiche_resp_legal_nom),
_info_line_map(FicheState.fiche_resp_legal_adresse, FicheState.fiche_resp_legal_cp_localite, FicheState.fiche_resp_legal_map_url),
_info_line_tel("phone", FicheState.fiche_resp_legal_tel_p, label_prefix="Fixe : "),
_info_line_tel("phone", FicheState.fiche_resp_legal_tel_n, label_prefix="Mobile : "),
spacing="1", align="start", flex="1", min_width="200px",
),
),
gap="1.5rem", flex_wrap="wrap", width="100%",
),
rx.text(
"Mis à jour le ", FicheState.fiche_updated_at, " depuis Escada",
size="1", color="#9e9e9e", margin_top="0.5rem",
),
spacing="3", width="100%",
),
rx.text(
"Aucune fiche disponible. Lancez une synchronisation Escada.",
size="2", color="#666",
),
),
padding="1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
# ── Bulletin de notes ─────────────────────────────────────
rx.box(
rx.tabs.root(
rx.tabs.list(
rx.tabs.trigger("Cours professionnels", value="bn"),
rx.tabs.trigger("Notes d'examen", value="notes"),
rx.tabs.trigger("Notices", value="notices"),
),
rx.tabs.content(
rx.cond(
FicheState.has_bn,
rx.vstack(
rx.text(FicheState.bn_caption, size="1", color="#9e9e9e"),
rx.box(
rx.html(FicheState.bn_html),
width="100%",
max_width="100%",
overflow_x="auto",
),
spacing="2", width="100%",
),
rx.text(
"Aucun bulletin de notes importé pour cet(te) apprenti(e).",
size="2", color="#666",
),
),
value="bn", width="100%", padding_top="1rem",
),
rx.tabs.content(
rx.cond(
FicheState.has_notes,
rx.html(FicheState.notes_html),
rx.text(
"Aucune note d'examen disponible. Lancez une synchronisation Escada avec l'option Notes.",
size="2", color="#666",
),
),
value="notes", width="100%", padding_top="1rem",
),
rx.tabs.content(
rx.cond(
FicheState.has_notices,
rx.box(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Date"),
rx.table.column_header_cell("Type"),
rx.table.column_header_cell("Auteur"),
rx.table.column_header_cell("Titre"),
rx.table.column_header_cell("Remarques"),
rx.table.column_header_cell("Matière"),
),
),
rx.table.body(
rx.foreach(FicheState.notices_data, _notice_row),
),
size="1", width="100%",
),
width="100%", overflow_x="auto",
),
rx.text(
"Aucune notice. Récupère-les depuis Escada via la page Escada (bouton « Récupérer les notices »).",
size="2", color="#666",
),
),
value="notices", width="100%", padding_top="1rem",
),
default_value="bn", width="100%",
),
padding="1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
# ── Calendrier mensuel (toujours visible pour pouvoir
# ajouter une absence sur un jour vierge) ──────────────────
rx.box(
rx.hstack(
rx.button(
rx.icon("chevron-left", size=14), FicheState.cal_prev_name,
on_click=FicheState.prev_month,
variant="outline", color_scheme="gray", size="2",
),
rx.text(
FicheState.cal_month_name,
size="4", font_weight="700", color="var(--text-strong)",
flex="1", text_align="center",
),
rx.button(
FicheState.cal_next_name, rx.icon("chevron-right", size=14),
on_click=FicheState.next_month,
variant="outline", color_scheme="gray", size="2",
),
width="100%", align="center", margin_bottom="0.5rem",
),
rx.grid(
*[
rx.text(h, size="1", color="#9e9e9e",
text_align="center", font_weight="600")
for h in _DOW
],
columns="7", gap="2px", width="100%", margin_bottom="2px",
),
rx.grid(
rx.foreach(FicheState.cal_days, _cal_day_cell),
columns="7", gap="2px", width="100%",
),
rx.hstack(
rx.box(width="12px", height="12px", background_color="#ffebee",
border_radius="2px", border="1px solid #eee"),
rx.text("Non excusée", size="1", color="#666"),
rx.box(width="12px", height="12px", background_color="#e8f5e9",
border_radius="2px", border="1px solid #eee"),
rx.text("Excusée", size="1", color="#666"),
rx.box(width="12px", height="12px", background_color="#dbeafe",
border_radius="2px", border="2px solid var(--brand-accent)"),
rx.text("Sélectionné", size="1", color="#666"),
spacing="2", align="center", margin_top="0.5rem",
),
rx.text(
"Cliquez sur un jour pour ajouter ou éditer les absences.",
size="1", color="#9e9e9e", margin_top="0.25rem",
),
padding="1rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
),
# ── Panneau d'édition ─────────────────────────────────────
rx.cond(
FicheState.edit_date != "",
_edit_panel(),
),
# ── Email ─────────────────────────────────────────────────
rx.cond(
FicheState.smtp_ok,
_email_section(),
rx.box(
rx.hstack(
rx.icon("mail", size=14, color="#9e9e9e"),
rx.text(
"Email non configuré. Rendez-vous dans Paramètres.",
size="2", color="#9e9e9e",
),
spacing="2", align="center",
),
padding="0.75rem 1rem",
background_color="var(--surface-muted)",
border_radius="8px",
border="1px solid var(--border-soft)",
width="100%",
),
),
spacing="4", width="100%",
),
empty_state(
icon="users",
title="Aucun apprenti",
description="Importe les classes depuis Escadaweb pour commencer à gérer les absences.",
action_label="Lancer un import",
action_href="/escada",
),
),
spacing="5", width="100%",
)
)