avis de sanction dans fiche apprenti
This commit is contained in:
parent
ef6072112b
commit
610e37d2a1
4 changed files with 329 additions and 76 deletions
|
|
@ -418,9 +418,6 @@ class FicheState(AuthState):
|
||||||
cal_next_name: str = ""
|
cal_next_name: str = ""
|
||||||
cal_days: list[dict] = []
|
cal_days: list[dict] = []
|
||||||
|
|
||||||
# ── Pending dates (quick excuse) ─────────────────────────────────────────
|
|
||||||
pending_dates: list[dict] = []
|
|
||||||
|
|
||||||
# ── Calendar day edit ─────────────────────────────────────────────────────
|
# ── Calendar day edit ─────────────────────────────────────────────────────
|
||||||
edit_date: str = ""
|
edit_date: str = ""
|
||||||
edit_date_label: str = ""
|
edit_date_label: str = ""
|
||||||
|
|
@ -435,6 +432,19 @@ class FicheState(AuthState):
|
||||||
edit_p9: str = "present"
|
edit_p9: str = "present"
|
||||||
edit_p10: 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 ─────────────────────────────────────────────────────────
|
# ── Escada fiche ─────────────────────────────────────────────────────────
|
||||||
fiche_available: bool = False
|
fiche_available: bool = False
|
||||||
fiche_adresse: str = ""
|
fiche_adresse: str = ""
|
||||||
|
|
@ -531,6 +541,7 @@ class FicheState(AuthState):
|
||||||
self.selected_id = self.apprenti_ids[0]
|
self.selected_id = self.apprenti_ids[0]
|
||||||
self.selected_label = self.apprenti_labels[0]
|
self.selected_label = self.apprenti_labels[0]
|
||||||
self._reload(reset_email=True)
|
self._reload(reset_email=True)
|
||||||
|
self._select_today()
|
||||||
|
|
||||||
def handle_select(self, label: str):
|
def handle_select(self, label: str):
|
||||||
self.selected_label = label
|
self.selected_label = label
|
||||||
|
|
@ -543,6 +554,13 @@ class FicheState(AuthState):
|
||||||
self.apprenti_select_open = False
|
self.apprenti_select_open = False
|
||||||
self.apprenti_search = ""
|
self.apprenti_search = ""
|
||||||
self._reload(reset_email=True)
|
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):
|
def set_apprenti_search(self, v: str):
|
||||||
self.apprenti_search = v
|
self.apprenti_search = v
|
||||||
|
|
@ -600,12 +618,8 @@ class FicheState(AuthState):
|
||||||
self._rebuild_calendar()
|
self._rebuild_calendar()
|
||||||
|
|
||||||
# ── Calendar day edit ─────────────────────────────────────────────────────
|
# ── Calendar day edit ─────────────────────────────────────────────────────
|
||||||
def select_day(self, date_str: str):
|
def _load_day_choices(self, date_str: str):
|
||||||
if not date_str:
|
"""Met à jour edit_p1..p10 + edit_date_label pour la date donnée."""
|
||||||
return
|
|
||||||
if self.edit_date == date_str:
|
|
||||||
self.edit_date = ""
|
|
||||||
return
|
|
||||||
sess = get_session()
|
sess = get_session()
|
||||||
d = date.fromisoformat(date_str)
|
d = date.fromisoformat(date_str)
|
||||||
absences = sess.execute(
|
absences = sess.execute(
|
||||||
|
|
@ -632,9 +646,74 @@ class FicheState(AuthState):
|
||||||
self.edit_p8 = _choice(8)
|
self.edit_p8 = _choice(8)
|
||||||
self.edit_p9 = _choice(9)
|
self.edit_p9 = _choice(9)
|
||||||
self.edit_p10 = _choice(10)
|
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")
|
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
|
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):
|
def cancel_edit(self):
|
||||||
self.edit_date = ""
|
self.edit_date = ""
|
||||||
|
|
||||||
|
|
@ -705,6 +784,10 @@ class FicheState(AuthState):
|
||||||
)
|
)
|
||||||
sess.commit()
|
sess.commit()
|
||||||
self._reload(reset_email=False)
|
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:
|
if nb_changes == 0:
|
||||||
return rx.toast.info("Aucune modification")
|
return rx.toast.info("Aucune modification")
|
||||||
msg = (
|
msg = (
|
||||||
|
|
@ -745,9 +828,10 @@ class FicheState(AuthState):
|
||||||
f"{old_type} → E (excuse rapide)"
|
f"{old_type} → E (excuse rapide)"
|
||||||
)
|
)
|
||||||
sess.commit()
|
sess.commit()
|
||||||
if self.edit_date == date_str:
|
|
||||||
self.edit_date = ""
|
|
||||||
self._reload(reset_email=False)
|
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:
|
if nb == 0:
|
||||||
return rx.toast.info("Aucune absence à excuser")
|
return rx.toast.info("Aucune absence à excuser")
|
||||||
msg = (
|
msg = (
|
||||||
|
|
@ -890,19 +974,6 @@ class FicheState(AuthState):
|
||||||
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
|
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
|
||||||
self.quota_atteint = self.kpi_blocs >= QUOTA
|
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
|
||||||
fiche = sess.execute(
|
fiche = sess.execute(
|
||||||
select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
|
select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
|
||||||
|
|
@ -1340,18 +1411,33 @@ def _edit_panel() -> rx.Component:
|
||||||
flex_wrap="wrap",
|
flex_wrap="wrap",
|
||||||
width="100%",
|
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.button(
|
||||||
rx.icon("save", size=14), "Enregistrer",
|
rx.icon("save", size=14), "Enregistrer",
|
||||||
on_click=FicheState.save_day_edit,
|
on_click=FicheState.save_day_edit,
|
||||||
|
disabled=~FicheState.edit_has_changes,
|
||||||
color_scheme="blue", size="2",
|
color_scheme="blue", size="2",
|
||||||
),
|
),
|
||||||
rx.button(
|
rx.button(
|
||||||
"Annuler",
|
"Annuler",
|
||||||
on_click=FicheState.cancel_edit,
|
on_click=FicheState.cancel_edit,
|
||||||
|
disabled=~FicheState.edit_has_changes,
|
||||||
variant="outline", color_scheme="gray", size="2",
|
variant="outline", color_scheme="gray", size="2",
|
||||||
),
|
),
|
||||||
spacing="3",
|
gap="0.75rem", flex_wrap="wrap",
|
||||||
),
|
),
|
||||||
spacing="3", width="100%",
|
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:
|
def _email_section() -> rx.Component:
|
||||||
return rx.box(
|
return rx.box(
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
|
|
@ -1849,33 +1924,6 @@ def fiche_page() -> rx.Component:
|
||||||
rx.text("Aucune absence enregistrée.", size="2", color="#666"),
|
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 ─────────────────────────────────────
|
# ── Panneau d'édition ─────────────────────────────────────
|
||||||
rx.cond(
|
rx.cond(
|
||||||
FicheState.edit_date != "",
|
FicheState.edit_date != "",
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,9 @@ class RetenueState(AuthState):
|
||||||
self.modal_open = True
|
self.modal_open = True
|
||||||
|
|
||||||
def _detect_existing_notice(self, sess, apprenti_id: int):
|
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()
|
today = _date.today()
|
||||||
existing = sess.execute(
|
existing = sess.execute(
|
||||||
select(Notice)
|
select(Notice)
|
||||||
|
|
@ -164,6 +166,7 @@ class RetenueState(AuthState):
|
||||||
Notice.apprenti_id == apprenti_id,
|
Notice.apprenti_id == apprenti_id,
|
||||||
Notice.date_event == today,
|
Notice.date_event == today,
|
||||||
Notice.status == "pending",
|
Notice.status == "pending",
|
||||||
|
Notice.source == "retenue",
|
||||||
)
|
)
|
||||||
.order_by(Notice.created_at.desc())
|
.order_by(Notice.created_at.desc())
|
||||||
).scalars().first()
|
).scalars().first()
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,20 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import date as _date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import reflex as rx
|
import reflex as rx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
_ROOT = Path(__file__).resolve().parent.parent.parent
|
_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
if str(_ROOT) not in sys.path:
|
if str(_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_ROOT))
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
from src.db import get_session, Apprenti, ApprentiFiche # noqa: E402
|
from src.db import get_session, Apprenti, ApprentiFiche, Notice # noqa: E402
|
||||||
from src.sanction_pdf import generate_avis_pdf # 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.email_sender import send_email # noqa: E402
|
||||||
from src.user_access import is_class_allowed # noqa: E402
|
from src.user_access import is_class_allowed # noqa: E402
|
||||||
from src.logger import app_log # 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_appr: str = ""
|
||||||
sel_fiche_email_form: 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
|
||||||
email_dest: str = "apprenti"
|
email_dest: str = "apprenti"
|
||||||
email_custom: str = ""
|
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 = ""
|
form_error: str = ""
|
||||||
|
|
||||||
def set_modal_open(self, v: bool):
|
def set_modal_open(self, v: bool):
|
||||||
|
|
@ -63,8 +77,11 @@ class SanctionState(AuthState):
|
||||||
if not v:
|
if not v:
|
||||||
self.form_error = ""
|
self.form_error = ""
|
||||||
|
|
||||||
def set_email_dest(self, v: str): self.email_dest = 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_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):
|
def preload_apprenti(self, apprenti_id: int, label: str):
|
||||||
self.selected_id = apprenti_id
|
self.selected_id = apprenti_id
|
||||||
|
|
@ -72,6 +89,15 @@ class SanctionState(AuthState):
|
||||||
self.form_error = ""
|
self.form_error = ""
|
||||||
self.email_dest = "apprenti"
|
self.email_dest = "apprenti"
|
||||||
self.email_custom = ""
|
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()
|
sess = get_session()
|
||||||
try:
|
try:
|
||||||
ap = sess.get(Apprenti, apprenti_id)
|
ap = sess.get(Apprenti, apprenti_id)
|
||||||
|
|
@ -84,10 +110,75 @@ class SanctionState(AuthState):
|
||||||
else:
|
else:
|
||||||
self.sel_fiche_email_appr = ""
|
self.sel_fiche_email_appr = ""
|
||||||
self.sel_fiche_email_form = ""
|
self.sel_fiche_email_form = ""
|
||||||
|
self._detect_existing_notice(sess, apprenti_id)
|
||||||
finally:
|
finally:
|
||||||
sess.close()
|
sess.close()
|
||||||
self.modal_open = True
|
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:
|
def _build_pdf(self) -> bytes | None:
|
||||||
if not self.selected_id:
|
if not self.selected_id:
|
||||||
self.form_error = "Aucun apprenti sélectionné."
|
self.form_error = "Aucun apprenti sélectionné."
|
||||||
|
|
@ -95,12 +186,18 @@ class SanctionState(AuthState):
|
||||||
if not is_class_allowed(self.username, self.sel_classe):
|
if not is_class_allowed(self.username, self.sel_classe):
|
||||||
self.form_error = "Accès refusé pour cette classe."
|
self.form_error = "Accès refusé pour cette classe."
|
||||||
return None
|
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 = ""
|
self.form_error = ""
|
||||||
sess = get_session()
|
sess = get_session()
|
||||||
try:
|
try:
|
||||||
return generate_avis_pdf(
|
return generate_avis_pdf(
|
||||||
sess, self.selected_id,
|
sess, self.selected_id,
|
||||||
prof_name=self.name or self.username,
|
prof_name=self.name or self.username,
|
||||||
|
texte_override=txt,
|
||||||
|
chef_override=(self.chef_section or "").strip() or None,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
sess.close()
|
sess.close()
|
||||||
|
|
@ -125,10 +222,11 @@ class SanctionState(AuthState):
|
||||||
f"[sanction] {self.username or '?'} : avis téléchargé pour "
|
f"[sanction] {self.username or '?'} : avis téléchargé pour "
|
||||||
f"{self.selected_label}"
|
f"{self.selected_label}"
|
||||||
)
|
)
|
||||||
|
self._create_notice()
|
||||||
self.modal_open = False
|
self.modal_open = False
|
||||||
return [
|
return [
|
||||||
rx.download(data=data, filename=self._filename()),
|
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):
|
def send_email_action(self):
|
||||||
|
|
@ -174,12 +272,101 @@ class SanctionState(AuthState):
|
||||||
f"[sanction] {self.username or '?'} : avis envoyé à {to} pour "
|
f"[sanction] {self.username or '?'} : avis envoyé à {to} pour "
|
||||||
f"{self.selected_label}"
|
f"{self.selected_label}"
|
||||||
)
|
)
|
||||||
|
self._create_notice()
|
||||||
self.modal_open = False
|
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 ────────────────────────────────────────────────────────────────────────
|
# ── 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:
|
def _email_section() -> rx.Component:
|
||||||
return rx.box(
|
return rx.box(
|
||||||
rx.vstack(
|
rx.vstack(
|
||||||
|
|
@ -278,13 +465,15 @@ def sanction_modal() -> rx.Component:
|
||||||
rx.callout.root(
|
rx.callout.root(
|
||||||
rx.callout.icon(rx.icon("info", size=16)),
|
rx.callout.icon(rx.icon("info", size=16)),
|
||||||
rx.callout.text(
|
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)"),
|
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",
|
color_scheme="blue", variant="soft", size="1",
|
||||||
),
|
),
|
||||||
|
_texte_section(),
|
||||||
|
_duplicate_notice_banner(),
|
||||||
rx.cond(
|
rx.cond(
|
||||||
SanctionState.form_error != "",
|
SanctionState.form_error != "",
|
||||||
rx.callout.root(
|
rx.callout.root(
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ def generate_avis_pdf(
|
||||||
sess: Session,
|
sess: Session,
|
||||||
apprenti_id: int,
|
apprenti_id: int,
|
||||||
prof_name: str = "",
|
prof_name: str = "",
|
||||||
|
texte_override: Optional[str] = None,
|
||||||
|
chef_override: Optional[str] = None,
|
||||||
) -> Optional[bytes]:
|
) -> Optional[bytes]:
|
||||||
"""Renvoie les bytes d'un PDF d'avis de sanction pré-rempli pour l'apprenti.
|
"""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.
|
NomParents = nom entreprise) puisque les parents ne sont pas stockés.
|
||||||
Texte de description et chef de section depuis data/settings.json.
|
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.
|
Renvoie None si le template est introuvable ou l'apprenti n'existe pas.
|
||||||
"""
|
"""
|
||||||
if not _TEMPLATE_PATH.exists():
|
if not _TEMPLATE_PATH.exists():
|
||||||
|
|
@ -77,9 +82,17 @@ def generate_avis_pdf(
|
||||||
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
"Adresse": (fiche.entreprise_adresse if fiche else "") or "",
|
||||||
"NPA-Ville": npa_ville,
|
"NPA-Ville": npa_ville,
|
||||||
"Date": date.today().strftime("%d.%m.%Y"),
|
"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 "",
|
"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)
|
# Lecture du template + clone vers writer (préserve la structure AcroForm)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue