V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation

Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PTA
2026-08-17 16:53:04 +02:00
parent 57bd8da5c8
commit 4201894e28
69 changed files with 5228 additions and 246 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
# Le Dockerfile fait COPY . /var/www/html : tout ce qui est listé ici reste
# hors de l'image, donc hors de la racine web et hors des couches publiées.
.env
.env.*
!.env.example
.git
.gitignore
.dockerignore
# Historique et documentation de version : inutiles dans l'image.
README*.md
CLAUDE.md
# Journaux, sauvegardes et artefacts locaux
*.log
*.sql.gz
*.dump
logs/
tmp/
.idea/
.vscode/
.DS_Store

View File

@@ -4,12 +4,17 @@ APP_PORT=8080
TZ=Europe/Paris TZ=Europe/Paris
# MariaDB # MariaDB
# Remplacer impérativement les deux mots de passe avant tout déploiement.
DB_NAME=pta DB_NAME=pta
DB_USER=pta DB_USER=pta
DB_PASSWORD=pta DB_PASSWORD=a_remplacer
DB_ROOT_PASSWORD=pta DB_ROOT_PASSWORD=a_remplacer_aussi
# Débogage : 1 renvoie le détail des exceptions au navigateur. Laisser à 0.
PTA_DEBUG=0
# phpMyAdmin # phpMyAdmin
# 127.0.0.1 = uniquement accessible depuis le serveur lui-même. # 127.0.0.1 = uniquement accessible depuis le serveur lui-même.
# phpMyAdmin demande désormais un identifiant MariaDB à chaque connexion.
PMA_BIND=127.0.0.1 PMA_BIND=127.0.0.1
PMA_PORT=8081 PMA_PORT=8081

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Configuration locale : contient les mots de passe MariaDB.
# Seul .env.example doit être versionné.
.env
.env.*
!.env.example
# Dumps et sauvegardes de base
*.sql.gz
*.dump
database/backup/
database/*_backup*.sql
# Journaux et fichiers temporaires
*.log
logs/
tmp/
# Dépendances éventuelles
/vendor/
/node_modules/
# Worktrees de travail Claude Code : copies du dépôt, jamais à versionner.
.claude/worktrees/
# Artefacts d'éditeur et de système
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*~

235
CLAUDE.md Normal file
View File

@@ -0,0 +1,235 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Contexte
PTA (Planning de Travail Annualisé) — application PHP 8.3 multipage (MVC maison) pour la
planification annualisée des agents d'accueils de loisirs périscolaires/extrascolaires.
**Aucune dépendance** : pas de Composer, pas de npm, pas de framework, pas de bundler, pas de
suite de tests. Uniquement PHP + PDO/MySQL + JavaScript vanilla. Ne pas introduire de dépendance
sans raison forte : le PDF (`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont
volontairement réécrits à la main.
**Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages
utilisateur, commentaires. Conserver cette langue pour tout nouveau code.
## Commandes
```bash
# Démarrage complet (nginx :8080, phpMyAdmin :8081, MariaDB, php-fpm)
cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build
# IMPORTANT : le code source est COPIÉ dans l'image au build (Dockerfile), il n'est pas monté.
# Toute modification PHP/JS/CSS exige un rebuild des DEUX services :
# (nginx sert lui-même les assets statiques depuis sa propre copie de /var/www/html)
docker compose up -d --build php nginx
docker compose logs -f php nginx db
docker compose logs db-migrate # compte rendu de l'application des migrations
docker compose ps
docker compose down # arrêt en conservant les données
docker compose down -v # DÉTRUIT le volume MariaDB
# Accès SQL direct
docker compose exec db mariadb -upta -p pta
# Vérification syntaxique (il n'existe ni linter ni tests automatisés)
find . -name '*.php' -not -path './.git/*' -exec php -l {} \;
```
Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué **qu'à la création initiale du volume**
MariaDB (`docker-entrypoint-initdb.d`) : il ne contient que le socle métier, et **aucune** des tables
ou vues ajoutées par les migrations.
Les fichiers `database/migration_*.sql` sont appliqués automatiquement au démarrage par le service
jetable `db-migrate` (`docker/db/apply-migrations.sh`), sur base neuve comme sur base existante.
Le service `php` attend sa sortie en succès : une migration en échec empêche l'application de
tourner sur une base incomplète, plutôt que de la laisser renvoyer une erreur 500 à l'accueil.
Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fichier déjà appliqué
et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester
idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`,
`INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une
migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`.
`PTA_DB_PORT`, `DB_PORT` et `PMA_PORT` du compose sont les ports joints **à l'intérieur** du réseau
Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`,
`PMA_PORT` de `.env`) : les modifier coupe l'accès à la base.
## Architecture
### Deux types de points d'entrée, tous à la racine ou dans `api/`
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
**Pages HTML** — `dashboard.php`, `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`,
`coverage.php`, `structures.php`, `pending.php`, `vacations.php`, `administration.php` :
```php
require __DIR__ . '/page_bootstrap.php';
runPage('planning'); // -> PageController::planning()
```
`page_bootstrap.php` attrape les `Throwable` et affiche une page d'erreur HTML.
**Endpoints JSON/PDF** — `api/*.php` plus `save_planning.php`, `validate_planning.php`,
`reopen_planning.php` (ces trois-là sont restés à la racine pour raisons historiques) :
```php
JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new PlanningController($pdo))->show();
```
`JsonResponse::bootstrap()` convertit warnings et exceptions en réponses JSON 500 — indispensable,
car le JS refuse toute réponse non-JSON. **Les endpoints PDF ne l'appellent pas** (ils émettent des
octets binaires via `PdfResponse::inline()`).
Pour ajouter un endpoint : créer le fichier mince dans `api/`, ajouter la méthode au contrôleur.
Pour ajouter une page : fichier racine + méthode dans `PageController` + vue `app/Views/pages/` +
partiel `app/Views/partials/` + module JS + **inscription de la clé dans le tableau `$navigation` de
`app/Views/layouts/header.php`, dans un groupe de `$navigationGroups`, et dans `acces_role_page` par
une migration SQL** (les pages autorisées sont lues en base, pas écrites en PHP — voir plus bas).
Une page absente des groupes reste affichée dans un groupe « Autres » : le filet évite qu'un oubli
la rende inatteignable. Chaque page passe aussi un `pageSubtitle` décrivant l'action de l'écran.
Seule exception assumée : `faq` (guide d'utilisation) est **volontairement absente de `$navigation`**
et rendue par le bloc `.view-tab-help` ancré à droite de la barre, sous condition `$showHelpTab`.
L'omettre de `$navigation` est ce qui l'empêche d'être aussi repêchée par le filet « Autres » et
d'apparaître deux fois. Elle reste soumise à `acces_role_page`
(`database/migration_v7_9d_faq.sql`, les trois rôles) : sans migration, ni bouton ni page.
### Chaîne d'un appel
```
api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send()
page.php → PageController → Models → Views/pages/x.php
├── layouts/header.php (nav filtrée par rôle)
├── partials/x.php (markup)
└── layouts/footer.php (injecte window.PTA_CONFIG + scripts)
```
`app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables
d'environnement `PTA_DB_*`) et appelle `SchemaModel::assertCurrent()` **à chaque requête**. Ajouter
une table ou une colonne obligatoire impose de mettre à jour les listes de `SchemaModel`, sinon
l'application entière refuse de démarrer.
### Rôles et périmètres (`app/Core/Access.php`)
Trois rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout, seul habilité à valider).
`role.php` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement
son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la
production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la
mécanique de droits.
**`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait
échouer l'application si la migration manque — c'est volontairement fail-closed.
`dashboard.php` (Service Enfance uniquement, voir `README_V7_9.md`) illustre la règle : le droit
d'accès vient de `database/migration_v7_9_tableau_de_bord.sql`. Tant que la migration n'est pas
jouée, l'onglet n'apparaît pas et `Access::homeUrl()` se replie sur `planning.php` — ce repli est
indispensable, sans lui `requirePage()` renverrait indéfiniment vers une page interdite.
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
garde ni sans `Csrf::assertPayload($payload)`.**
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
`csrf_token: window.PTA_CONFIG.csrfToken`.
### Contraintes de sécurité à respecter
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
Tout `<script>` inline ajouté à une vue doit porter `nonce="<?= $cspNonce ?>"`, et aucun
`onclick=` / `style=` inline ne fonctionnera.
- **`PTA.utils.escapeHtml` échappe aussi les guillemets** et est donc valable en contexte texte
comme en contexte attribut. Ne pas la réimplémenter localement : c'est une variante incomplète
qui avait introduit un XSS stocké.
- **Ordre des blocs nginx** : nginx retient la première regex correspondante. Tout nouveau refus
doit être placé **avant** `location ~ \.php$`, sinon il est sans effet.
- `PTA_DEBUG` reste à `0`. Les détails d'exception passent par `Security::reportThrowable()`, qui
journalise côté serveur et ne renvoie rien au client.
- `session_start()` ne doit jamais être appelé directement : passer par `App\Core\Session::start()`.
### JavaScript
Pas de bundler ni de module ES. Chaque fichier est une IIFE qui lit le global `window.PTA` publié
par `core.js` (`config`, `elements`, `state`, `shared`, `utils`, `modules`) et s'enregistre :
```js
PTA.modules.agentOverview = { init, load };
```
`PageController` décide quels scripts et quels modules charger (tableaux `scripts` / `modules`),
`footer.php` les injecte, puis `page-bootstrap.js` appelle `init()` sur chaque module déclaré et
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se
rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
Les assets sont versionnés à la main : `assets/style.css?v=7.9` et `assets/js/*.js?v=7.9` dans
`layouts/header.php` / `layouts/footer.php` **et dans `Views/pages/role.php`**, qui porte sa propre
balise `<head>`. **Bumper ce numéro aux trois endroits après toute modification de JS/CSS**, sinon
les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
### Modèle métier
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de
contrat : 1 607 h de référence proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`).
- Les 1 607 h sont un volume **NET de travail effectif** : `365 - 104 (week-ends) - 25 (congés
payés) - 8 (jours fériés) = 228 j × 7 h = 1 596 h`, arrondies à 1 600 h, plus les 7 h de la
journée de solidarité. Congés payés et jours fériés en sont donc **déjà retranchés** : leur
imputer des heures de quota les décompterait une seconde fois. Une tentative de décompter un
forfait de 7 h par jour de CP a été introduite puis annulée pour cette raison — ne pas la
réintroduire.
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
elles sont importées depuis l'API publique data.education.gouv.fr via
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
- Une **semaine type** (`modele_semaine_agent` / `modele_semaine_creneau`) appartient à **un agent**
et décrit un lundi→vendredi générique. Ce n'est pas un planning : rien n'est planifié tant qu'elle
n'est pas appliquée à une semaine ou dupliquée sur une période. Elle se compose soit à partir
d'une semaine réellement planifiée (`saveFromWeek()`), soit directement dans l'éditeur
(`saveFromEntries()`, voir `README_V7_10.md`) — les deux passent par `persistTemplate()`.
`planning.php` propose deux modes exclusifs (voir `README_V7_10.md`) : l'éditeur de semaine type,
ouvert par défaut, et la saisie semaine par semaine. Le second reste la cible de tous les liens
profonds : dès que l'URL porte `week` ou `focus`, `page-bootstrap.js` bascule sur ce mode, sinon un
utilisateur venant corriger un planning identifié atterrirait dans l'éditeur de modèles.
### Piège : logique de validation dupliquée
`database/pta_prod_schema_mariadb.sql` définit des vues (`v_suivi_quota_annuel`,
`v_plannings_en_attente_validation`, …) et des procédures (`sp_controler_planning`,
`sp_valider_planning`) — **aucune n'est appelée par le code PHP**. Les contrôles réellement
appliqués vivent dans `PlanningController::validate()`, `AnnualOverviewController::validateYear()`
et `PtaModel`. Modifier une règle métier impose de traiter le PHP ; le SQL correspondant est du
patrimoine à ne pas prendre pour la source de vérité.
Les contrôles renvoyés au client suivent partout la forme `{niveau: ERREUR|ALERTE|INFO, code,
message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation explicite
(409 + rappel avec `force_quota: true`).
## Points d'attention
- nginx interdit l'accès direct à `/app/`, `/database/`, `/docker/`, aux fichiers d'amorçage et aux
extensions de configuration (`docker/nginx/default.conf`) — ces règles doivent suivre toute
réorganisation de l'arborescence.
- phpMyAdmin n'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. Ne jamais redéfinir
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
- Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout
déploiement réel.
- Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier —
utiles pour comprendre une décision, mais seul le code fait foi.

View File

@@ -64,6 +64,13 @@ Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis
La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`. La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`.
## V7.8 — Sécurité et droits en base
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
vues SQL sont décrits dans `README_V7_8.md`. La migration
`database/migration_v7_8_securite_acces.sql` est obligatoire ; elle est appliquée
automatiquement au déploiement par le service `db-migrate` du docker-compose.
## V7.4 — Rôles et droits d’accès ## V7.4 — Rôles et droits d’accès
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`. La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.

View File

@@ -9,7 +9,9 @@ Copier les fichiers de ce kit à la racine du projet PTA :
- `.env.example` - `.env.example`
- `docker/nginx/default.conf` - `docker/nginx/default.conf`
- `docker/php/php.ini` - `docker/php/php.ini`
- `docker/db/apply-migrations.sh`
- `database/pta_prod_schema_mariadb.sql` - `database/pta_prod_schema_mariadb.sql`
- `database/migration_*.sql`
Puis : Puis :
@@ -32,7 +34,12 @@ Application :
http://IP_DU_SERVEUR:8080 http://IP_DU_SERVEUR:8080
``` ```
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut. phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
atteint le port.
Sur le serveur lui-même : Sur le serveur lui-même :
```text ```text
@@ -58,8 +65,31 @@ lorsque le volume MariaDB est créé pour la première fois.
Il ne sera PAS rejoué à chaque démarrage du conteneur. Il ne sera PAS rejoué à chaque démarrage du conteneur.
Si la base existe déjà, ne supprimez pas le volume pour appliquer une migration. Exécutez ### Migrations
les migrations explicitement sur la base existante.
Les fichiers `database/migration_*.sql` sont appliqués **automatiquement à chaque
`docker compose up`**, par le service jetable `db-migrate` :
- il attend que MariaDB soit sain, puis joue les migrations dans l'ordre alphabétique ;
- la table `schema_migration` mémorise les fichiers déjà appliqués — rien n'est rejoué
inutilement ;
- le service `php` ne démarre qu'après sa sortie en succès : une migration en échec arrête
le déploiement au lieu de faire tourner l'application sur une base incomplète ;
- il vérifie pour finir la présence des sept vues de contrôle d'accès de la V7.8.
Il n'est donc plus nécessaire de supprimer le volume ni d'exécuter les migrations à la main.
```bash
# Compte rendu de la dernière application
docker compose logs db-migrate
# Rejouer les migrations sans toucher au reste de la pile
docker compose up -d --force-recreate db-migrate
```
Toute nouvelle migration doit être écrite idempotente (`CREATE TABLE IF NOT EXISTS`,
`INSERT ... ON DUPLICATE KEY UPDATE`, `CREATE OR REPLACE VIEW`) : un fichier dont le contenu
change après coup est rejoué.
## Commandes utiles ## Commandes utiles
@@ -73,6 +103,9 @@ docker compose logs -f nginx php db
# Rebuild après modification du PHP / JS / CSS # Rebuild après modification du PHP / JS / CSS
docker compose up -d --build php nginx docker compose up -d --build php nginx
# Journal de l'application des migrations
docker compose logs db-migrate
# Arrêt # Arrêt
docker compose down docker compose down

89
README_V7_10.md Normal file
View File

@@ -0,0 +1,89 @@
# PTA V7.10 — Éditeur de semaine type et méthode de saisie explicite
## Le problème traité
L'écran de saisie ne proposait qu'un seul chemin : choisir un lieu, un agent, une semaine, puis
remplir les cinq jours. Répéter ce travail semaine après semaine pour un même agent était la norme.
Les semaines types existaient déjà, mais elles étaient **subordonnées à ce chemin** :
- on ne pouvait créer un modèle qu'en enregistrant une semaine *déjà planifiée* — impossible de
composer directement une semaine de référence ;
- les trois boutons (« Enregistrer comme semaine type », « Importer une semaine type », « Appliquer
sur une période ») étaient rangés dans l'en-tête de l'étape 3, à côté du bouton PDF, donc trouvés
par hasard ;
- rien n'indiquait que dupliquer un modèle sur une période était la voie la plus rapide.
## Ce qui change
### 1. La méthode de saisie devient un choix explicite
Après la sélection de l'agent, une étape « Choisir la méthode de saisie » propose deux voies :
- **Semaine type** — marquée « Le plus rapide », **ouverte par défaut** : composer une semaine de
référence, puis la dupliquer sur une période entière ;
- **Semaine précise** — saisir ou corriger une seule semaine, jour par jour.
Le second mode est l'écran d'avant, **inchangé** : mêmes champs, mêmes contrôles, mêmes boutons.
Un lien profond qui désigne une semaine (file de validation, tableau de bord, vue annuelle, bouton
« Corriger cette semaine ») ouvre directement le mode **Semaine précise** : la priorité donnée à la
semaine type ne doit jamais détourner quelqu'un qui vient corriger un planning identifié.
Les rôles sans droit aux semaines types — Responsable de structure — ne voient aucun sélecteur : la
page reste exactement celle qu'ils connaissent, et la numérotation des étapes se resserre de 1-2-3.
### 2. Un vrai éditeur de semaine type
Le mode « Semaine type » affiche les cinq jours ouvrés côte à côte. Pour chaque jour :
- ajouter autant de plages que nécessaire, chacune avec son motif, ses horaires et son lieu — une
semaine type peut donc couvrir plusieurs lieux d'affectation, comme le permet le modèle de données ;
- un total par jour, et le total de la semaine avec la part réellement décomptée du quota ;
- **« Copier vers… »** : recopier un jour sur un autre, ou sur tous les autres d'un coup — la façon
la plus courante de construire une semaine régulière.
La barre d'outils permet d'ouvrir une semaine type existante de l'agent, de la renommer, de
l'enregistrer ou de la supprimer. Rien n'est planifié à ce stade : une semaine type est une
description, pas un planning. Deux actions la transforment en plannings réels :
- **Dupliquer sur une période…** — la modale existante, avec ses filtres scolaire/vacances, son mode
compléter/remplacer et sa prévisualisation ;
- **Appliquer à la semaine sélectionnée…** — la modale d'import existante.
Ces deux boutons restent inactifs tant que le modèle n'a pas été enregistré : on ne duplique jamais
un brouillon d'écran, seulement ce qui est réellement en base.
### 3. Les contrôles ne bougent pas
Les plages composées à l'écran sont **intégralement revalidées par le serveur** :
- jours limités au lundi-vendredi ;
- horaires par tranches de 15 minutes, fin postérieure au début ;
- pas de chevauchement entre deux plages du même jour, même sur deux lieux différents — un agent
n'est jamais à deux endroits à la fois ;
- lieu actif, motif actif, et **« Formation » refusé pour un agent sans droit à l'enveloppe de
formation**, exactement comme à la saisie semaine par semaine.
L'écran effectue les mêmes contrôles avant d'envoyer, uniquement pour éviter un aller-retour inutile.
## Fichiers
| Ajouté | Rôle |
| --- | --- |
| `assets/js/week-template-editor.js` | module `PTA.modules.weekTemplateEditor` |
| `api/week_template.php` | contenu détaillé d'une semaine type (GET) |
| `api/save_week_template_entries.php` | enregistrement depuis l'éditeur (POST) |
| Modifié | Raison |
| --- | --- |
| `app/Models/WeekTemplateModel.php` | `saveFromEntries()`, validation des plages, persistance factorisée avec `saveFromWeek()` |
| `app/Controllers/WeekTemplateController.php` | `show()` et `storeEntries()` |
| `app/Views/partials/planning_editor.php` | sélecteur de méthode et panneau de l'éditeur |
| `app/Controllers/PageController.php` | chargement du module, sous-titre selon le droit |
| `assets/js/week-templates.js` | `openApplyModal()` / `openPeriodModal()` exposés, avec modèle présélectionné |
| `assets/js/page-bootstrap.js` | un lien profond vers une semaine ouvre le mode « Semaine précise » |
| `assets/style.css` | styles du sélecteur de méthode et de l'éditeur |
Aucune migration SQL : `modele_semaine_agent` et `modele_semaine_creneau` portaient déjà exactement
ce que l'éditeur produit (jour, lieu, motif, horaires).

139
README_V7_8.md Normal file
View File

@@ -0,0 +1,139 @@
# PTA V7.8 — Correctifs de sécurité et descente des droits en base
Cette version ne change aucune règle métier. Elle corrige les failles relevées
lors de l'audit et déplace l'intégralité des règles d'accès dans la base.
## Failles corrigées
### 1. phpMyAdmin ouvert sans mot de passe (critique)
`docker-compose.yml` fournissait `PMA_USER` et `PMA_PASSWORD` au conteneur
phpMyAdmin. Ces deux variables font basculer phpMyAdmin en `auth_type=config` :
toute personne atteignant le port arrivait sur une session **déjà
authentifiée**, avec accès complet en lecture et en écriture à la base. Le
`.env` publiait de surcroît ce port sur `0.0.0.0`, donc sur tout le réseau.
Les deux variables ont été retirées et le port est revenu sur `127.0.0.1`.
phpMyAdmin réclame désormais un identifiant MariaDB à chaque connexion. Pour y
accéder depuis un poste distant : `ssh -L 8081:127.0.0.1:8081 utilisateur@serveur`.
### 2. XSS stocké via les attributs HTML
`PTA.utils.escapeHtml` s'appuyait sur `textContent` / `innerHTML`, qui
n'échappent ni `"` ni `'`. La fonction était pourtant utilisée dans une
vingtaine de contextes d'attribut (`value="…"`, `title="…"`, `aria-label="…"`).
Un libellé contenant un guillemet permettait de sortir de l'attribut et
d'injecter du script.
Les vecteurs réalistes étaient les libellés de vacances scolaires — importés
depuis une API externe — ainsi que les noms de lieux, les noms d'agents, les
libellés de diplôme et les commentaires PTA.
`escapeHtml` échappe désormais les cinq caractères et reste valable en contexte
texte comme en contexte attribut. L'implémentation dupliquée `escapeAttr` de
`annual-overview.js` a été supprimée au profit de la fonction commune.
### 3. Règles de refus nginx sans effet
Les blocs interdisant `config.php`, `db.php` et `page_bootstrap.php` étaient
placés **après** `location ~ \.php$`. nginx retenant la première expression
régulière correspondante, ces refus n'ont jamais été appliqués : les trois
fichiers d'amorçage étaient exécutables depuis le navigateur.
L'ordre des blocs a été corrigé et la couverture élargie (`/docker/`, fichiers
`.env`, `.sql`, `.ini`, `.log`, `.md`).
### 4. Divulgation d'informations techniques
`PTA_DEBUG` valait `1` par défaut : le message des exceptions — requêtes SQL,
noms de tables, chemins serveur — était renvoyé au navigateur. Les pages
d'erreur de `page_bootstrap.php` et de `role.php` affichaient de la même façon
`getMessage()`, et `db.php` y ajoutait l'hôte, l'utilisateur et le nom de la
base issus du message PDO.
Le mode debug est désormais désactivé par défaut. Tout détail technique est
journalisé côté serveur via `App\Core\Security::reportThrowable()` et n'atteint
le client que si `PTA_DEBUG` est explicitement activé.
### 5. Absence d'en-têtes de sécurité
Les réponses HTML portent maintenant une `Content-Security-Policy` **à nonce** :
`script-src 'self' 'nonce-…'`, sans `unsafe-inline`. PTA ne contient aucun
gestionnaire d'événement inline ni style inline, la politique peut donc rester
stricte. S'y ajoutent `X-Content-Type-Options`, `X-Frame-Options`,
`Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` et, en
HTTPS, `Strict-Transport-Security`.
Tout nouveau `<script>` inline doit porter `nonce="<?= $cspNonce ?>"`, sinon le
navigateur refusera de l'exécuter.
### 6. Session et CSRF
`session_start()` était appelé à trois endroits sans paramètres de cookie.
`App\Core\Session::start()` centralise le démarrage et impose `HttpOnly`,
`SameSite=Lax`, `use_strict_mode` et `secure` en HTTPS — y compris hors du
conteneur Docker, où le `php.ini` fourni ne s'applique pas.
Le changement de rôle (`role.php?action=reset`), qui modifiait l'état de la
session via un simple GET, exige désormais le jeton CSRF. Le jeton est
régénéré à chaque changement de rôle.
### 7. Secrets versionnables
Le dépôt n'avait ni `.gitignore` ni `.dockerignore`. Le `.env` contenant les
mots de passe MariaDB était prêt à être commité, et `COPY . /var/www/html`
copiait `.env` et `.git` dans l'image publiée. Les deux fichiers ont été créés.
## Descente des droits en base
`app/Core/Access.php` ne contient plus aucune règle : la matrice des droits et
les périmètres de visibilité sont lus dans des vues SQL.
| Vue | Rôle |
| ------------------------------ | ----------------------------------------------------------- |
| `v_acces_role_page` | pages autorisées par rôle |
| `v_acces_role_permission` | permissions fonctionnelles par rôle |
| `v_acces_identite_agent` | identités d'agents actifs sélectionnables |
| `v_acces_identite_structure` | lieux d'affectation actifs sélectionnables |
| `v_acces_agent_par_structure` | quels agents un lieu peut consulter (rattachement ou planif.) |
| `v_acces_perimetre_creneau` | lieu et planning d'un créneau, avant modification |
| `v_acces_perimetre_planning` | lieu et agent d'un planning, avant validation |
Les deux premières s'appuient sur les tables `acces_role_page` et
`acces_role_permission` : **modifier un droit se fait désormais en SQL, sans
redéploiement**. Une permission absente de la base vaut « refusé ».
`SchemaModel::assertCurrent()` vérifie la présence des sept vues au démarrage.
Si la migration n'a pas été jouée, l'application refuse de démarrer plutôt que
de tourner sans contrôle de périmètre.
## Installation
La migration est **appliquée automatiquement au déploiement**. Le service jetable `db-migrate`
du compose joue `database/migration_v7_8_securite_acces.sql` dès que MariaDB est sain, et le
service `php` n'est démarré qu'après sa sortie en succès.
1. Sauvegarder la base.
2. Déployer :
```bash
docker compose up -d --build
docker compose logs db-migrate # doit afficher « Base à jour »
```
La migration est idempotente et suivie dans la table `schema_migration` : un second déploiement
l'ignore. Elle reste jouable à la main si besoin :
```sql
SOURCE database/migration_v7_8_securite_acces.sql;
```
## Reste à traiter
Le sélecteur de `role.php` n'est toujours **pas une authentification** :
n'importe quel visiteur peut choisir « Service Enfance » et obtenir le contrôle
complet de l'application. Tant que ce point n'est pas traité, aucune des
corrections ci-dessus ne protège une instance exposée sur Internet.
Le mot de passe MariaDB doit également être changé : le `.env` reprend encore la
valeur d'exemple.

118
README_V7_9.md Normal file
View File

@@ -0,0 +1,118 @@
# PTA V7.9 — Parcours et lisibilité du Service Enfance
## Le problème traité
Le rôle **Service Enfance** ouvre onze écrans. Jusqu'ici :
- il atterrissait sur **Saisie**, alors que son travail commence par le contrôle et non par la saisie ;
- les onze onglets étaient alignés à plat, sans hiérarchie : rien ne distinguait une tâche
quotidienne (« À valider ») d'un référentiel modifié deux fois par an (« Administration ») ;
- chaque bandeau de page affichait le **même** sous-titre générique, il fallait donc ouvrir un écran
pour savoir à quoi il servait ;
- les chiffres qui déclenchent une action (brouillons en attente, manques de couverture, quotas)
vivaient sur trois écrans distincts : personne ne voyait l'état général sans faire le tour ;
- la pastille « À valider » de la barre de navigation n'était alimentée que **sur la page
« À valider » elle-même**, donc invisible là où elle aurait servi.
## Ce qui change
### 1. Un tableau de bord comme page d'accueil
Nouvelle page `dashboard.php`, **réservée au Service Enfance**, qui devient sa page d'atterrissage.
- **Quatre tuiles d'action**, vertes quand il n'y a rien à faire, rouges dès qu'un compteur remonte :
plannings à valider, manques de couverture de la semaine, quotas dépassés, lieux sans horaires de
couverture. Chaque tuile est un lien vers l'écran de traitement.
- **« À valider en priorité »** : les cinq brouillons dont la semaine est la plus ancienne, avec un
bouton **Contrôler** qui ouvre directement l'écran de saisie sur le bon agent, le bon lieu, la
bonne semaine, curseur sur la validation.
- **« Couverture à renforcer »** : les cinq plages les plus déficitaires, triées par nombre d'agents
manquants, avec un lien vers l'écran de couverture déjà filtré.
- **« Suivi des quotas annuels »** : consommation globale, reste à affecter, agents sans aucune heure,
et le détail par agent avec une jauge et un lien vers sa vue annuelle.
- **« Actions fréquentes »** : six raccourcis vers les écrans les plus utilisés.
Un sélecteur de semaine en haut de page rejoue l'ensemble des indicateurs de couverture, et indique
si la semaine est scolaire, de vacances, ou à cheval sur les deux.
### 2. Navigation regroupée par intention
Les onglets sont désormais rangés en quatre groupes : **Piloter** (tableau de bord, à valider,
couverture), **Planifier** (saisie, équipes), **Consulter** (par agent, par lieu, vue annuelle, PTA
détaillé), **Paramétrer** (vacances, administration).
Les rôles qui n'ont que quelques pages — Agent (2), Responsable de structure (4) — gardent la barre
simple d'avant, sans titres de groupe. Une page autorisée en base mais absente des groupes reste
affichée dans un groupe « Autres » plutôt que de disparaître.
### 3. Chaque page dit ce qu'on y fait
Le sous-titre du bandeau est propre à chaque écran et décrit l'action, pas le produit.
### 4. La pastille « À valider » suit partout
Elle est alimentée depuis n'importe quel écran par un compteur léger
(`api/dashboard_summary.php?scope=badges`). L'échec de cet appel est silencieux : une commodité ne
doit jamais empêcher une page de fonctionner.
## Ce qui ne change pas
Aucune règle métier, aucun calcul, aucun droit n'est modifié.
Le tableau de bord ne fait que **relire** ce que les autres écrans calculent déjà : la file de
validation vient de `PlanningModel::pendingValidation()`, la couverture de
`CoverageModel::reportForStructure()`, et le quota reprend exactement la logique de `QuotaModel`
(année civile, motifs décomptés uniquement, quota de l'année la plus proche à défaut de l'année
exacte) — un agent ne peut donc pas afficher deux valeurs différentes selon l'écran consulté.
L'endpoint `api/dashboard_summary.php` est gardé par `requireService()` : 403 pour les autres rôles,
401 sans session.
## Migration
Aucun geste manuel : `database/migration_v7_9_tableau_de_bord.sql` est appliqué au démarrage par le
service `db-migrate`, qui joue tous les `database/migration_*.sql` dans l'ordre. Un simple
```bash
docker compose up -d --build
```
suffit, sur base neuve comme sur base existante. La migration insère une seule ligne dans
`acces_role_page` et est idempotente (`ON DUPLICATE KEY UPDATE`).
Si la migration n'était pas jouée, l'onglet n'apparaîtrait pas, le Service Enfance atterrirait comme
avant sur l'écran de saisie et `dashboard.php` renverrait vers l'accueil : l'application resterait
entièrement fonctionnelle, simplement sans tableau de bord.
Retour arrière :
```sql
DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard';
DELETE FROM schema_migration WHERE fichier = 'migration_v7_9_tableau_de_bord.sql';
```
## Fichiers
| Ajouté | Rôle |
| --- | --- |
| `dashboard.php` | point d'entrée de la page |
| `api/dashboard_summary.php` | endpoint JSON |
| `app/Controllers/DashboardController.php` | agrégation et gardes d'accès |
| `app/Models/DashboardModel.php` | suivi des quotas et compteurs |
| `app/Views/pages/dashboard.php`, `app/Views/partials/dashboard.php` | vue |
| `assets/js/dashboard.js` | module `PTA.modules.dashboard` |
| `database/migration_v7_9_tableau_de_bord.sql` | droit d'accès à la page |
| Modifié | Raison |
| --- | --- |
| `docker-compose.yml` | service `db-migrate`, port interne de la base rétabli à 3306, défaut de mot de passe aligné entre `db` et `php` |
| `docker/db/apply-migrations.sh` | application automatique et idempotente des migrations |
| `app/Core/Access.php` | `homeUrl()` vise le tableau de bord, avec repli si la migration manque |
| `app/Controllers/PageController.php` | méthode `dashboard()` et sous-titre propre à chaque page |
| `app/Views/layouts/header.php` | navigation regroupée, sous-titre contextuel |
| `app/Views/layouts/footer.php`, `app/Views/pages/role.php` | versions d'assets `?v=7.9` |
| `assets/js/page-bootstrap.js` | pastille « À valider » sur toutes les pages |
| `assets/style.css` | styles de la navigation groupée et du tableau de bord |
Les jauges de quota utilisent l'élément natif `<progress>` : la CSP interdit l'attribut `style`, une
largeur ne peut donc pas être calculée en JavaScript sur une simple `div`.

12
api/dashboard_summary.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
use App\Controllers\DashboardController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new DashboardController($pdo))->summary();

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new WeekTemplateController($pdo))->storeEntries();

12
api/week_template.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/app/autoload.php';
use App\Controllers\WeekTemplateController;
use App\Core\JsonResponse;
JsonResponse::bootstrap();
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new WeekTemplateController($pdo))->show();

View File

@@ -9,6 +9,7 @@ use App\Core\Csrf;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\PdfResponse; use App\Core\PdfResponse;
use App\Core\Request; use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel; use App\Models\AgentModel;
use App\Models\QuotaModel; use App\Models\QuotaModel;
use App\Models\StructureModel; use App\Models\StructureModel;
@@ -360,7 +361,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null; $contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); // Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); $ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
@@ -404,13 +407,13 @@ final class AgentController
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null, 'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId, 'id_poste' => (int) $postId,
'type_contrat' => $contractType, 'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned, 'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]); ]);
} catch (Throwable $e) { } catch (Throwable $e) {
JsonResponse::send(500, [ JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.', 'error' => 'Impossible de créer l’agent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null, 'details' => Security::debugEnabled() ? $e->getMessage() : null,
]); ]);
} }
@@ -435,7 +438,7 @@ final class AgentController
'id_poste' => (int) $postId, 'id_poste' => (int) $postId,
'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''), 'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''),
'type_contrat' => $contractType, 'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0, 'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]), ]),
]); ]);
@@ -460,7 +463,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null; $contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT); $postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT'))); $contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
$trainingPlanned = filter_var($payload['formation_repartition_annuelle'] ?? false, FILTER_VALIDATE_BOOLEAN); // Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? '')); $ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts()); $validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
$rawStructure = $payload['structure_id'] ?? null; $rawStructure = $payload['structure_id'] ?? null;
@@ -501,7 +506,7 @@ final class AgentController
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null, 'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId, 'id_poste' => (int) $postId,
'type_contrat' => $contractType, 'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned, 'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null, 'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
'structure_id' => $structureId !== null ? (int) $structureId : null, 'structure_id' => $structureId !== null ? (int) $structureId : null,
]); ]);

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\JsonResponse;
use App\Models\CoverageModel;
use App\Models\DashboardModel;
use App\Models\PlanningModel;
use App\Models\StructureModel;
use App\Models\VacationModel;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
/**
* Tableau de bord du Service Enfance.
*
* Le tableau de bord n'ouvre aucun droit et n'applique aucune règle nouvelle :
* il rassemble sur un seul écran les compteurs déjà calculés par les écrans
* « À valider », « Couverture » et « Vue annuelle », et les transforme en
* liens directs vers l'action correspondante.
*/
final class DashboardController
{
private const APERCU_MAX = 5;
private const QUOTA_APERCU_MAX = 8;
private Access $access;
private DashboardModel $dashboardModel;
private PlanningModel $planningModel;
private CoverageModel $coverageModel;
private StructureModel $structureModel;
private VacationModel $vacationModel;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
$this->dashboardModel = new DashboardModel($pdo);
$this->planningModel = new PlanningModel($pdo);
$this->coverageModel = new CoverageModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->vacationModel = new VacationModel($pdo);
}
public function summary(): void
{
$this->access->requireService();
// Portée « badges » : uniquement le compteur de la pastille de
// navigation, appelé depuis toutes les pages. Il doit rester léger.
if (trim((string) ($_GET['scope'] ?? '')) === 'badges') {
JsonResponse::send(200, [
'validation' => ['count' => $this->dashboardModel->pendingCount()],
]);
}
$week = $this->resolveWeek($_GET['week'] ?? null);
// L'année du quota suit l'année ISO de la semaine affichée : une
// semaine à cheval sur deux années civiles reste rattachée à l'année
// qui contient son jeudi, comme partout ailleurs dans PTA.
$year = $this->resolveYear($_GET['year'] ?? null, (int) $week['iso_year']);
JsonResponse::send(200, [
'semaine' => $this->weekPayload($week),
'validation' => $this->validationPayload(),
'couverture' => $this->coveragePayload((int) $week['iso_year'], (int) $week['iso_week']),
'quota' => $this->quotaPayload($year),
'perimetre' => $this->dashboardModel->scope(),
]);
}
/**
* File de validation : le volume total, le volume décompté et les
* brouillons les plus anciens, qui sont ceux à traiter en premier.
*/
private function validationPayload(): array
{
$plannings = $this->planningModel->pendingValidation();
$totalMinutes = 0;
$countedMinutes = 0;
$structures = [];
$agents = [];
foreach ($plannings as $planning) {
$totalMinutes += (int) $planning['total_minutes'];
$countedMinutes += (int) $planning['minutes_decomptees'];
$structures[(int) $planning['id_structure']] = true;
$agents[(int) $planning['id_agent']] = true;
}
// pendingValidation() trie déjà par semaine croissante : les premiers
// éléments sont les plus anciens, donc les plus urgents.
$apercu = array_slice($plannings, 0, self::APERCU_MAX);
return [
'count' => count($plannings),
'total_minutes' => $totalMinutes,
'minutes_decomptees' => $countedMinutes,
'structure_count' => count($structures),
'agent_count' => count($agents),
'semaine_la_plus_ancienne' => $plannings[0]['date_debut'] ?? null,
'apercu' => array_map(static function (array $planning): array {
return [
'id_planning' => (int) $planning['id_planning'],
'id_agent' => (int) $planning['id_agent'],
'id_structure' => (int) $planning['id_structure'],
'agent_nom' => (string) $planning['agent_nom'],
'agent_prenom' => (string) $planning['agent_prenom'],
'structure_nom' => (string) $planning['structure_nom'],
'annee' => (int) $planning['annee'],
'numero_semaine' => (int) $planning['numero_semaine'],
'date_debut' => (string) $planning['date_debut'],
'date_fin_ouvrable' => (string) ($planning['date_fin_ouvrable'] ?? $planning['date_fin']),
'nombre_creneaux' => (int) $planning['nombre_creneaux'],
'total_minutes' => (int) $planning['total_minutes'],
'minutes_decomptees' => (int) $planning['minutes_decomptees'],
];
}, $apercu),
];
}
/**
* Couverture de la semaine affichée, tous lieux actifs confondus.
* Reprend exactement le calcul de l'écran « Couverture ».
*/
private function coveragePayload(int $year, int $weekNumber): array
{
$gaps = [];
$unconfigured = [];
$affectedStructures = [];
$gapMinutes = 0;
$validationWarningCount = 0;
foreach ($this->structureModel->allActive() as $structure) {
$structureId = (int) $structure['id_structure'];
$report = $this->coverageModel->reportForStructure($structureId, $year, $weekNumber);
if (!$report['configured']) {
$unconfigured[] = [
'id_structure' => $structureId,
'nom' => (string) $structure['nom'],
'code' => (string) $structure['code'],
];
continue;
}
$validationWarningCount += (int) $report['summary']['validation_warning_count'];
foreach ($report['gaps'] as $gap) {
$gaps[] = [
'id_structure' => $structureId,
'structure_nom' => (string) $structure['nom'],
'date' => (string) $gap['date'],
'jour_libelle' => (string) $gap['jour_libelle'],
'heure_debut' => (string) $gap['heure_debut'],
'heure_fin' => (string) $gap['heure_fin'],
'minimum_agents' => (int) $gap['minimum_agents'],
'agents_planifies' => (int) $gap['agents_planifies'],
'agents_manquants' => (int) $gap['agents_manquants'],
];
$affectedStructures[$structureId] = true;
}
$gapMinutes += (int) $report['summary']['gap_minutes'];
}
// Les manques les plus importants d'abord : c'est là que le service
// doit agir en priorité.
usort($gaps, static function (array $a, array $b): int {
return [$b['agents_manquants'], $a['date'], $a['heure_debut']]
<=> [$a['agents_manquants'], $b['date'], $b['heure_debut']];
});
return [
'gap_count' => count($gaps),
'gap_minutes' => $gapMinutes,
'structure_count' => count($affectedStructures),
'validation_warning_count' => $validationWarningCount,
'unconfigured_count' => count($unconfigured),
'unconfigured' => array_slice($unconfigured, 0, self::APERCU_MAX),
'apercu' => array_slice($gaps, 0, self::APERCU_MAX),
];
}
/**
* Suivi des quotas : combien d'agents dépassent leur quota annuel et combien
* n'ont encore aucune heure planifiée. Les cumuls tous agents confondus
* (consommation globale, reste à affecter) ne sont volontairement pas exposés :
* une somme d'agents aux quotités différentes ne décrit aucune situation réelle.
*/
private function quotaPayload(int $year): array
{
$tracking = $this->dashboardModel->quotaTracking($year);
$overCount = 0;
$emptyCount = 0;
foreach ($tracking as $agent) {
if ($agent['minutes_restantes'] < 0) {
$overCount++;
}
if ($agent['minutes_affectees'] === 0) {
$emptyCount++;
}
}
// Tri décroissant sur le taux : les dépassements remontent en tête,
// les agents non planifiés restent visibles en bas de liste.
usort($tracking, static function (array $a, array $b): int {
return $b['taux_consommation'] <=> $a['taux_consommation'];
});
return [
'annee' => $year,
'agent_count' => count($tracking),
'depassement_count' => $overCount,
'sans_heure_count' => $emptyCount,
'apercu' => array_slice($tracking, 0, self::QUOTA_APERCU_MAX),
];
}
/**
* Contexte de la semaine affichée : dates, et caractère scolaire ou
* extrascolaire déduit des périodes de vacances enregistrées.
*/
private function weekPayload(array $week): array
{
/** @var DateTimeImmutable $start */
$start = $week['start'];
$end = $start->modify('+4 days');
$days = $this->vacationModel->typesForIsoWeek((int) $week['iso_year'], (int) $week['iso_week']);
$vacationLabels = [];
$vacationDays = 0;
foreach ($days as $day) {
if ($day['type_affectation'] === 'EXTRASCOLAIRE') {
$vacationDays++;
if ($day['vacances'] !== null) {
$vacationLabels[$day['vacances']['libelle']] = true;
}
}
}
if ($vacationDays === 0) {
$contexte = 'PERISCOLAIRE';
} elseif ($vacationDays === count($days)) {
$contexte = 'EXTRASCOLAIRE';
} else {
$contexte = 'MIXTE';
}
return [
'valeur' => $week['value'],
'annee' => (int) $week['iso_year'],
'numero' => (int) $week['iso_week'],
'date_debut' => $start->format('Y-m-d'),
'date_fin' => $end->format('Y-m-d'),
'contexte' => $contexte,
'jours_vacances' => $vacationDays,
'vacances_libelles' => array_keys($vacationLabels),
];
}
/**
* @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable}
*/
private function resolveWeek(mixed $value): array
{
$raw = trim((string) $value);
if (preg_match('/^(\d{4})-W(\d{2})$/', $raw, $matches) === 1) {
$isoYear = (int) $matches[1];
$isoWeek = (int) $matches[2];
if ($isoWeek >= 1 && $isoWeek <= 53) {
$start = (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))
->setISODate($isoYear, $isoWeek, 1)
->setTime(0, 0);
return [
'value' => $raw,
'iso_year' => $isoYear,
'iso_week' => $isoWeek,
'start' => $start,
];
}
}
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
return [
'value' => $now->format('o-\WW'),
'iso_year' => (int) $now->format('o'),
'iso_week' => (int) $now->format('W'),
'start' => $now->setISODate((int) $now->format('o'), (int) $now->format('W'), 1)->setTime(0, 0),
];
}
private function resolveYear(mixed $value, int $fallback): int
{
$year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]);
return $year === false ? $fallback : (int) $year;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel; use App\Models\AgentModel;
use App\Models\MotifModel; use App\Models\MotifModel;
use App\Models\StructureModel; use App\Models\StructureModel;
@@ -23,6 +24,20 @@ final class PageController extends Controller
$this->access = new Access($pdo); $this->access = new Access($pdo);
} }
public function dashboard(): void
{
$this->access->requirePage('dashboard');
$this->renderPage('dashboard', 'Tableau de bord', 'dashboard', [
'pageSubtitle' => 'Ce qui attend le Service Enfance : plannings à valider, couverture des lieux et suivi des quotas annuels.',
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['dashboard.js'],
'modules' => ['dashboard'],
'pageConfig' => [
'week' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
],
]);
}
public function planning(): void public function planning(): void
{ {
$this->access->requirePage('planning'); $this->access->requirePage('planning');
@@ -32,9 +47,14 @@ final class PageController extends Controller
$modules = ['planning']; $modules = ['planning'];
if (!empty($permissions['can_use_templates'])) { if (!empty($permissions['can_use_templates'])) {
$scripts[] = 'week-templates.js'; $scripts[] = 'week-templates.js';
$scripts[] = 'week-template-editor.js';
$modules[] = 'weekTemplates'; $modules[] = 'weekTemplates';
$modules[] = 'weekTemplateEditor';
} }
$this->renderPage('planning', 'Saisie du planning', 'planning', [ $this->renderPage('planning', 'Saisie du planning', 'planning', [
'pageSubtitle' => !empty($permissions['can_use_templates'])
? 'Composer une semaine type et la dupliquer sur une période, ou saisir une semaine en particulier.'
: 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
'structures' => $this->loadStructures(), 'structures' => $this->loadStructures(),
'motifs' => $this->loadMotifs(), 'motifs' => $this->loadMotifs(),
'currentWeek' => $this->currentWeek(), 'currentWeek' => $this->currentWeek(),
@@ -55,6 +75,7 @@ final class PageController extends Controller
{ {
$this->access->requirePage('pending'); $this->access->requirePage('pending');
$this->renderPage('pending', 'Plannings à valider', 'pending', [ $this->renderPage('pending', 'Plannings à valider', 'pending', [
'pageSubtitle' => 'La file des brouillons prêts à être contrôlés. Ouvrir un planning charge l’écran de saisie sur la bonne semaine.',
'scripts' => ['pending-validation.js'], 'scripts' => ['pending-validation.js'],
'modules' => ['pendingValidation'], 'modules' => ['pendingValidation'],
]); ]);
@@ -65,6 +86,7 @@ final class PageController extends Controller
$this->access->requirePage('coverage'); $this->access->requirePage('coverage');
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null; $forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
$this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [ $this->renderPage('coverage', 'Contrôle de couverture', 'coverage', [
'pageSubtitle' => 'Les plages où le nombre d’agents planifiés passe sous le minimum attendu du lieu.',
'structures' => $this->loadStructures(), 'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(), 'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['coverage.js'], 'scripts' => ['coverage.js'],
@@ -81,6 +103,7 @@ final class PageController extends Controller
$this->access->requirePage('structures'); $this->access->requirePage('structures');
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null; $forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
$this->renderPage('structures', 'Planning par lieu', 'structures', [ $this->renderPage('structures', 'Planning par lieu', 'structures', [
'pageSubtitle' => 'Le planning d’un lieu sur une semaine : les agents rattachés et ceux qui y sont réellement planifiés. Lecture seule.',
'structures' => $this->loadStructures(), 'structures' => $this->loadStructures(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(), 'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['structure-overview.js'], 'scripts' => ['structure-overview.js'],
@@ -97,6 +120,9 @@ final class PageController extends Controller
$this->access->requirePage('agents'); $this->access->requirePage('agents');
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null; $forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
$this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [ $this->renderPage('agents', $forcedAgent ? 'Mon planning' : 'Planning par agent', 'agents', [
'pageSubtitle' => $forcedAgent
? 'Vos horaires mois par mois, tous lieux d’affectation confondus.'
: 'Le planning d’un agent mois par mois, tous lieux d’affectation confondus, avec le suivi de son quota.',
'agents' => $this->loadAgents(), 'agents' => $this->loadAgents(),
'structures' => $this->loadStructuresForAgentEditor(), 'structures' => $this->loadStructuresForAgentEditor(),
'motifs' => $this->loadMotifs(), 'motifs' => $this->loadMotifs(),
@@ -114,6 +140,7 @@ final class PageController extends Controller
{ {
$this->access->requirePage('annual'); $this->access->requirePage('annual');
$this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [ $this->renderPage('annual', 'Vue annuelle du PTA', 'annual', [
'pageSubtitle' => 'L’annualisation d’un agent de janvier à décembre, et la validation de toute l’année en une fois.',
'agents' => (new AgentModel($this->pdo))->allActive(), 'agents' => (new AgentModel($this->pdo))->allActive(),
'scripts' => ['annual-overview.js'], 'scripts' => ['annual-overview.js'],
'modules' => ['annualOverview'], 'modules' => ['annualOverview'],
@@ -128,6 +155,7 @@ final class PageController extends Controller
{ {
$this->access->requirePage('pta'); $this->access->requirePage('pta');
$this->renderPage('pta', 'PTA annuel par agent', 'pta', [ $this->renderPage('pta', 'PTA annuel par agent', 'pta', [
'pageSubtitle' => 'Le PTA détaillé d’un agent : souhaits de lieux, contraintes et répartition des heures.',
'agents' => (new AgentModel($this->pdo))->allActive(), 'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['pta.js'], 'scripts' => ['pta.js'],
@@ -143,6 +171,7 @@ final class PageController extends Controller
{ {
$this->access->requirePage('teams'); $this->access->requirePage('teams');
$this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [ $this->renderPage('teams', 'Équipes des mercredis et vacances', 'teams', [
'pageSubtitle' => 'La composition des équipes sur les mercredis et les périodes de vacances.',
'agents' => (new AgentModel($this->pdo))->allActive(), 'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['teams.js'], 'scripts' => ['teams.js'],
@@ -158,6 +187,7 @@ final class PageController extends Controller
{ {
$this->access->requirePage('vacations'); $this->access->requirePage('vacations');
$this->renderPage('vacations', 'Vacances scolaires', 'vacations', [ $this->renderPage('vacations', 'Vacances scolaires', 'vacations', [
'pageSubtitle' => 'Les périodes de vacances qui déterminent le contexte périscolaire ou extrascolaire de chaque journée.',
'scripts' => ['vacation-calendar.js'], 'scripts' => ['vacation-calendar.js'],
'modules' => ['vacationCalendar'], 'modules' => ['vacationCalendar'],
]); ]);
@@ -167,6 +197,7 @@ final class PageController extends Controller
{ {
$this->access->requirePage('administration'); $this->access->requirePage('administration');
$this->renderPage('administration', 'Administration', 'administration', [ $this->renderPage('administration', 'Administration', 'administration', [
'pageSubtitle' => 'Les référentiels de PTA : agents, lieux d’affectation, postes et horaires de couverture attendus.',
'agents' => (new AgentModel($this->pdo))->allActive(), 'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => (new AgentModel($this->pdo))->allPosts(), 'posts' => (new AgentModel($this->pdo))->allPosts(),
@@ -175,12 +206,32 @@ final class PageController extends Controller
]); ]);
} }
/**
* Guide d'utilisation. Page purement statique : aucun script, aucun appel
* API, aucune donnée métier. Elle suit malgré tout la règle des droits en
* base — tant que la migration n'est pas jouée, la page est refusée et le
* bouton d'accès n'apparaît pas dans la barre de navigation.
*/
public function faq(): void
{
$this->access->requirePage('faq');
$this->renderPage('faq', 'Guide d’utilisation', 'faq', [
'pageSubtitle' => 'Le parcours complet, du paramétrage des lieux à la validation des plannings, et les réponses aux questions les plus fréquentes.',
]);
}
private function renderPage(string $view, string $title, string $activePage, array $data = []): void private function renderPage(string $view, string $title, string $activePage, array $data = []): void
{ {
Security::sendHtmlHeaders();
$profile = $this->access->profile(); $profile = $this->access->profile();
$this->render('pages/' . $view, array_merge([ $this->render('pages/' . $view, array_merge([
'pageTitle' => $title, 'pageTitle' => $title,
// Chaque page explique en une phrase ce qu'on y fait : le bandeau
// générique ne disait rien et obligeait à ouvrir l'écran pour
// comprendre à quoi il servait.
'pageSubtitle' => 'Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.',
'activePage' => $activePage, 'activePage' => $activePage,
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(), 'csrfToken' => Csrf::ensureToken(),
'scripts' => [], 'scripts' => [],
'modules' => [], 'modules' => [],

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\MotifCategories;
use App\Core\Request; use App\Core\Request;
use App\Models\AgentModel; use App\Models\AgentModel;
use App\Models\MotifModel; use App\Models\MotifModel;
@@ -98,7 +99,8 @@ final class PlanningController
if (!is_array($rawEntries)) { if (!is_array($rawEntries)) {
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']); JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
} }
if ($this->agentModel->findActive((int) $agentId) === null) { $agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
} }
if ($this->structureModel->findActive((int) $structureId) === null) { if ($this->structureModel->findActive((int) $structureId) === null) {
@@ -107,7 +109,10 @@ final class PlanningController
[$year, $weekNumber] = $week; [$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d'); $weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds()); // Les motifs autorisés dépendent de l'agent : « Formation » n'est pas
// affectable à un agent sans droit à l'enveloppe de formation.
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
$this->assertNoInternalOverlap($entries); $this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap( $this->assertNoOtherLocationOverlap(
$entries, $entries,
@@ -169,7 +174,12 @@ final class PlanningController
if ($this->structureModel->findActive((int) $structureId) === null) { if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
} }
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) { $entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
if ($entryAgent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
$this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent);
if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), true)) {
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']); JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
} }
@@ -550,6 +560,48 @@ final class PlanningController
return [$year, $week]; return [$year, $week];
} }
/**
* Refuse les affectations portant un motif fermé à l'agent, avec un message
* qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais
* sans dire pourquoi. L'interface masque déjà ces motifs — reste les
* plannings saisis avant le retrait du droit, et les appels directs.
*
* @param array<int, mixed> $rawEntries
*/
private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void
{
if (AgentModel::hasTrainingRight($agent)) {
return;
}
$restricted = [];
foreach ($this->motifModel->allActive() as $motif) {
if (MotifCategories::requiresTrainingRight((string) $motif['code'])) {
$restricted[(int) $motif['id_motif']] = (string) $motif['libelle'];
}
}
if ($restricted === []) {
return;
}
foreach ($rawEntries as $entry) {
if (!is_array($entry)) {
continue;
}
$motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT);
if (isset($restricted[$motifId])) {
JsonResponse::send(422, [
'error' => sprintf(
'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.',
$restricted[$motifId],
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? '')
),
]);
}
}
}
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{ {
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0); $weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access; use App\Core\Access;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel; use App\Models\AgentModel;
use App\Models\StructureModel; use App\Models\StructureModel;
use PDO; use PDO;
@@ -21,8 +22,13 @@ final class RoleController extends Controller
{ {
$access = new Access($this->pdo); $access = new Access($this->pdo);
// Le changement de rôle modifie l'état de la session : il est protégé
// par le jeton CSRF au même titre que les écritures de l'API.
if (($_GET['action'] ?? '') === 'reset') { if (($_GET['action'] ?? '') === 'reset') {
$access->clear(); $provided = (string) ($_GET['csrf_token'] ?? '');
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
$access->clear();
}
header('Location: role.php', true, 302); header('Location: role.php', true, 302);
exit; exit;
} }
@@ -47,8 +53,10 @@ final class RoleController extends Controller
} }
} }
Security::sendHtmlHeaders();
$this->render('pages/role', [ $this->render('pages/role', [
'pageTitle' => 'Choisir votre accès', 'pageTitle' => 'Choisir votre accès',
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(), 'csrfToken' => Csrf::ensureToken(),
'agents' => (new AgentModel($this->pdo))->allActive(), 'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(),

View File

@@ -8,8 +8,11 @@ use App\Core\Access;
use App\Core\Csrf; use App\Core\Csrf;
use App\Core\JsonResponse; use App\Core\JsonResponse;
use App\Core\Request; use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel; use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\QuotaModel; use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Models\WeekTemplateModel; use App\Models\WeekTemplateModel;
use DateTimeImmutable; use DateTimeImmutable;
use DomainException; use DomainException;
@@ -55,6 +58,120 @@ final class WeekTemplateController
JsonResponse::send(200, ['templates' => $templates]); JsonResponse::send(200, ['templates' => $templates]);
} }
/**
* Contenu détaillé d'une semaine type, pour l'ouvrir dans l'éditeur.
*/
public function show(): void
{
$this->access->requireService();
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$templateId = filter_input(INPUT_GET, 'template_id', FILTER_VALIDATE_INT);
if (!$agentId || !$templateId) {
JsonResponse::send(422, ['error' => 'Agent ou semaine type invalide.']);
}
$template = $this->templateModel->findForAgent((int) $templateId, (int) $agentId);
if ($template === null) {
JsonResponse::send(404, ['error' => 'Semaine type introuvable pour cet agent.']);
}
$entries = array_map(static function (array $entry): array {
return [
'jour_semaine' => (int) $entry['jour_semaine'],
'id_structure' => (int) $entry['id_structure'],
'id_motif' => (int) $entry['id_motif'],
// L'éditeur manipule des heures au format HH:MM.
'heure_debut' => substr((string) $entry['heure_debut'], 0, 5),
'heure_fin' => substr((string) $entry['heure_fin'], 0, 5),
'structure_nom' => (string) $entry['structure_nom'],
'structure_active' => (int) $entry['structure_actif'] === 1,
'motif_libelle' => (string) $entry['motif_libelle'],
'motif_actif' => (int) $entry['motif_actif'] === 1,
'compte_dans_quota' => (int) $entry['compte_dans_quota'] === 1,
];
}, $this->templateModel->entries((int) $templateId));
JsonResponse::send(200, [
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'updated_at' => (string) $template['date_modification'],
],
'entries' => $entries,
]);
}
/**
* Enregistre une semaine type composée dans l'éditeur.
*/
public function storeEntries(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$name = (string) ($payload['name'] ?? '');
$entries = $payload['entries'] ?? null;
$replace = filter_var($payload['replace'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$agentId || !is_array($entries)) {
JsonResponse::send(422, ['error' => 'Agent ou contenu de la semaine type invalide.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
// Mêmes règles qu'à la saisie : « Formation » n'est pas affectable à un
// agent sans droit à l'enveloppe, et un lieu inactif n'est pas choisissable.
$allowedMotifIds = (new MotifModel($this->pdo))->activeIdsForAgent($agent);
$allowedStructureIds = array_map(
static fn (array $structure): int => (int) $structure['id_structure'],
(new StructureModel($this->pdo))->allActive()
);
try {
$template = $this->templateModel->saveFromEntries(
(int) $agentId,
$name,
$entries,
$allowedMotifIds,
$allowedStructureIds,
$replace
);
} catch (DomainException $e) {
$status = str_contains($e->getMessage(), 'déjà ce nom') ? 409 : 422;
JsonResponse::send($status, [
'error' => $e->getMessage(),
'code' => $status === 409 ? 'TEMPLATE_EXISTS' : 'TEMPLATE_INVALID',
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible d’enregistrer la semaine type.',
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'success' => true,
'template' => [
'id' => (int) $template['id_modele'],
'name' => (string) $template['nom'],
'entry_count' => (int) $template['nombre_creneaux'],
],
'message' => sprintf(
'La semaine type « %s » a été enregistrée avec %d plage%s.',
$template['nom'],
$template['nombre_creneaux'],
$template['nombre_creneaux'] > 1 ? 's' : ''
),
]);
}
public function store(): void public function store(): void
{ {
$this->access->requireService(); $this->access->requireService();

View File

@@ -6,6 +6,14 @@ namespace App\Core;
use PDO; use PDO;
/**
* Contrôle des rôles et des périmètres.
*
* Aucune règle de droit n'est écrite ici : les pages autorisées, les
* permissions fonctionnelles et les périmètres de visibilité sont lus dans les
* vues SQL créées par database/migration_v7_8_securite_acces.sql. Cette classe
* ne fait que poser la question à la base et appliquer la réponse.
*/
final class Access final class Access
{ {
public const ROLE_AGENT = 'AGENT'; public const ROLE_AGENT = 'AGENT';
@@ -14,11 +22,15 @@ final class Access
private const SESSION_KEY = 'pta_access'; private const SESSION_KEY = 'pta_access';
/** @var array<string, array<string, bool>> */
private array $permissionCache = [];
/** @var array<string, array<int, string>> */
private array $pageCache = [];
public function __construct(private PDO $pdo) public function __construct(private PDO $pdo)
{ {
if (session_status() !== PHP_SESSION_ACTIVE) { Session::start();
session_start();
}
} }
public function isConfigured(): bool public function isConfigured(): bool
@@ -85,13 +97,16 @@ final class Access
$selection['structure_id'] = $structureId; $selection['structure_id'] = $structureId;
} }
$_SESSION[self::SESSION_KEY] = $selection;
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = $selection;
// Le jeton CSRF suit le cycle de vie de la session : un changement de
// rôle invalide le jeton précédent.
unset($_SESSION['csrf_token']);
} }
public function clear(): void public function clear(): void
{ {
unset($_SESSION[self::SESSION_KEY]); unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true); session_regenerate_id(true);
} }
@@ -128,39 +143,61 @@ final class Access
return $profile; return $profile;
} }
/**
* Permissions fonctionnelles lues dans v_acces_role_permission.
* Une permission absente de la base vaut « refusé ».
*
* @return array<string, bool>
*/
public function permissions(): array public function permissions(): array
{ {
$role = $this->role(); $role = $this->role();
$service = $role === self::ROLE_SERVICE; if ($role === null) {
$responsable = $role === self::ROLE_RESPONSABLE; return [];
}
return [ if (!isset($this->permissionCache[$role])) {
'read_only' => $role === self::ROLE_AGENT, $stmt = $this->pdo->prepare(
'can_edit_draft' => $service || $responsable, 'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
'can_edit_cross_structure' => $service, );
'can_validate' => $service, $stmt->execute(['role' => $role]);
'can_reopen' => $service || $responsable,
'can_use_templates' => $service, $permissions = [];
'can_copy_full_week' => $service, foreach ($stmt->fetchAll() as $row) {
'can_view_pending' => $service, $permissions[(string) $row['permission']] = (bool) $row['autorise'];
'can_manage_coverage' => $service, }
'can_manage_agents' => $service, $this->permissionCache[$role] = $permissions;
'can_manage_structures' => $service, }
'can_manage_vacations' => $service,
'can_manage_teams' => $service, return $this->permissionCache[$role];
'can_manage_pta' => $service,
'can_forecast_next_year' => $service,
];
} }
public function hasPermission(string $permission): bool
{
return $this->permissions()[$permission] ?? false;
}
/**
* Pages autorisées lues dans v_acces_role_page.
*
* @return array<int, string>
*/
public function allowedPages(): array public function allowedPages(): array
{ {
return match ($this->role()) { $role = $this->role();
self::ROLE_AGENT => ['agents', 'structures'], if ($role === null) {
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'], return [];
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'], }
default => [],
}; if (!isset($this->pageCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT page FROM v_acces_role_page WHERE role = :role ORDER BY ordre_affichage, page'
);
$stmt->execute(['role' => $role]);
$this->pageCache[$role] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}
return $this->pageCache[$role];
} }
public function canAccessPage(string $page): bool public function canAccessPage(string $page): bool
@@ -168,12 +205,21 @@ final class Access
return in_array($page, $this->allowedPages(), true); return in_array($page, $this->allowedPages(), true);
} }
/**
* Page d'atterrissage du rôle.
*
* Le Service Enfance arrive sur son tableau de bord plutôt que sur l'écran
* de saisie : son travail commence par le contrôle, pas par la saisie. Le
* repli sur « planning » est indispensable, car la page « dashboard » n'est
* autorisée que si la migration V7.9 a été jouée — sans ce repli,
* requirePage() renverrait indéfiniment vers une page interdite.
*/
public function homeUrl(): string public function homeUrl(): string
{ {
return match ($this->role()) { return match ($this->role()) {
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(), self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(), self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => 'planning.php', self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
default => 'role.php', default => 'role.php',
}; };
} }
@@ -248,10 +294,7 @@ final class Access
return; return;
} }
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT p.id_structure 'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
WHERE c.id_creneau = :id LIMIT 1'
); );
$stmt->execute(['id' => $entryId]); $stmt->execute(['id' => $entryId]);
$structureId = $stmt->fetchColumn(); $structureId = $stmt->fetchColumn();
@@ -267,7 +310,9 @@ final class Access
if ($this->role() === self::ROLE_SERVICE) { if ($this->role() === self::ROLE_SERVICE) {
return; return;
} }
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1'); $stmt = $this->pdo->prepare(
'SELECT id_structure FROM v_acces_perimetre_planning WHERE id_planning = :id LIMIT 1'
);
$stmt->execute(['id' => $planningId]); $stmt->execute(['id' => $planningId]);
$structureId = $stmt->fetchColumn(); $structureId = $stmt->fetchColumn();
if ($structureId === false) { if ($structureId === false) {
@@ -293,14 +338,14 @@ final class Access
private function activeAgentExists(int $agentId): bool private function activeAgentExists(int $agentId): bool
{ {
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1'); $stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
$stmt->execute(['id' => $agentId]); $stmt->execute(['id' => $agentId]);
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
private function activeStructureExists(int $structureId): bool private function activeStructureExists(int $structureId): bool
{ {
$stmt = $this->pdo->prepare('SELECT 1 FROM structure WHERE id_structure = :id AND actif = TRUE LIMIT 1'); $stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
$stmt->execute(['id' => $structureId]); $stmt->execute(['id' => $structureId]);
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
@@ -312,11 +357,9 @@ final class Access
return null; return null;
} }
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure, 'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
s.nom AS structure_nom FROM v_acces_identite_agent
FROM agent a WHERE id_agent = :id LIMIT 1'
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
); );
$stmt->execute(['id' => $agentId]); $stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
@@ -330,8 +373,8 @@ final class Access
} }
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom 'SELECT id_structure, code, nom
FROM structure FROM v_acces_identite_structure
WHERE id_structure = :id AND actif = TRUE LIMIT 1' WHERE id_structure = :id LIMIT 1'
); );
$stmt->execute(['id' => $structureId]); $stmt->execute(['id' => $structureId]);
return $stmt->fetch() ?: null; return $stmt->fetch() ?: null;
@@ -344,25 +387,11 @@ final class Access
return false; return false;
} }
$stmt = $this->pdo->prepare( $stmt = $this->pdo->prepare(
'SELECT 1 'SELECT 1 FROM v_acces_agent_par_structure
FROM agent a WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
WHERE a.id_agent = :agent_id
AND a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1 FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
LIMIT 1' LIMIT 1'
); );
$stmt->execute([ $stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
'agent_id' => $agentId,
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
return $stmt->fetchColumn() !== false; return $stmt->fetchColumn() !== false;
} }
} }

View File

@@ -8,9 +8,7 @@ final class Csrf
{ {
public static function ensureToken(): string public static function ensureToken(): string
{ {
if (session_status() !== PHP_SESSION_ACTIVE) { Session::start();
session_start();
}
if (empty($_SESSION['csrf_token'])) { if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -21,18 +21,14 @@ final class JsonResponse
}); });
set_exception_handler(static function (\Throwable $exception): void { set_exception_handler(static function (\Throwable $exception): void {
error_log(sprintf( // Security::reportThrowable journalise systématiquement le détail
'[PTA API] %s: %s in %s:%d', // côté serveur et ne le renvoie au client que si PTA_DEBUG est
get_class($exception), // explicitement activé. Par défaut, rien ne fuit.
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
$payload = ['error' => 'Une erreur serveur est survenue.']; $payload = ['error' => 'Une erreur serveur est survenue.'];
if ((getenv('PTA_DEBUG') ?: '1') !== '0') { if (Security::debugEnabled()) {
$payload['details'] = $exception->getMessage(); $payload['details'] = $exception->getMessage();
} }
Security::reportThrowable($exception, 'Erreur API.');
self::send(500, $payload); self::send(500, $payload);
}); });
@@ -42,6 +38,8 @@ final class JsonResponse
{ {
if (!headers_sent()) { if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
} }
http_response_code($status); http_response_code($status);

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Classement métier des motifs de planning, partagé par les modèles et les PDF.
*
* Le référentiel `motif_planning` distingue le temps de travail effectif (ALP matin,
* restauration, ALP soir, mercredi périscolaire, extrascolaire, préparations, ménage de fond…)
* du temps décompté mais non travaillé (formation, absence) et du temps hors quota
* (congé, autre absence, JNT). Tous ces motifs de travail doivent être présentés
* comme des heures travaillées, et pas seulement le motif générique `TRAVAIL`.
*/
final class MotifCategories
{
public const TRAVAIL = 'TRAVAIL';
public const FORMATION = 'FORMATION';
public const CONGE = 'CONGE';
public const ABSENCE = 'ABSENCE';
public const NON_DECOMPTE = 'NON_DECOMPTE';
/** Motifs comptant comme du temps de travail effectif. */
public const WORK_CODES = [
'TRAVAIL',
'ALP_MATIN',
'RESTAURATION',
'ALP_SOIR',
'MERCREDI_PERISCOLAIRE',
'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE',
'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI',
'MENAGE_FOND',
'JOUR_FRACTIONNEMENT',
];
/** Motifs décomptés du quota sans être du temps de travail effectif. */
private const FORMATION_CODES = ['FORMATION'];
private const ABSENCE_CODES = ['ABSENCE', 'MALADIE'];
private const CONGE_CODES = ['CONGE'];
private const NON_DECOMPTE_CODES = ['AUTRE_ABSENCE', 'JNT'];
public static function normalizeCode(?string $code): string
{
return strtoupper(trim((string) $code));
}
/**
* Motif dont l'affectation dépend d'un droit porté par l'agent : la formation
* n'est ouverte qu'aux agents bénéficiant de l'enveloppe annuelle de 14 h
* (colonne `droit_enveloppe_formation`, voir AgentModel::hasTrainingRight()).
*/
public static function requiresTrainingRight(?string $code): bool
{
return self::normalizeCode($code) === self::FORMATION;
}
/**
* Un motif inconnu du référentiel est rattaché au temps de travail dès lors qu'il
* décompte le quota : c'est le comportement le moins surprenant pour un motif ajouté
* en base après coup, et il évite qu'un total disparaisse des documents.
*/
public static function isWork(?string $code, bool $countsInQuota = false): bool
{
return self::category($code, $countsInQuota) === self::TRAVAIL;
}
/**
* @return self::TRAVAIL|self::FORMATION|self::CONGE|self::ABSENCE|self::NON_DECOMPTE
*/
public static function category(?string $code, bool $countsInQuota = false): string
{
$normalized = self::normalizeCode($code);
if (in_array($normalized, self::WORK_CODES, true)) {
return self::TRAVAIL;
}
if (in_array($normalized, self::FORMATION_CODES, true)) {
return self::FORMATION;
}
if (in_array($normalized, self::ABSENCE_CODES, true)) {
return self::ABSENCE;
}
if (in_array($normalized, self::CONGE_CODES, true)) {
return self::CONGE;
}
if (in_array($normalized, self::NON_DECOMPTE_CODES, true)) {
return self::NON_DECOMPTE;
}
return $countsInQuota ? self::TRAVAIL : self::NON_DECOMPTE;
}
}

89
app/Core/Security.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* En-têtes de sécurité des réponses HTML.
*
* La CSP est nominative : aucun script inline n'est autorisé sans le nonce
* généré à chaque requête. PTA ne contient aucun gestionnaire d'événement
* inline (onclick=, onchange=…) ni style inline, la politique peut donc rester
* stricte sans dégrader l'interface.
*/
final class Security
{
private static ?string $nonce = null;
public static function nonce(): string
{
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
return self::$nonce;
}
public static function sendHtmlHeaders(): void
{
if (headers_sent()) {
return;
}
$nonce = self::nonce();
$policy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"img-src 'self' data:",
"style-src 'self'",
"font-src 'self'",
// Aucune requête sortante depuis le navigateur : les appels à
// data.education.gouv.fr sont faits côté serveur.
"connect-src 'self'",
sprintf("script-src 'self' 'nonce-%s'", $nonce),
];
header('Content-Security-Policy: ' . implode('; ', $policy));
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()');
header('Cross-Origin-Opener-Policy: same-origin');
if (Session::isHttps()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
/**
* Vrai uniquement si le mode debug a été activé explicitement.
* Par défaut, aucun détail technique ne sort du serveur.
*/
public static function debugEnabled(): bool
{
return in_array(strtolower((string) (getenv('PTA_DEBUG') ?: '0')), ['1', 'true', 'on', 'yes'], true);
}
/**
* Journalise l'exception côté serveur et renvoie le message à afficher.
* Le détail technique n'est rendu que si PTA_DEBUG est explicitement actif.
*/
public static function reportThrowable(\Throwable $exception, string $publicMessage): string
{
error_log(sprintf(
'[PTA] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
return self::debugEnabled()
? $publicMessage . ' Détail : ' . $exception->getMessage()
: $publicMessage;
}
}

59
app/Core/Session.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Démarrage unique et durci de la session PHP.
*
* Les mêmes garanties sont posées ici et dans docker/php/php.ini : le php.ini
* ne s'applique qu'au conteneur fourni, alors que ce code protège aussi les
* déploiements sur un serveur PHP existant dont la configuration est inconnue.
*/
final class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started || session_status() === PHP_SESSION_ACTIVE) {
self::$started = true;
return;
}
if (!headers_sent()) {
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => self::isHttps(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
session_start();
self::$started = true;
}
/**
* Vrai si la requête courante arrive en HTTPS, directement ou via un proxy
* de confiance. Sans cette détection, le cookie de session serait marqué
* « secure » sur une installation HTTP et la session ne fonctionnerait plus.
*/
public static function isHttps(): bool
{
if (($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443) {
return true;
}
return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
}
}

View File

@@ -10,11 +10,34 @@ use Throwable;
final class AgentModel extends BaseModel final class AgentModel extends BaseModel
{ {
/** Enveloppe annuelle complémentaire de formation : 14 h. */
public const ENVELOPPE_FORMATION_MINUTES = 840;
/**
* Un agent sans droit à l'enveloppe de formation ne peut pas se voir
* affecter le motif « Formation ». Le droit vaut par défaut lorsque
* l'information n'est pas projetée : ne jamais retirer un droit sur une
* donnée absente.
*/
public static function hasTrainingRight(array $agent): bool
{
return (int) ($agent['droit_enveloppe_formation'] ?? 1) === 1;
}
/**
* Minutes de formation ouvertes à l'agent sur l'année : l'enveloppe de 14 h
* s'il y a droit, rien sinon.
*/
public static function trainingEnvelopeMinutes(array $agent): int
{
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
}
private const AGENT_SELECT = <<<'SQL' private const AGENT_SELECT = <<<'SQL'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email, SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle, a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
a.date_debut_contrat, a.date_fin_contrat, a.type_contrat, a.date_debut_contrat, a.date_fin_contrat, a.type_contrat,
a.formation_repartition_annuelle, a.commentaire_pta, a.droit_enveloppe_formation, a.commentaire_pta,
a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle, a.id_poste, po.code AS poste_code, po.libelle AS poste_libelle,
po.famille AS poste_famille, po.heures_mercredi_minutes, po.famille AS poste_famille, po.heures_mercredi_minutes,
po.heures_extrascolaire_minutes, po.autorise_preparation, po.heures_extrascolaire_minutes, po.autorise_preparation,
@@ -129,7 +152,9 @@ final class AgentModel extends BaseModel
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year)); $periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
$quotaReference = QuotaModel::REFERENCE_MINUTES; $quotaReference = QuotaModel::REFERENCE_MINUTES;
$quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100)); $quotaTarget = (int) round($quotaReference * ((float) $data['quotite_travail'] / 100));
$trainingEnvelope = !empty($data['formation_repartition_annuelle']) ? 0 : 840; $trainingEnvelope = self::trainingEnvelopeMinutes([
'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
]);
try { try {
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
@@ -139,13 +164,13 @@ final class AgentModel extends BaseModel
matricule, nom, prenom, email, telephone, adresse, matricule, nom, prenom, email, telephone, adresse,
est_diplome, diplome_libelle, date_debut_contrat, est_diplome, diplome_libelle, date_debut_contrat,
id_poste, type_contrat, date_fin_contrat, id_poste, type_contrat, date_fin_contrat,
formation_repartition_annuelle, commentaire_pta, droit_enveloppe_formation, commentaire_pta,
id_structure, actif id_structure, actif
) VALUES ( ) VALUES (
:matricule, :nom, :prenom, :email, :telephone, :adresse, :matricule, :nom, :prenom, :email, :telephone, :adresse,
:est_diplome, :diplome_libelle, :date_debut_contrat, :est_diplome, :diplome_libelle, :date_debut_contrat,
:id_poste, :type_contrat, :date_fin_contrat, :id_poste, :type_contrat, :date_fin_contrat,
:formation_repartition_annuelle, :commentaire_pta, :droit_enveloppe_formation, :commentaire_pta,
:structure_id, TRUE :structure_id, TRUE
)' )'
); );
@@ -162,7 +187,7 @@ final class AgentModel extends BaseModel
'id_poste' => $data['id_poste'], 'id_poste' => $data['id_poste'],
'type_contrat' => $data['type_contrat'], 'type_contrat' => $data['type_contrat'],
'date_fin_contrat' => $contractEnd?->format('Y-m-d'), 'date_fin_contrat' => $contractEnd?->format('Y-m-d'),
'formation_repartition_annuelle' => $data['formation_repartition_annuelle'] ? 1 : 0, 'droit_enveloppe_formation' => !empty($data['droit_enveloppe_formation']) ? 1 : 0,
'commentaire_pta' => $data['commentaire_pta'], 'commentaire_pta' => $data['commentaire_pta'],
'structure_id' => $data['structure_id'], 'structure_id' => $data['structure_id'],
]); ]);
@@ -260,7 +285,7 @@ final class AgentModel extends BaseModel
id_poste = :id_poste, id_poste = :id_poste,
type_contrat = :type_contrat, type_contrat = :type_contrat,
date_fin_contrat = :date_fin_contrat, date_fin_contrat = :date_fin_contrat,
formation_repartition_annuelle = :formation_repartition_annuelle, droit_enveloppe_formation = :droit_enveloppe_formation,
commentaire_pta = :commentaire_pta, commentaire_pta = :commentaire_pta,
id_structure = :structure_id id_structure = :structure_id
WHERE id_agent = :agent_id' WHERE id_agent = :agent_id'
@@ -275,7 +300,7 @@ final class AgentModel extends BaseModel
$update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT); $update->bindValue(':id_poste', $data['id_poste'], PDO::PARAM_INT);
$update->bindValue(':type_contrat', $data['type_contrat']); $update->bindValue(':type_contrat', $data['type_contrat']);
$update->bindValue(':date_fin_contrat', $data['date_fin_contrat']); $update->bindValue(':date_fin_contrat', $data['date_fin_contrat']);
$update->bindValue(':formation_repartition_annuelle', $data['formation_repartition_annuelle'] ? 1 : 0, PDO::PARAM_INT); $update->bindValue(':droit_enveloppe_formation', !empty($data['droit_enveloppe_formation']) ? 1 : 0, PDO::PARAM_INT);
$update->bindValue(':commentaire_pta', $data['commentaire_pta']); $update->bindValue(':commentaire_pta', $data['commentaire_pta']);
if ($newStructureId === null) { if ($newStructureId === null) {
$update->bindValue(':structure_id', null, PDO::PARAM_NULL); $update->bindValue(':structure_id', null, PDO::PARAM_NULL);

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Models;
/**
* Agrégats de pilotage du tableau de bord.
*
* Ce modèle ne porte AUCUNE règle métier nouvelle : il ne fait que compter et
* additionner ce que les autres écrans affichent déjà en détail. Le calcul du
* quota reprend exactement la logique de QuotaModel (année civile, motifs
* décomptés uniquement, quota de l'année la plus proche à défaut de l'année
* exacte) afin qu'un agent n'affiche jamais deux valeurs différentes selon
* l'écran consulté.
*/
final class DashboardModel extends BaseModel
{
/**
* Suivi du quota annuel de tous les agents actifs, en deux requêtes.
*
* @return array<int, array<string, mixed>>
*/
public function quotaTracking(int $year): array
{
$profiles = $this->quotaProfiles($year);
$rows = $this->allocatedMinutes($year);
$tracking = [];
foreach ($rows as $row) {
$agentId = (int) $row['id_agent'];
$profile = $profiles[$agentId] ?? [
'quotite_travail' => 100.0,
'quota_cible_minutes' => QuotaModel::REFERENCE_MINUTES,
'annee' => $year,
];
$target = (int) $profile['quota_cible_minutes'];
$validated = (int) $row['minutes_valides'];
$draft = (int) $row['minutes_brouillon'];
$allocated = $validated + $draft;
$tracking[] = [
'id_agent' => $agentId,
'matricule' => (string) ($row['matricule'] ?? ''),
'nom' => (string) $row['nom'],
'prenom' => (string) $row['prenom'],
'structure_nom' => $row['structure_nom'] !== null ? (string) $row['structure_nom'] : null,
'poste_libelle' => $row['poste_libelle'] !== null ? (string) $row['poste_libelle'] : null,
'quotite_travail' => (float) $profile['quotite_travail'],
'quota_cible_minutes' => $target,
'quota_source_annee' => (int) $profile['annee'],
'quota_estime' => !isset($profiles[$agentId]),
'minutes_valides' => $validated,
'minutes_brouillon' => $draft,
'minutes_affectees' => $allocated,
'minutes_restantes' => $target - $allocated,
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 1) : 0.0,
];
}
return $tracking;
}
/**
* Minutes décomptées de l'année civile, par agent actif.
*
* @return array<int, array<string, mixed>>
*/
private function allocatedMinutes(int $year): array
{
$stmt = $this->pdo->prepare(
"SELECT a.id_agent, a.matricule, a.nom, a.prenom,
s.nom AS structure_nom,
po.libelle AS poste_libelle,
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' AND m.id_motif IS NOT NULL THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_valides,
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' AND m.id_motif IS NOT NULL THEN
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
ELSE 0 END), 0) AS minutes_brouillon
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN planning p ON p.id_agent = a.id_agent
LEFT JOIN creneau_horaire c
ON c.id_planning = p.id_planning
AND c.date_jour BETWEEN :date_debut AND :date_fin
LEFT JOIN motif_planning m
ON m.id_motif = c.id_motif
AND m.compte_dans_quota = TRUE
WHERE a.actif = TRUE
GROUP BY a.id_agent, a.matricule, a.nom, a.prenom, s.nom, po.libelle
ORDER BY a.nom, a.prenom"
);
$stmt->execute([
'date_debut' => sprintf('%04d-01-01', $year),
'date_fin' => sprintf('%04d-12-31', $year),
]);
return $stmt->fetchAll();
}
/**
* Quota retenu pour chaque agent : année exacte si elle existe, sinon
* l'année passée la plus proche, sinon l'année future la plus proche.
* Même ordre de préférence que QuotaModel::quotaProfile().
*
* @return array<int, array<string, mixed>>
*/
private function quotaProfiles(int $year): array
{
$stmt = $this->pdo->prepare(
'SELECT id_agent, annee, quotite_travail, quota_cible_minutes
FROM (
SELECT q.id_agent, q.annee, q.quotite_travail, q.quota_cible_minutes,
ROW_NUMBER() OVER (
PARTITION BY q.id_agent
ORDER BY CASE
WHEN q.annee = :year_exact THEN 0
WHEN q.annee < :year_past THEN 1
ELSE 2
END,
CASE WHEN q.annee <= :year_latest THEN q.annee END DESC,
CASE WHEN q.annee > :year_future THEN q.annee END ASC
) AS rang
FROM quota_agent_annuel q
) AS classement
WHERE rang = 1'
);
$stmt->execute([
'year_exact' => $year,
'year_past' => $year,
'year_latest' => $year,
'year_future' => $year,
]);
$profiles = [];
foreach ($stmt->fetchAll() as $row) {
$profiles[(int) $row['id_agent']] = [
'annee' => (int) $row['annee'],
'quotite_travail' => (float) $row['quotite_travail'],
'quota_cible_minutes' => (int) $row['quota_cible_minutes'],
];
}
return $profiles;
}
/**
* Nombre de brouillons contenant au moins une affectation.
* Requête volontairement légère : elle alimente la pastille de navigation
* rechargée sur chaque page.
*/
public function pendingCount(): int
{
$stmt = $this->pdo->query(
"SELECT COUNT(*)
FROM (
SELECT p.id_planning
FROM planning p
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
WHERE p.statut = 'BROUILLON'
GROUP BY p.id_planning
) AS brouillons_servis"
);
return (int) $stmt->fetchColumn();
}
/**
* Nombre d'agents actifs et de lieux actifs, pour situer les compteurs.
*
* @return array{agents: int, structures: int}
*/
public function scope(): array
{
return [
'agents' => (int) $this->pdo->query('SELECT COUNT(*) FROM agent WHERE actif = TRUE')->fetchColumn(),
'structures' => (int) $this->pdo->query('SELECT COUNT(*) FROM structure WHERE actif = TRUE')->fetchColumn(),
];
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Core\MotifCategories;
final class MotifModel extends BaseModel final class MotifModel extends BaseModel
{ {
public function allActive(): array public function allActive(): array
@@ -23,4 +25,29 @@ final class MotifModel extends BaseModel
$this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN) $this->pdo->query('SELECT id_motif FROM motif_planning WHERE actif = TRUE')->fetchAll(\PDO::FETCH_COLUMN)
); );
} }
/**
* Motifs réellement affectables à un agent : « Formation » disparaît de la
* liste dès lors que l'agent n'a pas droit à l'enveloppe de formation.
*/
public function allActiveForAgent(array $agent): array
{
if (AgentModel::hasTrainingRight($agent)) {
return $this->allActive();
}
return array_values(array_filter(
$this->allActive(),
static fn(array $motif): bool => !MotifCategories::requiresTrainingRight((string) $motif['code'])
));
}
/** @return int[] */
public function activeIdsForAgent(array $agent): array
{
return array_map(
static fn(array $motif): int => (int) $motif['id_motif'],
$this->allActiveForAgent($agent)
);
}
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Core\MotifCategories;
use DateInterval; use DateInterval;
use DatePeriod; use DatePeriod;
use DateTimeImmutable; use DateTimeImmutable;
@@ -12,12 +13,8 @@ use RuntimeException;
final class PtaModel extends BaseModel final class PtaModel extends BaseModel
{ {
private const WORK_CODES = [ /** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', private const WORK_CODES = MotifCategories::WORK_CODES;
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
];
private const PREPARATION_CODES = [ private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI', 'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
@@ -148,7 +145,7 @@ final class PtaModel extends BaseModel
return $pta; return $pta;
} }
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; $training = AgentModel::trainingEnvelopeMinutes($agent);
$insert = $this->pdo->prepare( $insert = $this->pdo->prepare(
"INSERT INTO pta_annuel ( "INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail, id_agent, date_debut, date_fin, quotite_travail,
@@ -381,7 +378,13 @@ final class PtaModel extends BaseModel
} }
$formationMinutes = $categories['FORMATION']; $formationMinutes = $categories['FORMATION'];
$formationTarget = !empty($agent['formation_repartition_annuelle']) ? 0 : 840; $formationTarget = AgentModel::trainingEnvelopeMinutes($agent);
if (!AgentModel::hasTrainingRight($agent) && $formationMinutes > 0) {
$controls[] = $this->control('ALERTE', 'FORMATION_SANS_DROIT', sprintf(
'Formation : %s planifiées alors que l’agent n’a pas droit à l’enveloppe de formation. Ces plages sont à retirer.',
$this->formatMinutes($formationMinutes)
));
}
if ($formationTarget > 0 && $formationMinutes > $formationTarget) { if ($formationTarget > 0 && $formationMinutes > $formationTarget) {
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf( $controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.', 'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',

View File

@@ -16,6 +16,16 @@ use InvalidArgumentException;
*/ */
final class QuotaModel extends BaseModel final class QuotaModel extends BaseModel
{ {
/**
* Les 1 607 h sont un volume NET : la formule légale a déjà retranché les
* week-ends, les 25 jours de congés payés et les 8 jours fériés moyens
* (365 - 104 - 25 - 8 = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus
* les 7 h de la journée de solidarité).
*
* Conséquence : ni un jour de congé payé, ni un jour férié ne s'impute au
* quota. Les imputer reviendrait à les décompter une seconde fois. Seul le
* travail effectif alimente le compteur, via `compte_dans_quota`.
*/
public const REFERENCE_MINUTES = 1607 * 60; public const REFERENCE_MINUTES = 1607 * 60;
public function createForAgent(int $agentId, int $year, float $rate): void public function createForAgent(int $agentId, int $year, float $rate): void

View File

@@ -52,7 +52,7 @@ final class SchemaModel extends BaseModel
$requiredColumns = [ $requiredColumns = [
'creneau_horaire' => ['id_motif'], 'creneau_horaire' => ['id_motif'],
'planning' => ['date_validation'], 'planning' => ['date_validation'],
'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'formation_repartition_annuelle', 'commentaire_pta'], 'agent' => ['email', 'telephone', 'adresse', 'est_diplome', 'diplome_libelle', 'id_structure', 'id_poste', 'type_contrat', 'date_debut_contrat', 'date_fin_contrat', 'droit_enveloppe_formation', 'commentaire_pta'],
'structure' => ['type_affectation'], 'structure' => ['type_affectation'],
]; ];
@@ -78,5 +78,43 @@ final class SchemaModel extends BaseModel
)); ));
} }
} }
$this->assertAccessViews();
}
/**
* Les vues d'accès portent l'intégralité des règles de droits. Si elles
* manquent, l'application doit refuser de démarrer plutôt que de tourner
* sans contrôle de périmètre.
*/
private function assertAccessViews(): void
{
$requiredViews = [
'v_acces_role_page',
'v_acces_role_permission',
'v_acces_identite_agent',
'v_acces_identite_structure',
'v_acces_agent_par_structure',
'v_acces_perimetre_creneau',
'v_acces_perimetre_planning',
];
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
$stmt = $this->pdo->prepare(
"SELECT TABLE_NAME
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ($placeholders)"
);
$stmt->execute($requiredViews);
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
if ($missing) {
throw new \RuntimeException(
'Contrôle d’accès indisponible : vue(s) manquante(s) : '
. implode(', ', $missing)
. '. Exécutez database/migration_v7_8_securite_acces.sql.'
);
}
} }
} }

View File

@@ -62,16 +62,175 @@ final class WeekTemplateModel extends BaseModel
public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array public function saveFromWeek(int $agentId, int $year, int $weekNumber, string $name, bool $replaceExisting = false): array
{ {
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? ''); // La colonne `nom` fait 120 caractères : la borne annoncée à
if ($name === '' || strlen($name) > 240) { // l'utilisateur et celle réellement appliquée doivent coïncider.
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.'); $name = $this->normalizeName($name);
}
$sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber); $sourceEntries = $this->sourceWeekEntries($agentId, $year, $weekNumber);
if (!$sourceEntries) { if (!$sourceEntries) {
throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.'); throw new DomainException('La semaine sélectionnée ne contient aucune affectation à enregistrer comme modèle.');
} }
return $this->persistTemplate($agentId, $name, $sourceEntries, $replaceExisting);
}
/**
* Enregistre une semaine type composée directement dans l'éditeur, sans
* partir d'une semaine réellement planifiée.
*
* Les plages arrivent du navigateur : elles sont intégralement revalidées
* ici, y compris les droits de motif, car l'écran n'est qu'une commodité —
* le serveur reste seul juge de ce qui entre en base.
*
* @param array<int, mixed> $rawEntries
* @param int[] $allowedMotifIds motifs affectables à cet agent
* @param int[] $allowedStructureIds lieux actifs visibles par l'utilisateur
*/
public function saveFromEntries(
int $agentId,
string $name,
array $rawEntries,
array $allowedMotifIds,
array $allowedStructureIds,
bool $replaceExisting = false
): array {
$name = $this->normalizeName($name);
$entries = $this->normalizeEditorEntries($rawEntries, $allowedMotifIds, $allowedStructureIds);
if ($entries === []) {
throw new DomainException('Une semaine type doit contenir au moins une plage horaire.');
}
return $this->persistTemplate($agentId, $name, $entries, $replaceExisting);
}
private function normalizeName(string $name): string
{
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
if ($name === '' || mb_strlen($name) > 120) {
throw new DomainException('Le nom de la semaine type doit contenir entre 1 et 120 caractères.');
}
return $name;
}
/**
* @param array<int, mixed> $rawEntries
* @param int[] $allowedMotifIds
* @param int[] $allowedStructureIds
* @return array<int, array<string, mixed>>
*/
private function normalizeEditorEntries(array $rawEntries, array $allowedMotifIds, array $allowedStructureIds): array
{
$jours = [1 => 'lundi', 2 => 'mardi', 3 => 'mercredi', 4 => 'jeudi', 5 => 'vendredi'];
$entries = [];
foreach ($rawEntries as $rawEntry) {
if (!is_array($rawEntry)) {
throw new DomainException('Une plage de la semaine type est illisible.');
}
$weekday = filter_var($rawEntry['jour_semaine'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 5],
]);
if ($weekday === false) {
throw new DomainException('Une semaine type ne peut contenir que des jours du lundi au vendredi.');
}
$start = $this->normalizeQuarterHour($rawEntry['heure_debut'] ?? null, $jours[$weekday]);
$end = $this->normalizeQuarterHour($rawEntry['heure_fin'] ?? null, $jours[$weekday]);
if ($end <= $start) {
throw new DomainException(sprintf(
'Le %s, la fin (%s) doit être postérieure au début (%s).',
$jours[$weekday],
substr($end, 0, 5),
substr($start, 0, 5)
));
}
$structureId = filter_var($rawEntry['id_structure'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($structureId === false || !in_array((int) $structureId, $allowedStructureIds, true)) {
throw new DomainException(sprintf('Le lieu d’affectation choisi le %s n’est pas disponible.', $jours[$weekday]));
}
$motifId = filter_var($rawEntry['id_motif'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if ($motifId === false || !in_array((int) $motifId, $allowedMotifIds, true)) {
throw new DomainException(sprintf(
'Le motif choisi le %s n’est pas affectable à cet agent.',
$jours[$weekday]
));
}
$entries[] = [
'jour_semaine' => (int) $weekday,
'id_structure' => (int) $structureId,
'id_motif' => (int) $motifId,
'heure_debut' => $start,
'heure_fin' => $end,
];
}
$this->assertEditorEntriesNoOverlap($entries, $jours);
usort($entries, static function (array $a, array $b): int {
return [$a['jour_semaine'], $a['heure_debut']] <=> [$b['jour_semaine'], $b['heure_debut']];
});
return $entries;
}
private function normalizeQuarterHour(mixed $value, string $dayLabel): string
{
$time = trim((string) $value);
if (preg_match('/^([01]\d|2[0-3]):(00|15|30|45)(:00)?$/', $time) !== 1) {
throw new DomainException(sprintf(
'Le %s, les horaires doivent être renseignés par tranches de 15 minutes.',
$dayLabel
));
}
return substr($time, 0, 5) . ':00';
}
/**
* Deux plages du même jour ne peuvent pas se chevaucher, même sur deux
* lieux différents : un agent n'est jamais à deux endroits à la fois.
*
* @param array<int, array<string, mixed>> $entries
* @param array<int, string> $jours
*/
private function assertEditorEntriesNoOverlap(array $entries, array $jours): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$first = $entries[$i];
$second = $entries[$j];
if ($first['jour_semaine'] === $second['jour_semaine']
&& $first['heure_debut'] < $second['heure_fin']
&& $first['heure_fin'] > $second['heure_debut']) {
throw new DomainException(sprintf(
'Le %s, deux plages se chevauchent : %s-%s et %s-%s.',
$jours[$first['jour_semaine']] ?? 'jour',
substr($first['heure_debut'], 0, 5),
substr($first['heure_fin'], 0, 5),
substr($second['heure_debut'], 0, 5),
substr($second['heure_fin'], 0, 5)
));
}
}
}
}
/**
* Écriture du modèle et de ses plages, quelle qu'en soit l'origine :
* une semaine réellement planifiée ou l'éditeur de semaine type.
*
* @param array<int, array<string, mixed>> $rows
*/
private function persistTemplate(int $agentId, string $name, array $rows, bool $replaceExisting): array
{
try { try {
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
@@ -106,7 +265,7 @@ final class WeekTemplateModel extends BaseModel
(:template_id, :weekday, :structure_id, :motif_id, :start, :end)" (:template_id, :weekday, :structure_id, :motif_id, :start, :end)"
); );
foreach ($sourceEntries as $entry) { foreach ($rows as $entry) {
$insertEntry->execute([ $insertEntry->execute([
'template_id' => $templateId, 'template_id' => $templateId,
'weekday' => (int) $entry['jour_semaine'], 'weekday' => (int) $entry['jour_semaine'],
@@ -121,7 +280,7 @@ final class WeekTemplateModel extends BaseModel
return [ return [
'id_modele' => $templateId, 'id_modele' => $templateId,
'nom' => $name, 'nom' => $name,
'nombre_creneaux' => count($sourceEntries), 'nombre_creneaux' => count($rows),
]; ];
} catch (Throwable $e) { } catch (Throwable $e) {
if ($this->pdo->inTransaction()) { if ($this->pdo->inTransaction()) {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Core\MotifCategories;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
@@ -66,11 +67,11 @@ final class AgentTimeSummaryPdfService
$rowH = 43.0; $rowH = 43.0;
$rows = [ $rows = [
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']], [MotifCategories::TRAVAIL, 'Heures travaillées', $totals['TRAVAIL'], $totals['DETAIL_TRAVAIL']],
['MALADIE', 'Absence', $totals['MALADIE']], [MotifCategories::ABSENCE, 'Absence', $totals['ABSENCE'], []],
['FORMATION', 'Formation', $totals['FORMATION']], [MotifCategories::FORMATION, 'Formation', $totals['FORMATION'], []],
['CONGE', 'Congé', $totals['CONGE']], [MotifCategories::CONGE, 'Congé', $totals['CONGE'], []],
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']], [MotifCategories::NON_DECOMPTE, 'Absence (non décomptée)', $totals['NON_DECOMPTE'], []],
]; ];
$pdf->fillRect($x, $y, $w, $headerH, 0.92); $pdf->fillRect($x, $y, $w, $headerH, 0.92);
@@ -78,19 +79,75 @@ final class AgentTimeSummaryPdfService
$pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true); $pdf->text($x + 12, $y + 21, 'Répartition du temps', 11, true);
$cursorY = $y + $headerH; $cursorY = $y + $headerH;
foreach ($rows as [$code, $label, $minutes]) { foreach ($rows as [$code, $label, $minutes, $detail]) {
$palette = $this->palette($code); $palette = $this->palette($code);
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83); $pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']); $pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true); $detailLines = $detail === [] ? [] : $this->detailLines($pdf, $detail, $w - 174);
if ($detailLines === []) {
$pdf->text($x + 32, $cursorY + 25, $label, 9.4, true);
$pdf->text($x + $w - 130, $cursorY + 25, $this->minutesLabel($minutes), 10, true);
} else {
$pdf->text($x + 32, $cursorY + 19, $label, 9.4, true);
$pdf->text($x + $w - 130, $cursorY + 19, $this->minutesLabel($minutes), 10, true);
$detailY = $cursorY + 30;
foreach ($detailLines as $line) {
$pdf->text($x + 32, $detailY, $line, 7);
$detailY += 9;
}
}
$cursorY += $rowH; $cursorY += $rowH;
} }
} }
/**
* Détaille les motifs regroupés dans les heures travaillées (ALP matin, ALP soir,
* restauration, préparations…), sur deux lignes au maximum.
*
* @param array<string, int> $detail
* @return string[]
*/
private function detailLines(SimplePdf $pdf, array $detail, float $width): array
{
arsort($detail);
$parts = [];
foreach ($detail as $label => $minutes) {
if ($minutes > 0) {
$parts[] = $label . ' ' . $this->minutesLabel($minutes);
}
}
if ($parts === []) {
return [];
}
// Les motifs sont ajoutés un par un : la coupure tombe ainsi entre deux motifs
// et jamais au milieu d'un libellé, en gardant la place des points de suspension.
$text = '';
$truncated = false;
foreach ($parts as $part) {
$candidate = $text === '' ? 'Dont ' . $part : $text . ' · ' . $part;
if (count($pdf->wrap($candidate . ' …', $width, 7)) > 2) {
$truncated = true;
break;
}
$text = $candidate;
}
if ($text === '') {
return [];
}
return $pdf->wrap($truncated ? $text . ' …' : $text, $width, 7);
}
private function drawResult(SimplePdf $pdf, array $quota, array $totals): void private function drawResult(SimplePdf $pdf, array $quota, array $totals): void
{ {
$counted = $totals['TRAVAIL'] + $totals['MALADIE'] + $totals['FORMATION'] + $totals['CONGE'] + $totals['OTHER_COUNTED']; // Le décompte suit le drapeau compte_dans_quota du motif, comme QuotaModel :
// additionner les catégories affichées y ferait entrer les congés, hors quota.
$counted = (int) $totals['DECOMPTE'];
$target = (int) ($quota['quota_cible_minutes'] ?? 0); $target = (int) ($quota['quota_cible_minutes'] ?? 0);
$remaining = $target - $counted; $remaining = $target - $counted;
$validated = (int) ($quota['minutes_valides'] ?? 0); $validated = (int) ($quota['minutes_valides'] ?? 0);
@@ -135,28 +192,38 @@ final class AgentTimeSummaryPdfService
); );
} }
/**
* Regroupe les minutes par catégorie métier. Tous les motifs de travail effectif
* (ALP matin, ALP soir, restauration, mercredi périscolaire, extrascolaire,
* préparations, ménage de fond…) alimentent la ligne « Heures travaillées », et pas
* seulement le motif générique TRAVAIL.
*/
private function normalizeTotals(array $rows): array private function normalizeTotals(array $rows): array
{ {
$totals = [ $totals = [
'TRAVAIL' => 0, MotifCategories::TRAVAIL => 0,
'MALADIE' => 0, MotifCategories::ABSENCE => 0,
'FORMATION' => 0, MotifCategories::FORMATION => 0,
'CONGE' => 0, MotifCategories::CONGE => 0,
'AUTRE' => 0, MotifCategories::NON_DECOMPTE => 0,
'OTHER_COUNTED' => 0, 'DECOMPTE' => 0,
'DETAIL_TRAVAIL' => [],
]; ];
foreach ($rows as $row) { foreach ($rows as $row) {
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE')); $code = MotifCategories::normalizeCode($row['motif_code'] ?? null);
$minutes = (int) ($row['minutes_total'] ?? 0); $minutes = (int) ($row['minutes_total'] ?? 0);
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') { $counts = (int) ($row['compte_dans_quota'] ?? 0) === 1;
$totals[$code] += $minutes; $category = MotifCategories::category($code, $counts);
continue;
$totals[$category] += $minutes;
if ($counts) {
$totals['DECOMPTE'] += $minutes;
} }
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
$totals['OTHER_COUNTED'] += $minutes; if ($category === MotifCategories::TRAVAIL && $minutes > 0) {
} else { $label = trim((string) ($row['motif_libelle'] ?? '')) ?: $code;
$totals['AUTRE'] += $minutes; $totals['DETAIL_TRAVAIL'][$label] = ($totals['DETAIL_TRAVAIL'][$label] ?? 0) + $minutes;
} }
} }
@@ -167,10 +234,10 @@ final class AgentTimeSummaryPdfService
private function palette(string $code): array private function palette(string $code): array
{ {
return match (strtoupper($code)) { return match (strtoupper($code)) {
'TRAVAIL' => ['accent' => '#2563EB'], MotifCategories::TRAVAIL => ['accent' => '#2563EB'],
'FORMATION' => ['accent' => '#7C3AED'], MotifCategories::FORMATION => ['accent' => '#7C3AED'],
'CONGE' => ['accent' => '#059669'], MotifCategories::CONGE => ['accent' => '#059669'],
'MALADIE' => ['accent' => '#DC2626'], MotifCategories::ABSENCE, 'MALADIE' => ['accent' => '#DC2626'],
default => ['accent' => '#64748B'], default => ['accent' => '#64748B'],
}; };
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services; namespace App\Services;
use App\Core\MotifCategories;
use DateTimeImmutable; use DateTimeImmutable;
final class PlanningPdfService final class PlanningPdfService
@@ -170,7 +171,7 @@ final class PlanningPdfService
$blockY = $y + 5; $blockY = $y + 5;
foreach ($blocks as $block) { foreach ($blocks as $block) {
$entry = $block['entry']; $entry = $block['entry'];
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE')); $palette = $this->entryPalette($entry);
$blockHeight = (float) $block['height']; $blockHeight = (float) $block['height'];
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']); $pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']); $pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
@@ -269,7 +270,7 @@ final class PlanningPdfService
break; break;
} }
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE')); $palette = $this->entryPalette($entry);
$pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']); $pdf->fillRectColor($colX + 4, $cursorY, $colWidth - 8, $cardHeight, $palette['background']);
$pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']); $pdf->fillRectColor($colX + 4, $cursorY, 3.0, $cardHeight, $palette['accent']);
@@ -320,21 +321,35 @@ final class PlanningPdfService
return $pdf->wrap($first, $width, 7.0); return $pdf->wrap($first, $width, 7.0);
} }
/**
* Le motif générique TRAVAIL n'apporte aucune information sur une plage déjà colorée
* en « temps de travail » ; les motifs de travail spécialisés (ALP matin, ALP soir,
* restauration…) restent affichés, ils précisent la nature du temps travaillé.
*/
private function motifDisplayLabel(array $entry): string private function motifDisplayLabel(array $entry): string
{ {
return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL' return MotifCategories::normalizeCode($entry['motif_code'] ?? null) === 'TRAVAIL'
? '' ? ''
: (string) ($entry['motif_libelle'] ?? 'Affectation'); : (string) ($entry['motif_libelle'] ?? 'Affectation');
} }
/** @return array{accent:string,background:string,label:string} */ /** @return array{accent:string,background:string,label:string} */
private function motifPalette(string $code): array private function entryPalette(array $entry): array
{ {
return match (strtoupper($code)) { return $this->motifPalette(
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'], (string) ($entry['motif_code'] ?? 'AUTRE'),
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'], (int) ($entry['compte_dans_quota'] ?? 0) === 1
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'], );
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'], }
/** @return array{accent:string,background:string,label:string} */
private function motifPalette(string $code, bool $countsInQuota = false): array
{
return match (MotifCategories::category($code, $countsInQuota)) {
MotifCategories::TRAVAIL => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
MotifCategories::FORMATION => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
MotifCategories::CONGE => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
MotifCategories::ABSENCE => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'], default => ['accent' => '#64748B', 'background' => '#F8FAFC', 'label' => 'Autre absence'],
}; };
} }
@@ -342,18 +357,20 @@ final class PlanningPdfService
private function drawLegend(SimplePdf $pdf, float $x, float $y): void private function drawLegend(SimplePdf $pdf, float $x, float $y): void
{ {
$items = [ $items = [
['TRAVAIL', 'Travail'], ['TRAVAIL', 'Travail (dont ALP, restauration, preparation)'],
['FORMATION', 'Formation'], ['FORMATION', 'Formation'],
['CONGE', 'Conge'], ['CONGE', 'Conge'],
['MALADIE', 'Absence'], ['ABSENCE', 'Absence'],
['AUTRE', 'Autre absence'], ['JNT', 'Autre absence'],
]; ];
foreach ($items as [$code, $label]) { foreach ($items as [$code, $label]) {
$palette = $this->motifPalette($code); $palette = $this->motifPalette($code);
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']); $pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
$pdf->text($x + 12, $y, $label, 6.5); $pdf->text($x + 12, $y, $label, 6.5);
$x += 76; // Largeur estimée du libellé (Helvetica ~0,52 em) plutôt qu'un pas fixe :
// la première entrée est trop longue pour une colonne de 76 points.
$x += 12 + (mb_strlen($label) * 6.5 * 0.52) + 18;
} }
} }

View File

@@ -6,9 +6,10 @@
/** @var array $pageConfig */ /** @var array $pageConfig */
/** @var array $accessProfile */ /** @var array $accessProfile */
/** @var array $permissions */ /** @var array $permissions */
/** @var string $cspNonce */
?> ?>
</main> </main>
<script> <script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
window.PTA_CONFIG = { window.PTA_CONFIG = {
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>, csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>, access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
@@ -27,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig, 'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script> </script>
<script src="assets/js/core.js?v=7.7"></script> <script src="assets/js/core.js?v=7.16"></script>
<?php foreach ($scripts as $script): ?> <?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script> <script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.16"></script>
<?php endforeach; ?> <?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.7"></script> <script src="assets/js/page-bootstrap.js?v=7.16"></script>
</body> </body>
</html> </html>

View File

@@ -1,21 +1,73 @@
<?php <?php
/** @var string $pageTitle */ /** @var string $pageTitle */
/** @var string $pageSubtitle */
/** @var string $activePage */ /** @var string $activePage */
/** @var array $allowedPages */ /** @var array $allowedPages */
/** @var array $accessProfile */ /** @var array $accessProfile */
/** @var string $csrfToken */
$isAgent = ($accessProfile['role'] ?? '') === 'AGENT';
$navigation = [ $navigation = [
'dashboard' => ['dashboard.php', 'Tableau de bord'],
'planning' => ['planning.php', 'Saisie'], 'planning' => ['planning.php', 'Saisie'],
'pending' => ['pending.php', 'À valider'], 'pending' => ['pending.php', 'À valider'],
'coverage' => ['coverage.php', 'Couverture'], 'coverage' => ['coverage.php', 'Couverture'],
'structures' => ['structures.php', 'Par lieu'], 'structures' => ['structures.php', 'Par lieu'],
'agents' => ['agents.php', ($accessProfile['role'] ?? '') === 'AGENT' ? 'Mon planning' : 'Par agent'], 'agents' => ['agents.php', $isAgent ? 'Mon planning' : 'Par agent'],
'annual' => ['annual.php', 'Vue annuelle'], 'annual' => ['annual.php', 'Vue annuelle'],
'pta' => ['pta.php', 'PTA détaillé'], 'pta' => ['pta.php', 'PTA détaillé'],
'teams' => ['teams.php', 'Équipes'], 'teams' => ['teams.php', 'Équipes'],
'vacations' => ['vacations.php', 'Vacances'], 'vacations' => ['vacations.php', 'Vacances'],
'administration' => ['administration.php', 'Administration'], 'administration' => ['administration.php', 'Administration'],
]; ];
/**
* Les onze onglets du Service Enfance étaient alignés à plat, sans hiérarchie :
* rien ne distinguait une tâche quotidienne d'un référentiel modifié deux fois
* par an. Ils sont désormais regroupés par intention. Les rôles qui n'ont que
* quelques pages gardent une barre simple, sans titres de groupe.
*/
$navigationGroups = [
'Piloter' => ['dashboard', 'pending', 'coverage'],
'Planifier' => ['planning', 'teams'],
'Consulter' => ['agents', 'structures', 'annual', 'pta'],
'Paramétrer' => ['vacations', 'administration'],
];
$visibleGroups = [];
$grouped = [];
foreach ($navigationGroups as $groupLabel => $keys) {
$visibleKeys = array_values(array_filter(
$keys,
static fn (string $key): bool => in_array($key, $allowedPages, true) && isset($navigation[$key])
));
if ($visibleKeys !== []) {
$visibleGroups[$groupLabel] = $visibleKeys;
$grouped = array_merge($grouped, $visibleKeys);
}
}
// Filet de sécurité : une page autorisée en base mais oubliée dans les groupes
// ci-dessus reste accessible plutôt que de disparaître de la navigation.
$ungrouped = array_values(array_filter(
$allowedPages,
static fn (string $key): bool => isset($navigation[$key]) && !in_array($key, $grouped, true)
));
if ($ungrouped !== []) {
$visibleGroups['Autres'] = $ungrouped;
}
$visiblePageCount = count($grouped) + count($ungrouped);
$showGroupLabels = $visiblePageCount > 4;
/**
* Le guide n'appartient à aucun groupe d'intention : ce n'est pas une étape du
* travail mais un secours permanent. Il est volontairement absent de
* $navigation — il ne doit apparaître qu'une seule fois, ancré à droite de la
* barre, et jamais repêché par le filet « Autres ».
*/
$showHelpTab = in_array('faq', $allowedPages, true);
?> ?>
<!doctype html> <!doctype html>
<html lang="fr"> <html lang="fr">
@@ -23,15 +75,15 @@ $navigation = [
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title> <title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.7"> <link rel="stylesheet" href="assets/style.css?v=7.16">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">
<header class="page-header"> <header class="page-header">
<div> <div class="page-header-identity">
<p class="eyebrow">PTA</p> <p class="eyebrow">PTA</p>
<h1><?= htmlspecialchars($pageTitle) ?></h1> <h1><?= htmlspecialchars($pageTitle) ?></h1>
<p class="subtitle">Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.</p> <p class="subtitle"><?= htmlspecialchars($pageSubtitle ?? '') ?></p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<?php if ($activePage === 'planning'): ?> <?php if ($activePage === 'planning'): ?>
@@ -40,11 +92,11 @@ $navigation = [
<div class="access-profile-badge"> <div class="access-profile-badge">
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span> <span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
<strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong> <strong><?= htmlspecialchars((string) ($accessProfile['label'] ?? '')) ?></strong>
<?php if (!empty($accessProfile['structure_name']) && ($accessProfile['role'] ?? '') === 'AGENT'): ?> <?php if (!empty($accessProfile['structure_name']) && $isAgent): ?>
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small> <small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
<?php endif; ?> <?php endif; ?>
</div> </div>
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a> <a class="button button-ghost switch-role-link" href="role.php?action=reset&amp;csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Changer de rôle</a>
</div> </div>
</header> </header>
@@ -52,12 +104,29 @@ $navigation = [
<div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div> <div class="message message-warning role-access-warning">Cette page n’est pas disponible avec votre rôle actuel.</div>
<?php endif; ?> <?php endif; ?>
<nav class="view-tabs" aria-label="Navigation principale"> <nav class="view-tabs<?= $showGroupLabels ? ' view-tabs-grouped' : '' ?>" aria-label="Navigation principale">
<?php foreach ($navigation as $key => [$href, $label]): ?> <?php foreach ($visibleGroups as $groupLabel => $keys): ?>
<?php if (!in_array($key, $allowedPages, true)) continue; ?> <div class="view-tab-group" role="group"<?= $showGroupLabels ? ' aria-label="' . htmlspecialchars($groupLabel, ENT_QUOTES) . '"' : '' ?>>
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>> <?php if ($showGroupLabels): ?>
<?= htmlspecialchars($label) ?> <span class="view-tab-group-label"><?= htmlspecialchars($groupLabel) ?></span>
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?> <?php endif; ?>
</a> <div class="view-tab-group-items">
<?php foreach ($keys as $key): ?>
<?php [$href, $label] = $navigation[$key]; ?>
<a class="view-tab<?= $activePage === $key ? ' is-active' : '' ?>" href="<?= htmlspecialchars($href) ?>"<?= $activePage === $key ? ' aria-current="page"' : '' ?>>
<?= htmlspecialchars($label) ?>
<?php if ($key === 'pending'): ?><span id="pending-validation-tab-badge" class="nav-count-badge" hidden>0</span><?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($showHelpTab): ?>
<div class="view-tab-help">
<a class="view-tab view-tab-faq<?= $activePage === 'faq' ? ' is-active' : '' ?>" href="faq.php"<?= $activePage === 'faq' ? ' aria-current="page"' : '' ?>>
<span class="view-tab-faq-mark" aria-hidden="true">?</span>
FAQ
</a>
</div>
<?php endif; ?>
</nav> </nav>

View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/dashboard.php';
require dirname(__DIR__) . '/layouts/footer.php';

4
app/Views/pages/faq.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
require dirname(__DIR__) . '/layouts/header.php';
require dirname(__DIR__) . '/partials/faq.php';
require dirname(__DIR__) . '/layouts/footer.php';

View File

@@ -4,6 +4,7 @@
/** @var array $structures */ /** @var array $structures */
/** @var string $error */ /** @var string $error */
/** @var array|null $currentProfile */ /** @var array|null $currentProfile */
/** @var string $cspNonce */
?> ?>
<!doctype html> <!doctype html>
<html lang="fr"> <html lang="fr">
@@ -11,7 +12,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choisir votre accès — PTA</title> <title>Choisir votre accès — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.4"> <link rel="stylesheet" href="assets/style.css?v=7.16">
</head> </head>
<body class="role-page-body"> <body class="role-page-body">
<main class="role-page-shell"> <main class="role-page-shell">
@@ -97,7 +98,7 @@
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté. <strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
</aside> </aside>
</main> </main>
<script> <script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
(() => { (() => {
const agentContext = document.querySelector('#agent-context'); const agentContext = document.querySelector('#agent-context');
const responsableContext = document.querySelector('#responsable-context'); const responsableContext = document.querySelector('#responsable-context');

View File

@@ -123,8 +123,8 @@
<input id="new-agent-contract-end" name="date_fin_contrat" type="date"> <input id="new-agent-contract-end" name="date_fin_contrat" type="date">
</label> </label>
<label class="check-field admin-field-wide"> <label class="check-field admin-field-wide">
<input id="new-agent-training-planned" type="checkbox"> <input id="new-agent-training-right" type="checkbox" checked>
<span>Une formation est déjà prévue dans la répartition annuelle : ne pas ajouter l’enveloppe complémentaire de 14 h</span> <span>Droit à l’enveloppe de formation de 14 h. Décoché, le motif « Formation » ne pourra pas être affecté à cet agent lors de la saisie des horaires.</span>
</label> </label>
<label class="field admin-field-wide"> <label class="field admin-field-wide">
<span>Observations utiles à la construction du PTA</span> <span>Observations utiles à la construction du PTA</span>
@@ -242,7 +242,7 @@
data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>" data-date-fin-contrat="<?= htmlspecialchars((string) ($agent['date_fin_contrat'] ?? ''), ENT_QUOTES) ?>"
data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>" data-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>" data-type-contrat="<?= htmlspecialchars((string) ($agent['type_contrat'] ?? 'PERMANENT'), ENT_QUOTES) ?>"
data-formation-repartition-annuelle="<?= !empty($agent['formation_repartition_annuelle']) ? '1' : '0' ?>" data-droit-enveloppe-formation="<?= !isset($agent['droit_enveloppe_formation']) || !empty($agent['droit_enveloppe_formation']) ? '1' : '0' ?>"
data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>" data-commentaire-pta="<?= htmlspecialchars((string) ($agent['commentaire_pta'] ?? ''), ENT_QUOTES) ?>"
data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>" data-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
> >
@@ -326,8 +326,8 @@
<input id="edit-agent-profile-contract-end" type="date"> <input id="edit-agent-profile-contract-end" type="date">
</label> </label>
<label class="check-field admin-field-wide"> <label class="check-field admin-field-wide">
<input id="edit-agent-profile-training-planned" type="checkbox"> <input id="edit-agent-profile-training-right" type="checkbox">
<span>Formation déjà intégrée dans la répartition annuelle</span> <span>Droit à l’enveloppe de formation de 14 h</span>
</label> </label>
<label class="field admin-field-wide"> <label class="field admin-field-wide">
<span>Observations PTA</span> <span>Observations PTA</span>

View File

@@ -0,0 +1,154 @@
<?php
/** @var string $currentWeek */
?>
<section id="dashboard-view" class="app-view page-content dashboard-page">
<section class="card dashboard-context-card">
<div class="section-heading">
<div>
<h2>Semaine de travail</h2>
<p class="section-subtitle" id="dashboard-week-label">Chargement de la semaine en cours…</p>
</div>
<div class="dashboard-context-actions">
<label class="field dashboard-week-field">
<span>Semaine analysée</span>
<input id="dashboard-week" type="week" value="<?= htmlspecialchars($currentWeek) ?>">
</label>
<button id="dashboard-refresh" class="button button-secondary" type="button">Actualiser</button>
</div>
</div>
<p class="dashboard-context-note" id="dashboard-week-context" hidden></p>
</section>
<div id="dashboard-message" class="message" role="status" aria-live="polite"></div>
<div id="dashboard-loading" class="empty-state" hidden>Consolidation des indicateurs…</div>
<div class="dashboard-tiles" aria-live="polite">
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-pending" href="pending.php">
<span class="dashboard-tile-label">Plannings à valider</span>
<strong class="dashboard-tile-value" id="dashboard-pending-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-pending-detail">Brouillons contenant des affectations</small>
<span class="dashboard-tile-cta">Ouvrir la file de validation</span>
</a>
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-coverage" href="coverage.php">
<span class="dashboard-tile-label">Manques de couverture</span>
<strong class="dashboard-tile-value" id="dashboard-coverage-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-coverage-detail">Sur la semaine analysée</small>
<span class="dashboard-tile-cta">Voir le détail des plages</span>
</a>
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-quota" href="annual.php">
<span class="dashboard-tile-label">Quotas dépassés</span>
<strong class="dashboard-tile-value" id="dashboard-quota-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-quota-detail">Agents au-delà de leur quota annuel</small>
<span class="dashboard-tile-cta">Ouvrir la vue annuelle</span>
</a>
<a class="dashboard-tile dashboard-tile-action" id="dashboard-tile-setup" href="administration.php">
<span class="dashboard-tile-label">Lieux à paramétrer</span>
<strong class="dashboard-tile-value" id="dashboard-setup-count">—</strong>
<small class="dashboard-tile-detail" id="dashboard-setup-detail">Sans horaires de couverture attendus</small>
<span class="dashboard-tile-cta">Ouvrir l’administration</span>
</a>
</div>
<div class="dashboard-columns">
<section class="card dashboard-list-card">
<div class="section-heading">
<div>
<h2>À valider en priorité</h2>
<p class="section-subtitle">Les brouillons dont la semaine est la plus ancienne.</p>
</div>
<a class="button button-secondary" href="pending.php">Tout voir</a>
</div>
<div id="dashboard-pending-empty" class="empty-state" hidden>Aucun planning n’attend de validation.</div>
<ul class="dashboard-list" id="dashboard-pending-list"></ul>
</section>
<section class="card dashboard-list-card">
<div class="section-heading">
<div>
<h2>Couverture à renforcer</h2>
<p class="section-subtitle">Les plages les plus déficitaires de la semaine analysée.</p>
</div>
<a class="button button-secondary" id="dashboard-coverage-link" href="coverage.php">Tout voir</a>
</div>
<div id="dashboard-coverage-empty" class="empty-state" hidden>Aucun manque de couverture sur cette semaine.</div>
<ul class="dashboard-list" id="dashboard-coverage-list"></ul>
<div id="dashboard-coverage-unconfigured" class="dashboard-inline-note" hidden></div>
</section>
</div>
<section class="card dashboard-quota-card">
<div class="section-heading">
<div>
<h2>Suivi des quotas annuels</h2>
<p class="section-subtitle" id="dashboard-quota-subtitle">Heures affectées, validées et brouillon comprises, sur l’année civile.</p>
</div>
<a class="button button-secondary" href="annual.php">Vue annuelle détaillée</a>
</div>
<div class="dashboard-quota-summary">
<article class="summary-card">
<span>Agents suivis</span>
<strong id="dashboard-quota-agents">—</strong>
<small>actifs</small>
</article>
<article class="summary-card">
<span>Sans aucune heure</span>
<strong id="dashboard-quota-empty">—</strong>
<small>agent(s) non planifié(s)</small>
</article>
</div>
<div id="dashboard-quota-empty-state" class="empty-state" hidden>Aucun agent actif à suivre.</div>
<div class="dashboard-quota-table-wrap">
<table class="dashboard-quota-table">
<thead>
<tr>
<th>Agent</th>
<th>Lieu de rattachement</th>
<th>Quota annuel</th>
<th>Affecté</th>
<th>Consommation</th>
<th></th>
</tr>
</thead>
<tbody id="dashboard-quota-body"></tbody>
</table>
</div>
</section>
<section class="card dashboard-shortcuts-card">
<div class="section-heading">
<div>
<h2>Actions fréquentes</h2>
<p class="section-subtitle">Les écrans les plus utilisés, accessibles en un clic.</p>
</div>
</div>
<div class="dashboard-shortcuts">
<a class="dashboard-shortcut" href="planning.php">
<strong>Saisir un planning</strong>
<small>Un agent, une semaine, un lieu.</small>
</a>
<a class="dashboard-shortcut" href="structures.php">
<strong>Consulter un lieu</strong>
<small>Qui travaille où cette semaine.</small>
</a>
<a class="dashboard-shortcut" href="teams.php">
<strong>Composer une équipe</strong>
<small>Mercredis et vacances scolaires.</small>
</a>
<a class="dashboard-shortcut" href="vacations.php">
<strong>Mettre à jour les vacances</strong>
<small>Périodes scolaires de référence.</small>
</a>
<a class="dashboard-shortcut" href="pta.php">
<strong>Ouvrir un PTA détaillé</strong>
<small>Souhaits, contraintes et répartition.</small>
</a>
<a class="dashboard-shortcut" href="administration.php">
<strong>Gérer les référentiels</strong>
<small>Agents, lieux, postes, couverture.</small>
</a>
</div>
</section>
</section>

412
app/Views/partials/faq.php Normal file
View File

@@ -0,0 +1,412 @@
<?php
/** @var array $accessProfile */
/** @var array $allowedPages */
$roleActuel = (string) ($accessProfile['role'] ?? '');
/**
* Le guide décrit l'application entière, quel que soit le rôle : un
* responsable de structure doit pouvoir comprendre ce que fait le Service
* Enfance après lui. Chaque section indique donc explicitement qui en a le
* droit, plutôt que de masquer les étapes inaccessibles.
*/
$sommaire = [
'faq-parcours' => 'Le parcours en un coup d’œil',
'faq-roles' => 'Qui fait quoi : les trois rôles',
'faq-lieux' => '1. Ajouter un lieu d’affectation',
'faq-agents' => '2. Ajouter un agent',
'faq-couverture' => '3. Définir les horaires d’ouverture',
'faq-vacances' => '4. Renseigner les vacances scolaires',
'faq-saisie' => '5. Saisir un planning pour un agent',
'faq-validation' => '6. Contrôler et valider un planning',
'faq-equipes' => '7. Composer les équipes',
'faq-suivi' => '8. Suivre l’année et éditer les PDF',
'faq-quota' => 'Comprendre le quota annuel et les motifs',
'faq-questions' => 'Questions fréquentes',
];
?>
<section class="page-content faq-page">
<div class="faq-layout">
<nav class="card faq-summary" aria-label="Sommaire du guide">
<p class="faq-summary-title">Sommaire</p>
<ol class="faq-summary-list">
<?php foreach ($sommaire as $ancre => $libelle): ?>
<li><a href="#<?= htmlspecialchars($ancre) ?>"><?= htmlspecialchars($libelle) ?></a></li>
<?php endforeach; ?>
</ol>
<p class="faq-summary-note">
Vous êtes connecté en tant que
<strong><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? 'rôle non sélectionné')) ?></strong>.
Les étapes réservées à un autre rôle restent décrites ici, mais leurs écrans ne vous sont pas proposés.
</p>
</nav>
<div class="faq-sections">
<article class="card faq-section" id="faq-parcours">
<div class="section-heading">
<div>
<h2>Le parcours en un coup d’œil</h2>
<p class="section-subtitle">PTA sert à répartir, sur toute l’année civile, les heures dues par chaque agent des accueils de loisirs. Le paramétrage se fait une fois, la saisie se répète chaque semaine.</p>
</div>
</div>
<ol class="faq-flow">
<li><span class="faq-flow-step">1</span><strong>Lieux d’affectation</strong><small>Les crèches, écoles et centres où travaillent les agents.</small></li>
<li><span class="faq-flow-step">2</span><strong>Agents</strong><small>Identité, poste, quotité de travail et lieu principal.</small></li>
<li><span class="faq-flow-step">3</span><strong>Horaires d’ouverture</strong><small>Les plages où une présence minimale est obligatoire.</small></li>
<li><span class="faq-flow-step">4</span><strong>Vacances scolaires</strong><small>Elles distinguent le périscolaire de l’extrascolaire.</small></li>
<li><span class="faq-flow-step">5</span><strong>Saisie des plannings</strong><small>Semaine par semaine, agent par agent, en brouillon.</small></li>
<li><span class="faq-flow-step">6</span><strong>Contrôle et validation</strong><small>Le Service Enfance valide et fige la semaine.</small></li>
<li><span class="faq-flow-step">7</span><strong>Équipes</strong><small>Mercredis et vacances, avec contrôle de l’encadrement.</small></li>
<li><span class="faq-flow-step">8</span><strong>Suivi annuel</strong><small>Quotas, couverture, PTA détaillé et PDF.</small></li>
</ol>
<p class="hint">Les étapes 1 à 4 ne se font qu’une fois, puis à la marge (nouvel agent, nouveau lieu, nouvelle année scolaire). Le travail courant, ce sont les étapes 5 et 6.</p>
</article>
<article class="card faq-section" id="faq-roles">
<div class="section-heading">
<div>
<h2>Qui fait quoi : les trois rôles</h2>
<p class="section-subtitle">Votre rôle détermine les onglets visibles et les actions autorisées. Il se choisit à l’ouverture de l’application et se change à tout moment par le bouton « Changer de rôle », en haut à droite.</p>
</div>
</div>
<div class="table-scroll">
<table class="planning-table faq-table">
<thead>
<tr>
<th>Rôle</th>
<th>Ce qu’il voit</th>
<th>Ce qu’il peut faire</th>
</tr>
</thead>
<tbody>
<tr<?= $roleActuel === 'AGENT' ? ' class="faq-row-current"' : '' ?>>
<td><strong>Agent</strong></td>
<td>Son propre planning et celui de son lieu de rattachement.</td>
<td>Lecture seule. Aucune modification, aucune validation.</td>
</tr>
<tr<?= $roleActuel === 'RESPONSABLE_STRUCTURE' ? ' class="faq-row-current"' : '' ?>>
<td><strong>Responsable de structure</strong></td>
<td>Son lieu : la saisie, les agents qui y sont planifiés, la couverture.</td>
<td>Saisir et corriger les horaires et motifs de son lieu, <strong>en brouillon uniquement</strong>.</td>
</tr>
<tr<?= $roleActuel === 'SERVICE_ENFANCE' ? ' class="faq-row-current"' : '' ?>>
<td><strong>Service Enfance</strong></td>
<td>Tous les lieux, tous les agents, tous les écrans.</td>
<td>Paramétrer, saisir, composer les équipes, <strong>valider</strong> et rouvrir les plannings.</td>
</tr>
</tbody>
</table>
</div>
<p class="hint">Un planning validé n’est plus modifiable par un responsable de structure : il faut demander au Service Enfance de le rouvrir, ce qui le repasse en brouillon.</p>
</article>
<article class="card faq-section" id="faq-lieux">
<div class="section-heading">
<div>
<h2>1. Ajouter un lieu d’affectation</h2>
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. Un lieu doit exister avant qu’un agent puisse y être rattaché ou planifié.</p>
</div>
</div>
<ol class="faq-steps">
<li>Ouvrez <strong>Administration</strong>, encadré « Ajouter un lieu d’affectation ».</li>
<li>Saisissez le <strong>code</strong> : un identifiant court et stable, sans espace, par exemple <code>CRECHE_MATUSALEME</code>. Il ne s’affiche que dans les tableaux techniques.</li>
<li>Saisissez le <strong>nom</strong> tel qu’il doit apparaître partout ailleurs, par exemple « Crèche Matusalème ».</li>
<li>Choisissez le <strong>type d’affectation</strong> : <em>Périscolaire</em> (autour du temps scolaire) ou <em>Extrascolaire</em> (mercredis et vacances). Ce choix appartient au lieu, pas à l’agent.</li>
<li>L’<strong>adresse</strong> est facultative et sert uniquement d’information.</li>
<li>Cliquez sur <strong>Ajouter le lieu</strong>. Il devient immédiatement sélectionnable dans tous les écrans.</li>
</ol>
<div class="message message-info faq-callout">
Le type d’affectation reste modifiable après coup dans le tableau « Paramètres des lieux d’affectation », juste en dessous du formulaire : choisissez la nouvelle valeur puis cliquez sur <strong>Enregistrer</strong> sur la ligne concernée.
</div>
</article>
<article class="card faq-section" id="faq-agents">
<div class="section-heading">
<div>
<h2>2. Ajouter un agent</h2>
<p class="section-subtitle">Onglet <strong>Administration</strong> — réservé au Service Enfance. La fiche agent détermine son quota annuel et les motifs qu’on pourra lui affecter.</p>
</div>
</div>
<ol class="faq-steps">
<li><strong>Matricule, nom, prénom</strong> : l’identité de l’agent. Le matricule doit être unique.</li>
<li><strong>Poste PTA</strong> : la fonction occupée. Elle sert notamment à repérer les responsables dans les équipes.</li>
<li><strong>Type de contrat</strong> : <em>Permanent</em> (soumis au PTA) ou <em>Temporaire</em> (hors périmètre PTA, planifiable mais sans annualisation).</li>
<li><strong>Lieu d’affectation principal</strong> : le rattachement par défaut. Ce n’est pas une exclusivité — l’agent pourra être planifié sur d’autres lieux la même semaine.</li>
<li><strong>Quotité de travail</strong>, en pourcentage. C’est elle qui proratise le quota annuel : 100 % = 1 607 h, 80 % = 1 285 h 36, etc.</li>
<li><strong>Dates de contrat</strong> : information RH. Attention, la date de début <em>ne décale pas</em> l’année de référence, qui reste l’année civile complète.</li>
<li><strong>Qualification et diplôme</strong> : le champ « Diplôme » ne s’active que si l’agent est déclaré diplômé. Cette information alimente le contrôle du taux de diplômés dans les équipes.</li>
<li><strong>Droit à l’enveloppe de formation de 14 h</strong> : coché par défaut. Décoché, le motif « Formation » ne sera pas proposé pour cet agent lors de la saisie.</li>
<li><strong>Observations utiles à la construction du PTA</strong> : disponibilités particulières, organisation connue… Ces notes se retrouvent sur l’écran « PTA détaillé ».</li>
<li>Cliquez sur <strong>Ajouter l’agent</strong>.</li>
</ol>
<p class="hint">Pour corriger une fiche existante, utilisez le tableau « Informations des agents » en bas de la page : le bouton <strong>Modifier</strong> ouvre la fiche complète, lieu principal compris.</p>
</article>
<article class="card faq-section" id="faq-couverture">
<div class="section-heading">
<div>
<h2>3. Définir les horaires d’ouverture</h2>
<p class="section-subtitle">Onglet <strong>Administration</strong>, encadré « Horaires de couverture des lieux ». Ces plages décrivent quand le lieu doit être tenu, et par combien d’agents au minimum.</p>
</div>
</div>
<ol class="faq-steps">
<li>Choisissez le <strong>lieu d’affectation</strong> puis cliquez sur <strong>Charger</strong>.</li>
<li>L’éditeur affiche une section par jour, du <strong>lundi au vendredi</strong>.</li>
<li>Sur un jour, cliquez sur <strong>+ Ajouter une plage</strong>, puis renseignez l’heure de début, l’heure de fin et le <strong>nombre minimum d’agents</strong> attendus sur cette plage.</li>
<li>Répétez pour chaque plage d’ouverture réelle : par exemple 07:30–08:30 avec 2 agents, puis 11:30–13:30 avec 4 agents, puis 16:30–18:30 avec 3 agents.</li>
<li>Cliquez sur <strong>Enregistrer les horaires de couverture</strong>.</li>
</ol>
<div class="message message-info faq-callout">
Seules les affectations portant le motif <strong>« Heure de travail »</strong> comptent comme une présence sur ces plages. Une formation, un congé ou une absence ne couvrent pas le lieu — c’est le principe même du contrôle.
</div>
<p class="hint">Tant qu’un lieu n’a aucune plage configurée, il apparaît comme « non configuré » dans l’onglet <strong>Couverture</strong> : l’application ne peut alors rien contrôler pour lui, et le silence ne veut pas dire que tout va bien.</p>
</article>
<article class="card faq-section" id="faq-vacances">
<div class="section-heading">
<div>
<h2>4. Renseigner les vacances scolaires</h2>
<p class="section-subtitle">Onglet <strong>Vacances</strong> — réservé au Service Enfance. Ces périodes déterminent si une journée relève du périscolaire ou de l’extrascolaire.</p>
</div>
</div>
<ol class="faq-steps">
<li>Indiquez l’<strong>année civile</strong> souhaitée et lancez la recherche : PTA interroge l’API publique du ministère de l’Éducation nationale et agrège les deux années scolaires concernées.</li>
<li>Contrôlez les <strong>dates proposées</strong>. Elles restent modifiables : corrigez ce qui doit l’être avant d’enregistrer.</li>
<li><strong>Cochez uniquement</strong> les périodes à conserver, puis enregistrez.</li>
<li>Si une période manque ou doit être adaptée aux besoins du service, utilisez le formulaire <strong>« Ajouter une période manuellement »</strong>.</li>
<li>Le tableau <strong>« Périodes enregistrées »</strong> est la référence réellement utilisée par l’application : c’est lui qui fait foi, pas la proposition de l’API.</li>
</ol>
<p class="hint">Cette étape est à refaire à chaque nouvelle année civile. Sans période enregistrée, PTA considère toutes les journées comme périscolaires.</p>
</article>
<article class="card faq-section" id="faq-saisie">
<div class="section-heading">
<div>
<h2>5. Saisir un planning pour un agent</h2>
<p class="section-subtitle">Onglet <strong>Saisie</strong> — Service Enfance et responsables de structure. Un planning, c’est <strong>un agent × une semaine × un lieu</strong>.</p>
</div>
</div>
<ol class="faq-steps">
<li><strong>Sélection du planning.</strong> Choisissez le lieu, puis l’agent, puis la semaine. Les listes se débloquent dans cet ordre. Un responsable de structure a son lieu déjà imposé.</li>
<li><strong>Lisez le bandeau de quota</strong> qui apparaît : quotité de l’agent, quota de la période, heures déjà affectées (validées et brouillons confondus) et reste à affecter.</li>
<li><strong>Saisissez les affectations.</strong> Le tableau propose une ligne par jour, du lundi au vendredi. Sur chaque jour, renseignez une ou plusieurs plages horaires, chacune avec son propre <strong>motif</strong>. Les heures se saisissent par pas de 15 minutes.</li>
<li>Cliquez sur <strong>Appliquer les horaires renseignés</strong> : les plages basculent dans le planning de la semaine.</li>
<li><strong>Vérifiez le planning global de l’agent</strong>, en dessous. Il montre <em>toutes</em> ses affectations de la semaine, y compris celles posées sur d’autres lieux. Le total des heures décomptées s’affiche à droite.</li>
<li>Pour corriger, cliquez sur un créneau pour le <strong>déplacer ou le dupliquer</strong>, ou faites-le glisser directement vers un autre jour.</li>
<li>Cliquez sur <strong>Enregistrer le brouillon</strong>. Le planning est conservé au statut <strong>BROUILLON</strong> et reste modifiable.</li>
</ol>
<div class="message message-info faq-callout">
Un même agent peut être planifié sur plusieurs lieux la même semaine. En revanche, deux créneaux ne peuvent jamais se chevaucher : une plage déjà posée ailleurs apparaît dans le planning global et bloque toute saisie qui l’empiéterait.
</div>
<h3>Gagner du temps avec les semaines types</h3>
<p>Quand une semaine se répète à l’identique, le Service Enfance peut l’enregistrer puis la rejouer :</p>
<ul class="faq-list">
<li><strong>Enregistrer comme semaine type</strong> : mémorise la semaine affichée sous un nom.</li>
<li><strong>Importer une semaine type</strong> : la rejoue sur la semaine en cours de saisie.</li>
<li><strong>Appliquer sur une période</strong> : la rejoue sur plusieurs semaines d’un coup. Un aperçu annonce les conflits avant d’écrire quoi que ce soit — le mode « Compléter » refuse d’écraser, le mode « Remplacer » supprime les créneaux existants des jours concernés.</li>
</ul>
</article>
<article class="card faq-section" id="faq-validation">
<div class="section-heading">
<div>
<h2>6. Contrôler et valider un planning</h2>
<p class="section-subtitle">Onglets <strong>À valider</strong> et <strong>Saisie</strong> — la validation est réservée au Service Enfance.</p>
</div>
</div>
<ol class="faq-steps">
<li>Ouvrez <strong>À valider</strong> : la file de tous les brouillons contenant des affectations.</li>
<li>Cliquez sur un planning : l’écran de <strong>Saisie</strong> s’ouvre sur le bon agent et la bonne semaine, positionné sur la zone de validation.</li>
<li>Contrôlez les horaires, puis cliquez sur <strong>Valider le planning</strong>.</li>
</ol>
<p>La validation applique quatre contrôles bloquants et une alerte :</p>
<div class="table-scroll">
<table class="planning-table faq-table">
<thead>
<tr>
<th>Niveau</th>
<th>Contrôle</th>
<th>Conséquence</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="faq-level faq-level-error">Erreur</span></td>
<td>Le planning ne contient aucune affectation.</td>
<td rowspan="4">La validation est <strong>refusée</strong>. Corrigez puis recommencez.</td>
</tr>
<tr>
<td><span class="faq-level faq-level-error">Erreur</span></td>
<td>Une affectation est en dehors de la semaine sélectionnée.</td>
</tr>
<tr>
<td><span class="faq-level faq-level-error">Erreur</span></td>
<td>Deux affectations du planning se chevauchent.</td>
</tr>
<tr>
<td><span class="faq-level faq-level-error">Erreur</span></td>
<td>Une affectation chevauche un créneau déjà validé sur un autre lieu.</td>
</tr>
<tr>
<td><span class="faq-level faq-level-warning">Alerte</span></td>
<td>Le quota annuel de l’agent est dépassé.</td>
<td>Une <strong>confirmation explicite</strong> est demandée. Le dépassement est possible, mais il doit être assumé.</td>
</tr>
</tbody>
</table>
</div>
<p class="hint">Une fois validé, le planning est figé : le bouton <strong>Modifier le planning</strong> le rouvre et le repasse en brouillon, ce qui impose une nouvelle validation. Modifier un créneau validé depuis l’onglet « Par agent » produit exactement le même effet, automatiquement.</p>
</article>
<article class="card faq-section" id="faq-equipes">
<div class="section-heading">
<div>
<h2>7. Composer les équipes</h2>
<p class="section-subtitle">Onglet <strong>Équipes</strong> — réservé au Service Enfance. Une équipe couvre une période (mercredis, vacances) sur un lieu, et vérifie que l’encadrement est réglementaire.</p>
</div>
</div>
<p>Une équipe est indépendante des plannings hebdomadaires : elle répond à la question « qui encadre, sur cette période, et est-ce suffisant ? ».</p>
<ol class="faq-steps">
<li><strong>Nommez l’équipe</strong> de façon parlante, par exemple « Vacances d’hiver — Équipe Matusalème ».</li>
<li>Choisissez son <strong>type</strong> : <em>Mercredi périscolaire</em>, <em>Extrascolaire</em> ou <em>Périscolaire semaine</em>. Un même agent peut appartenir à plusieurs types d’équipes dans l’année.</li>
<li>Choisissez le <strong>lieu</strong>, puis les <strong>dates de début et de fin</strong> de la période couverte.</li>
<li>Renseignez l’<strong>effectif d’enfants</strong> attendu, séparé en moins de 6 ans et 6 ans et plus.</li>
<li>Ajustez les <strong>ratios d’encadrement</strong> : par défaut 1 agent pour 8 enfants de moins de 6 ans, et 1 pour 12 au-delà. PTA en déduit le nombre d’agents requis.</li>
<li>Fixez le <strong>minimum d’agents</strong> et le <strong>pourcentage minimum de diplômés</strong> (50 % par défaut).</li>
<li>Créez l’équipe, puis <strong>ajoutez-y les agents</strong> depuis la liste « Équipes enregistrées ».</li>
</ol>
<p>À chaque modification, PTA recalcule ses contrôles :</p>
<ul class="faq-list">
<li><span class="faq-level faq-level-error">Erreur</span> <strong>Encadrement insuffisant</strong> — moins d’agents affectés que requis par les ratios et le minimum.</li>
<li><span class="faq-level faq-level-error">Erreur</span> <strong>Qualification insuffisante</strong> — le taux de diplômés est sous le seuil paramétré.</li>
<li><span class="faq-level faq-level-warning">Alerte</span> <strong>Responsable manquant</strong> — aucun agent de l’équipe n’occupe un poste de responsable.</li>
<li><span class="faq-level faq-level-warning">Alerte</span> <strong>Contrainte prioritaire</strong> — un agent a une préconisation médicale ou un temps partiel thérapeutique actif sur la période.</li>
<li><span class="faq-level faq-level-info">Info</span> <strong>Préférence respectée</strong> — un agent avait exprimé un souhait pour ce lieu dans son PTA détaillé.</li>
</ul>
<p class="hint">L’équipe passe ensuite de <strong>Brouillon</strong> à <strong>À contrôler</strong> puis <strong>Validée</strong>. Les contraintes prioritaires et les souhaits de lieu se saisissent dans l’onglet <strong>PTA détaillé</strong> de chaque agent.</p>
</article>
<article class="card faq-section" id="faq-suivi">
<div class="section-heading">
<div>
<h2>8. Suivre l’année et éditer les PDF</h2>
<p class="section-subtitle">Les écrans de consultation ne modifient rien : ils servent à contrôler, comparer et imprimer.</p>
</div>
</div>
<div class="table-scroll">
<table class="planning-table faq-table">
<thead>
<tr>
<th>Onglet</th>
<th>À quoi il sert</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Tableau de bord</strong></td>
<td>Le point d’entrée du Service Enfance : brouillons les plus anciens à valider, plages de couverture déficitaires et suivi des quotas annuels.</td>
</tr>
<tr>
<td><strong>Couverture</strong></td>
<td>Les plages où le nombre d’agents en « Heure de travail » passe sous le minimum attendu du lieu, et les lieux encore non configurés.</td>
</tr>
<tr>
<td><strong>Par lieu</strong></td>
<td>Le planning d’un lieu sur une semaine : agents rattachés et agents réellement planifiés. Lecture seule.</td>
</tr>
<tr>
<td><strong>Par agent</strong></td>
<td>Le planning d’un agent mois par mois, tous lieux confondus, avec son quota. C’est ici qu’on corrige un créneau isolé ou qu’on duplique une semaine.</td>
</tr>
<tr>
<td><strong>Vue annuelle</strong></td>
<td>L’annualisation d’un agent de janvier à décembre, et la validation de toute l’année en une seule fois.</td>
</tr>
<tr>
<td><strong>PTA détaillé</strong></td>
<td>La fiche annuelle complète : répartition des heures, congés et JNT, contrôles métier, contraintes prioritaires, préférences et interdictions de lieu.</td>
</tr>
</tbody>
</table>
</div>
<h3>Les documents imprimables</h3>
<ul class="faq-list">
<li><strong>PDF de l’agent (7 semaines)</strong>, depuis l’écran de saisie : le planning détaillé sur sept semaines à partir de la semaine affichée.</li>
<li><strong>PDF du lieu</strong>, depuis la vue par lieu : la semaine complète d’un lieu, tous agents confondus.</li>
<li><strong>Récapitulatif horaire et contrat</strong>, depuis la fiche agent : les heures travaillées et le suivi du quota annuel.</li>
</ul>
</article>
<article class="card faq-section" id="faq-quota">
<div class="section-heading">
<div>
<h2>Comprendre le quota annuel et les motifs</h2>
<p class="section-subtitle">C’est la mécanique centrale de l’application : tout le reste sert à la nourrir.</p>
</div>
</div>
<ul class="faq-list">
<li>Le quota suit l’<strong>année civile</strong>, du 1<sup>er</sup> janvier au 31 décembre — jamais l’année scolaire, jamais la date de contrat.</li>
<li>La référence est de <strong>1 607 heures</strong> à 100 %, <strong>proratisée par la quotité</strong> de l’agent.</li>
<li>Les heures « déjà affectées » comptent <strong>les brouillons comme les plannings validés</strong> : le compteur ne ment pas en attendant la validation.</li>
<li>Chaque plage horaire porte un <strong>motif</strong>, et c’est le motif qui décide si les heures sont décomptées du quota.</li>
</ul>
<p>Trois motifs ne sont <strong>pas</strong> décomptés du quota :</p>
<div class="table-scroll">
<table class="planning-table faq-table">
<thead>
<tr>
<th>Motif</th>
<th>Décompté du quota ?</th>
</tr>
</thead>
<tbody>
<tr><td>Heure de travail, ALP matin, Restauration, ALP soir, Mercredi périscolaire, Temps extrascolaire</td><td><strong>Oui</strong></td></tr>
<tr><td>Préparation (périscolaire, extrascolaire, du lundi), Ménage de fond</td><td><strong>Oui</strong></td></tr>
<tr><td>Formation, Journée de fractionnement, Absence</td><td><strong>Oui</strong></td></tr>
<tr><td>Congé annuel</td><td>Non</td></tr>
<tr><td>Autre absence</td><td>Non</td></tr>
<tr><td>Journée non travaillée (JNT)</td><td>Non</td></tr>
</tbody>
</table>
</div>
<p class="hint">Le motif « Formation » n’est proposé que pour les agents disposant du droit à l’enveloppe de formation de 14 h, coché sur leur fiche.</p>
</article>
<article class="card faq-section" id="faq-questions">
<div class="section-heading">
<div>
<h2>Questions fréquentes</h2>
</div>
</div>
<dl class="faq-qa">
<dt>Je ne vois pas l’onglet dont on me parle.</dt>
<dd>Les onglets dépendent du rôle sélectionné. Vérifiez le badge en haut à droite et, si nécessaire, utilisez « Changer de rôle ». Si l’onglet reste absent, votre rôle n’y a pas droit.</dd>
<dt>Impossible de choisir un agent dans l’écran de saisie.</dt>
<dd>Il faut d’abord choisir un lieu d’affectation : la liste des agents en dépend. La semaine ne se débloque qu’une fois l’agent choisi.</dd>
<dt>L’application refuse ma saisie pour cause de chevauchement.</dt>
<dd>L’agent a déjà une affectation sur ce créneau, éventuellement <strong>sur un autre lieu</strong>. Regardez le « Planning global de l’agent » : il affiche toutes ses affectations de la semaine, tous lieux confondus.</dd>
<dt>Le quota est dépassé, que faire ?</dt>
<dd>C’est une alerte, pas une erreur. Vous pouvez confirmer la validation en connaissance de cause, ou retirer des heures. Le dépassement reste visible dans le suivi des quotas.</dd>
<dt>Comment corriger un planning déjà validé ?</dt>
<dd>Seul le Service Enfance peut le rouvrir : bouton <strong>Modifier le planning</strong> dans l’écran de saisie. Le planning repasse en brouillon et devra être validé à nouveau. Toute modification d’un créneau validé depuis « Par agent » déclenche la même bascule.</dd>
<dt>Un agent doit travailler sur deux lieux la même semaine.</dt>
<dd>C’est prévu. Le lieu principal de la fiche agent n’est qu’un rattachement par défaut : saisissez simplement un planning sur chacun des lieux, tant que les horaires ne se chevauchent pas.</dd>
<dt>L’onglet Couverture ne signale rien pour un de mes lieux.</dt>
<dd>Ce lieu n’a probablement aucune plage d’ouverture configurée. Sans plage attendue, l’application n’a rien à comparer. Renseignez-les dans <strong>Administration</strong> → « Horaires de couverture des lieux ».</dd>
<dt>Les vacances scolaires affichées sont fausses.</dt>
<dd>Les dates proposées par l’API restent modifiables avant enregistrement, et le formulaire manuel permet d’ajouter ou de corriger une période. Seul le tableau « Périodes enregistrées » est utilisé par l’application.</dd>
<dt>Mes modifications n’apparaissent pas après enregistrement.</dt>
<dd>Rechargez la page avec <kbd>Ctrl</kbd> + <kbd>F5</kbd>. Si le problème persiste, un message d’erreur rouge s’affiche normalement sous le formulaire concerné : signalez son contenu à l’administrateur de l’application.</dd>
</dl>
</article>
</div>
</div>
</section>

View File

@@ -1,3 +1,10 @@
<?php
// Les rôles sans droit aux semaines types n'ont qu'un seul mode de saisie :
// le sélecteur de méthode disparaît et la numérotation des étapes se resserre.
$usesTemplates = !empty($permissions['can_use_templates']);
$etapeSaisie = $usesTemplates ? 3 : 2;
$etapePlanning = $usesTemplates ? 4 : 3;
?>
<section id="editor-view" class="app-view page-content"> <section id="editor-view" class="app-view page-content">
<?php if (($accessProfile['role'] ?? '') === 'RESPONSABLE_STRUCTURE'): ?> <?php if (($accessProfile['role'] ?? '') === 'RESPONSABLE_STRUCTURE'): ?>
<div class="message message-info role-page-notice"> <div class="message message-info role-page-notice">
@@ -33,6 +40,83 @@
</div> </div>
</section> </section>
<?php if ($usesTemplates): ?>
<section class="card editor-mode-card">
<div class="section-heading">
<div>
<h2>2. Choisir la méthode de saisie</h2>
<p class="section-subtitle">Une semaine type se compose une fois puis se duplique sur toute une période. La saisie semaine par semaine reste disponible pour les corrections.</p>
</div>
</div>
<div class="editor-mode-choices" role="tablist" aria-label="Méthode de saisie">
<button id="editor-mode-template" class="editor-mode-choice is-active" type="button"
role="tab" aria-selected="true" aria-controls="template-mode-panel">
<span class="editor-mode-choice-badge">Le plus rapide</span>
<strong>Semaine type</strong>
<small>Composer une semaine de référence, puis la dupliquer sur une période entière.</small>
</button>
<button id="editor-mode-week" class="editor-mode-choice" type="button"
role="tab" aria-selected="false" aria-controls="week-mode-panel">
<strong>Semaine précise</strong>
<small>Saisir ou corriger une seule semaine, jour par jour.</small>
</button>
</div>
</section>
<div id="template-mode-panel" class="editor-mode-panel" role="tabpanel" aria-labelledby="editor-mode-template">
<section class="card template-editor-card">
<div class="section-heading">
<div>
<h2>Éditeur de semaine type</h2>
<p class="section-subtitle">Les plages saisies ici ne sont pas encore planifiées : elles décrivent une semaine de référence, réutilisable autant de fois que nécessaire.</p>
</div>
<div class="totals template-editor-total">
<span>Total de la semaine type</span>
<strong id="template-editor-total">0 h 00</strong>
<small id="template-editor-total-detail">dont 0 h 00 décomptées</small>
</div>
</div>
<div class="template-editor-toolbar">
<label class="field">
<span>Semaine type de l’agent</span>
<select id="template-editor-select" disabled>
<option value="">Nouvelle semaine type</option>
</select>
</label>
<label class="field">
<span>Nom de la semaine type <strong>*</strong></span>
<input id="template-editor-name" type="text" maxlength="120" placeholder="Ex. Semaine scolaire classique" disabled>
</label>
<div class="template-editor-toolbar-actions">
<button id="template-editor-delete" class="button button-danger" type="button" disabled>Supprimer</button>
</div>
</div>
<div id="template-editor-empty" class="empty-state">Choisissez un lieu d’affectation et un agent pour composer sa semaine type.</div>
<div id="template-editor-grid" class="template-editor-grid" hidden></div>
<div id="template-editor-message" class="message" role="status" aria-live="polite"></div>
<div class="template-editor-actions">
<button id="template-editor-save" class="button button-primary" type="button" disabled>
Enregistrer la semaine type
</button>
<button id="template-editor-apply-period" class="button button-success" type="button" disabled>
Dupliquer sur une période…
</button>
<button id="template-editor-apply-week" class="button button-secondary" type="button" disabled>
Appliquer à la semaine sélectionnée…
</button>
</div>
<p class="hint">Enregistrer une semaine type ne modifie aucun planning. Rien n’est planifié tant que vous ne l’avez pas dupliquée sur une période ou appliquée à une semaine.</p>
</section>
</div>
<?php endif; ?>
<div id="week-mode-panel" class="editor-mode-panel"<?= $usesTemplates ? ' hidden' : '' ?>>
<section class="quota-strip" id="editor-quota" hidden> <section class="quota-strip" id="editor-quota" hidden>
<article class="quota-card"> <article class="quota-card">
<span>Quotité</span> <span>Quotité</span>
@@ -57,7 +141,7 @@
<section class="card" id="entry-card" aria-disabled="true"> <section class="card" id="entry-card" aria-disabled="true">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>2. Saisir ou modifier les affectations</h2> <h2><?= $etapeSaisie ?>. Saisir ou modifier les affectations</h2>
<p class="section-subtitle">Chaque plage horaire possède son propre motif et sa propre règle de décompte du quota annuel.</p> <p class="section-subtitle">Chaque plage horaire possède son propre motif et sa propre règle de décompte du quota annuel.</p>
</div> </div>
</div> </div>
@@ -92,7 +176,7 @@
<section class="card"> <section class="card">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>3. Planning global de l’agent</h2> <h2><?= $etapePlanning ?>. Planning global de l’agent</h2>
<p id="week-label" class="section-subtitle">Toutes les affectations de l’agent sur la semaine, quel que soit le lieu d’affectation. Cliquez sur un créneau pour le déplacer ou le dupliquer, ou glissez-le directement vers un autre jour.</p> <p id="week-label" class="section-subtitle">Toutes les affectations de l’agent sur la semaine, quel que soit le lieu d’affectation. Cliquez sur un créneau pour le déplacer ou le dupliquer, ou glissez-le directement vers un autre jour.</p>
</div> </div>
<div class="planning-summary-actions"> <div class="planning-summary-actions">
@@ -133,6 +217,8 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
</section> </section>
</div><!-- /#week-mode-panel -->
</section> </section>

View File

@@ -10,7 +10,7 @@ $defaultCalendarYear = $currentYear;
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>Vacances scolaires</h2> <h2>Vacances scolaires</h2>
<p class="section-subtitle">Consultez toutes les vacances d’une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p> <p class="section-subtitle">Consultez toutes les vacances d'une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
</div> </div>
</div> </div>
@@ -21,7 +21,7 @@ $defaultCalendarYear = $currentYear;
<div class="selection-grid vacation-filters"> <div class="selection-grid vacation-filters">
<label class="field"> <label class="field">
<span>Académie</span> <span>Académie</span>
<input id="vacation-academy" type="text" maxlength="100" value="Orléans-Tours" placeholder="Ex. Orléans-Tours" list="vacation-academies" autocomplete="off"> <input id="vacation-academy" type="text" maxlength="100" value="Dijon" placeholder="Ex. Dijon" list="vacation-academies" autocomplete="off">
<datalist id="vacation-academies"> <datalist id="vacation-academies">
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux"> <option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble"> <option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
@@ -45,8 +45,8 @@ $defaultCalendarYear = $currentYear;
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden> <div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
<div class="section-heading compact-heading"> <div class="section-heading compact-heading">
<div> <div>
<h3>Dates proposées par l’API</h3> <h3>Dates proposées par l'API</h3>
<p class="section-subtitle">Toutes les périodes de l’année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p> <p class="section-subtitle">Toutes les périodes de l'année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
</div> </div>
<label class="vacation-select-all"> <label class="vacation-select-all">
<input id="vacation-select-all" type="checkbox"> <input id="vacation-select-all" type="checkbox">

View File

@@ -2,11 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
use App\Core\Session;
use App\Models\SchemaModel; use App\Models\SchemaModel;
if (session_status() !== PHP_SESSION_ACTIVE) { Session::start();
session_start();
}
$pdo = require dirname(__DIR__) . '/db.php'; $pdo = require dirname(__DIR__) . '/db.php';
(new SchemaModel($pdo))->assertCurrent(); (new SchemaModel($pdo))->assertCurrent();

View File

@@ -211,7 +211,7 @@
row.dataset.dateFinContrat = agent.date_fin_contrat || ''; row.dataset.dateFinContrat = agent.date_fin_contrat || '';
row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : ''; row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : '';
row.dataset.typeContrat = agent.type_contrat || 'PERMANENT'; row.dataset.typeContrat = agent.type_contrat || 'PERMANENT';
row.dataset.formationRepartitionAnnuelle = Number(agent.formation_repartition_annuelle) ? '1' : '0'; row.dataset.droitEnveloppeFormation = Number(agent.droit_enveloppe_formation ?? 1) ? '1' : '0';
row.dataset.commentairePta = agent.commentaire_pta || ''; row.dataset.commentairePta = agent.commentaire_pta || '';
row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : ''; row.dataset.structureId = agent.id_structure ? String(Number(agent.id_structure)) : '';
} }
@@ -255,7 +255,7 @@
document.querySelector('#edit-agent-profile-contract-end').value = row.dataset.dateFinContrat || ''; document.querySelector('#edit-agent-profile-contract-end').value = row.dataset.dateFinContrat || '';
document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || ''; document.querySelector('#edit-agent-profile-post').value = row.dataset.idPoste || '';
document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT'; document.querySelector('#edit-agent-profile-contract-type').value = row.dataset.typeContrat || 'PERMANENT';
document.querySelector('#edit-agent-profile-training-planned').checked = row.dataset.formationRepartitionAnnuelle === '1'; document.querySelector('#edit-agent-profile-training-right').checked = row.dataset.droitEnveloppeFormation !== '0';
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || ''; document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || ''; document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma')); toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma'));
@@ -302,7 +302,7 @@
date_fin_contrat: document.querySelector('#edit-agent-profile-contract-end').value, date_fin_contrat: document.querySelector('#edit-agent-profile-contract-end').value,
id_poste: Number(document.querySelector('#edit-agent-profile-post').value), id_poste: Number(document.querySelector('#edit-agent-profile-post').value),
type_contrat: document.querySelector('#edit-agent-profile-contract-type').value, type_contrat: document.querySelector('#edit-agent-profile-contract-type').value,
formation_repartition_annuelle: document.querySelector('#edit-agent-profile-training-planned').checked, droit_enveloppe_formation: document.querySelector('#edit-agent-profile-training-right').checked,
commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value, commentaire_pta: document.querySelector('#edit-agent-profile-pta-comment').value,
structure_id: structureValue ? Number(structureValue) : null, structure_id: structureValue ? Number(structureValue) : null,
}), }),
@@ -407,7 +407,7 @@
date_fin_contrat: document.querySelector('#new-agent-contract-end').value, date_fin_contrat: document.querySelector('#new-agent-contract-end').value,
id_poste: Number(document.querySelector('#new-agent-post').value), id_poste: Number(document.querySelector('#new-agent-post').value),
type_contrat: document.querySelector('#new-agent-contract-type').value, type_contrat: document.querySelector('#new-agent-contract-type').value,
formation_repartition_annuelle: document.querySelector('#new-agent-training-planned').checked, droit_enveloppe_formation: document.querySelector('#new-agent-training-right').checked,
commentaire_pta: document.querySelector('#new-agent-pta-comment').value, commentaire_pta: document.querySelector('#new-agent-pta-comment').value,
}), }),
}); });
@@ -419,7 +419,6 @@
form.reset(); form.reset();
document.querySelector('#new-agent-rate').value = '100'; document.querySelector('#new-agent-rate').value = '100';
document.querySelector('#new-agent-contract-type').value = 'PERMANENT'; document.querySelector('#new-agent-contract-type').value = 'PERMANENT';
document.querySelector('#new-agent-training-planned').checked = false;
document.querySelector('#new-agent-qualified').value = '0'; document.querySelector('#new-agent-qualified').value = '0';
toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma')); toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma'));

View File

@@ -254,6 +254,28 @@
setMessage(message); setMessage(message);
} }
/**
* Le sélecteur de motif est rendu côté serveur avec tous les motifs actifs :
* on le reconstruit à l'ouverture pour retirer « Formation » aux agents sans
* droit à l'enveloppe de formation. Le motif déjà porté par le créneau reste
* proposé, sans quoi le modal changerait silencieusement de motif.
*/
function renderMotifOptions(selectedMotifId) {
const select = document.querySelector('#edit-agent-entry-motif');
const motifs = window.PTA_CONFIG?.motifs || [];
if (!select || motifs.length === 0) return;
const agent = shared.lastAgentOverviewData?.agent;
const hasTrainingRight = Number(agent?.droit_enveloppe_formation ?? 1) !== 0;
select.innerHTML = motifs
.filter((motif) => hasTrainingRight
|| String(motif.code).toUpperCase() !== 'FORMATION'
|| Number(motif.id) === Number(selectedMotifId))
.map((motif) => `<option value="${Number(motif.id)}">${escapeHtml(motif.label)}</option>`)
.join('');
}
function openModal(entryId) { function openModal(entryId) {
const entry = findEntry(entryId); const entry = findEntry(entryId);
if (!entry || !canEditEntry(entry)) return; if (!entry || !canEditEntry(entry)) return;
@@ -263,6 +285,7 @@
document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau)); document.querySelector('#edit-agent-entry-id').value = String(Number(entry.id_creneau));
document.querySelector('#edit-agent-entry-date').value = entry.date_jour; document.querySelector('#edit-agent-entry-date').value = entry.date_jour;
document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure)); document.querySelector('#edit-agent-entry-structure').value = String(Number(entry.id_structure));
renderMotifOptions(Number(entry.id_motif));
document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif)); document.querySelector('#edit-agent-entry-motif').value = String(Number(entry.id_motif));
document.querySelector('#edit-agent-entry-start').value = entry.heure_debut; document.querySelector('#edit-agent-entry-start').value = entry.heure_debut;
document.querySelector('#edit-agent-entry-end').value = entry.heure_fin; document.querySelector('#edit-agent-entry-end').value = entry.heure_fin;

View File

@@ -5,12 +5,9 @@
if (!PTA) return; if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils; const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
const escapeAttr = (value) => String(value ?? '') // escapeHtml échappe désormais aussi les guillemets : une seule
.replaceAll('&', '&amp;') // implémentation partagée, valable en contexte texte comme attribut.
.replaceAll('\"', '&quot;') const escapeAttr = escapeHtml;
.replaceAll("'", '&#039;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
let current = null; let current = null;
function show(text, type = 'info') { function show(text, type = 'info') {

View File

@@ -31,6 +31,7 @@
entries: [], entries: [],
otherStructureEntries: [], otherStructureEntries: [],
currentStructure: null, currentStructure: null,
currentAgent: null,
planningId: null, planningId: null,
planningStatus: null, planningStatus: null,
weekDays: [], weekDays: [],
@@ -44,10 +45,21 @@
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi']; const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
/**
* Échappe une valeur pour l'insertion dans du HTML, texte ET attribut.
*
* La sérialisation via textContent/innerHTML n'échappe ni " ni ' : elle est
* sûre entre deux balises, mais permet de sortir d'un attribut value="…" ou
* title="…". PTA insère des libellés de vacances, des noms de lieux et des
* commentaires dans des attributs : les guillemets doivent être échappés.
*/
function escapeHtml(value) { function escapeHtml(value) {
const div = document.createElement('div'); return String(value ?? '')
div.textContent = String(value ?? ''); .replaceAll('&', '&amp;')
return div.innerHTML; .replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
} }
function showMessage(text, type = 'info') { function showMessage(text, type = 'info') {

325
assets/js/dashboard.js Normal file
View File

@@ -0,0 +1,325 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant dashboard.js');
const { utils } = PTA;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDuration, formatDate } = utils;
const elements = {
week: document.querySelector('#dashboard-week'),
weekLabel: document.querySelector('#dashboard-week-label'),
weekContext: document.querySelector('#dashboard-week-context'),
refresh: document.querySelector('#dashboard-refresh'),
message: document.querySelector('#dashboard-message'),
loading: document.querySelector('#dashboard-loading'),
pendingCount: document.querySelector('#dashboard-pending-count'),
pendingDetail: document.querySelector('#dashboard-pending-detail'),
pendingTile: document.querySelector('#dashboard-tile-pending'),
pendingList: document.querySelector('#dashboard-pending-list'),
pendingEmpty: document.querySelector('#dashboard-pending-empty'),
coverageCount: document.querySelector('#dashboard-coverage-count'),
coverageDetail: document.querySelector('#dashboard-coverage-detail'),
coverageTile: document.querySelector('#dashboard-tile-coverage'),
coverageList: document.querySelector('#dashboard-coverage-list'),
coverageEmpty: document.querySelector('#dashboard-coverage-empty'),
coverageLink: document.querySelector('#dashboard-coverage-link'),
coverageUnconfigured: document.querySelector('#dashboard-coverage-unconfigured'),
quotaCount: document.querySelector('#dashboard-quota-count'),
quotaDetail: document.querySelector('#dashboard-quota-detail'),
quotaTile: document.querySelector('#dashboard-tile-quota'),
quotaSubtitle: document.querySelector('#dashboard-quota-subtitle'),
quotaAgents: document.querySelector('#dashboard-quota-agents'),
quotaEmptyAgents: document.querySelector('#dashboard-quota-empty'),
quotaBody: document.querySelector('#dashboard-quota-body'),
quotaEmptyState: document.querySelector('#dashboard-quota-empty-state'),
setupCount: document.querySelector('#dashboard-setup-count'),
setupDetail: document.querySelector('#dashboard-setup-detail'),
setupTile: document.querySelector('#dashboard-tile-setup'),
navBadge: document.querySelector('#pending-validation-tab-badge'),
};
let pendingItems = [];
let loading = false;
function setMessage(text = '', type = 'info') {
if (!elements.message) return;
elements.message.className = text ? `message message-${type}` : 'message';
elements.message.textContent = text;
}
/**
* Une tuile est neutre quand il n'y a rien à faire, alertante dès qu'un
* compteur remonte. La couleur ne dit rien de plus que le chiffre : elle
* évite juste d'avoir à lire les quatre tuiles pour savoir où agir.
*/
function setTileTone(tile, count) {
if (!tile) return;
tile.classList.toggle('is-alert', Number(count) > 0);
tile.classList.toggle('is-clear', Number(count) === 0);
}
function plural(count, singular, pluralForm) {
return Number(count) > 1 ? pluralForm : singular;
}
function weekParam() {
return elements.week?.value || '';
}
function weekValueOf(item) {
return `${item.annee}-W${String(item.numero_semaine).padStart(2, '0')}`;
}
function setNavBadge(count) {
if (!elements.navBadge) return;
elements.navBadge.textContent = String(count);
elements.navBadge.hidden = Number(count) === 0;
elements.navBadge.setAttribute(
'aria-label',
`${count} planning${Number(count) > 1 ? 's' : ''} en attente de validation`
);
}
function renderWeek(week) {
if (!week) return;
const contexteLabel = {
PERISCOLAIRE: 'Semaine scolaire — contexte périscolaire',
EXTRASCOLAIRE: 'Semaine de vacances — contexte extrascolaire',
MIXTE: 'Semaine à cheval entre période scolaire et vacances',
}[week.contexte] || '';
if (elements.weekLabel) {
elements.weekLabel.textContent =
`Semaine ${week.numero} · du ${formatDate(week.date_debut)} au ${formatDate(week.date_fin)}`;
}
if (elements.weekContext) {
const vacances = Array.isArray(week.vacances_libelles) ? week.vacances_libelles : [];
const suffix = vacances.length ? ` (${vacances.join(', ')})` : '';
elements.weekContext.textContent = contexteLabel ? `${contexteLabel}${suffix}` : '';
elements.weekContext.hidden = !contexteLabel;
}
if (elements.coverageLink) {
elements.coverageLink.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
}
if (elements.coverageTile) {
elements.coverageTile.href = `coverage.php?week=${encodeURIComponent(week.valeur)}`;
}
}
function renderPending(validation) {
const count = Number(validation.count || 0);
pendingItems = Array.isArray(validation.apercu) ? validation.apercu : [];
setNavBadge(count);
setTileTone(elements.pendingTile, count);
if (elements.pendingCount) elements.pendingCount.textContent = String(count);
if (elements.pendingDetail) {
elements.pendingDetail.textContent = count === 0
? 'Aucun brouillon en attente'
: `${formatDuration(Number(validation.minutes_decomptees || 0))} décomptées · `
+ `${validation.agent_count} ${plural(validation.agent_count, 'agent', 'agents')} · `
+ `${validation.structure_count} ${plural(validation.structure_count, 'lieu', 'lieux')}`;
}
if (elements.pendingEmpty) elements.pendingEmpty.hidden = count !== 0;
if (!elements.pendingList) return;
elements.pendingList.innerHTML = pendingItems.map((item) => `
<li class="dashboard-list-item">
<div class="dashboard-list-main">
<strong>${escapeHtml(item.agent_prenom)} ${escapeHtml(item.agent_nom)}</strong>
<small>${escapeHtml(item.structure_nom)} · semaine ${Number(item.numero_semaine)} du ${formatDate(item.date_debut)}</small>
</div>
<div class="dashboard-list-meta">
<span>${Number(item.nombre_creneaux)} ${plural(item.nombre_creneaux, 'créneau', 'créneaux')}</span>
<strong>${formatDuration(Number(item.total_minutes || 0))}</strong>
</div>
<button class="button button-success dashboard-list-action" type="button" data-planning-id="${Number(item.id_planning)}">
Contrôler
</button>
</li>
`).join('');
}
function renderCoverage(coverage, week, perimetre) {
const gapCount = Number(coverage.gap_count || 0);
const unconfigured = Number(coverage.unconfigured_count || 0);
// Sans aucun lieu enregistré, « tous les minimums sont atteints » serait
// vrai mais trompeur : il n'y a simplement rien à contrôler.
const sansLieu = Number(perimetre?.structures || 0) === 0;
setTileTone(elements.coverageTile, gapCount);
setTileTone(elements.setupTile, unconfigured);
if (elements.coverageCount) elements.coverageCount.textContent = String(gapCount);
if (elements.coverageDetail) {
elements.coverageDetail.textContent = gapCount === 0
? (sansLieu ? 'Aucun lieu d’affectation enregistré' : 'Tous les minimums sont atteints')
: `${formatDuration(Number(coverage.gap_minutes || 0))} non couvertes · `
+ `${coverage.structure_count} ${plural(coverage.structure_count, 'lieu concerné', 'lieux concernés')}`;
}
if (elements.setupCount) elements.setupCount.textContent = String(unconfigured);
if (elements.setupDetail) {
elements.setupDetail.textContent = unconfigured === 0
? (sansLieu ? 'Aucun lieu à paramétrer pour l’instant' : 'Tous les lieux ont des horaires attendus')
: 'Aucun minimum d’agents n’y est contrôlé';
}
const items = Array.isArray(coverage.apercu) ? coverage.apercu : [];
if (elements.coverageEmpty) elements.coverageEmpty.hidden = items.length !== 0;
if (elements.coverageList) {
const weekValue = week?.valeur || '';
elements.coverageList.innerHTML = items.map((gap) => {
const href = `coverage.php?week=${encodeURIComponent(weekValue)}&structure_id=${Number(gap.id_structure)}`;
return `
<li class="dashboard-list-item">
<div class="dashboard-list-main">
<strong>${escapeHtml(gap.structure_nom)}</strong>
<small>${escapeHtml(gap.jour_libelle)} ${formatDate(gap.date)} · ${escapeHtml(gap.heure_debut)} → ${escapeHtml(gap.heure_fin)}</small>
</div>
<div class="dashboard-list-meta">
<span>${Number(gap.agents_planifies)} / ${Number(gap.minimum_agents)} agents</span>
<strong class="dashboard-gap-count">−${Number(gap.agents_manquants)}</strong>
</div>
<a class="button button-secondary dashboard-list-action" href="${href}">Analyser</a>
</li>`;
}).join('');
}
if (elements.coverageUnconfigured) {
const list = Array.isArray(coverage.unconfigured) ? coverage.unconfigured : [];
if (list.length === 0) {
elements.coverageUnconfigured.hidden = true;
elements.coverageUnconfigured.textContent = '';
} else {
const names = list.map((structure) => escapeHtml(structure.nom)).join(', ');
const reste = unconfigured - list.length;
elements.coverageUnconfigured.innerHTML =
`<strong>Non contrôlés :</strong> ${names}${reste > 0 ? ` et ${reste} autre${reste > 1 ? 's' : ''}` : ''}. `
+ 'Définissez leurs horaires de couverture dans l’administration.';
elements.coverageUnconfigured.hidden = false;
}
}
}
function renderQuota(quota) {
const overCount = Number(quota.depassement_count || 0);
setTileTone(elements.quotaTile, overCount);
if (elements.quotaCount) elements.quotaCount.textContent = String(overCount);
if (elements.quotaDetail) {
elements.quotaDetail.textContent = overCount === 0
? (Number(quota.agent_count || 0) === 0
? 'Aucun agent actif à suivre'
: `Aucun dépassement sur ${quota.annee}`)
: `Sur ${quota.agent_count} ${plural(quota.agent_count, 'agent suivi', 'agents suivis')} en ${quota.annee}`;
}
if (elements.quotaTile) {
elements.quotaTile.href = `annual.php?year=${Number(quota.annee)}`;
}
if (elements.quotaSubtitle) {
elements.quotaSubtitle.textContent =
`Année civile ${quota.annee} · heures validées et brouillon comprises, motifs décomptés uniquement.`;
}
if (elements.quotaAgents) elements.quotaAgents.textContent = String(quota.agent_count || 0);
if (elements.quotaEmptyAgents) elements.quotaEmptyAgents.textContent = String(quota.sans_heure_count || 0);
const items = Array.isArray(quota.apercu) ? quota.apercu : [];
if (elements.quotaEmptyState) elements.quotaEmptyState.hidden = items.length !== 0;
if (!elements.quotaBody) return;
elements.quotaBody.innerHTML = items.map((agent) => {
const over = Number(agent.minutes_restantes) < 0;
const rate = Number(agent.taux_consommation || 0);
const href = `annual.php?agent_id=${Number(agent.id_agent)}&year=${Number(quota.annee)}`;
return `
<tr class="${over ? 'is-over-quota' : ''}">
<td>
<strong>${escapeHtml(agent.prenom)} ${escapeHtml(agent.nom)}</strong>
<small>${escapeHtml(agent.poste_libelle || 'Poste non renseigné')}</small>
</td>
<td>${escapeHtml(agent.structure_nom || 'Non rattaché')}</td>
<td>
<strong>${formatDuration(Number(agent.quota_cible_minutes || 0))}</strong>
<small>quotité ${Number(agent.quotite_travail)} %</small>
</td>
<td>
<strong>${formatDuration(Number(agent.minutes_affectees || 0))}</strong>
<small>${formatDuration(Number(agent.minutes_brouillon || 0))} en brouillon</small>
</td>
<td class="dashboard-quota-gauge-cell">
<progress class="dashboard-quota-gauge${over ? ' is-over' : ''}" max="100" value="${Math.min(100, Math.max(0, rate))}" aria-hidden="true"></progress>
<span>${rate} %</span>
</td>
<td class="dashboard-quota-action-cell">
<a class="button button-secondary" href="${href}">Ouvrir</a>
</td>
</tr>`;
}).join('');
}
function openPendingPlanning(item) {
if (!item) return;
const params = new URLSearchParams({
structure_id: String(item.id_structure),
agent_id: String(item.id_agent),
week: weekValueOf(item),
focus: 'validation',
notice: `Planning de ${item.agent_prenom} ${item.agent_nom} ouvert depuis le tableau de bord.`,
});
window.location.href = `planning.php?${params.toString()}`;
}
async function load(options = {}) {
if (loading) return;
loading = true;
if (!options.silent) setMessage('');
if (elements.loading) elements.loading.hidden = false;
if (elements.refresh) elements.refresh.disabled = true;
try {
const query = new URLSearchParams();
const week = weekParam();
if (week) query.set('week', week);
const response = await fetch(`api/dashboard_summary.php?${query.toString()}`, {
headers: { Accept: 'application/json' },
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger le tableau de bord.'));
renderWeek(data.semaine);
renderPending(data.validation || {});
renderCoverage(data.couverture || {}, data.semaine, data.perimetre);
renderQuota(data.quota || {});
} catch (error) {
setMessage(error.message, 'error');
} finally {
loading = false;
if (elements.loading) elements.loading.hidden = true;
if (elements.refresh) elements.refresh.disabled = false;
}
}
function init() {
elements.refresh?.addEventListener('click', () => load());
elements.week?.addEventListener('change', () => load());
elements.pendingList?.addEventListener('click', (event) => {
const trigger = event.target.closest('[data-planning-id]');
if (!trigger) return;
const id = Number(trigger.dataset.planningId);
openPendingPlanning(pendingItems.find((item) => Number(item.id_planning) === id));
});
load({ silent: true });
}
PTA.modules.dashboard = { init, load, refresh: load };
})();

View File

@@ -15,6 +15,14 @@
if (!planning) return; if (!planning) return;
const { structureSelect, agentSelect, weekInput, validateButton } = PTA.elements; const { structureSelect, agentSelect, weekInput, validateButton } = PTA.elements;
// Un lien profond qui désigne une semaine précise (file de validation,
// tableau de bord, vue annuelle) doit ouvrir la saisie semaine par
// semaine, et non l'éditeur de semaine type proposé par défaut.
if (params.week || params.focus) {
PTA.modules.weekTemplateEditor?.setMode?.('week');
}
if (params.structureId && structureSelect) { if (params.structureId && structureSelect) {
structureSelect.value = String(params.structureId); structureSelect.value = String(params.structureId);
await planning.loadAgents(); await planning.loadAgents();
@@ -73,8 +81,40 @@
} }
} }
/**
* Pastille « À valider » de la barre de navigation.
*
* Elle n'était alimentée que sur la page « À valider » elle-même, donc
* invisible là où elle sert : depuis n'importe quel écran, le service voit
* maintenant si des brouillons attendent. La requête est volontairement
* réduite à un compteur, et son échec est silencieux — une commodité ne
* doit jamais empêcher une page de fonctionner.
*/
async function refreshNavigationCounters() {
const badge = document.querySelector('#pending-validation-tab-badge');
if (!badge) return;
// Les écrans qui chargent déjà ce compteur s'en chargent eux-mêmes.
if (PTA.modules.pendingValidation || PTA.modules.dashboard) return;
if (!PTA.config?.permissions?.can_view_pending) return;
try {
const response = await fetch('api/dashboard_summary.php?scope=badges', {
headers: { Accept: 'application/json' },
});
if (!response.ok) return;
const data = await PTA.utils.readJsonResponse(response);
const count = Number(data?.validation?.count || 0);
badge.textContent = String(count);
badge.hidden = count === 0;
badge.setAttribute('aria-label', `${count} planning${count > 1 ? 's' : ''} en attente de validation`);
} catch (error) {
badge.hidden = true;
}
}
initialisePlanning(); initialisePlanning();
initialiseStructureOverview(); initialiseStructureOverview();
initialiseAgentOverview(); initialiseAgentOverview();
initialiseCoverage(); initialiseCoverage();
refreshNavigationCounters();
})(); })();

View File

@@ -110,8 +110,37 @@
updateAgentPdfButton(); updateAgentPdfButton();
} }
/**
* Motif proposé par défaut sur une nouvelle plage : le temps de travail, très
* majoritaire à la saisie. On se rabat sur le premier motif décomptant le quota
* si le référentiel ne contient pas le code TRAVAIL.
*/
function defaultMotifId() {
const motifs = window.PTA_CONFIG?.motifs || [];
const motif = motifs.find((item) => String(item.code).toUpperCase() === 'TRAVAIL')
|| motifs.find((item) => item.quota)
|| motifs[0];
return motif ? Number(motif.id) : null;
}
/**
* Un agent sans droit à l'enveloppe de formation ne peut pas recevoir le motif
* « Formation ». Le droit vaut tant que l'agent n'est pas chargé : on ne retire
* jamais une possibilité sur une donnée absente.
*/
function agentHasTrainingRight() {
return Number(state.currentAgent?.droit_enveloppe_formation ?? 1) !== 0;
}
function isMotifAllowedForAgent(motif) {
return agentHasTrainingRight() || String(motif.code).toUpperCase() !== 'FORMATION';
}
function motifOptionsHtml(selectedMotifId = null) { function motifOptionsHtml(selectedMotifId = null) {
return (window.PTA_CONFIG?.motifs || []) return (window.PTA_CONFIG?.motifs || [])
// Un motif interdit reste proposé s'il est déjà celui de la plage :
// le retirer changerait silencieusement le motif d'une saisie existante.
.filter((motif) => isMotifAllowedForAgent(motif) || Number(motif.id) === Number(selectedMotifId))
.map((motif) => { .map((motif) => {
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : ''; const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota'; const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
@@ -122,13 +151,16 @@
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) { function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
const suffix = `${dayLabel} plage ${index + 1}`; const suffix = `${dayLabel} plage ${index + 1}`;
const fallbackMotifId = defaultMotifId();
const motifId = selectedMotifId ?? fallbackMotifId;
const placeholder = fallbackMotifId === null ? '<option value="">Choisir un motif</option>' : '';
return ` return `
<div class="weekly-time-range"> <div class="weekly-time-range">
<label class="weekly-range-motif-field"> <label class="weekly-range-motif-field">
<span>Motif</span> <span>Motif</span>
<select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}"> <select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}">
<option value="">Choisir un motif</option> ${placeholder}
${motifOptionsHtml(selectedMotifId)} ${motifOptionsHtml(motifId)}
</select> </select>
</label> </label>
<label> <label>
@@ -649,6 +681,7 @@
state.entries = []; state.entries = [];
state.otherStructureEntries = []; state.otherStructureEntries = [];
state.currentStructure = null; state.currentStructure = null;
state.currentAgent = null;
state.planningId = null; state.planningId = null;
state.quota = null; state.quota = null;
state.savedCurrentCountedMinutes = 0; state.savedCurrentCountedMinutes = 0;
@@ -710,6 +743,7 @@
state.entries = []; state.entries = [];
state.otherStructureEntries = []; state.otherStructureEntries = [];
state.currentStructure = null; state.currentStructure = null;
state.currentAgent = null;
state.planningId = null; state.planningId = null;
state.savedCurrentCountedMinutes = 0; state.savedCurrentCountedMinutes = 0;
setStatus(null); setStatus(null);
@@ -728,6 +762,7 @@
state.quota = data.quota || null; state.quota = data.quota || null;
state.currentStructure = data.current_structure || null; state.currentStructure = data.current_structure || null;
state.currentAgent = data.agent || null;
const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day])); const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day]));
state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) })); state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) }));
state.otherStructureEntries = (data.other_entries || []).map((entry) => ({ state.otherStructureEntries = (data.other_entries || []).map((entry) => ({
@@ -819,6 +854,11 @@
return; return;
} }
if (!isMotifAllowedForAgent(motif)) {
errors.push(`${day?.label || row.dataset.date} ${label} : le motif « ${motif.label} » n’est pas ouvert à cet agent, qui n’a pas droit à l’enveloppe de formation.`);
return;
}
if (!start || !end) { if (!start || !end) {
errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`); errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`);
return; return;

View File

@@ -20,6 +20,17 @@
return `<article class="summary-card"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ''}</article>`; return `<article class="summary-card"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong>${detail ? `<small>${escapeHtml(detail)}</small>` : ''}</article>`;
} }
/**
* QuotaModel::enrich() renvoie les durées sous forme d'objet
* { minutes, heures_decimales, libelle } et non de chaîne. Passer l'objet
* tel quel à une interpolation affiche « [object Object] » — et un objet
* étant toujours truthy, un `||` ne rattrape jamais le coup. On lit donc
* explicitement le libellé, en gardant le nombre de minutes comme repli.
*/
function quotaLabel(enriched, fallbackMinutes) {
return enriched?.libelle || formatDuration(Number(fallbackMinutes || 0));
}
function renderSummary(data) { function renderSummary(data) {
current = data; current = data;
const agent = data.agent || {}; const agent = data.agent || {};
@@ -37,9 +48,9 @@
el('pta-summary-cards').innerHTML = [ el('pta-summary-cards').innerHTML = [
card('Année civile PTA', `${formatDate(quota.date_debut_periode)} → ${formatDate(quota.date_fin_periode)}`), card('Année civile PTA', `${formatDate(quota.date_debut_periode)} → ${formatDate(quota.date_fin_periode)}`),
card('Quotité', `${Number(quota.quotite_travail || 0).toLocaleString('fr-FR')} %`, `Référence : ${formatDuration(Number(quota.quota_reference_minutes || 96420))}`), card('Quotité', `${Number(quota.quotite_travail || 0).toLocaleString('fr-FR')} %`, `Référence : ${formatDuration(Number(quota.quota_reference_minutes || 96420))}`),
card('Quota à réaliser', quota.quota_cible || formatDuration(Number(quota.quota_cible_minutes || 0))), card('Quota à réaliser', quotaLabel(quota.quota_cible, quota.quota_cible_minutes)),
card('Déjà affecté', quota.affectees || formatDuration(Number(quota.minutes_affectees || 0))), card('Déjà affecté', quotaLabel(quota.affectees, quota.minutes_affectees)),
card('Reste à affecter', quota.restantes || formatDuration(Number(quota.minutes_restantes || 0))), card('Reste à affecter', quotaLabel(quota.restantes, quota.minutes_restantes)),
card('Formation', `${counters.formation_duree || '0 h 00'} / ${counters.formation_cible_duree || '0 h 00'}`), card('Formation', `${counters.formation_duree || '0 h 00'} / ${counters.formation_cible_duree || '0 h 00'}`),
card('Congés annuels', `${Number(counters.conges_annuels_jours || 0)} / ${Number(counters.conges_annuels_cible || 25)} jours`), card('Congés annuels', `${Number(counters.conges_annuels_jours || 0)} / ${Number(counters.conges_annuels_cible || 25)} jours`),
card('Fractionnement', `${Number(counters.jours_fractionnement || 0)} / ${Number(counters.jours_fractionnement_cible || 2)} jours`), card('Fractionnement', `${Number(counters.jours_fractionnement || 0)} / ${Number(counters.jours_fractionnement_cible || 2)} jours`),

View File

@@ -0,0 +1,579 @@
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant week-template-editor.js');
const { state } = PTA;
const { structureSelect, agentSelect, weekInput } = PTA.elements;
const { escapeHtml, readJsonResponse, apiErrorMessage, formatDuration, timeToMinutes, motifInfo } = PTA.utils;
const panel = document.querySelector('#template-mode-panel');
const weekPanel = document.querySelector('#week-mode-panel');
const modeTemplateButton = document.querySelector('#editor-mode-template');
const modeWeekButton = document.querySelector('#editor-mode-week');
const templateSelect = document.querySelector('#template-editor-select');
const nameInput = document.querySelector('#template-editor-name');
const gridElement = document.querySelector('#template-editor-grid');
const emptyElement = document.querySelector('#template-editor-empty');
const messageElement = document.querySelector('#template-editor-message');
const totalElement = document.querySelector('#template-editor-total');
const totalDetailElement = document.querySelector('#template-editor-total-detail');
const saveButton = document.querySelector('#template-editor-save');
const deleteButton = document.querySelector('#template-editor-delete');
const applyPeriodButton = document.querySelector('#template-editor-apply-period');
const applyWeekButton = document.querySelector('#template-editor-apply-week');
const DAYS = [
{ weekday: 1, label: 'Lundi' },
{ weekday: 2, label: 'Mardi' },
{ weekday: 3, label: 'Mercredi' },
{ weekday: 4, label: 'Jeudi' },
{ weekday: 5, label: 'Vendredi' },
];
/** Plages de la semaine type en cours d'édition, indexées par jour. */
let draft = new Map();
let templates = [];
let currentTemplateId = null;
let loading = false;
function setMessage(text = '', type = 'info') {
if (!messageElement) return;
messageElement.className = text ? `message message-${type}` : 'message';
messageElement.textContent = text;
}
function agentId() {
return Number(agentSelect?.value || 0) || null;
}
function defaultStructureId() {
const selected = Number(structureSelect?.value || 0);
if (selected) return selected;
const first = structureSelect?.querySelector('option[value]:not([value=""])');
return first ? Number(first.value) : null;
}
/**
* Un agent sans droit à l'enveloppe de formation ne peut pas recevoir le
* motif « Formation ». Même règle qu'à la saisie semaine par semaine : le
* droit vaut tant que l'agent n'est pas chargé, on ne retire jamais une
* possibilité sur une donnée absente.
*/
function agentHasTrainingRight() {
return Number(state.currentAgent?.droit_enveloppe_formation ?? 1) !== 0;
}
function motifs() {
return window.PTA_CONFIG?.motifs || [];
}
function defaultMotifId() {
const list = motifs();
const motif = list.find((item) => String(item.code).toUpperCase() === 'TRAVAIL')
|| list.find((item) => item.quota)
|| list[0];
return motif ? Number(motif.id) : null;
}
function motifOptionsHtml(selectedMotifId = null) {
return motifs()
// Un motif interdit reste proposé s'il est déjà celui de la plage :
// le retirer changerait silencieusement une saisie existante.
.filter((motif) => agentHasTrainingRight()
|| String(motif.code).toUpperCase() !== 'FORMATION'
|| Number(motif.id) === Number(selectedMotifId))
.map((motif) => {
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
return `<option value="${Number(motif.id)}"${selected}>${escapeHtml(motif.label)}${quotaLabel}</option>`;
})
.join('');
}
function structureOptionsHtml(selectedStructureId = null) {
return [...(structureSelect?.querySelectorAll('option[value]:not([value=""])') || [])]
.map((option) => {
const selected = Number(option.value) === Number(selectedStructureId) ? ' selected' : '';
return `<option value="${Number(option.value)}"${selected}>${escapeHtml(option.textContent.trim())}</option>`;
})
.join('');
}
function copyTargetOptionsHtml(weekday) {
const others = DAYS.filter((day) => day.weekday !== weekday);
return [
'<option value="">Copier vers…</option>',
...others.map((day) => `<option value="${day.weekday}">${escapeHtml(day.label)}</option>`),
'<option value="all">Tous les autres jours</option>',
].join('');
}
function rangesFor(weekday) {
return draft.get(weekday) || [];
}
function emptyDraft() {
const next = new Map();
DAYS.forEach((day) => next.set(day.weekday, []));
return next;
}
function newRange() {
return {
heure_debut: '',
heure_fin: '',
id_motif: defaultMotifId(),
id_structure: defaultStructureId(),
};
}
function rangeMinutes(range) {
if (!range.heure_debut || !range.heure_fin) return 0;
return Math.max(0, timeToMinutes(range.heure_fin) - timeToMinutes(range.heure_debut));
}
function dayMinutes(weekday) {
return rangesFor(weekday).reduce((total, range) => total + rangeMinutes(range), 0);
}
function totals() {
let all = 0;
let counted = 0;
DAYS.forEach((day) => {
rangesFor(day.weekday).forEach((range) => {
const minutes = rangeMinutes(range);
all += minutes;
if (motifInfo(range.id_motif)?.quota) counted += minutes;
});
});
return { all, counted };
}
function rangeHtml(weekday, index, range) {
const label = `${DAYS.find((day) => day.weekday === weekday)?.label || 'Jour'} plage ${index + 1}`;
return `
<div class="template-range" data-weekday="${weekday}" data-index="${index}">
<label class="template-range-field template-range-field-motif">
<span>Motif</span>
<select class="template-range-motif" aria-label="Motif ${escapeHtml(label)}">
${motifOptionsHtml(range.id_motif)}
</select>
</label>
<label class="template-range-field">
<span>Début</span>
<input class="template-range-start" type="time" step="900" value="${escapeHtml(range.heure_debut)}" aria-label="Début ${escapeHtml(label)}">
</label>
<span class="template-range-separator" aria-hidden="true">→</span>
<label class="template-range-field">
<span>Fin</span>
<input class="template-range-end" type="time" step="900" value="${escapeHtml(range.heure_fin)}" aria-label="Fin ${escapeHtml(label)}">
</label>
<label class="template-range-field template-range-field-structure">
<span>Lieu</span>
<select class="template-range-structure" aria-label="Lieu ${escapeHtml(label)}">
${structureOptionsHtml(range.id_structure)}
</select>
</label>
<button type="button" class="button button-ghost template-range-remove" aria-label="Supprimer ${escapeHtml(label)}" title="Supprimer cette plage">×</button>
</div>`;
}
function render() {
if (!gridElement) return;
const ready = Boolean(agentId());
if (emptyElement) emptyElement.hidden = ready;
gridElement.hidden = !ready;
if (ready) {
gridElement.innerHTML = DAYS.map((day) => {
const ranges = rangesFor(day.weekday);
const minutes = dayMinutes(day.weekday);
return `
<article class="template-day${ranges.length === 0 ? ' is-empty' : ''}" data-weekday="${day.weekday}">
<header class="template-day-header">
<strong>${escapeHtml(day.label)}</strong>
<span class="template-day-total">${minutes > 0 ? formatDuration(minutes) : '—'}</span>
</header>
<div class="template-day-ranges">
${ranges.length === 0
? '<p class="template-day-empty">Aucune plage — jour non travaillé.</p>'
: ranges.map((range, index) => rangeHtml(day.weekday, index, range)).join('')}
</div>
<footer class="template-day-actions">
<button type="button" class="button button-secondary template-day-add" data-weekday="${day.weekday}">+ Ajouter une plage</button>
<label class="template-day-copy-field">
<span class="visually-hidden">Copier ${escapeHtml(day.label)} vers un autre jour</span>
<select class="template-day-copy" data-weekday="${day.weekday}" aria-label="Copier ${escapeHtml(day.label)} vers un autre jour"${ranges.length === 0 ? ' disabled' : ''}>
${copyTargetOptionsHtml(day.weekday)}
</select>
</label>
</footer>
</article>`;
}).join('');
}
const { all, counted } = totals();
if (totalElement) totalElement.textContent = formatDuration(all);
if (totalDetailElement) totalDetailElement.textContent = `dont ${formatDuration(counted)} décomptées`;
refreshButtons();
}
function hasAnyRange() {
return DAYS.some((day) => rangesFor(day.weekday).length > 0);
}
function refreshButtons() {
const ready = Boolean(agentId());
const named = Boolean(nameInput?.value.trim());
if (templateSelect) templateSelect.disabled = !ready;
if (nameInput) nameInput.disabled = !ready;
if (saveButton) saveButton.disabled = !ready || !named || !hasAnyRange() || loading;
if (deleteButton) deleteButton.disabled = !ready || currentTemplateId === null || loading;
// Dupliquer suppose un modèle déjà enregistré : on ne duplique jamais un
// brouillon d'écran, seulement ce qui est réellement en base.
if (applyPeriodButton) applyPeriodButton.disabled = !ready || currentTemplateId === null || loading;
if (applyWeekButton) applyWeekButton.disabled = !ready || currentTemplateId === null || loading;
}
function renderTemplateSelect() {
if (!templateSelect) return;
const options = ['<option value="">➕ Nouvelle semaine type</option>'];
templates.forEach((template) => {
const selected = Number(template.id) === Number(currentTemplateId) ? ' selected' : '';
const detail = `${template.entry_count} plage${template.entry_count > 1 ? 's' : ''}`;
options.push(`<option value="${Number(template.id)}"${selected}>${escapeHtml(template.name)} — ${detail}</option>`);
});
templateSelect.innerHTML = options.join('');
}
async function loadTemplates() {
const agent = agentId();
if (!agent) {
templates = [];
renderTemplateSelect();
return;
}
try {
const response = await fetch(`api/week_templates.php?agent_id=${agent}`, {
headers: { Accept: 'application/json' },
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de charger les semaines types.'));
templates = Array.isArray(data.templates) ? data.templates : [];
renderTemplateSelect();
} catch (error) {
setMessage(error.message, 'error');
}
}
async function openTemplate(templateId) {
const agent = agentId();
if (!agent || !templateId) {
currentTemplateId = null;
draft = emptyDraft();
if (nameInput) nameInput.value = '';
render();
return;
}
loading = true;
refreshButtons();
try {
const response = await fetch(`api/week_template.php?agent_id=${agent}&template_id=${Number(templateId)}`, {
headers: { Accept: 'application/json' },
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’ouvrir cette semaine type.'));
currentTemplateId = Number(data.template.id);
if (nameInput) nameInput.value = data.template.name;
draft = emptyDraft();
(data.entries || []).forEach((entry) => {
const weekday = Number(entry.jour_semaine);
if (!draft.has(weekday)) return;
draft.get(weekday).push({
heure_debut: entry.heure_debut,
heure_fin: entry.heure_fin,
id_motif: Number(entry.id_motif),
id_structure: Number(entry.id_structure),
});
});
setMessage('');
} catch (error) {
setMessage(error.message, 'error');
} finally {
loading = false;
render();
}
}
/** Lit les valeurs saisies à l'écran dans le brouillon en mémoire. */
function syncDraftFromDom() {
if (!gridElement || gridElement.hidden) return;
const next = emptyDraft();
gridElement.querySelectorAll('.template-range').forEach((element) => {
const weekday = Number(element.dataset.weekday);
if (!next.has(weekday)) return;
next.get(weekday).push({
heure_debut: element.querySelector('.template-range-start')?.value || '',
heure_fin: element.querySelector('.template-range-end')?.value || '',
id_motif: Number(element.querySelector('.template-range-motif')?.value || 0) || null,
id_structure: Number(element.querySelector('.template-range-structure')?.value || 0) || null,
});
});
draft = next;
}
/**
* Contrôles faits à l'écran pour éviter un aller-retour serveur inutile.
* Le serveur revalide tout : ceci n'est qu'une commodité.
*/
function localValidationError() {
for (const day of DAYS) {
const ranges = rangesFor(day.weekday);
for (const range of ranges) {
if (!range.heure_debut || !range.heure_fin) {
return `${day.label} : une plage n’a pas d’horaire de début ou de fin.`;
}
if (timeToMinutes(range.heure_fin) <= timeToMinutes(range.heure_debut)) {
return `${day.label} : la fin doit être postérieure au début (${range.heure_debut} → ${range.heure_fin}).`;
}
if (!range.id_motif || !range.id_structure) {
return `${day.label} : chaque plage doit avoir un motif et un lieu d’affectation.`;
}
}
const sorted = [...ranges].sort((a, b) => a.heure_debut.localeCompare(b.heure_debut));
for (let index = 1; index < sorted.length; index++) {
if (sorted[index].heure_debut < sorted[index - 1].heure_fin) {
return `${day.label} : deux plages se chevauchent (${sorted[index - 1].heure_debut}–${sorted[index - 1].heure_fin} et ${sorted[index].heure_debut}–${sorted[index].heure_fin}).`;
}
}
}
return null;
}
function flatEntries() {
const entries = [];
DAYS.forEach((day) => {
rangesFor(day.weekday).forEach((range) => {
entries.push({
jour_semaine: day.weekday,
heure_debut: range.heure_debut,
heure_fin: range.heure_fin,
id_motif: range.id_motif,
id_structure: range.id_structure,
});
});
});
return entries;
}
async function save(replace = false) {
const agent = agentId();
const name = nameInput?.value.trim() || '';
if (!agent || !name) return;
syncDraftFromDom();
const error = localValidationError();
if (error) {
setMessage(error, 'error');
return;
}
loading = true;
refreshButtons();
try {
const response = await fetch('api/save_week_template_entries.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
agent_id: agent,
name,
replace,
entries: flatEntries(),
}),
});
const data = await readJsonResponse(response);
if (response.status === 409 && data.code === 'TEMPLATE_EXISTS' && !replace) {
loading = false;
if (window.confirm(`Une semaine type « ${name} » existe déjà pour cet agent. La remplacer ?`)) {
await save(true);
return;
}
setMessage('Enregistrement annulé : le nom est déjà utilisé.', 'warning');
refreshButtons();
return;
}
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible d’enregistrer la semaine type.'));
currentTemplateId = Number(data.template.id);
setMessage(`${data.message} Elle peut maintenant être dupliquée sur une période.`, 'success');
await loadTemplates();
renderTemplateSelect();
} catch (err) {
setMessage(err.message, 'error');
} finally {
loading = false;
render();
}
}
async function removeTemplate() {
const agent = agentId();
if (!agent || currentTemplateId === null) return;
const template = templates.find((item) => Number(item.id) === Number(currentTemplateId));
if (!window.confirm(`Supprimer définitivement la semaine type « ${template?.name || ''} » ? Les plannings déjà créés ne sont pas affectés.`)) {
return;
}
loading = true;
refreshButtons();
try {
const response = await fetch('api/delete_week_template.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
csrf_token: window.PTA_CONFIG.csrfToken,
agent_id: agent,
template_id: currentTemplateId,
}),
});
const data = await readJsonResponse(response);
if (!response.ok) throw new Error(apiErrorMessage(data, 'Impossible de supprimer la semaine type.'));
currentTemplateId = null;
draft = emptyDraft();
if (nameInput) nameInput.value = '';
setMessage(data.message || 'Semaine type supprimée.', 'success');
await loadTemplates();
} catch (error) {
setMessage(error.message, 'error');
} finally {
loading = false;
render();
}
}
function copyDay(sourceWeekday, target) {
syncDraftFromDom();
const source = rangesFor(sourceWeekday);
if (source.length === 0) return;
const targets = target === 'all'
? DAYS.filter((day) => day.weekday !== sourceWeekday).map((day) => day.weekday)
: [Number(target)];
targets.forEach((weekday) => {
draft.set(weekday, source.map((range) => ({ ...range })));
});
const sourceLabel = DAYS.find((day) => day.weekday === sourceWeekday)?.label || 'Ce jour';
const targetLabel = target === 'all'
? 'tous les autres jours'
: (DAYS.find((day) => day.weekday === Number(target))?.label || 'le jour choisi').toLowerCase();
setMessage(`${sourceLabel} copié sur ${targetLabel}. Pensez à enregistrer la semaine type.`, 'info');
render();
}
function setMode(mode) {
const isTemplate = mode === 'template';
if (panel) panel.hidden = !isTemplate;
if (weekPanel) weekPanel.hidden = isTemplate;
modeTemplateButton?.classList.toggle('is-active', isTemplate);
modeWeekButton?.classList.toggle('is-active', !isTemplate);
modeTemplateButton?.setAttribute('aria-selected', String(isTemplate));
modeWeekButton?.setAttribute('aria-selected', String(!isTemplate));
}
function onAgentChanged() {
currentTemplateId = null;
draft = emptyDraft();
if (nameInput) nameInput.value = '';
setMessage('');
loadTemplates().then(render);
}
function init() {
draft = emptyDraft();
modeTemplateButton?.addEventListener('click', () => setMode('template'));
modeWeekButton?.addEventListener('click', () => setMode('week'));
agentSelect?.addEventListener('change', onAgentChanged);
structureSelect?.addEventListener('change', () => {
// Le lieu par défaut des nouvelles plages suit la sélection.
loadTemplates();
});
templateSelect?.addEventListener('change', (event) => openTemplate(event.target.value));
nameInput?.addEventListener('input', refreshButtons);
gridElement?.addEventListener('click', (event) => {
const addButton = event.target.closest('.template-day-add');
if (addButton) {
syncDraftFromDom();
const weekday = Number(addButton.dataset.weekday);
draft.get(weekday)?.push(newRange());
render();
return;
}
const removeButton = event.target.closest('.template-range-remove');
if (removeButton) {
const range = removeButton.closest('.template-range');
syncDraftFromDom();
const weekday = Number(range.dataset.weekday);
const index = Number(range.dataset.index);
draft.get(weekday)?.splice(index, 1);
render();
}
});
gridElement?.addEventListener('change', (event) => {
const copySelect = event.target.closest('.template-day-copy');
if (copySelect) {
const target = copySelect.value;
copySelect.value = '';
if (target) copyDay(Number(copySelect.dataset.weekday), target);
return;
}
// Toute autre modification met simplement les totaux à jour.
syncDraftFromDom();
render();
});
saveButton?.addEventListener('click', () => save(false));
deleteButton?.addEventListener('click', removeTemplate);
applyPeriodButton?.addEventListener('click', () => {
PTA.modules.weekTemplates?.openPeriodModal?.(currentTemplateId);
});
applyWeekButton?.addEventListener('click', () => {
if (!weekInput?.value) {
setMessage('Choisissez d’abord une semaine dans la sélection en haut de page.', 'warning');
return;
}
PTA.modules.weekTemplates?.openApplyModal?.(currentTemplateId);
});
render();
if (agentId()) loadTemplates().then(render);
}
PTA.modules.weekTemplateEditor = {
init,
setMode,
refresh: () => loadTemplates().then(render),
};
})();

View File

@@ -195,7 +195,7 @@
templateSelect.disabled = false; templateSelect.disabled = false;
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => ( templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${template.id}">${escapeHtml(template.name)}</option>` `<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
)).join(''); )).join('');
renderSelectedTemplateDetails(); renderSelectedTemplateDetails();
} }
@@ -219,7 +219,11 @@
`; `;
} }
async function openApplyModal() { /**
* `preselectedId` permet à l'éditeur de semaine type d'ouvrir la modale
* directement sur le modèle en cours d'édition, sans le rechercher.
*/
async function openApplyModal(preselectedId = null) {
if (!contextReady()) { if (!contextReady()) {
showMessage('Sélectionnez un agent et la semaine cible avant d’importer une semaine type.', 'warning'); showMessage('Sélectionnez un agent et la semaine cible avant d’importer une semaine type.', 'warning');
return; return;
@@ -235,6 +239,10 @@
try { try {
await loadTemplates(); await loadTemplates();
renderTemplateSelect(); renderTemplateSelect();
if (preselectedId && templateSelect) {
templateSelect.value = String(Number(preselectedId));
renderSelectedTemplateDetails();
}
setInlineMessage(applyMessage); setInlineMessage(applyMessage);
} catch (error) { } catch (error) {
setInlineMessage(applyMessage, error.message, 'error'); setInlineMessage(applyMessage, error.message, 'error');
@@ -364,7 +372,7 @@
} }
periodTemplateSelect.disabled = false; periodTemplateSelect.disabled = false;
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => ( periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${template.id}">${escapeHtml(template.name)}</option>` `<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
)).join(''); )).join('');
} }
@@ -379,7 +387,7 @@
} }
} }
async function openPeriodModal() { async function openPeriodModal(preselectedId = null) {
if (!contextReady()) { if (!contextReady()) {
showMessage('Sélectionnez un agent et une semaine avant d’appliquer une semaine type sur une période.', 'warning'); showMessage('Sélectionnez un agent et une semaine avant d’appliquer une semaine type sur une période.', 'warning');
return; return;
@@ -397,6 +405,10 @@
try { try {
await loadTemplates(); await loadTemplates();
renderPeriodTemplateSelect(); renderPeriodTemplateSelect();
if (preselectedId && periodTemplateSelect) {
periodTemplateSelect.value = String(Number(preselectedId));
invalidatePeriodPreview();
}
setInlineMessage(periodMessage); setInlineMessage(periodMessage);
} catch (error) { } catch (error) {
setInlineMessage(periodMessage, error.message, 'error'); setInlineMessage(periodMessage, error.message, 'error');
@@ -687,5 +699,8 @@
init, init,
refreshButtons, refreshButtons,
loadTemplates, loadTemplates,
// Ouverts aussi depuis l'éditeur de semaine type.
openApplyModal,
openPeriodModal,
}; };
})(); })();

View File

@@ -2553,3 +2553,612 @@ textarea:disabled {
@media print { @media print {
.annual-validation-card { display: none !important; } .annual-validation-card { display: none !important; }
} }
/* ============================================================
PTA V7.9 - Navigation regroupée et tableau de bord
============================================================ */
/* --- Barre de navigation par intention --------------------- */
.view-tabs-grouped {
align-items: stretch;
gap: 0;
padding: 0.45rem 0.35rem 0.4rem;
}
.view-tab-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0 0.6rem;
}
.view-tabs-grouped .view-tab-group + .view-tab-group {
border-left: 1px solid #e2e8f0;
}
.view-tab-group-label {
padding-left: 0.35rem;
color: #7b8794;
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: nowrap;
}
.view-tab-group-items {
display: flex;
gap: 0.35rem;
}
.view-tabs-grouped .view-tab {
padding: 0.55rem 0.8rem;
font-size: 0.94rem;
}
/* Sans titres de groupe, la barre retrouve son alignement d'origine. */
.view-tabs:not(.view-tabs-grouped) .view-tab-group,
.view-tabs:not(.view-tabs-grouped) .view-tab-group-items {
display: contents;
}
.page-header-identity { min-width: 0; }
.page-header-identity .subtitle { max-width: 68ch; }
/* --- Tuiles d'action --------------------------------------- */
.dashboard-context-card .section-heading { align-items: flex-end; }
.dashboard-context-actions {
display: flex;
align-items: flex-end;
gap: 12px;
}
.dashboard-week-field { min-width: 190px; }
.dashboard-context-note {
margin: 14px 0 0;
padding: 10px 13px;
border-radius: 10px;
border-left: 5px solid #2563eb;
background: #eff6ff;
color: #1e3a5f;
font-size: 0.9rem;
}
.dashboard-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.dashboard-tile {
display: grid;
gap: 5px;
padding: 18px 20px;
border: 1px solid #e4e8eb;
border-left: 6px solid #cbd5e1;
border-radius: 16px;
background: #fff;
box-shadow: 0 6px 24px rgba(23, 32, 42, 0.05);
color: inherit;
text-decoration: none;
transition: transform 0.12s ease, box-shadow 0.15s ease;
}
.dashboard-tile:hover,
.dashboard-tile:focus-visible {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(23, 32, 42, 0.11);
}
.dashboard-tile-label {
color: #5f6b7a;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.dashboard-tile-value { font-size: 2.1rem; line-height: 1.1; }
.dashboard-tile-detail { color: #66737d; font-size: 0.84rem; }
.dashboard-tile-cta {
margin-top: 6px;
color: #1f4c73;
font-size: 0.82rem;
font-weight: 800;
}
.dashboard-tile-cta::after { content: ' →'; }
.dashboard-tile.is-alert { border-left-color: #c72f3f; }
.dashboard-tile.is-alert .dashboard-tile-value { color: #a3212f; }
.dashboard-tile.is-clear { border-left-color: #2f855a; }
.dashboard-tile.is-clear .dashboard-tile-value { color: #256d3b; }
/* --- Listes « à traiter » ---------------------------------- */
.dashboard-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 20px;
}
.dashboard-columns > .card { margin-bottom: 0; }
.dashboard-list-card { display: flex; flex-direction: column; }
.dashboard-list {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
list-style: none;
}
.dashboard-list-item {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 13px;
border: 1px solid #e6ebee;
border-radius: 11px;
background: #f9fbfc;
}
.dashboard-list-main { flex: 1 1 auto; min-width: 0; }
.dashboard-list-main strong { display: block; }
.dashboard-list-main small { color: #66737d; font-size: 0.82rem; }
.dashboard-list-meta {
display: grid;
gap: 2px;
text-align: right;
white-space: nowrap;
}
.dashboard-list-meta span { color: #66737d; font-size: 0.78rem; }
.dashboard-list-action {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
min-height: 38px;
padding: 8px 14px;
font-size: 0.86rem;
text-decoration: none;
}
.dashboard-gap-count { color: #a3212f; }
.dashboard-inline-note {
margin-top: 14px;
padding: 11px 13px;
border-radius: 10px;
border-left: 5px solid #d97706;
background: #fffbeb;
color: #7d5c00;
font-size: 0.86rem;
}
/* --- Suivi des quotas -------------------------------------- */
.dashboard-quota-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.dashboard-quota-table-wrap { overflow-x: auto; }
.dashboard-quota-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.dashboard-quota-table th,
.dashboard-quota-table td {
padding: 10px 12px;
border-bottom: 1px solid #eceff1;
text-align: left;
vertical-align: middle;
}
.dashboard-quota-table th {
color: #5f6b7a;
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.dashboard-quota-table td small { display: block; color: #66737d; font-size: 0.78rem; }
.dashboard-quota-table tr.is-over-quota { background: #fef4f4; }
.dashboard-quota-action-cell { text-align: right; }
.dashboard-quota-action-cell .button {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 7px 13px;
font-size: 0.84rem;
text-decoration: none;
}
.dashboard-quota-gauge-cell {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
/* Jauge native : la CSP interdit l'attribut style, la largeur ne peut donc pas
être calculée en JavaScript sur une simple div. */
.dashboard-quota-gauge {
width: 110px;
height: 9px;
border: 0;
border-radius: 999px;
background: #e4e9ec;
appearance: none;
}
.dashboard-quota-gauge::-webkit-progress-bar { background: #e4e9ec; border-radius: 999px; }
.dashboard-quota-gauge::-webkit-progress-value { background: #2f6f9f; border-radius: 999px; }
.dashboard-quota-gauge::-moz-progress-bar { background: #2f6f9f; border-radius: 999px; }
.dashboard-quota-gauge.is-over::-webkit-progress-value { background: #c72f3f; }
.dashboard-quota-gauge.is-over::-moz-progress-bar { background: #c72f3f; }
/* --- Raccourcis -------------------------------------------- */
.dashboard-shortcuts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
gap: 12px;
}
.dashboard-shortcut {
display: grid;
gap: 3px;
padding: 14px 16px;
border: 1px solid #e4e8eb;
border-radius: 12px;
background: #f9fbfc;
color: inherit;
text-decoration: none;
}
.dashboard-shortcut:hover,
.dashboard-shortcut:focus-visible { border-color: #b9c6cf; background: #fff; }
.dashboard-shortcut small { color: #66737d; font-size: 0.82rem; }
@media (max-width: 900px) {
.view-tabs-grouped { flex-direction: column; align-items: flex-start; gap: 0.6rem; }
.view-tabs-grouped .view-tab-group + .view-tab-group { border-left: 0; }
.view-tab-group { width: 100%; padding: 0; }
.view-tab-group-items { flex-wrap: wrap; }
.dashboard-context-card .section-heading { align-items: stretch; }
.dashboard-context-actions { flex-wrap: wrap; }
}
@media (max-width: 620px) {
.dashboard-list-item { flex-wrap: wrap; }
.dashboard-list-meta { text-align: left; }
.dashboard-list-action { width: 100%; justify-content: center; }
}
/* ============================================================
PTA V7.10 - Guide d'utilisation (FAQ)
============================================================ */
/* --- Bouton d'accès ancré à droite de la navigation --------- */
/* La barre défile horizontalement quand les onglets débordent ;
`position: sticky` garde le guide visible quoi qu'il arrive. */
.view-tab-help {
display: flex;
position: sticky;
right: 0;
align-items: center;
margin-left: auto;
padding-left: 0.55rem;
background: #f8fafc;
box-shadow: -14px 0 12px -10px rgba(15, 23, 42, 0.16);
}
.view-tabs-grouped .view-tab-help { border-left: 1px solid #e2e8f0; }
.view-tab-faq {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #263238;
text-decoration: none;
}
.view-tab-faq-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
border-radius: 50%;
background: #263238;
color: #fff;
font-size: 0.78rem;
line-height: 1;
}
.view-tab-faq:hover,
.view-tab-faq:focus-visible { background: #fff; }
/* --- Mise en page du guide --------------------------------- */
.faq-layout {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
align-items: start;
gap: 20px;
}
.faq-summary {
position: sticky;
top: 18px;
padding: 18px 20px;
}
.faq-summary-title {
margin: 0 0 10px;
color: #7b8794;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.faq-summary-list {
margin: 0;
padding-left: 1.1rem;
color: #66737d;
font-size: 0.9rem;
}
.faq-summary-list li { margin-bottom: 7px; }
.faq-summary-list a { color: #263238; text-decoration: none; }
.faq-summary-list a:hover,
.faq-summary-list a:focus-visible { text-decoration: underline; }
.faq-summary-note {
margin: 14px 0 0;
padding-top: 12px;
border-top: 1px solid #eceff1;
color: #66737d;
font-size: 0.8rem;
}
.faq-section { scroll-margin-top: 18px; }
.faq-section h3 { margin: 22px 0 8px; font-size: 1rem; }
.faq-section p { line-height: 1.55; }
.faq-section code {
padding: 1px 5px;
border-radius: 5px;
background: #eef1f4;
font-size: 0.86rem;
}
.faq-section kbd {
padding: 1px 6px;
border: 1px solid #cfd8dc;
border-bottom-width: 2px;
border-radius: 5px;
background: #f8fafc;
font-size: 0.82rem;
}
/* --- Le parcours en huit étapes ---------------------------- */
.faq-flow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.faq-flow li {
display: grid;
gap: 3px;
padding: 13px 15px;
border: 1px solid #e4e8eb;
border-radius: 12px;
background: #f9fbfc;
}
.faq-flow-step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: #263238;
color: #fff;
font-size: 0.8rem;
font-weight: 700;
}
.faq-flow li small { color: #66737d; font-size: 0.82rem; line-height: 1.4; }
/* --- Étapes numérotées et listes --------------------------- */
.faq-steps { margin: 0; padding-left: 1.3rem; }
.faq-steps li { margin-bottom: 9px; line-height: 1.55; }
.faq-list { margin: 0 0 4px; padding-left: 1.3rem; }
.faq-list li { margin-bottom: 7px; line-height: 1.55; }
.faq-callout { margin: 16px 0 0; }
.faq-table th,
.faq-table td { vertical-align: top; line-height: 1.5; }
.faq-table tr.faq-row-current { background: #f2f7f4; }
.faq-level {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.faq-level-error { background: #fdecec; color: #a3212f; }
.faq-level-warning { background: #fffbeb; color: #7d5c00; }
.faq-level-info { background: #eef2f7; color: #37474f; }
/* --- Questions / réponses ---------------------------------- */
.faq-qa { margin: 0; }
.faq-qa dt {
margin-top: 16px;
font-weight: 700;
}
.faq-qa dt:first-child { margin-top: 0; }
.faq-qa dd {
margin: 5px 0 0;
padding-left: 13px;
border-left: 3px solid #e4e8eb;
color: #4b5563;
line-height: 1.55;
}
@media (max-width: 1000px) {
.faq-layout { grid-template-columns: minmax(0, 1fr); }
.faq-summary { position: static; }
}
@media (max-width: 900px) {
.view-tab-help {
position: static;
width: 100%;
margin-left: 0;
padding-left: 0;
box-shadow: none;
}
.view-tabs-grouped .view-tab-help { border-left: 0; }
}
/* ============================================================
PTA V7.15 - Méthode de saisie et éditeur de semaine type
============================================================ */
.editor-mode-choices {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.editor-mode-choice {
display: grid;
gap: 4px;
padding: 16px 18px;
border: 1px solid #dbe3ee;
border-left: 6px solid #cbd5e1;
border-radius: 14px;
background: #fff;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.15s ease;
}
.editor-mode-choice:hover { border-color: #b9c6cf; }
.editor-mode-choice strong { font-size: 1.05rem; }
.editor-mode-choice small { color: #66737d; }
.editor-mode-choice.is-active {
border-left-color: #256d3b;
box-shadow: 0 6px 22px rgba(23, 32, 42, 0.09);
}
.editor-mode-choice-badge {
justify-self: start;
margin-bottom: 2px;
padding: 3px 9px;
border-radius: 999px;
background: #dff3e5;
color: #256d3b;
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* --- Éditeur de semaine type -------------------------------- */
.template-editor-total { text-align: right; }
.template-editor-total small { display: block; color: #65737b; font-size: 0.78rem; }
.template-editor-toolbar {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.4fr) auto;
align-items: end;
gap: 16px;
margin-bottom: 18px;
}
.template-editor-toolbar-actions { display: flex; gap: 10px; }
.template-editor-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 14px;
}
.template-day {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid #e1e6e9;
border-radius: 12px;
background: #fafbfb;
}
.template-day.is-empty { background: #f5f7f8; }
.template-day-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #e6ebee;
}
.template-day-total { color: #5f6b7a; font-size: 0.85rem; font-weight: 800; }
.template-day-empty { margin: 4px 0; color: #98a1a7; font-size: 0.85rem; }
.template-day-ranges { display: grid; gap: 10px; }
.template-range {
display: grid;
grid-template-columns: 1fr auto auto auto auto;
align-items: end;
gap: 8px;
padding: 10px;
border: 1px solid #dde3e6;
border-radius: 10px;
background: #fff;
}
.template-range-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.template-range-field > span { color: #66737d; font-size: 0.72rem; font-weight: 800; }
.template-range-field-motif { grid-column: 1 / -1; }
.template-range-field-structure { grid-column: 1 / -1; }
.template-range input,
.template-range select { min-height: 38px; padding: 6px 9px; }
.template-range-separator { padding-bottom: 10px; color: #98a1a7; }
.template-range-remove {
min-height: 38px;
padding: 4px 10px;
font-size: 1.1rem;
line-height: 1;
}
.template-day-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.template-day-actions .button { min-height: 38px; padding: 7px 12px; font-size: 0.85rem; }
.template-day-copy-field { flex: 1 1 130px; min-width: 130px; }
.template-day-copy { min-height: 38px; padding: 6px 9px; font-size: 0.85rem; }
.template-editor-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 760px) {
.template-editor-toolbar { grid-template-columns: 1fr; }
.template-editor-toolbar-actions { justify-content: flex-start; }
.template-range { grid-template-columns: 1fr 1fr auto; }
.template-range-separator { display: none; }
}

7
dashboard.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require __DIR__ . '/page_bootstrap.php';
runPage('dashboard');

View File

@@ -0,0 +1,201 @@
-- ============================================================================
-- PTA V7.8 — Sécurisation : les règles d'accès descendent en base
-- ============================================================================
-- À exécuter UNE SEULE FOIS sur une base PTA V7.7 existante :
-- SOURCE database/migration_v7_8_securite_acces.sql;
--
-- Objectif : plus aucune règle de périmètre ni de droit n'est reconstruite en
-- PHP. Le contrôleur pose une question à la base, la base répond oui ou non.
-- Toute évolution des droits se fait désormais en SQL, sans redéploiement.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. MATRICE DES DROITS : rôle -> pages et rôle -> permissions
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS acces_role_page (
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
page VARCHAR(50) NOT NULL,
ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100,
PRIMARY KEY (role, page)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS acces_role_permission (
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
permission VARCHAR(50) NOT NULL,
autorise BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (role, permission)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Pages autorisées — reprise à l'identique des droits V7.7.
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('AGENT', 'agents', 10),
('AGENT', 'structures', 20),
('RESPONSABLE_STRUCTURE', 'planning', 10),
('RESPONSABLE_STRUCTURE', 'structures', 20),
('RESPONSABLE_STRUCTURE', 'agents', 30),
('RESPONSABLE_STRUCTURE', 'coverage', 40),
('SERVICE_ENFANCE', 'planning', 10),
('SERVICE_ENFANCE', 'pending', 20),
('SERVICE_ENFANCE', 'coverage', 30),
('SERVICE_ENFANCE', 'structures', 40),
('SERVICE_ENFANCE', 'agents', 50),
('SERVICE_ENFANCE', 'annual', 60),
('SERVICE_ENFANCE', 'pta', 70),
('SERVICE_ENFANCE', 'teams', 80),
('SERVICE_ENFANCE', 'vacations', 90),
('SERVICE_ENFANCE', 'administration', 100)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
-- Permissions fonctionnelles — reprise à l'identique des droits V7.7.
INSERT INTO acces_role_permission (role, permission, autorise) VALUES
('AGENT', 'read_only', TRUE),
('AGENT', 'can_edit_draft', FALSE),
('AGENT', 'can_edit_cross_structure', FALSE),
('AGENT', 'can_validate', FALSE),
('AGENT', 'can_reopen', FALSE),
('AGENT', 'can_use_templates', FALSE),
('AGENT', 'can_copy_full_week', FALSE),
('AGENT', 'can_view_pending', FALSE),
('AGENT', 'can_manage_coverage', FALSE),
('AGENT', 'can_manage_agents', FALSE),
('AGENT', 'can_manage_structures', FALSE),
('AGENT', 'can_manage_vacations', FALSE),
('AGENT', 'can_manage_teams', FALSE),
('AGENT', 'can_manage_pta', FALSE),
('AGENT', 'can_forecast_next_year', FALSE),
('RESPONSABLE_STRUCTURE', 'read_only', FALSE),
('RESPONSABLE_STRUCTURE', 'can_edit_draft', TRUE),
('RESPONSABLE_STRUCTURE', 'can_edit_cross_structure', FALSE),
('RESPONSABLE_STRUCTURE', 'can_validate', FALSE),
('RESPONSABLE_STRUCTURE', 'can_reopen', TRUE),
('RESPONSABLE_STRUCTURE', 'can_use_templates', FALSE),
('RESPONSABLE_STRUCTURE', 'can_copy_full_week', FALSE),
('RESPONSABLE_STRUCTURE', 'can_view_pending', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_coverage', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_agents', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_structures', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_vacations', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_teams', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_pta', FALSE),
('RESPONSABLE_STRUCTURE', 'can_forecast_next_year', FALSE),
('SERVICE_ENFANCE', 'read_only', FALSE),
('SERVICE_ENFANCE', 'can_edit_draft', TRUE),
('SERVICE_ENFANCE', 'can_edit_cross_structure', TRUE),
('SERVICE_ENFANCE', 'can_validate', TRUE),
('SERVICE_ENFANCE', 'can_reopen', TRUE),
('SERVICE_ENFANCE', 'can_use_templates', TRUE),
('SERVICE_ENFANCE', 'can_copy_full_week', TRUE),
('SERVICE_ENFANCE', 'can_view_pending', TRUE),
('SERVICE_ENFANCE', 'can_manage_coverage', TRUE),
('SERVICE_ENFANCE', 'can_manage_agents', TRUE),
('SERVICE_ENFANCE', 'can_manage_structures', TRUE),
('SERVICE_ENFANCE', 'can_manage_vacations', TRUE),
('SERVICE_ENFANCE', 'can_manage_teams', TRUE),
('SERVICE_ENFANCE', 'can_manage_pta', TRUE),
('SERVICE_ENFANCE', 'can_forecast_next_year', TRUE)
ON DUPLICATE KEY UPDATE autorise = VALUES(autorise);
-- ----------------------------------------------------------------------------
-- 2. VUES DE DROITS
-- ----------------------------------------------------------------------------
CREATE OR REPLACE VIEW v_acces_role_page AS
SELECT role, page, ordre_affichage
FROM acces_role_page;
CREATE OR REPLACE VIEW v_acces_role_permission AS
SELECT role, permission, autorise
FROM acces_role_permission;
-- ----------------------------------------------------------------------------
-- 3. VUES D'IDENTITÉ
-- ----------------------------------------------------------------------------
-- Identité complète d'un agent actif : sert à valider la sélection de rôle et
-- à construire le bandeau de profil. Un agent inactif n'apparaît jamais ici,
-- il ne peut donc pas être choisi comme identité.
CREATE OR REPLACE VIEW v_acces_identite_agent AS
SELECT
a.id_agent,
a.matricule,
a.nom,
a.prenom,
a.id_structure,
s.nom AS structure_nom,
s.code AS structure_code
FROM agent a
LEFT JOIN structure s
ON s.id_structure = a.id_structure
AND s.actif = TRUE
WHERE a.actif = TRUE;
-- Lieux d'affectation sélectionnables par un responsable de structure.
CREATE OR REPLACE VIEW v_acces_identite_structure AS
SELECT
s.id_structure,
s.code,
s.nom,
s.type_affectation
FROM structure s
WHERE s.actif = TRUE;
-- ----------------------------------------------------------------------------
-- 4. VUES DE PÉRIMÈTRE
-- ----------------------------------------------------------------------------
-- Quels agents un lieu d'affectation a-t-il le droit de consulter ?
-- Deux origines : le rattachement principal, ou une planification effective
-- sur ce lieu. C'est la règle exacte que le PHP reconstruisait à la main.
CREATE OR REPLACE VIEW v_acces_agent_par_structure AS
SELECT DISTINCT
a.id_agent,
a.id_structure AS id_structure_autorisee,
'RATTACHEMENT' AS origine
FROM agent a
WHERE a.actif = TRUE
AND a.id_structure IS NOT NULL
UNION
SELECT DISTINCT
a.id_agent,
p.id_structure AS id_structure_autorisee,
'PLANIFICATION' AS origine
FROM agent a
INNER JOIN planning p ON p.id_agent = a.id_agent
WHERE a.actif = TRUE;
-- Périmètre d'un créneau : à quel lieu et à quel planning appartient-il ?
-- Utilisé avant toute modification ou suppression d'affectation.
CREATE OR REPLACE VIEW v_acces_perimetre_creneau AS
SELECT
c.id_creneau,
c.id_planning,
p.id_structure,
p.id_agent,
p.statut AS statut_planning,
s.nom AS structure_nom
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure s ON s.id_structure = p.id_structure;
-- Périmètre d'un planning : utilisé avant validation ou réouverture.
CREATE OR REPLACE VIEW v_acces_perimetre_planning AS
SELECT
p.id_planning,
p.id_structure,
p.id_agent,
p.statut AS statut_planning,
s.nom AS structure_nom,
a.nom AS agent_nom,
a.prenom AS agent_prenom
FROM planning p
INNER JOIN structure s ON s.id_structure = p.id_structure
INNER JOIN agent a ON a.id_agent = p.id_agent;
-- ============================================================================
-- Vérification recommandée après import :
-- SELECT role, COUNT(*) FROM acces_role_page GROUP BY role;
-- SELECT * FROM v_acces_agent_par_structure LIMIT 10;
-- ============================================================================

View File

@@ -0,0 +1,26 @@
-- ============================================================================
-- PTA V7.9 — Tableau de bord du Service Enfance
-- ============================================================================
-- À exécuter UNE SEULE FOIS sur une base PTA V7.8 existante :
-- SOURCE database/migration_v7_9_tableau_de_bord.sql;
--
-- La page « dashboard » suit la règle posée en V7.8 : une page absente de
-- acces_role_page est refusée. Tant que cette migration n'est pas jouée,
-- l'onglet n'apparaît pas et le Service Enfance atterrit comme avant sur
-- l'écran de saisie — l'application reste fonctionnelle, sans tableau de bord.
--
-- Aucune table, aucune vue, aucune donnée métier n'est modifiée : le tableau de
-- bord ne fait que relire ce que les autres écrans calculent déjà.
-- ============================================================================
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('SERVICE_ENFANCE', 'dashboard', 5)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
-- ============================================================================
-- Vérification après import :
-- SELECT * FROM v_acces_role_page WHERE role = 'SERVICE_ENFANCE' ORDER BY ordre_affichage;
--
-- Retour arrière :
-- DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard';
-- ============================================================================

View File

@@ -0,0 +1,23 @@
-- ============================================================================
-- PTA — Droit à l'enveloppe annuelle de formation (14 h)
-- ============================================================================
-- Tous les agents ne bénéficient pas de l'enveloppe complémentaire de 14 h de
-- formation. Cette colonne porte ce droit, coché par défaut à la création d'un
-- agent. Un agent sans ce droit ne peut plus se voir affecter le motif
-- « Formation » lors de la saisie des horaires, et son enveloppe de formation
-- au PTA annuel vaut 0.
--
-- La colonne ne porte volontairement pas de clause AFTER : `migration_v7_9c`
-- retire `formation_repartition_annuelle`, qui aurait servi de repère, et le
-- socle ne la crée plus du tout sur une base neuve.
--
-- Nommage : les migrations sont jouées dans l'ordre alphabétique. Le suffixe
-- « 9b » place ce fichier après migration_v7_9_tableau_de_bord.sql, ce qu'un
-- « v7_10 » ou « v7_11 » n'aurait pas fait ('1' trie avant '8' et '9').
--
-- Idempotente : rejouable sans effet de bord.
-- ============================================================================
ALTER TABLE agent
ADD COLUMN IF NOT EXISTS droit_enveloppe_formation BOOLEAN NOT NULL DEFAULT TRUE
COMMENT 'Droit à l''enveloppe annuelle de 14 h de formation';

View File

@@ -0,0 +1,17 @@
-- ============================================================================
-- PTA — Retrait de `agent.formation_repartition_annuelle`
-- ============================================================================
-- Cette colonne portait une seconde case à cocher sur la fiche agent : « une
-- formation est déjà prévue dans la répartition annuelle, ne pas ajouter
-- l'enveloppe complémentaire de 14 h ». Elle faisait doublon avec le droit à
-- l'enveloppe de formation (`droit_enveloppe_formation`, migration v7_9b), seul
-- conservé : l'enveloppe vaut 14 h si l'agent y a droit, 0 sinon.
--
-- Le calcul de `pta_annuel.enveloppe_formation_minutes` ne dépend donc plus que
-- de `droit_enveloppe_formation` (AgentModel::trainingEnvelopeMinutes()).
--
-- Idempotente : rejouable sans effet de bord.
-- ============================================================================
ALTER TABLE agent
DROP COLUMN IF EXISTS formation_repartition_annuelle;

View File

@@ -0,0 +1,30 @@
-- ============================================================================
-- PTA — Guide d'utilisation (page « faq »)
-- ============================================================================
-- Appliquée automatiquement au démarrage par le service db-migrate.
-- Pour un import manuel sur une base existante :
-- SOURCE database/migration_v7_9d_faq.sql;
--
-- Le guide suit la règle posée en V7.8 : une page absente de acces_role_page
-- est refusée. Il est ouvert aux trois rôles — c'est un secours, pas un droit
-- métier — et son bouton d'accès n'apparaît dans la barre de navigation que si
-- cette migration a été jouée.
--
-- La page est purement statique : aucune table, aucune vue, aucune donnée
-- métier n'est touchée. L'ordre d'affichage est indicatif, le bouton étant
-- ancré à droite de la barre plutôt que rangé dans un groupe d'onglets.
-- ============================================================================
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('AGENT', 'faq', 200),
('RESPONSABLE_STRUCTURE', 'faq', 200),
('SERVICE_ENFANCE', 'faq', 200)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
-- ============================================================================
-- Vérification après import :
-- SELECT role, page FROM v_acces_role_page WHERE page = 'faq';
--
-- Retour arrière :
-- DELETE FROM acces_role_page WHERE page = 'faq';
-- ============================================================================

View File

@@ -105,7 +105,6 @@ CREATE TABLE agent (
id_poste INT UNSIGNED NULL, id_poste INT UNSIGNED NULL,
type_contrat ENUM('PERMANENT', 'TEMPORAIRE') NOT NULL DEFAULT 'PERMANENT', type_contrat ENUM('PERMANENT', 'TEMPORAIRE') NOT NULL DEFAULT 'PERMANENT',
date_fin_contrat DATE NULL, date_fin_contrat DATE NULL,
formation_repartition_annuelle BOOLEAN NOT NULL DEFAULT FALSE,
commentaire_pta VARCHAR(1000) NULL, commentaire_pta VARCHAR(1000) NULL,
id_structure INT UNSIGNED NULL COMMENT 'Lieu principal de rattachement', id_structure INT UNSIGNED NULL COMMENT 'Lieu principal de rattachement',
actif BOOLEAN NOT NULL DEFAULT TRUE, actif BOOLEAN NOT NULL DEFAULT TRUE,
@@ -277,6 +276,9 @@ VALUES
('MERCREDI_PERISCOLAIRE', 'Mercredi périscolaire', TRUE, 40, TRUE), ('MERCREDI_PERISCOLAIRE', 'Mercredi périscolaire', TRUE, 40, TRUE),
('EXTRASCOLAIRE', 'Temps extrascolaire', TRUE, 50, TRUE), ('EXTRASCOLAIRE', 'Temps extrascolaire', TRUE, 50, TRUE),
('FORMATION', 'Formation', TRUE, 60, TRUE), ('FORMATION', 'Formation', TRUE, 60, TRUE),
-- compte_dans_quota = FALSE : les 1 607 h sont un volume NET dont les 25
-- jours de congés payés ont déjà été retranchés. Les imputer au quota les
-- décompterait une seconde fois. Idem pour les jours fériés.
('CONGE', 'Congé annuel (non décompté)', FALSE, 70, TRUE), ('CONGE', 'Congé annuel (non décompté)', FALSE, 70, TRUE),
('JOUR_FRACTIONNEMENT', 'Journée de fractionnement', TRUE, 80, TRUE), ('JOUR_FRACTIONNEMENT', 'Journée de fractionnement', TRUE, 80, TRUE),
('PREPARATION_PERISCOLAIRE', 'Préparation périscolaire', TRUE, 90, TRUE), ('PREPARATION_PERISCOLAIRE', 'Préparation périscolaire', TRUE, 90, TRUE),

6
db.php
View File

@@ -20,8 +20,12 @@ try {
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_EMULATE_PREPARES => false,
]); ]);
} catch (PDOException $e) { } catch (PDOException $e) {
// Le message PDO contient l'hôte, l'utilisateur et le nom de la base :
// il est journalisé côté serveur, jamais propagé vers le navigateur.
error_log(sprintf('[PTA] Connexion base impossible : %s', $e->getMessage()));
throw new RuntimeException( throw new RuntimeException(
'Connexion à la base impossible. Vérifiez config.php, le nom de la base et que MySQL est démarré. Détail : ' . $e->getMessage(), 'Connexion à la base impossible. Vérifiez la configuration de la base de données et que le serveur MySQL est démarré.',
0, 0,
$e $e
); );

View File

@@ -25,6 +25,32 @@ services:
networks: networks:
- pta - pta
# Service jetable : applique les migrations SQL puis s'arrête. Le service php
# ne démarre qu'après sa sortie en succès, si bien qu'une migration en échec
# empêche l'application de tourner sur une base incomplète.
db-migrate:
image: mariadb:11.4
container_name: pta-db-migrate
restart: "no"
environment:
DB_HOST: db
DB_PORT: 3306
DB_NAME: ${DB_NAME:-pta}
# Les migrations créent tables et vues : elles passent par le compte
# d'administration, jamais par le compte applicatif.
DB_USER: root
DB_PASSWORD: ${DB_ROOT_PASSWORD:-pta}
TZ: ${TZ:-Europe/Paris}
volumes:
- ./database:/migrations:ro
- ./docker/db/apply-migrations.sh:/usr/local/bin/apply-migrations.sh:ro
entrypoint: ["/bin/sh", "/usr/local/bin/apply-migrations.sh"]
depends_on:
db:
condition: service_healthy
networks:
- pta
php: php:
build: build:
context: . context: .
@@ -38,10 +64,15 @@ services:
PTA_DB_NAME: ${DB_NAME:-pta} PTA_DB_NAME: ${DB_NAME:-pta}
PTA_DB_USER: ${DB_USER:-pta} PTA_DB_USER: ${DB_USER:-pta}
PTA_DB_PASS: ${DB_PASSWORD:-change_me} PTA_DB_PASS: ${DB_PASSWORD:-change_me}
# Aucun détail technique n'est renvoyé au navigateur tant que ce drapeau
# n'est pas explicitement passé à 1. Ne jamais l'activer en production.
PTA_DEBUG: ${PTA_DEBUG:-0}
TZ: ${TZ:-Europe/Paris} TZ: ${TZ:-Europe/Paris}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
db-migrate:
condition: service_completed_successfully
networks: networks:
- pta - pta
@@ -66,13 +97,16 @@ services:
environment: environment:
PMA_HOST: db PMA_HOST: db
PMA_PORT: 3306 PMA_PORT: 3306
PMA_USER: ${DB_USER:-pta} # PMA_USER / PMA_PASSWORD sont volontairement absents : leur présence fait
PMA_PASSWORD: ${DB_PASSWORD:-change_me} # basculer phpMyAdmin en auth_type=config, c'est-à-dire une session déjà
# authentifiée pour quiconque atteint le port. Sans eux, phpMyAdmin exige
# un identifiant et un mot de passe MariaDB à chaque connexion.
UPLOAD_LIMIT: 64M UPLOAD_LIMIT: 64M
TZ: ${TZ:-Europe/Paris} TZ: ${TZ:-Europe/Paris}
ports: ports:
# Par défaut phpMyAdmin n'est accessible que depuis la machine hôte. # phpMyAdmin n'écoute que sur la boucle locale. Pour y accéder à distance,
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-8081}:80" # passer par un tunnel SSH (voir README_DOCKER.md).
- "${PMA_BIND:-0.0.0.0}:${PMA_PORT:-8081}:80"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

153
docker/db/apply-migrations.sh Executable file
View File

@@ -0,0 +1,153 @@
#!/bin/sh
# ============================================================================
# PTA — application automatique des migrations SQL au démarrage de la stack
# ============================================================================
# Exécuté par le service jetable `db-migrate` du docker-compose, une fois que
# MariaDB est déclaré sain. Le service `php` attend la fin de ce script en
# succès : si une migration échoue, l'application ne démarre pas du tout.
#
# Le schéma initial (database/pta_prod_schema_mariadb.sql) reste joué par
# docker-entrypoint-initdb.d à la création du volume. Ce script ne traite que
# les fichiers `migration_*.sql`, dans l'ordre alphabétique, sur une base déjà
# existante comme sur une base fraîchement créée.
#
# Un registre `schema_migration` mémorise chaque fichier appliqué avec son
# empreinte. Un fichier déjà appliqué et inchangé est ignoré ; un fichier
# modifié est rejoué — les migrations PTA sont écrites idempotentes
# (CREATE TABLE IF NOT EXISTS, INSERT ... ON DUPLICATE KEY UPDATE,
# CREATE OR REPLACE VIEW), les rejouer est sans effet de bord.
# ============================================================================
set -eu
DB_HOST="${DB_HOST:-db}"
DB_PORT="${DB_PORT:-3306}"
DB_NAME="${DB_NAME:-pta}"
DB_USER="${DB_USER:-root}"
MIGRATIONS_DIR="${MIGRATIONS_DIR:-/migrations}"
# Le mot de passe passe par MYSQL_PWD et jamais par la ligne de commande :
# un `ps` dans le conteneur ne doit pas le révéler.
MYSQL_PWD="${DB_PASSWORD:-}"
export MYSQL_PWD
log() {
echo "[pta-migrate] $*"
}
sql() {
# --ssl-verify-server-cert=0 : le client MariaDB 11.4 désactive de toute
# façon cette vérification quand le mot de passe ne vient pas de la ligne
# de commande, en émettant un avertissement trompeur parlant de « login
# sans mot de passe ». Le poser explicitement supprime ce bruit dans les
# journaux de déploiement sans rien changer au comportement réel : la
# liaison reste interne au réseau bridge du compose.
mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" \
--database="$DB_NAME" --default-character-set=utf8mb4 \
--ssl-verify-server-cert=0 "$@"
}
# ----------------------------------------------------------------------------
# Attente de la base
# ----------------------------------------------------------------------------
# Le compose garantit déjà `service_healthy`, mais le healthcheck interroge la
# boucle locale du conteneur : cette boucle couvre le court instant où le port
# n'accepte pas encore de connexion depuis le réseau.
attente=0
until sql -N -B -e 'SELECT 1' >/dev/null 2>&1; do
attente=$((attente + 1))
if [ "$attente" -ge 30 ]; then
log "ERREUR : base $DB_NAME injoignable sur $DB_HOST:$DB_PORT après 60 s."
exit 1
fi
log "Base pas encore joignable, nouvelle tentative dans 2 s ($attente/30)…"
sleep 2
done
log "Base $DB_NAME joignable sur $DB_HOST:$DB_PORT."
# ----------------------------------------------------------------------------
# Registre des migrations appliquées
# ----------------------------------------------------------------------------
sql <<'SQL'
CREATE TABLE IF NOT EXISTS schema_migration (
fichier VARCHAR(190) NOT NULL,
empreinte CHAR(32) NOT NULL,
applique_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (fichier)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SQL
# ----------------------------------------------------------------------------
# Application des migrations
# ----------------------------------------------------------------------------
applique=0
ignore=0
for fichier in "$MIGRATIONS_DIR"/migration_*.sql; do
# Le glob non résolu se présente tel quel quand aucun fichier ne correspond.
[ -f "$fichier" ] || continue
nom=$(basename "$fichier")
empreinte=$(md5sum < "$fichier" | cut -d' ' -f1)
connue=$(sql -N -B -e \
"SELECT empreinte FROM schema_migration WHERE fichier = '$nom'")
if [ "$connue" = "$empreinte" ]; then
log "$nom : déjà appliquée, ignorée."
ignore=$((ignore + 1))
continue
fi
if [ -n "$connue" ]; then
log "$nom : contenu modifié depuis la dernière application, rejeu."
else
log "$nom : application…"
fi
if ! sql < "$fichier"; then
log "ERREUR : échec de $nom. L'application ne sera pas démarrée."
exit 1
fi
sql -e "INSERT INTO schema_migration (fichier, empreinte)
VALUES ('$nom', '$empreinte')
ON DUPLICATE KEY UPDATE empreinte = VALUES(empreinte),
applique_le = CURRENT_TIMESTAMP"
log "$nom : appliquée."
applique=$((applique + 1))
done
log "Bilan : $applique migration(s) appliquée(s), $ignore ignorée(s)."
# ----------------------------------------------------------------------------
# Contrôle final : les vues de droits doivent exister
# ----------------------------------------------------------------------------
# Même contrôle que SchemaModel::assertAccessViews() côté PHP. Le faire ici
# évite de laisser démarrer une pile dont la page d'accueil renverrait une
# erreur 500 incompréhensible.
manquantes=$(sql -N -B -e "
SELECT GROUP_CONCAT(attendue.nom ORDER BY attendue.nom SEPARATOR ', ')
FROM (
SELECT 'v_acces_role_page' AS nom
UNION ALL SELECT 'v_acces_role_permission'
UNION ALL SELECT 'v_acces_identite_agent'
UNION ALL SELECT 'v_acces_identite_structure'
UNION ALL SELECT 'v_acces_agent_par_structure'
UNION ALL SELECT 'v_acces_perimetre_creneau'
UNION ALL SELECT 'v_acces_perimetre_planning'
) AS attendue
LEFT JOIN information_schema.VIEWS v
ON v.TABLE_SCHEMA = DATABASE()
AND v.TABLE_NAME = attendue.nom
WHERE v.TABLE_NAME IS NULL")
if [ -n "$manquantes" ] && [ "$manquantes" != "NULL" ]; then
log "ERREUR : vue(s) de contrôle d'accès manquante(s) : $manquantes"
log "La migration v7.8 n'a pas produit le résultat attendu."
exit 1
fi
log "Les sept vues de contrôle d'accès sont présentes. Base à jour."

View File

@@ -8,8 +8,67 @@ server {
charset utf-8; charset utf-8;
client_max_body_size 64M; client_max_body_size 64M;
location / { # Ne pas annoncer la version du serveur.
try_files $uri $uri/ /index.php?$query_string; server_tokens off;
# ------------------------------------------------------------------------
# ORDRE IMPORTANT
# nginx évalue les expressions régulières dans l'ordre du fichier et retient
# la PREMIÈRE qui correspond. Tous les blocs de refus doivent donc précéder
# le bloc générique « location ~ \.php$ », sinon ils ne s'appliquent jamais
# et les fichiers d'amorçage deviennent exécutables depuis le navigateur.
# ------------------------------------------------------------------------
# Fichiers inclus par l'application, jamais des points d'entrée.
location ~* ^/(config|db|page_bootstrap)\.php$ {
deny all;
return 404;
}
# Classes, modèles, schémas SQL et documentation de version.
location ^~ /app/ {
deny all;
return 404;
}
location ^~ /database/ {
deny all;
return 404;
}
location ^~ /docker/ {
deny all;
return 404;
}
location ~* ^/README[^/]*$ {
deny all;
return 404;
}
# Configuration, dumps et journaux ne doivent jamais être téléchargeables.
location ~* \.(?:env|sql|sql\.gz|ini|log|bak|dump|yml|yaml|md)$ {
deny all;
return 404;
}
# Fichiers et dossiers cachés (.env, .git, .htaccess…).
location ~ /\. {
deny all;
return 404;
}
# ------------------------------------------------------------------------
# Contenus servis
# ------------------------------------------------------------------------
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
# Les réponses PHP portent leurs propres en-têtes de sécurité, CSP à
# nonce comprise (App\Core\Security) ; ici on couvre le statique.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
try_files $uri =404;
} }
location ~ \.php$ { location ~ \.php$ {
@@ -23,33 +82,13 @@ server {
fastcgi_param DOCUMENT_ROOT $document_root; fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param HTTP_PROXY ""; fastcgi_param HTTP_PROXY "";
# Les erreurs PHP restent gérées par l'application (JSON ou page HTML) :
# nginx ne les remplace pas par ses propres pages.
fastcgi_intercept_errors off;
fastcgi_read_timeout 120s; fastcgi_read_timeout 120s;
} }
# Les classes, modèles et fichiers SQL ne doivent jamais être servis directement. location / {
location ^~ /app/ { try_files $uri $uri/ /index.php?$query_string;
deny all;
return 404;
}
location ^~ /database/ {
deny all;
return 404;
}
location ~* ^/(config\.php|db\.php|page_bootstrap\.php|README[^/]*)$ {
deny all;
return 404;
}
location ~ /\. {
deny all;
return 404;
}
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
} }
} }

7
faq.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require __DIR__ . '/page_bootstrap.php';
runPage('faq');

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/app/autoload.php'; require_once __DIR__ . '/app/autoload.php';
use App\Controllers\PageController; use App\Controllers\PageController;
use App\Core\Security;
function runPage(string $method): void function runPage(string $method): void
{ {
@@ -17,10 +18,14 @@ function runPage(string $method): void
$controller->{$method}(); $controller->{$method}();
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); http_response_code(500);
$message = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); Security::sendHtmlHeaders();
$message = htmlspecialchars(
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de cette page.'),
ENT_QUOTES,
'UTF-8'
);
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>'; echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">'; echo '<body><h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
echo '<h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>'; echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
echo '<p>Vérifiez <code>config.php</code> et le schéma de la base de données.</p></body></html>';
} }
} }

View File

@@ -5,13 +5,20 @@ declare(strict_types=1);
require_once __DIR__ . '/app/autoload.php'; require_once __DIR__ . '/app/autoload.php';
use App\Controllers\RoleController; use App\Controllers\RoleController;
use App\Core\Security;
try { try {
$pdo = require __DIR__ . '/app/bootstrap.php'; $pdo = require __DIR__ . '/app/bootstrap.php';
(new RoleController($pdo))->index(); (new RoleController($pdo))->index();
} catch (Throwable $e) { } catch (Throwable $e) {
http_response_code(500); http_response_code(500);
Security::sendHtmlHeaders();
$message = htmlspecialchars(
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de la sélection du rôle.'),
ENT_QUOTES,
'UTF-8'
);
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>'; echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">'; echo '<body><h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . $message . '</p>';
echo '<h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</p></body></html>'; echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
} }