feat(heures) : export Contingent heures de nuit (liste employés) (#28)
Auto Tag Develop / tag (push) Successful in 9s

## Résumé
Nouvel export PDF **Contingent heures de nuit** dans le drawer Export de la liste employés.

- PDF **A4 paysage** : lignes = employés (groupés par site, triés displayOrder/nom/prénom), colonnes = 12 mois civils, chaque mois avec 2 sous-colonnes **H.nuit** et **N.jours**.
- Heures de nuit = minutes dans la fenêtre **21h→6h** via un service partagé `NightHoursCalculator` (mutualisé avec `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` — duplication supprimée, sans changement de comportement).
- **Conducteurs inclus** via `WorkHour.nightHoursMinutes`. Statut conducteur résolu par date.
- **N.jours** = nb de jours où les minutes de nuit ≥ 240 (4h). Aucun crédit absence/férié.
- Périmètre via `EmployeeRepository::findScoped` (admin → tous, chef de site → ses sites), endpoint `GET /night-hours-contingent/print?year=YYYY` (`ROLE_USER`).
- Sélecteur d'année (année civile). Colonne Nom calibrée, séparateurs de mois épais.

## Composants
- Service `NightHoursCalculator`, builder `NightContingentExportBuilder`, DTO `NightContingentRow`
- Provider `NightHoursContingentPrintProvider` + opération API `NightHoursContingentPrint`
- Gabarit `templates/night-hours-contingent/print.html.twig`
- Option frontend dans `frontend/pages/employees/index.vue`
- Docs : `doc/functional-rules.md`, `CLAUDE.md`, `frontend/data/documentation-content.ts`

## Tests
- Nouveaux tests unitaires : `NightHoursCalculatorTest` (fenêtre 21h→6h, passage minuit, bornes), `NightContingentExportBuilderTest` (agrégation mensuelle, règle ≥4h=1j, conducteur, cas sans heures)
- Suite complète : **208 tests OK**
- Rendu PDF validé visuellement (Twig→Dompdf)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #28
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #28.
This commit is contained in:
2026-06-11 13:02:30 +00:00
committed by Autin
parent 49ad6306ea
commit b5bd4db5f1
20 changed files with 1974 additions and 140 deletions
+19 -3
View File
@@ -230,6 +230,16 @@
/>
</template>
<div v-else-if="exportChoice === 'night-contingent'">
<MalioSelect
:model-value="exportYear"
:options="exportYearOptions"
label="Année *"
min-width=""
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
label="Valider"
@@ -264,7 +274,7 @@ const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isExportDrawerOpen = ref(false)
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''>('')
const exportYear = ref<number>(new Date().getFullYear())
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
@@ -272,7 +282,8 @@ const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new
const exportTypeOptions = [
{ label: 'Récap. congés', value: 'leave-recap' },
{ label: 'Récap. salaire', value: 'salary-recap' },
{ label: 'Heures annuelles', value: 'yearly-hours' }
{ label: 'Heures annuelles', value: 'yearly-hours' },
{ label: 'Contingent H.nuit', value: 'night-contingent' }
]
const exportYearOptions = computed(() => {
const current = new Date().getFullYear()
@@ -301,11 +312,14 @@ const isExportValid = computed(() => {
if (exportChoice.value === 'yearly-hours') {
return exportYear.value > 0 && exportMonth.value !== ''
}
if (exportChoice.value === 'night-contingent') {
return exportYear.value > 0
}
return true
})
const onExportChoiceChange = (value: string | number | null) => {
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | ''
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''
}
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
@@ -618,6 +632,8 @@ const handleExportValidate = async () => {
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
} else if (choice === 'yearly-hours') {
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
} else if (choice === 'night-contingent') {
await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`)
}
}