From edbb1f7b29b9a418976972c296b50223d69cbade Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:13:00 +0200 Subject: [PATCH 01/16] docs : spec export PDF heures vue jour par sites Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-08-hours-day-export-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-hours-day-export-design.md diff --git a/docs/superpowers/specs/2026-06-08-hours-day-export-design.md b/docs/superpowers/specs/2026-06-08-hours-day-export-design.md new file mode 100644 index 0000000..05f270e --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-hours-day-export-design.md @@ -0,0 +1,170 @@ +# Export PDF des heures — vue Jour (par sites) + +**Date** : 2026-06-08 +**Branche** : feature/SIRH-35-export-des-heures-employe + +## Objectif + +Ajouter un bouton **Exporter** sur l'écran « Heures », réservé aux administrateurs, +qui produit un **PDF d'une journée** reprenant les colonnes de la vue Jour (sans la +colonne de validation), pour les employés des sites sélectionnés, **regroupés par site**. + +## Décisions validées + +| Sujet | Choix | +|-------|-------| +| Format | PDF (Twig → Dompdf) | +| Période | Un seul jour | +| Orientation | A4 **portrait**, mise en page compacte (objectif : tenir sur une page ; débordement multipage seulement si le nombre d'employés l'impose) | +| Regroupement | Une section par site | +| Accès | `ROLE_ADMIN` uniquement | + +## Comportement frontend + +### Bouton + +- Dans `frontend/pages/hours.vue`, à droite du titre « Heures » (le conteneur titre est + déjà `flex flex-wrap items-center justify-between`). +- Visible uniquement si `isAdmin` (déjà exposé par `useHoursPage`). +- Style cohérent avec les autres boutons d'action de l'app ; libellé « Exporter » + (préfixe non requis ici, ce n'est pas un « + Ajouter »). + +### Drawer `HoursDayExportDrawer.vue` + +Nouveau composant utilisant `AppDrawer` (mode create — bouton centré). + +Champs : +1. **Date** — champ date (input date), prérempli avec `selectedDate` de l'écran. +2. **Sites** — `MalioSelectCheckbox` avec `display-select-all`, mêmes options que la + toolbar (`sites` du composable), présélectionné sur `selectedSiteIds` courants. + +Bouton **« Exporter »** : désactivé si aucune date ou aucun site sélectionné. + +### Déclenchement + +- À la validation : `usePdfPrinter().printPdf(url)` avec + `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3`. +- Le téléchargement réutilise le pattern blob existant (`usePdfPrinter`). +- État `isLoading` sur le bouton pendant la génération. + +### Câblage dans `hours.vue` / `useHoursPage.ts` + +- `hours.vue` gère l'état d'ouverture du drawer et passe `sites`, `selectedSiteIds`, + `selectedDate`, `isAdmin`. +- L'appel d'export peut vivre dans un petit handler local (`hours.vue`) ou dans le + composable ; au choix de l'implémentation, en gardant `useHoursPage` comme source des + données affichées. + +## Portée des données (identique à l'écran Jour) + +- Employés **non-conducteurs** (`isDriver !== true`). +- **Sous contrat** à la date choisie. +- Appartenant aux **sites cochés**. +- **Tous les employés sous contrat sont affichés**, même sans saisie (lignes vides) — + cohérent avec la règle des exports heures annuelles. + +## Colonnes du PDF + +Mêmes colonnes que la vue Jour, **sans la colonne Valider** : + +`Nom` · `Statut` · `Début matin` · `Fin matin` · `Début après-midi` · +`Fin après-midi` · `Début soir` · `Fin soir` · `Jour` · `Nuit` · `Total` + +- **Statut** : libellé d'absence (ou formation, ou nom du férié) si présent, sinon vide. +- **Heures** (`Début/Fin` matin/après-midi/soir) : valeurs `WorkHour` brutes (`HH:MM`), + vides si non saisies. +- **Jour / Nuit / Total** : calculés comme à l'écran — minutes jour vs nuit, total + incluant le crédit d'absence (`countAsWorkedHours`) et le **crédit virtuel férié** + (`HolidayVirtualHoursResolver`). +- Week-ends / fériés : lignes grisées/colorées comme dans les templates existants. + +## Architecture backend + +### ApiResource `WorkHourDayExport` + +`src/ApiResource/WorkHourDayExport.php` — calqué sur `EmployeeYearlyHoursBulkPrint` : + +```php +new Get( + uriTemplate: '/work-hours/day-export', + provider: WorkHourDayExportProvider::class, + parameters: [ + new QueryParameter(key: 'workDate', required: true), + new QueryParameter(key: 'siteIds', required: true), + ], + security: "is_granted('ROLE_ADMIN')" +) +``` + +### Provider `WorkHourDayExportProvider` + +`src/State/WorkHourDayExportProvider.php` : + +1. Lire/valider `workDate` (`Y-m-d`) et `siteIds` (CSV d'entiers). +2. Charger les employés (`EmployeeRepository::findAll()` — feature admin-only), + filtrer : non-drivers, site ∈ siteIds. +3. Pour chaque site (ordre `displayOrder`), trier les employés par nom. +4. Filtrer les employés sous contrat à la date (le builder ignore déjà les jours hors + contrat — un employé sans contrat ce jour produit une ligne vide à exclure). +5. Construire les lignes via `YearlyHoursExportBuilder` (méthode dédiée, voir ci-dessous). +6. Rendre le Twig → Dompdf (`A4`, `portrait`), renvoyer `Response` binaire avec + `Content-Disposition: attachment; filename="heures_jour_YYYY-MM-DD.pdf"`. + +### Réutilisation `YearlyHoursExportBuilder` + +Ajouter une méthode publique : + +```php +/** + * @param list $employees + * @return list + */ +public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array +``` + +- Réutilise les helpers privés existants (`computeMetrics`, résolution d'absences, + `HolidayVirtualHoursResolver`, `EmployeeContractResolver`, fériés) — **source unique + de vérité** pour le calcul des cellules d'une journée. +- Émet en plus `dayHours` / `nightHours` (issus de `WorkMetrics.dayMinutes` / + `nightMinutes`) que l'export annuel n'affichait pas par ligne en mode TIME. +- Les employés sans contrat ce jour sont exclus (pas de ligne). +- Le `statut` agrège absence / formation / libellé férié (réutilise la logique de + résolution d'absence/formation déjà présente dans le contexte jour si nécessaire). + +> Note : la vue Jour mélange potentiellement modes TIME et PRESENCE selon le contrat à +> la date. Pour l'export, on suit le mode résolu à la date (comme l'écran). En mode +> PRESENCE, les cellules horaires restent vides et `Total` exprime les demi-journées, +> identique à l'affichage écran. + +### Template `templates/work-hour-day-export/print.html.twig` + +- A4 portrait, marges fines, police ~9px (réf. `employee-yearly-hours/print.html.twig`). +- Barre de titre : « Heures — {date} » + date d'export en haut à droite. +- Une `

` par site, suivie d'un tableau avec les 11 colonnes ci-dessus. +- Week-ends / fériés grisés (`#c0c0c0` / `#b3e5fc`) comme les templates existants. +- `table-layout: auto`, largeurs compactes pour viser une page. + +## Limites connues + +- Un grand nombre d'employés (beaucoup de sites cochés) peut déborder sur plusieurs + pages — on vise une page sans la garantir. +- Pas de risque mémoire particulier (un seul jour, volume très inférieur à l'export + annuel tous employés). + +## Documentation à mettre à jour (règles CLAUDE.md) + +1. `doc/` : nouvelle section (ou ajout à un doc heures existant) décrivant l'export jour. +2. `frontend/data/documentation-content.ts` : entrée niveau **admin** dans la section + Heures. +3. `CLAUDE.md` : note sous la section heures/exports (provider, builder réutilisé, + colonnes, scope identique écran, portrait). + +## Tests + +- Test unitaire `YearlyHoursExportBuilder::buildDayRowsForEmployees` : un employé TIME + avec saisie (vérifier day/night/total), un employé sans contrat (exclu), un jour férié + (crédit virtuel), une absence `countAsWorkedHours`. +- (Optionnel) test provider : validation des paramètres `workDate` / `siteIds`. -- 2.39.5 From b917fd2e416d0482ed92bbb2664660338ccef9bf Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:21:41 +0200 Subject: [PATCH 02/16] feat(heures) : calcul des lignes jour pour export PDF Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WorkHours/YearlyHoursExportBuilder.php | 131 +++++++++++++++++- .../WorkHours/YearlyHoursDayRowsTest.php | 112 +++++++++++++++ 2 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 tests/Service/WorkHours/YearlyHoursDayRowsTest.php diff --git a/src/Service/WorkHours/YearlyHoursExportBuilder.php b/src/Service/WorkHours/YearlyHoursExportBuilder.php index 7b67a00..1399ef5 100644 --- a/src/Service/WorkHours/YearlyHoursExportBuilder.php +++ b/src/Service/WorkHours/YearlyHoursExportBuilder.php @@ -11,8 +11,8 @@ use App\Entity\WorkHour; use App\Enum\ContractNature; use App\Enum\ContractType; use App\Enum\TrackingMode; -use App\Repository\AbsenceRepository; -use App\Repository\WorkHourRepository; +use App\Repository\Contract\AbsenceReadRepositoryInterface; +use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Service\Contracts\EmployeeContractResolver; use App\Service\PublicHolidayServiceInterface; use DateInterval; @@ -22,8 +22,8 @@ use Throwable; class YearlyHoursExportBuilder { public function __construct( - private WorkHourRepository $workHourRepository, - private AbsenceRepository $absenceRepository, + private WorkHourReadRepositoryInterface $workHourRepository, + private AbsenceReadRepositoryInterface $absenceRepository, private EmployeeContractResolver $contractResolver, private AbsenceSegmentsResolver $absenceSegmentsResolver, private WorkedHoursCreditPolicy $workedHoursCreditPolicy, @@ -103,6 +103,129 @@ class YearlyHoursExportBuilder return $this->buildForEmployees([$employee], $from, $to); } + /** + * Construit une ligne par employé pour une seule journée (vue Jour de l'écran Heures). + * Réutilise les helpers de calcul de cellule pour rester l'unique source de vérité. + * Les employés sans contrat ce jour sont exclus (comme l'écran). + * + * @param list $employees + * + * @return list + */ + public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array + { + $ymd = $date->format('Y-m-d'); + $days = [$ymd]; + + $workHours = $this->workHourRepository->findByDateRangeAndEmployees($date, $date, $employees); + $absences = $this->absenceRepository->findForPrint($date, $date, $employees); + $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); + $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); + $workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days); + $holidayMap = $this->buildHolidayMap($date, $date); + + $workHourMap = $this->buildWorkHourMap($workHours); + $absenceMap = $this->buildAbsenceMap($absences, $days); + + $isoDay = (int) $date->format('N'); + $isWeekend = $isoDay >= 6; + $holidayLabel = $holidayMap[$ymd] ?? null; + + $rows = []; + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + $contract = $contractMap[$employeeId][$ymd] ?? null; + + // Hors contrat ce jour → exclu (avant embauche / après départ / suspension). + if (null === $contract) { + continue; + } + + $wh = $workHourMap[$employeeId][$ymd] ?? null; + $absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee); + $hasAbsence = $absenceData['hasDayAbsence'][$ymd] ?? false; + + $isDriver = $driverMap[$employeeId][$ymd] ?? false; + $mode = $this->resolveSegmentMode($contract->getTrackingMode(), $isDriver); + $creditedMinutes = $absenceData['credited'][$ymd] ?? 0; + $virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit( + $contract, + $date, + $hasAbsence, + $workDaysMap[$employeeId][$ymd] ?? null, + ); + + $statut = $absenceData['labels'][$ymd] ?? null; + if (null === $statut && null !== $holidayLabel) { + $statut = $holidayLabel; + } + + $row = [ + 'employeeId' => $employeeId, + 'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), + 'statut' => $statut, + 'morningFrom' => '', + 'morningTo' => '', + 'afternoonFrom' => '', + 'afternoonTo' => '', + 'eveningFrom' => '', + 'eveningTo' => '', + 'dayHours' => '', + 'nightHours' => '', + 'total' => '', + 'isWeekend' => $isWeekend, + 'isHoliday' => null !== $holidayLabel, + ]; + + if ('presence' === $mode) { + $absentMorning = $absenceData['absentMorning'][$ymd] ?? false; + $absentAfternoon = $absenceData['absentAfternoon'][$ymd] ?? false; + $morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0; + $afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0; + $total = $morning + $afternoon; + $row['total'] = $total > 0 ? (string) $total : ''; + } elseif ('driver' === $mode) { + $dayMin = $wh?->getDayHoursMinutes() ?? 0; + $nightMin = $wh?->getNightHoursMinutes() ?? 0; + $workshop = $wh?->getWorkshopHoursMinutes() ?? 0; + $totalMin = $dayMin + $nightMin + $workshop + $creditedMinutes; + if ($virtualMinutes > $totalMin) { + $totalMin = $virtualMinutes; + } + $row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : ''; + $row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : ''; + $row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : ''; + } else { + $metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics(); + $metrics->addCreditedMinutes($creditedMinutes); + $dayMin = $metrics->dayMinutes; + $nightMin = $metrics->nightMinutes; + $totalMin = $metrics->totalMinutes; + if ($virtualMinutes > $totalMin) { + $dayMin += $virtualMinutes - $totalMin; + $totalMin = $virtualMinutes; + } + + $row['morningFrom'] = $wh?->getMorningFrom() ?? ''; + $row['morningTo'] = $wh?->getMorningTo() ?? ''; + $row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? ''; + $row['afternoonTo'] = $wh?->getAfternoonTo() ?? ''; + $row['eveningFrom'] = $wh?->getEveningFrom() ?? ''; + $row['eveningTo'] = $wh?->getEveningTo() ?? ''; + $row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : ''; + $row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : ''; + $row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : ''; + } + + $rows[] = $row; + } + + return $rows; + } + public function buildContractLabel(Employee $employee): ?string { $contract = $employee->getContract(); diff --git a/tests/Service/WorkHours/YearlyHoursDayRowsTest.php b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php new file mode 100644 index 0000000..ba2974b --- /dev/null +++ b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php @@ -0,0 +1,112 @@ +setName('35h'); + $contract->setTrackingMode(Contract::TRACKING_TIME); + $contract->setWeeklyHours(35); + + $withContract = new Employee(); + $withContract->setFirstName('Jean')->setLastName('Dupont'); + $this->setEmployeeId($withContract, 1); + + $noContract = new Employee(); + $noContract->setFirstName('Paul')->setLastName('Martin'); + $this->setEmployeeId($noContract, 2); + + $workHour = new WorkHour(); + $workHour->setEmployee($withContract) + ->setWorkDate($date) + ->setMorningFrom('08:00')->setMorningTo('12:00') + ->setAfternoonFrom('13:00')->setAfternoonTo('17:00') + ; + + $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class); + $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$workHour]); + + $absenceRepo = $this->createStub(AbsenceReadRepositoryInterface::class); + $absenceRepo->method('findForPrint')->willReturn([]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveForEmployeesAndDays')->willReturn([ + 1 => ['2026-06-08' => $contract], + 2 => ['2026-06-08' => null], + ]); + $contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([ + 1 => ['2026-06-08' => false], + 2 => ['2026-06-08' => false], + ]); + $contractResolver->method('resolveWorkDaysMinutesForEmployeesAndDays')->willReturn([ + 1 => ['2026-06-08' => null], + 2 => ['2026-06-08' => null], + ]); + + $holidayService = $this->createStub(PublicHolidayServiceInterface::class); + $holidayService->method('getHolidaysDayByYears')->willReturn([]); + + // No holiday on this Monday → virtual credit resolves to 0 via the real resolver. + $virtualResolver = new HolidayVirtualHoursResolver( + new DailyReferenceMinutesResolver(), + $holidayService, + $contractResolver, + ); + + $builder = new YearlyHoursExportBuilder( + $workHourRepo, + $absenceRepo, + $contractResolver, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()), + $holidayService, + $virtualResolver, + ); + + $rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date); + + self::assertCount(1, $rows); + self::assertSame(1, $rows[0]['employeeId']); + self::assertSame('Dupont Jean', $rows[0]['employeeName']); + self::assertSame('08:00', $rows[0]['morningFrom']); + self::assertSame('17:00', $rows[0]['afternoonTo']); + self::assertSame('8h', $rows[0]['total']); + self::assertSame('8h', $rows[0]['dayHours']); + self::assertSame('', $rows[0]['nightHours']); + self::assertNull($rows[0]['statut']); + self::assertFalse($rows[0]['isWeekend']); + } + + private function setEmployeeId(Employee $employee, int $id): void + { + $ref = new ReflectionProperty(Employee::class, 'id'); + $ref->setAccessible(true); + $ref->setValue($employee, $id); + } +} -- 2.39.5 From f78e8500927e5b6d5d06d54269f3e4bb76dc6240 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:25:52 +0200 Subject: [PATCH 03/16] feat(heures) : gabarit PDF export jour --- .../work-hour-day-export/print.html.twig | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 templates/work-hour-day-export/print.html.twig diff --git a/templates/work-hour-day-export/print.html.twig b/templates/work-hour-day-export/print.html.twig new file mode 100644 index 0000000..d5b9e04 --- /dev/null +++ b/templates/work-hour-day-export/print.html.twig @@ -0,0 +1,68 @@ + + + + + Heures - {{ dateLabel }} + + + +
+

Heures du {{ dateLabel }}

+
Édité le {{ exportedAt }}
+
+ + {% for group in groups %} +
+

{{ group.siteName }}

+ + + + + + + + + + + + + + + + + + {% for row in group.rows %} + + + + + + + + + + + + + + {% endfor %} + +
NomStatutDébut matinFin matinDébut après-midiFin après-midiDébut soirFin soirJourNuitTotal
{{ row.employeeName }}{{ row.statut }}{{ row.morningFrom }}{{ row.morningTo }}{{ row.afternoonFrom }}{{ row.afternoonTo }}{{ row.eveningFrom }}{{ row.eveningTo }}{{ row.dayHours }}{{ row.nightHours }}{{ row.total }}
+
+ {% endfor %} + + -- 2.39.5 From 15231d5a33412daca38e9854b662898995322253 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:27:41 +0200 Subject: [PATCH 04/16] feat(heures) : endpoint export PDF heures jour par sites Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ApiResource/WorkHourDayExport.php | 25 ++++++ src/State/WorkHourDayExportProvider.php | 109 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/ApiResource/WorkHourDayExport.php create mode 100644 src/State/WorkHourDayExportProvider.php diff --git a/src/ApiResource/WorkHourDayExport.php b/src/ApiResource/WorkHourDayExport.php new file mode 100644 index 0000000..b8eaf3f --- /dev/null +++ b/src/ApiResource/WorkHourDayExport.php @@ -0,0 +1,25 @@ +requestStack->getCurrentRequest(); + if (!$request) { + return new Response('Missing request.', Response::HTTP_BAD_REQUEST); + } + + $workDateRaw = (string) $request->query->get('workDate'); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDateRaw)) { + throw new UnprocessableEntityHttpException('workDate must use YYYY-MM-DD format.'); + } + $date = new DateTimeImmutable($workDateRaw); + + $siteIdsRaw = (string) $request->query->get('siteIds', ''); + $siteIds = array_values(array_filter(array_map( + static fn (string $value): int => (int) trim($value), + explode(',', $siteIdsRaw), + ), static fn (int $id): bool => $id > 0)); + if ([] === $siteIds) { + throw new UnprocessableEntityHttpException('siteIds is required.'); + } + + // Feature réservée admin : on charge tous les employés puis on filtre. + $employees = $this->employeeRepository->findAll(); + + // Regroupement par site (ordre displayOrder), non-conducteurs uniquement. + $bySite = []; + $siteMeta = []; + foreach ($employees as $employee) { + if (true === $employee->getIsDriver()) { + continue; + } + $site = $employee->getSite(); + if (null === $site || !in_array($site->getId(), $siteIds, true)) { + continue; + } + $siteId = $site->getId(); + $bySite[$siteId][] = $employee; + $siteMeta[$siteId] ??= [ + 'name' => $site->getName(), + 'order' => $site->getDisplayOrder(), + ]; + } + + uasort($siteMeta, static function (array $a, array $b): int { + return [$a['order'], $a['name']] <=> [$b['order'], $b['name']]; + }); + + $groups = []; + foreach ($siteMeta as $siteId => $meta) { + $siteEmployees = $bySite[$siteId]; + usort($siteEmployees, static fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? '')); + + $rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date); + if ([] === $rows) { + continue; + } + $groups[] = ['siteName' => $meta['name'], 'rows' => $rows]; + } + + $options = new Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new Dompdf($options); + + $html = $this->twig->render('work-hour-day-export/print.html.twig', [ + 'groups' => $groups, + 'dateLabel' => $date->format('d/m/Y'), + 'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'), + ]); + + $dompdf->loadHtml($html); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + + $filename = sprintf('heures_jour_%s.pdf', $date->format('Y-m-d')); + + return new Response($dompdf->output(), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]); + } +} -- 2.39.5 From c3f3b83b87a6e60e9f0965cf4415b6e02f798705 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:32:49 +0200 Subject: [PATCH 05/16] feat(heures) : drawer d'export PDF jour --- .../components/hours/HoursDayExportDrawer.vue | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 frontend/components/hours/HoursDayExportDrawer.vue diff --git a/frontend/components/hours/HoursDayExportDrawer.vue b/frontend/components/hours/HoursDayExportDrawer.vue new file mode 100644 index 0000000..a7d9b61 --- /dev/null +++ b/frontend/components/hours/HoursDayExportDrawer.vue @@ -0,0 +1,87 @@ + + + -- 2.39.5 From 0ef609b2e2be03bbbe43ae28252070a00409b5d7 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:35:17 +0200 Subject: [PATCH 06/16] feat(heures) : bouton export PDF jour (admin) --- frontend/pages/hours.vue | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue index ed5456b..269252e 100644 --- a/frontend/pages/hours.vue +++ b/frontend/pages/hours.vue @@ -2,8 +2,26 @@

Heures

+
+ + { + isExporting.value = true + try { + const siteIdsParam = payload.siteIds.join(',') + await printPdf(`/work-hours/day-export?workDate=${payload.date}&siteIds=${siteIdsParam}`) + isExportDrawerOpen.value = false + } finally { + isExporting.value = false + } +} + useHead({ title: 'Heures' }) -- 2.39.5 From 7fbd4029c794f0aa8851bb7c647402a4a287b4e3 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:37:11 +0200 Subject: [PATCH 07/16] docs(heures) : documenter l'export PDF jour --- CLAUDE.md | 1 + doc/hours-day-export.md | 25 +++++++++++++++++++++++++ frontend/data/documentation-content.ts | 9 +++++++++ 3 files changed, 35 insertions(+) create mode 100644 doc/hours-day-export.md diff --git a/CLAUDE.md b/CLAUDE.md index 9af7a88..513bda2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). - **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin. +- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_ADMIN`) : bouton « Exporter » à droite du titre « Heures ». PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »**. Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Gabarit `templates/work-hour-day-export/print.html.twig`. - **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin. - **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`. - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots diff --git a/doc/hours-day-export.md b/doc/hours-day-export.md new file mode 100644 index 0000000..e211204 --- /dev/null +++ b/doc/hours-day-export.md @@ -0,0 +1,25 @@ +# Export PDF des heures — vue Jour + +Bouton **Exporter** à droite du titre « Heures », visible **uniquement pour les +administrateurs** (`ROLE_ADMIN`). + +## Comportement +- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à + cocher des sites** (présélectionnées sur le filtre courant). +- Génère un **PDF A4 portrait** d'une seule journée, **regroupé par site**. + +## Données +- Mêmes employés que la vue Jour : **non-conducteurs**, **sous contrat** à la date + choisie, des sites cochés. Les employés sous contrat sans saisie apparaissent (lignes + vides). +- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi · + Début soir · Fin soir · Jour · Nuit · Total. **Pas de colonne « Valider ».** +- Jour / Nuit / Total : identiques à l'écran (crédit d'absence `countAsWorkedHours` et + crédit virtuel férié inclus). + +## Technique +- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`). +- Provider : `App\State\WorkHourDayExportProvider`. +- Calcul des cellules : `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source + unique de vérité, partagée avec les exports annuels). +- Gabarit : `templates/work-hour-day-export/print.html.twig`. diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 8b16866..74e6a39 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -81,6 +81,15 @@ export const documentationSections: DocSection[] = [ { type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24:00\nTotal : somme des heures de jour et de nuit' }, ], }, + { + id: 'export-heures-jour', + title: 'Exporter les heures (PDF par jour)', + requiredLevel: 'admin', + blocks: [ + { type: 'paragraph', content: 'Le bouton « Exporter », à droite du titre « Heures », ouvre un panneau permettant de générer un PDF des heures d\'une journée. Choisissez la date et les sites concernés.' }, + { type: 'paragraph', content: 'Le PDF est organisé par site et reprend les colonnes de la vue Jour (nom, statut, horaires matin/après-midi/soir, jour, nuit, total), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.' }, + ], + }, { id: 'commentaire-semaine', title: 'Commentaires de semaine (admin)', -- 2.39.5 From b7f602bc7b1162945461181d272bc36c9717bce6 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:41:01 +0200 Subject: [PATCH 08/16] =?UTF-8?q?docs=20:=20plan=20d'impl=C3=A9mentation?= =?UTF-8?q?=20export=20PDF=20heures=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-08-hours-day-export.md | 854 ++++++++++++++++++ 1 file changed, 854 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-hours-day-export.md diff --git a/docs/superpowers/plans/2026-06-08-hours-day-export.md b/docs/superpowers/plans/2026-06-08-hours-day-export.md new file mode 100644 index 0000000..5368c1b --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-hours-day-export.md @@ -0,0 +1,854 @@ +# Export PDF des heures — vue Jour — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter un bouton « Exporter » (admin) sur l'écran Heures qui génère un PDF d'une journée (colonnes de la vue Jour, sans Valider) pour les employés des sites sélectionnés, regroupés par site. + +**Architecture:** Réutilisation de `YearlyHoursExportBuilder` (nouvelle méthode `buildDayRowsForEmployees`) pour le calcul des cellules d'une journée — source unique de vérité. Une `ApiResource` GET `/work-hours/day-export` + provider rend un Twig A4 portrait via Dompdf. Côté front, un `AppDrawer` (date + checkboxes sites) déclenche le téléchargement via `usePdfPrinter`. + +**Tech Stack:** Symfony + API Platform + Doctrine, Dompdf, Twig ; Nuxt 4 + Vue 3 + TypeScript + `@malio/layer-ui`. + +--- + +## File Structure + +**Backend** +- `src/Service/WorkHours/YearlyHoursExportBuilder.php` (modifier) — ajout méthode publique `buildDayRowsForEmployees`. +- `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` (créer) — test unitaire de la nouvelle méthode. +- `src/ApiResource/WorkHourDayExport.php` (créer) — opération GET `/work-hours/day-export`. +- `src/State/WorkHourDayExportProvider.php` (créer) — parse params, scope/filtre/groupe, rend le PDF. +- `templates/work-hour-day-export/print.html.twig` (créer) — gabarit A4 portrait. + +**Frontend** +- `frontend/components/hours/HoursDayExportDrawer.vue` (créer) — drawer date + sites. +- `frontend/pages/hours.vue` (modifier) — bouton « Exporter » + câblage drawer + appel export. + +**Docs** +- `doc/hours-day-export.md` (créer). +- `frontend/data/documentation-content.ts` (modifier) — entrée admin. +- `CLAUDE.md` (modifier) — note sous la section exports heures. + +--- + +## Task 1 : Méthode `buildDayRowsForEmployees` (backend, TDD) + +**Files:** +- Test: `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` +- Modify: `src/Service/WorkHours/YearlyHoursExportBuilder.php` + +- [ ] **Step 1 : Écrire le test qui échoue** + +Créer `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` : + +```php +setName('35h'); + $contract->setTrackingMode(Contract::TRACKING_TIME); + $contract->setWeeklyHours(35); + + $withContract = new Employee(); + $withContract->setFirstName('Jean')->setLastName('Dupont'); + $this->setEmployeeId($withContract, 1); + + $noContract = new Employee(); + $noContract->setFirstName('Paul')->setLastName('Martin'); + $this->setEmployeeId($noContract, 2); + + $workHour = new WorkHour(); + $workHour->setEmployee($withContract) + ->setWorkDate($date) + ->setMorningFrom('08:00')->setMorningTo('12:00') + ->setAfternoonFrom('13:00')->setAfternoonTo('17:00'); + + $workHourRepo = $this->createStub(WorkHourRepository::class); + $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$workHour]); + + $absenceRepo = $this->createStub(AbsenceRepository::class); + $absenceRepo->method('findForPrint')->willReturn([]); + + $contractResolver = $this->createStub(EmployeeContractResolver::class); + $contractResolver->method('resolveForEmployeesAndDays')->willReturn([ + 1 => ['2026-06-08' => $contract], + 2 => ['2026-06-08' => null], + ]); + $contractResolver->method('resolveIsDriverForEmployeesAndDays')->willReturn([ + 1 => ['2026-06-08' => false], + 2 => ['2026-06-08' => false], + ]); + $contractResolver->method('resolveWorkDaysMinutesForEmployeesAndDays')->willReturn([ + 1 => ['2026-06-08' => null], + 2 => ['2026-06-08' => null], + ]); + + $holidayService = $this->createStub(PublicHolidayServiceInterface::class); + $holidayService->method('getHolidaysDayByYears')->willReturn([]); + + $virtualResolver = $this->createStub(HolidayVirtualHoursResolver::class); + $virtualResolver->method('resolveVirtualCredit')->willReturn(0); + + $builder = new YearlyHoursExportBuilder( + $workHourRepo, + $absenceRepo, + $contractResolver, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()), + $holidayService, + $virtualResolver, + ); + + $rows = $builder->buildDayRowsForEmployees([$withContract, $noContract], $date); + + self::assertCount(1, $rows); + self::assertSame(1, $rows[0]['employeeId']); + self::assertSame('Dupont Jean', $rows[0]['employeeName']); + self::assertSame('08:00', $rows[0]['morningFrom']); + self::assertSame('17:00', $rows[0]['afternoonTo']); + self::assertSame('8:00', $rows[0]['total']); + self::assertSame('8:00', $rows[0]['dayHours']); + self::assertSame('', $rows[0]['nightHours']); + self::assertNull($rows[0]['statut']); + self::assertFalse($rows[0]['isWeekend']); + } + + private function setEmployeeId(Employee $employee, int $id): void + { + $ref = new \ReflectionProperty(Employee::class, 'id'); + $ref->setAccessible(true); + $ref->setValue($employee, $id); + } +} +``` + +- [ ] **Step 2 : Lancer le test, vérifier l'échec** + +Run: `make test` (ou `docker exec -t -u www-data php-sirh-fpm php vendor/bin/phpunit --filter YearlyHoursDayRowsTest`) +Expected: FAIL — `Call to undefined method ...::buildDayRowsForEmployees()`. + +- [ ] **Step 3 : Implémenter la méthode** + +Dans `src/Service/WorkHours/YearlyHoursExportBuilder.php`, ajouter cette méthode publique (après `buildForEmployee`, avant `buildContractLabel`) : + +```php + /** + * Construit une ligne par employé pour une seule journée (vue Jour de l'écran Heures). + * Réutilise les helpers de calcul de cellule pour rester l'unique source de vérité. + * Les employés sans contrat ce jour sont exclus (comme l'écran). + * + * @param list $employees + * + * @return list + */ + public function buildDayRowsForEmployees(array $employees, DateTimeImmutable $date): array + { + $ymd = $date->format('Y-m-d'); + $days = [$ymd]; + + $workHours = $this->workHourRepository->findByDateRangeAndEmployees($date, $date, $employees); + $absences = $this->absenceRepository->findForPrint($date, $date, $employees); + $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); + $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); + $workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days); + $holidayMap = $this->buildHolidayMap($date, $date); + + $workHourMap = $this->buildWorkHourMap($workHours); + $absenceMap = $this->buildAbsenceMap($absences, $days); + + $isoDay = (int) $date->format('N'); + $isWeekend = $isoDay >= 6; + $holidayLabel = $holidayMap[$ymd] ?? null; + + $rows = []; + foreach ($employees as $employee) { + $employeeId = $employee->getId(); + $contract = $contractMap[$employeeId][$ymd] ?? null; + + // Hors contrat ce jour → exclu (avant embauche / après départ / suspension). + if (null === $contract) { + continue; + } + + $wh = $workHourMap[$employeeId][$ymd] ?? null; + $absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee); + $hasAbsence = $absenceData['hasDayAbsence'][$ymd] ?? false; + + $isDriver = $driverMap[$employeeId][$ymd] ?? false; + $mode = $this->resolveSegmentMode($contract->getTrackingMode(), $isDriver); + $creditedMinutes = $absenceData['credited'][$ymd] ?? 0; + $virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit( + $contract, + $date, + $hasAbsence, + $workDaysMap[$employeeId][$ymd] ?? null, + ); + + $statut = $absenceData['labels'][$ymd] ?? null; + if (null === $statut && null !== $holidayLabel) { + $statut = $holidayLabel; + } + + $row = [ + 'employeeId' => $employeeId, + 'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), + 'statut' => $statut, + 'morningFrom' => '', + 'morningTo' => '', + 'afternoonFrom' => '', + 'afternoonTo' => '', + 'eveningFrom' => '', + 'eveningTo' => '', + 'dayHours' => '', + 'nightHours' => '', + 'total' => '', + 'isWeekend' => $isWeekend, + 'isHoliday' => null !== $holidayLabel, + ]; + + if ('presence' === $mode) { + $absentMorning = $absenceData['absentMorning'][$ymd] ?? false; + $absentAfternoon = $absenceData['absentAfternoon'][$ymd] ?? false; + $morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0; + $afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0; + $total = $morning + $afternoon; + $row['total'] = $total > 0 ? (string) $total : ''; + } elseif ('driver' === $mode) { + $dayMin = $wh?->getDayHoursMinutes() ?? 0; + $nightMin = $wh?->getNightHoursMinutes() ?? 0; + $workshop = $wh?->getWorkshopHoursMinutes() ?? 0; + $totalMin = $dayMin + $nightMin + $workshop + $creditedMinutes; + if ($virtualMinutes > $totalMin) { + $totalMin = $virtualMinutes; + } + $row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : ''; + $row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : ''; + $row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : ''; + } else { + $metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics(); + $metrics->addCreditedMinutes($creditedMinutes); + $dayMin = $metrics->dayMinutes; + $nightMin = $metrics->nightMinutes; + $totalMin = $metrics->totalMinutes; + if ($virtualMinutes > $totalMin) { + $dayMin += $virtualMinutes - $totalMin; + $totalMin = $virtualMinutes; + } + + $row['morningFrom'] = $wh?->getMorningFrom() ?? ''; + $row['morningTo'] = $wh?->getMorningTo() ?? ''; + $row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? ''; + $row['afternoonTo'] = $wh?->getAfternoonTo() ?? ''; + $row['eveningFrom'] = $wh?->getEveningFrom() ?? ''; + $row['eveningTo'] = $wh?->getEveningTo() ?? ''; + $row['dayHours'] = $dayMin > 0 ? $this->formatMinutes($dayMin) : ''; + $row['nightHours'] = $nightMin > 0 ? $this->formatMinutes($nightMin) : ''; + $row['total'] = $totalMin > 0 ? $this->formatMinutes($totalMin) : ''; + } + + $rows[] = $row; + } + + return $rows; + } +``` + +- [ ] **Step 4 : Relancer le test, vérifier le succès** + +Run: `make test` +Expected: PASS (toute la suite verte). + +- [ ] **Step 5 : Commit** + +```bash +git add tests/Service/WorkHours/YearlyHoursDayRowsTest.php src/Service/WorkHours/YearlyHoursExportBuilder.php +git commit -m "feat(heures) : calcul des lignes jour pour export PDF" +``` + +--- + +## Task 2 : Gabarit Twig + +**Files:** +- Create: `templates/work-hour-day-export/print.html.twig` + +- [ ] **Step 1 : Créer le gabarit** + +```twig + + + + + Heures - {{ dateLabel }} + + + +
+

Heures du {{ dateLabel }}

+
Édité le {{ exportedAt }}
+
+ + {% for group in groups %} +
+

{{ group.siteName }}

+ + + + + + + + + + + + + + + + + + {% for row in group.rows %} + + + + + + + + + + + + + + {% endfor %} + +
NomStatutDébut matinFin matinDébut après-midiFin après-midiDébut soirFin soirJourNuitTotal
{{ row.employeeName }}{{ row.statut }}{{ row.morningFrom }}{{ row.morningTo }}{{ row.afternoonFrom }}{{ row.afternoonTo }}{{ row.eveningFrom }}{{ row.eveningTo }}{{ row.dayHours }}{{ row.nightHours }}{{ row.total }}
+
+ {% endfor %} + + +``` + +- [ ] **Step 2 : Commit** + +```bash +git add templates/work-hour-day-export/print.html.twig +git commit -m "feat(heures) : gabarit PDF export jour" +``` + +--- + +## Task 3 : ApiResource + Provider + +**Files:** +- Create: `src/ApiResource/WorkHourDayExport.php` +- Create: `src/State/WorkHourDayExportProvider.php` + +- [ ] **Step 1 : Créer l'ApiResource** + +`src/ApiResource/WorkHourDayExport.php` : + +```php +requestStack->getCurrentRequest(); + if (!$request) { + return new Response('Missing request.', Response::HTTP_BAD_REQUEST); + } + + $workDateRaw = (string) $request->query->get('workDate'); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $workDateRaw)) { + throw new UnprocessableEntityHttpException('workDate must use YYYY-MM-DD format.'); + } + $date = new DateTimeImmutable($workDateRaw); + + $siteIdsRaw = (string) $request->query->get('siteIds', ''); + $siteIds = array_values(array_filter(array_map( + static fn (string $value): int => (int) trim($value), + explode(',', $siteIdsRaw), + ), static fn (int $id): bool => $id > 0)); + if ([] === $siteIds) { + throw new UnprocessableEntityHttpException('siteIds is required.'); + } + + // Feature réservée admin : on charge tous les employés puis on filtre. + $employees = $this->employeeRepository->findAll(); + + // Regroupement par site (ordre displayOrder), non-conducteurs uniquement. + $bySite = []; + $siteMeta = []; + foreach ($employees as $employee) { + if (true === $employee->getIsDriver()) { + continue; + } + $site = $employee->getSite(); + if (null === $site || !in_array($site->getId(), $siteIds, true)) { + continue; + } + $siteId = $site->getId(); + $bySite[$siteId][] = $employee; + $siteMeta[$siteId] ??= [ + 'name' => $site->getName(), + 'order' => $site->getDisplayOrder() ?? 0, + ]; + } + + uasort($siteMeta, static function (array $a, array $b): int { + return [$a['order'], $a['name']] <=> [$b['order'], $b['name']]; + }); + + $groups = []; + foreach ($siteMeta as $siteId => $meta) { + $siteEmployees = $bySite[$siteId]; + usort($siteEmployees, static fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? '')); + + $rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date); + if ([] === $rows) { + continue; + } + $groups[] = ['siteName' => $meta['name'], 'rows' => $rows]; + } + + $options = new Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new Dompdf($options); + + $html = $this->twig->render('work-hour-day-export/print.html.twig', [ + 'groups' => $groups, + 'dateLabel' => $date->format('d/m/Y'), + 'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'), + ]); + + $dompdf->loadHtml($html); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + + $filename = sprintf('heures_jour_%s.pdf', $date->format('Y-m-d')); + + return new Response($dompdf->output(), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => sprintf('attachment; filename="%s"', $filename), + ]); + } +} +``` + +- [ ] **Step 3 : Vérifier les getters utilisés** + +Run: `grep -n "function getIsDriver\|function getSite\b\|function getDisplayOrder\|function getName" src/Entity/Employee.php src/Entity/Site.php` +Expected: les méthodes `Employee::getIsDriver()`, `Employee::getSite()`, `Site::getDisplayOrder()`, `Site::getName()` existent. Si `getIsDriver` n'existe pas, utiliser le getter réel (ex. `isDriver()`), idem pour `getDisplayOrder`. + +- [ ] **Step 4 : Vider le cache et vérifier la route** + +Run: `php bin/console cache:clear && php bin/console debug:router | grep day-export` +Expected: la route `/work-hours/day-export` apparaît. + +- [ ] **Step 5 : Lancer la suite backend** + +Run: `make test` +Expected: PASS. + +- [ ] **Step 6 : Commit** + +```bash +git add src/ApiResource/WorkHourDayExport.php src/State/WorkHourDayExportProvider.php +git commit -m "feat(heures) : endpoint export PDF heures jour par sites" +``` + +--- + +## Task 4 : Drawer frontend + +**Files:** +- Create: `frontend/components/hours/HoursDayExportDrawer.vue` + +- [ ] **Step 1 : Créer le composant** + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier le type `Site` et l'option `MalioSelectCheckbox`** + +Run: `grep -rn "export type Site\|export interface Site" frontend/services/dto/ ; grep -n "value\|label\|options" node_modules/@malio/layer-ui/COMPONENTS.md | grep -i "selectcheckbox" ` +Expected: confirmer le chemin d'import `Site` (ajuster `~/services/dto/site` si nécessaire — cf. import existant dans `HoursToolbar.vue`) et la forme des `options` (`{ value, label }`) attendue par `MalioSelectCheckbox`. Aligner sur l'usage existant dans `HoursToolbar.vue`. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/components/hours/HoursDayExportDrawer.vue +git commit -m "feat(heures) : drawer d'export PDF jour" +``` + +--- + +## Task 5 : Bouton + câblage dans `hours.vue` + +**Files:** +- Modify: `frontend/pages/hours.vue` + +- [ ] **Step 1 : Ajouter le bouton dans l'en-tête** + +Remplacer le bloc titre (lignes ~3-5) : + +```html +
+

Heures

+
+``` + +par : + +```html +
+

Heures

+ +
+ + +``` + +> Note : si `Icon` n'est pas auto-importé dans ce projet, retirer la balise `` et garder uniquement le texte « Exporter ». Vérifier l'usage d'`Icon` ailleurs dans `frontend/` avant. + +- [ ] **Step 2 : Ajouter l'état et le handler dans le `