ajouté import du statut des désavantages, affichage de toutes les notes du BN.

This commit is contained in:
Julien Balet 2026-05-11 19:19:26 +02:00
parent 38189deb0f
commit 7431339ce5
21 changed files with 796 additions and 188 deletions

295
DEPLOY_PROD.md Normal file
View file

@ -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)
### J1 (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)
```

View file

@ -6,14 +6,20 @@ en haut de la section concernée.
## Idées / fonctionnalités ## Idées / fonctionnalités
- [ ] Ajouter sur le dashboard l'affichage des notes insuffisantes - [ ] 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 - [ ] 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 ## 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? - [ ] 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 ## Améliorations UX

View file

@ -39,7 +39,7 @@
/* Brand tokens (thèmes utilisateur) /* Brand tokens (thèmes utilisateur)
Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge Tokens utilisés par l'app pour les couleurs de marque. Chaque thème surcharge
ces variables via [data-theme="..."] sur <body>. ces variables via [data-theme="..."] sur <html>.
Les couleurs sémantiques des notes (rouge<4 / orange<5 / vert>=5) restent 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. */ hardcodées dans fiche.py / classe.py et NE doivent PAS utiliser ces tokens. */
:root { :root {
@ -49,6 +49,17 @@
--brand-primary-light: #ff4a54; /* sidebar active text */ --brand-primary-light: #ff4a54; /* sidebar active text */
--brand-accent: #1565c0; /* liens, infos, sélection */ --brand-accent: #1565c0; /* liens, infos, sélection */
--brand-accent-soft: #e3f2fd; /* fond pâle pour bannières d'info */ --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"] { [data-theme="bleu"] {
@ -78,6 +89,57 @@
--brand-accent-soft: #e8f5e9; --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 { body {
font-family: var(--default-font-family); font-family: var(--default-font-family);
/* Activations Inter : cv11 = 1 sans empattement, ss01 = a/g modernes, /* Activations Inter : cv11 = 1 sans empattement, ss01 = a/g modernes,

View file

@ -32,15 +32,15 @@
"MP1-TASV 4D:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=3bc53c77-bda8-43a4-ab47-08c2eb84a917", "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", "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", "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 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=ffa9ae4e-4531-428b-88ad-7dd3684bdc8f", "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=a3c8ce5a-9636-44ed-8bff-d52def0a72a1", "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=391befbf-cf01-4eed-b18c-23c4a86e8d75", "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=cdbc227f-c8b5-498f-b8c8-9ef8bdc18e91", "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=876e70ab-fdfa-40ad-bc73-9fa05a08135c", "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=bc53830f-a121-4fc6-a88b-5495f5ba3d28", "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=cbaceaff-133a-4d64-930f-3fb79ecbc795", "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=ad7c4d6b-ddb9-4414-b218-d98249fe559d", "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=c4609f4e-5176-4ad8-b115-2a789f1d82de", "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=b17daa90-89fa-4a10-a5aa-b2433c503aac" "MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7abe005d-291d-4ed6-91a4-a4b3d0f37c7d"
} }

View file

@ -55,6 +55,8 @@ class AccueilState(AuthState):
} }
for _, row in df.iterrows() 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 # Filtrage selon les classes autorisées
if allowed is not None: if allowed is not None:
items = [it for it in items if it["classe"] in allowed] 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( return rx.box(
rx.text(label, size="1", color="#555555"), rx.text(label, size="1", color="#555555"),
rx.text(value, size="8", font_weight="700", line_height="1.1", class_name="tabular"), rx.text(value, size="8", font_weight="700", line_height="1.1", class_name="tabular"),
background_color="white", background_color="var(--surface)",
border="1px solid #dee2e6", border="1px solid var(--border)",
border_radius="8px", border_radius="8px",
padding="0.75rem 1rem", padding="0.75rem 1rem",
flex="1", flex="1",
@ -151,53 +153,44 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
def _sanction_tile(item: rx.Var) -> rx.Component: def _sanction_tile(item: rx.Var) -> rx.Component:
return rx.box( return rx.flex(
rx.vstack( rx.text(
rx.flex( item["nom"], " ", item["prenom"],
rx.text( size="2", color="#1a237e",
item["nom"], " ", item["prenom"], white_space="nowrap", overflow="hidden",
size="3", weight="bold", color="#1a237e", text_overflow="ellipsis",
), flex="1", min_width="0",
rx.spacer(), ),
rx.box( rx.flex(
rx.flex( rx.icon("triangle-alert", size=11, color="#B71C1C"),
rx.icon("triangle-alert", size=12, color="#B71C1C"), rx.text(item["absences"], size="1", color="#B71C1C", weight="bold"),
rx.text( gap="0.2rem", align="center",
item["absences"], " abs.", background_color="#ffe5e5",
size="1", color="#B71C1C", weight="bold", padding="0.1rem 0.4rem",
), border_radius="9999px",
gap="0.25rem", align="center", flex_shrink="0",
), ),
background_color="#ffe5e5", rx.button(
padding="0.15rem 0.5rem", rx.icon("file-plus", size=13),
border_radius="9999px", "Créer l'avis de sanction",
flex_shrink="0", on_click=AccueilState.open_sanction(
), item["id"], item["nom"], item["prenom"], item["classe"],
width="100%", align="center", gap="0.5rem", wrap="wrap", ).stop_propagation,
), size="1",
rx.button( color_scheme="gray",
rx.icon("file-plus", size=13), variant="soft",
"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%",
), ),
on_click=AccueilState.open_fiche(item["id"]), on_click=AccueilState.open_fiche(item["id"]),
cursor="pointer", cursor="pointer",
padding="0.85rem 1rem", padding="0.4rem 0.6rem",
background_color="white", background_color="var(--surface)",
border="1px solid #e0e0e0", border="1px solid var(--border)",
border_radius="8px", border_radius="6px",
flex="1 1 240px", flex="1 1 280px",
min_width="220px", min_width="280px",
max_width="320px", max_width="380px",
align="center",
gap="0.5rem",
class_name="hover-lift sanction-tile", class_name="hover-lift sanction-tile",
) )
@ -206,13 +199,13 @@ def _class_group(group: rx.Var) -> rx.Component:
return rx.box( return rx.box(
# En-tête de classe (cliquable → page Classes pré-sélectionnée) # En-tête de classe (cliquable → page Classes pré-sélectionnée)
rx.flex( rx.flex(
rx.icon("users", size=15, color="#37474f"), rx.icon("users", size=15, color="var(--text-strong)"),
rx.text(group["classe"], size="3", weight="bold", color="#37474f"), rx.text(group["classe"], size="3", weight="bold", color="var(--text-strong)"),
on_click=AccueilState.open_classe(group["classe"]), on_click=AccueilState.open_classe(group["classe"]),
cursor="pointer", cursor="pointer",
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
border_radius="6px", border_radius="6px",
background_color="#f8f9fa", background_color="var(--surface-muted)",
border="1px solid #e9ecef", border="1px solid #e9ecef",
_hover={"background_color": "#eef2f6"}, _hover={"background_color": "#eef2f6"},
width="100%", width="100%",

View file

@ -11,8 +11,9 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
from ..state import AuthState from ..state import AuthState
from ..sidebar import layout from ..sidebar import layout
from ..components import empty_state, skeleton_apprenti_card from ..components import empty_state, skeleton_apprenti_card
from .fiche import FicheState, _notice_row
from src.db import ( from src.db import (
get_session, Apprenti, Absence, get_session, Apprenti, Absence, ApprentiNotice,
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu, NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
) )
from src.stats import nb_blocs_absences, synthese_classe 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" TD = "border:1px solid #dee2e6;padding:5px 10px"
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa" TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
SEP = ";border-top:3px solid #9e9e9e" SEP = ";border-top:3px solid #9e9e9e"
MOY_BG = "background:#f0f7ff"
header = f'<th style="{TH};text-align:left;min-width:230px"></th>' header = f'<th style="{TH};text-align:left;min-width:230px"></th>'
for i in range(N): 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): def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else "" s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>' cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for i in range(N): for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>' cells += f'<td style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>" return f"<tr>{cells}</tr>"
def _moy_ann_row(label, gd, label_style, sep=False): def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else "" s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>' cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for year_start in range(0, N, 2): for year_start in range(0, N, 2):
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
cells += f'<td colspan="2" style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>' cells += f'<td colspan="2" style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>" return f"<tr>{cells}</tr>"
def _branch_row(branche, sep=False):
s = SEP if sep else ""
cells = f'<td style="{TD}{s}">{branche["nom"]}</td>'
notes = branche.get("notes") or [None] * N
for i in range(N):
v = notes[i] if i < len(notes) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _group_header_row(label, sep=False):
s = SEP if sep else ""
return (
f'<tr><td colspan="{N + 1}" style="{TD};font-weight:bold;'
f'background:#f0f0f0{s}">{label}</td></tr>'
)
body = "" body = ""
for grp in groups_order: for grp in groups_order:
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N}) gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
lbl = _GROUP_LABELS.get(grp, grp) 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_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) 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._reload()
self.is_loading_apprentis = False 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): def set_class_search(self, v: str):
self.class_search = v self.class_search = v
@ -532,6 +560,24 @@ class ClasseState(AuthState):
).all() ).all()
ne_by_id = {ne.apprenti_id: json.loads(ne.donnees_json) for ne, _ in ne_rows} 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 = [] data = []
for apprenti in apprentis: for apprenti in apprentis:
abs_data = abs_by_name.get((apprenti.nom, apprenti.prenom)) 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 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 non_exc = int(abs_data["NON excusées"]) if abs_data is not None else 0
blocs = nb_blocs_absences(sess, apprenti.id) 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 HTML
bn = bn_by_id.get(apprenti.id) bn = bn_by_id.get(apprenti.id)
@ -570,6 +617,7 @@ class ClasseState(AuthState):
has_notes = False has_notes = False
notes_html = "" notes_html = ""
notices = notices_by_id.get(apprenti.id, [])
data.append({ data.append({
"id": apprenti.id, "id": apprenti.id,
"nom": apprenti.nom, "nom": apprenti.nom,
@ -584,6 +632,8 @@ class ClasseState(AuthState):
"bn_caption": bn_caption if has_bn else "", "bn_caption": bn_caption if has_bn else "",
"has_notes": has_notes, "has_notes": has_notes,
"notes_html": notes_html, "notes_html": notes_html,
"has_notices": len(notices) > 0,
"notices": notices,
"has_pdf_bn": bn_pdf_exists, "has_pdf_bn": bn_pdf_exists,
"has_pdf_notes": notes_pdf_exists, "has_pdf_notes": notes_pdf_exists,
}) })
@ -622,7 +672,7 @@ def _classe_searchable_select() -> rx.Component:
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)", border="1px solid var(--gray-7)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
cursor="pointer", cursor="pointer",
width="100%", width="100%",
custom_attrs={"data-shortcut": "class-search"}, 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(label, size="1", color="#888"),
rx.text(value, size="5", font_weight="700", color=color, class_name="tabular"), rx.text(value, size="5", font_weight="700", color=color, class_name="tabular"),
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
background_color="#f8f9fa", background_color="var(--surface-muted)",
border_radius="6px", border_radius="6px",
border="1px solid #e9ecef", border="1px solid #e9ecef",
min_width="80px", min_width="80px",
@ -681,13 +731,14 @@ def _apprenti_card(item) -> rx.Component:
return rx.box( return rx.box(
# ── En-tête : nom + badge quota ─────────────────────────────────────── # ── En-tête : nom + badge quota ───────────────────────────────────────
rx.hstack( rx.hstack(
rx.link( rx.box(
rx.text( rx.text(
item["prenom"], " ", item["nom"], item["prenom"], " ", item["nom"],
size="4", font_weight="700", color="#1a237e", size="4", font_weight="700", color="#1a237e",
), ),
href="/fiche", on_click=ClasseState.open_apprenti(item["id"]),
text_decoration="none", cursor="pointer",
_hover={"text_decoration": "underline"},
), ),
rx.cond( rx.cond(
item["quota_atteint"], item["quota_atteint"],
@ -757,11 +808,12 @@ def _apprenti_card(item) -> rx.Component:
margin_bottom="0.75rem", margin_bottom="0.75rem",
), ),
# ── Onglets BN / Notes ──────────────────────────────────────────────── # ── Onglets BN / Notes / Notices ──────────────────────────────────────
rx.tabs.root( rx.tabs.root(
rx.tabs.list( rx.tabs.list(
rx.tabs.trigger("Cours professionnels", value="bn"), rx.tabs.trigger("Cours professionnels", value="bn"),
rx.tabs.trigger("Notes d'examen", value="notes"), rx.tabs.trigger("Notes d'examen", value="notes"),
rx.tabs.trigger("Notices", value="notices"),
), ),
rx.tabs.content( rx.tabs.content(
rx.cond( rx.cond(
@ -801,14 +853,45 @@ def _apprenti_card(item) -> rx.Component:
width="100%", width="100%",
padding_top="0.75rem", 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", default_value="bn",
width="100%", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
overflow="hidden", overflow="hidden",
class_name="hover-lift anim-fade", class_name="hover-lift anim-fade",

View file

@ -574,7 +574,7 @@ def _job_row(job: rx.Var) -> rx.Component:
), ),
), ),
padding="0.75rem 1rem", padding="0.75rem 1rem",
background_color="white", background_color="var(--surface)",
border="1px solid var(--gray-5)", border="1px solid var(--gray-5)",
border_radius="6px", border_radius="6px",
width="100%", width="100%",

View file

@ -76,7 +76,7 @@ def _content() -> rx.Component:
rx.heading(DocState.selected_title, size="6", margin_bottom="1rem"), rx.heading(DocState.selected_title, size="6", margin_bottom="1rem"),
rx.html(DocState.selected_html, class_name="doc-content"), rx.html(DocState.selected_html, class_name="doc-content"),
padding="1.5rem 2rem", padding="1.5rem 2rem",
background_color="white", background_color="var(--surface)",
border="1px solid var(--gray-5)", border="1px solid var(--gray-5)",
border_radius="8px", border_radius="8px",
width="100%", width="100%",

View file

@ -1289,7 +1289,7 @@ def _classe_multi_select_escada() -> rx.Component:
padding="0.45rem 0.6rem", padding="0.45rem 0.6rem",
border="2px solid var(--red-7)", border="2px solid var(--red-7)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
cursor="pointer", cursor="pointer",
width="100%", width="100%",
max_width="640px", max_width="640px",
@ -1335,16 +1335,16 @@ def _log_box() -> rx.Component:
rx.text( rx.text(
EscadaState.op_log, EscadaState.op_log,
size="1", size="1",
color="#37474f", color="var(--text-strong)",
white_space="pre", white_space="pre",
font_family="'Courier New', monospace", font_family="'Courier New', monospace",
), ),
max_height="240px", max_height="240px",
overflow_y="auto", overflow_y="auto",
overflow_x="auto", overflow_x="auto",
background_color="#f8f9fa", background_color="var(--surface-muted)",
border_radius="6px", border_radius="6px",
border="1px solid #dee2e6", border="1px solid var(--border)",
padding="0.75rem", padding="0.75rem",
width="100%", width="100%",
margin_top="0.75rem", margin_top="0.75rem",
@ -1356,7 +1356,7 @@ def _result_list(label: str, items, row_fn) -> rx.Component:
return rx.cond( return rx.cond(
items.length() > 0, items.length() > 0,
rx.vstack( 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), rx.foreach(items, row_fn),
spacing="1", spacing="1",
), ),
@ -1637,7 +1637,7 @@ def escada_page() -> rx.Component:
rx.box( rx.box(
rx.text( rx.text(
"Synchronisation depuis Escada", "Synchronisation depuis Escada",
size="3", font_weight="700", color="#37474f", size="3", font_weight="700", color="var(--text-strong)",
margin_bottom="0.75rem", margin_bottom="0.75rem",
), ),
@ -1685,11 +1685,11 @@ def escada_page() -> rx.Component:
# ── Formulaire sync ──────────────────────────────────────── # ── Formulaire sync ────────────────────────────────────────
rx.vstack( rx.vstack(
# Sélection des classes — multi-select style Streamlit # 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(), _classe_multi_select_escada(),
# Options de sync # 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.flex( rx.flex(
rx.checkbox(checked=EscadaState.sync_abs, rx.checkbox(checked=EscadaState.sync_abs,
@ -1823,9 +1823,9 @@ def escada_page() -> rx.Component:
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
), ),
@ -1833,7 +1833,7 @@ def escada_page() -> rx.Component:
rx.box( rx.box(
rx.text( rx.text(
"Pousser les absences en attente sur Escada", "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", margin_bottom="0.75rem",
), ),
@ -1923,9 +1923,9 @@ def escada_page() -> rx.Component:
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
), ),
@ -1933,7 +1933,7 @@ def escada_page() -> rx.Component:
rx.box( rx.box(
rx.text( rx.text(
"Pousser les notices en attente sur Escada", "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", margin_bottom="0.75rem",
), ),
rx.cond( rx.cond(
@ -2021,9 +2021,9 @@ def escada_page() -> rx.Component:
), ),
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
), ),

View file

@ -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" TD = "border:1px solid #dee2e6;padding:5px 10px"
TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa" TH = "border:1px solid #dee2e6;padding:5px 10px;text-align:center;background:#f8f9fa"
SEP = ";border-top:3px solid #9e9e9e" 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'<th style="{TH};text-align:left;min-width:230px"></th>' header = f'<th style="{TH};text-align:left;min-width:230px"></th>'
for i in range(N): 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): def _moy_sem_row(label, gd, label_style, sep=False):
s = SEP if sep else "" s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>' cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for i in range(N): for i in range(N):
v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None v = gd["moy_sem"][i] if i < len(gd.get("moy_sem", [])) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>' cells += f'<td style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>" return f"<tr>{cells}</tr>"
def _moy_ann_row(label, gd, label_style, sep=False): def _moy_ann_row(label, gd, label_style, sep=False):
s = SEP if sep else "" s = SEP if sep else ""
cells = f'<td style="{label_style}{s}">{label}</td>' cells = f'<td style="{label_style};{MOY_BG}{s}">{label}</td>'
for year_start in range(0, N, 2): for year_start in range(0, N, 2):
v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None v = gd["moy_ann"][year_start] if year_start < len(gd.get("moy_ann", [])) else None
cells += f'<td colspan="2" style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>' cells += f'<td colspan="2" style="{_bn_cell_style(v)};{MOY_BG}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>" return f"<tr>{cells}</tr>"
def _branch_row(branche, sep=False):
s = SEP if sep else ""
cells = f'<td style="{TD}{s}">{branche["nom"]}</td>'
notes = branche.get("notes") or [None] * N
for i in range(N):
v = notes[i] if i < len(notes) else None
cells += f'<td style="{_bn_cell_style(v)}{s}">{_bn_fmt(v)}</td>'
return f"<tr>{cells}</tr>"
def _group_header_row(label, sep=False):
s = SEP if sep else ""
return (
f'<tr><td colspan="{N + 1}" style="{TD};font-weight:bold;'
f'background:#f0f0f0{s}">{label}</td></tr>'
)
body = "" body = ""
for grp in groups_order: for grp in groups_order:
gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N}) gd = d["groupes"].get(grp, {"moy_sem": [None] * N, "moy_ann": [None] * N})
lbl = _GROUP_LABELS.get(grp, grp) 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_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) 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_email_val: str = ""
fiche_date_naissance: str = "" fiche_date_naissance: str = ""
fiche_majeur: str = "" fiche_majeur: str = ""
fiche_compensation: str = ""
fiche_entreprise_nom: str = "" fiche_entreprise_nom: str = ""
fiche_entreprise_adresse: str = "" fiche_entreprise_adresse: str = ""
fiche_entreprise_cp_localite: 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_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_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
self.kpi_blocs = nb_blocs_absences(sess, self.selected_id) 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
fiche = sess.execute( fiche = sess.execute(
@ -991,6 +1021,12 @@ class FicheState(AuthState):
("Majeur : oui" if fiche.majeur else "Majeur : non") ("Majeur : oui" if fiche.majeur else "Majeur : non")
if fiche.majeur is not None else "" 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_nom = fiche.entreprise_nom or ""
self.fiche_entreprise_adresse = fiche.entreprise_adresse or "" self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
self.fiche_entreprise_cp_localite = ( self.fiche_entreprise_cp_localite = (
@ -1009,6 +1045,7 @@ class FicheState(AuthState):
for attr in [ for attr in [
"fiche_adresse", "fiche_cp_localite", "fiche_telephone", "fiche_adresse", "fiche_cp_localite", "fiche_telephone",
"fiche_email_val", "fiche_date_naissance", "fiche_majeur", "fiche_email_val", "fiche_date_naissance", "fiche_majeur",
"fiche_compensation",
"fiche_entreprise_nom", "fiche_entreprise_adresse", "fiche_entreprise_nom", "fiche_entreprise_adresse",
"fiche_entreprise_cp_localite", "fiche_entreprise_telephone", "fiche_entreprise_cp_localite", "fiche_entreprise_telephone",
"fiche_entreprise_email", "fiche_formateur_nom", "fiche_entreprise_email", "fiche_formateur_nom",
@ -1230,7 +1267,7 @@ def _apprenti_searchable_select() -> rx.Component:
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)", border="1px solid var(--gray-7)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
cursor="pointer", cursor="pointer",
width="100%", width="100%",
custom_attrs={"data-shortcut": "apprenti-search"}, 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(label, size="1", color="#666"),
rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"), rx.text(value, size="7", font_weight="700", color=color, class_name="tabular"),
padding="1rem", padding="1rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
flex="1", flex="1",
min_width="120px", min_width="120px",
class_name="hover-lift", class_name="hover-lift",
@ -1379,7 +1416,7 @@ def _edit_panel() -> rx.Component:
rx.icon("pencil", size=15, color="var(--brand-accent)"), rx.icon("pencil", size=15, color="var(--brand-accent)"),
rx.text( rx.text(
"Édition du ", FicheState.edit_date_label, "Édition du ", FicheState.edit_date_label,
size="3", weight="bold", color="#37474f", size="3", weight="bold", color="var(--text-strong)",
), ),
rx.spacer(), rx.spacer(),
rx.button( rx.button(
@ -1442,9 +1479,9 @@ def _edit_panel() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1rem", padding="1rem",
background_color="#f0f7ff", background_color="var(--brand-accent-soft)",
border_radius="8px", border_radius="8px",
border="1px solid #bfdbfe", border="1px solid var(--border)",
width="100%", width="100%",
class_name="anim-slide-down", class_name="anim-slide-down",
) )
@ -1509,9 +1546,9 @@ def _actions_row() -> rx.Component:
width="100%", width="100%",
), ),
padding="0.75rem 1rem", padding="0.75rem 1rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -1531,8 +1568,8 @@ def _email_section() -> rx.Component:
return rx.box( return rx.box(
rx.vstack( rx.vstack(
rx.hstack( rx.hstack(
rx.icon("mail", size=16, color="#37474f"), rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"), rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
spacing="2", align="center", spacing="2", align="center",
), ),
rx.divider(), rx.divider(),
@ -1677,9 +1714,9 @@ def _email_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1rem", padding="1rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -1744,17 +1781,18 @@ def fiche_page() -> rx.Component:
rx.vstack( rx.vstack(
rx.flex( rx.flex(
rx.vstack( 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_adresse),
_info_line("map-pin", FicheState.fiche_cp_localite), _info_line("map-pin", FicheState.fiche_cp_localite),
_info_line("phone", FicheState.fiche_telephone), _info_line("phone", FicheState.fiche_telephone),
_info_line("mail", FicheState.fiche_email_val), _info_line("mail", FicheState.fiche_email_val),
_info_line("cake", FicheState.fiche_date_naissance), _info_line("cake", FicheState.fiche_date_naissance),
_info_line("user-check", FicheState.fiche_majeur), _info_line("user-check", FicheState.fiche_majeur),
_info_line("scale", FicheState.fiche_compensation),
spacing="1", align="start", flex="1", min_width="200px", spacing="1", align="start", flex="1", min_width="200px",
), ),
rx.vstack( 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("building-2", FicheState.fiche_entreprise_nom),
_info_line("map-pin", FicheState.fiche_entreprise_adresse), _info_line("map-pin", FicheState.fiche_entreprise_adresse),
_info_line("map-pin", FicheState.fiche_entreprise_cp_localite), _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", spacing="1", align="start", flex="1", min_width="200px",
), ),
rx.vstack( 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("user", FicheState.fiche_formateur_nom),
_info_line("mail", FicheState.fiche_formateur_email), _info_line("mail", FicheState.fiche_formateur_email),
spacing="1", align="start", flex="1", min_width="200px", spacing="1", align="start", flex="1", min_width="200px",
@ -1782,9 +1820,9 @@ def fiche_page() -> rx.Component:
), ),
), ),
padding="1rem", padding="1rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
), ),
@ -1859,9 +1897,9 @@ def fiche_page() -> rx.Component:
default_value="bn", width="100%", default_value="bn", width="100%",
), ),
padding="1rem", padding="1rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
), ),
@ -1877,7 +1915,7 @@ def fiche_page() -> rx.Component:
), ),
rx.text( rx.text(
FicheState.cal_month_name, 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", flex="1", text_align="center",
), ),
rx.button( rx.button(
@ -1916,9 +1954,9 @@ def fiche_page() -> rx.Component:
size="1", color="#9e9e9e", margin_top="0.25rem", size="1", color="#9e9e9e", margin_top="0.25rem",
), ),
padding="1rem", padding="1rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
), ),
rx.text("Aucune absence enregistrée.", size="2", color="#666"), rx.text("Aucune absence enregistrée.", size="2", color="#666"),
@ -1944,9 +1982,9 @@ def fiche_page() -> rx.Component:
spacing="2", align="center", spacing="2", align="center",
), ),
padding="0.75rem 1rem", padding="0.75rem 1rem",
background_color="#f9fafb", background_color="var(--surface-muted)",
border_radius="8px", border_radius="8px",
border="1px solid #e5e7eb", border="1px solid var(--border-soft)",
width="100%", width="100%",
), ),
), ),

View file

@ -250,9 +250,9 @@ def _section(title: str, *children) -> rx.Component:
width="100%", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -535,7 +535,7 @@ def _mapping_row(m: rx.Var) -> rx.Component:
padding="0.4rem 0.6rem", padding="0.4rem 0.6rem",
border="1px solid var(--gray-5)", border="1px solid var(--gray-5)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
width="100%", width="100%",
) )

View file

@ -277,9 +277,9 @@ def _avatar_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -337,9 +337,9 @@ def _info_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -393,18 +393,19 @@ def _password_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
_THEMES = [ _THEMES = [
("eptm", "EPTM (rouge)", "#dc000e"), ("eptm", "EPTM (rouge)", "#dc000e"),
("bleu", "Bleu corporate", "#1565c0"), ("bleu", "Bleu corporate", "#1565c0"),
("indigo", "Indigo nuit", "#3f51b5"), ("indigo", "Indigo nuit", "#3f51b5"),
("vert", "Vert académique","#2e7d32"), ("vert", "Vert académique","#2e7d32"),
("sombre", "Sombre (dark)", "#1a1a1a"),
] ]
@ -458,9 +459,9 @@ def _theme_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -511,9 +512,9 @@ def _totp_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )

View file

@ -365,7 +365,7 @@ def _classe_selector() -> rx.Component:
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)", border="1px solid var(--gray-7)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
cursor="pointer", cursor="pointer",
width="100%", width="100%",
custom_attrs={"data-shortcut": "purge-search"}, 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(label, size="1", color="#666"),
rx.text(value, size="5", font_weight="700", color=color), rx.text(value, size="5", font_weight="700", color=color),
padding="0.6rem 0.85rem", padding="0.6rem 0.85rem",
background_color="white", background_color="var(--surface)",
border="1px solid #e0e0e0", border="1px solid var(--border)",
border_radius="6px", border_radius="6px",
min_width="110px", min_width="110px",
text_align="center", text_align="center",
@ -427,7 +427,7 @@ def _preview_panel() -> rx.Component:
rx.vstack( rx.vstack(
rx.text( rx.text(
"Données qui seront supprimées :", "Données qui seront supprimées :",
size="2", weight="bold", color="#37474f", size="2", weight="bold", color="var(--text-strong)",
), ),
rx.flex( rx.flex(
_kpi("Apprentis", PurgeState.pv_apprentis, "var(--brand-primary-dark)"), _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"), lambda f: rx.text("", f, size="1", color="#666"),
), ),
padding="0.6rem 0.75rem", padding="0.6rem 0.75rem",
background_color="#fafafa", background_color="var(--surface-soft)",
border_radius="6px", border_radius="6px",
border="1px solid #eee", border="1px solid #eee",
width="100%", width="100%",

View file

@ -499,7 +499,7 @@ def _apprenti_selector() -> rx.Component:
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)", border="1px solid var(--gray-7)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
cursor="pointer", cursor="pointer",
width="100%", width="100%",
custom_attrs={"data-shortcut": "apprenti-search"}, custom_attrs={"data-shortcut": "apprenti-search"},
@ -570,7 +570,7 @@ def _branche_selector() -> rx.Component:
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
border="1px solid var(--gray-7)", border="1px solid var(--gray-7)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
cursor="pointer", cursor="pointer",
width="100%", width="100%",
), ),
@ -637,7 +637,7 @@ def _form() -> rx.Component:
rx.box( rx.box(
rx.flex( rx.flex(
rx.icon("user", size=16, color="var(--brand-accent)"), 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", gap="0.5rem", align="center",
), ),
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",
@ -801,8 +801,8 @@ def _email_section() -> rx.Component:
return rx.box( return rx.box(
rx.vstack( rx.vstack(
rx.flex( rx.flex(
rx.icon("mail", size=16, color="#37474f"), rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"), rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center", gap="0.5rem", align="center",
), ),
rx.divider(), rx.divider(),
@ -857,9 +857,9 @@ def _email_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )

View file

@ -287,8 +287,8 @@ def _texte_section() -> rx.Component:
return rx.box( return rx.box(
rx.vstack( rx.vstack(
rx.flex( rx.flex(
rx.icon("file-text", size=16, color="#37474f"), rx.icon("file-text", size=16, color="var(--text-strong)"),
rx.text("Contenu de l'avis", size="3", weight="bold", color="#37474f"), rx.text("Contenu de l'avis", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center", gap="0.5rem", align="center",
), ),
rx.divider(), rx.divider(),
@ -318,9 +318,9 @@ def _texte_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -371,8 +371,8 @@ def _email_section() -> rx.Component:
return rx.box( return rx.box(
rx.vstack( rx.vstack(
rx.flex( rx.flex(
rx.icon("mail", size=16, color="#37474f"), rx.icon("mail", size=16, color="var(--text-strong)"),
rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"), rx.text("Envoyer par email", size="3", weight="bold", color="var(--text-strong)"),
gap="0.5rem", align="center", gap="0.5rem", align="center",
), ),
rx.divider(), rx.divider(),
@ -427,9 +427,9 @@ def _email_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -454,7 +454,7 @@ def sanction_modal() -> rx.Component:
rx.box( rx.box(
rx.flex( rx.flex(
rx.icon("user", size=16, color="#c62828"), 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", gap="0.5rem", align="center",
), ),
padding="0.5rem 0.75rem", padding="0.5rem 0.75rem",

View file

@ -770,7 +770,7 @@ def _classes_multi_select() -> rx.Component:
padding="0.45rem 0.6rem", padding="0.45rem 0.6rem",
border="1px solid var(--gray-7)", border="1px solid var(--gray-7)",
border_radius="6px", border_radius="6px",
background_color="white", background_color="var(--surface)",
cursor="pointer", cursor="pointer",
width="100%", width="100%",
), ),
@ -1064,9 +1064,9 @@ def _add_user_section() -> rx.Component:
spacing="3", width="100%", spacing="3", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
) )
@ -1085,9 +1085,9 @@ def users_page() -> rx.Component:
spacing="2", width="100%", spacing="2", width="100%",
), ),
padding="1.25rem", padding="1.25rem",
background_color="white", background_color="var(--surface)",
border_radius="8px", border_radius="8px",
border="1px solid #e0e0e0", border="1px solid var(--border)",
width="100%", width="100%",
), ),
_edit_panel(), _edit_panel(),

View file

@ -10,14 +10,13 @@ FULL_W = "240px"
RAIL_W = "68px" RAIL_W = "68px"
TOPBAR_H = "56px" TOPBAR_H = "56px"
# Sidebar palette : couleurs neutres locales + tokens de marque (cf. responsive.css). # Sidebar palette — utilise les tokens de marque (cf. responsive.css).
_BG = "#f8f9fa" # sidebar background (light) _BG = "var(--surface-muted)" # sidebar background
_BORDER = "#e5e7eb" # subtle separator _BORDER = "var(--border-soft)" # subtle separator
_TEXT = "#4b5563" # inactive text _TEXT = "var(--text-soft)" # inactive text
_TEXT_MUTED = "#9ca3af" # muted labels _TEXT_MUTED = "var(--text-muted)" # muted labels
_HOVER_BG = "#f3f4f6" _HOVER_BG = "var(--surface-hover)"
_USER_BG = "#f3f4f6" # slightly darker user section _USER_BG = "var(--surface-hover)" # slightly darker user section
# Tokens dynamiques (changent selon le thème user)
_ACTIVE_BG = "var(--brand-primary-tint)" _ACTIVE_BG = "var(--brand-primary-tint)"
_ACTIVE_CLR = "var(--brand-primary-light)" _ACTIVE_CLR = "var(--brand-primary-light)"
@ -537,7 +536,9 @@ def layout(content: rx.Component) -> rx.Component:
"content-area", "content-area",
), ),
padding=rx.cond(AuthState.sidebar_collapsed, "1rem", "1.5rem"), 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", overflow_x="hidden",
transition="margin-left 0.22s ease, width 0.22s ease", transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box", box_sizing="border-box",

View file

@ -144,7 +144,7 @@ class AuthState(rx.State):
def set_theme(self, value: str): def set_theme(self, value: str):
"""Change le thème de couleur (persiste en LocalStorage et auth.yaml).""" """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" value = "eptm"
self.theme = value self.theme = value
# Persister dans auth.yaml pour synchronisation multi-device. # Persister dans auth.yaml pour synchronisation multi-device.

View file

@ -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) # Déplier + lire une ligne à la fois (Escada ne gère pas les AJAX simultanés)
fiches: list[dict] = [] fiches: list[dict] = []
for i in range(n): 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 # Clic sur le bouton expand de la ligne i
clicked = page.evaluate("""([gid, i]) => { clicked = page.evaluate("""([gid, i]) => {
const row = document.getElementById(`${gid}_DXDataRow${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: if raw:
fiche = _parse_fiche_text(raw) fiche = _parse_fiche_text(raw)
fiche["compensation_desavantages"] = compensation
if fiche.get('nom_eleve') or fiche.get('entreprise_nom'): if fiche.get('nom_eleve') or fiche.get('entreprise_nom'):
fiches.append(fiche) 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: else:
_log(f" [fiches] {i}: WARNING données vides — raw[:80]={raw[:80]!r}") _log(f" [fiches] {i}: WARNING données vides — raw[:80]={raw[:80]!r}")
else: else:

View file

@ -176,6 +176,9 @@ class ApprentiFiche(Base):
email: Mapped[Optional[str]] = mapped_column(String, nullable=True) email: Mapped[Optional[str]] = mapped_column(String, nullable=True)
date_naissance: 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) 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
entreprise_nom: Mapped[Optional[str]] = mapped_column(String, nullable=True) 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 sanctions_export ADD COLUMN nb_absences INTEGER",
"ALTER TABLE cron_jobs ADD COLUMN notify_level TEXT DEFAULT 'normal'", "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 profession TEXT",
"ALTER TABLE apprenti_fiches ADD COLUMN compensation_desavantages BOOLEAN",
"ALTER TABLE cron_jobs ADD COLUMN sync_notices BOOLEAN DEFAULT 0", "ALTER TABLE cron_jobs ADD COLUMN sync_notices BOOLEAN DEFAULT 0",
# Migration cron task_kind — schéma 3 valeurs + checkbox sync_notices. # 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 # É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() ).scalar_one_or_none()
fields = [ fields = [
"adresse", "code_postal", "localite", "telephone", "email", "adresse", "code_postal", "localite", "telephone", "email",
"date_naissance", "majeur", "date_naissance", "majeur", "compensation_desavantages",
"entreprise_nom", "entreprise_adresse", "entreprise_code_postal", "entreprise_nom", "entreprise_adresse", "entreprise_code_postal",
"entreprise_localite", "entreprise_telephone", "entreprise_email", "entreprise_localite", "entreprise_telephone", "entreprise_email",
"formateur_nom", "formateur_email", "formateur_nom", "formateur_email",

View file

@ -7,6 +7,7 @@ Two PDF variants:
groups: Branches professionnelles (BP) + Travaux pratiques (TP) groups: Branches professionnelles (BP) + Travaux pratiques (TP)
Extracted rows (per group): Extracted rows (per group):
- Branches individuelles branches: [{"nom": str, "notes": [...]}]
- Moyenne semestrielle du groupe moy_sem[0..7] - 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) - 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 "", "" 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: 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: for tbl in tables:
if not tbl or len(tbl) < 4: if not tbl or len(tbl) < 4:
continue continue
@ -129,8 +175,7 @@ def parse_bn_page(page) -> dict | None:
nom, prenom = _extract_name(page) nom, prenom = _extract_name(page)
tables = page.extract_tables() table_obj, bn_table = _find_bn_table_obj(page)
bn_table = _find_bn_table(tables)
if not bn_table: if not bn_table:
return None return None
@ -143,14 +188,61 @@ def parse_bn_page(page) -> dict | None:
while len(sem_labels) < 8: while len(sem_labels) < 8:
sem_labels.append(None) sem_labels.append(None)
table_rows = table_obj.rows # bbox-aware rows, indexed comme bn_table
# Parse data rows # Parse data rows
current_group: str | None = None current_group: str | None = None
groups: dict[str, dict] = {} groups: dict[str, dict] = {}
globale: dict[str, list] = {"moy_sem": [None] * 8, "moy_ann": [None] * 8} 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]: if not row or not row[0]:
continue continue
table_row = table_rows[idx] if idx < len(table_rows) else None
label = str(row[0]).strip() label = str(row[0]).strip()
vals = [ vals = [
_to_float(row[i + 1]) if (i + 1) < len(row) else None _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() 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" current_group = "CG"
groups.setdefault("CG", {"moy_sem": [None] * 8, "moy_ann": [None] * 8}) groups.setdefault("CG", _empty_group())
elif "branches professionnelles" in low: elif is_group_header and "branches professionnelles" in low:
current_group = "BP" current_group = "BP"
groups.setdefault("BP", {"moy_sem": [None] * 8, "moy_ann": [None] * 8}) groups.setdefault("BP", _empty_group())
elif "travaux pratiques" in low: elif is_group_header and "travaux pratiques" in low:
current_group = "TP" 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: elif "moyenne semestrielle du groupe" in low and current_group:
groups[current_group]["moy_sem"] = vals groups[current_group]["moy_sem"] = vals
elif "moyenne annuelle du groupe" in low and current_group: 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 globale["moy_sem"] = vals
elif "moyenne annuelle globale" in low: elif "moyenne annuelle globale" in low:
globale["moy_ann"] = vals 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: if not groups:
return None return None