Files
SIRH/docs/superpowers/plans/2026-06-08-hours-day-export.md
T
2026-06-08 17:41:01 +02:00

33 KiB

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

declare(strict_types=1);

namespace App\Tests\Service\WorkHours;

use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;

/**
 * @internal
 */
final class YearlyHoursDayRowsTest extends TestCase
{
    public function testTimeContractRowComputesHoursAndExcludesNoContract(): void
    {
        $date = new DateTimeImmutable('2026-06-08'); // lundi

        $contract = new Contract();
        $contract->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) :

    /**
     * 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<Employee> $employees
     *
     * @return list<array{employeeId:int, employeeName:string, statut:?string,
     *   morningFrom:string, morningTo:string, afternoonFrom:string, afternoonTo:string,
     *   eveningFrom:string, eveningTo:string, dayHours:string, nightHours:string,
     *   total:string, isWeekend:bool, isHoliday:bool}>
     */
    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
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

<!doctype html>
<html lang="fr">
<head>
    <meta charset="utf-8">
    <title>Heures - {{ dateLabel }}</title>
    <style>
        @page { size: A4 portrait; margin: 4mm; }
        html, body { margin: 0; padding: 2mm; font-family: Helvetica, sans-serif; font-size: 8px; }
        .title-bar { position: relative; margin: 0 0 3mm 0; }
        h1 { text-align: center; font-size: 15px; margin: 0; }
        .export-date { position: absolute; top: 0; right: 0; font-size: 8px; color: #333; padding-top: 4px; }
        h2 { font-size: 11px; margin: 3mm 0 1mm 0; padding: 2px 6px; background: #e8e8e8; }
        table { width: 100%; border-collapse: collapse; table-layout: auto; border: 2px solid #0a0a0a; }
        th, td { border: 1px solid #0a0a0a; padding: 1px 3px; vertical-align: middle; white-space: nowrap; text-align: center; }
        th { font-weight: 700; background: #f0f0f0; }
        td.name { text-align: left; }
        tr.weekend td { background: #c0c0c0; }
        td.statut { background: #b3e5fc; }
        .site-block { page-break-inside: auto; }
    </style>
</head>
<body>
    <div class="title-bar">
        <h1>Heures du {{ dateLabel }}</h1>
        <div class="export-date">Édité le {{ exportedAt }}</div>
    </div>

    {% for group in groups %}
        <div class="site-block">
            <h2>{{ group.siteName }}</h2>
            <table>
                <thead>
                    <tr>
                        <th>Nom</th>
                        <th>Statut</th>
                        <th>Début matin</th>
                        <th>Fin matin</th>
                        <th>Début après-midi</th>
                        <th>Fin après-midi</th>
                        <th>Début soir</th>
                        <th>Fin soir</th>
                        <th>Jour</th>
                        <th>Nuit</th>
                        <th>Total</th>
                    </tr>
                </thead>
                <tbody>
                    {% for row in group.rows %}
                        <tr class="{{ row.isWeekend ? 'weekend' : '' }}">
                            <td class="name">{{ row.employeeName }}</td>
                            <td class="{{ row.statut ? 'statut' : '' }}">{{ row.statut }}</td>
                            <td>{{ row.morningFrom }}</td>
                            <td>{{ row.morningTo }}</td>
                            <td>{{ row.afternoonFrom }}</td>
                            <td>{{ row.afternoonTo }}</td>
                            <td>{{ row.eveningFrom }}</td>
                            <td>{{ row.eveningTo }}</td>
                            <td>{{ row.dayHours }}</td>
                            <td>{{ row.nightHours }}</td>
                            <td>{{ row.total }}</td>
                        </tr>
                    {% endfor %}
                </tbody>
            </table>
        </div>
    {% endfor %}
</body>
</html>
  • Step 2 : Commit
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

declare(strict_types=1);

namespace App\ApiResource;

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

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/work-hours/day-export',
            provider: WorkHourDayExportProvider::class,
            parameters: [
                new QueryParameter(key: 'workDate', required: true),
                new QueryParameter(key: 'siteIds', required: true),
            ],
            security: "is_granted('ROLE_ADMIN')"
        ),
    ]
)]
final class WorkHourDayExport {}
  • Step 2 : Créer le Provider

src/State/WorkHourDayExportProvider.php :

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\EmployeeRepository;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;

class WorkHourDayExportProvider implements ProviderInterface
{
    public function __construct(
        private Environment $twig,
        private readonly RequestStack $requestStack,
        private EmployeeRepository $employeeRepository,
        private YearlyHoursExportBuilder $exportBuilder,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
    {
        $request = $this->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
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

<template>
    <AppDrawer v-model="drawerOpen" title="Export des heures (par jour)">
        <form class="space-y-4" @submit.prevent="handleSubmit">
            <div>
                <label class="text-md font-semibold text-neutral-700" for="hours-export-date">
                    Date <span class="text-red-600">*</span>
                </label>
                <input
                    id="hours-export-date"
                    v-model="selectedDate"
                    type="date"
                    class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
                >
            </div>

            <div>
                <label class="text-md font-semibold text-neutral-700">
                    Sites <span class="text-red-600">*</span>
                </label>
                <MalioSelectCheckbox
                    v-model="selectedSites"
                    :options="siteOptions"
                    groupClass="w-full mt-2"
                    label="Sites"
                    display-select-all
                />
            </div>

            <div class="flex justify-center pt-2">
                <button
                    type="submit"
                    class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
                    :disabled="isLoading || !selectedDate || selectedSites.length === 0"
                >
                    <template v-if="isLoading">Génération en cours...</template>
                    <template v-else>Exporter</template>
                </button>
            </div>
        </form>
    </AppDrawer>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
import type { Site } from '~/services/dto/site'

const props = defineProps<{
    modelValue: boolean
    sites: Site[]
    initialDate: string
    initialSiteIds: number[]
    isLoading?: boolean
}>()

const emit = defineEmits<{
    (event: 'update:modelValue', value: boolean): void
    (event: 'submit', payload: { date: string; siteIds: number[] }): void
}>()

const drawerOpen = computed({
    get: () => props.modelValue,
    set: (value: boolean) => emit('update:modelValue', value)
})

const selectedDate = ref(props.initialDate)
const selectedSites = ref<number[]>([...props.initialSiteIds])

const siteOptions = computed(() =>
    props.sites.map((site) => ({ value: site.id, label: site.name }))
)

const handleSubmit = () => {
    if (!selectedDate.value || selectedSites.value.length === 0) return
    emit('submit', { date: selectedDate.value, siteIds: [...selectedSites.value] })
}

// Réinitialise sur l'état courant de l'écran à chaque ouverture.
watch(
    () => props.modelValue,
    (isOpen) => {
        if (isOpen) {
            selectedDate.value = props.initialDate
            selectedSites.value = [...props.initialSiteIds]
        }
    }
)
</script>
  • 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
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) :

        <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>
        </div>

par :

        <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>
            <button
                v-if="isAdmin"
                type="button"
                class="flex items-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
                @click="isExportDrawerOpen = true"
            >
                <Icon name="mdi:file-export-outline" />
                Exporter
            </button>
        </div>

        <HoursDayExportDrawer
            v-model="isExportDrawerOpen"
            :sites="sites"
            :initial-date="selectedDate"
            :initial-site-ids="selectedSiteIds"
            :is-loading="isExporting"
            @submit="handleExport"
        />

Note : si Icon n'est pas auto-importé dans ce projet, retirer la balise <Icon> 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 <script setup>

Dans le <script setup lang="ts"> de hours.vue, ajouter les imports et l'état. Repérer la déstructuration existante de useHoursPage() pour confirmer que isAdmin, sites, selectedSiteIds, selectedDate en sont issus, puis ajouter :

import { ref } from 'vue'
import HoursDayExportDrawer from '~/components/hours/HoursDayExportDrawer.vue'
import { usePdfPrinter } from '~/composables/usePdfPrinter'

const { printPdf } = usePdfPrinter()
const isExportDrawerOpen = ref(false)
const isExporting = ref(false)

const handleExport = async (payload: { date: string; siteIds: number[] }) => {
    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
    }
}

Note : selectedDate côté useHoursPage est attendu au format YYYY-MM-DD (utilisé tel quel dans getWorkHourDayContext(selectedDate.value)). Le passer directement comme initial-date. Si son format diffère, normaliser en YYYY-MM-DD avant de le transmettre.

  • Step 3 : Lancer le typecheck / lint frontend

Run: cd frontend && npx vue-tsc --noEmit (ou la commande de typecheck du projet ; ne pas lancer npm run build). Expected: pas d'erreur de type sur les fichiers modifiés.

  • Step 4 : Vérification manuelle

Démarrer la stack (make start + make dev-nuxt si besoin), se connecter en admin, écran Heures :

  • Le bouton « Exporter » est visible (et absent pour un non-admin).

  • Le drawer s'ouvre avec la date courante et les sites cochés.

  • « Exporter » télécharge un PDF portrait, une section par site, colonnes attendues sans « Valider ».

  • Step 5 : Commit

git add frontend/pages/hours.vue
git commit -m "feat(heures) : bouton export PDF jour (admin)"

Task 6 : Documentation

Files:

  • Create: doc/hours-day-export.md

  • Modify: frontend/data/documentation-content.ts

  • Modify: CLAUDE.md

  • Step 1 : Créer doc/hours-day-export.md

# 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`.
  • Step 2 : Ajouter une entrée admin dans documentation-content.ts

Repérer la section « Heures » dans frontend/data/documentation-content.ts et ajouter, dans ses articles (ou un nouvel article requiredLevel: 'admin'), un bloc décrivant l'export. Exemple d'article à insérer (adapter id/structure aux types DocArticle/DocBlock existants dans le fichier) :

{
    id: 'hours-day-export',
    title: 'Exporter les heures (PDF par jour)',
    requiredLevel: 'admin',
    blocks: [
        {
            type: 'paragraph',
            text: "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',
            text: "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.",
        },
    ],
},

Avant d'écrire, lire le haut de documentation-content.ts et frontend/types/documentation.ts pour respecter exactement la forme des objets DocArticle/DocBlock (noms de champs, types de blocs autorisés).

  • Step 3 : Mettre à jour CLAUDE.md

Ajouter, sous la puce « Exports heures annuelles » de la section Functional Rules, une nouvelle puce :

- **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`.
  • Step 4 : Commit
git add doc/hours-day-export.md frontend/data/documentation-content.ts CLAUDE.md
git commit -m "docs(heures) : documenter l'export PDF jour"

Notes de mise en œuvre

  • Conducteurs exclus : filtrés côté provider (getIsDriver()), cohérent avec l'écran.
  • PRESENCE : géré dans le builder (cellules horaires vides, Total en demi-journées).
  • Validation des params : workDate (YYYY-MM-DD) et siteIds (CSV d'entiers > 0) rejetés en 422 si invalides.
  • Pas de npm run build (règle projet) — utiliser typecheck/dev pour vérifier le front.
  • Format des commits : le hook impose <type>(<scope>) : <message> (espace avant les deux-points). Les messages ci-dessus le respectent.