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é
- [ ] 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

View file

@ -77,6 +77,18 @@
--text-muted: #9ca3af; /* labels */
--border: #e0e0e0; /* borders cartes */
--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"] {
@ -151,7 +163,10 @@
--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 {
background-color: #0A0A0B;
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"),
# Android Chrome / PWA : manifest avec icônes 192/512
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)
rx.el.meta(name="theme-color", content="#dc000e"),
rx.el.meta(name="apple-mobile-web-app-capable", content="yes"),
@ -45,7 +48,8 @@ app = rx.App(
crossorigin="anonymous",
),
# 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(
"""
(function() {
@ -55,6 +59,8 @@ app = rx.App(
document.documentElement.setAttribute('data-theme', t);
document.body && document.body.setAttribute('data-theme', t);
}
document.documentElement.style.colorScheme =
(t === 'sombre') ? 'dark' : 'light';
} catch(e) {}
})();
"""

View file

@ -5,7 +5,7 @@ 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.stats import kpis, alertes_quota_absences, alertes_notes_insuffisantes
from src.user_access import get_allowed_classes
from sqlalchemy import select, func
from ..state import AuthState
@ -24,6 +24,9 @@ class AccueilState(AuthState):
classes_total: int = 0
# Groupement par classe pour l'affichage en tuiles
sanctions_groups: list[dict] = []
# Notes insuffisantes (BN / Matu < 4.0)
notes_insuf_total: int = 0
notes_insuf_groups: list[dict] = []
def load_data(self):
if not self.authenticated:
@ -75,6 +78,42 @@ class AccueilState(AuthState):
]
self.sanctions_total = len(items)
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:
sess.close()
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:
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",
),
return rx.vstack(
# Ligne 1 : nom + badge absences
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",
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.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.icon("file-plus", size=13),
"Créer l'avis de sanction",
@ -179,19 +223,118 @@ def _sanction_tile(item: rx.Var) -> rx.Component:
size="1",
color_scheme="gray",
variant="soft",
width="100%",
),
on_click=AccueilState.open_fiche(item["id"]),
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)",
border="1px solid var(--border)",
border_radius="6px",
flex="1 1 280px",
min_width="280px",
max_width="380px",
max_width="400px",
align="center",
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
rx.hstack(
_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("Périodes à traiter", AccueilState.kpi_traiter),
spacing="3",
@ -278,22 +422,12 @@ def accueil_page() -> rx.Component:
rx.divider(),
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
rx.box(
rx.flex(
rx.icon("info", size=16, color="var(--brand-accent)"),
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%",
rx.flex(
rx.icon("triangle-alert", size=20, color="#c62828"),
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
gap="0.5rem", align="center",
),
_notes_insuf_section(),
spacing="5",
width="100%",

View file

@ -152,16 +152,21 @@ class AuthState(rx.State):
@staticmethod
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 "-_")
scheme = "dark" if safe == "sombre" else "light"
if not safe or safe == "eptm":
return rx.call_script(
"document.documentElement.removeAttribute('data-theme');"
"document.body && document.body.removeAttribute('data-theme');"
f"document.documentElement.style.colorScheme = '{scheme}';"
)
return rx.call_script(
f"document.documentElement.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):

View file

@ -1,6 +1,7 @@
"""Fonctions de calcul pour les dashboards (sans dépendance Streamlit)."""
import io
import json
from datetime import date, timedelta
from itertools import groupby
@ -8,7 +9,10 @@ import pandas as pd
from sqlalchemy import case, func, or_, select
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 ──────────────────────────────────────────────────────────
@ -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)
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