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