894 lines
33 KiB
Python
894 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 pending existe déjà aujourd'hui pour cet apprenti."""
|
|
today = _date.today()
|
|
existing = sess.execute(
|
|
select(Notice)
|
|
.where(
|
|
Notice.apprenti_id == apprenti_id,
|
|
Notice.date_event == today,
|
|
Notice.status == "pending",
|
|
)
|
|
.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="white",
|
|
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="white",
|
|
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="#37474f"),
|
|
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="#37474f"),
|
|
rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
|
|
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="white",
|
|
border_radius="8px",
|
|
border="1px solid #e0e0e0",
|
|
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,
|
|
)
|