From 9fdb8cfa8d566574f643a2b39bf910369ddd595c Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 11 Jun 2026 17:10:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(overtime-contingent)=20:=20export=20PDF=20?= =?UTF-8?q?group=C3=A9=20par=20site=20(heures=20supp=20pay=C3=A9es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/ApiResource/OvertimeContingentPrint.php | 25 +++ src/State/OvertimeContingentPrintProvider.php | 169 ++++++++++++++++++ templates/overtime-contingent/print.html.twig | 54 ++++++ 3 files changed, 248 insertions(+) create mode 100644 src/ApiResource/OvertimeContingentPrint.php create mode 100644 src/State/OvertimeContingentPrintProvider.php create mode 100644 templates/overtime-contingent/print.html.twig diff --git a/src/ApiResource/OvertimeContingentPrint.php b/src/ApiResource/OvertimeContingentPrint.php new file mode 100644 index 0000000..594e919 --- /dev/null +++ b/src/ApiResource/OvertimeContingentPrint.php @@ -0,0 +1,25 @@ +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)); + + // Filtre sites optionnel (vide = tout le perimetre). + $rawSiteIds = (string) $request->query->get('siteIds', ''); + $siteIds = array_values(array_filter(array_map('intval', array_filter(explode(',', $rawSiteIds), 'strlen')))); + + // Perimetre selon le profil : admin -> tous, chef de site -> ses sites. + $employees = $this->employeeRepository->findScoped($user); + + $today = new DateTimeImmutable('today'); + $bySite = []; + $siteMeta = []; + foreach ($employees as $employee) { + if (!$this->hasContractInRange($employee, $from, $to)) { + continue; + } + // Exclure les forfait (contrat courant). + $currentContract = $this->contractResolver->resolveForEmployeeAndDate($employee, $today); + if (null !== $currentContract && ContractType::FORFAIT === $currentContract->getType()) { + continue; + } + $site = $employee->getSite(); + if (null === $site) { + continue; + } + $siteId = $site->getId(); + if ([] !== $siteIds && !in_array($siteId, $siteIds, true)) { + continue; + } + $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]; + // Meme tri que le calendrier : displayOrder, puis nom, puis prenom. + 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); + + $renderRows = []; + foreach ($rows as $row) { + $cells = []; + for ($m = 1; $m <= 12; ++$m) { + $cells[] = $row->months[$m] > 0 ? $this->formatMinutes($row->months[$m]) : '—'; + } + $renderRows[] = [ + 'employeeName' => $row->employeeName, + 'cells' => $cells, + 'totalHours' => $this->formatMinutes($row->totalMinutes), + 'capHours' => $row->capHours, + 'exceeded' => $row->totalMinutes > $row->capHours * 60, + ]; + } + + $groups[] = ['siteName' => $meta['name'], 'siteColor' => $meta['color'], 'rows' => $renderRows]; + } + + $options = new Options(); + $options->set('isRemoteEnabled', true); + $dompdf = new Dompdf($options); + + $html = $this->twig->render('overtime-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_supp_%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); + } +} diff --git a/templates/overtime-contingent/print.html.twig b/templates/overtime-contingent/print.html.twig new file mode 100644 index 0000000..0efd2ea --- /dev/null +++ b/templates/overtime-contingent/print.html.twig @@ -0,0 +1,54 @@ + + + + + + + +

Contingent heures supplémentaires payées — {{ year }}

+
Édité le {{ exportedAt }}
+ + {% set months = ['Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] %} + + + + + + {% for m in months %} + + {% endfor %} + + + + + {% for group in groups %} + + + + {% for row in group.rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + + {% endfor %} + {% endfor %} + +
Nom{{ m }}Total payé / payable
{{ group.siteName }}
{{ row.employeeName }}{{ cell }}{{ row.totalHours }} / {{ row.capHours }} h
+ +