ajout des tuiles notes insuf.
This commit is contained in:
parent
eb98ec273c
commit
ea8954bc6f
6 changed files with 318 additions and 39 deletions
2
TODO.md
2
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
})();
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,7 +192,9 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
|||
|
||||
|
||||
def _sanction_tile(item: rx.Var) -> rx.Component:
|
||||
return rx.flex(
|
||||
return rx.vstack(
|
||||
# Ligne 1 : nom + badge absences
|
||||
rx.flex(
|
||||
rx.text(
|
||||
item["nom"], " ", item["prenom"],
|
||||
size="2", color="#1a237e",
|
||||
|
|
@ -170,6 +211,9 @@ def _sanction_tile(item: rx.Var) -> rx.Component:
|
|||
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",
|
||||
),
|
||||
rx.icon("triangle-alert", size=20, color="#c62828"),
|
||||
rx.heading("Notes insuffisantes (BN / Matu < 4.0)", size="5"),
|
||||
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",
|
||||
width="100%",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
121
src/stats.py
121
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue