From 610e37d2a1fa20e7067fc9e438fe891eb3053f1a Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Mon, 11 May 2026 15:22:55 +0200 Subject: [PATCH] avis de sanction dans fiche apprenti --- eptm_dashboard/pages/fiche.py | 176 ++++++++++++++++---------- eptm_dashboard/pages/retenue.py | 5 +- eptm_dashboard/pages/sanction.py | 207 +++++++++++++++++++++++++++++-- src/sanction_pdf.py | 17 ++- 4 files changed, 329 insertions(+), 76 deletions(-) diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py index 1b58cad..190b374 100644 --- a/eptm_dashboard/pages/fiche.py +++ b/eptm_dashboard/pages/fiche.py @@ -418,9 +418,6 @@ class FicheState(AuthState): cal_next_name: str = "" cal_days: list[dict] = [] - # ── Pending dates (quick excuse) ───────────────────────────────────────── - pending_dates: list[dict] = [] - # ── Calendar day edit ───────────────────────────────────────────────────── edit_date: str = "" edit_date_label: str = "" @@ -435,6 +432,19 @@ class FicheState(AuthState): 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 = "" @@ -531,6 +541,7 @@ class FicheState(AuthState): 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 @@ -543,6 +554,13 @@ class FicheState(AuthState): 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 @@ -600,12 +618,8 @@ class FicheState(AuthState): self._rebuild_calendar() # ── Calendar day edit ───────────────────────────────────────────────────── - def select_day(self, date_str: str): - if not date_str: - return - if self.edit_date == date_str: - self.edit_date = "" - return + 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( @@ -632,9 +646,74 @@ class FicheState(AuthState): 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 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 = "" @@ -705,6 +784,10 @@ class FicheState(AuthState): ) 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 = ( @@ -745,9 +828,10 @@ class FicheState(AuthState): f"{old_type} → E (excuse rapide)" ) sess.commit() - if self.edit_date == date_str: - self.edit_date = "" 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 = ( @@ -890,19 +974,6 @@ class FicheState(AuthState): self.kpi_blocs = nb_blocs_absences(sess, self.selected_id) self.quota_atteint = self.kpi_blocs >= QUOTA - # Pending dates - by_date: dict = {} - for ab in absences: - by_date.setdefault(ab.date, []).append(ab) - self.pending_dates = [ - { - "date_str": d.isoformat(), - "label": f"{d.strftime('%d.%m')} ({sum(1 for a in al if a.statut == 'a_traiter')})", - } - for d, al in sorted(by_date.items()) - if any(a.statut == "a_traiter" for a in al) - ] - # Fiche fiche = sess.execute( select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id) @@ -1340,18 +1411,33 @@ def _edit_panel() -> rx.Component: flex_wrap="wrap", width="100%", ), - rx.hstack( + # Action rapide : excuser visuellement toutes les N → E. + # N'enregistre pas en DB — il faut cliquer sur « Enregistrer ». + rx.flex( + 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", + ), + 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", ), - spacing="3", + gap="0.75rem", flex_wrap="wrap", ), spacing="3", width="100%", ), @@ -1441,17 +1527,6 @@ def _notice_row(item) -> rx.Component: ) -def _pending_btn(item: dict) -> rx.Component: - return rx.button( - rx.icon("check", size=13), - item["label"], - on_click=FicheState.excuse_day(item["date_str"]), - color_scheme="green", - variant="soft", - size="1", - ) - - def _email_section() -> rx.Component: return rx.box( rx.vstack( @@ -1849,33 +1924,6 @@ def fiche_page() -> rx.Component: rx.text("Aucune absence enregistrée.", size="2", color="#666"), ), - # ── Actions rapides ─────────────────────────────────────── - rx.cond( - FicheState.pending_dates.length() > 0, - rx.box( - rx.vstack( - rx.hstack( - rx.icon("clock", size=15, color="#b45309"), - rx.text( - "Valider toutes les absences d'une journée", - size="2", weight="bold", color="#92400e", - ), - spacing="2", align="center", - ), - rx.flex( - rx.foreach(FicheState.pending_dates, _pending_btn), - flex_wrap="wrap", gap="0.5rem", - ), - spacing="2", width="100%", - ), - padding="0.75rem 1rem", - background_color="#fffbeb", - border_radius="8px", - border="1px solid #fcd34d", - width="100%", - ), - ), - # ── Panneau d'édition ───────────────────────────────────── rx.cond( FicheState.edit_date != "", diff --git a/eptm_dashboard/pages/retenue.py b/eptm_dashboard/pages/retenue.py index 30388f8..b68788f 100644 --- a/eptm_dashboard/pages/retenue.py +++ b/eptm_dashboard/pages/retenue.py @@ -156,7 +156,9 @@ class RetenueState(AuthState): 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.""" + """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) @@ -164,6 +166,7 @@ class RetenueState(AuthState): Notice.apprenti_id == apprenti_id, Notice.date_event == today, Notice.status == "pending", + Notice.source == "retenue", ) .order_by(Notice.created_at.desc()) ).scalars().first() diff --git a/eptm_dashboard/pages/sanction.py b/eptm_dashboard/pages/sanction.py index b02e0a9..18dfdeb 100644 --- a/eptm_dashboard/pages/sanction.py +++ b/eptm_dashboard/pages/sanction.py @@ -10,16 +10,20 @@ from __future__ import annotations import json import os import sys +from datetime import date as _date from pathlib import Path 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 # noqa: E402 -from src.sanction_pdf import generate_avis_pdf # noqa: E402 +from src.db import get_session, Apprenti, ApprentiFiche, Notice # noqa: E402 +from src.sanction_pdf import ( # noqa: E402 + generate_avis_pdf, _DEFAULT_TEXTE_SANCTION, _DEFAULT_CHEF_SECTION, +) from src.email_sender import send_email # noqa: E402 from src.user_access import is_class_allowed # noqa: E402 from src.logger import app_log # noqa: E402 @@ -52,10 +56,20 @@ class SanctionState(AuthState): sel_fiche_email_appr: str = "" sel_fiche_email_form: str = "" + # Texte de description et chef de section, pré-remplis depuis Paramètres + # (ou valeurs par défaut) à chaque preload — modifiables librement. + texte_description: str = "" + chef_section: 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 + form_error: str = "" def set_modal_open(self, v: bool): @@ -63,8 +77,11 @@ class SanctionState(AuthState): if not v: self.form_error = "" - 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_dest(self, v: str): self.email_dest = v + def set_email_custom(self, v: str): self.email_custom = v + def set_texte_description(self, v: str): self.texte_description = v + def set_chef_section(self, v: str): self.chef_section = v + def set_create_anyway(self, v: bool): self.create_anyway = v def preload_apprenti(self, apprenti_id: int, label: str): self.selected_id = apprenti_id @@ -72,6 +89,15 @@ class SanctionState(AuthState): self.form_error = "" self.email_dest = "apprenti" self.email_custom = "" + # Pré-remplit texte + chef de section avec Paramètres ou valeurs par défaut. + settings = _load_settings() + self.texte_description = ( + (settings.get("texte_sanction") or "").strip() or _DEFAULT_TEXTE_SANCTION + ) + self.chef_section = ( + (settings.get("chef_section") or "").strip() or _DEFAULT_CHEF_SECTION + ) + self.create_anyway = False sess = get_session() try: ap = sess.get(Apprenti, apprenti_id) @@ -84,10 +110,75 @@ class SanctionState(AuthState): else: self.sel_fiche_email_appr = "" self.sel_fiche_email_form = "" + self._detect_existing_notice(sess, apprenti_id) finally: sess.close() self.modal_open = True + def _detect_existing_notice(self, sess, apprenti_id: int): + """Détecte si une Notice de sanction pending existe déjà aujourd'hui + pour cet apprenti. Filtre par source pour ne pas confondre avec une + notice de retenue.""" + today = _date.today() + existing = sess.execute( + select(Notice) + .where( + Notice.apprenti_id == apprenti_id, + Notice.date_event == today, + Notice.status == "pending", + Notice.source == "sanction", + ) + .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 _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 + remarque = (self.texte_description or "").strip() or None + sess = get_session() + try: + sess.add(Notice( + apprenti_id=self.selected_id, + date_event=_date.today(), + titre="Avis de sanction", + remarque=remarque, + type_notice=None, + matiere=None, + source="sanction", + status="pending", + created_by=self.username or None, + )) + sess.commit() + app_log( + f"[notice] {self.username or '?'} : création (sanction) pour " + f"{self.selected_label}" + ) + except Exception as e: + sess.rollback() + app_log(f"[notice] échec création : {e}") + finally: + sess.close() + def _build_pdf(self) -> bytes | None: if not self.selected_id: self.form_error = "Aucun apprenti sélectionné." @@ -95,12 +186,18 @@ class SanctionState(AuthState): if not is_class_allowed(self.username, self.sel_classe): self.form_error = "Accès refusé pour cette classe." return None + txt = (self.texte_description or "").strip() + if not txt: + self.form_error = "Le texte de description ne peut pas être vide." + return None self.form_error = "" sess = get_session() try: return generate_avis_pdf( sess, self.selected_id, prof_name=self.name or self.username, + texte_override=txt, + chef_override=(self.chef_section or "").strip() or None, ) finally: sess.close() @@ -125,10 +222,11 @@ class SanctionState(AuthState): f"[sanction] {self.username or '?'} : avis téléchargé pour " f"{self.selected_label}" ) + self._create_notice() self.modal_open = False return [ rx.download(data=data, filename=self._filename()), - rx.toast.success("Avis de sanction téléchargé"), + rx.toast.success("Avis de sanction téléchargé — notice ajoutée à la file Escada"), ] def send_email_action(self): @@ -174,12 +272,101 @@ class SanctionState(AuthState): f"[sanction] {self.username or '?'} : avis envoyé à {to} pour " f"{self.selected_label}" ) + self._create_notice() self.modal_open = False - return rx.toast.success(f"Avis de sanction envoyé à {to}") + return rx.toast.success( + f"Avis de sanction envoyé à {to} — notice ajoutée à la file Escada" + ) # ── UI ──────────────────────────────────────────────────────────────────────── +def _texte_section() -> rx.Component: + """Texte de description + chef de section, pré-remplis depuis Paramètres + (ou fallback). L'utilisateur les modifie librement avant génération.""" + return rx.box( + rx.vstack( + rx.flex( + rx.icon("file-text", size=16, color="#37474f"), + rx.text("Contenu de l'avis", size="3", weight="bold", color="#37474f"), + gap="0.5rem", align="center", + ), + rx.divider(), + rx.text( + "Pré-remplis depuis les Paramètres. Modifiables avant génération.", + size="1", color="var(--gray-11)", + ), + rx.vstack( + rx.text("Texte de description", size="2", weight="medium", color="var(--gray-11)"), + rx.text_area( + value=SanctionState.texte_description, + on_change=SanctionState.set_texte_description, + rows="8", + width="100%", + ), + spacing="1", width="100%", + ), + rx.vstack( + rx.text("Chef de section", size="2", weight="medium", color="var(--gray-11)"), + rx.input( + value=SanctionState.chef_section, + on_change=SanctionState.set_chef_section, + width="100%", + ), + spacing="1", width="100%", + ), + spacing="3", width="100%", + ), + padding="1.25rem", + background_color="white", + border_radius="8px", + border="1px solid #e0e0e0", + width="100%", + ) + + +def _duplicate_notice_banner() -> rx.Component: + """Bannière jaune si une notice pending existe déjà aujourd'hui (avec checkbox override).""" + return rx.cond( + SanctionState.has_existing_notice, + rx.box( + rx.vstack( + rx.flex( + rx.icon("triangle-alert", size=16, color="#92400e"), + rx.text( + "Une notice est déjà en attente aujourd'hui pour cet apprenti :", + size="2", weight="medium", color="#92400e", + ), + gap="0.5rem", align="center", + ), + rx.text( + SanctionState.existing_notice_label, + size="1", color="#78350f", + ), + rx.flex( + rx.checkbox( + checked=SanctionState.create_anyway, + on_change=SanctionState.set_create_anyway, + size="2", + ), + rx.text( + "Créer quand même une nouvelle notice", + size="2", color="#78350f", + ), + gap="0.5rem", align="center", + ), + spacing="2", width="100%", + ), + padding="0.75rem 1rem", + background_color="#fef3c7", + border="1px solid #fcd34d", + border_radius="6px", + width="100%", + ), + rx.fragment(), + ) + + def _email_section() -> rx.Component: return rx.box( rx.vstack( @@ -278,13 +465,15 @@ def sanction_modal() -> rx.Component: rx.callout.root( rx.callout.icon(rx.icon("info", size=16)), rx.callout.text( - "L'avis utilise le texte par défaut configuré dans ", + "L'adresse et le nom de l'entreprise proviennent de la fiche " + "apprenti Escada. Les valeurs par défaut sont configurables dans ", rx.link("Paramètres", href="/params", color="var(--brand-accent)"), - " (motif et chef de section). L'adresse et le nom de l'entreprise " - "proviennent de la fiche apprenti Escada.", + ".", ), color_scheme="blue", variant="soft", size="1", ), + _texte_section(), + _duplicate_notice_banner(), rx.cond( SanctionState.form_error != "", rx.callout.root( diff --git a/src/sanction_pdf.py b/src/sanction_pdf.py index 6fcb78a..5f5a9bc 100644 --- a/src/sanction_pdf.py +++ b/src/sanction_pdf.py @@ -44,6 +44,8 @@ def generate_avis_pdf( sess: Session, apprenti_id: int, prof_name: str = "", + texte_override: Optional[str] = None, + chef_override: Optional[str] = None, ) -> Optional[bytes]: """Renvoie les bytes d'un PDF d'avis de sanction pré-rempli pour l'apprenti. @@ -51,6 +53,9 @@ def generate_avis_pdf( NomParents = nom entreprise) puisque les parents ne sont pas stockés. Texte de description et chef de section depuis data/settings.json. + Si `texte_override` ou `chef_override` est fourni (non vide), il remplace + la valeur issue des paramètres. + Renvoie None si le template est introuvable ou l'apprenti n'existe pas. """ if not _TEMPLATE_PATH.exists(): @@ -77,9 +82,17 @@ def generate_avis_pdf( "Adresse": (fiche.entreprise_adresse if fiche else "") or "", "NPA-Ville": npa_ville, "Date": date.today().strftime("%d.%m.%Y"), - "TexteDescription": settings.get("texte_sanction") or _DEFAULT_TEXTE_SANCTION, + "TexteDescription": ( + (texte_override or "").strip() + or settings.get("texte_sanction") + or _DEFAULT_TEXTE_SANCTION + ), "Prof": prof_name or "", - "CS": settings.get("chef_section") or _DEFAULT_CHEF_SECTION, + "CS": ( + (chef_override or "").strip() + or settings.get("chef_section") + or _DEFAULT_CHEF_SECTION + ), } # Lecture du template + clone vers writer (préserve la structure AcroForm)