From b7f602bc7b1162945461181d272bc36c9717bce6 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 8 Jun 2026 17:41:01 +0200 Subject: [PATCH] =?UTF-8?q?docs=20:=20plan=20d'impl=C3=A9mentation=20expor?= =?UTF-8?q?t=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 `