agent, year, quota_periods, calendar_totals, * months (12 entrées) et legend. * * @throws RuntimeException Si l'agent est introuvable ou inactif. */ public function overview(int $agentId, int $year): array { $agentModel = new AgentModel($this->pdo); $agent = $agentModel->findActive($agentId); if ($agent === null) { throw new RuntimeException('Agent introuvable.'); } $start = sprintf('%04d-01-01', $year); $end = sprintf('%04d-12-31', $year); $entries = $agentModel->entriesForDateRange($agentId, $start, $end); $vacations = (new VacationModel($this->pdo))->periodsBetween($start, $end); $quotaPeriods = $this->quotaPeriodsForYear($agentId, $year, $agent); $entriesByDate = []; foreach ($entries as $entry) { $entriesByDate[(string) $entry['date_jour']][] = $entry; } $months = []; $yearTotals = array_fill_keys(self::CELL_KEYS, 0); $statusTotals = ['VALIDE' => 0, 'BROUILLON' => 0]; $specialTotals = []; for ($month = 1; $month <= 12; $month++) { $monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)); $monthEnd = $monthStart->modify('last day of this month'); $days = []; $monthTotals = array_fill_keys(self::CELL_KEYS, 0); $period = new DatePeriod($monthStart, new DateInterval('P1D'), $monthEnd->modify('+1 day')); foreach ($period as $date) { $dateValue = $date->format('Y-m-d'); $vacation = $this->vacationForDate($dateValue, $vacations); $day = $this->buildDay($date, $entriesByDate[$dateValue] ?? [], $vacation, $agent); $days[] = $day; foreach (self::CELL_KEYS as $key) { $minutes = (int) $day['cells'][$key]['minutes']; $monthTotals[$key] += $minutes; $yearTotals[$key] += $minutes; } foreach ($day['status_minutes'] as $status => $minutes) { $statusTotals[$status] = ($statusTotals[$status] ?? 0) + $minutes; } foreach ($day['special_minutes'] as $code => $minutes) { $specialTotals[$code] = ($specialTotals[$code] ?? 0) + $minutes; } } $months[] = [ 'number' => $month, 'name' => $this->monthName($month), 'days' => $days, 'totals' => $this->formatTotals($monthTotals), ]; } $calendarMinutes = array_sum($yearTotals); return [ 'agent' => $agent, 'year' => $year, 'generated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'), 'quota_periods' => $quotaPeriods, 'calendar_totals' => [ 'minutes' => $calendarMinutes, 'duration' => $this->formatMinutes($calendarMinutes), 'cells' => $this->formatTotals($yearTotals), 'validated' => $this->formatMinutes((int) ($statusTotals['VALIDE'] ?? 0)), 'draft' => $this->formatMinutes((int) ($statusTotals['BROUILLON'] ?? 0)), 'specials' => array_map(fn(int $minutes): string => $this->formatMinutes($minutes), $specialTotals), ], 'months' => $months, 'legend' => [ ['code' => 'TRAVAIL', 'label' => 'Temps de travail', 'class' => 'annual-work'], ['code' => 'PREPA', 'label' => 'Temps de préparation', 'class' => 'annual-preparation'], ['code' => 'FOR', 'label' => 'Formation', 'class' => 'annual-training'], ['code' => 'CA', 'label' => 'Congé annuel', 'class' => 'annual-leave'], ['code' => 'ABS', 'label' => 'Absence', 'class' => 'annual-absence'], ['code' => 'JNT', 'label' => 'Journée non travaillée', 'class' => 'annual-jnt'], ['code' => 'JF', 'label' => 'Journée de fractionnement', 'class' => 'annual-fraction'], ['code' => 'VAC', 'label' => 'Vacances scolaires', 'class' => 'annual-vacation'], ], ]; } /** * Construit UNE journée du calendrier à partir de ses créneaux. * * Les quatre cases sont toujours présentes, même vides : la grille HTML doit * garder un nombre de colonnes constant. * * Une case peut recevoir plusieurs créneaux ; elle prend alors la classe CSS * du motif le plus « fort » (voir strongestClass()), et son contenu affiche * les codes courts plutôt que la durée dès qu'un motif particulier est * présent — « CA » est plus parlant que « 7h00 » pour un jour de congé. * * `structure_id` retient le lieu du PREMIER créneau du jour, ou à défaut le * rattachement de l'agent : il sert à construire les liens profonds vers * l'écran de saisie. * * @param array> $entries Créneaux de la journée. * @param array|null $vacation Période de vacances, ou null. * @param array $agent Fiche de l'agent. * * @return array */ private function buildDay(DateTimeImmutable $date, array $entries, ?array $vacation, array $agent): array { $cells = []; foreach (self::CELL_KEYS as $key) { $cells[$key] = [ 'minutes' => 0, 'display' => '', 'details' => [], 'codes' => [], 'class' => '', 'structure_id' => null, ]; } $statusMinutes = ['VALIDE' => 0, 'BROUILLON' => 0]; $specialMinutes = []; $hasDraft = false; $firstStructureId = null; foreach ($entries as $entry) { $minutes = $this->minutesBetween((string) $entry['heure_debut'], (string) $entry['heure_fin']); if ($minutes <= 0) { continue; } $code = strtoupper((string) $entry['motif_code']); $cellKey = $this->cellForEntry($code, $vacation !== null); $shortCode = $this->shortCode($code); $status = strtoupper((string) $entry['statut']) === 'VALIDE' ? 'VALIDE' : 'BROUILLON'; $statusMinutes[$status] += $minutes; $hasDraft = $hasDraft || $status === 'BROUILLON'; $firstStructureId ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null; $cells[$cellKey]['minutes'] += $minutes; $cells[$cellKey]['structure_id'] ??= isset($entry['id_structure']) ? (int) $entry['id_structure'] : null; $cells[$cellKey]['details'][] = sprintf( '%s–%s · %s · %s%s', (string) $entry['heure_debut'], (string) $entry['heure_fin'], (string) $entry['motif_libelle'], (string) $entry['structure_nom'], $status === 'BROUILLON' ? ' · Brouillon' : '' ); if ($shortCode !== null) { $cells[$cellKey]['codes'][$shortCode] = true; $specialMinutes[$code] = ($specialMinutes[$code] ?? 0) + $minutes; } $cells[$cellKey]['class'] = $this->strongestClass( (string) $cells[$cellKey]['class'], $this->classForCode($code, $cellKey) ); } foreach (self::CELL_KEYS as $key) { $codes = array_keys($cells[$key]['codes']); $cells[$key]['display'] = $this->cellDisplay((int) $cells[$key]['minutes'], $codes); $cells[$key]['title'] = implode("\n", $cells[$key]['details']); unset($cells[$key]['details'], $cells[$key]['codes']); } $weekday = (int) $date->format('N'); $week = (int) $date->format('W'); $principalStructure = isset($agent['id_structure']) && $agent['id_structure'] !== null ? (int) $agent['id_structure'] : null; return [ 'date' => $date->format('Y-m-d'), 'day' => (int) $date->format('j'), 'weekday' => $weekday, 'weekday_short' => $this->weekdayShort($weekday), 'week' => $week, 'week_year' => (int) $date->format('o'), 'show_week' => $weekday === 1 || (int) $date->format('j') === 1, 'is_weekend' => $weekday >= 6, 'is_vacation' => $vacation !== null, 'vacation_label' => $vacation['libelle'] ?? null, 'has_entries' => $entries !== [], 'has_draft' => $hasDraft, 'structure_id' => $firstStructureId ?? $principalStructure, 'cells' => $cells, 'status_minutes' => $statusMinutes, 'special_minutes' => $specialMinutes, ]; } /** * Périodes de quota à afficher pour l'année. * * Trois dates de contexte sont interrogées (1er janvier, 31 décembre, et le * début de contrat s'il tombe dans l'année), puis dédoublonnées par leurs * bornes. Depuis que le quota suit l'année civile, elles donnent toutes la * même période : le résultat ne contient donc en pratique qu'une entrée. La * mécanique est conservée parce qu'elle reste juste si une règle de * proratisation en cours d'année est réintroduite. * * @return array> */ private function quotaPeriodsForYear(int $agentId, int $year, array $agent): array { $quotaModel = new QuotaModel($this->pdo); $contexts = [sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year)]; $contractStart = trim((string) ($agent['date_debut_contrat'] ?? '')); if ($contractStart !== '' && str_starts_with($contractStart, (string) $year)) { $contexts[] = $contractStart; } $periods = []; foreach ($contexts as $context) { $quota = $quotaModel->forDateForApi($agentId, $context); $key = $quota['date_debut_periode'] . '|' . $quota['date_fin_periode']; $periods[$key] = $quota; } uasort($periods, static fn(array $a, array $b): int => strcmp( (string) $a['date_debut_periode'], (string) $b['date_debut_periode'] )); return array_values($periods); } /** * Choisit la case (AP/PP/AE/PE) d'un créneau selon son motif. * * Un motif non reconnu bascule sur le contexte de la journée : animation * extrascolaire pendant les vacances, périscolaire sinon. C'est le * comportement le moins surprenant pour un motif ajouté en base après coup, * et il évite qu'un temps disparaisse du calendrier. */ private function cellForEntry(string $code, bool $isVacation): string { return match ($code) { 'PREPARATION_PERISCOLAIRE', 'PREPARATION_LUNDI' => 'PP', 'PREPARATION_EXTRASCOLAIRE' => 'PE', 'EXTRASCOLAIRE' => 'AE', 'ALP_MATIN', 'RESTAURATION', 'ALP_SOIR', 'MERCREDI_PERISCOLAIRE' => 'AP', 'JOUR_FRACTIONNEMENT' => 'AP', default => $isVacation ? 'AE' : 'AP', }; } /** * Abréviation affichée dans la case pour les motifs PARTICULIERS. * * @return string|null null pour les motifs de travail ordinaires, qui * affichent alors leur durée plutôt qu'un code. */ private function shortCode(string $code): ?string { return match ($code) { 'FORMATION' => 'FOR', 'CONGE' => 'CA', 'ABSENCE', 'MALADIE' => 'ABS', 'AUTRE_ABSENCE' => 'A.ND', 'JNT' => 'JNT', 'JOUR_FRACTIONNEMENT' => 'JF', 'MENAGE_FOND' => 'MF', default => null, }; } /** * Classe CSS d'un créneau, à partir de son motif et, à défaut, de sa case. * Les classes correspondantes sont définies dans assets/style.css. */ private function classForCode(string $code, string $cellKey): string { return match ($code) { 'FORMATION' => 'annual-training', 'CONGE' => 'annual-leave', 'ABSENCE', 'MALADIE' => 'annual-absence', 'AUTRE_ABSENCE' => 'annual-other-absence', 'JNT' => 'annual-jnt', 'JOUR_FRACTIONNEMENT' => 'annual-fraction', 'PREPARATION_PERISCOLAIRE', 'PREPARATION_EXTRASCOLAIRE', 'PREPARATION_LUNDI' => 'annual-preparation', 'EXTRASCOLAIRE' => 'annual-extra', default => in_array($cellKey, ['PP', 'PE'], true) ? 'annual-preparation' : 'annual-work', }; } /** * Arbitre la classe d'une case contenant plusieurs motifs. * * Une seule couleur par case : c'est la plus « forte » qui l'emporte, selon * une priorité croissante allant du travail ordinaire à la journée non * travaillée. Une demi-journée de congé doit rester visible à côté d'une * demi-journée travaillée ; l'inverse ferait disparaître l'information. * * Le `>=` fait gagner le dernier créneau à priorité égale, ce qui est sans * conséquence puisque la classe est alors la même. */ private function strongestClass(string $current, string $candidate): string { $priority = [ '' => 0, 'annual-work' => 1, 'annual-extra' => 2, 'annual-preparation' => 3, 'annual-fraction' => 4, 'annual-training' => 5, 'annual-leave' => 6, 'annual-other-absence' => 7, 'annual-absence' => 8, 'annual-jnt' => 9, ]; return ($priority[$candidate] ?? 0) >= ($priority[$current] ?? 0) ? $candidate : $current; } /** * Contenu textuel d'une case : rien si vide, les codes courts s'il y en a, * la durée compacte sinon. * * @param array $codes */ private function cellDisplay(int $minutes, array $codes): string { if ($minutes <= 0) { return ''; } if ($codes !== []) { return implode('/', $codes); } return $this->formatCompactMinutes($minutes); } /** * Période de vacances contenant cette date, ou null. * Copie locale de VacationModel::vacationForDate() : les périodes sont déjà * chargées en mémoire pour toute l'année, inutile de repasser par le modèle. * * @param array> $vacations * * @return array|null */ private function vacationForDate(string $date, array $vacations): ?array { foreach ($vacations as $vacation) { if ($date >= (string) $vacation['date_debut'] && $date <= (string) $vacation['date_fin']) { return $vacation; } } return null; } /** Durée en minutes entre deux HH:MM, plancher à 0. */ private function minutesBetween(string $start, string $end): int { [$startHour, $startMinute] = array_map('intval', explode(':', $start)); [$endHour, $endMinute] = array_map('intval', explode(':', $end)); return max(0, ($endHour * 60 + $endMinute) - ($startHour * 60 + $startMinute)); } /** * Décline chaque total en trois formes : minutes brutes, durée lisible * (« 7 h 30 ») et forme compacte (« 7h30 ») pour les cases étroites. * * @param array $totals * * @return array */ private function formatTotals(array $totals): array { $formatted = []; foreach ($totals as $key => $minutes) { $formatted[$key] = [ 'minutes' => (int) $minutes, 'duration' => $this->formatMinutes((int) $minutes), 'compact' => $this->formatCompactMinutes((int) $minutes), ]; } return $formatted; } /** 450 → « 7 h 30 ». Gère les valeurs négatives (signe extrait d'abord). */ private function formatMinutes(int $minutes): string { $sign = $minutes < 0 ? '-' : ''; $minutes = abs($minutes); return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); } /** 450 → « 7h30 », 420 → « 7h », 0 → chaîne vide. Pour les cases étroites. */ private function formatCompactMinutes(int $minutes): string { if ($minutes <= 0) { return ''; } $hours = intdiv($minutes, 60); $mins = $minutes % 60; return $mins === 0 ? $hours . 'h' : sprintf('%dh%02d', $hours, $mins); } /** Nom du mois en français. Table en dur : le conteneur n'a pas de locale fr. */ private function monthName(int $month): string { return [ 1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril', 5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août', 9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre', ][$month]; } /** Initiale du jour (1 = lundi). Mardi et Mercredi partagent « M ». */ private function weekdayShort(int $weekday): string { return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday]; } }