ajouté import du statut des désavantages, affichage de toutes les notes du BN.
This commit is contained in:
parent
38189deb0f
commit
7431339ce5
21 changed files with 796 additions and 188 deletions
295
DEPLOY_PROD.md
Normal file
295
DEPLOY_PROD.md
Normal 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)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
```
|
||||||
8
TODO.md
8
TODO.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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,30 +153,23 @@ 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.flex(
|
|
||||||
rx.text(
|
rx.text(
|
||||||
item["nom"], " ", item["prenom"],
|
item["nom"], " ", item["prenom"],
|
||||||
size="3", weight="bold", color="#1a237e",
|
size="2", color="#1a237e",
|
||||||
|
white_space="nowrap", overflow="hidden",
|
||||||
|
text_overflow="ellipsis",
|
||||||
|
flex="1", min_width="0",
|
||||||
),
|
),
|
||||||
rx.spacer(),
|
|
||||||
rx.box(
|
|
||||||
rx.flex(
|
rx.flex(
|
||||||
rx.icon("triangle-alert", size=12, color="#B71C1C"),
|
rx.icon("triangle-alert", size=11, color="#B71C1C"),
|
||||||
rx.text(
|
rx.text(item["absences"], size="1", color="#B71C1C", weight="bold"),
|
||||||
item["absences"], " abs.",
|
gap="0.2rem", align="center",
|
||||||
size="1", color="#B71C1C", weight="bold",
|
|
||||||
),
|
|
||||||
gap="0.25rem", align="center",
|
|
||||||
),
|
|
||||||
background_color="#ffe5e5",
|
background_color="#ffe5e5",
|
||||||
padding="0.15rem 0.5rem",
|
padding="0.1rem 0.4rem",
|
||||||
border_radius="9999px",
|
border_radius="9999px",
|
||||||
flex_shrink="0",
|
flex_shrink="0",
|
||||||
),
|
),
|
||||||
width="100%", align="center", gap="0.5rem", wrap="wrap",
|
|
||||||
),
|
|
||||||
rx.button(
|
rx.button(
|
||||||
rx.icon("file-plus", size=13),
|
rx.icon("file-plus", size=13),
|
||||||
"Créer l'avis de sanction",
|
"Créer l'avis de sanction",
|
||||||
|
|
@ -182,22 +177,20 @@ def _sanction_tile(item: rx.Var) -> rx.Component:
|
||||||
item["id"], item["nom"], item["prenom"], item["classe"],
|
item["id"], item["nom"], item["prenom"], item["classe"],
|
||||||
).stop_propagation,
|
).stop_propagation,
|
||||||
size="1",
|
size="1",
|
||||||
color_scheme="red",
|
color_scheme="gray",
|
||||||
variant="soft",
|
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%",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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%",
|
||||||
|
|
|
||||||
|
|
@ -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%",
|
||||||
|
|
|
||||||
|
|
@ -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%",
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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%",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,9 +393,9 @@ 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%",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -405,6 +405,7 @@ _THEMES = [
|
||||||
("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%",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%",
|
||||||
|
|
|
||||||
|
|
@ -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%",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
125
src/parser_bn.py
125
src/parser_bn.py
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue