'), 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 horaires de couverture attendus de chaque lieu : les plages où un effectif minimum doit être présent.', // Depuis la V7.14, cet écran ne porte plus que les règles de // couverture : agents et lieux ont leur propre page (management). // Seule la liste des lieux reste nécessaire, pour son sélecteur. 'structures' => (new StructureModel($this->pdo))->allActive(), 'scripts' => ['coverage.js'], 'modules' => ['coverage'], ]); } /** * Management — Agents (V7.14) : création, liste et fiche des agents. * * Ce référentiel partageait l'écran « Administration » avec celui des lieux * et les horaires de couverture, trois objets sans rapport. Chacun a * désormais son onglet, réunis sous le groupe « Management » de la barre de * navigation. * * NOM DE MÉTHODE EN SNAKE_CASE, à rebours du reste du projet : le nom de la * méthode EST la clé de page passée à runPage() et inscrite dans * acces_role_page, où les clés sont en minuscules. Les garder identiques * prime sur l'uniformité de style — voir l'en-tête de cette classe. * * La liste des lieux est nécessaire ici aussi : le formulaire de création * demande le lieu principal de l'agent. */ public function manage_agents(): void { $this->access->requirePage('manage_agents'); $agentModel = new AgentModel($this->pdo); $this->renderPage('manage_agents', 'Agents', 'manage_agents', [ 'pageSubtitle' => 'Créer un agent, corriger sa fiche, changer son lieu principal de rattachement.', 'agents' => $agentModel->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(), 'posts' => $agentModel->allPosts(), 'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js'], 'modules' => ['administration', 'addressAutocomplete', 'fieldFormat'], ]); } /** * Management — Lieux d'affectation (V7.14) : création et paramètres. * * Les horaires de couverture d'un lieu restent sur « Administration » : ils * ne décrivent pas le lieu mais une exigence de présence. */ public function manage_structures(): void { $this->access->requirePage('manage_structures'); $this->renderPage('manage_structures', 'Lieux d\'affectation', 'manage_structures', [ 'pageSubtitle' => 'Créer un lieu d\'affectation et définir son type : périscolaire ou extrascolaire.', 'structures' => (new StructureModel($this->pdo))->allActive(), 'scripts' => ['administration.js'], 'modules' => ['administration'], ]); } /** * Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité. * * Page d'administration TECHNIQUE, ouverte au seul rôle DSI par * acces_role_page. Elle ne charge aucun référentiel métier — la DSI n'a rien * à connaître des agents ni des plannings ; les seules listes qu'elle * manipule (identités sans compte) sont servies par l'API. */ public function dsi(): void { $this->access->requirePage('dsi'); $this->renderPage('dsi', 'Espace DSI', 'dsi', [ 'pageSubtitle' => 'Les comptes d\'accès de l\'application, leurs jetons, et le journal des connexions, des écritures et des refus.', 'scripts' => ['dsi.js'], 'modules' => ['dsi'], 'pageConfig' => [ // Lien profond « journal de ce compte » : filtre d'emblée le // journal sur le compte désigné. 'compteId' => $this->positiveInt($_GET['compte_id'] ?? null), ], ]); } /** * 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