ajout des tuiles notes insuf.

This commit is contained in:
Julien Balet 2026-05-12 09:46:18 +02:00
parent eb98ec273c
commit ea8954bc6f
6 changed files with 318 additions and 39 deletions

View file

@ -25,7 +25,7 @@ en haut de la section concernée.
- [ ] Faire un thème avec fond foncé - [ ] Faire un thème avec fond foncé
- [ ] Lancer une optimisation des toasts - [ ] Lancer une optimisation des toasts
- [ ] Changer la couleur du bouton Générer l'avais de sanction - [X] Changer la couleur du bouton Générer l'avais de sanction
## Notes / réflexions ## Notes / réflexions

View file

@ -77,6 +77,18 @@
--text-muted: #9ca3af; /* labels */ --text-muted: #9ca3af; /* labels */
--border: #e0e0e0; /* borders cartes */ --border: #e0e0e0; /* borders cartes */
--border-soft: #e5e7eb; /* séparateurs subtils */ --border-soft: #e5e7eb; /* séparateurs subtils */
/* color-scheme: light empêche le browser d'appliquer le dark mode
système sur le body/form controls/scrollbars. Critique en clair forcé. */
color-scheme: light;
}
/* Fond explicite sur <html> et <body> sinon le browser tombe sur le
défaut système (noir si OS en dark mode). Sans ça, en bleu/indigo/vert
etc., le contenu hors radix-themes hérite du fond dark système. */
html, body {
background-color: white;
color-scheme: light;
} }
[data-theme="bleu"] { [data-theme="bleu"] {
@ -151,7 +163,10 @@
--gray-12: #F5F5F7; --gray-12: #F5F5F7;
} }
/* Page body en sombre */ /* Page body en sombre + color-scheme dark */
[data-theme="sombre"], [data-theme="sombre"] body, html[data-theme="sombre"] {
color-scheme: dark;
}
[data-theme="sombre"] body { [data-theme="sombre"] body {
background-color: #0A0A0B; background-color: #0A0A0B;
color: var(--text-strong); color: var(--text-strong);

View file

@ -31,6 +31,9 @@ app = rx.App(
rx.el.link(rel="apple-touch-icon", href="/apple-touch-icon.png"), rx.el.link(rel="apple-touch-icon", href="/apple-touch-icon.png"),
# Android Chrome / PWA : manifest avec icônes 192/512 # Android Chrome / PWA : manifest avec icônes 192/512
rx.el.link(rel="manifest", href="/manifest.webmanifest"), rx.el.link(rel="manifest", href="/manifest.webmanifest"),
# Force le rendu light du browser (form controls, scrollbars, etc.)
# même quand l'OS est en dark mode. Le thème "sombre" override via CSS.
rx.el.meta(name="color-scheme", content="light"),
# Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone) # Couleur de la barre d'adresse (Android) + barre de statut (iOS standalone)
rx.el.meta(name="theme-color", content="#dc000e"), rx.el.meta(name="theme-color", content="#dc000e"),
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"), rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
@ -45,7 +48,8 @@ app = rx.App(
crossorigin="anonymous", crossorigin="anonymous",
), ),
# Applique le thème stocké en localStorage avant le premier render — # Applique le thème stocké en localStorage avant le premier render —
# évite un flash au défaut EPTM puis bascule. # évite un flash au défaut EPTM puis bascule. Force aussi colorScheme
# pour empêcher le browser de bascule dark sur OS dark.
rx.el.script( rx.el.script(
""" """
(function() { (function() {
@ -55,6 +59,8 @@ app = rx.App(
document.documentElement.setAttribute('data-theme', t); document.documentElement.setAttribute('data-theme', t);
document.body && document.body.setAttribute('data-theme', t); document.body && document.body.setAttribute('data-theme', t);
} }
document.documentElement.style.colorScheme =
(t === 'sombre') ? 'dark' : 'light';
} catch(e) {} } catch(e) {}
})(); })();
""" """

View file

@ -5,7 +5,7 @@ sys.path.insert(0, "/opt/eptm-dashboard")
import reflex as rx import reflex as rx
from src.db import get_session, Apprenti, Absence from src.db import get_session, Apprenti, Absence
from src.stats import kpis, alertes_quota_absences from src.stats import kpis, alertes_quota_absences, alertes_notes_insuffisantes
from src.user_access import get_allowed_classes from src.user_access import get_allowed_classes
from sqlalchemy import select, func from sqlalchemy import select, func
from ..state import AuthState from ..state import AuthState
@ -24,6 +24,9 @@ class AccueilState(AuthState):
classes_total: int = 0 classes_total: int = 0
# Groupement par classe pour l'affichage en tuiles # Groupement par classe pour l'affichage en tuiles
sanctions_groups: list[dict] = [] sanctions_groups: list[dict] = []
# Notes insuffisantes (BN / Matu < 4.0)
notes_insuf_total: int = 0
notes_insuf_groups: list[dict] = []
def load_data(self): def load_data(self):
if not self.authenticated: if not self.authenticated:
@ -75,6 +78,42 @@ class AccueilState(AuthState):
] ]
self.sanctions_total = len(items) self.sanctions_total = len(items)
self.classes_total = len(self.sanctions_groups) self.classes_total = len(self.sanctions_groups)
# ── Notes insuffisantes (BN / Matu < 4.0) ───────────────────
notes_alerts = alertes_notes_insuffisantes(sess, allowed)
# Construit les labels d'affichage des badges :
# - "BN sem. 3,5" / "BN ann. 3,7" / "Matu 3,5"
# - Si bn_sem insuf : on AJOUTE l'annuelle en contexte
# (même si ≥ 4.0) pour donner la vision complète.
def _fmt(v):
return f"{v:.1f}".replace(".", ",") if v is not None else ""
for a in notes_alerts:
bn_badges = []
if a["bn_sem_insuf"]:
bn_badges.append((True, f"BN sem. {_fmt(a['bn_sem'])}"))
# Contexte annuel quand sem insuf — affiché grisé si OK
if a["bn_ann"] is not None and not a["bn_ann_insuf"]:
bn_badges.append((False, f"ann. {_fmt(a['bn_ann'])}"))
if a["bn_ann_insuf"]:
bn_badges.append((True, f"BN ann. {_fmt(a['bn_ann'])}"))
matu_badge = None
if a["matu_insuf"]:
matu_badge = f"Matu {_fmt(a['matu'])}"
a["badges"] = [{"text": t, "insuf": insuf} for insuf, t in bn_badges]
if matu_badge:
a["badges"].append({"text": matu_badge, "insuf": True})
ni_grouped: dict[str, list[dict]] = defaultdict(list)
for a in notes_alerts:
ni_grouped[a["classe"]].append(a)
self.notes_insuf_groups = [
{
"classe": c,
"count": len(ni_grouped[c]),
"items": sorted(ni_grouped[c], key=lambda x: x["worst"] or 99),
}
for c in sorted(ni_grouped.keys())
]
self.notes_insuf_total = len(notes_alerts)
finally: finally:
sess.close() sess.close()
except Exception as e: except Exception as e:
@ -153,23 +192,28 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
def _sanction_tile(item: rx.Var) -> rx.Component: def _sanction_tile(item: rx.Var) -> rx.Component:
return rx.flex( return rx.vstack(
rx.text( # Ligne 1 : nom + badge absences
item["nom"], " ", item["prenom"],
size="2", color="#1a237e",
white_space="nowrap", overflow="hidden",
text_overflow="ellipsis",
flex="1", min_width="0",
),
rx.flex( rx.flex(
rx.icon("triangle-alert", size=11, color="#B71C1C"), rx.text(
rx.text(item["absences"], size="1", color="#B71C1C", weight="bold"), item["nom"], " ", item["prenom"],
gap="0.2rem", align="center", size="2", color="#1a237e",
background_color="#ffe5e5", white_space="nowrap", overflow="hidden",
padding="0.1rem 0.4rem", text_overflow="ellipsis",
border_radius="9999px", flex="1", min_width="0",
flex_shrink="0", ),
rx.flex(
rx.icon("triangle-alert", size=11, color="#B71C1C"),
rx.text(item["absences"], size="1", color="#B71C1C", weight="bold"),
gap="0.2rem", align="center",
background_color="#ffe5e5",
padding="0.1rem 0.4rem",
border_radius="9999px",
flex_shrink="0",
),
width="100%", align="center", gap="0.5rem",
), ),
# Ligne 2 : bouton créer l'avis
rx.button( rx.button(
rx.icon("file-plus", size=13), rx.icon("file-plus", size=13),
"Créer l'avis de sanction", "Créer l'avis de sanction",
@ -179,19 +223,118 @@ def _sanction_tile(item: rx.Var) -> rx.Component:
size="1", size="1",
color_scheme="gray", color_scheme="gray",
variant="soft", variant="soft",
width="100%",
), ),
on_click=AccueilState.open_fiche(item["id"]), on_click=AccueilState.open_fiche(item["id"]),
cursor="pointer", cursor="pointer",
padding="0.4rem 0.6rem", padding="0.5rem 0.65rem",
background_color="var(--surface)",
border="1px solid var(--border)",
border_radius="6px",
flex="1 1 220px",
min_width="220px",
max_width="280px",
spacing="2",
align="start",
class_name="hover-lift sanction-tile",
)
def _notes_badge(badge: rx.Var) -> rx.Component:
"""Badge moyenne : rouge si insuffisant (<4), gris si en contexte (≥4)."""
return rx.flex(
rx.text(
badge["text"], size="1", weight="bold",
color=rx.cond(badge["insuf"], "#B71C1C", "#555"),
),
background_color=rx.cond(badge["insuf"], "#ffe5e5", "var(--surface-hover)"),
padding="0.1rem 0.4rem",
border_radius="9999px",
flex_shrink="0",
)
def _notes_insuf_tile(item: rx.Var) -> rx.Component:
"""Tuile compacte 1 ligne : nom + badges moyennes. Click → fiche apprenti."""
return rx.flex(
rx.text(
item["nom"], " ", item["prenom"],
size="2", color="#1a237e",
white_space="nowrap", overflow="hidden",
text_overflow="ellipsis",
flex="1", min_width="0",
),
rx.flex(
rx.foreach(item["badges"].to(list[dict]), _notes_badge),
gap="0.3rem", flex_wrap="wrap", flex_shrink="0",
),
on_click=AccueilState.open_fiche(item["id"]),
cursor="pointer",
padding="0.5rem 0.65rem",
background_color="var(--surface)", background_color="var(--surface)",
border="1px solid var(--border)", border="1px solid var(--border)",
border_radius="6px", border_radius="6px",
flex="1 1 280px", flex="1 1 280px",
min_width="280px", min_width="280px",
max_width="380px", max_width="400px",
align="center", align="center",
gap="0.5rem", gap="0.5rem",
class_name="hover-lift sanction-tile", class_name="hover-lift",
)
def _notes_class_group(group: rx.Var) -> rx.Component:
"""Groupe de classe pour notes insuffisantes — même pattern que _class_group."""
return rx.box(
rx.flex(
rx.icon("users", size=15, color="var(--text-strong)"),
rx.text(group["classe"], size="3", weight="bold", color="var(--text-strong)"),
on_click=AccueilState.open_classe(group["classe"]),
cursor="pointer",
padding="0.5rem 0.75rem",
border_radius="6px",
background_color="var(--surface-muted)",
border="1px solid #e9ecef",
_hover={"background_color": "#eef2f6"},
width="100%",
align="center",
gap="0.5rem",
class_name="smooth-transition",
margin_bottom="0.6rem",
),
rx.flex(
rx.foreach(group["items"].to(list[dict]), _notes_insuf_tile),
gap="0.6rem",
flex_wrap="wrap",
width="100%",
),
width="100%",
)
def _notes_insuf_section() -> rx.Component:
return rx.cond(
AccueilState.notes_insuf_total == 0,
rx.box(
rx.flex(
rx.icon("circle-check-big", size=18, color="#2e7d32"),
rx.text(
"Aucun apprenti avec moyenne BN ou Matu insuffisante.",
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.notes_insuf_groups, _notes_class_group),
spacing="4",
width="100%",
),
) )
@ -259,6 +402,7 @@ def accueil_page() -> rx.Component:
# KPIs # KPIs
rx.hstack( rx.hstack(
_kpi_card("Avis de sanction pour absences", AccueilState.sanctions_total), _kpi_card("Avis de sanction pour absences", AccueilState.sanctions_total),
_kpi_card("Notes insuffisantes (BN/Matu)", AccueilState.notes_insuf_total),
_kpi_card("Total périodes d'absence", AccueilState.kpi_total), _kpi_card("Total périodes d'absence", AccueilState.kpi_total),
_kpi_card("Périodes à traiter", AccueilState.kpi_traiter), _kpi_card("Périodes à traiter", AccueilState.kpi_traiter),
spacing="3", spacing="3",
@ -278,22 +422,12 @@ def accueil_page() -> rx.Component:
rx.divider(), rx.divider(),
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"), rx.flex(
rx.box( rx.icon("triangle-alert", size=20, color="#c62828"),
rx.flex( rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
rx.icon("info", size=16, color="var(--brand-accent)"), gap="0.5rem", align="center",
rx.text(
"Migration en cours — disponible prochainement.",
color="var(--brand-accent)", size="2",
),
gap="0.5rem", align="center",
),
background_color="#e3f2fd",
border="1px solid #90caf9",
border_radius="6px",
padding="0.75rem 1rem",
width="100%",
), ),
_notes_insuf_section(),
spacing="5", spacing="5",
width="100%", width="100%",

View file

@ -152,16 +152,21 @@ class AuthState(rx.State):
@staticmethod @staticmethod
def _apply_theme_script(theme: str): def _apply_theme_script(theme: str):
"""Script JS qui set data-theme sur <html> immédiatement (sans attendre re-render).""" """Script JS qui set data-theme + color-scheme sur <html> immédiatement
(sans attendre re-render). color-scheme empêche le browser de bascule
dark sur OS dark mode."""
safe = "".join(c for c in (theme or "eptm") if c.isalnum() or c in "-_") safe = "".join(c for c in (theme or "eptm") if c.isalnum() or c in "-_")
scheme = "dark" if safe == "sombre" else "light"
if not safe or safe == "eptm": if not safe or safe == "eptm":
return rx.call_script( return rx.call_script(
"document.documentElement.removeAttribute('data-theme');" "document.documentElement.removeAttribute('data-theme');"
"document.body && document.body.removeAttribute('data-theme');" "document.body && document.body.removeAttribute('data-theme');"
f"document.documentElement.style.colorScheme = '{scheme}';"
) )
return rx.call_script( return rx.call_script(
f"document.documentElement.setAttribute('data-theme', '{safe}');" f"document.documentElement.setAttribute('data-theme', '{safe}');"
f"document.body && document.body.setAttribute('data-theme', '{safe}');" f"document.body && document.body.setAttribute('data-theme', '{safe}');"
f"document.documentElement.style.colorScheme = '{scheme}';"
) )
def set_theme(self, value: str): def set_theme(self, value: str):

View file

@ -1,6 +1,7 @@
"""Fonctions de calcul pour les dashboards (sans dépendance Streamlit).""" """Fonctions de calcul pour les dashboards (sans dépendance Streamlit)."""
import io import io
import json
from datetime import date, timedelta from datetime import date, timedelta
from itertools import groupby from itertools import groupby
@ -8,7 +9,10 @@ import pandas as pd
from sqlalchemy import case, func, or_, select from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.db import Absence, Apprenti, Import from src.db import (
Absence, Apprenti, Import,
NotesBulletin, NotesMatu, ImportBN, ImportMatu,
)
# ── Helpers semestre ────────────────────────────────────────────────────────── # ── Helpers semestre ──────────────────────────────────────────────────────────
@ -243,3 +247,118 @@ def export_excel_global(session: Session, semestre: str | None = None) -> bytes:
df_syn.to_excel(writer, sheet_name=sheet, index=False) df_syn.to_excel(writer, sheet_name=sheet, index=False)
return buf.getvalue() return buf.getvalue()
# ── Alertes notes insuffisantes (BN / Matu < 4.0) ─────────────────────────────
def _last_filled(arr):
"""Dernière valeur non-null dune liste, ou None."""
if not arr:
return None
for v in reversed(arr):
if v is None:
continue
try:
return float(v)
except (TypeError, ValueError):
continue
return None
def alertes_notes_insuffisantes(
session: Session, allowed_classes: list[str] | None = None,
) -> list[dict]:
"""Liste les apprentis avec une moyenne insuffisante (< 4.0) :
- sur le BN : dernier moy_sem global non-null OU dernier moy_ann global non-null
- sur la Matu : champ moy < 4.0
Retourne une liste de dicts triés par classe puis nom :
{id, nom, prenom, classe, worst (float), types (list[str]),
bn_sem, bn_ann, matu (None si non concerné)}.
"""
q = select(Apprenti).order_by(Apprenti.classe, Apprenti.nom, Apprenti.prenom)
if allowed_classes is not None:
q = q.where(Apprenti.classe.in_(allowed_classes))
apprentis = session.execute(q).scalars().all()
if not apprentis:
return []
ids = [a.id for a in apprentis]
# Latest BN par apprenti (1 query)
bn_rows = session.execute(
select(NotesBulletin, ImportBN.date_import)
.join(ImportBN, ImportBN.id == NotesBulletin.import_id)
.where(NotesBulletin.apprenti_id.in_(ids))
.order_by(ImportBN.date_import.desc())
).all()
bn_by_id = {}
for bn, _dt in bn_rows:
bn_by_id.setdefault(bn.apprenti_id, bn) # premier (= plus récent)
# Latest Matu par apprenti (1 query)
nm_rows = session.execute(
select(NotesMatu, ImportMatu.date_import)
.join(ImportMatu, ImportMatu.id == NotesMatu.import_id)
.where(NotesMatu.apprenti_id.in_(ids))
.order_by(ImportMatu.date_import.desc())
).all()
nm_by_id = {}
for nm, _dt in nm_rows:
nm_by_id.setdefault(nm.apprenti_id, nm)
alerts = []
for ap in apprentis:
bn = bn_by_id.get(ap.id)
nm = nm_by_id.get(ap.id)
# Valeurs "brutes" : dernière non-null peu importe le seuil (utile
# pour afficher la moyenne annuelle en contexte quand la sem est insuf).
bn_sem_val = bn_ann_val = matu_val = None
if bn:
try:
d = json.loads(bn.donnees_json or "{}")
except (ValueError, TypeError):
d = {}
g = d.get("globale", {}) or {}
bn_sem_val = _last_filled(g.get("moy_sem"))
bn_ann_val = _last_filled(g.get("moy_ann"))
if nm and nm.moy is not None:
try:
matu_val = float(nm.moy)
except (TypeError, ValueError):
matu_val = None
# Flags d'insuffisance
bn_sem_insuf = bn_sem_val is not None and bn_sem_val < 4.0
bn_ann_insuf = bn_ann_val is not None and bn_ann_val < 4.0
matu_insuf = matu_val is not None and matu_val < 4.0
if not (bn_sem_insuf or bn_ann_insuf or matu_insuf):
continue
types = []
if bn_sem_insuf: types.append("BN sem.")
if bn_ann_insuf: types.append("BN ann.")
if matu_insuf: types.append("Matu")
worst = min(v for v in (
bn_sem_val if bn_sem_insuf else None,
bn_ann_val if bn_ann_insuf else None,
matu_val if matu_insuf else None,
) if v is not None)
alerts.append({
"id": ap.id,
"nom": ap.nom,
"prenom": ap.prenom,
"classe": ap.classe,
"worst": round(worst, 1),
"types": types,
# Valeurs brutes (toujours, si dispo)
"bn_sem": bn_sem_val,
"bn_ann": bn_ann_val,
"matu": matu_val,
# Flags : laquelle est < 4
"bn_sem_insuf": bn_sem_insuf,
"bn_ann_insuf": bn_ann_insuf,
"matu_insuf": matu_insuf,
})
return alerts