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); } /** * GET api/dashboard_summary.php — données du tableau de bord. * * Deux portées : * - scope=badges : uniquement le compteur de plannings en attente. Cette * variante est appelée depuis TOUTES les pages pour alimenter la pastille * de navigation ; elle doit rester à une seule requête SQL ; * - portée complète : les quatre blocs de l'écran, ce qui représente * plusieurs dizaines de requêtes et n'est acceptable que sur le tableau * de bord lui-même. * * Paramètres GET : scope (badges|absent), week, year (facultatifs). */ 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 et combien * n'ont encore aucune heure planifiée. Les cumuls tous agents confondus * (consommation globale, reste à affecter) ne sont volontairement pas exposés : * une somme d'agents aux quotités différentes ne décrit aucune situation réelle. */ private function quotaPayload(int $year): array { $tracking = $this->dashboardModel->quotaTracking($year); $overCount = 0; $emptyCount = 0; foreach ($tracking as $agent) { if ($agent['minutes_restantes'] < 0) { $overCount++; } if ($agent['minutes_affectees'] === 0) { $emptyCount++; } } // 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, '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), ]; } /** * Décode la semaine demandée, avec repli SILENCIEUX sur la semaine courante. * * Contrairement aux endpoints de saisie, une semaine illisible n'est pas une * erreur ici : le tableau de bord doit toujours s'afficher, quitte à montrer * la semaine en cours. Fuseau explicite, le conteneur PHP tournant en UTC. * * @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), ]; } /** * Année du suivi de quota, avec repli sur l'année ISO de la semaine affichée. */ 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; } }