diff --git a/DEPLOY_PROD.md b/DEPLOY_PROD.md
new file mode 100644
index 0000000..ac7a252
--- /dev/null
+++ b/DEPLOY_PROD.md
@@ -0,0 +1,295 @@
+# Déploiement en production — EPTM Dashboard
+
+Procédure de bascule de l'app **Streamlit legacy** (`absences.service`,
+`dashboard.eptm-automation.ch`) vers l'app **Reflex** (en prod sur le
+même sous-domaine). Document à retravailler avant exécution.
+
+---
+
+## État actuel (2026-05-11)
+
+```
+Internet :80/:443
+ │
+ ▼
+ ┌────────────────────────────────────┐
+ │ NPM (nginx-proxy-manager) │
+ │ container "npm" sur proxy_net │
+ └────────────────────────────────────┘
+ │
+ ├── dashboard.eptm-automation.ch
+ │ → 172.17.0.1:8501
+ │ → Streamlit legacy (systemd "absences.service")
+ │ /opt/absences/.venv/bin/streamlit run src/app.py
+ │
+ └── dev.dashboard.eptm-automation.ch
+ → eptm-dashboard-app-1:3001 (Reflex dev)
+ + /_event → :8001
+ (network proxy_net)
+```
+
+- Streamlit tourne en systemd `absences.service` (enabled, depuis le 2026-05-09).
+- L'app Reflex dev est containerisée, déjà sur `proxy_net`, accessible via NPM.
+- Pas encore de Dockerfile/compose **prod** : il faut les créer.
+
+## État cible
+
+```
+Internet :80/:443 → NPM
+ │
+ ├── dashboard.eptm-automation.ch
+ │ → eptm-dashboard-prod-app-1:3002 (Reflex prod)
+ │ + /_event → :8002
+ │
+ └── dev.dashboard.eptm-automation.ch
+ → eptm-dashboard-app-1:3001 (Reflex dev, inchangé)
+ + /_event → :8001
+```
+
+- Streamlit `absences.service` arrêté + disabled (binaires gardés en backup
+ quelques semaines, suppression plus tard).
+- Deux stacks compose côte à côte sur `proxy_net`, ports internes
+ distincts (3001/8001 dev, 3002/8002 prod), aucun port host exposé.
+- NPM continue de gérer les certs Let's Encrypt (déjà émis pour ce
+ sous-domaine, juste à conserver — pas de renouvellement à forcer).
+
+---
+
+## ⚠️ À clarifier avant exécution
+
+| # | Question | Hypothèse par défaut |
+|---|---|---|
+| 1 | **Données** : prod partage `./data` avec dev ou stack séparée `./data-prod` ? | **Séparer** (`./data-prod`) — sinon un test en dev peut planter la prod (WAL OK mais migrations destructives non) |
+| 2 | **Premier remplissage de la DB prod** : repartir d'un import frais (run_imports + sync_escada) ou copier `data/eptm.db` actuel ? | **Copier** la DB de dev au moment du cutover (snapshot cohérent) |
+| 3 | **Streamlit après cutover** : on garde le service en `disabled` qq semaines, ou on purge `/opt/absences` ? | **Garder disabled** ~1 mois (rollback plan B), purge en tâche TODO |
+| 4 | **Build** : local sur le serveur ou registry (GHCR / Docker Hub) ? | **Local** sur le serveur — pas de CI pour démarrer, on n'est qu'une instance |
+| 5 | **Downtime acceptable** au cutover ? | ~30-60s pendant que NPM bascule de proxy host. Pas de zero-downtime |
+| 6 | **`.env.prod`** : tester avec env identique à dev ? | À auditer ensemble (peut contenir `escada_username/password`, SMTP, etc.) |
+
+---
+
+## Fichiers à créer
+
+### 1. `Dockerfile.prod` (multi-stage)
+
+```dockerfile
+# Stage 1 : builder — installe deps + export frontend
+FROM python:3.13-slim AS builder
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ nodejs npm curl unzip && rm -rf /var/lib/apt/lists/*
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+RUN reflex export --frontend-only --no-zip
+
+# Stage 2 : runtime — backend granian uniquement
+FROM python:3.13-slim
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl ca-certificates && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /app /app
+RUN pip install --no-cache-dir -r requirements.txt
+ENV FRONTEND_PORT=3002 BACKEND_PORT=8002
+EXPOSE 3002 8002
+CMD ["reflex", "run", "--env", "prod"]
+```
+
+> NB : à vérifier — Reflex 0.9.x peut exiger `reflex run --backend-only` côté
+> runtime si le frontend export est servi par le même process. **TODO :
+> tester avec un build pilote avant cutover.**
+
+### 2. `docker-compose.prod.yml`
+
+```yaml
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile.prod
+ image: eptm-dashboard-prod
+ container_name: eptm-dashboard-prod-app-1
+ init: true
+ restart: unless-stopped
+ volumes:
+ - ./data-prod:/app/data # ← séparé de dev (cf. Q1)
+ - ./logs-prod:/logs
+ env_file:
+ - .env.prod
+ environment:
+ - FRONTEND_PORT=3002
+ - BACKEND_PORT=8002
+ - API_URL=https://dashboard.eptm-automation.ch
+ - REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data
+ - TZ=Europe/Zurich
+ networks:
+ - default
+ - proxy_net
+
+networks:
+ proxy_net:
+ external: true
+```
+
+### 3. `.env.prod`
+
+À auditer — copier les valeurs sensibles depuis le `settings.json` plutôt
+que de tout mettre en env (séparation cleane). Variables minimales :
+- `DATA_DIR=/app/data` (déjà override probable)
+- `(à voir avec l'user — TZ, secrets, etc.)`
+
+---
+
+## Procédure de cutover (jour J)
+
+### J−1 (préparation)
+
+1. **Backup DB Streamlit** (si pertinent — vérifier si Streamlit a sa propre DB) :
+ ```bash
+ sudo cp /opt/absences/data /opt/backups/absences-$(date +%F).tgz # à adapter
+ ```
+2. **Backup DB Reflex dev** (qui deviendra la prod) :
+ ```bash
+ cp /opt/eptm-dashboard/data/eptm.db /opt/backups/eptm-pre-cutover-$(date +%F).db
+ ```
+3. Créer `Dockerfile.prod`, `docker-compose.prod.yml`, `.env.prod` (cf. ci-dessus).
+4. Préparer `./data-prod/` (copie de `./data/` au moment opportun).
+
+### J0 (cutover, ~10 min de fenêtre)
+
+```bash
+# 1. Build l'image prod (peut être fait avant la fenêtre — pas de downtime)
+cd /opt/eptm-dashboard
+docker compose -f docker-compose.prod.yml build app
+
+# 2. Snapshot DB de dev → data-prod
+cp -r data/ data-prod/
+# (optionnel : purger data-prod/browser_profile et data-prod/pdfs si volumineux
+# et resync-able depuis Escada)
+
+# 3. Démarrer le container prod (encore inaccessible — NPM pointe encore sur Streamlit)
+docker compose -f docker-compose.prod.yml up -d app
+docker logs -f eptm-dashboard-prod-app-1 # vérifier "App running" puis Ctrl-C
+
+# 4. Stopper Streamlit
+sudo systemctl stop absences
+sudo systemctl disable absences
+
+# 5. Reconfigurer NPM proxy host #2 (dashboard.eptm-automation.ch)
+# via UI https://npm.eptm-automation.ch :
+# - Forward Hostname / IP : eptm-dashboard-prod-app-1
+# - Forward Port : 3002
+# - WebSocket support : ON
+# - Custom location :
+# Location : /_event
+# Forward : eptm-dashboard-prod-app-1:8002
+# Advanced : proxy_read_timeout 86400;
+# - SSL : conserver le certificat Let's Encrypt déjà émis pour
+# ce domaine, Force SSL, HSTS, HTTP/2
+
+# 6. Recharger NPM si l'UI ne le fait pas auto :
+docker exec npm nginx -s reload
+
+# 7. Vérification
+curl -I https://dashboard.eptm-automation.ch
+# → doit retourner 200 + headers Reflex (Server: granian)
+```
+
+### J+1 → J+30 (stabilisation)
+
+- Garder `absences.service` en `disabled` (rollback rapide possible).
+- Surveiller `docker logs -f eptm-dashboard-prod-app-1` + `data-prod/logs/`.
+- Si stable après ~1 mois : purger `/opt/absences/`, retirer le user systemd file.
+
+---
+
+## Rollback (si quelque chose plante après le cutover)
+
+```bash
+# 1. Rebasculer NPM sur Streamlit
+# UI NPM → proxy host #2 → Forward : 172.17.0.1:8501
+
+# 2. Relancer Streamlit
+sudo systemctl start absences
+
+# 3. Stopper le container prod Reflex
+docker compose -f docker-compose.prod.yml down
+
+# 4. Investiguer les logs Reflex tranquillement
+docker logs eptm-dashboard-prod-app-1 > /tmp/cutover-fail.log
+```
+
+---
+
+## Workflow déploiements suivants
+
+Une fois la prod en place :
+
+```bash
+# 1. Dev : commit & push
+git add -A && git commit -m "feat: xxx" && git push
+
+# 2. Build nouvelle image prod
+cd /opt/eptm-dashboard
+docker compose -f docker-compose.prod.yml build app
+
+# 3. Redémarrer le container (downtime ~10s)
+docker compose -f docker-compose.prod.yml up -d app
+
+# 4. Vérifier
+docker logs -f eptm-dashboard-prod-app-1
+curl -I https://dashboard.eptm-automation.ch
+```
+
+Optionnel : tagger les versions prod (`git tag -a prod-2026-05-15 && git push --tags`).
+
+---
+
+## Caveats / pièges à surveiller
+
+- **WebSocket NPM** : déjà OK en dev (cf. memory NPM). Reproduire **exactement**
+ la même config sur proxy host #2 : WS support cocheé + custom location
+ `/_event` avec `proxy_read_timeout 86400`. Sans ça, le state Reflex ne
+ fonctionne pas.
+- **`API_URL`** : doit matcher l'URL publique exacte (`https://dashboard.eptm-automation.ch`),
+ sinon le frontend ne joint pas le backend.
+- **Ports internes prod** : `3002`/`8002` (pas `3001`/`8001` qui sont pris par dev).
+- **`reflex export`** prend 1-5 min (npm install + bundle). À faire **avant**
+ la fenêtre de cutover.
+- **`data-prod/browser_profile/`** : éviter de copier — le profil Chrome
+ contient une session SSO valide pour le compte de dev. La prod doit re-login
+ au premier sync Escada (ouvrir un Chromium visible avec `headless=False`
+ une fois, ou pré-importer le profile sur un compte service dédié).
+- **localStorage des users connectés** : le LocalStorage du browser des
+ utilisateurs survit au cutover (clés `username`, `theme`, etc.) — ils ne
+ seront pas déconnectés.
+- **DNS / certs** : `dashboard.eptm-automation.ch` pointe déjà sur l'IP
+ publique du serveur, le cert NPM est déjà émis et auto-renouvelé. Pas
+ d'action DNS/cert nécessaire.
+- **Backups DB** : ajouter un cron quotidien après cutover :
+ ```cron
+ 0 3 * * * cp /opt/eptm-dashboard/data-prod/eptm.db /opt/backups/eptm-$(date +\%F).db
+ ```
+
+---
+
+## Annexe : commandes de check post-déploiement
+
+```bash
+# Container prod up ?
+docker ps --filter name=eptm-dashboard-prod-app-1
+
+# Logs récents
+docker logs --tail 50 eptm-dashboard-prod-app-1
+
+# Proxy NPM répond ?
+curl -fsS -o /dev/null -w "HTTP %{http_code}\n" https://dashboard.eptm-automation.ch/login
+
+# WS handshake OK ?
+curl -fsS -o /dev/null -w "HTTP %{http_code}\n" \
+ -H "Upgrade: websocket" -H "Connection: Upgrade" \
+ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
+ -H "Sec-WebSocket-Version: 13" \
+ https://dashboard.eptm-automation.ch/_event
+# → attend HTTP 101 (Switching Protocols)
+```
diff --git a/TODO.md b/TODO.md
index 323f19d..a451d7b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -6,14 +6,20 @@ en haut de la section concernée.
## Idées / fonctionnalités
- [ ] Ajouter sur le dashboard l'affichage des notes insuffisantes
-- [ ] Afficher toutes les notes du BN
+- [X] Afficher toutes les notes du BN
- [ ] Mettre à jour les MD
-- [ ] Ajouter l'indication des compensation des désavantages
+- [ ] Ajouter l'indication des compensation des désavantages
+- [X] Ajouter le TAB notices aussi sur la vue classe
+- [ ] Réussir à récupérer le fichier session Esacada d'un utilisateur pour l'utiliser sur le serveur afin de récupérer la liste des classes dont il a accès et de pouvoir uploader les notices avec son nom propre
+- [X] Filtrer que les classes EM pour les avis de sanction
+- [ ] Ajouter sur la page apprenti sa situation au niveau des sanctions (barre de progression en fonction des notices)
+- [ ] Ajouter dans le texte des notices qui a créé la notice (USER) en attendant d'avoir une identification spécifique escada à chaque utilisateur.
## Bugs connus
- [ ] Dans les logs, en mode Live, la fenêtre ne défile pas toute seule au fond. Mettre les dernières lignes en premier?
+- [X] Liens entre apprenti sur classe vers apprenti ne fonctionne pas
## Améliorations UX
diff --git a/assets/responsive.css b/assets/responsive.css
index 3fbf2be..719d59e 100644
--- a/assets/responsive.css
+++ b/assets/responsive.css
@@ -39,7 +39,7 @@
/* ── Brand tokens (thèmes utilisateur) ───────────────────────────────────────
Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge
- ces variables via [data-theme="..."] sur
.
+ ces variables via [data-theme="..."] sur .
Les couleurs sémantiques des notes (rouge<4 / orange<5 / vert>=5) restent
hardcodées dans fiche.py / classe.py et NE doivent PAS utiliser ces tokens. */
:root {
@@ -49,6 +49,17 @@
--brand-primary-light: #ff4a54; /* sidebar active text */
--brand-accent: #1565c0; /* liens, infos, sélection */
--brand-accent-soft: #e3f2fd; /* fond pâle pour bannières d'info */
+
+ /* Surfaces et texte (light par défaut) */
+ --surface: white; /* cartes, modales */
+ --surface-soft: #fafafa; /* fond de page secondaire */
+ --surface-muted: #f8f9fa; /* sidebar, sections grisées */
+ --surface-hover: #f3f4f6; /* survol */
+ --text-strong: #37474f; /* titres, texte fort */
+ --text-soft: #4b5563; /* texte courant */
+ --text-muted: #9ca3af; /* labels */
+ --border: #e0e0e0; /* borders cartes */
+ --border-soft: #e5e7eb; /* séparateurs subtils */
}
[data-theme="bleu"] {
@@ -78,6 +89,57 @@
--brand-accent-soft: #e8f5e9;
}
+/* ── Thème sombre ────────────────────────────────────────────────────────────
+ Palette zinc + accent bleu unique. Override aussi les variables Radix
+ `--gray-*` pour que tous les composants Radix s'adaptent. */
+[data-theme="sombre"] {
+ /* Accent unique (bleu) — remplace la couleur de marque rouge EPTM */
+ --brand-primary: #3B82F6;
+ --brand-primary-dark: #1E40AF;
+ --brand-primary-tint: rgba(59, 130, 246, 0.18);
+ --brand-primary-light: #60A5FA;
+ --brand-accent: #3B82F6;
+ --brand-accent-soft: #1E3A5F;
+
+ /* Surfaces */
+ --surface: #141416; /* cartes, panneaux */
+ --surface-soft: #0A0A0B; /* fond de page secondaire */
+ --surface-muted: #141416; /* sidebar, sections grisées */
+ --surface-hover: #26262A; /* survol / actif */
+
+ /* Texte */
+ --text-strong: #F5F5F7; /* texte principal */
+ --text-soft: #A1A1AA; /* texte secondaire */
+ --text-muted: #71717A; /* labels / metadata */
+
+ /* Borders */
+ --border: #33333A; /* visibles (séparateurs, inputs) */
+ --border-soft: #26262A; /* subtiles */
+}
+
+/* Override Radix gray scale (palette zinc-like cohérente avec ci-dessus). */
+[data-theme="sombre"],
+[data-theme="sombre"] .radix-themes {
+ --gray-1: #0A0A0B;
+ --gray-2: #141416;
+ --gray-3: #1C1C1F;
+ --gray-4: #26262A;
+ --gray-5: #33333A;
+ --gray-6: #3F3F46;
+ --gray-7: #52525B;
+ --gray-8: #71717A;
+ --gray-9: #A1A1AA;
+ --gray-10: #C4C4C9;
+ --gray-11: #D4D4D8;
+ --gray-12: #F5F5F7;
+}
+
+/* Page body en sombre */
+[data-theme="sombre"] body {
+ background-color: #0A0A0B;
+ color: var(--text-strong);
+}
+
body {
font-family: var(--default-font-family);
/* Activations Inter : cv11 = 1 sans empattement, ss01 = a/g modernes,
diff --git a/data/class_href_cache.json b/data/class_href_cache.json
index 74d9f0f..bc1c547 100644
--- a/data/class_href_cache.json
+++ b/data/class_href_cache.json
@@ -32,15 +32,15 @@
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917",
"MP1-TASV 4E:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7673335b-a2aa-42de-9cab-6b95ffc90d7c",
"Z-IT Test 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f0707cc3-4511-403b-a2a6-d79f2da4fd99",
- "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=47666e48-95f2-4607-b1c6-fa1bd72c79a2",
- "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ffa9ae4e-4531-428b-88ad-7dd3684bdc8f",
- "AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a3c8ce5a-9636-44ed-8bff-d52def0a72a1",
- "AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=391befbf-cf01-4eed-b18c-23c4a86e8d75",
- "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cdbc227f-c8b5-498f-b8c8-9ef8bdc18e91",
- "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=876e70ab-fdfa-40ad-bc73-9fa05a08135c",
- "EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=bc53830f-a121-4fc6-a88b-5495f5ba3d28",
- "EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=cbaceaff-133a-4d64-930f-3fb79ecbc795",
- "MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=ad7c4d6b-ddb9-4414-b218-d98249fe559d",
- "MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=c4609f4e-5176-4ad8-b115-2a789f1d82de",
- "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b17daa90-89fa-4a10-a5aa-b2433c503aac"
+ "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=884d4b5a-5c84-4699-b317-a1c20519e8d1",
+ "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=eff788b8-9b09-4166-baba-91cdb8f4cc8f",
+ "AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b11daad1-028b-4fb5-bc98-303c5f59c9a8",
+ "AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=fbd5b085-8016-43bc-88f7-ab9542829a35",
+ "EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=194ac578-f4b3-4d17-adf7-3294d8042ce0",
+ "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=24b579b1-d943-4933-91e7-65bd42a4050a",
+ "EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a5203dae-ffe4-49f0-a0e7-543e457c3494",
+ "EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b1f42f17-426f-44e2-ae21-480486849505",
+ "MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=71823b1e-2ec3-4209-aaaa-bff6dcfc16a6",
+ "MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5b147370-9ed0-42ec-ba48-1e10b3d81455",
+ "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7abe005d-291d-4ed6-91a4-a4b3d0f37c7d"
}
\ No newline at end of file
diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py
index 22f2cba..4304cd8 100644
--- a/eptm_dashboard/pages/accueil.py
+++ b/eptm_dashboard/pages/accueil.py
@@ -55,6 +55,8 @@ class AccueilState(AuthState):
}
for _, row in df.iterrows()
]
+ # Le seuil de 5 absences ne s'applique qu'aux classes EM.
+ items = [it for it in items if it["classe"].startswith("EM")]
# Filtrage selon les classes autorisées
if allowed is not None:
items = [it for it in items if it["classe"] in allowed]
@@ -139,8 +141,8 @@ 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",
+ background_color="var(--surface)",
+ border="1px solid var(--border)",
border_radius="8px",
padding="0.75rem 1rem",
flex="1",
@@ -151,53 +153,44 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
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-plus", size=13),
- "Créer l'avis de sanction",
- on_click=AccueilState.open_sanction(
- item["id"], item["nom"], item["prenom"], item["classe"],
- ).stop_propagation,
- size="1",
- color_scheme="red",
- variant="soft",
- ),
- spacing="2",
- align="start",
- width="100%",
+ 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.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.button(
+ rx.icon("file-plus", size=13),
+ "Créer l'avis de sanction",
+ on_click=AccueilState.open_sanction(
+ item["id"], item["nom"], item["prenom"], item["classe"],
+ ).stop_propagation,
+ size="1",
+ color_scheme="gray",
+ variant="soft",
),
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",
+ padding="0.4rem 0.6rem",
+ background_color="var(--surface)",
+ border="1px solid var(--border)",
+ border_radius="6px",
+ flex="1 1 280px",
+ min_width="280px",
+ max_width="380px",
+ align="center",
+ gap="0.5rem",
class_name="hover-lift sanction-tile",
)
@@ -206,13 +199,13 @@ 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"),
+ 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="#f8f9fa",
+ background_color="var(--surface-muted)",
border="1px solid #e9ecef",
_hover={"background_color": "#eef2f6"},
width="100%",
diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py
index 3f8013d..33945d5 100644
--- a/eptm_dashboard/pages/classe.py
+++ b/eptm_dashboard/pages/classe.py
@@ -11,8 +11,9 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
from ..state import AuthState
from ..sidebar import layout
from ..components import empty_state, skeleton_apprenti_card
+from .fiche import FicheState, _notice_row
from src.db import (
- get_session, Apprenti, Absence,
+ get_session, Apprenti, Absence, ApprentiNotice,
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
)
from src.stats import nb_blocs_absences, synthese_classe
@@ -57,6 +58,7 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
TD = "border:1px solid #dee2e6;padding:5px 10px"
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
SEP = ";border-top:3px solid #9e9e9e"
+ MOY_BG = "background:#f0f7ff"
header = f' | '
for i in range(N):
@@ -71,25 +73,44 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
- cells = f'{label} | '
+ cells = f'{label} | '
for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
- cells += f'{_bn_fmt(v)} | '
+ cells += f'{_bn_fmt(v)} | '
return f"{cells}
"
def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
- cells = f'{label} | '
+ cells = f'{label} | '
for year_start in range(0, N, 2):
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
- cells += f'{_bn_fmt(v)} | '
+ cells += f'{_bn_fmt(v)} | '
return f"{cells}
"
+ def _branch_row(branche, sep=False):
+ s = SEP if sep else ""
+ cells = f'{branche["nom"]} | '
+ notes = branche.get("notes") or [None] * N
+ for i in range(N):
+ v = notes[i] if i < len(notes) else None
+ cells += f'{_bn_fmt(v)} | '
+ return f"{cells}
"
+
+ def _group_header_row(label, sep=False):
+ s = SEP if sep else ""
+ return (
+ f'| {label} |
'
+ )
+
body = ""
for grp in groups_order:
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
lbl = _GROUP_LABELS.get(grp, grp)
- body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
+ body += _group_header_row(lbl, sep=True)
+ for br in gd.get("branches", []) or []:
+ body += _branch_row(br)
+ body += _moy_sem_row("Moyenne semestrielle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
@@ -410,6 +431,13 @@ class ClasseState(AuthState):
self._reload()
self.is_loading_apprentis = False
+ def open_apprenti(self, apprenti_id: int):
+ """Ouvre la fiche d'un apprenti avec sa sélection pré-remplie."""
+ return [
+ FicheState.navigate_to(apprenti_id),
+ rx.redirect("/fiche"),
+ ]
+
def set_class_search(self, v: str):
self.class_search = v
@@ -532,6 +560,24 @@ class ClasseState(AuthState):
).all()
ne_by_id = {ne.apprenti_id: json.loads(ne.donnees_json) for ne, _ in ne_rows}
+ # Notices Escada (ApprentiNotice) groupées par apprenti
+ notices_rows = sess.execute(
+ select(ApprentiNotice, Apprenti)
+ .join(Apprenti, Apprenti.id == ApprentiNotice.apprenti_id)
+ .where(Apprenti.classe == classe)
+ .order_by(ApprentiNotice.date_event.desc())
+ ).all()
+ notices_by_id: dict[int, list[dict]] = {}
+ for n, _ in notices_rows:
+ notices_by_id.setdefault(n.apprenti_id, []).append({
+ "date": n.date_event.strftime("%d.%m.%Y") if n.date_event else "",
+ "type": n.type_notice or "",
+ "auteur": n.auteur or "",
+ "titre": n.titre or "",
+ "remarque": n.remarque or "",
+ "matiere": n.matiere or "",
+ })
+
data = []
for apprenti in apprentis:
abs_data = abs_by_name.get((apprenti.nom, apprenti.prenom))
@@ -539,7 +585,8 @@ class ClasseState(AuthState):
excusees = int(abs_data["Excusées"]) if abs_data is not None else 0
non_exc = int(abs_data["NON excusées"]) if abs_data is not None else 0
blocs = nb_blocs_absences(sess, apprenti.id)
- quota_atteint = blocs >= QUOTA
+ # Le quota de 5 absences ne s'applique qu'aux classes EM.
+ quota_atteint = classe.startswith("EM") and blocs >= QUOTA
# BN HTML
bn = bn_by_id.get(apprenti.id)
@@ -570,6 +617,7 @@ class ClasseState(AuthState):
has_notes = False
notes_html = ""
+ notices = notices_by_id.get(apprenti.id, [])
data.append({
"id": apprenti.id,
"nom": apprenti.nom,
@@ -584,6 +632,8 @@ class ClasseState(AuthState):
"bn_caption": bn_caption if has_bn else "",
"has_notes": has_notes,
"notes_html": notes_html,
+ "has_notices": len(notices) > 0,
+ "notices": notices,
"has_pdf_bn": bn_pdf_exists,
"has_pdf_notes": notes_pdf_exists,
})
@@ -622,7 +672,7 @@ def _classe_searchable_select() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "class-search"},
@@ -669,7 +719,7 @@ def _kpi_mini(label: str, value, color: str = "#37474f") -> rx.Component:
rx.text(label, size="1", color="#888"),
rx.text(value, size="5", font_weight="700", color=color, class_name="tabular"),
padding="0.5rem 0.75rem",
- background_color="#f8f9fa",
+ background_color="var(--surface-muted)",
border_radius="6px",
border="1px solid #e9ecef",
min_width="80px",
@@ -681,13 +731,14 @@ def _apprenti_card(item) -> rx.Component:
return rx.box(
# ── En-tête : nom + badge quota ───────────────────────────────────────
rx.hstack(
- rx.link(
+ rx.box(
rx.text(
item["prenom"], " ", item["nom"],
size="4", font_weight="700", color="#1a237e",
),
- href="/fiche",
- text_decoration="none",
+ on_click=ClasseState.open_apprenti(item["id"]),
+ cursor="pointer",
+ _hover={"text_decoration": "underline"},
),
rx.cond(
item["quota_atteint"],
@@ -757,11 +808,12 @@ def _apprenti_card(item) -> rx.Component:
margin_bottom="0.75rem",
),
- # ── Onglets BN / Notes ────────────────────────────────────────────────
+ # ── Onglets BN / Notes / Notices ──────────────────────────────────────
rx.tabs.root(
rx.tabs.list(
rx.tabs.trigger("Cours professionnels", value="bn"),
rx.tabs.trigger("Notes d'examen", value="notes"),
+ rx.tabs.trigger("Notices", value="notices"),
),
rx.tabs.content(
rx.cond(
@@ -801,14 +853,45 @@ def _apprenti_card(item) -> rx.Component:
width="100%",
padding_top="0.75rem",
),
+ rx.tabs.content(
+ rx.cond(
+ item["has_notices"],
+ rx.box(
+ rx.table.root(
+ rx.table.header(
+ rx.table.row(
+ rx.table.column_header_cell("Date"),
+ rx.table.column_header_cell("Type"),
+ rx.table.column_header_cell("Auteur"),
+ rx.table.column_header_cell("Titre"),
+ rx.table.column_header_cell("Remarques"),
+ rx.table.column_header_cell("Matière"),
+ ),
+ ),
+ rx.table.body(
+ rx.foreach(item["notices"].to(list[dict]), _notice_row),
+ ),
+ width="100%", size="1",
+ ),
+ width="100%", overflow_x="auto",
+ ),
+ rx.text(
+ "Aucune notice Escada pour cet(te) apprenti(e).",
+ size="2", color="#666",
+ ),
+ ),
+ value="notices",
+ width="100%",
+ padding_top="0.75rem",
+ ),
default_value="bn",
width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
overflow="hidden",
class_name="hover-lift anim-fade",
diff --git a/eptm_dashboard/pages/cron.py b/eptm_dashboard/pages/cron.py
index fabe3b7..3601038 100644
--- a/eptm_dashboard/pages/cron.py
+++ b/eptm_dashboard/pages/cron.py
@@ -574,7 +574,7 @@ def _job_row(job: rx.Var) -> rx.Component:
),
),
padding="0.75rem 1rem",
- background_color="white",
+ background_color="var(--surface)",
border="1px solid var(--gray-5)",
border_radius="6px",
width="100%",
diff --git a/eptm_dashboard/pages/doc.py b/eptm_dashboard/pages/doc.py
index 41d642f..d64106c 100644
--- a/eptm_dashboard/pages/doc.py
+++ b/eptm_dashboard/pages/doc.py
@@ -76,7 +76,7 @@ def _content() -> rx.Component:
rx.heading(DocState.selected_title, size="6", margin_bottom="1rem"),
rx.html(DocState.selected_html, class_name="doc-content"),
padding="1.5rem 2rem",
- background_color="white",
+ background_color="var(--surface)",
border="1px solid var(--gray-5)",
border_radius="8px",
width="100%",
diff --git a/eptm_dashboard/pages/escada.py b/eptm_dashboard/pages/escada.py
index 17294e6..3590153 100644
--- a/eptm_dashboard/pages/escada.py
+++ b/eptm_dashboard/pages/escada.py
@@ -1289,7 +1289,7 @@ def _classe_multi_select_escada() -> rx.Component:
padding="0.45rem 0.6rem",
border="2px solid var(--red-7)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
cursor="pointer",
width="100%",
max_width="640px",
@@ -1335,16 +1335,16 @@ def _log_box() -> rx.Component:
rx.text(
EscadaState.op_log,
size="1",
- color="#37474f",
+ color="var(--text-strong)",
white_space="pre",
font_family="'Courier New', monospace",
),
max_height="240px",
overflow_y="auto",
overflow_x="auto",
- background_color="#f8f9fa",
+ background_color="var(--surface-muted)",
border_radius="6px",
- border="1px solid #dee2e6",
+ border="1px solid var(--border)",
padding="0.75rem",
width="100%",
margin_top="0.75rem",
@@ -1356,7 +1356,7 @@ def _result_list(label: str, items, row_fn) -> rx.Component:
return rx.cond(
items.length() > 0,
rx.vstack(
- rx.text(label, size="2", font_weight="700", color="#37474f"),
+ rx.text(label, size="2", font_weight="700", color="var(--text-strong)"),
rx.foreach(items, row_fn),
spacing="1",
),
@@ -1637,7 +1637,7 @@ def escada_page() -> rx.Component:
rx.box(
rx.text(
"Synchronisation depuis Escada",
- size="3", font_weight="700", color="#37474f",
+ size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem",
),
@@ -1685,11 +1685,11 @@ def escada_page() -> rx.Component:
# ── Formulaire sync ────────────────────────────────────────
rx.vstack(
# Sélection des classes — multi-select style Streamlit
- rx.text("Classes", size="2", font_weight="700", color="#37474f"),
+ rx.text("Classes", size="2", font_weight="700", color="var(--text-strong)"),
_classe_multi_select_escada(),
# Options de sync
- rx.text("Options", size="2", font_weight="700", color="#37474f"),
+ rx.text("Options", size="2", font_weight="700", color="var(--text-strong)"),
rx.flex(
rx.flex(
rx.checkbox(checked=EscadaState.sync_abs,
@@ -1823,9 +1823,9 @@ def escada_page() -> rx.Component:
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
),
@@ -1833,7 +1833,7 @@ def escada_page() -> rx.Component:
rx.box(
rx.text(
"Pousser les absences en attente sur Escada",
- size="3", font_weight="700", color="#37474f",
+ size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem",
),
@@ -1923,9 +1923,9 @@ def escada_page() -> rx.Component:
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
),
@@ -1933,7 +1933,7 @@ def escada_page() -> rx.Component:
rx.box(
rx.text(
"Pousser les notices en attente sur Escada",
- size="3", font_weight="700", color="#37474f",
+ size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem",
),
rx.cond(
@@ -2021,9 +2021,9 @@ def escada_page() -> rx.Component:
),
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
),
diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py
index 190b374..8b9bd9e 100644
--- a/eptm_dashboard/pages/fiche.py
+++ b/eptm_dashboard/pages/fiche.py
@@ -79,6 +79,9 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
TD = "border:1px solid #dee2e6;padding:5px 10px"
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
SEP = ";border-top:3px solid #9e9e9e"
+ # Fond pour les lignes "Moyenne ..." — pas gris (déjà utilisé par les
+ # en-têtes de groupe), juste un bleu très pâle pour les distinguer.
+ MOY_BG = "background:#f0f7ff"
header = f' | '
for i in range(N):
@@ -93,25 +96,48 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
- cells = f'{label} | '
+ cells = f'{label} | '
for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
- cells += f'{_bn_fmt(v)} | '
+ cells += f'{_bn_fmt(v)} | '
return f"{cells}
"
def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else ""
- cells = f'{label} | '
+ cells = f'{label} | '
for year_start in range(0, N, 2):
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
- cells += f'{_bn_fmt(v)} | '
+ cells += f'{_bn_fmt(v)} | '
return f"{cells}
"
+ def _branch_row(branche, sep=False):
+ s = SEP if sep else ""
+ cells = f'{branche["nom"]} | '
+ notes = branche.get("notes") or [None] * N
+ for i in range(N):
+ v = notes[i] if i < len(notes) else None
+ cells += f'{_bn_fmt(v)} | '
+ return f"{cells}
"
+
+ def _group_header_row(label, sep=False):
+ s = SEP if sep else ""
+ return (
+ f'| {label} |
'
+ )
+
body = ""
for grp in groups_order:
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
lbl = _GROUP_LABELS.get(grp, grp)
- body += _moy_sem_row(lbl, gd, f"{TD};font-weight:bold")
+ # En-tête du groupe — séparation visuelle au-dessus (y compris du 1er,
+ # pour le détacher de la ligne d'en-tête des semestres).
+ body += _group_header_row(lbl, sep=True)
+ # Branches individuelles du groupe (Anglais, Automatisation, …)
+ for br in gd.get("branches", []) or []:
+ body += _branch_row(br)
+ # Moyennes du groupe
+ body += _moy_sem_row("Moyenne semestrielle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_ann_row("Moyenne annuelle du groupe", gd, f"{TD};font-style:italic;color:#555")
body += _moy_sem_row("Moyenne semestrielle globale", d["globale"], f"{TD};font-style:italic", sep=True)
@@ -453,6 +479,7 @@ class FicheState(AuthState):
fiche_email_val: str = ""
fiche_date_naissance: str = ""
fiche_majeur: str = ""
+ fiche_compensation: str = ""
fiche_entreprise_nom: str = ""
fiche_entreprise_adresse: str = ""
fiche_entreprise_cp_localite: str = ""
@@ -972,7 +999,10 @@ class FicheState(AuthState):
self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
- self.quota_atteint = self.kpi_blocs >= QUOTA
+ # Le quota de 5 absences ne s'applique qu'aux classes EM.
+ apprenti = sess.get(Apprenti, self.selected_id)
+ _is_em = bool(apprenti and (apprenti.classe or "").startswith("EM"))
+ self.quota_atteint = _is_em and self.kpi_blocs >= QUOTA
# Fiche
fiche = sess.execute(
@@ -991,6 +1021,12 @@ class FicheState(AuthState):
("Majeur : oui" if fiche.majeur else "Majeur : non")
if fiche.majeur is not None else ""
)
+ self.fiche_compensation = (
+ ("Compensation des désavantages : oui"
+ if fiche.compensation_desavantages
+ else "Compensation des désavantages : non")
+ if fiche.compensation_desavantages is not None else ""
+ )
self.fiche_entreprise_nom = fiche.entreprise_nom or ""
self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
self.fiche_entreprise_cp_localite = (
@@ -1009,6 +1045,7 @@ class FicheState(AuthState):
for attr in [
"fiche_adresse", "fiche_cp_localite", "fiche_telephone",
"fiche_email_val", "fiche_date_naissance", "fiche_majeur",
+ "fiche_compensation",
"fiche_entreprise_nom", "fiche_entreprise_adresse",
"fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
"fiche_entreprise_email", "fiche_formateur_nom",
@@ -1230,7 +1267,7 @@ def _apprenti_searchable_select() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "apprenti-search"},
@@ -1277,9 +1314,9 @@ def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
rx.text(label, size="1", color="#666"),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
padding="1rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
flex="1",
min_width="120px",
class_name="hover-lift",
@@ -1379,7 +1416,7 @@ def _edit_panel() -> rx.Component:
rx.icon("pencil", size=15, color="var(--brand-accent)"),
rx.text(
"Édition du ", FicheState.edit_date_label,
- size="3", weight="bold", color="#37474f",
+ size="3", weight="bold", color="var(--text-strong)",
),
rx.spacer(),
rx.button(
@@ -1442,9 +1479,9 @@ def _edit_panel() -> rx.Component:
spacing="3", width="100%",
),
padding="1rem",
- background_color="#f0f7ff",
+ background_color="var(--brand-accent-soft)",
border_radius="8px",
- border="1px solid #bfdbfe",
+ border="1px solid var(--border)",
width="100%",
class_name="anim-slide-down",
)
@@ -1509,9 +1546,9 @@ def _actions_row() -> rx.Component:
width="100%",
),
padding="0.75rem 1rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -1531,8 +1568,8 @@ def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.hstack(
- rx.icon("mail", size=16, color="#37474f"),
- rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
+ rx.icon("mail", size=16, color="var(--text-strong)"),
+ rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
spacing="2", align="center",
),
rx.divider(),
@@ -1677,9 +1714,9 @@ def _email_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -1744,17 +1781,18 @@ def fiche_page() -> rx.Component:
rx.vstack(
rx.flex(
rx.vstack(
- rx.text("Élève", size="2", font_weight="700", color="#37474f"),
+ rx.text("Élève", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("map-pin", FicheState.fiche_adresse),
_info_line("map-pin", FicheState.fiche_cp_localite),
_info_line("phone", FicheState.fiche_telephone),
_info_line("mail", FicheState.fiche_email_val),
_info_line("cake", FicheState.fiche_date_naissance),
_info_line("user-check", FicheState.fiche_majeur),
+ _info_line("scale", FicheState.fiche_compensation),
spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
- rx.text("Entreprise", size="2", font_weight="700", color="#37474f"),
+ rx.text("Entreprise", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("building-2", FicheState.fiche_entreprise_nom),
_info_line("map-pin", FicheState.fiche_entreprise_adresse),
_info_line("map-pin", FicheState.fiche_entreprise_cp_localite),
@@ -1763,7 +1801,7 @@ def fiche_page() -> rx.Component:
spacing="1", align="start", flex="1", min_width="200px",
),
rx.vstack(
- rx.text("Formateur", size="2", font_weight="700", color="#37474f"),
+ rx.text("Formateur", size="2", font_weight="700", color="var(--text-strong)"),
_info_line("user", FicheState.fiche_formateur_nom),
_info_line("mail", FicheState.fiche_formateur_email),
spacing="1", align="start", flex="1", min_width="200px",
@@ -1782,9 +1820,9 @@ def fiche_page() -> rx.Component:
),
),
padding="1rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
),
@@ -1859,9 +1897,9 @@ def fiche_page() -> rx.Component:
default_value="bn", width="100%",
),
padding="1rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
),
@@ -1877,7 +1915,7 @@ def fiche_page() -> rx.Component:
),
rx.text(
FicheState.cal_month_name,
- size="4", font_weight="700", color="#37474f",
+ size="4", font_weight="700", color="var(--text-strong)",
flex="1", text_align="center",
),
rx.button(
@@ -1916,9 +1954,9 @@ def fiche_page() -> rx.Component:
size="1", color="#9e9e9e", margin_top="0.25rem",
),
padding="1rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
),
rx.text("Aucune absence enregistrée.", size="2", color="#666"),
@@ -1944,9 +1982,9 @@ def fiche_page() -> rx.Component:
spacing="2", align="center",
),
padding="0.75rem 1rem",
- background_color="#f9fafb",
+ background_color="var(--surface-muted)",
border_radius="8px",
- border="1px solid #e5e7eb",
+ border="1px solid var(--border-soft)",
width="100%",
),
),
diff --git a/eptm_dashboard/pages/params.py b/eptm_dashboard/pages/params.py
index 5d6c9f8..bec36f7 100644
--- a/eptm_dashboard/pages/params.py
+++ b/eptm_dashboard/pages/params.py
@@ -250,9 +250,9 @@ def _section(title: str, *children) -> rx.Component:
width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -535,7 +535,7 @@ def _mapping_row(m: rx.Var) -> rx.Component:
padding="0.4rem 0.6rem",
border="1px solid var(--gray-5)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
width="100%",
)
diff --git a/eptm_dashboard/pages/profile.py b/eptm_dashboard/pages/profile.py
index 93b8ec7..9838b38 100644
--- a/eptm_dashboard/pages/profile.py
+++ b/eptm_dashboard/pages/profile.py
@@ -277,9 +277,9 @@ def _avatar_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -337,9 +337,9 @@ def _info_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -393,18 +393,19 @@ def _password_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
_THEMES = [
- ("eptm", "EPTM (rouge)", "#dc000e"),
+ ("eptm", "EPTM (rouge)", "#dc000e"),
("bleu", "Bleu corporate", "#1565c0"),
("indigo", "Indigo nuit", "#3f51b5"),
("vert", "Vert académique","#2e7d32"),
+ ("sombre", "Sombre (dark)", "#1a1a1a"),
]
@@ -458,9 +459,9 @@ def _theme_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -511,9 +512,9 @@ def _totp_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
diff --git a/eptm_dashboard/pages/purge.py b/eptm_dashboard/pages/purge.py
index 64a5a67..850881d 100644
--- a/eptm_dashboard/pages/purge.py
+++ b/eptm_dashboard/pages/purge.py
@@ -365,7 +365,7 @@ def _classe_selector() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "purge-search"},
@@ -412,8 +412,8 @@ def _kpi(label: str, value, color: str = "#37474f") -> rx.Component:
rx.text(label, size="1", color="#666"),
rx.text(value, size="5", font_weight="700", color=color),
padding="0.6rem 0.85rem",
- background_color="white",
- border="1px solid #e0e0e0",
+ background_color="var(--surface)",
+ border="1px solid var(--border)",
border_radius="6px",
min_width="110px",
text_align="center",
@@ -427,7 +427,7 @@ def _preview_panel() -> rx.Component:
rx.vstack(
rx.text(
"Données qui seront supprimées :",
- size="2", weight="bold", color="#37474f",
+ size="2", weight="bold", color="var(--text-strong)",
),
rx.flex(
_kpi("Apprentis", PurgeState.pv_apprentis, "var(--brand-primary-dark)"),
@@ -458,7 +458,7 @@ def _preview_panel() -> rx.Component:
lambda f: rx.text("• ", f, size="1", color="#666"),
),
padding="0.6rem 0.75rem",
- background_color="#fafafa",
+ background_color="var(--surface-soft)",
border_radius="6px",
border="1px solid #eee",
width="100%",
diff --git a/eptm_dashboard/pages/retenue.py b/eptm_dashboard/pages/retenue.py
index b68788f..e9af4c5 100644
--- a/eptm_dashboard/pages/retenue.py
+++ b/eptm_dashboard/pages/retenue.py
@@ -499,7 +499,7 @@ def _apprenti_selector() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
cursor="pointer",
width="100%",
custom_attrs={"data-shortcut": "apprenti-search"},
@@ -570,7 +570,7 @@ def _branche_selector() -> rx.Component:
padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
cursor="pointer",
width="100%",
),
@@ -637,7 +637,7 @@ def _form() -> rx.Component:
rx.box(
rx.flex(
rx.icon("user", size=16, color="var(--brand-accent)"),
- rx.text(RetenueState.selected_label, size="2", weight="medium", color="#37474f"),
+ rx.text(RetenueState.selected_label, size="2", weight="medium", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
padding="0.5rem 0.75rem",
@@ -801,8 +801,8 @@ def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.flex(
- rx.icon("mail", size=16, color="#37474f"),
- rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
+ rx.icon("mail", size=16, color="var(--text-strong)"),
+ rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
rx.divider(),
@@ -857,9 +857,9 @@ def _email_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
diff --git a/eptm_dashboard/pages/sanction.py b/eptm_dashboard/pages/sanction.py
index 18dfdeb..e2f7407 100644
--- a/eptm_dashboard/pages/sanction.py
+++ b/eptm_dashboard/pages/sanction.py
@@ -287,8 +287,8 @@ def _texte_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.flex(
- rx.icon("file-text", size=16, color="#37474f"),
- rx.text("Contenu de l'avis", size="3", weight="bold", color="#37474f"),
+ rx.icon("file-text", size=16, color="var(--text-strong)"),
+ rx.text("Contenu de l'avis", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
rx.divider(),
@@ -318,9 +318,9 @@ def _texte_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -371,8 +371,8 @@ def _email_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.flex(
- rx.icon("mail", size=16, color="#37474f"),
- rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
+ rx.icon("mail", size=16, color="var(--text-strong)"),
+ rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
rx.divider(),
@@ -427,9 +427,9 @@ def _email_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -454,7 +454,7 @@ def sanction_modal() -> rx.Component:
rx.box(
rx.flex(
rx.icon("user", size=16, color="#c62828"),
- rx.text(SanctionState.selected_label, size="2", weight="medium", color="#37474f"),
+ rx.text(SanctionState.selected_label, size="2", weight="medium", color="var(--text-strong)"),
gap="0.5rem", align="center",
),
padding="0.5rem 0.75rem",
diff --git a/eptm_dashboard/pages/users.py b/eptm_dashboard/pages/users.py
index d1cdd4e..c4cdebb 100644
--- a/eptm_dashboard/pages/users.py
+++ b/eptm_dashboard/pages/users.py
@@ -770,7 +770,7 @@ def _classes_multi_select() -> rx.Component:
padding="0.45rem 0.6rem",
border="1px solid var(--gray-7)",
border_radius="6px",
- background_color="white",
+ background_color="var(--surface)",
cursor="pointer",
width="100%",
),
@@ -1064,9 +1064,9 @@ def _add_user_section() -> rx.Component:
spacing="3", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
)
@@ -1085,9 +1085,9 @@ def users_page() -> rx.Component:
spacing="2", width="100%",
),
padding="1.25rem",
- background_color="white",
+ background_color="var(--surface)",
border_radius="8px",
- border="1px solid #e0e0e0",
+ border="1px solid var(--border)",
width="100%",
),
_edit_panel(),
diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py
index 87664a3..915b8be 100644
--- a/eptm_dashboard/sidebar.py
+++ b/eptm_dashboard/sidebar.py
@@ -10,14 +10,13 @@ FULL_W = "240px"
RAIL_W = "68px"
TOPBAR_H = "56px"
-# Sidebar palette : couleurs neutres locales + tokens de marque (cf. responsive.css).
-_BG = "#f8f9fa" # sidebar background (light)
-_BORDER = "#e5e7eb" # subtle separator
-_TEXT = "#4b5563" # inactive text
-_TEXT_MUTED = "#9ca3af" # muted labels
-_HOVER_BG = "#f3f4f6"
-_USER_BG = "#f3f4f6" # slightly darker user section
-# Tokens dynamiques (changent selon le thème user)
+# Sidebar palette — utilise les tokens de marque (cf. responsive.css).
+_BG = "var(--surface-muted)" # sidebar background
+_BORDER = "var(--border-soft)" # subtle separator
+_TEXT = "var(--text-soft)" # inactive text
+_TEXT_MUTED = "var(--text-muted)" # muted labels
+_HOVER_BG = "var(--surface-hover)"
+_USER_BG = "var(--surface-hover)" # slightly darker user section
_ACTIVE_BG = "var(--brand-primary-tint)"
_ACTIVE_CLR = "var(--brand-primary-light)"
@@ -537,7 +536,9 @@ def layout(content: rx.Component) -> rx.Component:
"content-area",
),
padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"),
- background_color="var(--gray-2)",
+ # Fond de page : doit être plus sombre que les cartes en mode dark
+ # (--surface = gray-2) pour assurer la séparation visuelle.
+ background_color="var(--gray-1)",
overflow_x="hidden",
transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box",
diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py
index 6b2ab2a..a36f546 100644
--- a/eptm_dashboard/state.py
+++ b/eptm_dashboard/state.py
@@ -144,7 +144,7 @@ class AuthState(rx.State):
def set_theme(self, value: str):
"""Change le thème de couleur (persiste en LocalStorage et auth.yaml)."""
- if value not in ("eptm", "bleu", "indigo", "vert"):
+ if value not in ("eptm", "bleu", "indigo", "vert", "sombre"):
value = "eptm"
self.theme = value
# Persister dans auth.yaml pour synchronisation multi-device.
diff --git a/scripts/sync_esacada.py b/scripts/sync_esacada.py
index 4050a55..e70203d 100644
--- a/scripts/sync_esacada.py
+++ b/scripts/sync_esacada.py
@@ -766,6 +766,20 @@ def _scrape_student_details(page: Page, class_name: str) -> list[dict]:
# Déplier + lire une ligne à la fois (Escada ne gère pas les AJAX simultanés)
fiches: list[dict] = []
for i in range(n):
+ # Lire l'indicateur "Compensation des désavantages" sur la ligne
+ # principale AVANT l'expand. L'icône est pawn_glass_blue.png (a le droit)
+ # ou pawn_glass_white.png (pas le droit).
+ compensation = page.evaluate("""([gid, i]) => {
+ const row = document.getElementById(`${gid}_DXDataRow${i}`);
+ if (!row) return null;
+ const img = row.querySelector('img[src*="pawn_glass"]');
+ if (!img) return null;
+ const src = img.getAttribute('src') || '';
+ if (src.includes('blue')) return true;
+ if (src.includes('white')) return false;
+ return null;
+ }""", [gid, i])
+
# Clic sur le bouton expand de la ligne i
clicked = page.evaluate("""([gid, i]) => {
const row = document.getElementById(`${gid}_DXDataRow${i}`);
@@ -816,9 +830,15 @@ def _scrape_student_details(page: Page, class_name: str) -> list[dict]:
if raw:
fiche = _parse_fiche_text(raw)
+ fiche["compensation_desavantages"] = compensation
if fiche.get('nom_eleve') or fiche.get('entreprise_nom'):
fiches.append(fiche)
- _log(f" [fiches] {i}: {fiche.get('nom_eleve', '?')}")
+ _comp_lbl = (
+ "compensation=oui" if compensation
+ else "compensation=non" if compensation is False
+ else "compensation=?"
+ )
+ _log(f" [fiches] {i}: {fiche.get('nom_eleve', '?')} ({_comp_lbl})")
else:
_log(f" [fiches] {i}: WARNING données vides — raw[:80]={raw[:80]!r}")
else:
diff --git a/src/db.py b/src/db.py
index 4c1422c..3500ff5 100644
--- a/src/db.py
+++ b/src/db.py
@@ -176,6 +176,9 @@ class ApprentiFiche(Base):
email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
date_naissance: Mapped[Optional[str]] = mapped_column(String, nullable=True)
majeur: Mapped[Optional[bool]] = mapped_column(nullable=True)
+ # Compensation des désavantages (Nachteilsausgleich) — True si accordée,
+ # False sinon, None si la donnée n'a pas été scrapée
+ compensation_desavantages: Mapped[Optional[bool]] = mapped_column(nullable=True)
# Entreprise
entreprise_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True)
@@ -341,6 +344,7 @@ def init_db(engine=None):
"ALTER TABLE sanctions_export ADD COLUMN nb_absences INTEGER",
"ALTER TABLE cron_jobs ADD COLUMN notify_level TEXT DEFAULT 'normal'",
"ALTER TABLE apprenti_fiches ADD COLUMN profession TEXT",
+ "ALTER TABLE apprenti_fiches ADD COLUMN compensation_desavantages BOOLEAN",
"ALTER TABLE cron_jobs ADD COLUMN sync_notices BOOLEAN DEFAULT 0",
# Migration cron task_kind — schéma 3 valeurs + checkbox sync_notices.
# Étape A : pour les rows qui ciblaient les notices, on flag sync_notices=1
@@ -407,7 +411,7 @@ def upsert_apprenti_fiche(session: Session, apprenti_id: int, data: dict) -> Non
).scalar_one_or_none()
fields = [
"adresse", "code_postal", "localite", "telephone", "email",
- "date_naissance", "majeur",
+ "date_naissance", "majeur", "compensation_desavantages",
"entreprise_nom", "entreprise_adresse", "entreprise_code_postal",
"entreprise_localite", "entreprise_telephone", "entreprise_email",
"formateur_nom", "formateur_email",
diff --git a/src/parser_bn.py b/src/parser_bn.py
index 25a955d..7fbc786 100644
--- a/src/parser_bn.py
+++ b/src/parser_bn.py
@@ -7,6 +7,7 @@ Two PDF variants:
groups: Branches professionnelles (BP) + Travaux pratiques (TP)
Extracted rows (per group):
+ - Branches individuelles → branches: [{"nom": str, "notes": [...]}]
- Moyenne semestrielle du groupe → moy_sem[0..7]
- Moyenne annuelle du groupe → moy_ann[0..7] (non-null at Sem.2,4,6,8 positions)
@@ -89,8 +90,53 @@ def _extract_name(page) -> tuple[str, str]:
return "", ""
+def _find_bn_table_obj(page):
+ """Retourne l'objet Table (avec bbox) correspondant à la table des notes,
+ et le contenu extrait sous forme list[list[str]]. Garder l'objet permet
+ d'utiliser les bbox de chaque cellule pour aligner les sous-lignes."""
+ for tbl in page.find_tables():
+ ext = tbl.extract()
+ if not ext or len(ext) < 4:
+ continue
+ header = ext[0]
+ if len(header) >= 7 and any(h and "Sem." in str(h) for h in header):
+ return tbl, ext
+ return None, None
+
+
+def _cell_lines(page, bbox):
+ """Retourne la liste des lignes visuelles dans une cellule, avec leur
+ position verticale (top) — sert à aligner branches ↔ notes."""
+ if bbox is None:
+ return []
+ try:
+ words = page.crop(bbox).extract_words()
+ except Exception:
+ return []
+ if not words:
+ return []
+ words.sort(key=lambda w: (w["top"], w["x0"]))
+ lines: list[list[dict]] = [[words[0]]]
+ for w in words[1:]:
+ if abs(w["top"] - lines[-1][-1]["top"]) < 4:
+ lines[-1].append(w)
+ else:
+ lines.append([w])
+ out = []
+ for ln in lines:
+ ln.sort(key=lambda w: w["x0"])
+ out.append({
+ "top": sum(w["top"] for w in ln) / len(ln),
+ "text": " ".join(w["text"] for w in ln).strip(),
+ })
+ return out
+
+
def _find_bn_table(tables: list) -> list | None:
- """Return the first table that looks like the BN grades table (≥7 cols, Sem. header)."""
+ """Return the first table that looks like the BN grades table (≥7 cols, Sem. header).
+
+ Kept for backward compatibility — preferred path is _find_bn_table_obj.
+ """
for tbl in tables:
if not tbl or len(tbl) < 4:
continue
@@ -129,8 +175,7 @@ def parse_bn_page(page) -> dict | None:
nom, prenom = _extract_name(page)
- tables = page.extract_tables()
- bn_table = _find_bn_table(tables)
+ table_obj, bn_table = _find_bn_table_obj(page)
if not bn_table:
return None
@@ -143,14 +188,61 @@ def parse_bn_page(page) -> dict | None:
while len(sem_labels) < 8:
sem_labels.append(None)
+ table_rows = table_obj.rows # bbox-aware rows, indexed comme bn_table
+
# Parse data rows
current_group: str | None = None
groups: dict[str, dict] = {}
globale: dict[str, list] = {"moy_sem": [None] * 8, "moy_ann": [None] * 8}
- for row in bn_table[1:]:
+ def _empty_group() -> dict:
+ return {
+ "moy_sem": [None] * 8,
+ "moy_ann": [None] * 8,
+ "branches": [],
+ }
+
+ def _branches_from_bbox(table_row) -> list[dict]:
+ """Démultiplexe une ligne du tableau en plusieurs branches en utilisant
+ la position verticale des mots dans chaque cellule. Indispensable car
+ pdfplumber.extract_tables() ne préserve PAS les sous-lignes vides
+ (ex: 25 branches dans le label, 7 valeurs visibles dans la colonne
+ Sem.1 → l'approche par split('\\n') décale tout)."""
+ if table_row is None:
+ return []
+ cells = table_row.cells
+ if not cells or len(cells) < 2 or cells[0] is None:
+ return []
+ label_lines = _cell_lines(page, cells[0])
+ if not label_lines:
+ return []
+ col_lines: list[list[dict]] = []
+ for i in range(8):
+ bbox = cells[i + 1] if (i + 1) < len(cells) else None
+ col_lines.append(_cell_lines(page, bbox))
+ branches = []
+ for lab in label_lines:
+ notes = []
+ for col in col_lines:
+ match = None
+ for nl in col:
+ if abs(nl["top"] - lab["top"]) < 4:
+ match = _to_float(nl["text"])
+ break
+ notes.append(match)
+ branches.append({"nom": lab["text"], "notes": notes})
+ return branches
+
+ stop = False # bascule à True après "moyenne annuelle globale" → ignore
+ # les lignes "Absences", "Observations", etc.
+
+ for idx in range(1, len(bn_table)):
+ if stop:
+ continue
+ row = bn_table[idx]
if not row or not row[0]:
continue
+ table_row = table_rows[idx] if idx < len(table_rows) else None
label = str(row[0]).strip()
vals = [
_to_float(row[i + 1]) if (i + 1) < len(row) else None
@@ -159,15 +251,20 @@ def parse_bn_page(page) -> dict | None:
low = label.lower()
- if "branches de culture" in low or "culture g" in low:
+ # Headers de groupe = label avec coefficient "(Nx)" (ex: "Travaux
+ # pratiques (1x)"). Indispensable pour distinguer du label de
+ # branche homonyme "Travaux pratiques" qui apparaît parfois.
+ is_group_header = bool(re.search(r"\(\d+x\)", low))
+
+ if is_group_header and ("branches de culture" in low or "culture g" in low):
current_group = "CG"
- groups.setdefault("CG", {"moy_sem": [None] * 8, "moy_ann": [None] * 8})
- elif "branches professionnelles" in low:
+ groups.setdefault("CG", _empty_group())
+ elif is_group_header and "branches professionnelles" in low:
current_group = "BP"
- groups.setdefault("BP", {"moy_sem": [None] * 8, "moy_ann": [None] * 8})
- elif "travaux pratiques" in low:
+ groups.setdefault("BP", _empty_group())
+ elif is_group_header and "travaux pratiques" in low:
current_group = "TP"
- groups.setdefault("TP", {"moy_sem": [None] * 8, "moy_ann": [None] * 8})
+ groups.setdefault("TP", _empty_group())
elif "moyenne semestrielle du groupe" in low and current_group:
groups[current_group]["moy_sem"] = vals
elif "moyenne annuelle du groupe" in low and current_group:
@@ -176,6 +273,14 @@ def parse_bn_page(page) -> dict | None:
globale["moy_sem"] = vals
elif "moyenne annuelle globale" in low:
globale["moy_ann"] = vals
+ stop = True # tout ce qui suit (Absences, Observations) est ignoré
+ elif current_group is not None:
+ # Toute autre ligne dans un groupe = branches individuelles.
+ # On utilise la position verticale (bbox) pour aligner branches
+ # ↔ notes — voir docstring de _branches_from_bbox.
+ groups[current_group]["branches"].extend(
+ _branches_from_bbox(table_row)
+ )
if not groups:
return None