Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions 2802f9524c chore: bump version to v0.1.110
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 33s
2026-06-09 15:35:39 +00:00
tristan 589018064b feat(heures) : tri des employés par ordre manuel sur l'export PDF jour (#26)
Auto Tag Develop / tag (push) Successful in 7s
Le tri intra-site de l'export PDF des heures (vue Jour) reprend désormais celui du calendrier : **`displayOrder` (ordre manuel) → nom → prénom**, au lieu du nom seul.

`doc/` (CLAUDE.md) mis à jour. Tests backend verts (173/361).

Reviewed-on: #26
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 15:35:32 +00:00
gitea-actions 9cc5024e25 chore: bump version to v0.1.109
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-06-09 14:04:56 +00:00
tristan b6c0dfb90b feat(heures) : codes d'absence, total en gras et légende sur l'export PDF jour (#25)
Auto Tag Develop / tag (push) Successful in 7s
Affinements de l'export PDF des heures (vue Jour) :

- **Colonne Statut** : affiche le **code** du type d'absence (ex. `AT`) au lieu du libellé, sur sa couleur de fond. Férié sans absence inchangé (nom du férié sur fond bleu clair).
- **Colonne Total** en gras.
- **Légende** sous le tableau : carré coloré contenant le code + libellé à droite, 6 éléments par ligne, triée et dédupliquée (hors férié).
- **Bouton Exporter masqué en vue Semaine** (visible uniquement en vue Jour).

Docs mises à jour : `doc/hours-day-export.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md`. Tests backend verts (173/361).

Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 14:04:50 +00:00
10 changed files with 72 additions and 16 deletions
+1 -1
View File
@@ -35,7 +35,7 @@
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - 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). - **É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. - **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). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). 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. - **É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`. - **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 - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.108' app.version: '0.1.110'
+7 -2
View File
@@ -1,7 +1,7 @@
# Export PDF des heures — vue Jour # Export PDF des heures — vue Jour
Bouton **Exporter** à droite du titre « Heures », visible **uniquement pour les 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 ## Comportement
- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à - 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 choisie, des sites cochés. Les employés sous contrat sans saisie apparaissent (lignes
vides). vides).
- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi · - 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 - Jour / Nuit / Total : identiques à l'écran (crédit d'absence `countAsWorkedHours` et
crédit virtuel férié inclus). 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 ## Technique
- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`). - Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`).
@@ -23,6 +23,7 @@
groupClass="w-full mt-2" groupClass="w-full mt-2"
label="Sites" label="Sites"
display-select-all display-select-all
display-tag
/> />
</div> </div>
@@ -49,7 +50,6 @@ const props = defineProps<{
modelValue: boolean modelValue: boolean
sites: Site[] sites: Site[]
initialDate: string initialDate: string
initialSiteIds: number[]
isLoading?: boolean isLoading?: boolean
}>() }>()
@@ -64,7 +64,7 @@ const drawerOpen = computed({
}) })
const selectedDate = ref(props.initialDate) const selectedDate = ref(props.initialDate)
const selectedSites = ref<number[]>([...props.initialSiteIds]) const selectedSites = ref<number[]>([])
const siteOptions = computed(() => const siteOptions = computed(() =>
props.sites.map((site) => ({ label: site.name, value: site.id })) props.sites.map((site) => ({ label: site.name, value: site.id }))
@@ -80,7 +80,7 @@ watch(
(isOpen) => { (isOpen) => {
if (isOpen) { if (isOpen) {
selectedDate.value = props.initialDate selectedDate.value = props.initialDate
selectedSites.value = [...props.initialSiteIds] selectedSites.value = []
} }
} }
) )
+3 -2
View File
@@ -86,8 +86,9 @@ export const documentationSections: DocSection[] = [
title: 'Exporter les heures (PDF par jour)', title: 'Exporter les heures (PDF par jour)',
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ 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 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), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.' }, { 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é.' },
], ],
}, },
{ {
+1 -2
View File
@@ -3,7 +3,7 @@
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1> <h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
<MalioButton <MalioButton
v-if="isAdmin" v-if="isAdmin && viewMode === 'day'"
label="Export" label="Export"
variant="secondary" variant="secondary"
icon-name="mdi:download" icon-name="mdi:download"
@@ -16,7 +16,6 @@
v-model="isExportDrawerOpen" v-model="isExportDrawerOpen"
:sites="sites" :sites="sites"
:initial-date="selectedDate" :initial-date="selectedDate"
:initial-site-ids="selectedSiteIds"
:is-loading="isExporting" :is-loading="isExporting"
@submit="handleExport" @submit="handleExport"
/> />
@@ -110,7 +110,7 @@ class YearlyHoursExportBuilder
* *
* @param list<Employee> $employees * @param list<Employee> $employees
* *
* @return list<array{employeeId:int, employeeName:string, statut:?string, statutColor:?string, * @return list<array{employeeId:int, employeeName:string, statut:?string, statutLabel:?string, statutColor:?string,
* morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string, * morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
* eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string, * eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
* total:string, isWeekend:bool, isHoliday:bool}> * total:string, isWeekend:bool, isHoliday:bool}>
@@ -158,11 +158,14 @@ class YearlyHoursExportBuilder
$workDaysMap[$employeeId][$ymd] ?? null, $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; $statutColor = ($absenceData['colors'][$ymd] ?? '') ?: null;
if (null === $statut && null !== $holidayLabel) { if (null === $statut && null !== $holidayLabel) {
// Férié sans absence : badge bleu clair, comme la vue Jour. // Férié sans absence : badge bleu clair, comme la vue Jour.
$statut = $holidayLabel; $statut = $holidayLabel;
$statutLabel = null;
$statutColor = '#b3e5fc'; $statutColor = '#b3e5fc';
} }
@@ -170,6 +173,7 @@ class YearlyHoursExportBuilder
'employeeId' => $employeeId, 'employeeId' => $employeeId,
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')), 'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
'statut' => $statut, 'statut' => $statut,
'statutLabel' => $statutLabel,
'statutColor' => $statutColor, 'statutColor' => $statutColor,
'morningFrom' => '', 'morningFrom' => '',
'morningTo' => '', 'morningTo' => '',
@@ -296,11 +300,12 @@ class YearlyHoursExportBuilder
} }
/** /**
* @return array{credited: array<string, int>, labels: array<string, string>, colors: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>} * @return array{credited: array<string, int>, codes: array<string, string>, labels: array<string, string>, colors: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
*/ */
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
{ {
$credited = []; $credited = [];
$codes = [];
$labels = []; $labels = [];
$colors = []; $colors = [];
$absentMorning = []; $absentMorning = [];
@@ -322,6 +327,7 @@ class YearlyHoursExportBuilder
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning; $absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon; $absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) { if (!isset($labels[$date])) {
$codes[$date] = $absence->getType()?->getCode() ?? '';
$labels[$date] = $absence->getType()?->getLabel() ?? ''; $labels[$date] = $absence->getType()?->getLabel() ?? '';
$colors[$date] = $absence->getType()?->getColor() ?? ''; $colors[$date] = $absence->getType()?->getColor() ?? '';
} }
@@ -334,6 +340,7 @@ class YearlyHoursExportBuilder
return [ return [
'credited' => $credited, 'credited' => $credited,
'codes' => $codes,
'labels' => $labels, 'labels' => $labels,
'colors' => $colors, 'colors' => $colors,
'absentMorning' => $absentMorning, 'absentMorning' => $absentMorning,
+21 -1
View File
@@ -75,16 +75,35 @@ class WorkHourDayExportProvider implements ProviderInterface
}); });
$groups = []; $groups = [];
$legend = [];
foreach ($siteMeta as $siteId => $meta) { foreach ($siteMeta as $siteId => $meta) {
$siteEmployees = $bySite[$siteId]; $siteEmployees = $bySite[$siteId];
usort($siteEmployees, static fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? '')); // Même tri que le calendrier : ordre manuel (displayOrder) puis nom, puis prénom.
usort($siteEmployees, static function ($a, $b): int {
return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()]
<=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()];
});
$rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date); $rows = $this->exportBuilder->buildDayRowsForEmployees($siteEmployees, $date);
if ([] === $rows) { if ([] === $rows) {
continue; continue;
} }
$groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $rows]; $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 = new Options();
$options->set('isRemoteEnabled', true); $options->set('isRemoteEnabled', true);
@@ -92,6 +111,7 @@ class WorkHourDayExportProvider implements ProviderInterface
$html = $this->twig->render('work-hour-day-export/print.html.twig', [ $html = $this->twig->render('work-hour-day-export/print.html.twig', [
'groups' => $groups, 'groups' => $groups,
'legend' => $legend,
'dateLabel' => $date->format('d/m/Y'), 'dateLabel' => $date->format('d/m/Y'),
'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'), 'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
]); ]);
+24 -1
View File
@@ -16,6 +16,13 @@
td.name { text-align: left; } 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.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; } 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; }
</style> </style>
</head> </head>
<body> <body>
@@ -57,11 +64,27 @@
<td>{{ row.eveningTo }}</td> <td>{{ row.eveningTo }}</td>
<td>{{ row.dayHours }}</td> <td>{{ row.dayHours }}</td>
<td>{{ row.nightHours }}</td> <td>{{ row.nightHours }}</td>
<td>{{ row.total }}</td> <td class="total">{{ row.total }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if legend is not empty %}
<table class="legend">
{% for chunk in legend|batch(6) %}
<tr>
<td class="legend-title">{% if loop.first %}Légende :{% endif %}</td>
{% for item in chunk %}
<td class="legend-box-cell">
<span class="legend-box" style="background: {{ item.color ?: '#e8e8e8' }};">{{ item.code }}</span>
</td>
<td class="legend-label">{{ item.label }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
{% endif %}
</body> </body>
</html> </html>
@@ -100,6 +100,7 @@ final class YearlyHoursDayRowsTest extends TestCase
self::assertSame('8h', $rows[0]['dayHours']); self::assertSame('8h', $rows[0]['dayHours']);
self::assertSame('', $rows[0]['nightHours']); self::assertSame('', $rows[0]['nightHours']);
self::assertNull($rows[0]['statut']); self::assertNull($rows[0]['statut']);
self::assertNull($rows[0]['statutLabel']);
self::assertNull($rows[0]['statutColor']); self::assertNull($rows[0]['statutColor']);
self::assertFalse($rows[0]['isWeekend']); self::assertFalse($rows[0]['isWeekend']);
} }