docs : plan d'implémentation export PDF heures jour
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
# 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
|
||||
<?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`) :
|
||||
|
||||
```php
|
||||
/**
|
||||
* 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```twig
|
||||
<!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**
|
||||
|
||||
```bash
|
||||
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
|
||||
<?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
|
||||
<?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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```vue
|
||||
<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**
|
||||
|
||||
```bash
|
||||
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) :
|
||||
|
||||
```html
|
||||
<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 :
|
||||
|
||||
```html
|
||||
<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 :
|
||||
|
||||
```ts
|
||||
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**
|
||||
|
||||
```bash
|
||||
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`**
|
||||
|
||||
```markdown
|
||||
# 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) :
|
||||
|
||||
```ts
|
||||
{
|
||||
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 :
|
||||
|
||||
```markdown
|
||||
- **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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user