330 lines
12 KiB
Python
330 lines
12 KiB
Python
import os
|
|
import sys
|
|
from collections import defaultdict
|
|
sys.path.insert(0, "/opt/eptm-dashboard")
|
|
|
|
import reflex as rx
|
|
from src.db import get_session, Apprenti, Absence
|
|
from src.stats import kpis, alertes_quota_absences
|
|
from src.sanction_pdf import generate_avis_pdf
|
|
from src.logger import app_log
|
|
from src.user_access import get_allowed_classes, is_class_allowed
|
|
from sqlalchemy import select, func
|
|
from ..state import AuthState
|
|
from ..sidebar import layout
|
|
from .fiche import FicheState
|
|
from .classe import ClasseState
|
|
|
|
|
|
class AccueilState(AuthState):
|
|
kpi_mois: int = 0
|
|
kpi_total: int = 0
|
|
kpi_traiter: int = 0
|
|
# Liste à plat (compteur global)
|
|
sanctions_total: int = 0
|
|
classes_total: int = 0
|
|
# Groupement par classe pour l'affichage en tuiles
|
|
sanctions_groups: list[dict] = []
|
|
|
|
def load_data(self):
|
|
if not self.authenticated:
|
|
return rx.redirect("/login")
|
|
try:
|
|
sess = get_session()
|
|
try:
|
|
allowed = get_allowed_classes(self.username)
|
|
|
|
# KPIs : recalcul filtré si l'utilisateur est restreint
|
|
if allowed is None:
|
|
k = kpis(sess)
|
|
self.kpi_mois = k["total_ce_mois"]
|
|
self.kpi_total = k["total_global"]
|
|
self.kpi_traiter = k["n_a_traiter"]
|
|
else:
|
|
self.kpi_mois, self.kpi_total, self.kpi_traiter = self._filtered_kpis(
|
|
sess, allowed,
|
|
)
|
|
|
|
df = alertes_quota_absences(sess, seuil=5)
|
|
items = [
|
|
{
|
|
"id": int(row["_id"]),
|
|
"nom": str(row["Nom"]),
|
|
"prenom": str(row["Prénom"]),
|
|
"classe": str(row["Classe"]),
|
|
"absences": int(row["Absences"]),
|
|
}
|
|
for _, row in df.iterrows()
|
|
]
|
|
# Filtrage selon les classes autorisées
|
|
if allowed is not None:
|
|
items = [it for it in items if it["classe"] in allowed]
|
|
# Groupement par classe (tri alphabétique des classes,
|
|
# puis par nom dans chaque classe).
|
|
grouped: dict[str, list[dict]] = defaultdict(list)
|
|
for it in items:
|
|
grouped[it["classe"]].append(it)
|
|
self.sanctions_groups = [
|
|
{
|
|
"classe": c,
|
|
"count": len(grouped[c]),
|
|
"items": sorted(grouped[c], key=lambda x: (x["nom"], x["prenom"])),
|
|
}
|
|
for c in sorted(grouped.keys())
|
|
]
|
|
self.sanctions_total = len(items)
|
|
self.classes_total = len(self.sanctions_groups)
|
|
finally:
|
|
sess.close()
|
|
except Exception as e:
|
|
print(f"[accueil] erreur: {e}")
|
|
|
|
@staticmethod
|
|
def _filtered_kpis(sess, allowed: list[str]) -> tuple[int, int, int]:
|
|
"""Recalcule les KPIs sur les apprentis appartenant aux classes autorisées."""
|
|
from datetime import date as _date
|
|
if not allowed:
|
|
return 0, 0, 0
|
|
ids = sess.execute(
|
|
select(Apprenti.id).where(Apprenti.classe.in_(allowed))
|
|
).scalars().all()
|
|
ids = list(ids)
|
|
if not ids:
|
|
return 0, 0, 0
|
|
today = _date.today()
|
|
first_of_month = today.replace(day=1)
|
|
# Total ce mois (même logique : count d'absences sur la période)
|
|
total_mois = sess.execute(
|
|
select(func.count(Absence.id)).where(
|
|
Absence.apprenti_id.in_(ids),
|
|
Absence.date >= first_of_month,
|
|
)
|
|
).scalar() or 0
|
|
total_global = sess.execute(
|
|
select(func.count(Absence.id)).where(Absence.apprenti_id.in_(ids))
|
|
).scalar() or 0
|
|
n_traiter = sess.execute(
|
|
select(func.count(Absence.id)).where(
|
|
Absence.apprenti_id.in_(ids),
|
|
Absence.statut == "a_traiter",
|
|
)
|
|
).scalar() or 0
|
|
return int(total_mois), int(total_global), int(n_traiter)
|
|
|
|
# ── Navigation cross-page (pré-sélection) ────────────────────────────────
|
|
|
|
def open_fiche(self, apprenti_id: int):
|
|
return [
|
|
FicheState.navigate_to(apprenti_id),
|
|
rx.redirect("/fiche"),
|
|
]
|
|
|
|
def open_classe(self, classe: str):
|
|
return [
|
|
ClasseState.set_class(classe),
|
|
rx.redirect("/classe"),
|
|
]
|
|
|
|
# ── Téléchargement de l'avis de sanction ─────────────────────────────────
|
|
|
|
def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str):
|
|
# Garde-fou : refuse si la classe n'est pas autorisée
|
|
if not is_class_allowed(self.username, classe):
|
|
return rx.toast.error("Accès refusé pour cette classe.")
|
|
sess = get_session()
|
|
try:
|
|
data = generate_avis_pdf(
|
|
sess, apprenti_id, prof_name=self.name or self.username,
|
|
)
|
|
finally:
|
|
sess.close()
|
|
if data is None:
|
|
return rx.toast.error(
|
|
"Template introuvable. Vérifiez data/templates/GF_FO_Avis_de_sanction.pdf"
|
|
)
|
|
app_log(
|
|
f"[avis] {self.username or '?'} : avis de sanction généré pour "
|
|
f"{nom} {prenom} ({classe})"
|
|
)
|
|
safe_nom = "".join(c if c.isalnum() else "_" for c in nom)
|
|
safe_prenom = "".join(c if c.isalnum() else "_" for c in prenom)
|
|
filename = f"Avis_sanction_{safe_nom}_{safe_prenom}.pdf"
|
|
return rx.download(data=data, filename=filename)
|
|
|
|
|
|
# ── UI ────────────────────────────────────────────────────────────────────────
|
|
|
|
def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
|
return rx.box(
|
|
rx.text(label, size="1", color="#555555"),
|
|
rx.text(value, size="8", font_weight="700", line_height="1.1", class_name="tabular"),
|
|
background_color="white",
|
|
border="1px solid #dee2e6",
|
|
border_radius="8px",
|
|
padding="0.75rem 1rem",
|
|
flex="1",
|
|
min_width="80px",
|
|
width="100%",
|
|
class_name="hover-lift",
|
|
)
|
|
|
|
|
|
def _sanction_tile(item: rx.Var) -> rx.Component:
|
|
return rx.box(
|
|
rx.vstack(
|
|
rx.flex(
|
|
rx.text(
|
|
item["nom"], " ", item["prenom"],
|
|
size="3", weight="bold", color="#1a237e",
|
|
),
|
|
rx.spacer(),
|
|
rx.box(
|
|
rx.flex(
|
|
rx.icon("triangle-alert", size=12, color="#B71C1C"),
|
|
rx.text(
|
|
item["absences"], " abs.",
|
|
size="1", color="#B71C1C", weight="bold",
|
|
),
|
|
gap="0.25rem", align="center",
|
|
),
|
|
background_color="#ffe5e5",
|
|
padding="0.15rem 0.5rem",
|
|
border_radius="9999px",
|
|
flex_shrink="0",
|
|
),
|
|
width="100%", align="center", gap="0.5rem", wrap="wrap",
|
|
),
|
|
rx.button(
|
|
rx.icon("file-down", size=13),
|
|
"PDF avis de sanction",
|
|
on_click=AccueilState.download_avis(
|
|
item["id"], item["nom"], item["prenom"], item["classe"],
|
|
).stop_propagation,
|
|
size="1",
|
|
color_scheme="gray",
|
|
variant="soft",
|
|
),
|
|
spacing="2",
|
|
align="start",
|
|
width="100%",
|
|
),
|
|
on_click=AccueilState.open_fiche(item["id"]),
|
|
cursor="pointer",
|
|
padding="0.85rem 1rem",
|
|
background_color="white",
|
|
border="1px solid #e0e0e0",
|
|
border_radius="8px",
|
|
flex="1 1 240px",
|
|
min_width="220px",
|
|
max_width="320px",
|
|
class_name="hover-lift sanction-tile",
|
|
)
|
|
|
|
|
|
def _class_group(group: rx.Var) -> rx.Component:
|
|
return rx.box(
|
|
# En-tête de classe (cliquable → page Classes pré-sélectionnée)
|
|
rx.flex(
|
|
rx.icon("users", size=15, color="#37474f"),
|
|
rx.text(group["classe"], size="3", weight="bold", color="#37474f"),
|
|
on_click=AccueilState.open_classe(group["classe"]),
|
|
cursor="pointer",
|
|
padding="0.5rem 0.75rem",
|
|
border_radius="6px",
|
|
background_color="#f8f9fa",
|
|
border="1px solid #e9ecef",
|
|
_hover={"background_color": "#eef2f6"},
|
|
width="100%",
|
|
align="center",
|
|
gap="0.5rem",
|
|
class_name="smooth-transition",
|
|
margin_bottom="0.6rem",
|
|
),
|
|
# Tuiles apprentis
|
|
rx.flex(
|
|
rx.foreach(group["items"].to(list[dict]), _sanction_tile),
|
|
gap="0.6rem",
|
|
flex_wrap="wrap",
|
|
width="100%",
|
|
),
|
|
width="100%",
|
|
)
|
|
|
|
|
|
def _sanctions_section() -> rx.Component:
|
|
return rx.cond(
|
|
AccueilState.sanctions_total == 0,
|
|
rx.box(
|
|
rx.flex(
|
|
rx.icon("circle-check-big", size=18, color="#2e7d32"),
|
|
rx.text(
|
|
"Aucun apprenti n'a atteint le quota de 5 absences.",
|
|
size="2", color="#2e7d32",
|
|
),
|
|
gap="0.5rem", align="center",
|
|
),
|
|
background_color="#f1f8f1",
|
|
border="1px solid #c8e6c9",
|
|
border_radius="6px",
|
|
padding="0.85rem 1rem",
|
|
width="100%",
|
|
),
|
|
rx.vstack(
|
|
rx.foreach(AccueilState.sanctions_groups, _class_group),
|
|
spacing="4",
|
|
width="100%",
|
|
),
|
|
)
|
|
|
|
|
|
def accueil_page() -> rx.Component:
|
|
return layout(
|
|
rx.vstack(
|
|
rx.heading("Tableau de bord", size="7"),
|
|
|
|
# KPIs
|
|
rx.hstack(
|
|
_kpi_card("Avis de sanction pour absences", AccueilState.sanctions_total),
|
|
_kpi_card("Total périodes d'absence", AccueilState.kpi_total),
|
|
_kpi_card("Périodes à traiter", AccueilState.kpi_traiter),
|
|
spacing="3",
|
|
width="100%",
|
|
wrap="wrap",
|
|
align_items="stretch",
|
|
),
|
|
|
|
rx.divider(),
|
|
|
|
rx.flex(
|
|
rx.icon("triangle-alert", size=20, color="#c62828"),
|
|
rx.heading("Avis de sanction (> de 5 absences)", size="5"),
|
|
gap="0.5rem", align="center",
|
|
),
|
|
_sanctions_section(),
|
|
|
|
rx.divider(),
|
|
|
|
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
|
|
rx.box(
|
|
rx.flex(
|
|
rx.icon("info", size=16, color="#1565c0"),
|
|
rx.text(
|
|
"Migration en cours — disponible prochainement.",
|
|
color="#1565c0", size="2",
|
|
),
|
|
gap="0.5rem", align="center",
|
|
),
|
|
background_color="#e3f2fd",
|
|
border="1px solid #90caf9",
|
|
border_radius="6px",
|
|
padding="0.75rem 1rem",
|
|
width="100%",
|
|
),
|
|
|
|
spacing="5",
|
|
width="100%",
|
|
max_width="100%",
|
|
align="stretch",
|
|
padding_bottom="2rem",
|
|
)
|
|
)
|