# Export « Contingent H.nuit » — 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 export PDF (A4 paysage) sur la liste des employés : tableau du contingent d'heures de nuit, employés en lignes, 12 mois en colonnes, chaque mois portant « Total H.nuit » et « Total N.jours ».

**Architecture:** Une opération API Platform read-only `GET /night-hours-contingent/print?year=YYYY` rend un PDF. Un `NightHoursContingentPrintProvider` résout le périmètre (`findScoped`), groupe/trie par site (comme le day-export), et délègue le calcul à un `NightContingentExportBuilder`. Le calcul des minutes de nuit (fenêtre 21h→6h, déjà en place) est extrait dans un service partagé `NightHoursCalculator` réutilisé par les deux providers existants.

**Tech Stack:** Symfony, API Platform (Provider), Doctrine, Dompdf, Twig, PHPUnit ; frontend Nuxt 4 / Vue 3.

---

## File Structure

- Create: `src/Service/WorkHours/NightHoursCalculator.php` — calcul des minutes de nuit (fenêtre 21h→6h) pour un `WorkHour`.
- Create: `tests/Service/WorkHours/NightHoursCalculatorTest.php`
- Modify: `src/Service/WorkHours/YearlyHoursExportBuilder.php` — délègue la nuit au calculateur.
- Modify: `src/State/WorkHourWeeklySummaryProvider.php` — délègue la nuit au calculateur.
- Modify: `tests/Service/WorkHours/YearlyHoursDayRowsTest.php` — ajoute l'argument constructeur.
- Create: `src/Dto/WorkHours/NightContingentRow.php` — DTO d'une ligne employé (12 mois).
- Create: `src/Service/WorkHours/NightContingentExportBuilder.php` — agrégation mensuelle.
- Create: `tests/Service/WorkHours/NightContingentExportBuilderTest.php`
- Create: `src/State/NightHoursContingentPrintProvider.php` — provider PDF.
- Create: `src/ApiResource/NightHoursContingentPrint.php` — opération API.
- Create: `templates/night-hours-contingent/print.html.twig` — gabarit paysage.
- Modify: `frontend/pages/employees/index.vue` — option de drawer + sélecteur d'année.
- Modify: `doc/functional-rules.md`, `CLAUDE.md`, `frontend/data/documentation-content.ts` — documentation.

---

## Task 1: Service partagé `NightHoursCalculator`

**Files:**
- Create: `src/Service/WorkHours/NightHoursCalculator.php`
- Test: `tests/Service/WorkHours/NightHoursCalculatorTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Service\WorkHours;

use App\Entity\WorkHour;
use App\Service\WorkHours\NightHoursCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;

/**
 * @internal
 */
final class NightHoursCalculatorTest extends TestCase
{
    public function testNullRangeReturnsZero(): void
    {
        $calc = new NightHoursCalculator();
        self::assertSame(0, $calc->nightIntervalMinutes(null, null));
        self::assertSame(0, $calc->nightIntervalMinutes('08:00', null));
    }

    public function testPureDayRangeHasNoNight(): void
    {
        $calc = new NightHoursCalculator();
        // 08:00 → 17:00 : entièrement hors fenêtres nuit (00:00-06:00, 21:00-24:00).
        self::assertSame(0, $calc->nightIntervalMinutes('08:00', '17:00'));
    }

    public function testEveningWindowCounts(): void
    {
        $calc = new NightHoursCalculator();
        // 21:00 → 24:00 = 180 min de nuit.
        self::assertSame(180, $calc->nightIntervalMinutes('21:00', '00:00'));
    }

    public function testShiftCrossingMidnightCountsBothWindows(): void
    {
        $calc = new NightHoursCalculator();
        // 21:00 → 05:00 : 21-24 (180) + 00-05 (300) = 480 min.
        self::assertSame(480, $calc->nightIntervalMinutes('21:00', '05:00'));
    }

    public function testNightMinutesForWorkHourDriverUsesManualField(): void
    {
        $calc = new NightHoursCalculator();
        $wh   = new WorkHour();
        $wh->setWorkDate(new DateTimeImmutable('2026-01-15'))
            ->setDayHoursMinutes(300)
            ->setNightHoursMinutes(250)
            ->setMorningFrom('08:00')->setMorningTo('12:00');

        // Driver → champ manuel nightHoursMinutes, plages ignorées.
        self::assertSame(250, $calc->nightMinutesForWorkHour($wh, true));
    }

    public function testNightMinutesForWorkHourNonDriverSumsRanges(): void
    {
        $calc = new NightHoursCalculator();
        $wh   = new WorkHour();
        $wh->setWorkDate(new DateTimeImmutable('2026-01-15'))
            ->setMorningFrom('22:00')->setMorningTo('00:00')   // 120 min nuit
            ->setEveningFrom('04:00')->setEveningTo('06:00');  // 120 min nuit

        self::assertSame(240, $calc->nightMinutesForWorkHour($wh, false));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `make test FILES=tests/Service/WorkHours/NightHoursCalculatorTest.php`
Expected: FAIL — class `App\Service\WorkHours\NightHoursCalculator` not found.

- [ ] **Step 3: Write minimal implementation**

```php
<?php

declare(strict_types=1);

namespace App\Service\WorkHours;

use App\Entity\WorkHour;

/**
 * Calcul des minutes travaillées de nuit (fenêtre 21h→6h).
 *
 * Fenêtres en minutes depuis 00:00 : [0,360] (00:00-06:00) et [1260,1440]
 * (21:00-24:00). On projette sur J+1 pour les shifts qui traversent minuit.
 * Source de vérité unique partagée par les écrans Heures et les exports.
 */
final class NightHoursCalculator
{
    /**
     * Minutes de nuit d'un WorkHour. Conducteurs : champ manuel nightHoursMinutes.
     * Non-conducteurs : somme calculée depuis les plages matin/après-midi/soir.
     */
    public function nightMinutesForWorkHour(WorkHour $workHour, bool $isDriver): int
    {
        if ($isDriver) {
            return $workHour->getNightHoursMinutes() ?? 0;
        }

        return $this->nightMinutesFromRanges($workHour);
    }

    public function nightMinutesFromRanges(WorkHour $workHour): int
    {
        $ranges = [
            [$workHour->getMorningFrom(), $workHour->getMorningTo()],
            [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
            [$workHour->getEveningFrom(), $workHour->getEveningTo()],
        ];

        $total = 0;
        foreach ($ranges as [$from, $to]) {
            $total += $this->nightIntervalMinutes($from, $to);
        }

        return $total;
    }

    public function nightIntervalMinutes(?string $from, ?string $to): int
    {
        $interval = $this->resolveInterval($from, $to);
        if (null === $interval) {
            return 0;
        }

        [$start, $end] = $interval;
        $windows       = [[0, 360], [1260, 1440]];
        $total         = 0;

        for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
            $shift = $dayOffset * 1440;
            foreach ($windows as [$windowStart, $windowEnd]) {
                $total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
            }
        }

        return $total;
    }

    /**
     * @return null|array{int, int}
     */
    private function resolveInterval(?string $from, ?string $to): ?array
    {
        $fromMinutes = $this->toMinutes($from);
        $toMinutes   = $this->toMinutes($to);
        if (null === $fromMinutes || null === $toMinutes) {
            return null;
        }

        $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;

        return [$fromMinutes, $end];
    }

    private function toMinutes(?string $time): ?int
    {
        if (null === $time || '' === $time) {
            return null;
        }

        [$hours, $minutes] = array_map('intval', explode(':', $time));

        return ($hours * 60) + $minutes;
    }

    private function overlap(int $startA, int $endA, int $startB, int $endB): int
    {
        $start = max($startA, $startB);
        $end   = min($endA, $endB);

        return max(0, $end - $start);
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `make test FILES=tests/Service/WorkHours/NightHoursCalculatorTest.php`
Expected: PASS (6 tests).

- [ ] **Step 5: Commit**

```bash
git add src/Service/WorkHours/NightHoursCalculator.php tests/Service/WorkHours/NightHoursCalculatorTest.php
git commit -m "feat(night-contingent) : service partagé NightHoursCalculator"
```

---

## Task 2: Déléguer le calcul de nuit dans les 2 providers existants

**Files:**
- Modify: `src/Service/WorkHours/YearlyHoursExportBuilder.php`
- Modify: `src/State/WorkHourWeeklySummaryProvider.php`
- Modify: `tests/Service/WorkHours/YearlyHoursDayRowsTest.php:82-90`

But: garantir une fenêtre 21h→6h identique en supprimant la duplication, sans changer aucun résultat. Les tests existants (`YearlyHoursDayRowsTest`, suite complète) sont le filet de non-régression.

- [ ] **Step 1: Inject the calculator into `YearlyHoursExportBuilder`**

Dans `src/Service/WorkHours/YearlyHoursExportBuilder.php`, ajouter l'import et l'argument constructeur :

```php
// en haut, avec les autres use :
use App\Service\WorkHours\NightHoursCalculator;
```

```php
    public function __construct(
        private WorkHourReadRepositoryInterface $workHourRepository,
        private AbsenceReadRepositoryInterface $absenceRepository,
        private EmployeeContractResolver $contractResolver,
        private AbsenceSegmentsResolver $absenceSegmentsResolver,
        private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
        private PublicHolidayServiceInterface $publicHolidayService,
        private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
        private NightHoursCalculator $nightHoursCalculator,
    ) {}
```

- [ ] **Step 2: Replace the night computation inside `computeMetrics`**

Remplacer la méthode `computeMetrics` (lignes ~535-558) par une version qui délègue la nuit :

```php
    private function computeMetrics(WorkHour $workHour): WorkMetrics
    {
        $ranges = [
            [$workHour->getMorningFrom(), $workHour->getMorningTo()],
            [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
            [$workHour->getEveningFrom(), $workHour->getEveningTo()],
        ];

        $totalMinutes = 0;
        foreach ($ranges as [$from, $to]) {
            $totalMinutes += $this->intervalMinutes($from, $to);
        }

        $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
        $dayMinutes   = max(0, $totalMinutes - $nightMinutes);

        return new WorkMetrics(
            dayMinutes: $dayMinutes,
            nightMinutes: $nightMinutes,
            totalMinutes: $totalMinutes,
        );
    }
```

Puis **supprimer** les méthodes privées désormais inutilisées dans ce fichier : `nightIntervalMinutes` (~599-618) et `overlap` (~620-626). Conserver `resolveInterval`, `toMinutes`, `intervalMinutes` (toujours utilisées par le total).

- [ ] **Step 3: Update `YearlyHoursDayRowsTest` constructor call**

Dans `tests/Service/WorkHours/YearlyHoursDayRowsTest.php`, ajouter l'import et l'argument :

```php
use App\Service\WorkHours\NightHoursCalculator;
```

```php
        $builder = new YearlyHoursExportBuilder(
            $workHourRepo,
            $absenceRepo,
            $contractResolver,
            new AbsenceSegmentsResolver(),
            new WorkedHoursCreditPolicy($contractResolver, new DailyReferenceMinutesResolver()),
            $holidayService,
            $virtualResolver,
            new NightHoursCalculator(),
        );
```

- [ ] **Step 4: Inject the calculator into `WorkHourWeeklySummaryProvider`**

Dans `src/State/WorkHourWeeklySummaryProvider.php`, ajouter l'import :

```php
use App\Service\WorkHours\NightHoursCalculator;
```

et l'argument constructeur (en dernière position) :

```php
        private EmployeeWeekCommentRepository $weekCommentRepository,
        private NightHoursCalculator $nightHoursCalculator,
    ) {}
```

- [ ] **Step 5: Replace the night computation inside its `computeMetrics`**

Remplacer `computeMetrics` (lignes ~427-450) par :

```php
    private function computeMetrics(WorkHour $workHour): WorkMetrics
    {
        $ranges = [
            [$workHour->getMorningFrom(), $workHour->getMorningTo()],
            [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
            [$workHour->getEveningFrom(), $workHour->getEveningTo()],
        ];

        $totalMinutes = 0;
        foreach ($ranges as [$from, $to]) {
            $totalMinutes += $this->intervalMinutes($from, $to);
        }

        $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
        $dayMinutes   = max(0, $totalMinutes - $nightMinutes);

        return new WorkMetrics(
            dayMinutes: $dayMinutes,
            nightMinutes: $nightMinutes,
            totalMinutes: $totalMinutes,
        );
    }
```

Puis **supprimer** les méthodes privées `nightIntervalMinutes` (~492-513) et `overlap` (~515+) de ce fichier. Conserver `resolveInterval`, `toMinutes`, `intervalMinutes`.

> ⚠️ Vérifier que `overlap` n'est plus référencée ailleurs dans le fichier avant suppression : `grep -n "overlap\|nightIntervalMinutes" src/State/WorkHourWeeklySummaryProvider.php` ne doit plus rien renvoyer après l'édition.

- [ ] **Step 6: Run the full suite (non-regression)**

Run: `make test`
Expected: PASS — tous les tests (y compris `YearlyHoursDayRowsTest` et les tests de récap/heures) passent, prouvant que la fenêtre nuit est inchangée.

- [ ] **Step 7: Commit**

```bash
git add src/Service/WorkHours/YearlyHoursExportBuilder.php src/State/WorkHourWeeklySummaryProvider.php tests/Service/WorkHours/YearlyHoursDayRowsTest.php
git commit -m "refactor(night) : mutualiser le calcul de nuit via NightHoursCalculator"
```

---

## Task 3: DTO `NightContingentRow`

**Files:**
- Create: `src/Dto/WorkHours/NightContingentRow.php`

(Pas de test dédié : structure de données pure, couverte par le test du builder en Task 4.)

- [ ] **Step 1: Create the DTO**

```php
<?php

declare(strict_types=1);

namespace App\Dto\WorkHours;

final class NightContingentRow
{
    /**
     * @param array<int, array{nightMinutes: int, nightDays: int}> $months clé 1..12
     */
    public function __construct(
        public readonly int $employeeId,
        public readonly string $employeeName,
        public readonly array $months,
    ) {}
}
```

- [ ] **Step 2: Commit**

```bash
git add src/Dto/WorkHours/NightContingentRow.php
git commit -m "feat(night-contingent) : DTO NightContingentRow"
```

---

## Task 4: Builder `NightContingentExportBuilder`

**Files:**
- Create: `src/Service/WorkHours/NightContingentExportBuilder.php`
- Test: `tests/Service/WorkHours/NightContingentExportBuilderTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Service\WorkHours;

use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\NightContingentExportBuilder;
use App\Service\WorkHours\NightHoursCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;

/**
 * @internal
 */
final class NightContingentExportBuilderTest extends TestCase
{
    public function testAggregatesNightMinutesAndDaysPerMonth(): void
    {
        $employee = $this->makeEmployee(1, 'Dupont', 'Jean');

        // Janvier : un jour 4h de nuit (≥240 → 1 jour) + un jour 3h59 (<240 → 0 jour).
        $whFull = (new WorkHour())->setEmployee($employee)
            ->setWorkDate(new DateTimeImmutable('2026-01-10'))
            ->setEveningFrom('21:00')->setEveningTo('01:00');   // 240 min nuit
        $whShort = (new WorkHour())->setEmployee($employee)
            ->setWorkDate(new DateTimeImmutable('2026-01-11'))
            ->setEveningFrom('21:00')->setEveningTo('00:59');   // 239 min nuit

        $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
        $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$whFull, $whShort]);

        $contractResolver = $this->createStub(EmployeeContractResolver::class);
        $contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(false);

        $builder = new NightContingentExportBuilder(
            $workHourRepo,
            $contractResolver,
            new NightHoursCalculator(),
        );

        $rows = $builder->buildRows([$employee], 2026);

        self::assertCount(1, $rows);
        self::assertSame(479, $rows[0]->months[1]['nightMinutes']); // 240 + 239
        self::assertSame(1, $rows[0]->months[1]['nightDays']);      // seul le jour ≥240
        self::assertSame(0, $rows[0]->months[2]['nightMinutes']);   // février vide
        self::assertSame(0, $rows[0]->months[2]['nightDays']);
    }

    public function testDriverUsesManualNightMinutes(): void
    {
        $employee = $this->makeEmployee(2, 'Martin', 'Paul');

        $wh = (new WorkHour())->setEmployee($employee)
            ->setWorkDate(new DateTimeImmutable('2026-03-05'))
            ->setNightHoursMinutes(300)
            ->setMorningFrom('08:00')->setMorningTo('12:00'); // ignoré (driver)

        $workHourRepo = $this->createStub(WorkHourReadRepositoryInterface::class);
        $workHourRepo->method('findByDateRangeAndEmployees')->willReturn([$wh]);

        $contractResolver = $this->createStub(EmployeeContractResolver::class);
        $contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(true);

        $builder = new NightContingentExportBuilder(
            $workHourRepo,
            $contractResolver,
            new NightHoursCalculator(),
        );

        $rows = $builder->buildRows([$employee], 2026);

        self::assertSame(300, $rows[0]->months[3]['nightMinutes']);
        self::assertSame(1, $rows[0]->months[3]['nightDays']); // 300 ≥ 240
    }

    private function makeEmployee(int $id, string $last, string $first): Employee
    {
        $employee = new Employee();
        $employee->setLastName($last)->setFirstName($first);
        $ref = new ReflectionProperty(Employee::class, 'id');
        $ref->setValue($employee, $id);

        return $employee;
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `make test FILES=tests/Service/WorkHours/NightContingentExportBuilderTest.php`
Expected: FAIL — class `NightContingentExportBuilder` not found.

- [ ] **Step 3: Write minimal implementation**

```php
<?php

declare(strict_types=1);

namespace App\Service\WorkHours;

use App\Dto\WorkHours\NightContingentRow;
use App\Entity\Employee;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use DateTimeImmutable;

/**
 * Construit, par employé, les totaux mensuels d'heures de nuit et le nombre de
 * nuits travaillées (≥ 4h de nuit dans la journée). Fenêtre 21h→6h via
 * NightHoursCalculator. Conducteurs : minutes saisies (nightHoursMinutes).
 * Aucun crédit absence/férié : seules les heures réellement travaillées comptent.
 */
final class NightContingentExportBuilder
{
    private const NIGHT_DAY_THRESHOLD_MINUTES = 240;

    public function __construct(
        private WorkHourReadRepositoryInterface $workHourRepository,
        private EmployeeContractResolver $contractResolver,
        private NightHoursCalculator $nightHoursCalculator,
    ) {}

    /**
     * @param list<Employee> $employees
     *
     * @return list<NightContingentRow>
     */
    public function buildRows(array $employees, int $year): array
    {
        $from = new DateTimeImmutable(sprintf('%d-01-01', $year));
        $to   = new DateTimeImmutable(sprintf('%d-12-31', $year));

        $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);

        $byEmployee = [];
        foreach ($workHours as $wh) {
            $employeeId = $wh->getEmployee()?->getId();
            if (null === $employeeId) {
                continue;
            }
            $byEmployee[$employeeId][] = $wh;
        }

        $rows = [];
        foreach ($employees as $employee) {
            $employeeId = $employee->getId();
            if (null === $employeeId) {
                continue;
            }

            $months = [];
            for ($m = 1; $m <= 12; ++$m) {
                $months[$m] = ['nightMinutes' => 0, 'nightDays' => 0];
            }

            foreach ($byEmployee[$employeeId] ?? [] as $wh) {
                $date     = DateTimeImmutable::createFromInterface($wh->getWorkDate());
                $isDriver = $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $date);
                $nightMin = $this->nightHoursCalculator->nightMinutesForWorkHour($wh, $isDriver);
                if ($nightMin <= 0) {
                    continue;
                }

                $month = (int) $date->format('n');
                $months[$month]['nightMinutes'] += $nightMin;
                if ($nightMin >= self::NIGHT_DAY_THRESHOLD_MINUTES) {
                    ++$months[$month]['nightDays'];
                }
            }

            $rows[] = new NightContingentRow(
                employeeId: $employeeId,
                employeeName: trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
                months: $months,
            );
        }

        return $rows;
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `make test FILES=tests/Service/WorkHours/NightContingentExportBuilderTest.php`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add src/Service/WorkHours/NightContingentExportBuilder.php tests/Service/WorkHours/NightContingentExportBuilderTest.php
git commit -m "feat(night-contingent) : builder agrégation mensuelle des heures de nuit"
```

---

## Task 5: Provider PDF `NightHoursContingentPrintProvider`

**Files:**
- Create: `src/State/NightHoursContingentPrintProvider.php`

(Le rendu PDF est validé manuellement en Task 9 ; pas de test unitaire sur le provider HTTP, cohérent avec `WorkHourDayExportProvider`.)

- [ ] **Step 1: Create the provider**

```php
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Entity\User;
use App\Repository\EmployeeRepository;
use App\Service\WorkHours\NightContingentExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;

class NightHoursContingentPrintProvider implements ProviderInterface
{
    public function __construct(
        private Environment $twig,
        private readonly RequestStack $requestStack,
        private EmployeeRepository $employeeRepository,
        private NightContingentExportBuilder $exportBuilder,
        private readonly Security $security,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
    {
        $user = $this->security->getUser();
        if (!$user instanceof User) {
            throw new AccessDeniedHttpException('Authentication required.');
        }

        $request = $this->requestStack->getCurrentRequest();
        if (!$request) {
            return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
        }

        $year = (int) $request->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'));
        if ($year < 2000 || $year > 2100) {
            throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
        }

        $from = new DateTimeImmutable(sprintf('%d-01-01', $year));
        $to   = new DateTimeImmutable(sprintf('%d-12-31', $year));

        // Périmètre selon le profil : admin → tous, chef de site → ses sites.
        $employees = $this->employeeRepository->findScoped($user);

        // Regroupement par site (ordre displayOrder), employés avec contrat sur l'année.
        $bySite   = [];
        $siteMeta = [];
        foreach ($employees as $employee) {
            if (!$this->hasContractInRange($employee, $from, $to)) {
                continue;
            }
            $site = $employee->getSite();
            if (null === $site) {
                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];
            // Même tri que le calendrier : displayOrder, puis nom, puis prénom.
            usort($siteEmployees, static function (Employee $a, Employee $b): int {
                return [$a->getDisplayOrder(), $a->getLastName(), $a->getFirstName()]
                    <=> [$b->getDisplayOrder(), $b->getLastName(), $b->getFirstName()];
            });

            $rows = $this->exportBuilder->buildRows($siteEmployees, $year);
            if ([] === $rows) {
                continue;
            }

            $renderRows = [];
            foreach ($rows as $row) {
                $cells = [];
                for ($m = 1; $m <= 12; ++$m) {
                    $cells[] = [
                        'hours' => $this->formatMinutes($row->months[$m]['nightMinutes']),
                        'days'  => $row->months[$m]['nightDays'],
                    ];
                }
                $renderRows[] = [
                    'employeeName' => $row->employeeName,
                    'cells'        => $cells,
                ];
            }

            $groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows];
        }

        $options = new Options();
        $options->set('isRemoteEnabled', true);
        $dompdf = new Dompdf($options);

        $html = $this->twig->render('night-hours-contingent/print.html.twig', [
            'groups'     => $groups,
            'year'       => $year,
            'exportedAt' => new DateTimeImmutable('now')->format('d/m/Y H:i'),
        ]);

        $dompdf->loadHtml($html);
        $dompdf->setPaper('A4', 'landscape');
        $dompdf->render();

        $filename = sprintf('contingent_heures_nuit_%d.pdf', $year);

        return new Response($dompdf->output(), Response::HTTP_OK, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
        ]);
    }

    private function hasContractInRange(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
    {
        $fromDay = $from->format('Y-m-d');
        $toDay   = $to->format('Y-m-d');

        foreach ($employee->getContractPeriods() as $period) {
            $start = $period->getStartDate()->format('Y-m-d');
            $end   = $period->getEndDate()?->format('Y-m-d');
            if ($start <= $toDay && (null === $end || $end >= $fromDay)) {
                return true;
            }
        }

        return false;
    }

    private function formatMinutes(int $minutes): string
    {
        $h = intdiv($minutes, 60);
        $m = $minutes % 60;

        return sprintf('%dh%02d', $h, $m);
    }
}
```

- [ ] **Step 2: Sanity check (lint/cache)**

Run: `make test` (la suite charge le conteneur ; aucune nouvelle assertion mais le code doit se charger sans erreur de syntaxe).
Expected: PASS — pas d'erreur de compilation/autowire.

- [ ] **Step 3: Commit**

```bash
git add src/State/NightHoursContingentPrintProvider.php
git commit -m "feat(night-contingent) : provider PDF contingent heures de nuit"
```

---

## Task 6: Opération API `NightHoursContingentPrint`

**Files:**
- Create: `src/ApiResource/NightHoursContingentPrint.php`

- [ ] **Step 1: Create the API resource**

```php
<?php

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\NightHoursContingentPrintProvider;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/night-hours-contingent/print',
            provider: NightHoursContingentPrintProvider::class,
            parameters: [
                new QueryParameter(key: 'year', required: true),
            ],
            security: "is_granted('ROLE_USER')"
        ),
    ]
)]
final class NightHoursContingentPrint {}
```

- [ ] **Step 2: Verify the route is registered**

Run: `docker exec -t -u www-data php-sirh-fpm php bin/console debug:router | grep night-hours-contingent`
Expected: une route `GET /api/night-hours-contingent/print` (ou équivalent selon le préfixe API) apparaît.

- [ ] **Step 3: Commit**

```bash
git add src/ApiResource/NightHoursContingentPrint.php
git commit -m "feat(night-contingent) : opération API GET /night-hours-contingent/print"
```

---

## Task 7: Gabarit Twig (A4 paysage)

**Files:**
- Create: `templates/night-hours-contingent/print.html.twig`

- [ ] **Step 1: Create the template**

```twig
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <style>
        @page { margin: 16px; }
        body { font-family: DejaVu Sans, sans-serif; font-size: 8px; color: #000; }
        h1 { font-size: 13px; margin: 0 0 2px; }
        .meta { font-size: 8px; color: #555; margin-bottom: 8px; }
        table { width: 100%; border-collapse: collapse; table-layout: fixed; }
        th, td { border: 1px solid #999; padding: 2px 3px; text-align: center; }
        th { background: #d9d9d9; }
        td.name, th.name { text-align: left; width: 90px; }
        .sub { font-size: 7px; }
        tr.site-title td { background: #eee; text-align: left; font-weight: bold; }
        td.hours { white-space: nowrap; }
    </style>
</head>
<body>
    <h1>Contingent heures de nuit — {{ year }}</h1>
    <div class="meta">Édité le {{ exportedAt }}</div>

    {% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %}

    <table>
        <thead>
            <tr>
                <th class="name" rowspan="2">Nom</th>
                {% for m in months %}
                    <th colspan="2">{{ m }}</th>
                {% endfor %}
            </tr>
            <tr>
                {% for m in months %}
                    <th class="sub">H.nuit</th>
                    <th class="sub">N.jours</th>
                {% endfor %}
            </tr>
        </thead>
        <tbody>
            {% for group in groups %}
                <tr class="site-title">
                    <td colspan="25" style="background: {{ group.siteColor ?? '#eee' }}">{{ group.siteName }}</td>
                </tr>
                {% for row in group.rows %}
                    <tr>
                        <td class="name">{{ row.employeeName }}</td>
                        {% for cell in row.cells %}
                            <td class="hours">{{ cell.hours }}</td>
                            <td>{{ cell.days }}</td>
                        {% endfor %}
                    </tr>
                {% endfor %}
            {% endfor %}
        </tbody>
    </table>
</body>
</html>
```

- [ ] **Step 2: Commit**

```bash
git add templates/night-hours-contingent/print.html.twig
git commit -m "feat(night-contingent) : gabarit PDF paysage"
```

---

## Task 8: Frontend — option de drawer + sélecteur d'année

**Files:**
- Modify: `frontend/pages/employees/index.vue`

- [ ] **Step 1: Add the option, the year selector, validation, and handler**

Dans `frontend/pages/employees/index.vue` :

1. Élargir le type de `exportChoice` (ligne 267) et le cast dans `onExportChoiceChange` (ligne 308) pour inclure `'night-contingent'` :

```ts
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''>('')
```

```ts
const onExportChoiceChange = (value: string | number | null) => {
    exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | ''
}
```

2. Ajouter l'option dans `exportTypeOptions` (ligne 272-276) :

```ts
const exportTypeOptions = [
    { label: 'Récap. congés', value: 'leave-recap' },
    { label: 'Récap. salaire', value: 'salary-recap' },
    { label: 'Heures annuelles', value: 'yearly-hours' },
    { label: 'Contingent H.nuit', value: 'night-contingent' }
]
```

3. Ajouter le bloc de sélection d'année dans le template, juste après le `</template>` du bloc `yearly-hours` (après la ligne 231) :

```vue
                <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>
```

4. Étendre `isExportValid` (ligne 296-305) :

```ts
const isExportValid = computed(() => {
    if (!exportChoice.value) return false
    if (exportChoice.value === 'salary-recap') {
        return exportSalaryMonth.value.trim() !== ''
    }
    if (exportChoice.value === 'yearly-hours') {
        return exportYear.value > 0 && exportMonth.value !== ''
    }
    if (exportChoice.value === 'night-contingent') {
        return exportYear.value > 0
    }
    return true
})
```

5. Étendre `handleExportValidate` (autour de la ligne 612-622) en ajoutant une branche :

```ts
    } else if (choice === 'night-contingent') {
        await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`)
    }
```

- [ ] **Step 2: Type-check the frontend**

Run: `cd frontend && npx vue-tsc --noEmit` (ou `npm run typecheck` si défini)
Expected: pas d'erreur de type sur `index.vue`.

> NB : ne PAS lancer `npm run build` (préférence utilisateur).

- [ ] **Step 3: Commit**

```bash
git add frontend/pages/employees/index.vue
git commit -m "feat(night-contingent) : option export Contingent H.nuit (liste employés)"
```

---

## Task 9: Documentation (obligatoire — même intervention)

**Files:**
- Modify: `doc/functional-rules.md`
- Modify: `CLAUDE.md`
- Modify: `frontend/data/documentation-content.ts`

- [ ] **Step 1: Add a section to `doc/functional-rules.md`**

Ajouter (près des autres exports) une section :

```markdown
## Export Contingent heures de nuit

- Accès : drawer « Export » de la liste employés, type « Contingent H.nuit ».
  Endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`.
- Périmètre : `EmployeeRepository::findScoped($user)` (admin → tous, chef de
  site → ses sites). Employés ayant ≥ 1 contrat sur l'année civile uniquement.
- PDF A4 **paysage** : lignes = employés (groupés par site, triés displayOrder
  puis nom/prénom), colonnes = 12 mois (Janv→Déc), chaque mois avec 2 sous-
  colonnes « H.nuit » et « N.jours ».
- Heures de nuit : minutes travaillées dans la fenêtre **21h→6h**
  (`NightHoursCalculator`, identique au reste de l'app). Conducteurs : champ
  manuel `WorkHour.nightHoursMinutes`.
- « N.jours » : un jour compte 1 dès que ses minutes de nuit ≥ 240 (4h).
- Aucun crédit absence/férié : seules les heures réellement travaillées comptent.
- Services : `App\State\NightHoursContingentPrintProvider` +
  `App\Service\WorkHours\NightContingentExportBuilder`.
```

- [ ] **Step 2: Add an entry to `CLAUDE.md`**

Ajouter sous une rubrique export (par ex. juste après la règle « Export heures vue Jour ») :

```markdown
- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (mutualisé avec `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`.
```

- [ ] **Step 3: Add a user-facing article to `frontend/data/documentation-content.ts`**

Repérer la section décrivant les exports de la liste employés (où figurent « Récap. congés », « Heures annuelles ») et ajouter un article/bloc de niveau `site_manager` :

```ts
{ type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' },
```

> Respecter la structure existante (`DocBlock` dans un `DocArticle`). Placer le bloc dans l'article des exports, niveau d'accès `site_manager` (visible chefs de site + admin).

- [ ] **Step 4: Commit**

```bash
git add doc/functional-rules.md CLAUDE.md frontend/data/documentation-content.ts
git commit -m "docs(night-contingent) : documentation export contingent heures de nuit"
```

---

## Task 10: Vérification finale

- [ ] **Step 1: Run the full backend suite**

Run: `make test`
Expected: PASS — toute la suite, incluant `NightHoursCalculatorTest` et `NightContingentExportBuilderTest`.

- [ ] **Step 2: Manual smoke test (PDF)**

- `make start` si nécessaire.
- Se connecter en admin, aller sur la liste des employés, bouton **Export** → **Contingent H.nuit** → choisir une année avec des heures saisies → **Valider**.
- Vérifier le PDF : paysage, groupé par site, 12 mois × (H.nuit / N.jours), totaux cohérents avec quelques jours connus (un jour 21h-01h = 4h → +1 N.jours).
- Vérifier en chef de site : seuls ses sites apparaissent.

- [ ] **Step 3: Final commit (if any doc tweak)**

```bash
git status   # doit être propre ; sinon committer les ajustements
```

---

## Self-Review

**Spec coverage :**
- Drawer « Contingent H.nuit » sur la liste employés → Task 8. ✅
- PDF A4 paysage → Task 5 (`setPaper('A4','landscape')`) + Task 7. ✅
- Lignes = employés, colonnes = mois, 2 sous-colonnes H.nuit / N.jours → Task 7. ✅
- Heures de nuit 21h→6h (réutilise l'existant) → Task 1 + Task 2. ✅
- ≥4h = 1 jour → Task 4 (`NIGHT_DAY_THRESHOLD_MINUTES = 240`). ✅
- Conducteurs via nightHoursMinutes → Task 1/4. ✅
- Statut driver résolu par date → Task 4 (`resolveIsDriverForEmployeeAndDate` par WorkHour). ✅
- Groupé par site, trié ordre BDD → Task 5 (`displayOrder`/nom/prénom, sites par `displayOrder`). ✅
- Pas de total annuel → aucune colonne total dans Task 7. ✅
- Service partagé → Task 1 + Task 2. ✅
- Docs → Task 9. ✅

**Type consistency :** `NightContingentRow.months` est `array<int,{nightMinutes,nightDays}>` keyé 1..12, produit en Task 4 et lu en Task 5/test Task 4. `nightMinutesForWorkHour(WorkHour,bool)` cohérent entre Task 1, 2 et 4. Constructeurs mis à jour partout où ils changent (Task 2 met à jour `YearlyHoursDayRowsTest`). ✅

**Placeholders :** aucun — chaque étape contient le code complet. ✅
