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>
2229 lines
97 KiB
Python
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">—</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">—</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 — {nm.classe_mp} — {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 = ("⚠ " if _insuf else "") + _br["branche"]
|
|
_moy_html = (
|
|
f'<span style="font-weight:700;color:{_mc}">{_moy}</span>'
|
|
+ (f' <span style="font-size:0.8em;color:#888">({_moy_prov})</span>'
|
|
if _moy_prov is not None else "")
|
|
) if _moy is not None else "—"
|
|
_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 : {_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">—</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%",
|
|
)
|
|
)
|