# 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 %}
Nom Statut Début matin Fin matin Début après-midi Fin après-midi Début soir Fin soir Jour Nuit Total
{{ 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 `