From ea8954bc6f03a79f702cb58eddacf73a01b961fe Mon Sep 17 00:00:00 2001 From: Julien Balet Date: Tue, 12 May 2026 09:46:18 +0200 Subject: [PATCH] ajout des tuiles notes insuf. --- TODO.md | 2 +- assets/responsive.css | 17 ++- eptm_dashboard/eptm_dashboard.py | 8 +- eptm_dashboard/pages/accueil.py | 202 +++++++++++++++++++++++++------ eptm_dashboard/state.py | 7 +- src/stats.py | 121 +++++++++++++++++- 6 files changed, 318 insertions(+), 39 deletions(-) diff --git a/TODO.md b/TODO.md index 3464c66..9b0205e 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/assets/responsive.css b/assets/responsive.css index 863f63e..7dfd3e3 100644 --- a/assets/responsive.css +++ b/assets/responsive.css @@ -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 et — 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); diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py index 4502626..0076528 100644 --- a/eptm_dashboard/eptm_dashboard.py +++ b/eptm_dashboard/eptm_dashboard.py @@ -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) {} })(); """ diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py index 4304cd8..9c19128 100644 --- a/eptm_dashboard/pages/accueil.py +++ b/eptm_dashboard/pages/accueil.py @@ -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%", diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py index babdf7b..5cb8d14 100644 --- a/eptm_dashboard/state.py +++ b/eptm_dashboard/state.py @@ -152,16 +152,21 @@ class AuthState(rx.State): @staticmethod def _apply_theme_script(theme: str): - """Script JS qui set data-theme sur immédiatement (sans attendre re-render).""" + """Script JS qui set data-theme + color-scheme sur 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): diff --git a/src/stats.py b/src/stats.py index 1766770..34ccbd0 100644 --- a/src/stats.py +++ b/src/stats.py @@ -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