upgrade css + ajout documentation
This commit is contained in:
parent
ee4e212f7d
commit
f17041be18
24 changed files with 1405 additions and 47 deletions
|
|
@ -70,3 +70,173 @@ img {
|
|||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Animations ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.anim-fade {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.anim-slide-down {
|
||||
animation: slideDown 0.22s ease-out;
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.22s ease-out;
|
||||
}
|
||||
|
||||
.anim-scale-in {
|
||||
animation: scaleIn 0.18s ease-out;
|
||||
}
|
||||
|
||||
/* ── Interactive hover lift ─────────────────────────────────────────────── */
|
||||
|
||||
.hover-lift {
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.anim-pulse {
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Subtle press effect on cards/buttons */
|
||||
.hover-press {
|
||||
transition: transform 0.12s ease, background-color 0.15s ease;
|
||||
}
|
||||
.hover-press:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.hover-press:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
/* Smooth transition default for buttons and cards */
|
||||
.smooth-transition {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
/* ── Documentation rendered markdown ───────────────────────────────────── */
|
||||
|
||||
.doc-content { line-height: 1.65; color: #1f2937; }
|
||||
.doc-content h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.doc-content h3 {
|
||||
font-size: 1.15rem;
|
||||
margin-top: 1.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-content p { margin: 0 0 0.75rem 0; }
|
||||
.doc-content ul, .doc-content ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
.doc-content li { margin-bottom: 0.25rem; }
|
||||
.doc-content code {
|
||||
background-color: var(--gray-3);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.88em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.doc-content pre {
|
||||
background-color: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
margin: 0.75rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.doc-content pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.doc-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.93em;
|
||||
}
|
||||
.doc-content th, .doc-content td {
|
||||
border: 1px solid var(--gray-6);
|
||||
padding: 0.4rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
.doc-content th {
|
||||
background-color: var(--gray-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-content blockquote {
|
||||
border-left: 3px solid var(--red-7);
|
||||
padding: 0.25rem 0.75rem;
|
||||
color: var(--gray-11);
|
||||
margin: 0.75rem 0;
|
||||
background-color: var(--gray-2);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.doc-content a {
|
||||
color: var(--red-11);
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-content a:hover { text-decoration: underline; }
|
||||
.doc-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-5);
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
/* Respect users who prefer reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-fade,
|
||||
.anim-slide-down,
|
||||
.anim-slide-up,
|
||||
.anim-scale-in {
|
||||
animation: none;
|
||||
}
|
||||
.hover-lift,
|
||||
.hover-press,
|
||||
.smooth-transition {
|
||||
transition: none;
|
||||
}
|
||||
.anim-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=5aae2a26-4117-4722-aee2-0bd823fbcfc8",
|
||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=23fb9e1d-adda-4399-accd-7a2f49e0cc93",
|
||||
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4587e860-dc1f-496a-84a6-dbf1f5d0a963",
|
||||
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=4d128fbe-f18c-4e3b-9ec2-beea462837be",
|
||||
"EM-AU 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=30066257-3dad-4b00-857b-9ab60a5d8581",
|
||||
"EM-AU 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b9363b96-2d6e-4009-a495-f26c036cc088",
|
||||
"MONTAUT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=b51ec970-5bf4-4982-a05e-80546bb7421f",
|
||||
"MONTAUT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=aaacc343-c248-4f21-93f6-5d9e3079aa5d",
|
||||
"MONTAUT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=18c1ddbe-471f-44f6-bde6-8619adc3b767",
|
||||
"EM-AU 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=a4c4c187-920c-4c91-9620-7f153cf3738a",
|
||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=7bddb158-278f-4258-934d-eddb7de88af3"
|
||||
"EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=901ba9c8-5bb8-4170-a28e-ad1bdc8dccac",
|
||||
"AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=553e320e-a5e6-484f-bbf3-989301a15449",
|
||||
"AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dd16c8ed-fde7-4aa6-bbce-9cba960b2863",
|
||||
"AUTOMAT 3:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=dcae994d-7c4e-4843-aa1c-0d44929b277c",
|
||||
"AUTOMAT 4:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=77f8b395-5054-4414-97d1-96d9d1cba981",
|
||||
"CFTI-AU 1A:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=1e858971-1c2f-4d8a-9a41-63c66716ee45"
|
||||
}
|
||||
42
data/docs/01-overview.md
Normal file
42
data/docs/01-overview.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Vue d'ensemble
|
||||
|
||||
EPTM Dashboard est une application de gestion des absences, notes et bulletins pour l'École professionnelle technique et des métiers (EPTM Sion / Monthey). Elle se synchronise avec **Escadaweb** (le système de notation cantonal) pour récupérer et pousser les données.
|
||||
|
||||
## À quoi sert l'application
|
||||
|
||||
- **Visualiser les absences** par apprenti ou par classe, avec calendrier mensuel et statistiques.
|
||||
- **Excuser ou modifier** les absences manuellement (les changements sont mis en file d'attente avant d'être renvoyés à Escada).
|
||||
- **Récupérer les bulletins de notes (BN)**, les notes d'examen et les fiches personnelles depuis Escada.
|
||||
- **Automatiser** les imports/exports via des tâches planifiées (cron) avec notifications Telegram.
|
||||
- **Tracer** qui a modifié quoi (audit log complet).
|
||||
|
||||
## Modèle de données simplifié
|
||||
|
||||
```
|
||||
Apprenti ── Absence (avec statut: a_traiter, excusee, ...)
|
||||
├── ApprentiFiche (données personnelles : adresse, entreprise, formateur)
|
||||
├── NotesBulletin (BN par semestre)
|
||||
├── NotesMatu (Matu pro)
|
||||
└── NotesExamen (notes d'examen finales)
|
||||
|
||||
EscadaPending : file d'attente des modifications locales à pousser vers Escada
|
||||
(action ∈ {"E", "N", "clear"})
|
||||
|
||||
Import / ImportBN / ImportMatu : trace des imports PDF effectués
|
||||
|
||||
CronJob : tâches planifiées (push, sync, push+sync)
|
||||
```
|
||||
|
||||
## Architecture technique
|
||||
|
||||
- **Frontend** : Reflex 0.9.2 (Python full-stack avec Radix Themes + Tailwind-friendly)
|
||||
- **DB** : SQLite en mode WAL, à `data/absences.db`
|
||||
- **Scraping Escada** : Selenium / Playwright, dans `scripts/sync_esacada.py` et `scripts/push_to_escada.py`
|
||||
- **Parsing PDF** : `src/parser.py` (absences), `src/parser_bn.py` (bulletins), `src/parser_matu.py` (matu)
|
||||
- **Conteneurisation** : Docker Compose, derrière nginx-proxy-manager
|
||||
- **Cron** : OS cron déclenche `scripts/cron_tick.py` toutes les minutes, qui consulte la table `CronJob`
|
||||
|
||||
## Rôles utilisateurs
|
||||
|
||||
- **user** : accès aux pages Tableau de bord, Apprentis, Classes (lecture + édition d'absences).
|
||||
- **admin** : tout ce qui précède + Escada, Cron, Logs, Utilisateurs, Paramètres.
|
||||
82
data/docs/02-sync-escada.md
Normal file
82
data/docs/02-sync-escada.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Synchronisation Escada (pull)
|
||||
|
||||
La synchronisation depuis Escada télécharge les PDFs (absences, BN, notes, fiches) et les importe en base. C'est l'opération la plus complexe de l'application.
|
||||
|
||||
## Page : `/escada`
|
||||
|
||||
### Sélection des classes
|
||||
|
||||
La liste des classes vient d'un cache disque (`data/esacada_classes.json`) rempli via le bouton **Actualiser**. Le rafraîchissement lance une session Selenium qui se connecte à Escadaweb et récupère la liste complète des classes.
|
||||
|
||||
> Note : MP, MI et "Formation" sont **filtrées de l'affichage UI** mais conservées dans le cache (elles servent au matching Matu pro).
|
||||
|
||||
### Options de synchronisation
|
||||
|
||||
| Option | Effet |
|
||||
|---------------------|----------------------------------------------------------------------|
|
||||
| Absences | Télécharge les PDFs d'absences + parse + import |
|
||||
| BN | Bulletins de notes + moyennes Matu (semestres complets) |
|
||||
| Notes | Notes d'examens finales |
|
||||
| Données apprentis | Fiches personnelles : adresse, entreprise, formateur |
|
||||
|
||||
### Force réimportation complète
|
||||
|
||||
La case "Forcer la réimportation complète" (signalée en jaune) **écrase tous les statuts d'absences** côté local par les valeurs du PDF, et **vide les pendings** des absences concernées.
|
||||
|
||||
À utiliser uniquement quand on veut **reprendre l'état complet d'Escada** (par exemple après un test ou une corruption locale).
|
||||
|
||||
Sans ce flag :
|
||||
- Les absences modifiées localement et **pas encore poussées** (= en pending) sont **préservées**.
|
||||
- Les nouvelles absences du PDF sont importées normalement.
|
||||
- Les absences orphelines (présentes en DB mais absentes du PDF) sont supprimées.
|
||||
|
||||
## Phases d'exécution
|
||||
|
||||
### Phase 1 : Scraping (Selenium)
|
||||
|
||||
`scripts/sync_esacada.py --sync-all CLASSE1 CLASSE2 ...`
|
||||
|
||||
1. Selenium ouvre Escadaweb avec un profil persistant (`data/browser_profile/`)
|
||||
2. Pour chaque classe sélectionnée :
|
||||
- Télécharge le PDF d'absences
|
||||
- Télécharge le PDF de bulletin
|
||||
- Télécharge le PDF de notes
|
||||
- Pour les apprentis Matu : télécharge le PDF Matu de la classe MP correspondante
|
||||
- Scrape les fiches personnelles (vue ViewLernende)
|
||||
3. À la fin, écrit `ALL_DONE` dans la sortie standard et `data/sync_all_done.json` (timestamp).
|
||||
|
||||
### Phase 2 : Import en DB
|
||||
|
||||
`scripts/run_imports.py` est lancé par le wrapper après réception du signal `ALL_DONE` :
|
||||
|
||||
1. Parse chaque PDF d'absences → upsert des `Absence` (avec déduplication sur (apprenti, date, période))
|
||||
2. Parse les BN → insère `NotesBulletin`
|
||||
3. Parse les notes → insère `NotesExamen`
|
||||
4. Parse les fiches → upsert `ApprentiFiche`
|
||||
5. Détecte les **orphelines** (absences en DB mais absentes du PDF dans la fenêtre temporelle) et les supprime (sauf si elles ont un pending, sans force).
|
||||
6. Écrit `data/sync_last_result.json` avec les compteurs détaillés.
|
||||
|
||||
## Pendings : modifications locales en attente
|
||||
|
||||
Quand un utilisateur modifie une absence dans l'application (page Apprenti), une entrée est créée dans la table `EscadaPending` avec une action :
|
||||
|
||||
- **`E`** : marquer comme excusée sur Escada
|
||||
- **`N`** : marquer comme non excusée sur Escada
|
||||
- **`clear`** : retirer l'absence sur Escada (= remettre l'apprenti présent)
|
||||
|
||||
Ces pendings sont visibles sur la page `/escada` dans la section "Modifications en attente".
|
||||
|
||||
## Cas particuliers gérés
|
||||
|
||||
| Situation | Sans force | Avec force |
|
||||
|----------------------------------------------------|---------------------------|---------------------------|
|
||||
| Absence dans PDF + pending sur la même | Pending préservé | Pending écrasé |
|
||||
| Absence dans PDF + DB sans pending | Mise à jour si différent | Mise à jour systématique |
|
||||
| Absence dans DB, absente du PDF, sans pending | Supprimée (orpheline) | Supprimée |
|
||||
| Absence dans DB, absente du PDF, avec pending=clear | Conservée (le clear gagne)| Supprimée |
|
||||
|
||||
## Diagnostic
|
||||
|
||||
- **Timeout > 15 min** : vérifier les logs `data/logs/operations.log`. Souvent un problème Selenium (captcha, session expirée).
|
||||
- **Aucune classe récupérée** : token de session Escada expiré → relancer le rafraîchissement.
|
||||
- **Logs détaillés** : page `/logs` affiche `operations.log` en temps réel.
|
||||
66
data/docs/03-push-escada.md
Normal file
66
data/docs/03-push-escada.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Push vers Escada
|
||||
|
||||
Le push envoie les modifications locales (table `EscadaPending`) vers Escadaweb via Selenium.
|
||||
|
||||
## Page : `/escada` → "Pousser vers Escada"
|
||||
|
||||
### Quand un pending est créé ?
|
||||
|
||||
Chaque modification d'absence dans l'application crée ou met à jour une entrée dans `EscadaPending` :
|
||||
|
||||
| Action utilisateur | Pending créé |
|
||||
|------------------------------------------|---------------------|
|
||||
| Marquer P3 comme excusée | `action=E` |
|
||||
| Marquer P5 comme non excusée | `action=N` |
|
||||
| Retirer une absence (présent) | `action=clear` |
|
||||
| Excuse rapide d'une journée (page Fiche) | `action=E` × n |
|
||||
|
||||
La contrainte d'unicité `(apprenti_id, date, periode)` garantit qu'une période a au plus un pending. Si on modifie deux fois la même période, le dernier pending écrase le précédent.
|
||||
|
||||
## Phases du push
|
||||
|
||||
### Phase 1 : Préparation
|
||||
|
||||
`scripts/push_to_escada.py` :
|
||||
|
||||
1. Lit toutes les entrées de `EscadaPending`
|
||||
2. Groupe par classe pour minimiser les navigations Escada
|
||||
3. Lance Selenium
|
||||
|
||||
### Phase 2 : Exécution Selenium
|
||||
|
||||
Pour chaque pending :
|
||||
|
||||
1. Navigue jusqu'à la page d'absences de l'apprenti dans Escadaweb
|
||||
2. Trouve la cellule (date × période)
|
||||
3. Selon l'action :
|
||||
- `E` : sélectionne "Excusée" dans le dropdown
|
||||
- `N` : sélectionne "Non excusée"
|
||||
- `clear` : remet à blanc (= apprenti présent)
|
||||
4. Clique sur **Speichern** (Enregistrer)
|
||||
5. Si OK → supprime l'entrée du `EscadaPending`
|
||||
6. Si erreur → conserve l'entrée et la liste les erreurs dans `PUSH_DONE`
|
||||
|
||||
### Phase 3 : Rapport
|
||||
|
||||
Le script imprime une ligne `PUSH_DONE {"ok": N, "err": [...]}` à la fin. L'app la parse et affiche :
|
||||
- Nombre d'envois OK
|
||||
- Liste des erreurs (chaque erreur mentionne l'apprenti, la date et la période)
|
||||
|
||||
## Que faire si un push échoue ?
|
||||
|
||||
1. **Vérifier les logs** (`/logs`) — l'erreur exacte est tracée.
|
||||
2. **Causes fréquentes** :
|
||||
- Session Escada expirée → relancer un Actualiser sur la page Escada (re-login automatique)
|
||||
- Apprenti avec un nom différent dans Escada → renommage à faire dans la DB ou côté Escada
|
||||
- Page de notation verrouillée par un collègue (Escada utilise des locks pessimistes)
|
||||
3. **Re-tenter** : les pendings restent en file d'attente, un nouveau push les retraitera.
|
||||
|
||||
## Audit
|
||||
|
||||
Chaque push manuel logue qui l'a déclenché : `[abs] {user} : Push Escada démarré par {username}`. Côté résultat :
|
||||
- `Push terminé — ok:N erreurs:M` dans `operations.log`
|
||||
|
||||
## Push automatique via cron
|
||||
|
||||
La tâche planifiée de type `push` ou `push_then_sync` exécute le même script. Voir la section [Tâches planifiées](#).
|
||||
76
data/docs/04-edition-absences.md
Normal file
76
data/docs/04-edition-absences.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Édition des absences
|
||||
|
||||
## Page : `/fiche` (Apprentis)
|
||||
|
||||
### Sélectionner un apprenti
|
||||
|
||||
Le sélecteur en haut de la page propose une recherche en direct : tape une partie du nom, prénom ou classe pour filtrer.
|
||||
|
||||
**Raccourcis** :
|
||||
- `/` ouvre directement le sélecteur
|
||||
- `Entrée` sélectionne le premier résultat filtré
|
||||
- `Échap` ferme la recherche
|
||||
|
||||
### Calendrier mensuel
|
||||
|
||||
Chaque cellule représente un jour du mois. Les couleurs indiquent l'état :
|
||||
|
||||
| Couleur de fond | Signification |
|
||||
|-----------------|------------------------------------------|
|
||||
| Blanc | Aucune absence |
|
||||
| Vert clair | Toutes les absences sont excusées |
|
||||
| Rose | Au moins une absence non excusée |
|
||||
| Bleu | Jour sélectionné en édition |
|
||||
| Bleu pâle | Aujourd'hui |
|
||||
|
||||
Les nombres dans les cellules :
|
||||
- "2 ⚠️ 1" → 2 absences au total dont 1 non excusée
|
||||
- "5" → 5 absences toutes excusées
|
||||
|
||||
Cliquer sur un jour avec absences ouvre le panneau d'édition.
|
||||
|
||||
### Panneau d'édition
|
||||
|
||||
10 lignes (P1 à P10) avec un **segmented control** à 3 boutons :
|
||||
- **Présent** (gris) — l'apprenti était là
|
||||
- **E** (orange) — Excusée
|
||||
- **N** (rouge) — Non excusée
|
||||
|
||||
Un seul clic suffit. Le bouton **Enregistrer** sauve toutes les modifications de la journée d'un coup. Le panneau reste ouvert après l'enregistrement pour permettre un éventuel ajustement.
|
||||
|
||||
### Excuse rapide ("Valider toutes les absences d'une journée")
|
||||
|
||||
Sous le calendrier, un bandeau jaune liste les jours qui ont au moins une absence non encore traitée (statut `a_traiter`). Cliquer sur un de ces boutons excuse **toutes les absences à traiter de ce jour-là** en une seule action.
|
||||
|
||||
## Page : `/classe` (Vue classe)
|
||||
|
||||
### Sélection de classe
|
||||
|
||||
Même principe que pour les apprentis : recherche en direct avec `/`, `Entrée`, `Échap`.
|
||||
|
||||
### Cartes apprenti
|
||||
|
||||
Chaque apprenti de la classe a une carte avec :
|
||||
- Nom + lien vers sa fiche complète
|
||||
- Badge "Sanction" si quota atteint (≥5 absences brutes en blocs)
|
||||
- KPIs : Total / Excusées / Non excusées / Blocs d'absences
|
||||
- Boutons de téléchargement PDF (Absences, Bulletin, Notes)
|
||||
- Onglets BN / Notes d'examen pour visualiser
|
||||
|
||||
## Audit des modifications
|
||||
|
||||
Chaque modification d'absence est tracée dans `data/logs/operations.log` :
|
||||
|
||||
```
|
||||
[14:32:01] [abs] prof.demo : Dupont Marc (1MAB1) — 04.02.2026 P6 : N → E
|
||||
[14:32:01] [abs] prof.demo : Dupont Marc (1MAB1) — 04.02.2026 P6 : N → E
|
||||
```
|
||||
|
||||
Visible aussi sur la page `/logs`. Le champ `updated_by` de la table `Absence` enregistre le dernier utilisateur ayant modifié.
|
||||
|
||||
## Effet de chaque modification
|
||||
|
||||
1. La table `Absence` est mise à jour (ou créée/supprimée)
|
||||
2. Une entrée `EscadaPending` est ajoutée pour le push ultérieur vers Escada
|
||||
3. Un toast de confirmation s'affiche
|
||||
4. Une ligne est ajoutée à `operations.log`
|
||||
78
data/docs/05-cron.md
Normal file
78
data/docs/05-cron.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Tâches planifiées (cron)
|
||||
|
||||
## Page : `/cron` (admin uniquement)
|
||||
|
||||
Permet de créer des tâches automatiques de synchronisation et/ou de push.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
OS cron (toutes les minutes)
|
||||
↓
|
||||
docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
|
||||
↓
|
||||
Lit la table CronJob → identifie les tâches à exécuter maintenant
|
||||
↓
|
||||
Pour chaque tâche due :
|
||||
- Lance push_to_escada.py et/ou sync_esacada.py + run_imports.py
|
||||
- Met à jour last_run_at, last_status, last_message
|
||||
- Envoie une notification Telegram (selon notify_on)
|
||||
```
|
||||
|
||||
Le tick s'exécute toutes les minutes via la crontab du host. Le timezone du conteneur est aligné sur `Europe/Zurich` pour correspondre aux horaires saisis dans l'UI.
|
||||
|
||||
## Types de tâches
|
||||
|
||||
| Type | Action |
|
||||
|------------------|----------------------------------------------------------------------|
|
||||
| `push` | Pousse les pendings vers Escada uniquement |
|
||||
| `sync` | Récupère depuis Escada uniquement (selon options abs/BN/notes/fiches)|
|
||||
| `push_then_sync` | Pousse les pendings, puis récupère |
|
||||
|
||||
## Schedules
|
||||
|
||||
Trois types de planning sont disponibles :
|
||||
|
||||
- **Quotidien (daily)** : à une heure fixe chaque jour. Ex : `03:00`.
|
||||
- **Hebdo (weekly)** : à une heure fixe certains jours. Ex : `MON,WED,FRI:08:30`.
|
||||
- **Intervalle (interval)** : toutes les N minutes. Ex : `30` = toutes les 30 minutes.
|
||||
|
||||
## Options de sync (pour task_kind=sync ou push_then_sync)
|
||||
|
||||
- `sync_abs` : récupère les absences
|
||||
- `sync_bn` : récupère les BN
|
||||
- `sync_notes` : récupère les notes
|
||||
- `sync_fiches` : récupère les données apprentis
|
||||
- `force_abs` : forçage (cf. doc Sync Escada)
|
||||
- `classes_json` : `"ALL"` ou liste de classes spécifiques
|
||||
|
||||
## Notifications Telegram
|
||||
|
||||
Pour chaque tâche, on configure :
|
||||
|
||||
- `notify_on ∈ {"never", "always", "success", "failure"}`
|
||||
- `notify_level ∈ {"normal", "detailed"}`
|
||||
- `notify_chat_id` : pour cibler un chat différent du chat global (vide = utiliser le défaut)
|
||||
|
||||
Voir la section [Notifications Telegram](#) pour les détails.
|
||||
|
||||
## Activation / désactivation
|
||||
|
||||
Le toggle dans la liste des tâches active ou désactive sans supprimer. Quand on **réactive** une tâche, son `last_run_at` est remis à `None` pour qu'elle se déclenche au prochain tick (sinon elle attendrait la fin de l'intervalle complet).
|
||||
|
||||
## Logs persistants
|
||||
|
||||
Chaque exécution écrit son log détaillé dans `/logs/cron/cron-{job_id}-{timestamp}.log`. Ce dossier est en bind mount Docker → les logs survivent à la recréation du conteneur.
|
||||
|
||||
## Audit
|
||||
|
||||
Toute modification (création/édition/activation/suppression) est tracée :
|
||||
|
||||
```
|
||||
[09:14:22] [cron] prof.demo : création tâche 'Sync nocturne' (id=4) — push_then_sync / 03:00 / activée
|
||||
[09:30:05] [cron] prof.demo : désactivation tâche 'Push 30min' (id=2)
|
||||
```
|
||||
|
||||
## Bouton "Tester Telegram"
|
||||
|
||||
Bas de page : envoie un message de test au `chat_id` global pour vérifier la config bot.
|
||||
66
data/docs/06-telegram.md
Normal file
66
data/docs/06-telegram.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Notifications Telegram
|
||||
|
||||
L'application peut envoyer des notifications sur les résultats des tâches planifiées et des opérations manuelles importantes.
|
||||
|
||||
## Configuration
|
||||
|
||||
La configuration vit dans `data/auth.yaml` (section dédiée) ou via variables d'environnement :
|
||||
|
||||
```yaml
|
||||
telegram:
|
||||
bot_token: "1234567890:AABBCC..."
|
||||
chat_id: "-4992234358"
|
||||
```
|
||||
|
||||
`bot_token` s'obtient en parlant à [@BotFather](https://t.me/BotFather) sur Telegram :
|
||||
1. `/newbot`
|
||||
2. Choisir un nom et un identifiant
|
||||
3. Récupérer le token
|
||||
|
||||
`chat_id` se récupère :
|
||||
- **Pour un chat 1-1** : envoyer un message au bot puis appeler l'API `https://api.telegram.org/bot<TOKEN>/getUpdates`. L'ID est dans `result[].message.chat.id`.
|
||||
- **Pour un groupe** : ajouter le bot au groupe, envoyer un message, puis même API. L'ID des groupes commence par `-`.
|
||||
|
||||
## Niveaux de notification
|
||||
|
||||
### Normal
|
||||
|
||||
Message court, une ligne :
|
||||
|
||||
```
|
||||
✅ Sync nocturne — terminée
|
||||
12 classes, 247 nouvelles absences, 0 erreur
|
||||
```
|
||||
|
||||
### Détaillé
|
||||
|
||||
Avec breakdown par classe et détails des orphelines :
|
||||
|
||||
```
|
||||
✅ Sync nocturne — terminée
|
||||
|
||||
Absences :
|
||||
• 1MAB1: 23 nouv / 5 modif / 2 pending / 1 suppr
|
||||
• 2EM1: 18 nouv / 12 modif / 0 pending / 0 suppr
|
||||
...
|
||||
|
||||
Orphelines supprimées :
|
||||
• Dupont Marc — 12.03.2026 P3
|
||||
...
|
||||
|
||||
Pendings ignorés (sans force) : 4
|
||||
```
|
||||
|
||||
## Chat ID par tâche
|
||||
|
||||
Chaque tâche cron peut surcharger le `chat_id` global avec son propre `notify_chat_id`. Utile pour router certaines notifs vers un canal admin et d'autres vers un canal utilisateurs.
|
||||
|
||||
## Ce qui est notifié manuellement
|
||||
|
||||
Pas grand-chose pour l'instant — Telegram est principalement réservé aux **résultats des tâches cron**. Les opérations manuelles (sync, push) émettent uniquement des **toasts** dans l'app.
|
||||
|
||||
## Diagnostic
|
||||
|
||||
- **"Bouton Tester Telegram" répond une erreur 401** : token invalide ou révoqué.
|
||||
- **"Erreur 400 Bad Request"** : `chat_id` incorrect ou bot pas membre du groupe.
|
||||
- **Notification jamais reçue** : vérifier que le bot a bien été ajouté au groupe et qu'il y a au moins un message envoyé (sinon Telegram bloque).
|
||||
71
data/docs/07-auth.md
Normal file
71
data/docs/07-auth.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Authentification & rôles
|
||||
|
||||
## Login
|
||||
|
||||
Page : `/login`
|
||||
|
||||
L'identifiant + mot de passe sont vérifiés contre `data/auth.yaml` (mot de passe haché avec **bcrypt**).
|
||||
|
||||
Format `auth.yaml` :
|
||||
|
||||
```yaml
|
||||
credentials:
|
||||
usernames:
|
||||
prof.demo:
|
||||
password: "$2b$12$..."
|
||||
name: "Prof Demo"
|
||||
role: "admin" # ou "user"
|
||||
avatar_url: "/avatars/prof_demo.png"
|
||||
totp_secret: "ABCD1234..." # rempli automatiquement à la 1ère 2FA
|
||||
```
|
||||
|
||||
## 2FA TOTP (obligatoire)
|
||||
|
||||
À la **première connexion** d'un nouvel utilisateur :
|
||||
1. Login + mot de passe corrects
|
||||
2. L'app génère un secret TOTP et affiche un **QR code**
|
||||
3. L'utilisateur scanne avec Google Authenticator / Authy / 1Password / etc.
|
||||
4. Il saisit le code à 6 chiffres pour confirmer
|
||||
5. Le secret est sauvé dans `auth.yaml`
|
||||
|
||||
Aux connexions suivantes :
|
||||
1. Login + mot de passe → demande directe du code TOTP
|
||||
2. Code à 6 chiffres → connexion finalisée
|
||||
|
||||
Le code est valide ±30s (paramètre `valid_window=1` de `pyotp`) pour tolérer la dérive d'horloge.
|
||||
|
||||
## Session persistante
|
||||
|
||||
L'authentification est stockée dans le **`localStorage` du navigateur** (champs `username`, `name`, `role`, `photo_url`). La session survit aux rechargements de page et aux redémarrages du conteneur.
|
||||
|
||||
À chaque page protégée, `AuthState.check_auth` re-vérifie que l'utilisateur existe toujours dans `auth.yaml` ; sinon, redirection forcée vers `/login`.
|
||||
|
||||
## Rôles
|
||||
|
||||
| Page | user | admin |
|
||||
|-------------------|------|-------|
|
||||
| `/accueil` | ✅ | ✅ |
|
||||
| `/fiche` | ✅ | ✅ |
|
||||
| `/classe` | ✅ | ✅ |
|
||||
| `/doc` | ✅ | ✅ |
|
||||
| `/escada` | ❌ | ✅ |
|
||||
| `/cron` | ❌ | ✅ |
|
||||
| `/logs` | ❌ | ✅ |
|
||||
| `/users` | ❌ | ✅ |
|
||||
| `/params` | ❌ | ✅ |
|
||||
|
||||
## Gestion des utilisateurs
|
||||
|
||||
Page `/users` (admin) :
|
||||
- Créer / supprimer des utilisateurs
|
||||
- Changer le rôle
|
||||
- Réinitialiser le 2FA (efface `totp_secret` → forcera une nouvelle config au prochain login)
|
||||
- Définir / changer un avatar
|
||||
|
||||
## Logout
|
||||
|
||||
Bouton "Déconnexion" en bas de la sidebar. Vide le `localStorage` et redirige vers `/login`.
|
||||
|
||||
## Stockage des avatars
|
||||
|
||||
Les fichiers sont sous `assets/avatars/`. Le chemin est référencé dans `auth.yaml` via `avatar_url`. Si `avatar_url` est vide, l'app affiche les initiales de `name`.
|
||||
85
data/docs/08-logs.md
Normal file
85
data/docs/08-logs.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Logs & audit
|
||||
|
||||
## Page : `/logs` (admin)
|
||||
|
||||
Affiche en temps réel le contenu de `data/logs/operations.log` avec un mode **prod** (lignes principales) et un mode **debug** (lignes indentées détaillées).
|
||||
|
||||
## Format des logs
|
||||
|
||||
```
|
||||
[14:32:01] [abs] prof.demo : Dupont Marc (1MAB1) — 04.02.2026 P6 : N → E
|
||||
[14:32:08] Sync Escada démarrée par prof.demo — 3 classe(s) [abs/forcé, BN] : 1MAB1, 2EM1, 1MAB2
|
||||
[14:32:15] [sync] Connexion Escadaweb OK
|
||||
[14:34:42] [sync] Téléchargement absences 1MAB1 (4.2 MB)
|
||||
[14:35:01] ALL_DONE confirme — phase import
|
||||
[14:35:30] Résultats chargés — sync terminée OK
|
||||
```
|
||||
|
||||
- Lignes **non indentées** : événements importants (sync démarrée/terminée, modif d'absence, action admin)
|
||||
- Lignes **indentées** (avec deux espaces) : sortie verbeuse des sous-processus Selenium / parser PDF — visibles uniquement en mode debug
|
||||
|
||||
## Catégories d'événements tracés
|
||||
|
||||
### Modifications de données
|
||||
|
||||
| Préfixe | Événement |
|
||||
|-----------|----------------------------------------|
|
||||
| `[abs]` | Modification d'absence (qui, quel apprenti, quelle période, ancien → nouveau type) |
|
||||
| `[cron]` | Création / édition / activation / suppression d'une tâche planifiée |
|
||||
|
||||
### Opérations Escada manuelles
|
||||
|
||||
- `Rafraîchissement liste classes Escada par {user}`
|
||||
- `Sync Escada démarrée par {user} — N classe(s) [options] : ...`
|
||||
- `Push Escada démarré par {user}`
|
||||
- `Push terminé — ok:N erreurs:M`
|
||||
|
||||
### Sous-processus
|
||||
|
||||
- `[refresh]`, `[sync]`, `[push]` : sortie standard du subprocess Selenium correspondant
|
||||
|
||||
### Cron automatique
|
||||
|
||||
- `[run_imports] démarré`
|
||||
- `[run_imports] abs CLASSE: N nouv / N modif / ...`
|
||||
- `[run_imports] terminé OK`
|
||||
|
||||
## Localisation physique
|
||||
|
||||
- **App** : `/opt/eptm-dashboard/data/logs/operations.log` (ligne par ligne, append-only)
|
||||
- **Cron jobs** : `/logs/cron/cron-{job_id}-{timestamp}.log` (un fichier par exécution, persistant via bind mount Docker)
|
||||
|
||||
## Rotation
|
||||
|
||||
Pas de rotation automatique pour l'instant. À long terme :
|
||||
```bash
|
||||
# Garder les 30 derniers jours
|
||||
find data/logs/ -name "operations.log" -mtime +30 -delete
|
||||
```
|
||||
|
||||
## Filtrer / chercher
|
||||
|
||||
Sur la page `/logs` :
|
||||
- Recherche texte simple
|
||||
- Filtre par date
|
||||
- Toggle prod/debug
|
||||
|
||||
En CLI :
|
||||
|
||||
```bash
|
||||
docker exec eptm-dashboard-app-1 grep -i "force" data/logs/operations.log | tail -50
|
||||
docker exec eptm-dashboard-app-1 grep "\[abs\] prof.demo" data/logs/operations.log
|
||||
```
|
||||
|
||||
## Champ `updated_by` côté DB
|
||||
|
||||
Indépendamment de `operations.log`, chaque table sensible stocke directement qui a fait quoi :
|
||||
|
||||
| Table | Champ |
|
||||
|--------------|--------------------------------|
|
||||
| `Absence` | `updated_by`, `updated_at` |
|
||||
| `Import` | `imported_by` |
|
||||
| `ImportBN` | `imported_by` |
|
||||
| `ImportMatu` | `imported_by` |
|
||||
|
||||
Permet de retrouver l'historique même si le log fichier est supprimé.
|
||||
24
data/docs/09-shortcuts.md
Normal file
24
data/docs/09-shortcuts.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Raccourcis clavier
|
||||
|
||||
## Globaux
|
||||
|
||||
| Touche | Action |
|
||||
|--------|-----------------------------------------------------------------|
|
||||
| `/` | Ouvre le sélecteur de recherche de la page courante (apprenti sur `/fiche`, classe sur `/classe`). Désactivé si le focus est dans un champ de saisie. |
|
||||
|
||||
## Dans les sélecteurs (popovers de recherche)
|
||||
|
||||
| Touche | Action |
|
||||
|---------|---------------------------------------------------|
|
||||
| `Entrée`| Sélectionne le 1er résultat de la liste filtrée |
|
||||
| `Échap` | Ferme le sélecteur et vide le filtre |
|
||||
|
||||
## Édition d'absences (panneau d'édition)
|
||||
|
||||
Pas de raccourci spécifique pour l'instant. Pour fermer le panneau : clic sur le `×` ou bouton "Annuler".
|
||||
|
||||
## Pages de connexion
|
||||
|
||||
| Touche | Action |
|
||||
|---------|---------------------------------------------------|
|
||||
| `Entrée`| Soumet le formulaire (login ou code TOTP) |
|
||||
112
data/docs/10-faq.md
Normal file
112
data/docs/10-faq.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# FAQ / Dépannage
|
||||
|
||||
## Synchronisation Escada
|
||||
|
||||
### "Import timeout — vérifiez les logs (> 15min)"
|
||||
|
||||
Le subprocess Selenium n'a pas répondu dans le temps imparti. Causes possibles :
|
||||
- Escadaweb répond très lentement (en pic de charge)
|
||||
- Captcha / re-login imposé par Escada
|
||||
- Container Docker en surcharge
|
||||
|
||||
**Que faire** :
|
||||
1. Aller dans `/logs` et chercher le dernier `[sync]` actif
|
||||
2. Si Selenium est bloqué sur un écran de login : lancer un Actualiser des classes (re-login)
|
||||
3. Si gros volume de classes : lancer la sync en plusieurs lots de 5-6 classes
|
||||
|
||||
### "Aucune classe récupérée"
|
||||
|
||||
Le scraping Selenium a échoué — souvent token de session expiré.
|
||||
|
||||
**Que faire** : recliquer sur "Actualiser" (ça force un re-login propre).
|
||||
|
||||
### "Le push échoue toujours sur le même apprenti"
|
||||
|
||||
Possibles causes :
|
||||
- L'apprenti existe en local mais pas (ou plus) sur Escada → le pending est obsolète, à supprimer
|
||||
- Le nom diffère entre local et Escada (ex: prénom composé partiel)
|
||||
- La page Escada de cet apprenti est verrouillée par un autre éditeur (lock pessimiste Escada)
|
||||
|
||||
**Que faire** :
|
||||
1. Vérifier les logs pour le message d'erreur exact
|
||||
2. Si l'apprenti n'existe plus : supprimer le pending manuellement en DB
|
||||
3. Sinon : retenter plus tard
|
||||
|
||||
### "L'option 'Forcer la réimportation complète' est en rouge — c'est dangereux ?"
|
||||
|
||||
Pas dangereux mais **destructif** :
|
||||
- Tous les pendings concernés sont écrasés (les modifs locales pas encore poussées sont perdues)
|
||||
- Tous les statuts d'absences sont remis à ce qu'Escada dit
|
||||
|
||||
À utiliser **uniquement** quand on veut sciemment reprendre l'état complet d'Escada.
|
||||
|
||||
## Tâches planifiées (cron)
|
||||
|
||||
### "J'ai créé une tâche, elle ne se déclenche pas"
|
||||
|
||||
Vérifier dans l'ordre :
|
||||
|
||||
1. La tâche est-elle **activée** ? (toggle vert)
|
||||
2. L'horaire est-il dans le bon fuseau horaire ? (l'app fonctionne en `Europe/Zurich`)
|
||||
3. La crontab du host appelle-t-elle bien `cron_tick.py` ?
|
||||
|
||||
```bash
|
||||
crontab -l | grep cron_tick
|
||||
# Doit avoir : * * * * * docker exec eptm-dashboard-app-1 python scripts/cron_tick.py
|
||||
```
|
||||
|
||||
4. Regarder le log : `docker exec eptm-dashboard-app-1 cat /logs/cron/`
|
||||
|
||||
### "La tâche ne notifie pas sur Telegram"
|
||||
|
||||
- `notify_on` doit être `always`, `success` ou `failure` (pas `never`)
|
||||
- Le `bot_token` et `chat_id` doivent être valides → tester via le bouton "Tester Telegram"
|
||||
|
||||
## Authentification
|
||||
|
||||
### "J'ai perdu mon téléphone avec mon code 2FA"
|
||||
|
||||
Un admin peut réinitialiser le 2FA via la page `/users` : "Réinitialiser 2FA". Au prochain login, l'utilisateur reverra le QR code.
|
||||
|
||||
### "Mon utilisateur est bloqué après plusieurs tentatives"
|
||||
|
||||
Pas de blocage automatique pour le moment. Si on veut en ajouter un : voir `state.py:handle_login`.
|
||||
|
||||
## Données
|
||||
|
||||
### "Les BN affichent des trous (cellules vides)"
|
||||
|
||||
C'est normal : un apprenti peut avoir commencé sa formation au S2 ou S3 → les premiers semestres restent vides. L'extraction depuis le PDF Escada respecte ces trous.
|
||||
|
||||
### "Les notes Matu n'apparaissent pas"
|
||||
|
||||
Pré-requis : l'apprenti est dans une classe MP/MI correspondante. Le matching se fait via la classe MP1, MP2, etc. La sync Matu cherche le PDF correspondant **si la case "BN" est cochée**.
|
||||
|
||||
## Performance
|
||||
|
||||
### "L'app rame quand je change de classe avec beaucoup d'apprentis"
|
||||
|
||||
Le `_reload` reconstruit les tableaux HTML BN/Notes pour chaque apprenti — peut prendre quelques secondes pour 25+ apprentis. Un skeleton s'affiche pendant le chargement pour donner du feedback visuel.
|
||||
|
||||
Si vraiment lent : envisager une mise en cache des HTML rendus dans la DB (à voir avec un dev).
|
||||
|
||||
## Conteneur Docker
|
||||
|
||||
### "Le conteneur consomme 100% CPU à l'idle"
|
||||
|
||||
Bug historique lié au hot-reload qui détectait les fichiers WAL/SHM de SQLite comme des modifications source. Corrigé via `REFLEX_HOT_RELOAD_EXCLUDE_PATHS=/app/data` dans le docker-compose.
|
||||
|
||||
Si ça revient : vérifier que cette variable est bien présente dans `docker-compose.dev.yml`.
|
||||
|
||||
### "Comment redémarrer proprement"
|
||||
|
||||
```bash
|
||||
cd /opt/eptm-dashboard
|
||||
docker compose -f docker-compose.dev.yml restart app
|
||||
```
|
||||
|
||||
### "Comment voir les logs du serveur Reflex"
|
||||
|
||||
```bash
|
||||
docker logs -f eptm-dashboard-app-1
|
||||
```
|
||||
135
eptm_dashboard/components.py
Normal file
135
eptm_dashboard/components.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Composants UI réutilisables (empty states, skeletons, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import reflex as rx
|
||||
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", str(Path(__file__).resolve().parent.parent / "data")))
|
||||
DOCS_DIR = DATA_DIR / "docs"
|
||||
_RE_DOC_TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def scan_docs() -> list[dict]:
|
||||
"""Liste les .md du dossier docs, triés par nom de fichier (préfixe numérique).
|
||||
|
||||
Retourne une liste de dicts {slug, title, path}.
|
||||
"""
|
||||
if not DOCS_DIR.exists():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for p in sorted(DOCS_DIR.glob("*.md")):
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
m = _RE_DOC_TITLE.search(text)
|
||||
title = m.group(1) if m else p.stem
|
||||
out.append({"slug": p.stem, "title": title, "path": str(p)})
|
||||
return out
|
||||
|
||||
|
||||
def empty_state(
|
||||
icon: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
action_label: str = "",
|
||||
action_href: str = "",
|
||||
icon_color: str = "var(--gray-9)",
|
||||
bg_color: str = "white",
|
||||
) -> rx.Component:
|
||||
"""Empty state stylé avec icône, titre, description et action optionnelle."""
|
||||
children = [
|
||||
rx.box(
|
||||
rx.icon(icon, size=42, color=icon_color),
|
||||
display="flex",
|
||||
align_items="center",
|
||||
justify_content="center",
|
||||
width="64px",
|
||||
height="64px",
|
||||
border_radius="9999px",
|
||||
background_color="var(--gray-3)",
|
||||
margin_bottom="0.75rem",
|
||||
),
|
||||
rx.text(title, size="4", weight="bold", color="#37474f"),
|
||||
]
|
||||
if description:
|
||||
children.append(
|
||||
rx.text(
|
||||
description,
|
||||
size="2",
|
||||
color="#666",
|
||||
text_align="center",
|
||||
max_width="380px",
|
||||
)
|
||||
)
|
||||
if action_label and action_href:
|
||||
children.append(
|
||||
rx.link(
|
||||
rx.button(action_label, color_scheme="red", size="2"),
|
||||
href=action_href,
|
||||
margin_top="0.5rem",
|
||||
)
|
||||
)
|
||||
return rx.vstack(
|
||||
*children,
|
||||
spacing="2",
|
||||
align="center",
|
||||
padding="2.5rem 1.5rem",
|
||||
background_color=bg_color,
|
||||
border="1px dashed var(--gray-6)",
|
||||
border_radius="10px",
|
||||
width="100%",
|
||||
class_name="anim-fade",
|
||||
)
|
||||
|
||||
|
||||
def skeleton_card(height: str = "80px") -> rx.Component:
|
||||
"""Carte placeholder pour chargement."""
|
||||
return rx.box(
|
||||
height=height,
|
||||
background_color="var(--gray-3)",
|
||||
border_radius="8px",
|
||||
width="100%",
|
||||
class_name="anim-pulse",
|
||||
)
|
||||
|
||||
|
||||
def skeleton_line(width: str = "100%", height: str = "12px") -> rx.Component:
|
||||
"""Ligne placeholder."""
|
||||
return rx.box(
|
||||
background_color="var(--gray-4)",
|
||||
border_radius="4px",
|
||||
width=width,
|
||||
height=height,
|
||||
class_name="anim-pulse",
|
||||
)
|
||||
|
||||
|
||||
def skeleton_apprenti_card() -> rx.Component:
|
||||
"""Skeleton pour une carte apprenti (page classe)."""
|
||||
return rx.box(
|
||||
rx.vstack(
|
||||
skeleton_line(width="40%", height="18px"),
|
||||
rx.flex(
|
||||
skeleton_card(height="58px"),
|
||||
skeleton_card(height="58px"),
|
||||
skeleton_card(height="58px"),
|
||||
skeleton_card(height="58px"),
|
||||
gap="0.5rem",
|
||||
width="100%",
|
||||
flex_wrap="wrap",
|
||||
),
|
||||
skeleton_line(width="60%"),
|
||||
spacing="3",
|
||||
width="100%",
|
||||
),
|
||||
padding="1.25rem",
|
||||
background_color="white",
|
||||
border_radius="8px",
|
||||
border="1px solid #e0e0e0",
|
||||
width="100%",
|
||||
)
|
||||
|
|
@ -9,6 +9,7 @@ from .pages.logs import logs_page, LogsState
|
|||
from .pages.cron import cron_page, CronState
|
||||
from .pages.users import users_page, UsersState
|
||||
from .pages.params import params_page, ParamsState
|
||||
from .pages.doc import doc_page, DocState
|
||||
|
||||
TITLE = "EPTM Dashboard"
|
||||
|
||||
|
|
@ -34,3 +35,4 @@ app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth,
|
|||
app.add_page(cron_page, route="/cron", on_load=[AuthState.check_auth, CronState.load_data], title=TITLE)
|
||||
app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE)
|
||||
app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)
|
||||
app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ def _kpi_card(label: str, value: rx.Var) -> rx.Component:
|
|||
flex="1",
|
||||
min_width="80px",
|
||||
width="100%",
|
||||
class_name="hover-lift",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -90,6 +91,7 @@ def _sanction_card(s: dict) -> rx.Component:
|
|||
padding="0.625rem 0.875rem",
|
||||
margin_y="0.15rem",
|
||||
width="100%",
|
||||
class_name="hover-lift",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
|
|||
|
||||
from ..state import AuthState
|
||||
from ..sidebar import layout
|
||||
from ..components import empty_state, skeleton_apprenti_card
|
||||
from src.db import (
|
||||
get_session, Apprenti, Absence,
|
||||
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
|
||||
)
|
||||
from src.stats import nb_blocs_absences, synthese_classe
|
||||
from src.parser_bn import sem_short_label
|
||||
from src.parser_bn import sem_short_label, sem_year_only
|
||||
|
||||
QUOTA = 5
|
||||
|
||||
|
|
@ -60,7 +61,12 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
|
|||
for i in range(N):
|
||||
raw = sem_labels[i] if i < len(sem_labels) else None
|
||||
short = sem_short_label(raw, i)
|
||||
header += f'<th style="{TH}">{short}</th>'
|
||||
year = sem_year_only(raw)
|
||||
year_html = (
|
||||
f'<div style="font-size:0.78em;color:#666;font-weight:normal;margin-top:1px">{year}</div>'
|
||||
if year else ""
|
||||
)
|
||||
header += f'<th style="{TH}"><div style="font-weight:700">{short}</div>{year_html}</th>'
|
||||
|
||||
def _moy_sem_row(label, gd, label_style, sep=False):
|
||||
s = SEP if sep else ""
|
||||
|
|
@ -355,6 +361,7 @@ class ClasseState(AuthState):
|
|||
apprentis_data: list[dict] = []
|
||||
class_search: str = ""
|
||||
class_select_open: bool = False
|
||||
is_loading_apprentis: bool = False
|
||||
|
||||
@rx.var
|
||||
def filtered_classes(self) -> list[str]:
|
||||
|
|
@ -388,7 +395,11 @@ class ClasseState(AuthState):
|
|||
self.selected_class = classe
|
||||
self.class_select_open = False
|
||||
self.class_search = ""
|
||||
self.is_loading_apprentis = True
|
||||
self.apprentis_data = []
|
||||
yield # pousse l'état "loading" au client avant le _reload bloquant
|
||||
self._reload()
|
||||
self.is_loading_apprentis = False
|
||||
|
||||
def set_class_search(self, v: str):
|
||||
self.class_search = v
|
||||
|
|
@ -398,6 +409,16 @@ class ClasseState(AuthState):
|
|||
if not v:
|
||||
self.class_search = ""
|
||||
|
||||
def class_search_keydown(self, key: str):
|
||||
"""Enter → sélectionne le 1er résultat. Esc → ferme."""
|
||||
if key == "Enter":
|
||||
results = self.filtered_classes
|
||||
if results:
|
||||
return ClasseState.set_class(results[0])
|
||||
elif key == "Escape":
|
||||
self.class_select_open = False
|
||||
self.class_search = ""
|
||||
|
||||
def download_abs_pdf(self, apprenti_id: int):
|
||||
sess = get_session()
|
||||
apprenti = sess.get(Apprenti, apprenti_id)
|
||||
|
|
@ -595,6 +616,7 @@ def _classe_searchable_select() -> rx.Component:
|
|||
background_color="white",
|
||||
cursor="pointer",
|
||||
width="100%",
|
||||
custom_attrs={"data-shortcut": "class-search"},
|
||||
),
|
||||
),
|
||||
rx.popover.content(
|
||||
|
|
@ -603,6 +625,7 @@ def _classe_searchable_select() -> rx.Component:
|
|||
placeholder="Rechercher une classe…",
|
||||
value=ClasseState.class_search,
|
||||
on_change=ClasseState.set_class_search,
|
||||
on_key_down=ClasseState.class_search_keydown,
|
||||
size="2",
|
||||
width="100%",
|
||||
auto_focus=True,
|
||||
|
|
@ -779,6 +802,7 @@ def _apprenti_card(item) -> rx.Component:
|
|||
border="1px solid #e0e0e0",
|
||||
width="100%",
|
||||
overflow="hidden",
|
||||
class_name="hover-lift anim-fade",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -793,31 +817,40 @@ def classe_page() -> rx.Component:
|
|||
_classe_searchable_select(),
|
||||
|
||||
rx.cond(
|
||||
ClasseState.apprentis_data.length() > 0,
|
||||
ClasseState.is_loading_apprentis,
|
||||
rx.vstack(
|
||||
rx.foreach(ClasseState.apprentis_data, _apprenti_card),
|
||||
skeleton_apprenti_card(),
|
||||
skeleton_apprenti_card(),
|
||||
skeleton_apprenti_card(),
|
||||
spacing="4",
|
||||
width="100%",
|
||||
),
|
||||
rx.text(
|
||||
"Aucun apprenti dans cette classe.",
|
||||
size="2", color="#666",
|
||||
rx.cond(
|
||||
ClasseState.apprentis_data.length() > 0,
|
||||
rx.vstack(
|
||||
rx.foreach(ClasseState.apprentis_data, _apprenti_card),
|
||||
spacing="4",
|
||||
width="100%",
|
||||
),
|
||||
empty_state(
|
||||
icon="user-x",
|
||||
title="Aucun apprenti dans cette classe",
|
||||
description="Sélectionne une autre classe ou lance une synchronisation.",
|
||||
action_label="Synchroniser",
|
||||
action_href="/escada",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
spacing="4",
|
||||
width="100%",
|
||||
),
|
||||
rx.box(
|
||||
rx.text(
|
||||
"Aucune donnee. Importez d'abord un PDF.",
|
||||
size="2", color="#666",
|
||||
),
|
||||
padding="1rem",
|
||||
background_color="#e3f2fd",
|
||||
border_radius="6px",
|
||||
border="1px solid #90caf9",
|
||||
width="100%",
|
||||
empty_state(
|
||||
icon="database",
|
||||
title="Aucune donnée",
|
||||
description="Aucune classe n'est encore en base. Lance une synchronisation depuis Escadaweb.",
|
||||
action_label="Lancer un import",
|
||||
action_href="/escada",
|
||||
),
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from src.logger import app_log # noqa: E402
|
|||
|
||||
from ..state import AuthState
|
||||
from ..sidebar import layout
|
||||
from ..components import empty_state
|
||||
|
||||
|
||||
_DAY_NAMES = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]
|
||||
|
|
@ -400,18 +401,25 @@ class CronState(AuthState):
|
|||
f"{job.task_kind} / {schedule_value} / "
|
||||
f"{'activée' if job.enabled else 'désactivée'}"
|
||||
)
|
||||
job_name = job.name
|
||||
self.save_ok = True
|
||||
self._refresh()
|
||||
self.edit_open = False
|
||||
return rx.toast.success(
|
||||
f"Tâche '{job_name}' "
|
||||
+ ("créée" if is_new else "enregistrée")
|
||||
)
|
||||
except Exception as e:
|
||||
sess.rollback()
|
||||
self.save_error = f"Erreur DB : {e}"
|
||||
return rx.toast.error(f"Erreur lors de l'enregistrement : {e}")
|
||||
finally:
|
||||
sess.close()
|
||||
|
||||
def toggle_enabled(self, job_id: int):
|
||||
sess = get_session()
|
||||
user = self.username or "?"
|
||||
toast_msg = None
|
||||
try:
|
||||
job = sess.get(CronJob, job_id)
|
||||
if job:
|
||||
|
|
@ -428,13 +436,20 @@ class CronState(AuthState):
|
|||
f"{'activation' if job.enabled else 'désactivation'} "
|
||||
f"tâche '{job.name}' (id={job.id})"
|
||||
)
|
||||
toast_msg = (
|
||||
f"Tâche '{job.name}' "
|
||||
+ ("activée" if job.enabled else "désactivée")
|
||||
)
|
||||
self._refresh()
|
||||
finally:
|
||||
sess.close()
|
||||
if toast_msg:
|
||||
return rx.toast.success(toast_msg)
|
||||
|
||||
def delete_job(self, job_id: int):
|
||||
sess = get_session()
|
||||
user = self.username or "?"
|
||||
toast_msg = None
|
||||
try:
|
||||
job = sess.get(CronJob, job_id)
|
||||
if job:
|
||||
|
|
@ -442,9 +457,12 @@ class CronState(AuthState):
|
|||
sess.delete(job)
|
||||
sess.commit()
|
||||
app_log(f"[cron] {user} : suppression tâche '{job_name}' (id={job_id})")
|
||||
toast_msg = f"Tâche '{job_name}' supprimée"
|
||||
self._refresh()
|
||||
finally:
|
||||
sess.close()
|
||||
if toast_msg:
|
||||
return rx.toast.success(toast_msg)
|
||||
|
||||
def test_telegram(self):
|
||||
ok, msg = test_telegram()
|
||||
|
|
@ -885,16 +903,10 @@ def cron_page() -> rx.Component:
|
|||
),
|
||||
rx.cond(
|
||||
CronState.jobs.length() == 0,
|
||||
rx.box(
|
||||
rx.text(
|
||||
"Aucun job planifié. Clique sur \"Nouveau job\" pour en créer un.",
|
||||
size="2", color="#666",
|
||||
),
|
||||
padding="1rem",
|
||||
background_color="#e3f2fd",
|
||||
border_radius="6px",
|
||||
border="1px solid #90caf9",
|
||||
width="100%",
|
||||
empty_state(
|
||||
icon="clock",
|
||||
title="Aucune tâche planifiée",
|
||||
description="Crée une tâche pour automatiser la synchronisation Escada (push, pull ou les deux).",
|
||||
),
|
||||
rx.vstack(
|
||||
rx.foreach(CronState.jobs, _job_row),
|
||||
|
|
|
|||
99
eptm_dashboard/pages/doc.py
Normal file
99
eptm_dashboard/pages/doc.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Page /doc — documentation interne (markdown rendu en HTML côté Python)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import markdown as md
|
||||
import reflex as rx
|
||||
|
||||
from ..state import AuthState
|
||||
from ..sidebar import layout
|
||||
from ..components import scan_docs
|
||||
|
||||
_RE_TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
# Une seule instance de Markdown réutilisée (extensions activées).
|
||||
_MD = md.Markdown(extensions=["tables", "fenced_code", "attr_list", "sane_lists"])
|
||||
|
||||
|
||||
def _render_md(text: str) -> str:
|
||||
_MD.reset()
|
||||
return _MD.convert(text)
|
||||
|
||||
|
||||
class DocState(AuthState):
|
||||
sections: list[dict] = []
|
||||
selected_slug: str = ""
|
||||
selected_title: str = ""
|
||||
selected_html: str = ""
|
||||
|
||||
def load_data(self):
|
||||
if not self.authenticated:
|
||||
return rx.redirect("/login")
|
||||
self.sections = scan_docs()
|
||||
if not self.sections:
|
||||
self.selected_slug = ""
|
||||
self.selected_title = ""
|
||||
self.selected_html = "<p><em>Aucune documentation disponible. Ajoutez des fichiers <code>.md</code> dans <code>data/docs/</code>.</em></p>"
|
||||
return
|
||||
slugs = [s["slug"] for s in self.sections]
|
||||
if self.selected_slug not in slugs:
|
||||
self.selected_slug = slugs[0]
|
||||
self._load_selected()
|
||||
|
||||
def select_section(self, slug: str):
|
||||
self.selected_slug = slug
|
||||
self._load_selected()
|
||||
|
||||
def _load_selected(self):
|
||||
section = next((s for s in self.sections if s["slug"] == self.selected_slug), None)
|
||||
if not section:
|
||||
self.selected_title = ""
|
||||
self.selected_html = ""
|
||||
return
|
||||
try:
|
||||
text = Path(section["path"]).read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
self.selected_title = section["title"]
|
||||
self.selected_html = f"<p><em>Erreur de lecture : {e}</em></p>"
|
||||
return
|
||||
# Retirer le H1 (déjà affiché en titre)
|
||||
m = _RE_TITLE.search(text)
|
||||
if m:
|
||||
text = text[m.end():].lstrip("\n")
|
||||
self.selected_title = section["title"]
|
||||
self.selected_html = _render_md(text)
|
||||
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _content() -> rx.Component:
|
||||
return rx.box(
|
||||
rx.heading(DocState.selected_title, size="6", margin_bottom="1rem"),
|
||||
rx.html(DocState.selected_html, class_name="doc-content"),
|
||||
padding="1.5rem 2rem",
|
||||
background_color="white",
|
||||
border="1px solid var(--gray-5)",
|
||||
border_radius="8px",
|
||||
width="100%",
|
||||
class_name="anim-fade",
|
||||
)
|
||||
|
||||
|
||||
def doc_page() -> rx.Component:
|
||||
return layout(
|
||||
rx.vstack(
|
||||
rx.heading("Documentation", size="7"),
|
||||
rx.text(
|
||||
"Guide d'utilisation et fonctionnement interne de l'application.",
|
||||
size="2", color="#666",
|
||||
),
|
||||
_content(),
|
||||
spacing="3",
|
||||
width="100%",
|
||||
)
|
||||
)
|
||||
|
|
@ -353,6 +353,10 @@ class EscadaState(AuthState):
|
|||
pass
|
||||
self.op_log = "\n".join(lines[-60:])
|
||||
self.is_refreshing = False
|
||||
if ui_classes:
|
||||
yield rx.toast.success(f"{len(ui_classes)} classe(s) récupérée(s)")
|
||||
else:
|
||||
yield rx.toast.error("Aucune classe récupérée — vérifiez les logs")
|
||||
except Exception as _e:
|
||||
app_log(f"Erreur mise à jour état refresh : {_e}")
|
||||
try:
|
||||
|
|
@ -360,6 +364,7 @@ class EscadaState(AuthState):
|
|||
self.is_refreshing = False
|
||||
except Exception:
|
||||
pass
|
||||
yield rx.toast.error("Erreur lors du rafraîchissement")
|
||||
|
||||
# ── Background: sync depuis Escada ─────────────────────────────────────────
|
||||
# UN SEUL async with self: (au début) — transitions via yield vers regular handlers.
|
||||
|
|
@ -570,6 +575,7 @@ class EscadaState(AuthState):
|
|||
async with self:
|
||||
self.is_syncing = False
|
||||
self.sync_errors = ["Synchronisation echouee — aucune donnee recue depuis Escadaweb."]
|
||||
yield rx.toast.error("Sync échouée — aucune donnée reçue depuis Escadaweb")
|
||||
return
|
||||
|
||||
# ── Phase 2 : import subprocess en cours — async with self #2 ────────────
|
||||
|
|
@ -612,8 +618,19 @@ class EscadaState(AuthState):
|
|||
self.sync_errors = _result_data.get("errors", [])
|
||||
self.sync_done = True
|
||||
app_log("Résultats chargés — sync terminée OK")
|
||||
_nb_err = len(self.sync_errors)
|
||||
else:
|
||||
self.sync_errors = ["Import timeout — vérifiez les logs (> 15min)."]
|
||||
_nb_err = 1
|
||||
if _result_ready:
|
||||
if _nb_err == 0:
|
||||
yield rx.toast.success("Synchronisation Escada terminée")
|
||||
else:
|
||||
yield rx.toast.warning(
|
||||
f"Synchronisation terminée avec {_nb_err} erreur(s)"
|
||||
)
|
||||
else:
|
||||
yield rx.toast.error("Import timeout — vérifiez les logs (> 15min)")
|
||||
|
||||
# ── Background: push vers Escada ───────────────────────────────────────────
|
||||
|
||||
|
|
@ -733,6 +750,15 @@ class EscadaState(AuthState):
|
|||
self.op_log = "\n".join(lines[-60:])
|
||||
self.is_pushing = False
|
||||
self._reload_pending()
|
||||
if push_done:
|
||||
if push_errors:
|
||||
yield rx.toast.warning(
|
||||
f"Push terminé : {push_ok} OK, {len(push_errors)} erreur(s)"
|
||||
)
|
||||
else:
|
||||
yield rx.toast.success(f"Push terminé — {push_ok} envoyé(s)")
|
||||
else:
|
||||
yield rx.toast.error("Push échoué — vérifiez les logs")
|
||||
except Exception as _e:
|
||||
app_log(f"Erreur mise à jour état push : {_e}")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ from src.db import (
|
|||
upsert_escada_pending,
|
||||
)
|
||||
from src.stats import nb_blocs_absences
|
||||
from src.parser_bn import sem_short_label
|
||||
from src.parser_bn import sem_short_label, sem_year_only
|
||||
from src.email_sender import build_template_vars, render_template
|
||||
from src.logger import app_log
|
||||
from ..components import empty_state
|
||||
|
||||
MOIS_FR = [
|
||||
"janvier", "fevrier", "mars", "avril", "mai", "juin",
|
||||
|
|
@ -79,7 +80,12 @@ def _bn_html_table(d: dict, sem_labels: list, groups_order: list) -> str:
|
|||
for i in range(N):
|
||||
raw = sem_labels[i] if i < len(sem_labels) else None
|
||||
short = sem_short_label(raw, i)
|
||||
header += f'<th style="{TH}">{short}</th>'
|
||||
year = sem_year_only(raw)
|
||||
year_html = (
|
||||
f'<div style="font-size:0.78em;color:#666;font-weight:normal;margin-top:1px">{year}</div>'
|
||||
if year else ""
|
||||
)
|
||||
header += f'<th style="{TH}"><div style="font-weight:700">{short}</div>{year_html}</th>'
|
||||
|
||||
def _moy_sem_row(label, gd, label_style, sep=False):
|
||||
s = SEP if sep else ""
|
||||
|
|
@ -535,6 +541,16 @@ class FicheState(AuthState):
|
|||
if not v:
|
||||
self.apprenti_search = ""
|
||||
|
||||
def apprenti_search_keydown(self, key: str):
|
||||
"""Enter → sélectionne le 1er résultat. Esc → ferme."""
|
||||
if key == "Enter":
|
||||
results = self.filtered_apprenti_labels
|
||||
if results:
|
||||
return FicheState.handle_select(results[0])
|
||||
elif key == "Escape":
|
||||
self.apprenti_select_open = False
|
||||
self.apprenti_search = ""
|
||||
|
||||
def navigate_to(self, apprenti_id: int):
|
||||
if apprenti_id in self.apprenti_ids:
|
||||
idx = self.apprenti_ids.index(apprenti_id)
|
||||
|
|
@ -624,12 +640,14 @@ class FicheState(AuthState):
|
|||
10: self.edit_p10,
|
||||
}
|
||||
d_str = d.strftime("%d.%m.%Y")
|
||||
nb_changes = 0
|
||||
for p, choice in choices.items():
|
||||
ab = pm.get(p)
|
||||
if choice == "present":
|
||||
if ab:
|
||||
upsert_escada_pending(sess, self.selected_id, d, p, "clear")
|
||||
sess.delete(ab)
|
||||
nb_changes += 1
|
||||
app_log(
|
||||
f"[abs] {user} : {appr_label} — {d_str} P{p} : "
|
||||
f"{ab.type_origine} → présent (suppression)"
|
||||
|
|
@ -644,6 +662,7 @@ class FicheState(AuthState):
|
|||
ab.statut = statut
|
||||
ab.updated_by = self.username
|
||||
upsert_escada_pending(sess, self.selected_id, d, p, type_o)
|
||||
nb_changes += 1
|
||||
app_log(
|
||||
f"[abs] {user} : {appr_label} — {d_str} P{p} : "
|
||||
f"{old_type} → {type_o}"
|
||||
|
|
@ -656,13 +675,21 @@ class FicheState(AuthState):
|
|||
updated_by=self.username, import_id=None,
|
||||
))
|
||||
upsert_escada_pending(sess, self.selected_id, d, p, type_o)
|
||||
nb_changes += 1
|
||||
app_log(
|
||||
f"[abs] {user} : {appr_label} — {d_str} P{p} : "
|
||||
f"présent → {type_o} (création)"
|
||||
)
|
||||
sess.commit()
|
||||
self.edit_date = ""
|
||||
self._reload(reset_email=False)
|
||||
if nb_changes == 0:
|
||||
return rx.toast.info("Aucune modification")
|
||||
msg = (
|
||||
f"{nb_changes} période modifiée pour {d_str}"
|
||||
if nb_changes == 1
|
||||
else f"{nb_changes} périodes modifiées pour {d_str}"
|
||||
)
|
||||
return rx.toast.success(msg)
|
||||
|
||||
# ── Quick excuse ──────────────────────────────────────────────────────────
|
||||
def excuse_day(self, date_str: str):
|
||||
|
|
@ -682,12 +709,14 @@ class FicheState(AuthState):
|
|||
Absence.statut == "a_traiter",
|
||||
)
|
||||
).scalars().all()
|
||||
nb = 0
|
||||
for ab in absences:
|
||||
old_type = ab.type_origine
|
||||
ab.statut = "excusee"
|
||||
ab.type_origine = "E"
|
||||
ab.updated_by = self.username
|
||||
upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E")
|
||||
nb += 1
|
||||
app_log(
|
||||
f"[abs] {user} : {appr_label} — {d_str} P{ab.periode} : "
|
||||
f"{old_type} → E (excuse rapide)"
|
||||
|
|
@ -696,6 +725,14 @@ class FicheState(AuthState):
|
|||
if self.edit_date == date_str:
|
||||
self.edit_date = ""
|
||||
self._reload(reset_email=False)
|
||||
if nb == 0:
|
||||
return rx.toast.info("Aucune absence à excuser")
|
||||
msg = (
|
||||
f"1 période excusée pour {d_str}"
|
||||
if nb == 1
|
||||
else f"{nb} périodes excusées pour {d_str}"
|
||||
)
|
||||
return rx.toast.success(msg)
|
||||
|
||||
# ── Downloads ─────────────────────────────────────────────────────────────
|
||||
def download_abs_pdf(self):
|
||||
|
|
@ -857,7 +894,8 @@ class FicheState(AuthState):
|
|||
self.fiche_email_val = fiche.email or ""
|
||||
self.fiche_date_naissance = fiche.date_naissance or ""
|
||||
self.fiche_majeur = (
|
||||
("oui" if fiche.majeur else "non") if fiche.majeur is not None else ""
|
||||
("Majeur : oui" if fiche.majeur else "Majeur : non")
|
||||
if fiche.majeur is not None else ""
|
||||
)
|
||||
self.fiche_entreprise_nom = fiche.entreprise_nom or ""
|
||||
self.fiche_entreprise_adresse = fiche.entreprise_adresse or ""
|
||||
|
|
@ -1082,6 +1120,7 @@ def _apprenti_searchable_select() -> rx.Component:
|
|||
background_color="white",
|
||||
cursor="pointer",
|
||||
width="100%",
|
||||
custom_attrs={"data-shortcut": "apprenti-search"},
|
||||
),
|
||||
),
|
||||
rx.popover.content(
|
||||
|
|
@ -1090,6 +1129,7 @@ def _apprenti_searchable_select() -> rx.Component:
|
|||
placeholder="Rechercher un apprenti…",
|
||||
value=FicheState.apprenti_search,
|
||||
on_change=FicheState.set_apprenti_search,
|
||||
on_key_down=FicheState.apprenti_search_keydown,
|
||||
size="2",
|
||||
width="100%",
|
||||
auto_focus=True,
|
||||
|
|
@ -1129,6 +1169,7 @@ def _kpi_card(label: str, value, color: str = "#37474f") -> rx.Component:
|
|||
border="1px solid #e0e0e0",
|
||||
flex="1",
|
||||
min_width="120px",
|
||||
class_name="hover-lift",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1184,6 +1225,12 @@ def _cal_day_cell(d) -> rx.Component:
|
|||
justify_content="center",
|
||||
cursor=rx.cond(d["has_abs"], "pointer", "default"),
|
||||
on_click=FicheState.select_day(d["date_str"]),
|
||||
class_name="smooth-transition",
|
||||
_hover=rx.cond(
|
||||
d["has_abs"],
|
||||
{"transform": "scale(1.05)", "box_shadow": "0 2px 6px rgba(0,0,0,0.1)"},
|
||||
{},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1265,6 +1312,7 @@ def _edit_panel() -> rx.Component:
|
|||
border_radius="8px",
|
||||
border="1px solid #bfdbfe",
|
||||
width="100%",
|
||||
class_name="anim-slide-down",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1717,16 +1765,12 @@ def fiche_page() -> rx.Component:
|
|||
|
||||
spacing="4", width="100%",
|
||||
),
|
||||
rx.box(
|
||||
rx.text(
|
||||
"Aucun apprenti. Faites d'abord un import.",
|
||||
size="2", color="#666",
|
||||
),
|
||||
padding="1rem",
|
||||
background_color="#e3f2fd",
|
||||
border_radius="6px",
|
||||
border="1px solid #90caf9",
|
||||
width="100%",
|
||||
empty_state(
|
||||
icon="users",
|
||||
title="Aucun apprenti",
|
||||
description="Importe les classes depuis Escadaweb pour commencer à gérer les absences.",
|
||||
action_label="Lancer un import",
|
||||
action_href="/escada",
|
||||
),
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import reflex as rx
|
||||
from .state import AuthState
|
||||
from .components import scan_docs
|
||||
|
||||
# Liste des sections de doc (scan au module-load — un restart suffit pour
|
||||
# détecter de nouveaux fichiers).
|
||||
_DOC_SECTIONS = scan_docs()
|
||||
|
||||
FULL_W = "240px"
|
||||
RAIL_W = "68px"
|
||||
|
|
@ -110,6 +115,83 @@ def _nav_item(label: str, href: str, icon_name: str) -> rx.Component:
|
|||
)
|
||||
|
||||
|
||||
def _doc_subitem(title: str, slug: str, mobile: bool = False) -> rx.Component:
|
||||
"""Lien vers une section de doc — navigue vers /doc + sélectionne la section."""
|
||||
# Import local pour éviter le cycle sidebar ↔ pages.doc
|
||||
from .pages.doc import DocState
|
||||
is_active = (
|
||||
(AuthState.router.page.path == "/doc")
|
||||
& (DocState.selected_slug == slug)
|
||||
)
|
||||
on_click_actions = [DocState.select_section(slug), rx.redirect("/doc")]
|
||||
if mobile:
|
||||
on_click_actions.append(AuthState.close_mobile_menu)
|
||||
return rx.box(
|
||||
rx.text(
|
||||
title,
|
||||
size="2",
|
||||
color=rx.cond(is_active, "#ffffff", _TEXT),
|
||||
font_weight=rx.cond(is_active, "600", "400"),
|
||||
),
|
||||
on_click=on_click_actions,
|
||||
cursor="pointer",
|
||||
padding="0.4rem 0.75rem 0.4rem 2rem",
|
||||
border_radius="0 6px 6px 0",
|
||||
background_color=rx.cond(is_active, _ACTIVE_BG, "transparent"),
|
||||
_hover={"background_color": _HOVER_BG},
|
||||
width="100%",
|
||||
class_name="smooth-transition",
|
||||
)
|
||||
|
||||
|
||||
def _doc_section(mobile: bool = False) -> rx.Component:
|
||||
if not _DOC_SECTIONS:
|
||||
return rx.fragment()
|
||||
return rx.cond(
|
||||
AuthState.sidebar_collapsed if not mobile else rx.Var.create(False),
|
||||
# Rail mode : icône simple vers /doc, sans sous-menu
|
||||
rx.box(
|
||||
_nav_rail("Documentation", "/doc", "book-open"),
|
||||
padding_x="0.5rem", padding_y="0.25rem",
|
||||
width="100%",
|
||||
),
|
||||
# Full mode : header cliquable + sous-items
|
||||
rx.vstack(
|
||||
rx.button(
|
||||
rx.hstack(
|
||||
rx.icon("book-open", size=17, color=_TEXT, flex_shrink="0"),
|
||||
rx.text("Documentation", size="2", color=_TEXT, weight="medium"),
|
||||
rx.spacer(),
|
||||
rx.icon(
|
||||
rx.cond(AuthState.doc_expanded, "chevron-up", "chevron-down"),
|
||||
size=14, color=_TEXT_MUTED,
|
||||
),
|
||||
spacing="3", align="center", width="100%",
|
||||
),
|
||||
on_click=AuthState.toggle_doc,
|
||||
variant="ghost",
|
||||
width="100%",
|
||||
size="2",
|
||||
padding_x="0.75rem",
|
||||
padding_y="0.5rem",
|
||||
color=_TEXT,
|
||||
_hover={"background_color": _HOVER_BG},
|
||||
cursor="pointer",
|
||||
justify="start",
|
||||
),
|
||||
rx.cond(
|
||||
AuthState.doc_expanded,
|
||||
rx.vstack(
|
||||
*[_doc_subitem(s["title"], s["slug"], mobile) for s in _DOC_SECTIONS],
|
||||
spacing="0", width="100%",
|
||||
),
|
||||
),
|
||||
spacing="0", width="100%",
|
||||
padding_x="0.5rem", padding_y="0.1rem",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _admin_section(mobile: bool = False) -> rx.Component:
|
||||
return rx.cond(
|
||||
AuthState.role == "admin",
|
||||
|
|
@ -266,6 +348,7 @@ def sidebar() -> rx.Component:
|
|||
padding_y="0.5rem",
|
||||
),
|
||||
|
||||
_doc_section(),
|
||||
_admin_section(),
|
||||
rx.spacer(),
|
||||
|
||||
|
|
@ -342,6 +425,7 @@ def _mobile_topbar() -> rx.Component:
|
|||
spacing="1", width="100%",
|
||||
padding_x="0", padding_y="0.5rem",
|
||||
),
|
||||
_doc_section(mobile=True),
|
||||
_admin_section(mobile=True),
|
||||
rx.box(height="1px", width="100%", background_color=_BORDER),
|
||||
rx.box(
|
||||
|
|
@ -370,6 +454,29 @@ def _mobile_topbar() -> rx.Component:
|
|||
|
||||
# ── Layout wrapper ───────────────────────────────────────────────────────────
|
||||
|
||||
_KEYBOARD_SHORTCUTS_JS = """
|
||||
(() => {
|
||||
if (window.__eptmShortcutsInstalled) return;
|
||||
window.__eptmShortcutsInstalled = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)
|
||||
|| document.activeElement?.isContentEditable;
|
||||
// '/' = focus le sélecteur de recherche (apprenti/classe) sur la page courante
|
||||
if (e.key === '/' && !isTyping) {
|
||||
const trigger = document.querySelector(
|
||||
'[data-shortcut="apprenti-search"], [data-shortcut="class-search"]'
|
||||
);
|
||||
if (trigger) {
|
||||
e.preventDefault();
|
||||
trigger.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def layout(content: rx.Component) -> rx.Component:
|
||||
return rx.box(
|
||||
sidebar(),
|
||||
|
|
@ -394,6 +501,7 @@ def layout(content: rx.Component) -> rx.Component:
|
|||
transition="margin-left 0.22s ease, width 0.22s ease",
|
||||
box_sizing="border-box",
|
||||
),
|
||||
rx.script(_KEYBOARD_SHORTCUTS_JS),
|
||||
width="100%",
|
||||
height="100vh",
|
||||
overflow="hidden",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class AuthState(rx.State):
|
|||
sidebar_collapsed: bool = False
|
||||
mobile_menu_open: bool = False
|
||||
admin_expanded: bool = True
|
||||
doc_expanded: bool = False
|
||||
|
||||
@rx.var
|
||||
def authenticated(self) -> bool:
|
||||
|
|
@ -87,6 +88,9 @@ class AuthState(rx.State):
|
|||
def toggle_admin(self):
|
||||
self.admin_expanded = not self.admin_expanded
|
||||
|
||||
def toggle_doc(self):
|
||||
self.doc_expanded = not self.doc_expanded
|
||||
|
||||
def set_login_user(self, value: str):
|
||||
self.login_user = value
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
|
||||
reflex==0.9.2
|
||||
markdown==3.10.2
|
||||
|
|
@ -243,6 +243,25 @@ def sem_short_label(raw: str | None, idx: int) -> str:
|
|||
return f"S{idx + 1}"
|
||||
|
||||
|
||||
def sem_full_label(raw: str | None, idx: int) -> str:
|
||||
"""'Sem. 1\\n23-24 1' → 'S1 23-24'. Fallback: 'S{idx+1}' si pas d'année."""
|
||||
short = sem_short_label(raw, idx)
|
||||
if raw:
|
||||
m = _RE_YEAR.search(str(raw))
|
||||
if m:
|
||||
return f"{short} {m.group(1)}"
|
||||
return short
|
||||
|
||||
|
||||
def sem_year_only(raw: str | None) -> str:
|
||||
"""Extrait juste l'année '23-24' depuis le label brut, '' si absent."""
|
||||
if raw:
|
||||
m = _RE_YEAR.search(str(raw))
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def ann_short_label(sem_labels: list[str | None], idx: int) -> str:
|
||||
"""Return 'Moy.23-24' using the year embedded in the label at *idx*."""
|
||||
raw = sem_labels[idx] if idx < len(sem_labels) else None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue