Files
pta/CLAUDE.md
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

198 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`). Sur une base existante, toute évolution de schéma doit être
appliquée manuellement en SQL — il n'y a pas d'outil de migration.
## 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.
### 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.