Compare commits

8 Commits

Author SHA1 Message Date
PTA
3cdbd43c13 Guide d'utilisation accessible depuis toute la navigation
Les écrans expliquaient chacun leur propre action, mais rien ne racontait
l'enchaînement : un nouvel utilisateur ne pouvait pas deviner qu'un lieu
doit exister avant un agent, ni qu'un planning se saisit en brouillon
avant d'être validé. La page « faq » déroule ce parcours complet, du
paramétrage des lieux au suivi annuel, et répond aux questions qui
reviennent (chevauchements, quota dépassé, planning validé à corriger).

Son bouton d'accès est ancré à droite de la barre de navigation plutôt
que rangé dans un groupe d'intention : le guide n'est pas une étape du
travail, c'est un secours permanent. Il est donc volontairement absent de
$navigation — c'est aussi ce qui l'empêche d'être repêché par le filet
« Autres » et d'apparaître deux fois. La barre défilant horizontalement
quand les onglets débordent, le bloc est en position sticky pour rester
atteignable.

La page reste soumise à la règle V7.8 : le droit vient de
acces_role_page, ouvert aux trois rôles. Sans la migration, ni bouton ni
page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:16:45 +02:00
PTA
8d900bede8 Retrait de l'ancienne case « formation déjà prévue dans la répartition annuelle »
La fiche agent portait deux cases à cocher pour la formation depuis l'ajout du
droit à l'enveloppe : celle-ci faisait doublon et disparaît, avec sa colonne
`agent.formation_repartition_annuelle` et tout ce qui la lisait — projection
SQL, création et modification d'agent, formulaires d'administration, JS, et
liste des colonnes obligatoires de SchemaModel.

L'enveloppe de formation ne dépend donc plus que du droit :
AgentModel::trainingEnvelopeMinutes() rend 14 h ou 0.

La colonne est retirée du socle (le CREATE TABLE ne la crée plus sur une base
neuve) et par migration_v7_9c sur les bases existantes. migration_v7_9b perd sa
clause AFTER, qui pointait sur la colonne supprimée et aurait fait échouer
l'installation d'une base neuve ; sa réécriture est sans risque, le script de
migration rejoue un fichier modifié et l'ALTER reste idempotent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:40:56 +02:00
PTA
30cb50031d Droit à l'enveloppe de formation par agent, et motif « Formation » filtré à la saisie
Nouvelle colonne `agent.droit_enveloppe_formation`, cochée par défaut, saisie à
la création de l'agent et modifiable depuis sa fiche. Un agent sans ce droit :

- ne voit plus le motif « Formation » dans le sélecteur de motif de l'éditeur de
  planning ni dans le modal de la vue individuelle ;
- se voit refuser toute affectation de ce motif par le serveur, avec un message
  qui nomme le motif et l'agent (save_planning.php et update_agent_entry.php) ;
- a une enveloppe de formation de 0 min au PTA annuel, et une ALERTE
  FORMATION_SANS_DROIT si des heures de formation subsistent dans son planning.

À ne pas confondre avec `formation_repartition_annuelle`, qui existait déjà :
celle-ci dit que la formation est déjà comptée dans la répartition annuelle —
l'agent y a droit, l'enveloppe complémentaire n'est simplement pas ajoutée. Les
deux règles sont désormais rassemblées dans AgentModel::trainingEnvelopeMinutes(),
qui remplace les deux calculs « 840 ou 0 » jusque-là dupliqués.

Le motif interdit reste affiché lorsqu'il est déjà celui de la plage en cours
d'édition : le retirer de la liste changerait silencieusement le motif d'une
saisie existante. C'est le contrôle de saisie, puis le serveur, qui demandent
alors de corriger la plage.

La migration est nommée v7_9b et non v7_10 : les fichiers sont joués dans
l'ordre alphabétique, où « v7_10 » passerait avant « v7_8 ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:30:43 +02:00
PTA
935cc3ea1c Retrait des cumuls « Consommation globale » et « Reste à affecter » du tableau de bord
Ces deux cartes additionnaient les heures de tous les agents suivis, quotités
confondues : le pourcentage et le solde obtenus ne décrivaient la situation
d'aucun agent réel et n'appelaient aucune action. Le bloc « Suivi des quotas
annuels » conserve les indicateurs actionnables (agents suivis, agents sans
aucune heure) et le tableau agent par agent.

Les agrégats correspondants sont retirés du payload de api/dashboard_summary.php
et de leur calcul dans DashboardController : plus aucun consommateur ne les lit.
La tuile « Quotas dépassés » et son compteur de dépassements sont inchangés.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:09:15 +02:00
PTA
b13b52b22b Motif de travail par défaut à la saisie et heures travaillées complètes dans les PDF
La liste des motifs d'une nouvelle plage s'ouvrait sur un placeholder vide
« Choisir un motif », alors que le temps de travail est le cas très majoritaire :
elle propose désormais « Heure de travail » (code TRAVAIL), avec repli sur le
premier motif décomptant le quota si le référentiel ne contient pas ce code.

Côté PDF, seul le code générique TRAVAIL était traité comme du temps travaillé.
Les motifs ALP matin, ALP soir, restauration, mercredi périscolaire,
extrascolaire, préparations et ménage de fond tombaient donc :

- dans un total invisible du bilan annuel (ligne « Heures travaillées » à 0 h 00
  alors que les heures étaient bien décomptées plus bas) ;
- dans la couleur grise « Autre absence » des plannings agent et lieu.

La classification est centralisée dans App\Core\MotifCategories, réutilisée par
PtaModel (qui portait déjà la même liste) et par les deux services PDF. Un motif
inconnu du référentiel est rattaché au temps de travail dès lors qu'il décompte
le quota, pour qu'aucun total ne puisse disparaître d'un document.

La ligne « Heures travaillées » détaille en outre les motifs regroupés, et le
total décompté suit maintenant le drapeau compte_dans_quota comme QuotaModel :
il additionnait auparavant les congés, hors quota, en contradiction avec sa
propre note de bas de tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:45:52 +02:00
PTA
13ee16e25d Démarrage de la pile par un simple docker compose up
La pile de test de la branche ne démarrait pas : « Connexion à la base
impossible ». Trois causes cumulées, toutes corrigées.

1. PTA_DB_PORT et DB_PORT étaient passés à 9306. Ces variables désignent le
   port joint À L'INTÉRIEUR du réseau Docker, où MariaDB écoute sur 3306, et
   non un port publié sur l'hôte : la connexion était refusée. Elles reviennent
   à 3306, et le port hôte 9306 est désormais réellement publié par le service
   db (sur la boucle locale), pour un client SQL externe.

2. Le service db-migrate manquait sur la branche. Le schéma d'initialisation ne
   contient aucune des tables et vues d'accès V7.8 : sur un volume neuf,
   l'application refusait de démarrer. db-migrate applique maintenant tous les
   database/migration_*.sql, dont celle du tableau de bord V7.9 — plus aucune
   étape SQL manuelle.

3. Le défaut de PTA_DB_PASS (change_me) différait de celui de MARIADB_PASSWORD
   (pta). Sans fichier .env — et ce répertoire n'en a pas, il est ignoré par
   git — l'authentification échouait. Les deux défauts sont alignés.

Le bandeau du tableau de bord ne prétend plus que « tous les lieux ont des
horaires attendus » quand aucun lieu n'est enregistré.

Vérifié : volume détruit puis « docker compose up -d --build » seul suffit à
obtenir les onze pages en 200, l'atterrissage sur le tableau de bord et les deux
migrations appliquées ; un second up les ignore ; la base répond sur
127.0.0.1:9306 ; la pile principale sur 8080 est restée intacte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:53:19 +02:00
PTA
b68ac15de5 V7.9 — Tableau de bord et navigation regroupée pour le Service Enfance
Le Service Enfance ouvrait onze onglets à plat et atterrissait sur l'écran de
saisie, alors que son travail commence par le contrôle. Les chiffres qui
déclenchent une action vivaient sur trois écrans distincts.

- Nouvelle page dashboard.php, réservée au Service Enfance et devenue sa page
  d'atterrissage : quatre tuiles d'action, les brouillons les plus anciens avec
  ouverture directe sur la bonne semaine, les plages de couverture les plus
  déficitaires, et le suivi des quotas annuels par agent.
- Navigation regroupée par intention (Piloter / Planifier / Consulter /
  Paramétrer). Les rôles à quelques pages gardent la barre simple.
- Sous-titre propre à chaque page : le bandeau générique n'apprenait rien.
- Pastille « À valider » alimentée depuis toutes les pages, et non plus
  seulement depuis la page qu'elle annonce.

Aucune règle métier n'est modifiée : le tableau de bord relit ce que
PlanningModel, CoverageModel et QuotaModel calculent déjà, et son endpoint est
gardé par requireService(). Le droit d'accès à la page vient de la migration
V7.9 ; sans elle, l'onglet n'apparaît pas et homeUrl() se replie sur planning.

Vérifié sur la base réelle : les chiffres de quota du tableau de bord sont
identiques à ceux de api/annual_overview.php, les trois rôles rendent leurs
écrans sans erreur, le rendu JS a été exécuté dans un DOM avec la réponse réelle
de l'API, et la page ne produit aucun attribut style inline (CSP).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:48:50 +02:00
PTA
66cc8c8ade Base de travail V7.8 reprise du répertoire de travail
Commit technique : reproduit à l'identique l'état non commité de la copie
de travail (sécurisation V7.8, CSP à nonce, Session, migration des droits)
afin que le travail d'ergonomie qui suit soit isolable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:32:10 +02:00
60 changed files with 3964 additions and 244 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
# MariaDB
# Remplacer impérativement les deux mots de passe avant tout déploiement.
DB_NAME=pta
DB_USER=pta
DB_PASSWORD=pta
DB_ROOT_PASSWORD=pta
DB_PASSWORD=a_remplacer
DB_ROOT_PASSWORD=a_remplacer_aussi
# Débogage : 1 renvoie le détail des exceptions au navigateur. Laisser à 0.
PTA_DEBUG=0
# phpMyAdmin
# 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_PORT=8081

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# 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/
# Artefacts d'éditeur et de système
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*~

218
CLAUDE.md Normal file
View File

@@ -0,0 +1,218 @@
# 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 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`).
- 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).
### 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,12 @@ 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`.
## 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.
## 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`.

View File

@@ -32,7 +32,12 @@ Application :
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 :
```text

134
README_V7_8.md Normal file
View File

@@ -0,0 +1,134 @@
# 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
1. Sauvegarder la base.
2. Exécuter une seule fois :
```sql
SOURCE database/migration_v7_8_securite_acces.sql;
```
3. Reconstruire les images, phpMyAdmin compris :
```bash
docker compose up -d --build php nginx
docker compose up -d --force-recreate phpmyadmin
```
## 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

@@ -9,6 +9,7 @@ use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\PdfResponse;
use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
@@ -360,7 +361,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$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'] ?? ''));
$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,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'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,
'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,
'formation_repartition_annuelle' => $trainingPlanned ? 1 : 0,
'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]),
]);
@@ -460,7 +463,9 @@ final class AgentController
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$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'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
$rawStructure = $payload['structure_id'] ?? null;
@@ -501,7 +506,7 @@ final class AgentController
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'formation_repartition_annuelle' => $trainingPlanned,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : 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\Controller;
use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\StructureModel;
@@ -23,6 +24,20 @@ final class PageController extends Controller
$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
{
$this->access->requirePage('planning');
@@ -35,6 +50,7 @@ final class PageController extends Controller
$modules[] = 'weekTemplates';
}
$this->renderPage('planning', 'Saisie du planning', 'planning', [
'pageSubtitle' => 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.',
'structures' => $this->loadStructures(),
'motifs' => $this->loadMotifs(),
'currentWeek' => $this->currentWeek(),
@@ -55,6 +71,7 @@ final class PageController extends Controller
{
$this->access->requirePage('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'],
'modules' => ['pendingValidation'],
]);
@@ -65,6 +82,7 @@ final class PageController extends Controller
$this->access->requirePage('coverage');
$forcedStructure = $this->access->role() === Access::ROLE_RESPONSABLE ? $this->access->structureId() : null;
$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(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['coverage.js'],
@@ -81,6 +99,7 @@ final class PageController extends Controller
$this->access->requirePage('structures');
$forcedStructure = $this->access->role() !== Access::ROLE_SERVICE ? $this->access->structureId() : null;
$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(),
'currentWeek' => $this->weekValue($_GET['week'] ?? null) ?: $this->currentWeek(),
'scripts' => ['structure-overview.js'],
@@ -97,6 +116,9 @@ final class PageController extends Controller
$this->access->requirePage('agents');
$forcedAgent = $this->access->role() === Access::ROLE_AGENT ? $this->access->agentId() : null;
$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(),
'structures' => $this->loadStructuresForAgentEditor(),
'motifs' => $this->loadMotifs(),
@@ -114,6 +136,7 @@ final class PageController extends Controller
{
$this->access->requirePage('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(),
'scripts' => ['annual-overview.js'],
'modules' => ['annualOverview'],
@@ -128,6 +151,7 @@ final class PageController extends Controller
{
$this->access->requirePage('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(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['pta.js'],
@@ -143,6 +167,7 @@ final class PageController extends Controller
{
$this->access->requirePage('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(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'scripts' => ['teams.js'],
@@ -158,6 +183,7 @@ final class PageController extends Controller
{
$this->access->requirePage('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'],
'modules' => ['vacationCalendar'],
]);
@@ -167,6 +193,7 @@ final class PageController extends Controller
{
$this->access->requirePage('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(),
'structures' => (new StructureModel($this->pdo))->allActive(),
'posts' => (new AgentModel($this->pdo))->allPosts(),
@@ -175,12 +202,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
{
Security::sendHtmlHeaders();
$profile = $this->access->profile();
$this->render('pages/' . $view, array_merge([
'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,
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'scripts' => [],
'modules' => [],

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\MotifCategories;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\MotifModel;
@@ -98,7 +99,8 @@ final class PlanningController
if (!is_array($rawEntries)) {
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.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
@@ -107,7 +109,10 @@ final class PlanningController
[$year, $weekNumber] = $week;
$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->assertNoOtherLocationOverlap(
$entries,
@@ -169,7 +174,12 @@ final class PlanningController
if ($this->structureModel->findActive((int) $structureId) === null) {
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.']);
}
@@ -550,6 +560,48 @@ final class PlanningController
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
{
$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\Controller;
use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\StructureModel;
use PDO;
@@ -21,8 +22,13 @@ final class RoleController extends Controller
{
$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') {
$provided = (string) ($_GET['csrf_token'] ?? '');
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
$access->clear();
}
header('Location: role.php', true, 302);
exit;
}
@@ -47,8 +53,10 @@ final class RoleController extends Controller
}
}
Security::sendHtmlHeaders();
$this->render('pages/role', [
'pageTitle' => 'Choisir votre accès',
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),

View File

@@ -6,6 +6,14 @@ namespace App\Core;
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
{
public const ROLE_AGENT = 'AGENT';
@@ -14,11 +22,15 @@ final class 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)
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
Session::start();
}
public function isConfigured(): bool
@@ -85,13 +97,16 @@ final class Access
$selection['structure_id'] = $structureId;
}
$_SESSION[self::SESSION_KEY] = $selection;
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
{
unset($_SESSION[self::SESSION_KEY]);
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true);
}
@@ -128,39 +143,61 @@ final class Access
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
{
$role = $this->role();
$service = $role === self::ROLE_SERVICE;
$responsable = $role === self::ROLE_RESPONSABLE;
return [
'read_only' => $role === self::ROLE_AGENT,
'can_edit_draft' => $service || $responsable,
'can_edit_cross_structure' => $service,
'can_validate' => $service,
'can_reopen' => $service || $responsable,
'can_use_templates' => $service,
'can_copy_full_week' => $service,
'can_view_pending' => $service,
'can_manage_coverage' => $service,
'can_manage_agents' => $service,
'can_manage_structures' => $service,
'can_manage_vacations' => $service,
'can_manage_teams' => $service,
'can_manage_pta' => $service,
'can_forecast_next_year' => $service,
];
if ($role === null) {
return [];
}
if (!isset($this->permissionCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
);
$stmt->execute(['role' => $role]);
$permissions = [];
foreach ($stmt->fetchAll() as $row) {
$permissions[(string) $row['permission']] = (bool) $row['autorise'];
}
$this->permissionCache[$role] = $permissions;
}
return $this->permissionCache[$role];
}
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
{
return match ($this->role()) {
self::ROLE_AGENT => ['agents', 'structures'],
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
default => [],
};
$role = $this->role();
if ($role === null) {
return [];
}
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
@@ -168,12 +205,21 @@ final class Access
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
{
return match ($this->role()) {
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
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',
};
}
@@ -248,10 +294,7 @@ final class Access
return;
}
$stmt = $this->pdo->prepare(
'SELECT p.id_structure
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
WHERE c.id_creneau = :id LIMIT 1'
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
);
$stmt->execute(['id' => $entryId]);
$structureId = $stmt->fetchColumn();
@@ -267,7 +310,9 @@ final class Access
if ($this->role() === self::ROLE_SERVICE) {
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]);
$structureId = $stmt->fetchColumn();
if ($structureId === false) {
@@ -293,14 +338,14 @@ final class Access
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]);
return $stmt->fetchColumn() !== false;
}
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]);
return $stmt->fetchColumn() !== false;
}
@@ -312,11 +357,9 @@ final class Access
return null;
}
$stmt = $this->pdo->prepare(
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
s.nom AS structure_nom
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
FROM v_acces_identite_agent
WHERE id_agent = :id LIMIT 1'
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
@@ -330,8 +373,8 @@ final class Access
}
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom
FROM structure
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
FROM v_acces_identite_structure
WHERE id_structure = :id LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
return $stmt->fetch() ?: null;
@@ -344,25 +387,11 @@ final class Access
return false;
}
$stmt = $this->pdo->prepare(
'SELECT 1
FROM agent a
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
)
)
'SELECT 1 FROM v_acces_agent_par_structure
WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
LIMIT 1'
);
$stmt->execute([
'agent_id' => $agentId,
'structure_id_default' => $structureId,
'structure_id_planning' => $structureId,
]);
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
return $stmt->fetchColumn() !== false;
}
}

View File

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

View File

@@ -21,18 +21,14 @@ final class JsonResponse
});
set_exception_handler(static function (\Throwable $exception): void {
error_log(sprintf(
'[PTA API] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
// Security::reportThrowable journalise systématiquement le détail
// côté serveur et ne le renvoie au client que si PTA_DEBUG est
// explicitement activé. Par défaut, rien ne fuit.
$payload = ['error' => 'Une erreur serveur est survenue.'];
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
if (Security::debugEnabled()) {
$payload['details'] = $exception->getMessage();
}
Security::reportThrowable($exception, 'Erreur API.');
self::send(500, $payload);
});
@@ -42,6 +38,8 @@ final class JsonResponse
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
}
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
{
/** 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'
SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.email,
a.telephone, a.adresse, a.est_diplome, a.diplome_libelle,
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,
po.famille AS poste_famille, po.heures_mercredi_minutes,
po.heures_extrascolaire_minutes, po.autorise_preparation,
@@ -129,7 +152,9 @@ final class AgentModel extends BaseModel
$periodEnd = new DateTimeImmutable(sprintf('%04d-12-31', $year));
$quotaReference = QuotaModel::REFERENCE_MINUTES;
$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 {
$this->pdo->beginTransaction();
@@ -139,13 +164,13 @@ final class AgentModel extends BaseModel
matricule, nom, prenom, email, telephone, adresse,
est_diplome, diplome_libelle, date_debut_contrat,
id_poste, type_contrat, date_fin_contrat,
formation_repartition_annuelle, commentaire_pta,
droit_enveloppe_formation, commentaire_pta,
id_structure, actif
) VALUES (
:matricule, :nom, :prenom, :email, :telephone, :adresse,
:est_diplome, :diplome_libelle, :date_debut_contrat,
:id_poste, :type_contrat, :date_fin_contrat,
:formation_repartition_annuelle, :commentaire_pta,
:droit_enveloppe_formation, :commentaire_pta,
:structure_id, TRUE
)'
);
@@ -162,7 +187,7 @@ final class AgentModel extends BaseModel
'id_poste' => $data['id_poste'],
'type_contrat' => $data['type_contrat'],
'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'],
'structure_id' => $data['structure_id'],
]);
@@ -260,7 +285,7 @@ final class AgentModel extends BaseModel
id_poste = :id_poste,
type_contrat = :type_contrat,
date_fin_contrat = :date_fin_contrat,
formation_repartition_annuelle = :formation_repartition_annuelle,
droit_enveloppe_formation = :droit_enveloppe_formation,
commentaire_pta = :commentaire_pta,
id_structure = :structure_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(':type_contrat', $data['type_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']);
if ($newStructureId === 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;
use App\Core\MotifCategories;
final class MotifModel extends BaseModel
{
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)
);
}
/**
* 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;
use App\Core\MotifCategories;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
@@ -12,12 +13,8 @@ use RuntimeException;
final class PtaModel extends BaseModel
{
private const WORK_CODES = [
'TRAVAIL', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR',
'MERCREDI_PERISCOLAIRE', 'EXTRASCOLAIRE',
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE',
'PREPARATION_LUNDI', 'MENAGE_FOND', 'JOUR_FRACTIONNEMENT',
];
/** Référentiel partagé avec les PDF : voir App\Core\MotifCategories. */
private const WORK_CODES = MotifCategories::WORK_CODES;
private const PREPARATION_CODES = [
'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI',
@@ -148,7 +145,7 @@ final class PtaModel extends BaseModel
return $pta;
}
$training = !empty($agent['formation_repartition_annuelle']) ? 0 : 840;
$training = AgentModel::trainingEnvelopeMinutes($agent);
$insert = $this->pdo->prepare(
"INSERT INTO pta_annuel (
id_agent, date_debut, date_fin, quotite_travail,
@@ -381,7 +378,13 @@ final class PtaModel extends BaseModel
}
$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) {
$controls[] = $this->control('ALERTE', 'FORMATION_DEPASSEE', sprintf(
'Formation : %s planifiées pour une enveloppe complémentaire de 14 h.',

View File

@@ -52,7 +52,7 @@ final class SchemaModel extends BaseModel
$requiredColumns = [
'creneau_horaire' => ['id_motif'],
'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'],
];
@@ -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

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Core\MotifCategories;
use DateTimeImmutable;
use DateTimeZone;
@@ -66,11 +67,11 @@ final class AgentTimeSummaryPdfService
$rowH = 43.0;
$rows = [
['TRAVAIL', 'Heures travaillées', $totals['TRAVAIL']],
['MALADIE', 'Absence', $totals['MALADIE']],
['FORMATION', 'Formation', $totals['FORMATION']],
['CONGE', 'Congé', $totals['CONGE']],
['AUTRE', 'Absence (non décomptée)', $totals['AUTRE']],
[MotifCategories::TRAVAIL, 'Heures travaillées', $totals['TRAVAIL'], $totals['DETAIL_TRAVAIL']],
[MotifCategories::ABSENCE, 'Absence', $totals['ABSENCE'], []],
[MotifCategories::FORMATION, 'Formation', $totals['FORMATION'], []],
[MotifCategories::CONGE, 'Congé', $totals['CONGE'], []],
[MotifCategories::NON_DECOMPTE, 'Absence (non décomptée)', $totals['NON_DECOMPTE'], []],
];
$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);
$cursorY = $y + $headerH;
foreach ($rows as [$code, $label, $minutes]) {
foreach ($rows as [$code, $label, $minutes, $detail]) {
$palette = $this->palette($code);
$pdf->line($x, $cursorY, $x + $w, $cursorY, 0.83);
$pdf->fillRectColor($x + 10, $cursorY + 11, 10, 20, $palette['accent']);
$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;
}
}
/**
* 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
{
$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);
$remaining = $target - $counted;
$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
{
$totals = [
'TRAVAIL' => 0,
'MALADIE' => 0,
'FORMATION' => 0,
'CONGE' => 0,
'AUTRE' => 0,
'OTHER_COUNTED' => 0,
MotifCategories::TRAVAIL => 0,
MotifCategories::ABSENCE => 0,
MotifCategories::FORMATION => 0,
MotifCategories::CONGE => 0,
MotifCategories::NON_DECOMPTE => 0,
'DECOMPTE' => 0,
'DETAIL_TRAVAIL' => [],
];
foreach ($rows as $row) {
$code = strtoupper((string) ($row['motif_code'] ?? 'AUTRE'));
$code = MotifCategories::normalizeCode($row['motif_code'] ?? null);
$minutes = (int) ($row['minutes_total'] ?? 0);
if (array_key_exists($code, $totals) && $code !== 'OTHER_COUNTED') {
$totals[$code] += $minutes;
continue;
$counts = (int) ($row['compte_dans_quota'] ?? 0) === 1;
$category = MotifCategories::category($code, $counts);
$totals[$category] += $minutes;
if ($counts) {
$totals['DECOMPTE'] += $minutes;
}
if ((int) ($row['compte_dans_quota'] ?? 0) === 1) {
$totals['OTHER_COUNTED'] += $minutes;
} else {
$totals['AUTRE'] += $minutes;
if ($category === MotifCategories::TRAVAIL && $minutes > 0) {
$label = trim((string) ($row['motif_libelle'] ?? '')) ?: $code;
$totals['DETAIL_TRAVAIL'][$label] = ($totals['DETAIL_TRAVAIL'][$label] ?? 0) + $minutes;
}
}
@@ -167,10 +234,10 @@ final class AgentTimeSummaryPdfService
private function palette(string $code): array
{
return match (strtoupper($code)) {
'TRAVAIL' => ['accent' => '#2563EB'],
'FORMATION' => ['accent' => '#7C3AED'],
'CONGE' => ['accent' => '#059669'],
'MALADIE' => ['accent' => '#DC2626'],
MotifCategories::TRAVAIL => ['accent' => '#2563EB'],
MotifCategories::FORMATION => ['accent' => '#7C3AED'],
MotifCategories::CONGE => ['accent' => '#059669'],
MotifCategories::ABSENCE, 'MALADIE' => ['accent' => '#DC2626'],
default => ['accent' => '#64748B'],
};
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Core\MotifCategories;
use DateTimeImmutable;
final class PlanningPdfService
@@ -170,7 +171,7 @@ final class PlanningPdfService
$blockY = $y + 5;
foreach ($blocks as $block) {
$entry = $block['entry'];
$palette = $this->motifPalette((string) ($entry['motif_code'] ?? 'AUTRE'));
$palette = $this->entryPalette($entry);
$blockHeight = (float) $block['height'];
$pdf->fillRectColor($cellX + 4, $blockY, $dayWidth - 8, $blockHeight, $palette['background']);
$pdf->fillRectColor($cellX + 4, $blockY, 3.0, $blockHeight, $palette['accent']);
@@ -269,7 +270,7 @@ final class PlanningPdfService
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, 3.0, $cardHeight, $palette['accent']);
@@ -320,21 +321,35 @@ final class PlanningPdfService
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
{
return strtoupper((string) ($entry['motif_code'] ?? '')) === 'TRAVAIL'
return MotifCategories::normalizeCode($entry['motif_code'] ?? null) === 'TRAVAIL'
? ''
: (string) ($entry['motif_libelle'] ?? 'Affectation');
}
/** @return array{accent:string,background:string,label:string} */
private function motifPalette(string $code): array
private function entryPalette(array $entry): array
{
return match (strtoupper($code)) {
'TRAVAIL' => ['accent' => '#2563EB', 'background' => '#EFF6FF', 'label' => 'Travail'],
'FORMATION' => ['accent' => '#7C3AED', 'background' => '#F5F3FF', 'label' => 'Formation'],
'CONGE' => ['accent' => '#059669', 'background' => '#ECFDF5', 'label' => 'Conge'],
'MALADIE' => ['accent' => '#DC2626', 'background' => '#FEF2F2', 'label' => 'Absence'],
return $this->motifPalette(
(string) ($entry['motif_code'] ?? 'AUTRE'),
(int) ($entry['compte_dans_quota'] ?? 0) === 1
);
}
/** @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'],
};
}
@@ -342,18 +357,20 @@ final class PlanningPdfService
private function drawLegend(SimplePdf $pdf, float $x, float $y): void
{
$items = [
['TRAVAIL', 'Travail'],
['TRAVAIL', 'Travail (dont ALP, restauration, preparation)'],
['FORMATION', 'Formation'],
['CONGE', 'Conge'],
['MALADIE', 'Absence'],
['AUTRE', 'Autre absence'],
['ABSENCE', 'Absence'],
['JNT', 'Autre absence'],
];
foreach ($items as [$code, $label]) {
$palette = $this->motifPalette($code);
$pdf->fillRectColor($x, $y - 7, 8, 8, $palette['accent']);
$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 $accessProfile */
/** @var array $permissions */
/** @var string $cspNonce */
?>
</main>
<script>
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
window.PTA_CONFIG = {
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
@@ -27,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/core.js?v=7.7"></script>
<script src="assets/js/core.js?v=7.14"></script>
<?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.14"></script>
<?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
<script src="assets/js/page-bootstrap.js?v=7.14"></script>
</body>
</html>

View File

@@ -1,21 +1,73 @@
<?php
/** @var string $pageTitle */
/** @var string $pageSubtitle */
/** @var string $activePage */
/** @var array $allowedPages */
/** @var array $accessProfile */
/** @var string $csrfToken */
$isAgent = ($accessProfile['role'] ?? '') === 'AGENT';
$navigation = [
'dashboard' => ['dashboard.php', 'Tableau de bord'],
'planning' => ['planning.php', 'Saisie'],
'pending' => ['pending.php', 'À valider'],
'coverage' => ['coverage.php', 'Couverture'],
'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'],
'pta' => ['pta.php', 'PTA détaillé'],
'teams' => ['teams.php', 'Équipes'],
'vacations' => ['vacations.php', 'Vacances'],
'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>
<html lang="fr">
@@ -23,15 +75,15 @@ $navigation = [
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.7">
<link rel="stylesheet" href="assets/style.css?v=7.14">
</head>
<body>
<main class="app-shell">
<header class="page-header">
<div>
<div class="page-header-identity">
<p class="eyebrow">PTA</p>
<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 class="header-actions">
<?php if ($activePage === 'planning'): ?>
@@ -40,11 +92,11 @@ $navigation = [
<div class="access-profile-badge">
<span><?= htmlspecialchars((string) ($accessProfile['role_label'] ?? '')) ?></span>
<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>
<?php endif; ?>
</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>
</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>
<?php endif; ?>
<nav class="view-tabs" aria-label="Navigation principale">
<?php foreach ($navigation as $key => [$href, $label]): ?>
<?php if (!in_array($key, $allowedPages, true)) continue; ?>
<nav class="view-tabs<?= $showGroupLabels ? ' view-tabs-grouped' : '' ?>" aria-label="Navigation principale">
<?php foreach ($visibleGroups as $groupLabel => $keys): ?>
<div class="view-tab-group" role="group"<?= $showGroupLabels ? ' aria-label="' . htmlspecialchars($groupLabel, ENT_QUOTES) . '"' : '' ?>>
<?php if ($showGroupLabels): ?>
<span class="view-tab-group-label"><?= htmlspecialchars($groupLabel) ?></span>
<?php endif; ?>
<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 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>

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 string $error */
/** @var array|null $currentProfile */
/** @var string $cspNonce */
?>
<!doctype html>
<html lang="fr">
@@ -11,7 +12,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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.14">
</head>
<body class="role-page-body">
<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é.
</aside>
</main>
<script>
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
(() => {
const agentContext = document.querySelector('#agent-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">
</label>
<label class="check-field admin-field-wide">
<input id="new-agent-training-planned" type="checkbox">
<span>Une formation est déjà prévue dans la répartition annuelle : ne pas ajouter l’enveloppe complémentaire de 14 h</span>
<input id="new-agent-training-right" type="checkbox" checked>
<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 class="field admin-field-wide">
<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-id-poste="<?= (int) ($agent['id_poste'] ?? 0) ?>"
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-structure-id="<?= (int) ($agent['id_structure'] ?? 0) ?>"
>
@@ -326,8 +326,8 @@
<input id="edit-agent-profile-contract-end" type="date">
</label>
<label class="check-field admin-field-wide">
<input id="edit-agent-profile-training-planned" type="checkbox">
<span>Formation déjà intégrée dans la répartition annuelle</span>
<input id="edit-agent-profile-training-right" type="checkbox">
<span>Droit à l’enveloppe de formation de 14 h</span>
</label>
<label class="field admin-field-wide">
<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

@@ -10,7 +10,7 @@ $defaultCalendarYear = $currentYear;
<div class="section-heading">
<div>
<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>
@@ -21,7 +21,7 @@ $defaultCalendarYear = $currentYear;
<div class="selection-grid vacation-filters">
<label class="field">
<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">
<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">
@@ -45,8 +45,8 @@ $defaultCalendarYear = $currentYear;
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
<div class="section-heading compact-heading">
<div>
<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>
<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>
</div>
<label class="vacation-select-all">
<input id="vacation-select-all" type="checkbox">

View File

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

View File

@@ -211,7 +211,7 @@
row.dataset.dateFinContrat = agent.date_fin_contrat || '';
row.dataset.idPoste = agent.id_poste ? String(Number(agent.id_poste)) : '';
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.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-post').value = row.dataset.idPoste || '';
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-structure').value = row.dataset.structureId || '';
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,
id_poste: Number(document.querySelector('#edit-agent-profile-post').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,
structure_id: structureValue ? Number(structureValue) : null,
}),
@@ -407,7 +407,7 @@
date_fin_contrat: document.querySelector('#new-agent-contract-end').value,
id_poste: Number(document.querySelector('#new-agent-post').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,
}),
});
@@ -419,7 +419,6 @@
form.reset();
document.querySelector('#new-agent-rate').value = '100';
document.querySelector('#new-agent-contract-type').value = 'PERMANENT';
document.querySelector('#new-agent-training-planned').checked = false;
document.querySelector('#new-agent-qualified').value = '0';
toggleDiplomaField(document.querySelector('#new-agent-qualified'), document.querySelector('#new-agent-diploma'));

View File

@@ -254,6 +254,28 @@
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) {
const entry = findEntry(entryId);
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-date').value = entry.date_jour;
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-start').value = entry.heure_debut;
document.querySelector('#edit-agent-entry-end').value = entry.heure_fin;

View File

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

View File

@@ -31,6 +31,7 @@
entries: [],
otherStructureEntries: [],
currentStructure: null,
currentAgent: null,
planningId: null,
planningStatus: null,
weekDays: [],
@@ -44,10 +45,21 @@
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) {
const div = document.createElement('div');
div.textContent = String(value ?? '');
return div.innerHTML;
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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

@@ -73,8 +73,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();
initialiseStructureOverview();
initialiseAgentOverview();
initialiseCoverage();
refreshNavigationCounters();
})();

View File

@@ -110,8 +110,37 @@
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) {
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) => {
const selected = Number(motif.id) === Number(selectedMotifId) ? ' selected' : '';
const quotaLabel = motif.quota ? ' · décompte quota' : ' · hors quota';
@@ -122,13 +151,16 @@
function timeRangeHtml(dayLabel, index = 0, selectedMotifId = null) {
const suffix = `${dayLabel} plage ${index + 1}`;
const fallbackMotifId = defaultMotifId();
const motifId = selectedMotifId ?? fallbackMotifId;
const placeholder = fallbackMotifId === null ? '<option value="">Choisir un motif</option>' : '';
return `
<div class="weekly-time-range">
<label class="weekly-range-motif-field">
<span>Motif</span>
<select class="weekly-range-motif" aria-label="Motif ${escapeHtml(suffix)}">
<option value="">Choisir un motif</option>
${motifOptionsHtml(selectedMotifId)}
${placeholder}
${motifOptionsHtml(motifId)}
</select>
</label>
<label>
@@ -649,6 +681,7 @@
state.entries = [];
state.otherStructureEntries = [];
state.currentStructure = null;
state.currentAgent = null;
state.planningId = null;
state.quota = null;
state.savedCurrentCountedMinutes = 0;
@@ -710,6 +743,7 @@
state.entries = [];
state.otherStructureEntries = [];
state.currentStructure = null;
state.currentAgent = null;
state.planningId = null;
state.savedCurrentCountedMinutes = 0;
setStatus(null);
@@ -728,6 +762,7 @@
state.quota = data.quota || null;
state.currentStructure = data.current_structure || null;
state.currentAgent = data.agent || null;
const calendarByDate = new Map((data.calendar_days || []).map((day) => [day.date, day]));
state.weekDays = state.weekDays.map((day) => ({ ...day, ...(calendarByDate.get(day.date) || {}) }));
state.otherStructureEntries = (data.other_entries || []).map((entry) => ({
@@ -819,6 +854,11 @@
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) {
errors.push(`${day?.label || row.dataset.date} ${label} : renseignez l’heure de début et l’heure de fin.`);
return;

View File

@@ -195,7 +195,7 @@
templateSelect.disabled = false;
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('');
renderSelectedTemplateDetails();
}
@@ -364,7 +364,7 @@
}
periodTemplateSelect.disabled = false;
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('');
}

View File

@@ -2553,3 +2553,466 @@ textarea:disabled {
@media print {
.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; }
}

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,
type_contrat ENUM('PERMANENT', 'TEMPORAIRE') NOT NULL DEFAULT 'PERMANENT',
date_fin_contrat DATE NULL,
formation_repartition_annuelle BOOLEAN NOT NULL DEFAULT FALSE,
commentaire_pta VARCHAR(1000) NULL,
id_structure INT UNSIGNED NULL COMMENT 'Lieu principal de rattachement',
actif BOOLEAN NOT NULL DEFAULT TRUE,

6
db.php
View File

@@ -20,8 +20,12 @@ try {
PDO::ATTR_EMULATE_PREPARES => false,
]);
} 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(
'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,
$e
);

View File

@@ -1,7 +1,36 @@
# ============================================================================
# Pile de test de la branche V7.9.
# ============================================================================
# Noms de conteneurs, réseau, volume et ports publiés sont suffixés « test » :
# cette pile tourne EN PARALLÈLE de la pile principale (pta-db, pta-php, …)
# sans lui prendre ni ses conteneurs, ni ses données, ni ses ports.
#
# `docker compose up -d --build` suffit : le service db-migrate applique
# lui-même les migrations SQL, dont celle du tableau de bord V7.9.
#
# Ce répertoire n'a volontairement pas de fichier .env (il est ignoré par git) :
# les valeurs par défaut ci-dessous s'appliquent donc telles quelles, et les
# ports publiés (9080 / 9081 / 9306) ne peuvent pas entrer en conflit avec la
# pile principale (8080 / 8081). Si vous y copiez le .env de la pile principale,
# pensez à y changer APP_PORT et PMA_PORT.
#
# ATTENTION — deux familles de ports à ne pas confondre :
#
# · ports PUBLIÉS sur la machine hôte, dans les blocs `ports:` :
# 9080 application 9081 phpMyAdmin 9306 MariaDB
# Ceux-là se changent librement, ce sont eux qu'on tape dans un navigateur
# ou dans un client SQL.
#
# · ports JOINTS À L'INTÉRIEUR du réseau Docker, dans les blocs
# `environment:` (PTA_DB_PORT, DB_PORT, PMA_PORT) : MariaDB y écoute
# TOUJOURS sur 3306, quel que soit le port publié. Y mettre autre chose
# coupe l'accès à la base et l'application renvoie « Connexion à la base
# impossible ».
# ============================================================================
services:
db:
image: mariadb:11.4
container_name: pta-db
container_name: pta-db-test
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME:-pta}
@@ -13,9 +42,14 @@ services:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mariadb_data:/var/lib/mysql
- mariadb_data_test:/var/lib/mysql
# Ce fichier n'est exécuté QUE lors de l'initialisation d'un volume vide.
- ./database/pta_prod_schema_mariadb.sql:/docker-entrypoint-initdb.d/01-pta-schema.sql:ro
ports:
# Base joignable depuis la machine hôte pour un client SQL externe, sur la
# boucle locale uniquement. À l'intérieur du réseau Docker, les services
# continuent de joindre MariaDB sur son port natif 3306.
- "${DB_BIND:-127.0.0.1}:${DB_PORT_HOST:-9306}:3306"
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -p\"$${MARIADB_ROOT_PASSWORD}\" --silent"]
interval: 10s
@@ -23,65 +57,108 @@ services:
retries: 10
start_period: 20s
networks:
- pta
- pta-test
# 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-test
restart: "no"
environment:
DB_HOST: db
# Port joint À L'INTÉRIEUR du réseau Docker : MariaDB y écoute sur 3306.
# Le port publié sur l'hôte (9306) n'a pas cours ici.
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-test
php:
build:
context: .
dockerfile: Dockerfile
target: php
container_name: pta-php
container_name: pta-php-test
restart: unless-stopped
environment:
PTA_DB_HOST: db
# Port joint À L'INTÉRIEUR du réseau Docker, où MariaDB écoute sur 3306.
# Ce n'est pas le port publié sur l'hôte : pour joindre la base depuis la
# machine, utiliser 127.0.0.1:9306, publié par le service db ci-dessus.
PTA_DB_PORT: 3306
PTA_DB_NAME: ${DB_NAME:-pta}
PTA_DB_USER: ${DB_USER:-pta}
PTA_DB_PASS: ${DB_PASSWORD:-change_me}
# Le défaut doit rester identique à MARIADB_PASSWORD du service db :
# deux défauts différents rendent la pile inutilisable sans fichier .env.
PTA_DB_PASS: ${DB_PASSWORD:-pta}
# 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}
depends_on:
db:
condition: service_healthy
db-migrate:
condition: service_completed_successfully
networks:
- pta
- pta-test
nginx:
build:
context: .
dockerfile: Dockerfile
target: nginx
container_name: pta-nginx
container_name: pta-nginx-test
restart: unless-stopped
ports:
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-8080}:80"
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-9080}:80"
depends_on:
- php
networks:
- pta
- pta-test
phpmyadmin:
image: phpmyadmin:5.2.2
container_name: pta-phpmyadmin
container_name: pta-phpmyadmin-test
restart: unless-stopped
environment:
PMA_HOST: db
# Port interne au réseau Docker, jamais un port publié.
PMA_PORT: 3306
PMA_USER: ${DB_USER:-pta}
PMA_PASSWORD: ${DB_PASSWORD:-change_me}
# PMA_USER / PMA_PASSWORD sont volontairement absents : leur présence fait
# 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
TZ: ${TZ:-Europe/Paris}
ports:
# Par défaut phpMyAdmin n'est accessible que depuis la machine hôte.
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-8081}:80"
# phpMyAdmin n'écoute que sur la boucle locale : il donne un accès complet
# à la base. Pour y accéder à distance, passer par un tunnel SSH
# (voir README_DOCKER.md), plutôt que d'ouvrir ce port sur le réseau.
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-9081}:80"
depends_on:
db:
condition: service_healthy
networks:
- pta
- pta-test
networks:
pta:
pta-test:
driver: bridge
volumes:
mariadb_data:
mariadb_data_test:

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;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$query_string;
# Ne pas annoncer la version du serveur.
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$ {
@@ -23,33 +82,13 @@ server {
fastcgi_param DOCUMENT_ROOT $document_root;
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;
}
# Les classes, modèles et fichiers SQL ne doivent jamais être servis directement.
location ^~ /app/ {
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;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}

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';
use App\Controllers\PageController;
use App\Core\Security;
function runPage(string $method): void
{
@@ -17,10 +18,14 @@ function runPage(string $method): void
$controller->{$method}();
} catch (Throwable $e) {
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 '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
echo '<h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
echo '<p>Vérifiez <code>config.php</code> et le schéma de la base de données.</p></body></html>';
echo '<body><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>';
}
}

View File

@@ -5,13 +5,20 @@ declare(strict_types=1);
require_once __DIR__ . '/app/autoload.php';
use App\Controllers\RoleController;
use App\Core\Security;
try {
$pdo = require __DIR__ . '/app/bootstrap.php';
(new RoleController($pdo))->index();
} catch (Throwable $e) {
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 '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
echo '<h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</p></body></html>';
echo '<body><h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . $message . '</p>';
echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
}