diff --git a/CLAUDE.md b/CLAUDE.md index ca893a5..44f1936 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,8 +49,8 @@ appliquée manuellement en SQL — il n'y a pas d'outil de migration. Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes. -**Pages HTML** — `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`, `coverage.php`, -`structures.php`, `pending.php`, `vacations.php`, `administration.php` : +**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'; @@ -74,8 +74,11 @@ 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 `Access::allowedPages()` -et dans le tableau `$navigation` de `app/Views/layouts/header.php`**. +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 @@ -108,6 +111,11 @@ SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une p 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 @@ -145,9 +153,10 @@ PTA.modules.agentOverview = { init, load }; 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.7` et `assets/js/*.js?v=7.7` dans -`layouts/header.php` / `layouts/footer.php`. **Bumper ce numéro après toute modification de JS/CSS**, -sinon les navigateurs et le `expires 7d` de nginx servent l'ancienne version. +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 ``. **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 diff --git a/README_V7_9.md b/README_V7_9.md new file mode 100644 index 0000000..560dd06 --- /dev/null +++ b/README_V7_9.md @@ -0,0 +1,113 @@ +# PTA V7.9 — Parcours et lisibilité du Service Enfance + +## Le problème traité + +Le rôle **Service Enfance** ouvre onze écrans. Jusqu'ici : + +- il atterrissait sur **Saisie**, alors que son travail commence par le contrôle et non par la saisie ; +- les onze onglets étaient alignés à plat, sans hiérarchie : rien ne distinguait une tâche + quotidienne (« À valider ») d'un référentiel modifié deux fois par an (« Administration ») ; +- chaque bandeau de page affichait le **même** sous-titre générique, il fallait donc ouvrir un écran + pour savoir à quoi il servait ; +- les chiffres qui déclenchent une action (brouillons en attente, manques de couverture, quotas) + vivaient sur trois écrans distincts : personne ne voyait l'état général sans faire le tour ; +- la pastille « À valider » de la barre de navigation n'était alimentée que **sur la page + « À valider » elle-même**, donc invisible là où elle aurait servi. + +## Ce qui change + +### 1. Un tableau de bord comme page d'accueil + +Nouvelle page `dashboard.php`, **réservée au Service Enfance**, qui devient sa page d'atterrissage. + +- **Quatre tuiles d'action**, vertes quand il n'y a rien à faire, rouges dès qu'un compteur remonte : + plannings à valider, manques de couverture de la semaine, quotas dépassés, lieux sans horaires de + couverture. Chaque tuile est un lien vers l'écran de traitement. +- **« À valider en priorité »** : les cinq brouillons dont la semaine est la plus ancienne, avec un + bouton **Contrôler** qui ouvre directement l'écran de saisie sur le bon agent, le bon lieu, la + bonne semaine, curseur sur la validation. +- **« Couverture à renforcer »** : les cinq plages les plus déficitaires, triées par nombre d'agents + manquants, avec un lien vers l'écran de couverture déjà filtré. +- **« Suivi des quotas annuels »** : consommation globale, reste à affecter, agents sans aucune heure, + et le détail par agent avec une jauge et un lien vers sa vue annuelle. +- **« Actions fréquentes »** : six raccourcis vers les écrans les plus utilisés. + +Un sélecteur de semaine en haut de page rejoue l'ensemble des indicateurs de couverture, et indique +si la semaine est scolaire, de vacances, ou à cheval sur les deux. + +### 2. Navigation regroupée par intention + +Les onglets sont désormais rangés en quatre groupes : **Piloter** (tableau de bord, à valider, +couverture), **Planifier** (saisie, équipes), **Consulter** (par agent, par lieu, vue annuelle, PTA +détaillé), **Paramétrer** (vacances, administration). + +Les rôles qui n'ont que quelques pages — Agent (2), Responsable de structure (4) — gardent la barre +simple d'avant, sans titres de groupe. Une page autorisée en base mais absente des groupes reste +affichée dans un groupe « Autres » plutôt que de disparaître. + +### 3. Chaque page dit ce qu'on y fait + +Le sous-titre du bandeau est propre à chaque écran et décrit l'action, pas le produit. + +### 4. La pastille « À valider » suit partout + +Elle est alimentée depuis n'importe quel écran par un compteur léger +(`api/dashboard_summary.php?scope=badges`). L'échec de cet appel est silencieux : une commodité ne +doit jamais empêcher une page de fonctionner. + +## Ce qui ne change pas + +Aucune règle métier, aucun calcul, aucun droit n'est modifié. + +Le tableau de bord ne fait que **relire** ce que les autres écrans calculent déjà : la file de +validation vient de `PlanningModel::pendingValidation()`, la couverture de +`CoverageModel::reportForStructure()`, et le quota reprend exactement la logique de `QuotaModel` +(année civile, motifs décomptés uniquement, quota de l'année la plus proche à défaut de l'année +exacte) — un agent ne peut donc pas afficher deux valeurs différentes selon l'écran consulté. + +L'endpoint `api/dashboard_summary.php` est gardé par `requireService()` : 403 pour les autres rôles, +401 sans session. + +## Migration + +```sql +SOURCE database/migration_v7_9_tableau_de_bord.sql; +``` + +Elle insère une seule ligne dans `acces_role_page`. **Elle doit être jouée en même temps que le +déploiement du code**, dans un sens comme dans l'autre : + +- code déployé, migration non jouée → l'onglet n'apparaît pas, le Service Enfance atterrit comme + avant sur l'écran de saisie, et `dashboard.php` renvoie vers l'accueil. L'application reste + entièrement fonctionnelle, simplement sans tableau de bord ; +- migration jouée, code non déployé → l'onglet apparaît et pointe vers un fichier inexistant. + +Retour arrière : + +```sql +DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard'; +``` + +## Fichiers + +| Ajouté | Rôle | +| --- | --- | +| `dashboard.php` | point d'entrée de la page | +| `api/dashboard_summary.php` | endpoint JSON | +| `app/Controllers/DashboardController.php` | agrégation et gardes d'accès | +| `app/Models/DashboardModel.php` | suivi des quotas et compteurs | +| `app/Views/pages/dashboard.php`, `app/Views/partials/dashboard.php` | vue | +| `assets/js/dashboard.js` | module `PTA.modules.dashboard` | +| `database/migration_v7_9_tableau_de_bord.sql` | droit d'accès à la page | + +| Modifié | Raison | +| --- | --- | +| `app/Core/Access.php` | `homeUrl()` vise le tableau de bord, avec repli si la migration manque | +| `app/Controllers/PageController.php` | méthode `dashboard()` et sous-titre propre à chaque page | +| `app/Views/layouts/header.php` | navigation regroupée, sous-titre contextuel | +| `app/Views/layouts/footer.php`, `app/Views/pages/role.php` | versions d'assets `?v=7.9` | +| `assets/js/page-bootstrap.js` | pastille « À valider » sur toutes les pages | +| `assets/style.css` | styles de la navigation groupée et du tableau de bord | + +Les jauges de quota utilisent l'élément natif `` : la CSP interdit l'attribut `style`, une +largeur ne peut donc pas être calculée en JavaScript sur une simple `div`. diff --git a/api/dashboard_summary.php b/api/dashboard_summary.php new file mode 100644 index 0000000..871859b --- /dev/null +++ b/api/dashboard_summary.php @@ -0,0 +1,12 @@ +summary(); diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..761b700 --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,310 @@ +access = new Access($pdo); + $this->dashboardModel = new DashboardModel($pdo); + $this->planningModel = new PlanningModel($pdo); + $this->coverageModel = new CoverageModel($pdo); + $this->structureModel = new StructureModel($pdo); + $this->vacationModel = new VacationModel($pdo); + } + + public function summary(): void + { + $this->access->requireService(); + + // Portée « badges » : uniquement le compteur de la pastille de + // navigation, appelé depuis toutes les pages. Il doit rester léger. + if (trim((string) ($_GET['scope'] ?? '')) === 'badges') { + JsonResponse::send(200, [ + 'validation' => ['count' => $this->dashboardModel->pendingCount()], + ]); + } + + $week = $this->resolveWeek($_GET['week'] ?? null); + // L'année du quota suit l'année ISO de la semaine affichée : une + // semaine à cheval sur deux années civiles reste rattachée à l'année + // qui contient son jeudi, comme partout ailleurs dans PTA. + $year = $this->resolveYear($_GET['year'] ?? null, (int) $week['iso_year']); + + JsonResponse::send(200, [ + 'semaine' => $this->weekPayload($week), + 'validation' => $this->validationPayload(), + 'couverture' => $this->coveragePayload((int) $week['iso_year'], (int) $week['iso_week']), + 'quota' => $this->quotaPayload($year), + 'perimetre' => $this->dashboardModel->scope(), + ]); + } + + /** + * File de validation : le volume total, le volume décompté et les + * brouillons les plus anciens, qui sont ceux à traiter en premier. + */ + private function validationPayload(): array + { + $plannings = $this->planningModel->pendingValidation(); + $totalMinutes = 0; + $countedMinutes = 0; + $structures = []; + $agents = []; + + foreach ($plannings as $planning) { + $totalMinutes += (int) $planning['total_minutes']; + $countedMinutes += (int) $planning['minutes_decomptees']; + $structures[(int) $planning['id_structure']] = true; + $agents[(int) $planning['id_agent']] = true; + } + + // pendingValidation() trie déjà par semaine croissante : les premiers + // éléments sont les plus anciens, donc les plus urgents. + $apercu = array_slice($plannings, 0, self::APERCU_MAX); + + return [ + 'count' => count($plannings), + 'total_minutes' => $totalMinutes, + 'minutes_decomptees' => $countedMinutes, + 'structure_count' => count($structures), + 'agent_count' => count($agents), + 'semaine_la_plus_ancienne' => $plannings[0]['date_debut'] ?? null, + 'apercu' => array_map(static function (array $planning): array { + return [ + 'id_planning' => (int) $planning['id_planning'], + 'id_agent' => (int) $planning['id_agent'], + 'id_structure' => (int) $planning['id_structure'], + 'agent_nom' => (string) $planning['agent_nom'], + 'agent_prenom' => (string) $planning['agent_prenom'], + 'structure_nom' => (string) $planning['structure_nom'], + 'annee' => (int) $planning['annee'], + 'numero_semaine' => (int) $planning['numero_semaine'], + 'date_debut' => (string) $planning['date_debut'], + 'date_fin_ouvrable' => (string) ($planning['date_fin_ouvrable'] ?? $planning['date_fin']), + 'nombre_creneaux' => (int) $planning['nombre_creneaux'], + 'total_minutes' => (int) $planning['total_minutes'], + 'minutes_decomptees' => (int) $planning['minutes_decomptees'], + ]; + }, $apercu), + ]; + } + + /** + * Couverture de la semaine affichée, tous lieux actifs confondus. + * Reprend exactement le calcul de l'écran « Couverture ». + */ + private function coveragePayload(int $year, int $weekNumber): array + { + $gaps = []; + $unconfigured = []; + $affectedStructures = []; + $gapMinutes = 0; + $validationWarningCount = 0; + + foreach ($this->structureModel->allActive() as $structure) { + $structureId = (int) $structure['id_structure']; + $report = $this->coverageModel->reportForStructure($structureId, $year, $weekNumber); + + if (!$report['configured']) { + $unconfigured[] = [ + 'id_structure' => $structureId, + 'nom' => (string) $structure['nom'], + 'code' => (string) $structure['code'], + ]; + continue; + } + + $validationWarningCount += (int) $report['summary']['validation_warning_count']; + + foreach ($report['gaps'] as $gap) { + $gaps[] = [ + 'id_structure' => $structureId, + 'structure_nom' => (string) $structure['nom'], + 'date' => (string) $gap['date'], + 'jour_libelle' => (string) $gap['jour_libelle'], + 'heure_debut' => (string) $gap['heure_debut'], + 'heure_fin' => (string) $gap['heure_fin'], + 'minimum_agents' => (int) $gap['minimum_agents'], + 'agents_planifies' => (int) $gap['agents_planifies'], + 'agents_manquants' => (int) $gap['agents_manquants'], + ]; + $affectedStructures[$structureId] = true; + } + + $gapMinutes += (int) $report['summary']['gap_minutes']; + } + + // Les manques les plus importants d'abord : c'est là que le service + // doit agir en priorité. + usort($gaps, static function (array $a, array $b): int { + return [$b['agents_manquants'], $a['date'], $a['heure_debut']] + <=> [$a['agents_manquants'], $b['date'], $b['heure_debut']]; + }); + + return [ + 'gap_count' => count($gaps), + 'gap_minutes' => $gapMinutes, + 'structure_count' => count($affectedStructures), + 'validation_warning_count' => $validationWarningCount, + 'unconfigured_count' => count($unconfigured), + 'unconfigured' => array_slice($unconfigured, 0, self::APERCU_MAX), + 'apercu' => array_slice($gaps, 0, self::APERCU_MAX), + ]; + } + + /** + * Suivi des quotas : combien d'agents dépassent leur quota annuel, combien + * n'ont encore aucune heure planifiée, et le volume restant à affecter. + */ + private function quotaPayload(int $year): array + { + $tracking = $this->dashboardModel->quotaTracking($year); + $overCount = 0; + $emptyCount = 0; + $remainingMinutes = 0; + $targetMinutes = 0; + $allocatedMinutes = 0; + + foreach ($tracking as $agent) { + $targetMinutes += $agent['quota_cible_minutes']; + $allocatedMinutes += $agent['minutes_affectees']; + if ($agent['minutes_restantes'] < 0) { + $overCount++; + } + if ($agent['minutes_affectees'] === 0) { + $emptyCount++; + } + $remainingMinutes += max(0, $agent['minutes_restantes']); + } + + // Tri décroissant sur le taux : les dépassements remontent en tête, + // les agents non planifiés restent visibles en bas de liste. + usort($tracking, static function (array $a, array $b): int { + return $b['taux_consommation'] <=> $a['taux_consommation']; + }); + + return [ + 'annee' => $year, + 'agent_count' => count($tracking), + 'depassement_count' => $overCount, + 'sans_heure_count' => $emptyCount, + 'minutes_restantes' => $remainingMinutes, + 'minutes_cible' => $targetMinutes, + 'minutes_affectees' => $allocatedMinutes, + 'taux_global' => $targetMinutes > 0 ? round(($allocatedMinutes / $targetMinutes) * 100, 1) : 0.0, + 'apercu' => array_slice($tracking, 0, self::QUOTA_APERCU_MAX), + ]; + } + + /** + * Contexte de la semaine affichée : dates, et caractère scolaire ou + * extrascolaire déduit des périodes de vacances enregistrées. + */ + private function weekPayload(array $week): array + { + /** @var DateTimeImmutable $start */ + $start = $week['start']; + $end = $start->modify('+4 days'); + $days = $this->vacationModel->typesForIsoWeek((int) $week['iso_year'], (int) $week['iso_week']); + + $vacationLabels = []; + $vacationDays = 0; + foreach ($days as $day) { + if ($day['type_affectation'] === 'EXTRASCOLAIRE') { + $vacationDays++; + if ($day['vacances'] !== null) { + $vacationLabels[$day['vacances']['libelle']] = true; + } + } + } + + if ($vacationDays === 0) { + $contexte = 'PERISCOLAIRE'; + } elseif ($vacationDays === count($days)) { + $contexte = 'EXTRASCOLAIRE'; + } else { + $contexte = 'MIXTE'; + } + + return [ + 'valeur' => $week['value'], + 'annee' => (int) $week['iso_year'], + 'numero' => (int) $week['iso_week'], + 'date_debut' => $start->format('Y-m-d'), + 'date_fin' => $end->format('Y-m-d'), + 'contexte' => $contexte, + 'jours_vacances' => $vacationDays, + 'vacances_libelles' => array_keys($vacationLabels), + ]; + } + + /** + * @return array{value: string, iso_year: int, iso_week: int, start: DateTimeImmutable} + */ + private function resolveWeek(mixed $value): array + { + $raw = trim((string) $value); + if (preg_match('/^(\d{4})-W(\d{2})$/', $raw, $matches) === 1) { + $isoYear = (int) $matches[1]; + $isoWeek = (int) $matches[2]; + if ($isoWeek >= 1 && $isoWeek <= 53) { + $start = (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))) + ->setISODate($isoYear, $isoWeek, 1) + ->setTime(0, 0); + return [ + 'value' => $raw, + 'iso_year' => $isoYear, + 'iso_week' => $isoWeek, + 'start' => $start, + ]; + } + } + + $now = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')); + return [ + 'value' => $now->format('o-\WW'), + 'iso_year' => (int) $now->format('o'), + 'iso_week' => (int) $now->format('W'), + 'start' => $now->setISODate((int) $now->format('o'), (int) $now->format('W'), 1)->setTime(0, 0), + ]; + } + + private function resolveYear(mixed $value, int $fallback): int + { + $year = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 2000, 'max_range' => 2100]]); + return $year === false ? $fallback : (int) $year; + } +} diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php index 35f90ec..8537d89 100644 --- a/app/Controllers/PageController.php +++ b/app/Controllers/PageController.php @@ -24,6 +24,20 @@ final class PageController extends Controller $this->access = new Access($pdo); } + 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(), + ], + ]); + } + public function planning(): void { $this->access->requirePage('planning'); @@ -36,6 +50,7 @@ final class PageController extends Controller $modules[] = 'weekTemplates'; } $this->renderPage('planning', 'Saisie du planning', 'planning', [ + 'pageSubtitle' => '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(), @@ -56,6 +71,7 @@ final class PageController extends Controller { $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'], ]); @@ -66,6 +82,7 @@ final class PageController extends Controller $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'], @@ -82,6 +99,7 @@ final class PageController extends Controller $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'], @@ -98,6 +116,9 @@ final class PageController extends Controller $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(), @@ -115,6 +136,7 @@ final class PageController extends Controller { $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'], @@ -129,6 +151,7 @@ final class PageController extends Controller { $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'], @@ -144,6 +167,7 @@ final class PageController extends Controller { $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'], @@ -159,6 +183,7 @@ final class PageController extends Controller { $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'], ]); @@ -168,6 +193,7 @@ final class PageController extends Controller { $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(), @@ -182,6 +208,10 @@ final class PageController extends Controller $profile = $this->access->profile(); $this->render('pages/' . $view, array_merge([ 'pageTitle' => $title, + // Chaque page explique en une phrase ce qu'on y fait : le bandeau + // générique ne disait rien et obligeait à ouvrir l'écran pour + // comprendre à quoi il servait. + 'pageSubtitle' => 'Planification annuelle, équipes, quotas et contrôles métier des accueils de loisirs.', 'activePage' => $activePage, 'cspNonce' => Security::nonce(), 'csrfToken' => Csrf::ensureToken(), diff --git a/app/Core/Access.php b/app/Core/Access.php index f244f1a..011a490 100644 --- a/app/Core/Access.php +++ b/app/Core/Access.php @@ -205,12 +205,21 @@ final class Access return in_array($page, $this->allowedPages(), true); } + /** + * Page d'atterrissage du rôle. + * + * Le Service Enfance arrive sur son tableau de bord plutôt que sur l'écran + * de saisie : son travail commence par le contrôle, pas par la saisie. Le + * repli sur « planning » est indispensable, car la page « dashboard » n'est + * autorisée que si la migration V7.9 a été jouée — sans ce repli, + * requirePage() renverrait indéfiniment vers une page interdite. + */ public function homeUrl(): string { return match ($this->role()) { self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(), self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(), - self::ROLE_SERVICE => 'planning.php', + self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php', default => 'role.php', }; } diff --git a/app/Models/DashboardModel.php b/app/Models/DashboardModel.php new file mode 100644 index 0000000..8806e6f --- /dev/null +++ b/app/Models/DashboardModel.php @@ -0,0 +1,183 @@ +> + */ + public function quotaTracking(int $year): array + { + $profiles = $this->quotaProfiles($year); + $rows = $this->allocatedMinutes($year); + $tracking = []; + + foreach ($rows as $row) { + $agentId = (int) $row['id_agent']; + $profile = $profiles[$agentId] ?? [ + 'quotite_travail' => 100.0, + 'quota_cible_minutes' => QuotaModel::REFERENCE_MINUTES, + 'annee' => $year, + ]; + + $target = (int) $profile['quota_cible_minutes']; + $validated = (int) $row['minutes_valides']; + $draft = (int) $row['minutes_brouillon']; + $allocated = $validated + $draft; + + $tracking[] = [ + 'id_agent' => $agentId, + 'matricule' => (string) ($row['matricule'] ?? ''), + 'nom' => (string) $row['nom'], + 'prenom' => (string) $row['prenom'], + 'structure_nom' => $row['structure_nom'] !== null ? (string) $row['structure_nom'] : null, + 'poste_libelle' => $row['poste_libelle'] !== null ? (string) $row['poste_libelle'] : null, + 'quotite_travail' => (float) $profile['quotite_travail'], + 'quota_cible_minutes' => $target, + 'quota_source_annee' => (int) $profile['annee'], + 'quota_estime' => !isset($profiles[$agentId]), + 'minutes_valides' => $validated, + 'minutes_brouillon' => $draft, + 'minutes_affectees' => $allocated, + 'minutes_restantes' => $target - $allocated, + 'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 1) : 0.0, + ]; + } + + return $tracking; + } + + /** + * Minutes décomptées de l'année civile, par agent actif. + * + * @return array> + */ + private function allocatedMinutes(int $year): array + { + $stmt = $this->pdo->prepare( + "SELECT a.id_agent, a.matricule, a.nom, a.prenom, + s.nom AS structure_nom, + po.libelle AS poste_libelle, + COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' AND m.id_motif IS NOT NULL THEN + TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) + ELSE 0 END), 0) AS minutes_valides, + COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' AND m.id_motif IS NOT NULL THEN + TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin)) + ELSE 0 END), 0) AS minutes_brouillon + FROM agent a + LEFT JOIN structure s ON s.id_structure = a.id_structure + LEFT JOIN poste_agent po ON po.id_poste = a.id_poste + LEFT JOIN planning p ON p.id_agent = a.id_agent + LEFT JOIN creneau_horaire c + ON c.id_planning = p.id_planning + AND c.date_jour BETWEEN :date_debut AND :date_fin + LEFT JOIN motif_planning m + ON m.id_motif = c.id_motif + AND m.compte_dans_quota = TRUE + WHERE a.actif = TRUE + GROUP BY a.id_agent, a.matricule, a.nom, a.prenom, s.nom, po.libelle + ORDER BY a.nom, a.prenom" + ); + $stmt->execute([ + 'date_debut' => sprintf('%04d-01-01', $year), + 'date_fin' => sprintf('%04d-12-31', $year), + ]); + + return $stmt->fetchAll(); + } + + /** + * Quota retenu pour chaque agent : année exacte si elle existe, sinon + * l'année passée la plus proche, sinon l'année future la plus proche. + * Même ordre de préférence que QuotaModel::quotaProfile(). + * + * @return array> + */ + private function quotaProfiles(int $year): array + { + $stmt = $this->pdo->prepare( + 'SELECT id_agent, annee, quotite_travail, quota_cible_minutes + FROM ( + SELECT q.id_agent, q.annee, q.quotite_travail, q.quota_cible_minutes, + ROW_NUMBER() OVER ( + PARTITION BY q.id_agent + ORDER BY CASE + WHEN q.annee = :year_exact THEN 0 + WHEN q.annee < :year_past THEN 1 + ELSE 2 + END, + CASE WHEN q.annee <= :year_latest THEN q.annee END DESC, + CASE WHEN q.annee > :year_future THEN q.annee END ASC + ) AS rang + FROM quota_agent_annuel q + ) AS classement + WHERE rang = 1' + ); + $stmt->execute([ + 'year_exact' => $year, + 'year_past' => $year, + 'year_latest' => $year, + 'year_future' => $year, + ]); + + $profiles = []; + foreach ($stmt->fetchAll() as $row) { + $profiles[(int) $row['id_agent']] = [ + 'annee' => (int) $row['annee'], + 'quotite_travail' => (float) $row['quotite_travail'], + 'quota_cible_minutes' => (int) $row['quota_cible_minutes'], + ]; + } + + return $profiles; + } + + /** + * Nombre de brouillons contenant au moins une affectation. + * Requête volontairement légère : elle alimente la pastille de navigation + * rechargée sur chaque page. + */ + public function pendingCount(): int + { + $stmt = $this->pdo->query( + "SELECT COUNT(*) + FROM ( + SELECT p.id_planning + FROM planning p + INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning + WHERE p.statut = 'BROUILLON' + GROUP BY p.id_planning + ) AS brouillons_servis" + ); + + return (int) $stmt->fetchColumn(); + } + + /** + * Nombre d'agents actifs et de lieux actifs, pour situer les compteurs. + * + * @return array{agents: int, structures: int} + */ + public function scope(): array + { + return [ + 'agents' => (int) $this->pdo->query('SELECT COUNT(*) FROM agent WHERE actif = TRUE')->fetchColumn(), + 'structures' => (int) $this->pdo->query('SELECT COUNT(*) FROM structure WHERE actif = TRUE')->fetchColumn(), + ]; + } +} diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index fe628d6..b4d9ac6 100644 --- a/app/Views/layouts/footer.php +++ b/app/Views/layouts/footer.php @@ -28,10 +28,10 @@ window.PTA_PAGE_CONFIG = $pageConfig, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index 259fb64..ea590f0 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -1,22 +1,65 @@ ['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; ?> @@ -24,15 +67,15 @@ $navigation = [ <?= htmlspecialchars($pageTitle) ?> — PTA - +