diff --git a/composer.json b/composer.json index ebb7bc4..f0b9733 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", + "dompdf/dompdf": "^3.1", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", "phpdocumentor/reflection-docblock": "^5.6", diff --git a/composer.lock b/composer.lock index f40d9a5..19fbdb0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "567d0702493304b192a19126c0692e72", + "content-hash": "f181b165d122aecdae0a7df1d1e33aec", "packages": [ { "name": "api-platform/doctrine-common", @@ -2360,6 +2360,161 @@ }, "time": "2025-10-26T09:35:14+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.4", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", + "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + }, + "time": "2025-10-29T12:43:30+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -2549,6 +2704,73 @@ ], "time": "2025-12-20T17:47:00+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "nelmio/cors-bundle", "version": "2.6.1", @@ -3142,6 +3364,80 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.1.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.28 || 2.1.25", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.7", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6", + "phpunit/phpunit": "8.5.46", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.1.7", + "rector/type-perfect": "1.0.0 || 2.1.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0" + }, + "time": "2025-09-14T07:37:21+00:00" + }, { "name": "symfony/asset", "version": "v8.0.4", @@ -7325,6 +7621,145 @@ ], "time": "2025-12-04T18:17:06+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2025-05-14T06:15:44+00:00" + }, { "name": "twig/twig", "version": "v3.23.0", diff --git a/frontend/composables/usePdfPrinter.ts b/frontend/composables/usePdfPrinter.ts new file mode 100644 index 0000000..7d68179 --- /dev/null +++ b/frontend/composables/usePdfPrinter.ts @@ -0,0 +1,31 @@ +import {useApi} from '~/composables/useApi' + +export const usePdfPrinter = () => { + const api = useApi() + + const printPdf = async (url: string): Promise => { + const blob = await api.getBlob(url); + + const pdfBlob = blob.type === 'application/pdf' + ? blob + : new Blob([blob], { type: 'application/pdf' }); + + const blobUrl = URL.createObjectURL(pdfBlob); + + const filename = `test.pdf`; + + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); + // L'ouverture dans un nouvel onglet déclenche un 2e PDF sans le nom personnalisé. + setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + } + + return { + printPdf + } +} diff --git a/frontend/pages/calendar.vue b/frontend/pages/calendar.vue index 18a224e..61367bb 100644 --- a/frontend/pages/calendar.vue +++ b/frontend/pages/calendar.vue @@ -39,6 +39,13 @@ > Ajouter une absence + @@ -174,6 +181,64 @@ + + +
+
+
+ + +
+
+ + +
+
+ +
+

Sites

+
+
+
+ + +
+
+
+ +
+ + +
+
+
@@ -232,6 +297,7 @@ const absences = ref([]) const isDrawerOpen = ref(false) const isSubmitting = ref(false) const editingAbsence = ref(null) +const isPrintOpen = ref(false) const now = new Date() const selectedMonth = ref(now.getMonth()) @@ -269,6 +335,12 @@ const form = reactive({ comment: '' }) +const printForm = reactive({ + from: '', + to: '', + siteIds: [] as number[] +}) + const resetForm = () => { form.employeeId = '' @@ -284,6 +356,65 @@ const closeDrawer = () => { resetForm() } +const openPrint = () => { + const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1) + const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0) + printForm.from = monthStart + printForm.to = monthEnd + printForm.siteIds = [...selectedSiteIds.value] + isPrintOpen.value = true +} + +const closePrint = () => { + isPrintOpen.value = false +} + +const parseYmd = (value: string) => { + const [year, month, day] = value.split('-').map(Number) + if (!year || !month || !day) return null + return new Date(year, month - 1, day) +} + +const addMonths = (date: Date, months: number) => { + const next = new Date(date.getFullYear(), date.getMonth() + months, date.getDate()) + if (next.getMonth() !== (date.getMonth() + months) % 12) { + next.setDate(0) + } + return next +} + +const enforcePrintRange = () => { + if (!printForm.from) return + const start = parseYmd(printForm.from) + if (!start) return + const maxEnd = addMonths(start, 2) + maxEnd.setDate(maxEnd.getDate() - 1) + const maxEndYmd = toYmd(maxEnd.getFullYear(), maxEnd.getMonth(), maxEnd.getDate()) + + if (!printForm.to) { + printForm.to = maxEndYmd + return + } + + const end = parseYmd(printForm.to) + if (!end) { + printForm.to = maxEndYmd + return + } + + if (end < start) { + printForm.to = printForm.from + return + } + + if (end > maxEnd) { + printForm.to = maxEndYmd + } +} + +watch(() => printForm.from, enforcePrintRange) +watch(() => printForm.to, enforcePrintRange) + const loadEmployees = async () => { employees.value = await listEmployees() } @@ -434,4 +565,15 @@ const formatEmployeeName = (employee: Employee) => { return `${employee.firstName} ${initial}`.trim() } +const { printPdf } = usePdfPrinter() +const handlePrint = async () => { + const params = new URLSearchParams() + params.set('from', printForm.from) + params.set('to', printForm.to) + if (printForm.siteIds.length > 0) { + params.set('sites', printForm.siteIds.join(',')) + } + await printPdf(`/absences/print?${params.toString()}`) + isPrintOpen.value = false +} diff --git a/src/ApiResource/AbsencePrint.php b/src/ApiResource/AbsencePrint.php new file mode 100644 index 0000000..331c41a --- /dev/null +++ b/src/ApiResource/AbsencePrint.php @@ -0,0 +1,25 @@ +requestStack->getCurrentRequest(); + if (!$request) { + return new Response('Missing request.', Response::HTTP_BAD_REQUEST); + } + + $from = $request->query->get('from'); + $to = $request->query->get('to'); + if (!$from || !$to) { + return new Response('Missing from/to query params.', Response::HTTP_BAD_REQUEST); + } + + $fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from); + $toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to); + + $siteIds = $this->parseIds($request->query->get('sites')); + + $employees = $this->loadEmployees($siteIds); + $absences = $this->loadAbsences($fromDate, $toDate, $employees); + + $days = $this->buildDays($fromDate, $toDate); + $absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate); + + $options = new Options(); + $options->set('isRemoteEnabled', true); + + $dompdf = new Dompdf($options); + $html = $this->twig->render('absence/print.html.twig', [ + 'from' => $fromDate, + 'to' => $toDate, + 'days' => $days, + 'employees' => $employees, + 'absenceMap' => $absenceMap, + ]); + + $dompdf->loadHtml($html); + $dompdf->setPaper('A3', 'landscape'); + $dompdf->render(); + + $filename = 'test'; + + return new Response($dompdf->output(), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$filename.'"', + ]); + } + + private function parseIds(?string $value): array + { + if (null === $value || '' === trim($value)) { + return []; + } + + $ids = []; + foreach (explode(',', $value) as $part) { + $id = (int) trim($part); + if ($id > 0) { + $ids[] = $id; + } + } + + return array_values(array_unique($ids)); + } + + private function loadEmployees(array $siteIds): array + { + $qb = $this->entityManager + ->getRepository(Employee::class) + ->createQueryBuilder('e') + ->leftJoin('e.site', 's') + ->addSelect('s') + ->orderBy('s.name', 'ASC') + ->addOrderBy('e.lastName', 'ASC') + ->addOrderBy('e.firstName', 'ASC') + ; + + if ([] !== $siteIds) { + $qb->andWhere('s.id IN (:siteIds)') + ->setParameter('siteIds', $siteIds) + ; + } + + /** @var list $result */ + return $qb->getQuery()->getResult(); + } + + private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array + { + if ([] === $employees) { + return []; + } + + $qb = $this->entityManager + ->getRepository(Absence::class) + ->createQueryBuilder('a') + ->leftJoin('a.employee', 'e') + ->leftJoin('a.type', 't') + ->addSelect('e', 't') + ->andWhere('a.startDate <= :to') + ->andWhere('a.endDate >= :from') + ->andWhere('a.employee IN (:employees)') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->setParameter('employees', $employees) + ; + + /** @var list $result */ + return $qb->getQuery()->getResult(); + } + + private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array + { + $days = []; + $current = $from; + + while ($current <= $to) { + $days[] = [ + 'date' => $current->format('Y-m-d'), + 'label' => $current->format('d'), + ]; + + $current = $current->add(new DateInterval('P1D')); + } + + return $days; + } + + private function buildAbsenceMap(array $absences, DateTimeImmutable $from, DateTimeImmutable $to): array + { + $map = []; + + foreach ($absences as $absence) { + $employeeId = $absence->getEmployee()?->getId(); + $type = $absence->getType(); + + if (!$employeeId || !$type) { + continue; + } + + $absenceStart = DateTimeImmutable::createFromInterface($absence->getStartDate()); + $absenceEnd = DateTimeImmutable::createFromInterface($absence->getEndDate()); + + $start = max($absenceStart, $from); + $end = min($absenceEnd, $to); + + $current = $start; + while ($current <= $end) { + $dateKey = $current->format('Y-m-d'); + + $map[$employeeId][$dateKey] = [ + 'code' => (string) $type->getCode(), + 'color' => (string) $type->getColor(), + ]; + + $current = $current->add(new DateInterval('P1D')); + } + } + + return $map; + } +} diff --git a/src/State/CurrentUserProvider.php b/src/State/CurrentUserProvider.php index 964f977..a1dcdb2 100644 --- a/src/State/CurrentUserProvider.php +++ b/src/State/CurrentUserProvider.php @@ -9,9 +9,9 @@ use ApiPlatform\State\ProviderInterface; use App\Entity\User; use Symfony\Bundle\SecurityBundle\Security; -final class CurrentUserProvider implements ProviderInterface +final readonly class CurrentUserProvider implements ProviderInterface { - public function __construct(private readonly Security $security) {} + public function __construct(private Security $security) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?User {