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'], ], ]; } 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, ]; } 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); } 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', }; } 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, }; } 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', }; } 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; } private function cellDisplay(int $minutes, array $codes): string { if ($minutes <= 0) { return ''; } if ($codes !== []) { return implode('/', $codes); } return $this->formatCompactMinutes($minutes); } 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; } 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)); } 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; } private function formatMinutes(int $minutes): string { $sign = $minutes < 0 ? '-' : ''; $minutes = abs($minutes); return sprintf('%s%d h %02d', $sign, intdiv($minutes, 60), $minutes % 60); } 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); } 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]; } private function weekdayShort(int $weekday): string { return [1 => 'L', 2 => 'M', 3 => 'M', 4 => 'J', 5 => 'V', 6 => 'S', 7 => 'D'][$weekday]; } }