eptm_dashboard/eptm_dashboard/pages/retenue.py

897 lines
33 KiB
Python

"""Page /retenue — génération et envoi d'avis de retenue."""
from __future__ import annotations
import json
import os
import sys
from datetime import date as _date
from pathlib import Path
from typing import Optional
import reflex as rx
from sqlalchemy import select
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.db import get_session, Apprenti, ApprentiFiche, NotesExamen, Notice # noqa: E402
from src.user_access import get_allowed_classes, is_class_allowed # noqa: E402
from src.profession import resolve_profession # noqa: E402
from src.retenue_pdf import generate_retenue_pdf # noqa: E402
from src.email_sender import send_email # noqa: E402
from src.logger import app_log # noqa: E402
from ..state import AuthState
from ..sidebar import layout
from ..components import empty_state
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_FILE = DATA_DIR / "settings.json"
def _load_settings() -> dict:
if _SETTINGS_FILE.exists():
try:
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
# ── State ─────────────────────────────────────────────────────────────────────
class RetenueState(AuthState):
# Modal control (utilisé depuis /fiche)
modal_open: bool = False
# Sélecteur apprenti (présent pour le modal, en read-only)
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
# Données de l'apprenti sélectionné
sel_classe: str = ""
sel_profession: str = ""
sel_fiche_email_appr: str = ""
sel_fiche_email_form: str = ""
sel_fiche_email_entr: str = ""
sel_fiche_nom_entr: str = ""
# Cache des branches (récupérées des notes d'examen)
branches_cache: list[str] = []
branche_search: str = ""
branche_open: bool = False
# Formulaire
retenue_date: str = "" # ISO date "YYYY-MM-DD"
probleme_date: str = ""
case: str = "devoir" # "devoir" | "comportement" | "retard"
branche: str = ""
remarque: str = ""
# Email
email_dest: str = "apprenti"
email_custom: str = ""
# Détection notice existante (pending) pour cet apprenti à la date du jour
has_existing_notice: bool = False
existing_notice_label: str = ""
create_anyway: bool = False
# États
form_error: str = ""
@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()]
@rx.var
def filtered_branches(self) -> list[str]:
q = self.branche_search.lower().strip()
if not q:
return self.branches_cache
return [b for b in self.branches_cache if q in b.lower()]
# ── Setters ──────────────────────────────────────────────────────────────
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 set_branche_search(self, v: str): self.branche_search = v
def set_branche_open(self, v: bool):
self.branche_open = v
if not v:
self.branche_search = ""
def set_retenue_date(self, v: str): self.retenue_date = v
def set_probleme_date(self, v: str): self.probleme_date = v
def set_case(self, v: str): self.case = v
def set_branche(self, v: str): self.branche = v
def set_remarque(self, v: str): self.remarque = v
def set_profession(self, v: str): self.sel_profession = v
def set_email_dest(self, v: str): self.email_dest = v
def set_email_custom(self, v: str): self.email_custom = v
def set_create_anyway(self, v: bool): self.create_anyway = v
def set_modal_open(self, v: bool):
self.modal_open = v
if not v:
# Reset partiel à la fermeture
self.form_error = ""
def preload_apprenti(self, apprenti_id: int, label: str):
"""Pré-remplit l'apprenti depuis la fiche et ouvre le modal."""
self.selected_id = apprenti_id
self.selected_label = label
# Reset des autres champs
self.case = "devoir"
self.branche = ""
self.remarque = ""
self.form_error = ""
self.email_dest = "apprenti"
self.email_custom = ""
self.create_anyway = False
# Dates par défaut = aujourd'hui
today = _date.today().isoformat()
self.retenue_date = today
self.probleme_date = today
# Charger les données apprenti (profession, emails) + cache branches
self._load_apprenti()
sess = get_session()
try:
self._load_branches(sess)
self._detect_existing_notice(sess, apprenti_id)
finally:
sess.close()
# Ouvrir le modal
self.modal_open = True
def _detect_existing_notice(self, sess, apprenti_id: int):
"""Détecte si une Notice de retenue pending existe déjà aujourd'hui
pour cet apprenti. Filtre par source pour ne pas confondre avec une
notice de sanction."""
today = _date.today()
existing = sess.execute(
select(Notice)
.where(
Notice.apprenti_id == apprenti_id,
Notice.date_event == today,
Notice.status == "pending",
Notice.source == "retenue",
)
.order_by(Notice.created_at.desc())
).scalars().first()
if existing:
self.has_existing_notice = True
self.existing_notice_label = (
f"{existing.titre or '(sans titre)'}"
f"créée le {existing.created_at.strftime('%d.%m.%Y %H:%M')}"
)
else:
self.has_existing_notice = False
self.existing_notice_label = ""
def close_after_action(self):
"""Appelée après un téléchargement / envoi pour fermer le modal."""
self.modal_open = False
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
sess = get_session()
try:
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]
# Toujours partir d'une sélection vide à l'arrivée sur la page
self.selected_id = 0
self.selected_label = ""
self.sel_classe = ""
self.sel_profession = ""
self.sel_fiche_email_appr = ""
self.sel_fiche_email_form = ""
self.sel_fiche_email_entr = ""
self.sel_fiche_nom_entr = ""
self._load_branches(sess)
finally:
sess.close()
# Dates par défaut = aujourd'hui
today = _date.today().isoformat()
if not self.retenue_date:
self.retenue_date = today
if not self.probleme_date:
self.probleme_date = today
def _load_apprenti(self):
if not self.selected_id:
return
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
if not ap:
return
self.sel_classe = ap.classe
fiche = ap.fiche
if fiche:
self.sel_profession = fiche.profession or resolve_profession(ap.classe)
self.sel_fiche_email_appr = fiche.email or ""
self.sel_fiche_email_form = fiche.formateur_email or ""
self.sel_fiche_email_entr = fiche.entreprise_email or ""
self.sel_fiche_nom_entr = fiche.entreprise_nom or ""
else:
self.sel_profession = resolve_profession(ap.classe)
self.sel_fiche_email_appr = ""
self.sel_fiche_email_form = ""
self.sel_fiche_email_entr = ""
self.sel_fiche_nom_entr = ""
finally:
sess.close()
def _load_branches(self, sess):
"""Construit le cache des branches uniques depuis NotesExamen."""
rows = sess.execute(select(NotesExamen.donnees_json)).scalars().all()
seen: set[str] = set()
for raw in rows:
try:
d = json.loads(raw)
except Exception:
continue
if isinstance(d, list):
for br in d:
name = (br.get("branche") or "").strip()
if name:
seen.add(name)
self.branches_cache = sorted(seen)
def handle_select_apprenti(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.apprenti_select_open = False
self.apprenti_search = ""
self._load_apprenti()
def apprenti_search_keydown(self, key: str):
if key == "Enter":
results = self.filtered_apprenti_labels
if results:
return RetenueState.handle_select_apprenti(results[0])
elif key == "Escape":
self.apprenti_select_open = False
self.apprenti_search = ""
def select_branche(self, b: str):
self.branche = b
self.branche_open = False
self.branche_search = ""
def branche_keydown(self, key: str):
if key == "Enter":
# Si une seule branche filtrée : la sélectionne. Sinon prend la saisie libre.
results = self.filtered_branches
if len(results) == 1:
return RetenueState.select_branche(results[0])
elif self.branche_search:
self.branche = self.branche_search.strip()
self.branche_open = False
self.branche_search = ""
elif key == "Escape":
self.branche_open = False
self.branche_search = ""
# ── Actions ──────────────────────────────────────────────────────────────
_CASE_LABELS = {
"devoir": "N'a pas remis ses tâches scolaires dans les délais",
"comportement": "A manifesté un comportement répréhensible",
"retard": "Est arrivé en retard aux cours",
}
def _build_notice_titre(self) -> str:
label = self._CASE_LABELS.get(self.case, "")
if self.case == "devoir" and self.branche.strip():
return f"{label} en {self.branche.strip()}"
return label
def _create_notice(self):
"""Crée une Notice en DB (push queue Escada).
Si une notice pending existe déjà pour cet apprenti aujourd'hui et que
l'utilisateur n'a pas coché « Créer quand même », on saute la création.
"""
if not self.selected_id:
return
if self.has_existing_notice and not self.create_anyway:
app_log(
f"[notice] {self.username or '?'} : notice doublon évitée pour "
f"{self.selected_label} (existante : {self.existing_notice_label})"
)
return
sess = get_session()
try:
sess.add(Notice(
apprenti_id=self.selected_id,
date_event=_date.today(),
titre=self._build_notice_titre(),
remarque=(self.remarque or "").strip() or None,
type_notice=None,
matiere=None,
source="retenue",
status="pending",
created_by=self.username or None,
))
sess.commit()
app_log(
f"[notice] {self.username or '?'} : création (retenue) pour "
f"{self.selected_label} — case={self.case}"
)
except Exception as e:
sess.rollback()
app_log(f"[notice] échec création : {e}")
finally:
sess.close()
def _build_pdf(self) -> Optional[bytes]:
if not self.selected_id:
self.form_error = "Aucun apprenti sélectionné."
return None
if not is_class_allowed(self.username, self.sel_classe):
self.form_error = "Accès refusé pour cette classe."
return None
if self.case == "devoir" and not self.branche.strip():
self.form_error = "Veuillez préciser la branche."
return None
try:
r_date = _date.fromisoformat(self.retenue_date)
p_date = _date.fromisoformat(self.probleme_date)
except Exception:
self.form_error = "Date invalide."
return None
self.form_error = ""
sess = get_session()
try:
return generate_retenue_pdf(
sess, self.selected_id,
profession=self.sel_profession,
retenue_date=r_date,
probleme_date=p_date,
case=self.case,
branche=self.branche.strip(),
remarque=self.remarque,
prof_name=self.name or self.username,
)
finally:
sess.close()
def _filename(self) -> str:
sess = get_session()
try:
ap = sess.get(Apprenti, self.selected_id)
if not ap:
return "Avis_retenue.pdf"
safe_nom = "".join(c if c.isalnum() else "_" for c in ap.nom)
safe_prenom = "".join(c if c.isalnum() else "_" for c in ap.prenom)
return f"Avis_retenue_{safe_nom}_{safe_prenom}.pdf"
finally:
sess.close()
def download_pdf(self):
data = self._build_pdf()
if data is None:
if self.form_error:
return rx.toast.error(self.form_error)
return rx.toast.error("Impossible de générer le PDF.")
app_log(
f"[retenue] {self.username or '?'} : avis téléchargé pour "
f"{self.selected_label} (case={self.case})"
)
self._create_notice()
self.modal_open = False
return [
rx.download(data=data, filename=self._filename()),
rx.toast.success("Avis téléchargé — notice ajoutée à la file Escada"),
]
def send_email_action(self):
data = self._build_pdf()
if data is None:
if self.form_error:
return rx.toast.error(self.form_error)
return rx.toast.error("Impossible de générer le PDF.")
# Destinataire
if self.email_dest == "apprenti":
to = self.sel_fiche_email_appr
elif self.email_dest == "formateur":
to = self.sel_fiche_email_form
else:
to = self.email_custom.strip()
if not to or "@" not in to:
return rx.toast.error("Adresse email invalide ou manquante.")
s = _load_settings()
smtp_host = s.get("smtp_host")
smtp_port = int(s.get("smtp_port") or 587)
smtp_login = s.get("smtp_login")
smtp_password = s.get("smtp_password")
smtp_sender = s.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
return rx.toast.error("Configuration SMTP incomplète (Paramètres).")
subject = f"Avis de retenue — {self.selected_label}"
body = (
f"Bonjour,\n\nVeuillez trouver en pièce jointe l'avis de retenue concernant "
f"{self.selected_label}.\n\nCordialement,\n{self.name or self.username}\n"
)
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=subject, body=body,
attachments=[(data, self._filename())],
)
except Exception as e:
return rx.toast.error(f"Échec d'envoi : {e}")
app_log(
f"[retenue] {self.username or '?'} : avis envoyé à {to} pour "
f"{self.selected_label}"
)
self._create_notice()
self.modal_open = False
return rx.toast.success(
f"Avis envoyé à {to} — notice ajoutée à la file Escada"
)
# ── UI ────────────────────────────────────────────────────────────────────────
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=RetenueState.handle_select_apprenti(label),
_hover={"background_color": "var(--gray-3)"},
width="100%",
)
def _apprenti_selector() -> rx.Component:
return rx.popover.root(
rx.popover.trigger(
rx.box(
rx.flex(
rx.cond(
RetenueState.selected_label != "",
rx.text(RetenueState.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=RetenueState.apprenti_search,
on_change=RetenueState.set_apprenti_search,
on_key_down=RetenueState.apprenti_search_keydown,
size="2",
width="100%",
auto_focus=True,
),
rx.cond(
RetenueState.filtered_apprenti_labels.length() > 0,
rx.box(
rx.foreach(RetenueState.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=RetenueState.apprenti_select_open,
on_open_change=RetenueState.set_apprenti_select_open,
)
def _branche_option(b: rx.Var) -> rx.Component:
return rx.box(
rx.text(b, size="2"),
padding="0.45rem 0.75rem",
cursor="pointer",
on_click=RetenueState.select_branche(b),
_hover={"background_color": "var(--gray-3)"},
width="100%",
)
def _branche_selector() -> rx.Component:
return rx.popover.root(
rx.popover.trigger(
rx.box(
rx.flex(
rx.cond(
RetenueState.branche != "",
rx.text(RetenueState.branche, size="2"),
rx.text("Choisir / taper une branche…", 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%",
),
),
rx.popover.content(
rx.vstack(
rx.input(
placeholder="Rechercher ou saisir une branche libre…",
value=RetenueState.branche_search,
on_change=RetenueState.set_branche_search,
on_key_down=RetenueState.branche_keydown,
size="2",
width="100%",
auto_focus=True,
),
rx.cond(
RetenueState.filtered_branches.length() > 0,
rx.box(
rx.foreach(RetenueState.filtered_branches, _branche_option),
max_height="280px",
overflow_y="auto",
width="100%",
),
rx.text(
"Appuyez sur Entrée pour valider votre saisie libre.",
size="1", color="var(--gray-9)",
padding="0.5rem 0.75rem",
),
),
spacing="2",
width="100%",
),
min_width="320px",
max_width="500px",
padding="0.5rem",
),
open=RetenueState.branche_open,
on_open_change=RetenueState.set_branche_open,
)
def _profession_warning() -> rx.Component:
# Affiché uniquement si un apprenti est sélectionné ET que sa profession est vide
return rx.cond(
(RetenueState.selected_id != 0) & (RetenueState.sel_profession == ""),
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(
"Profession non définie pour ",
RetenueState.sel_classe,
". Renseigne-la ci-dessous, ou ajoute la correspondance dans ",
rx.link("Paramètres", href="/params", color="var(--brand-accent)"),
" pour qu'elle soit pré-remplie automatiquement.",
),
color_scheme="amber", variant="soft", size="1",
),
rx.fragment(),
)
def _form() -> rx.Component:
return rx.vstack(
# Bannière apprenti (read-only, pré-rempli depuis la fiche)
rx.box(
rx.flex(
rx.icon("user", size=16, color="var(--brand-accent)"),
rx.text(RetenueState.selected_label, size="2", weight="medium", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
padding="0.5rem 0.75rem",
background_color="#e3f2fd",
border_radius="6px",
border="1px solid #90caf9",
),
_profession_warning(),
# Profession (éditable)
rx.vstack(
rx.text("Profession", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
value=RetenueState.sel_profession,
on_change=RetenueState.set_profession,
placeholder="ex. Automaticien CFC",
width="100%",
),
spacing="1", width="100%",
),
# Dates
rx.flex(
rx.vstack(
rx.text("Date de retenue", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
type="date",
value=RetenueState.retenue_date,
on_change=RetenueState.set_retenue_date,
width="100%",
),
spacing="1", flex="1", min_width="200px",
),
rx.vstack(
rx.text("Date du problème", size="2", weight="medium", color="var(--gray-11)"),
rx.input(
type="date",
value=RetenueState.probleme_date,
on_change=RetenueState.set_probleme_date,
width="100%",
),
spacing="1", flex="1", min_width="200px",
),
gap="0.75rem", flex_wrap="wrap", width="100%",
),
# Motif (radio)
rx.vstack(
rx.text("Motif de la retenue", size="2", weight="medium", color="var(--gray-11)"),
rx.radio_group.root(
rx.vstack(
rx.radio_group.item(
rx.text("N'a pas remis ses tâches scolaires dans les délais", size="2"),
value="devoir",
),
rx.radio_group.item(
rx.text("A manifesté un comportement répréhensible", size="2"),
value="comportement",
),
rx.radio_group.item(
rx.text("Est arrivé en retard aux cours", size="2"),
value="retard",
),
spacing="2",
),
value=RetenueState.case,
on_change=RetenueState.set_case,
),
spacing="2", width="100%",
),
# Branche (visible seulement si case devoir)
rx.cond(
RetenueState.case == "devoir",
rx.vstack(
rx.text("Branche", size="2", weight="medium", color="var(--gray-11)"),
_branche_selector(),
spacing="1", width="100%",
),
rx.fragment(),
),
# Remarque
rx.vstack(
rx.text("Remarque éventuelle de l'école", size="2", weight="medium", color="var(--gray-11)"),
rx.text_area(
value=RetenueState.remarque,
on_change=RetenueState.set_remarque,
rows="4",
width="100%",
resize="vertical",
),
spacing="1", width="100%",
),
# Erreur
rx.cond(
RetenueState.form_error != "",
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(RetenueState.form_error),
color_scheme="red", variant="soft", size="1",
),
rx.fragment(),
),
# Bandeau d'info notice Escada (jaune si doublon détecté, bleu sinon)
rx.cond(
RetenueState.has_existing_notice,
rx.box(
rx.flex(
rx.icon("triangle-alert", size=14, color="#b45309"),
rx.text(
"Une notice est déjà en attente pour cet apprenti aujourd'hui : ",
rx.text.strong(RetenueState.existing_notice_label),
". Par défaut, aucune nouvelle notice ne sera créée.",
size="1", color="#78350f",
),
gap="0.4rem", align="start",
),
rx.flex(
rx.checkbox(
checked=RetenueState.create_anyway,
on_change=RetenueState.set_create_anyway,
size="2",
color_scheme="amber",
),
rx.text(
"Créer quand même une nouvelle notice",
size="2", color="#78350f", weight="medium",
),
gap="0.5rem", align="center", margin_top="0.4rem",
),
padding="0.6rem 0.75rem",
background_color="#fef3c7",
border="1px solid #fcd34d",
border_radius="6px",
),
rx.flex(
rx.icon("info", size=14, color="var(--brand-accent)"),
rx.text(
"Une notice sera ajoutée à la file d'attente Escada lors du téléchargement "
"ou de l'envoi par email. Choisis une seule de ces deux actions.",
size="1", color="var(--brand-accent)",
),
gap="0.4rem", align="start",
padding="0.5rem 0.65rem",
background_color="#e3f2fd",
border="1px solid #90caf9",
border_radius="6px",
),
),
# Bouton Télécharger
rx.button(
rx.icon("file-down", size=16),
"Télécharger l'avis",
on_click=RetenueState.download_pdf,
color_scheme="red", size="2",
disabled=RetenueState.selected_id == 0,
width="100%",
),
spacing="4",
width="100%",
)
def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.flex(
rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
rx.divider(),
rx.text("Destinataire", size="2", weight="medium", color="var(--gray-11)"),
rx.radio_group.root(
rx.vstack(
rx.radio_group.item(
rx.cond(
RetenueState.sel_fiche_email_appr != "",
rx.text("Apprenti — ", RetenueState.sel_fiche_email_appr, size="2"),
rx.text("Apprenti (email inconnu)", size="2", color="var(--gray-9)"),
),
value="apprenti",
disabled=RetenueState.sel_fiche_email_appr == "",
),
rx.radio_group.item(
rx.cond(
RetenueState.sel_fiche_email_form != "",
rx.text("Formateur — ", RetenueState.sel_fiche_email_form, size="2"),
rx.text("Formateur (email inconnu)", size="2", color="var(--gray-9)"),
),
value="formateur",
disabled=RetenueState.sel_fiche_email_form == "",
),
rx.radio_group.item(
rx.text("Autre adresse", size="2"),
value="autre",
),
spacing="2",
),
value=RetenueState.email_dest,
on_change=RetenueState.set_email_dest,
),
rx.cond(
RetenueState.email_dest == "autre",
rx.input(
placeholder="email@domaine.ch",
value=RetenueState.email_custom,
on_change=RetenueState.set_email_custom,
type="email",
width="100%",
),
rx.fragment(),
),
rx.button(
rx.icon("send", size=16),
"Envoyer l'avis par email",
on_click=RetenueState.send_email_action,
color_scheme="blue", size="2",
disabled=RetenueState.selected_id == 0,
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="var(--surface)",
border_radius="8px",
border="1px solid var(--border)",
width="100%",
)
def retenue_modal() -> rx.Component:
"""Modal réutilisable pour créer un avis de retenue.
L'apprenti doit être pré-rempli via `RetenueState.preload_apprenti(id, label)`
avant l'ouverture. L'état `modal_open` contrôle l'affichage.
"""
return rx.dialog.root(
rx.dialog.content(
rx.dialog.title("Créer un avis de retenue"),
rx.dialog.description(
"Renseigne les informations et télécharge ou envoie l'avis par email.",
size="2", color="var(--gray-11)",
),
rx.vstack(
_form(),
_email_section(),
spacing="4", width="100%",
),
rx.flex(
rx.dialog.close(
rx.button("Fermer", variant="soft", color_scheme="gray"),
),
gap="0.5rem", justify="end", margin_top="1rem",
),
max_width="720px",
max_height="90vh",
overflow_y="auto",
),
open=RetenueState.modal_open,
on_open_change=RetenueState.set_modal_open,
)