'), qui appelle la * méthode homonyme de cette classe. Le NOM DE LA MÉTHODE fait donc trois choses * à la fois : il est la route, la clé de page dans acces_role_page, et la valeur * de $activePage qui surligne l'onglet courant. * * Ajouter une page suppose donc, ensemble : * 1. un fichier mince à la racine ; * 2. une méthode ici ; * 3. une vue app/Views/pages/ et son partiel app/Views/partials/ ; * 4. un module JS ; * 5. l'inscription de la clé dans $navigation / $navigationGroups * (Views/layouts/header.php) ; * 6. une migration SQL insérant la ligne dans acces_role_page — les droits * sont lus en base, jamais écrits en PHP. Sans elle, la page est refusée. * * Chaque méthode déclare les scripts et modules JS à charger ; footer.php les * injecte et page-bootstrap.js appelle init() sur chaque module. */ final class PageController extends Controller { private Access $access; public function __construct(private PDO $pdo) { $this->access = new Access($pdo); } /** * Tableau de bord du Service Enfance (V7.9) — page d'accueil de ce rôle. * Voir Access::homeUrl() pour le repli si la migration n'est pas jouée. */ 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(), ], ]); } /** * Écran de saisie — cœur de l'application. * * Le lieu est VERROUILLÉ pour un responsable de structure * (`structureLocked`) : il ne peut saisir que chez lui. Le verrou n'est * qu'un confort d'interface, le serveur retranche de toute façon. * * Les scripts de semaine type ne sont chargés que si la permission * can_use_templates est accordée : inutile d'envoyer 1 300 lignes de JS à un * utilisateur qui n'y a pas droit. La page ouvre alors deux modes exclusifs * (éditeur de semaine type / saisie semaine par semaine, voir * README_V7_10.md) ; `focus` et `week` forcent le second mode, car ce sont * les paramètres des liens profonds venus des autres écrans. */ public function planning(): void { $this->access->requirePage('planning'); $forcedStructure = $this->access->editableStructureId(); $permissions = $this->access->permissions(); $scripts = ['planning-editor.js']; $modules = ['planning']; if (!empty($permissions['can_use_templates'])) { $scripts[] = 'week-templates.js'; $scripts[] = 'week-template-editor.js'; $modules[] = 'weekTemplates'; $modules[] = 'weekTemplateEditor'; } $this->renderPage('planning', 'Saisie du planning', 'planning', [ 'pageSubtitle' => !empty($permissions['can_use_templates']) ? 'Composer une semaine type et la dupliquer sur une période, ou saisir une semaine en particulier.' : 'Choisir un lieu, un agent et une semaine, saisir les plages horaires, puis enregistrer le brouillon.', 'structures' => $this->loadStructures(), 'motifs' => $this->loadMotifs(), 'currentWeek' => $this->currentWeek(), 'scripts' => $scripts, 'modules' => $modules, 'pageConfig' => [ 'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null), 'agentId' => $this->positiveInt($_GET['agent_id'] ?? null), 'week' => $this->weekValue($_GET['week'] ?? null), 'focus' => (string) ($_GET['focus'] ?? ''), 'notice' => trim((string) ($_GET['notice'] ?? '')), 'structureLocked' => $forcedStructure !== null, ], ]); } /** File des plannings en attente de validation (Service Enfance). */ public function pending(): void { $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'], ]); } /** Contrôle de couverture : plages où l'effectif passe sous le minimum du lieu. */ public function coverage(): void { $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'], 'modules' => ['coverage'], 'pageConfig' => [ 'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null), 'structureLocked' => $forcedStructure !== null, ], ]); } /** Planning d'un lieu sur une semaine, en lecture seule. */ public function structures(): void { $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'], 'modules' => ['structureOverview'], 'pageConfig' => [ 'structureId' => $forcedStructure ?: $this->positiveInt($_GET['structure_id'] ?? null), 'structureLocked' => $forcedStructure !== null, ], ]); } /** * Planning d'un agent, mois par mois et tous lieux confondus. * * Un AGENT est verrouillé sur lui-même (`agentLocked`) et le titre bascule * sur « Mon planning » : la page sert alors de vue personnelle. */ public function agents(): void { $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(), 'scripts' => ['agent-overview.js'], 'modules' => ['agentOverview'], 'pageConfig' => [ 'agentId' => $forcedAgent ?: $this->positiveInt($_GET['agent_id'] ?? null), 'month' => $this->monthValue($_GET['month'] ?? null), 'agentLocked' => $forcedAgent !== null, ], ]); } /** Vue annuelle (janvier -> décembre) et validation de l'année entière. */ public function annual(): void { $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'], 'pageConfig' => [ 'agentId' => $this->positiveInt($_GET['agent_id'] ?? null), 'year' => $this->yearValue($_GET['year'] ?? null), ], ]); } /** PTA détaillé d'un agent : souhaits de lieux, contraintes, répartition. */ public function pta(): void { $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'], 'modules' => ['pta'], 'pageConfig' => [ 'agentId' => $this->positiveInt($_GET['agent_id'] ?? null), 'date' => $this->dateValue($_GET['date'] ?? null), ], ]); } /** Composition des équipes sur les mercredis et les vacances. */ public function teams(): void { $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'], 'modules' => ['teams'], 'pageConfig' => [ 'dateDebut' => $this->dateValue($_GET['date_debut'] ?? null), 'dateFin' => $this->dateValue($_GET['date_fin'] ?? null), ], ]); } /** * Périodes de vacances scolaires : elles déterminent le contexte * périscolaire / extrascolaire de chaque journée, et sont importables depuis * l'API data.education.gouv.fr (SchoolHolidayApiService). */ public function vacations(): void { $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'], ]); } /** Référentiels : agents, lieux, postes, règles de couverture. */ public function administration(): void { $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(), 'scripts' => ['administration.js', 'coverage.js'], 'modules' => ['administration', 'coverage'], ]); } /** * 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.', ]); } /** * Fabrique commune à toutes les pages : en-têtes de sécurité puis rendu. * * Pose les clés attendues par header.php et footer.php avec des valeurs par * défaut, que $data écrase. Une page qui oublie 'scripts' ou 'motifs' rendra * donc correctement au lieu de lever une notice sur variable indéfinie. * * Security::sendHtmlHeaders() DOIT être appelé avant tout rendu : il pose la * CSP à nonce, et $cspNonce est ce que chaque