eptm_dashboard/eptm_dashboard/pages/accueil.py
2026-05-11 09:00:56 +02:00

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",
)
)