From 73b34c2ea41af14a994857952172f29bf6639ff5 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 9 Jun 2026 16:03:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(heures)=20:=20codes=20d'absence,=20total?= =?UTF-8?q?=20en=20gras=20et=20l=C3=A9gende=20sur=20l'export=20PDF=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Colonne Statut affiche le code du type d'absence (ex. AT) au lieu du libellé - Colonne Total en gras - Légende sous le tableau (carré coloré + code + libellé), 6 par ligne - Bouton Exporter masqué en vue Semaine Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- doc/hours-day-export.md | 9 +++++-- frontend/data/documentation-content.ts | 5 ++-- frontend/pages/hours.vue | 2 +- .../WorkHours/YearlyHoursExportBuilder.php | 13 +++++++--- src/State/WorkHourDayExportProvider.php | 16 ++++++++++++ .../work-hour-day-export/print.html.twig | 25 ++++++++++++++++++- .../WorkHours/YearlyHoursDayRowsTest.php | 1 + 8 files changed, 63 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 513bda2..5fc905f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +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`. +- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_ADMIN`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="isAdmin && viewMode === 'day'"`, masqué en vue Semaine). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). 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é). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). 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 index e211204..5fb3e04 100644 --- a/doc/hours-day-export.md +++ b/doc/hours-day-export.md @@ -1,7 +1,7 @@ # Export PDF des heures — vue Jour Bouton **Exporter** à droite du titre « Heures », visible **uniquement pour les -administrateurs** (`ROLE_ADMIN`). +administrateurs** (`ROLE_ADMIN`) et **uniquement en vue Jour** (masqué en vue Semaine). ## Comportement - Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à @@ -13,9 +13,14 @@ administrateurs** (`ROLE_ADMIN`). 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 ».** + Début soir · Fin soir · Jour · Nuit · **Total** (en gras). **Pas de colonne « Valider ».** +- Colonne **Statut** : affiche le **code** du type d'absence (ex. `AT`), pas le libellé, + sur la couleur de fond du type. Un jour férié sans absence affiche le **nom du férié** + sur fond bleu clair (`#b3e5fc`). - Jour / Nuit / Total : identiques à l'écran (crédit d'absence `countAsWorkedHours` et crédit virtuel férié inclus). +- **Légende** sous le tableau : pour chaque code d'absence présent (hors férié), un carré + de couleur contenant le code et le libellé du type à droite. Triée par code, dédupliquée. ## Technique - Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`). diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 74e6a39..2bba8b4 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -86,8 +86,9 @@ export const documentationSections: DocSection[] = [ 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.' }, + { type: 'paragraph', content: 'Le bouton « Exporter », à droite du titre « Heures » (visible uniquement en vue Jour), 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 en gras), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.' }, + { type: 'paragraph', content: 'La colonne Statut affiche le code du type d\'absence (ex. « AT ») sur sa couleur. Une légende sous le tableau associe chaque code présent à son libellé.' }, ], }, { diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue index 33af560..b36de7c 100644 --- a/frontend/pages/hours.vue +++ b/frontend/pages/hours.vue @@ -3,7 +3,7 @@

Heures

$employees * - * @return list @@ -158,11 +158,14 @@ class YearlyHoursExportBuilder $workDaysMap[$employeeId][$ymd] ?? null, ); - $statut = $absenceData['labels'][$ymd] ?? null; + // Colonne Statut = code d'absence (ex. « AT »), pas le libellé. + $statut = ($absenceData['codes'][$ymd] ?? '') ?: null; + $statutLabel = $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; + $statutLabel = null; $statutColor = '#b3e5fc'; } @@ -170,6 +173,7 @@ class YearlyHoursExportBuilder 'employeeId' => $employeeId, 'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), 'statut' => $statut, + 'statutLabel' => $statutLabel, 'statutColor' => $statutColor, 'morningFrom' => '', 'morningTo' => '', @@ -296,11 +300,12 @@ class YearlyHoursExportBuilder } /** - * @return array{credited: array, labels: array, colors: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} + * @return array{credited: array, codes: array, labels: array, colors: array, absentMorning: array, absentAfternoon: array, hasDayAbsence: array} */ private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array { $credited = []; + $codes = []; $labels = []; $colors = []; $absentMorning = []; @@ -322,6 +327,7 @@ class YearlyHoursExportBuilder $absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning; $absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon; if (!isset($labels[$date])) { + $codes[$date] = $absence->getType()?->getCode() ?? ''; $labels[$date] = $absence->getType()?->getLabel() ?? ''; $colors[$date] = $absence->getType()?->getColor() ?? ''; } @@ -334,6 +340,7 @@ class YearlyHoursExportBuilder return [ 'credited' => $credited, + 'codes' => $codes, 'labels' => $labels, 'colors' => $colors, 'absentMorning' => $absentMorning, diff --git a/src/State/WorkHourDayExportProvider.php b/src/State/WorkHourDayExportProvider.php index 4312dbf..e9b4aa9 100644 --- a/src/State/WorkHourDayExportProvider.php +++ b/src/State/WorkHourDayExportProvider.php @@ -75,6 +75,7 @@ class WorkHourDayExportProvider implements ProviderInterface }); $groups = []; + $legend = []; foreach ($siteMeta as $siteId => $meta) { $siteEmployees = $bySite[$siteId]; usort($siteEmployees, static fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? '')); @@ -84,7 +85,21 @@ class WorkHourDayExportProvider implements ProviderInterface continue; } $groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $rows]; + + // Légende : codes d'absence présents (hors férié), dédupliqués par code. + foreach ($rows as $row) { + if ($row['isHoliday'] || null === $row['statut'] || null === $row['statutLabel']) { + continue; + } + $legend[$row['statut']] ??= [ + 'code' => $row['statut'], + 'label' => $row['statutLabel'], + 'color' => $row['statutColor'] ?? '#e8e8e8', + ]; + } } + ksort($legend); + $legend = array_values($legend); $options = new Options(); $options->set('isRemoteEnabled', true); @@ -92,6 +107,7 @@ class WorkHourDayExportProvider implements ProviderInterface $html = $this->twig->render('work-hour-day-export/print.html.twig', [ 'groups' => $groups, + 'legend' => $legend, 'dateLabel' => $date->format('d/m/Y'), 'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'), ]); diff --git a/templates/work-hour-day-export/print.html.twig b/templates/work-hour-day-export/print.html.twig index cb16fb3..7a4bfd3 100644 --- a/templates/work-hour-day-export/print.html.twig +++ b/templates/work-hour-day-export/print.html.twig @@ -16,6 +16,13 @@ td.name { text-align: left; } tr.site-title td { font-weight: bold; font-size: 11px; text-transform: uppercase; text-align: left; padding: 2px 6px; white-space: nowrap; } tr.weekend td { background: #c0c0c0; } + td.total { font-weight: bold; } + table.legend { width: auto; table-layout: auto; margin-top: 4mm; font-size: 10px; border: 0; border-collapse: collapse; } + table.legend td { border: 0; padding: 2px 0; vertical-align: middle; overflow: visible; white-space: nowrap; } + table.legend .legend-title { font-weight: bold; padding-right: 8px; } + table.legend .legend-box-cell { padding-left: 12px; } + table.legend .legend-box { display: inline-block; box-sizing: content-box; width: 14px; height: 14px; padding: 3px; line-height: 14px; text-align: center; font-weight: bold; font-size: 9px; } + table.legend .legend-label { padding-left: 4px; } @@ -57,11 +64,27 @@ {{ row.eveningTo }} {{ row.dayHours }} {{ row.nightHours }} - {{ row.total }} + {{ row.total }} {% endfor %} {% endfor %} + + {% if legend is not empty %} + + {% for chunk in legend|batch(6) %} + + + {% for item in chunk %} + + + {% endfor %} + + {% endfor %} +
{% if loop.first %}Légende :{% endif %} + {{ item.code }} + {{ item.label }}
+ {% endif %} diff --git a/tests/Service/WorkHours/YearlyHoursDayRowsTest.php b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php index 6ede5f6..8138be8 100644 --- a/tests/Service/WorkHours/YearlyHoursDayRowsTest.php +++ b/tests/Service/WorkHours/YearlyHoursDayRowsTest.php @@ -100,6 +100,7 @@ final class YearlyHoursDayRowsTest extends TestCase self::assertSame('8h', $rows[0]['dayHours']); self::assertSame('', $rows[0]['nightHours']); self::assertNull($rows[0]['statut']); + self::assertNull($rows[0]['statutLabel']); self::assertNull($rows[0]['statutColor']); self::assertFalse($rows[0]['isWeekend']); }