eptm_dashboard/eptm_dashboard/pages/fiche.py
2026-05-11 09:00:56 +02:00

1806 lines
77 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,
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
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"
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}{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)}{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}{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)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</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)
body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
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:
by_date.setdefault(ab.date, {})[ab.periode] = "E" if ab.statut == "excusee" 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] = []
# ── Pending dates (quick excuse) ─────────────────────────────────────────
pending_dates: list[dict] = []
# ── Calendar day edit ─────────────────────────────────────────────────────
edit_date: str = ""
edit_date_label: str = ""
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"
# ── 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_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
# ── 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)
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)
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.edit_date = ""
self._reload(reset_email=True)
# ── 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 select_day(self, date_str: str):
if not date_str:
return
if self.edit_date == date_str:
self.edit_date = ""
return
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()
pm = {ab.periode: ab.statut for ab in absences}
def _choice(p: int) -> str:
s = pm.get(p)
if s == "excusee": return "excusee"
if s == "a_traiter": return "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)
self.edit_date_label = d.strftime("%d.%m.%Y")
self.edit_date = date_str
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)
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()
if self.edit_date == date_str:
self.edit_date = ""
self._reload(reset_email=False)
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()
self.kpi_total = len(absences)
self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
self.quota_atteint = self.kpi_blocs >= QUOTA
# Pending dates
by_date: dict = {}
for ab in absences:
by_date.setdefault(ab.date, []).append(ab)
self.pending_dates = [
{
"date_str": d.isoformat(),
"label": f"{d.strftime('%d.%m')} ({sum(1 for a in al if a.statut == 'a_traiter')})",
}
for d, al in sorted(by_date.items())
if any(a.statut == "a_traiter" for a in al)
]
# 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_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 ""
)
else:
self.fiche_available = False
for attr in [
"fiche_adresse", "fiche_cp_localite", "fiche_telephone",
"fiche_email_val", "fiche_date_naissance", "fiche_majeur",
"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)
if absences:
self.cal_year = absences[0].date.year
self.cal_month = absences[0].date.month
else:
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))
_def_subj = "Relevé d'absences — {nom_complet} ({classe})"
_def_body = (
"Bonjour {prenom},\n\n"
"Veuillez trouver ci-joint votre document.\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 = ""
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="white",
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"),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
padding="1rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
flex="1",
min_width="120px",
class_name="hover-lift",
)
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 _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, "#1565c0",
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 #1565c0",
rx.cond(d["is_today"], "2px solid #90caf9", "1px solid #eee"),
),
display="flex",
align_items="center",
justify_content="center",
cursor=rx.cond(d["has_abs"], "pointer", "default"),
on_click=FicheState.select_day(d["date_str"]),
class_name="smooth-transition",
_hover=rx.cond(
d["has_abs"],
{"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="#1565c0"),
rx.text(
"Édition du ", FicheState.edit_date_label,
size="3", weight="bold", color="#37474f",
),
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%",
),
rx.hstack(
rx.button(
rx.icon("save", size=14), "Enregistrer",
on_click=FicheState.save_day_edit,
color_scheme="blue", size="2",
),
rx.button(
"Annuler",
on_click=FicheState.cancel_edit,
variant="outline", color_scheme="gray", size="2",
),
spacing="3",
),
spacing="3", width="100%",
),
padding="1rem",
background_color="#f0f7ff",
border_radius="8px",
border="1px solid #bfdbfe",
width="100%",
class_name="anim-slide-down",
)
def _pending_btn(item: dict) -> rx.Component:
return rx.button(
rx.icon("check", size=13),
item["label"],
on_click=FicheState.excuse_day(item["date_str"]),
color_scheme="green",
variant="soft",
size="1",
)
def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
rx.icon("mail", size=16, color="#37474f"),
rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
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="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
)
_DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
def fiche_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Fiche apprenti", 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, "#c62828"),
rx.box(
rx.text("Absences", size="1", color="#666"),
rx.text(
FicheState.kpi_blocs,
size="7", font_weight="700",
color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"),
),
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",
),
gap="1rem", flex_wrap="wrap", width="100%",
),
# ── 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="#37474f"),
_info_line("map-pin", FicheState.fiche_adresse),
_info_line("map-pin", FicheState.fiche_cp_localite),
_info_line("phone", FicheState.fiche_telephone),
_info_line("mail", FicheState.fiche_email_val),
_info_line("cake", FicheState.fiche_date_naissance),
_info_line("user-check", FicheState.fiche_majeur),
spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Entreprise", size="2", font_weight="700", color="#37474f"),
_info_line("building-2", FicheState.fiche_entreprise_nom),
_info_line("map-pin", FicheState.fiche_entreprise_adresse),
_info_line("map-pin", FicheState.fiche_entreprise_cp_localite),
_info_line("phone", FicheState.fiche_entreprise_telephone),
_info_line("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="#37474f"),
_info_line("user", FicheState.fiche_formateur_nom),
_info_line("mail", FicheState.fiche_formateur_email),
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="white",
border_radius="8px",
border="1px solid #e0e0e0",
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.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",
),
default_value="bn", width="100%",
),
padding="1rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
),
# ── Export PDF ────────────────────────────────────────────
rx.flex(
rx.button(
rx.icon("download", size=13), "PDF absences",
on_click=FicheState.download_abs_pdf,
variant="outline", color_scheme="gray", size="1",
),
rx.cond(
FicheState.has_pdf_bn,
rx.button(
rx.icon("file-text", size=13), "PDF bulletin",
on_click=FicheState.download_bn_pdf,
variant="outline", color_scheme="blue", size="1",
),
),
rx.cond(
FicheState.has_pdf_notes,
rx.button(
rx.icon("file-text", size=13), "PDF notes",
on_click=FicheState.download_notes_pdf,
variant="outline", color_scheme="violet", size="1",
),
),
flex_wrap="wrap", gap="0.5rem",
),
# ── Calendrier mensuel ────────────────────────────────────
rx.cond(
FicheState.kpi_total > 0,
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="#37474f",
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 #1565c0"),
rx.text("Sélectionné", size="1", color="#666"),
spacing="2", align="center", margin_top="0.5rem",
),
rx.text(
"Cliquez sur un jour avec absences pour éditer les périodes.",
size="1", color="#9e9e9e", margin_top="0.25rem",
),
padding="1rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
),
rx.text("Aucune absence enregistrée.", size="2", color="#666"),
),
# ── Actions rapides ───────────────────────────────────────
rx.cond(
FicheState.pending_dates.length() > 0,
rx.box(
rx.vstack(
rx.hstack(
rx.icon("clock", size=15, color="#b45309"),
rx.text(
"Valider toutes les absences d'une journée",
size="2", weight="bold", color="#92400e",
),
spacing="2", align="center",
),
rx.flex(
rx.foreach(FicheState.pending_dates, _pending_btn),
flex_wrap="wrap", gap="0.5rem",
),
spacing="2", width="100%",
),
padding="0.75rem 1rem",
background_color="#fffbeb",
border_radius="8px",
border="1px solid #fcd34d",
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="#f9fafb",
border_radius="8px",
border="1px solid #e5e7eb",
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%",
)
)