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