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/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 ` 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)', diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue index ed5456b..33af560 100644 --- a/frontend/pages/hours.vue +++ b/frontend/pages/hours.vue @@ -2,8 +2,25 @@

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' }) 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 @@ +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; + $statutColor = ($absenceData['colors'][$ymd] ?? '') ?: null; + if (null === $statut && null !== $holidayLabel) { + // Férié sans absence : badge bleu clair, comme la vue Jour. + $statut = $holidayLabel; + $statutColor = '#b3e5fc'; + } + + $row = [ + 'employeeId' => $employeeId, + 'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), + 'statut' => $statut, + 'statutColor' => $statutColor, + '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(); @@ -169,12 +296,13 @@ class YearlyHoursExportBuilder } /** - * @return array{credited: array, labels: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} + * @return array{credited: array, labels: array, colors: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} */ private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array { $credited = []; $labels = []; + $colors = []; $absentMorning = []; $absentAfternoon = []; $hasDayAbsence = []; @@ -195,6 +323,7 @@ class YearlyHoursExportBuilder $absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon; if (!isset($labels[$date])) { $labels[$date] = $absence->getType()?->getLabel() ?? ''; + $colors[$date] = $absence->getType()?->getColor() ?? ''; } } @@ -206,6 +335,7 @@ class YearlyHoursExportBuilder return [ 'credited' => $credited, 'labels' => $labels, + 'colors' => $colors, 'absentMorning' => $absentMorning, 'absentAfternoon' => $absentAfternoon, 'hasDayAbsence' => $hasDayAbsence, diff --git a/src/State/WorkHourDayExportProvider.php b/src/State/WorkHourDayExportProvider.php new file mode 100644 index 0000000..4312dbf --- /dev/null +++ b/src/State/WorkHourDayExportProvider.php @@ -0,0 +1,110 @@ +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(), + 'color' => $site->getColor(), + ]; + } + + 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'], 'siteColor' => $meta['color'], '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), + ]); + } +} 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..cb16fb3 --- /dev/null +++ b/templates/work-hour-day-export/print.html.twig @@ -0,0 +1,67 @@ + + + + + Heures - {{ dateLabel }} + + + +
+

Heures du {{ dateLabel }}

+
Édité le {{ exportedAt }}
+
+ + + + + + + + + + + + + + + + + + + {% for group in groups %} + + + + {% for row in group.rows %} + + + {{ row.statut }} + + + + + + + + + + + {% endfor %} + {% endfor %} + +
NomStatutDébut matinFin matinDébut après-midiFin après-midiDébut soirFin soirJourNuitTotal
{{ group.siteName }}
{{ row.employeeName }}{{ row.morningFrom }}{{ row.morningTo }}{{ row.afternoonFrom }}{{ row.afternoonTo }}{{ row.eveningFrom }}{{ row.eveningTo }}{{ row.dayHours }}{{ row.nightHours }}{{ row.total }}
+ + diff --git a/tests/Service/WorkHours/YearlyHoursDayRowsTest.php b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php new file mode 100644 index 0000000..6ede5f6 --- /dev/null +++ b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php @@ -0,0 +1,113 @@ +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::assertNull($rows[0]['statutColor']); + 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); + } +}