From 4201894e28dfdd8e11d825b72d9ea5b2bbeb963c Mon Sep 17 00:00:00 2001 From: PTA Date: Mon, 17 Aug 2026 16:53:04 +0200 Subject: [PATCH] =?UTF-8?q?V7.10=20=E2=80=94=20Tableau=20de=20bord,=20guid?= =?UTF-8?q?e,=20semaine=20type=20et=20enveloppe=20de=20formation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 22 + .env.example | 9 +- .gitignore | 31 + CLAUDE.md | 235 +++++++ README.md | 7 + README_DOCKER.md | 39 +- README_V7_10.md | 89 +++ README_V7_8.md | 139 ++++ README_V7_9.md | 118 ++++ api/dashboard_summary.php | 12 + api/save_week_template_entries.php | 12 + api/week_template.php | 12 + app/Controllers/AgentController.php | 17 +- app/Controllers/DashboardController.php | 302 +++++++++ app/Controllers/PageController.php | 51 ++ app/Controllers/PlanningController.php | 58 +- app/Controllers/RoleController.php | 10 +- app/Controllers/WeekTemplateController.php | 117 ++++ app/Core/Access.php | 153 +++-- app/Core/Csrf.php | 4 +- app/Core/JsonResponse.php | 16 +- app/Core/MotifCategories.php | 95 +++ app/Core/Security.php | 89 +++ app/Core/Session.php | 59 ++ app/Models/AgentModel.php | 39 +- app/Models/DashboardModel.php | 183 ++++++ app/Models/MotifModel.php | 27 + app/Models/PtaModel.php | 19 +- app/Models/QuotaModel.php | 10 + app/Models/SchemaModel.php | 40 +- app/Models/WeekTemplateModel.php | 171 ++++- app/Services/AgentTimeSummaryPdfService.php | 121 +++- app/Services/PlanningPdfService.php | 43 +- app/Views/layouts/footer.php | 9 +- app/Views/layouts/header.php | 95 ++- app/Views/pages/dashboard.php | 4 + app/Views/pages/faq.php | 4 + app/Views/pages/role.php | 5 +- app/Views/partials/administration.php | 10 +- app/Views/partials/dashboard.php | 154 +++++ app/Views/partials/faq.php | 412 ++++++++++++ app/Views/partials/planning_editor.php | 90 ++- app/Views/partials/vacation_calendar.php | 8 +- app/bootstrap.php | 5 +- assets/js/administration.js | 9 +- assets/js/agent-overview.js | 23 + assets/js/annual-overview.js | 9 +- assets/js/core.js | 18 +- assets/js/dashboard.js | 325 ++++++++++ assets/js/page-bootstrap.js | 40 ++ assets/js/planning-editor.js | 44 +- assets/js/pta.js | 17 +- assets/js/week-template-editor.js | 579 +++++++++++++++++ assets/js/week-templates.js | 23 +- assets/style.css | 609 ++++++++++++++++++ dashboard.php | 7 + database/migration_v7_8_securite_acces.sql | 201 ++++++ database/migration_v7_9_tableau_de_bord.sql | 26 + ...ration_v7_9b_droit_enveloppe_formation.sql | 23 + ...on_v7_9c_retrait_formation_repartition.sql | 17 + database/migration_v7_9d_faq.sql | 30 + database/pta_prod_schema_mariadb.sql | 4 +- db.php | 6 +- docker-compose.yml | 42 +- docker/db/apply-migrations.sh | 153 +++++ docker/nginx/default.conf | 93 ++- faq.php | 7 + page_bootstrap.php | 13 +- role.php | 11 +- 69 files changed, 5228 insertions(+), 246 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README_V7_10.md create mode 100644 README_V7_8.md create mode 100644 README_V7_9.md create mode 100644 api/dashboard_summary.php create mode 100644 api/save_week_template_entries.php create mode 100644 api/week_template.php create mode 100644 app/Controllers/DashboardController.php create mode 100644 app/Core/MotifCategories.php create mode 100644 app/Core/Security.php create mode 100644 app/Core/Session.php create mode 100644 app/Models/DashboardModel.php create mode 100644 app/Views/pages/dashboard.php create mode 100644 app/Views/pages/faq.php create mode 100644 app/Views/partials/dashboard.php create mode 100644 app/Views/partials/faq.php create mode 100644 assets/js/dashboard.js create mode 100644 assets/js/week-template-editor.js create mode 100644 dashboard.php create mode 100644 database/migration_v7_8_securite_acces.sql create mode 100644 database/migration_v7_9_tableau_de_bord.sql create mode 100644 database/migration_v7_9b_droit_enveloppe_formation.sql create mode 100644 database/migration_v7_9c_retrait_formation_repartition.sql create mode 100644 database/migration_v7_9d_faq.sql create mode 100755 docker/db/apply-migrations.sh create mode 100644 faq.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..624824f --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 0f3a3a4..a6789ca 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf921d --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Configuration locale : contient les mots de passe MariaDB. +# Seul .env.example doit être versionné. +.env +.env.* +!.env.example + +# Dumps et sauvegardes de base +*.sql.gz +*.dump +database/backup/ +database/*_backup*.sql + +# Journaux et fichiers temporaires +*.log +logs/ +tmp/ + +# Dépendances éventuelles +/vendor/ +/node_modules/ + +# Worktrees de travail Claude Code : copies du dépôt, jamais à versionner. +.claude/worktrees/ + +# Artefacts d'éditeur et de système +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*~ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c4bfd4b --- /dev/null +++ b/CLAUDE.md @@ -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 ` - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index deb7fae..1e37ced 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -1,21 +1,73 @@ ['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); ?> @@ -23,15 +75,15 @@ $navigation = [ <?= htmlspecialchars($pageTitle) ?> — PTA - +
@@ -52,12 +104,29 @@ $navigation = [
Cette page n’est pas disponible avec votre rôle actuel.
-