Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions
92d5fa55d0 chore: bump version to v0.1.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-09 17:11:56 +00:00
65f6d8a530 feat : ajout monolog + fix script de déploiement
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-09 18:11:34 +01:00
gitea-actions
dffa366133 chore: bump version to v0.1.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-02-09 14:52:23 +00:00
930e6addbe fix : CI/CD
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-09 15:52:14 +01:00
gitea-actions
a339b34a5b chore: bump version to v0.1.0
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
2026-02-09 14:48:26 +00:00
e6fbfcd36d fix : CI/CD
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-09 15:48:16 +01:00
31c0ae15f4 feat : numéro de version et config CI/CD
Some checks failed
Auto Tag Develop / tag (push) Failing after 4s
2026-02-09 15:40:14 +01:00
c1025d6066 feat : refacto de la partie calendrier + ajout de validation sur les formulaires + ajout des jours fériés 2026-02-09 14:25:18 +01:00
03f5552dd4 feat : ajout de l'impression des tableaux d'absence 2026-02-06 16:20:09 +01:00
ee26fdd045 Ajout du service pour récupérer les jours fériés (#1)
Reviewed-on: #1
Co-authored-by: kevin <kevin@yuno.malio.fr>
Co-committed-by: kevin <kevin@yuno.malio.fr>
2026-02-06 09:58:06 +00:00
50 changed files with 3453 additions and 299 deletions

View File

@@ -0,0 +1,23 @@
---
name: "Merge Request"
about: "Template de MR"
title: "[#NUMERO_TICKET] TITRE TICKET"
ref: "main"
---
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
| | |
## Description de la PR
## Modification du .env
## Check list
- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

View File

@@ -0,0 +1,65 @@
name: Auto Tag Develop
on:
push:
branches:
- develop
jobs:
tag:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
persist-credentials: true
- name: Create next tag from config/version.yaml
shell: bash
run: |
set -euo pipefail
# Skip if current commit already has a vX.Y.Z tag
if git tag --points-at HEAD | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag already exists on this commit. Skipping."
exit 0
fi
changed_version=false
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
changed_version=true
fi
read_version() {
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
}
if $changed_version; then
version="$(read_version)"
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version in version.yaml: $version" >&2
exit 1
fi
else
last_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)"
if [ -z "$last_tag" ]; then
version="0.1.0"
else
base="${last_tag#v}"
IFS='.' read -r major minor patch <<< "$base"
version="${major}.${minor}.$((patch + 1))"
fi
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
git config user.name "gitea-actions"
git config user.email "gitea-actions@local"
git add config/version.yaml
git commit -m "chore: bump version to v$version" || true
git push origin develop || true
fi
tag="v$version"
git tag "$tag"
git push origin "$tag"

View File

@@ -0,0 +1,65 @@
name: Build Release Artefact
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
set -euo pipefail
mkdir -p release
tar -czf "release/sirh-${GITHUB_REF_NAME}.tar.gz" \
bin \
config \
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \
symfony.lock \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/sirh-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

146
.idea/SIRH.iml generated
View File

@@ -1,7 +1,151 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="App\" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="App\Tests\" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/doctrine-common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/doctrine-orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/documentation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/http-cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/hydra" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/json-schema" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/jsonld" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/metadata" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/openapi" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/symfony" />
<excludeFolder url="file://$MODULE_DIR$/vendor/api-platform/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/clue/ndjson-react" />
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/dbal" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/deprecations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/event-manager" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/inflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/instantiator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/lexer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/migrations" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/evenement/evenement" />
<excludeFolder url="file://$MODULE_DIR$/vendor/fidry/cpu-core-counter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/friendsofphp/php-cs-fixer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/lcobucci/jwt" />
<excludeFolder url="file://$MODULE_DIR$/vendor/lexik/jwt-authentication-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nelmio/cors-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-text-template" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-timer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/phpunit" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/child-process" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/dns" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/event-loop" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/promise" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/socket" />
<excludeFolder url="file://$MODULE_DIR$/vendor/react/stream" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/comparator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/complexity" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/diff" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/environment" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/global-state" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/lines-of-code" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-enumerator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/object-reflector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/recursion-context" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/type" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/version" />
<excludeFolder url="file://$MODULE_DIR$/vendor/staabm/side-effects-detector" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/asset" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/cache-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/clock" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/config" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/console" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/framework-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-mbstring" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php85" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-uuid" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/process" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-access" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/property-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/routing" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/runtime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-core" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-csrf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/uid" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
<excludeFolder url="file://$MODULE_DIR$/vendor/willdurand/negotiation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/dompdf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-font-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-svg-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>

6
.idea/copilot.data.migration.agent.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

12
.idea/dataSources.xml generated
View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="postgres@localhost" uuid="9cad43df-2147-4989-b7a4-443067034884">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5434/postgres</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
</component>
</project>

8
.idea/laravel-idea.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="InertiaPackage">
<option name="directoryPaths">
<list />
</option>
</component>
</project>

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="-7cf7a629:19c1e9ce3f8:-7e79" />
</MTProjectMetadataState>
</option>
</component>
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-3bc0fa3e:19bc6e06872:-7ff9" />
</MTProjectMetadataState>
</option>
</component>
</project>

188
.idea/php.xml generated
View File

@@ -1,20 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/psr/link" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PhpUnit">
<phpunit_settings>
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
</phpunit_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

View File

@@ -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",
@@ -22,6 +23,8 @@
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",

870
composer.lock generated
View File

@@ -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": "a5d6b80c80a3a24c3086c71312114bb8",
"content-hash": "71d28cc0a29fa3f385b067186aa43678",
"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,176 @@
],
"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": "monolog/monolog",
"version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "nelmio/cors-bundle",
"version": "2.6.1",
@@ -3142,6 +3467,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",
@@ -4617,6 +5016,180 @@
],
"time": "2026-01-27T09:06:10+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.0.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
"reference": "f9fdd372473e66469c6d32a4ed12efcffdea38c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client/zipball/f9fdd372473e66469c6d32a4ed12efcffdea38c4",
"reference": "f9fdd372473e66469c6d32a4ed12efcffdea38c4",
"shasum": ""
},
"require": {
"php": ">=8.4",
"psr/log": "^1|^2|^3",
"symfony/http-client-contracts": "~3.4.4|^3.5.2",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/cache": "^7.4|^8.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"homepage": "https://symfony.com",
"keywords": [
"http"
],
"support": {
"source": "https://github.com/symfony/http-client/tree/v8.0.5"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-27T16:18:07+00:00"
},
{
"name": "symfony/http-client-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
"reference": "75d7043853a42837e68111812f4d964b01e5101c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
"reference": "75d7043853a42837e68111812f4d964b01e5101c",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Contracts\\HttpClient\\": ""
},
"exclude-from-classmap": [
"/Test/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Generic abstractions related to HTTP clients",
"homepage": "https://symfony.com",
"keywords": [
"abstractions",
"contracts",
"decoupling",
"interfaces",
"interoperability",
"standards"
],
"support": {
"source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-29T11:18:49+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v8.0.5",
@@ -4801,6 +5374,162 @@
],
"time": "2026-01-28T10:46:31+00:00"
},
{
"name": "symfony/monolog-bridge",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
"reference": "7c3da570ec252d5ca1212945ddbbf1dac4a0d779"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/7c3da570ec252d5ca1212945ddbbf1dac4a0d779",
"reference": "7c3da570ec252d5ca1212945ddbbf1dac4a0d779",
"shasum": ""
},
"require": {
"monolog/monolog": "^3",
"php": ">=8.4",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/mailer": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/security-core": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Monolog\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.4"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-01-07T12:23:22+00:00"
},
{
"name": "symfony/monolog-bundle",
"version": "v4.0.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bundle.git",
"reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/3b4ee2717ee56c5e1edb516140a175eb2a72bc66",
"reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.0",
"monolog/monolog": "^3.5",
"php": ">=8.2",
"symfony/config": "^7.3 || ^8.0",
"symfony/dependency-injection": "^7.3 || ^8.0",
"symfony/http-kernel": "^7.3 || ^8.0",
"symfony/monolog-bridge": "^7.3 || ^8.0",
"symfony/polyfill-php84": "^1.30"
},
"require-dev": {
"phpunit/phpunit": "^11.5.41 || ^12.3",
"symfony/console": "^7.3 || ^8.0",
"symfony/yaml": "^7.3 || ^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MonologBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MonologBundle",
"homepage": "https://symfony.com",
"keywords": [
"log",
"logging"
],
"support": {
"issues": "https://github.com/symfony/monolog-bundle/issues",
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-08T08:00:13+00:00"
},
{
"name": "symfony/password-hasher",
"version": "v8.0.4",
@@ -7151,6 +7880,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",

View File

@@ -8,6 +8,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
@@ -20,4 +21,5 @@ return [
NelmioCorsBundle::class => ['all' => true],
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,28 @@
monolog:
channels: [deprecation]
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@prod:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!deprecation"]
deprecation:
type: stream
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"

View File

@@ -48,6 +48,8 @@ security:
access_control:
- { path: ^/login_check, roles: PUBLIC_ACCESS }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Version de l'application en public
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:

View File

@@ -467,7 +467,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
* http_client?: bool|array{ // HTTP Client configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* max_host_connections?: int|Param, // The maximum number of connections to a single host.
* default_options?: array{
* headers?: array<string, mixed>,
@@ -1608,6 +1608,149 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
* },
* }
* @psalm-type MonologConfig = array{
* use_microseconds?: scalar|Param|null, // Default: true
* channels?: list<scalar|Param|null>,
* handlers?: array<string, array{ // Default: []
* type: scalar|Param|null,
* id?: scalar|Param|null,
* enabled?: bool|Param, // Default: true
* priority?: scalar|Param|null, // Default: 0
* level?: scalar|Param|null, // Default: "DEBUG"
* bubble?: bool|Param, // Default: true
* interactive_only?: bool|Param, // Default: false
* app_name?: scalar|Param|null, // Default: null
* include_stacktraces?: bool|Param, // Default: false
* process_psr_3_messages?: array{
* enabled?: bool|Param|null, // Default: null
* date_format?: scalar|Param|null,
* remove_used_context_fields?: bool|Param,
* },
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
* file_permission?: scalar|Param|null, // Default: null
* use_locking?: bool|Param, // Default: false
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
* date_format?: scalar|Param|null, // Default: "Y-m-d"
* ident?: scalar|Param|null, // Default: false
* logopts?: scalar|Param|null, // Default: 1
* facility?: scalar|Param|null, // Default: "user"
* max_files?: scalar|Param|null, // Default: 0
* action_level?: scalar|Param|null, // Default: "WARNING"
* activation_strategy?: scalar|Param|null, // Default: null
* stop_buffering?: bool|Param, // Default: true
* passthru_level?: scalar|Param|null, // Default: null
* excluded_http_codes?: list<array{ // Default: []
* code?: scalar|Param|null,
* urls?: list<scalar|Param|null>,
* }>,
* accepted_levels?: list<scalar|Param|null>,
* min_level?: scalar|Param|null, // Default: "DEBUG"
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
* buffer_size?: scalar|Param|null, // Default: 0
* flush_on_overflow?: bool|Param, // Default: false
* handler?: scalar|Param|null,
* url?: scalar|Param|null,
* exchange?: scalar|Param|null,
* exchange_name?: scalar|Param|null, // Default: "log"
* channel?: scalar|Param|null, // Default: null
* bot_name?: scalar|Param|null, // Default: "Monolog"
* use_attachment?: scalar|Param|null, // Default: true
* use_short_attachment?: scalar|Param|null, // Default: false
* include_extra?: scalar|Param|null, // Default: false
* icon_emoji?: scalar|Param|null, // Default: null
* webhook_url?: scalar|Param|null,
* exclude_fields?: list<scalar|Param|null>,
* token?: scalar|Param|null,
* region?: scalar|Param|null,
* source?: scalar|Param|null,
* use_ssl?: bool|Param, // Default: true
* user?: mixed,
* title?: scalar|Param|null, // Default: null
* host?: scalar|Param|null, // Default: null
* port?: scalar|Param|null, // Default: 514
* config?: list<scalar|Param|null>,
* members?: list<scalar|Param|null>,
* connection_string?: scalar|Param|null,
* timeout?: scalar|Param|null,
* time?: scalar|Param|null, // Default: 60
* deduplication_level?: scalar|Param|null, // Default: 400
* store?: scalar|Param|null, // Default: null
* connection_timeout?: scalar|Param|null,
* persistent?: bool|Param,
* message_type?: scalar|Param|null, // Default: 0
* parse_mode?: scalar|Param|null, // Default: null
* disable_webpage_preview?: bool|Param|null, // Default: null
* disable_notification?: bool|Param|null, // Default: null
* split_long_messages?: bool|Param, // Default: false
* delay_between_messages?: bool|Param, // Default: false
* topic?: int|Param, // Default: null
* factor?: int|Param, // Default: 1
* tags?: list<scalar|Param|null>,
* console_formatter_options?: mixed, // Default: []
* formatter?: scalar|Param|null,
* nested?: bool|Param, // Default: false
* publisher?: string|array{
* id?: scalar|Param|null,
* hostname?: scalar|Param|null,
* port?: scalar|Param|null, // Default: 12201
* chunk_size?: scalar|Param|null, // Default: 1420
* encoder?: "json"|"compressed_json"|Param,
* },
* mongodb?: string|array{
* id?: scalar|Param|null, // ID of a MongoDB\Client service
* uri?: scalar|Param|null,
* username?: scalar|Param|null,
* password?: scalar|Param|null,
* database?: scalar|Param|null, // Default: "monolog"
* collection?: scalar|Param|null, // Default: "logs"
* },
* elasticsearch?: string|array{
* id?: scalar|Param|null,
* hosts?: list<scalar|Param|null>,
* host?: scalar|Param|null,
* port?: scalar|Param|null, // Default: 9200
* transport?: scalar|Param|null, // Default: "Http"
* user?: scalar|Param|null, // Default: null
* password?: scalar|Param|null, // Default: null
* },
* index?: scalar|Param|null, // Default: "monolog"
* document_type?: scalar|Param|null, // Default: "logs"
* ignore_error?: scalar|Param|null, // Default: false
* redis?: string|array{
* id?: scalar|Param|null,
* host?: scalar|Param|null,
* password?: scalar|Param|null, // Default: null
* port?: scalar|Param|null, // Default: 6379
* database?: scalar|Param|null, // Default: 0
* key_name?: scalar|Param|null, // Default: "monolog_redis"
* },
* predis?: string|array{
* id?: scalar|Param|null,
* host?: scalar|Param|null,
* },
* from_email?: scalar|Param|null,
* to_email?: list<scalar|Param|null>,
* subject?: scalar|Param|null,
* content_type?: scalar|Param|null, // Default: null
* headers?: list<scalar|Param|null>,
* mailer?: scalar|Param|null, // Default: null
* email_prototype?: string|array{
* id: scalar|Param|null,
* method?: scalar|Param|null, // Default: null
* },
* verbosity_levels?: array{
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
* },
* channels?: string|array{
* type?: scalar|Param|null,
* elements?: list<scalar|Param|null>,
* },
* }>,
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1620,6 +1763,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* monolog?: MonologConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1632,6 +1776,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* monolog?: MonologConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1645,6 +1790,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* monolog?: MonologConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1658,6 +1804,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* monolog?: MonologConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,

View File

@@ -8,6 +8,9 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
imports:
- { resource: version.yaml }
services:
# default configuration for services in *this* file
_defaults:

2
config/version.yaml Normal file
View File

@@ -0,0 +1,2 @@
parameters:
app.version: '0.1.2'

43
deploy/nginx/sirh.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen 80;
server_name sirh.malio-dev.fr;
root /var/www/sirh/frontend/.output/public;
index index.html;
location ^~ /api/ {
root /var/www/sirh/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/sirh/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/sirh/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/sirh/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/sirh/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/sirh/public;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -3,3 +3,11 @@
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
const { load } = useAppVersion()
onMounted(() => {
load()
})
</script>

View File

@@ -0,0 +1,201 @@
<template>
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé <span class="text-red-600">*</span>
</label>
<select
id="employee"
v-model="absenceForm.employeeId"
:class="employeeFieldClass"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
L'employé est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="type">
Type d'absence <span class="text-red-600">*</span>
</label>
<select
id="type"
v-model="absenceForm.typeId"
:class="typeFieldClass"
>
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
Le type d'absence est obligatoire.
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-md font-semibold text-neutral-700" for="start-date">Date de début</label>
<input
id="start-date"
v-model="absenceForm.startDate"
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" for="end-date">Date de fin</label>
<input
id="end-date"
v-model="absenceForm.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea
id="comment"
v-model="absenceForm.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
v-if="editingAbsence"
type="button"
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
@click="handleDelete"
>
Supprimer
</button>
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="handleCancel"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, reactive, toRef, watch } from 'vue'
import type { Employee } from '~/services/dto/employee'
import type { AbsenceType } from '~/services/dto/absence-type'
import type { Absence } from '~/services/dto/absence'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
employees: Employee[]
absenceTypes: AbsenceType[]
form: {
employeeId: number | ''
typeId: number | ''
startDate: string
endDate: string
comment: string
}
editingAbsence: Absence | null
isSubmitting: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit'): void
(event: 'delete'): void
(event: 'cancel'): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const absenceForm = toRef(props, 'form')
const editingAbsence = toRef(props, 'editingAbsence')
const validationTouched = reactive({
employee: false,
type: false
})
const isEmployeeValid = computed(() => absenceForm.value.employeeId !== '')
const isTypeValid = computed(() => absenceForm.value.typeId !== '')
const isFormValid = computed(() => isEmployeeValid.value && isTypeValid.value)
const showEmployeeError = computed(
() => validationTouched.employee && !isEmployeeValid.value
)
const showTypeError = computed(
() => validationTouched.type && !isTypeValid.value
)
const submitButtonClass = computed(() => {
if (props.isSubmitting || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const employeeFieldClass = computed(() => {
if (showEmployeeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const typeFieldClass = computed(() => {
if (showTypeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
validationTouched.employee = false
validationTouched.type = false
}
}
)
const handleSubmit = () => {
validationTouched.employee = true
validationTouched.type = true
if (!isEmployeeValid.value || !isTypeValid.value) return
emit('submit')
}
const handleDelete = () => {
emit('delete')
}
const handleCancel = () => {
emit('cancel')
emit('update:modelValue', false)
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<AppDrawer v-model="drawerOpen" title="Imprimer les absences">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-md font-semibold text-neutral-700" for="print-from">
Date de début <span class="text-red-600">*</span>
</label>
<input
id="print-from"
v-model="printForm.from"
type="date"
:class="fromFieldClass"
/>
<p v-if="showFromError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="print-to">
Date de fin <span class="text-red-600">*</span>
</label>
<input
id="print-to"
v-model="printForm.to"
type="date"
:class="toFieldClass"
/>
<p v-if="showToError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire.
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Sites <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
<label class="text-md" :for="`print-site-${site.id}`">{{ site.name }}</label>
<input
:id="`print-site-${site.id}`"
v-model="printForm.siteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showSitesError" class="text-sm text-red-600">
Sélectionne au moins un site.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="handleCancel"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, reactive, toRef, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
type SiteOption = {
id: number
name: string
color: string
}
const props = defineProps<{
modelValue: boolean
sites: SiteOption[]
printForm: {
from: string
to: string
siteIds: number[]
}
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit'): void
(event: 'cancel'): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const printForm = toRef(props, 'printForm')
const validationTouched = reactive({
from: false,
to: false,
sites: false
})
const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '')
const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
const isFormValid = computed(
() => isFromValid.value && isToValid.value && isSitesValid.value
)
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const fromFieldClass = computed(() => {
if (showFromError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const toFieldClass = computed(() => {
if (showToError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (!isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const handleSubmit = () => {
validationTouched.from = true
validationTouched.to = true
validationTouched.sites = true
if (!isFormValid.value) return
emit('submit')
}
const handleCancel = () => {
emit('cancel')
emit('update:modelValue', false)
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
validationTouched.from = false
validationTouched.to = false
validationTouched.sites = false
}
}
)
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="min-w-[900px]">
<div class="grid" :style="gridStyle">
<div
class="sticky left-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
>
Employés
</div>
<div
v-for="day in daysInMonth"
:key="day.date"
class="border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
>
<div>{{ day.label }}</div>
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
</div>
<template v-for="employee in visibleEmployees" :key="employee.id">
<div
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
>
{{ formatEmployeeName(employee) }}
</div>
<div
v-for="day in daysInMonth"
:key="employee.id + '-' + day.date"
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
>
<button
type="button"
class="flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@click="handleCellClick(employee, day.date)"
>
<span v-if="getCellCode(employee.id, day.date)">
{{ getCellCode(employee.id, day.date) }}
</span>
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
type DayInfo = {
date: string
label: string
weekday: string
}
defineProps<{
daysInMonth: DayInfo[]
visibleEmployees: Employee[]
gridStyle: Record<string, string>
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
getCellCode: (employeeId: number, date: string) => string
formatEmployeeName: (employee: Employee) => string
isHolidayDate: (date: string) => boolean
}>()
const emit = defineEmits<{
(event: 'cell-click', employee: Employee, date: string): void
}>()
const handleCellClick = (employee: Employee, date: string) => {
emit('cell-click', employee, date)
}
</script>

View File

@@ -0,0 +1,17 @@
export const useAppVersion = () => {
const api = useApi()
const version = useState<string | null>('app-version', () => null)
const load = async () => {
if (version.value) {
return version.value
}
const response = await api.get<{ version: string }>('version', {}, {
toast: false
})
version.value = response.version
return version.value
}
return { version, load }
}

View File

@@ -0,0 +1,31 @@
import {useApi} from '~/composables/useApi'
export const usePdfPrinter = () => {
const api = useApi()
const printPdf = async (url: string): Promise<void> => {
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
}
}

View File

@@ -5,3 +5,7 @@
</main>
</div>
</template>
<script setup lang="ts">
const { version } = useAppVersion()
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="min-h-screen">
<div class="flex min-h-screen">
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500">
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
<div>
<img src="/malio.png" alt="Logo" class="w-auto"/>
</div>
@@ -43,7 +43,7 @@
</NuxtLink>
</nav>
<div class="p-4">
<div class="flex flex-col gap-2 items-center p-4">
<button
type="button"
class="w-full rounded-lg px-4 py-2 text-md font-semibold text-white bg-primary-500"
@@ -51,10 +51,11 @@
>
Déconnexion
</button>
<p class="font-bold">v{{ version }}</p>
</div>
</aside>
<main class="flex-1 px-8 py-8">
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
<slot/>
</main>
</div>
@@ -63,6 +64,7 @@
<script setup lang="ts">
const auth = useAuthStore()
const {version} = useAppVersion()
const handleLogout = async () => {
await auth.logout()

View File

@@ -66,35 +66,50 @@
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="code">Code</label>
<label class="text-md font-semibold text-neutral-700" for="code">
Code <span class="text-red-600">*</span>
</label>
<input
id="code"
v-model="form.code"
type="text"
maxlength="10"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="codeFieldClass"
/>
<p v-if="showCodeError" class="mt-1 text-sm text-red-600">
Le code est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="label">Libellé</label>
<label class="text-md font-semibold text-neutral-700" for="label">
Libellé <span class="text-red-600">*</span>
</label>
<input
id="label"
v-model="form.label"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="labelFieldClass"
/>
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
Le libellé est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
</label>
<div class="mt-2 flex items-center gap-3">
<input
id="color"
v-model="form.color"
type="color"
class="h-10 w-16 cursor-pointer rounded-md border border-neutral-300 bg-white p-1"
:class="colorFieldClass"
/>
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
</div>
<p v-if="showColorError" class="mt-1 text-sm text-red-600">
La couleur est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -107,7 +122,7 @@
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
:class="submitButtonClass"
>
Enregistrer
</button>
@@ -135,7 +150,53 @@ const drawerTitle = computed(() =>
const form = reactive({
code: '',
label: '',
color: ''
color: '#222783'
})
const validationTouched = reactive({
code: false,
label: false,
color: false
})
const isCodeValid = computed(() => form.code.trim() !== '')
const isLabelValid = computed(() => form.label.trim() !== '')
const isColorValid = computed(() => form.color.trim() !== '')
const isFormValid = computed(
() => isCodeValid.value && isLabelValid.value && isColorValid.value
)
const showCodeError = computed(() => validationTouched.code && !isCodeValid.value)
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const labelFieldClass = computed(() => {
if (showLabelError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const colorFieldClass = computed(() => {
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
if (showColorError.value) {
return `${baseColorClass} border-red-500`
}
return `${baseColorClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadAbsenceTypes = async () => {
@@ -152,7 +213,7 @@ onMounted(loadAbsenceTypes)
const resetForm = () => {
form.code = ''
form.label = ''
form.color = ''
form.color = '#222783'
}
const openCreate = () => {
@@ -177,6 +238,10 @@ const closeDrawer = () => {
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.code = true
validationTouched.label = true
validationTouched.color = true
if (!isFormValid.value) return
isSubmitting.value = true
try {
@@ -201,6 +266,14 @@ const handleSubmit = async () => {
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.code = false
validationTouched.label = false
validationTouched.color = false
}
})
const confirmDelete = async (type: AbsenceType) => {
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
if (!ok) return

View File

@@ -1,8 +1,10 @@
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-4 pb-10">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
<div class="flex items-center gap-3">
</div>
<div class="flex items-center justify-between py-6">
<div class="flex items-center gap-4">
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
@@ -18,7 +20,7 @@
</div>
<select
v-model="selectedMonth"
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
@@ -26,154 +28,61 @@
</select>
<select
v-model="selectedYear"
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="year in years" :key="year" :value="year">
{{ year }}
</option>
</select>
</div>
<div class="flex gap-4">
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreateFromToday"
>
Ajouter une absence
</button>
<button
type="button"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
@click="openPrint"
>
Imprimer
</button>
</div>
</div>
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="min-w-[900px]">
<div class="grid" :style="gridStyle">
<div
class="sticky left-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700">
Employés
</div>
<div
v-for="day in daysInMonth"
:key="day.date"
class="border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
>
<div>{{ day.label }}</div>
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
</div>
<CalendarGrid
:days-in-month="daysInMonth"
:visible-employees="visibleEmployees"
:grid-style="gridStyle"
:get-cell-style="getCellStyle"
:get-cell-code="getCellCode"
:format-employee-name="formatEmployeeName"
:is-holiday-date="isHolidayDate"
@cell-click="openCreate"
/>
<template v-for="employee in visibleEmployees" :key="employee.id">
<div
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
>
{{ formatEmployeeName(employee) }}
</div>
<div
v-for="day in daysInMonth"
:key="employee.id + '-' + day.date"
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
>
<button
type="button"
class="flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
:style="getCellStyle(employee.id, day.date)"
@click="openCreate(employee, day.date)"
>
<span v-if="getCellCode(employee.id, day.date)">
{{ getCellCode(employee.id, day.date) }}
</span>
</button>
</div>
</template>
</div>
</div>
</div>
<AbsenceFormDrawer
v-model="isDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="form"
:editing-absence="editingAbsence"
:is-submitting="isSubmitting"
@submit="handleSubmit"
@delete="handleDelete"
@cancel="closeDrawer"
/>
<AppDrawer v-model="isDrawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="employee">Employé</label>
<select
id="employee"
v-model="form.employeeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="type">Type d'absence</label>
<select
id="type"
v-model="form.typeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-md font-semibold text-neutral-700" for="start-date">Date de début</label>
<input
id="start-date"
v-model="form.startDate"
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" for="end-date">Date de fin</label>
<input
id="end-date"
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea
id="comment"
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
v-if="editingAbsence"
type="button"
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
@click="handleDelete"
>
Supprimer
</button>
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
<AbsencePrintDrawer
v-model="isPrintOpen"
:sites="sites"
:print-form="printForm"
@submit="handlePrint"
@cancel="closePrint"
/>
</div>
</template>
@@ -184,17 +93,21 @@ import type {Absence} from '~/services/dto/absence'
import {listEmployees} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
const employees = ref<Employee[]>([])
const sites = computed(() => {
const map = new Map<number, { id: number; name: string; color: string }>()
const siteMap = new Map<number, { id: number; name: string; color: string }>()
for (const employee of employees.value) {
if (employee.site) {
map.set(employee.site.id, employee.site)
siteMap.set(employee.site.id, employee.site)
}
}
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
return Array.from(siteMap.values()).sort((siteA, siteB) => siteA.name.localeCompare(siteB.name, 'fr'))
})
const selectedSiteIds = ref<number[]>([])
@@ -207,16 +120,16 @@ watch(sites, (next) => {
}, { immediate: true })
const sortedEmployees = computed(() => {
return [...employees.value].sort((a, b) => {
const siteA = a.site?.name ?? ''
const siteB = b.site?.name ?? ''
if (siteA !== siteB) return siteA.localeCompare(siteB, 'fr')
const lastA = a.lastName ?? ''
const lastB = b.lastName ?? ''
if (lastA !== lastB) return lastA.localeCompare(lastB, 'fr')
const firstA = a.firstName ?? ''
const firstB = b.firstName ?? ''
return firstA.localeCompare(firstB, 'fr')
return [...employees.value].sort((employeeA, employeeB) => {
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
})
})
@@ -228,10 +141,12 @@ const visibleEmployees = computed(() => {
})
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const publicHolidays = ref<Record<string, string>>({})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const editingAbsence = ref<Absence | null>(null)
const isPrintOpen = ref(false)
const now = new Date()
const selectedMonth = ref(now.getMonth())
@@ -252,13 +167,15 @@ const months = [
{value: 11, label: 'Décembre'}
]
const years = Array.from({length: 5}, (_, i) => now.getFullYear() - 2 + i)
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
const monthStartDate = computed(() => new Date(selectedYear.value, selectedMonth.value, 1))
const monthEndDate = computed(() => new Date(selectedYear.value, selectedMonth.value + 1, 0))
const gridStyle = computed(() => ({
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
gridTemplateColumns: `160px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
}))
const form = reactive({
@@ -269,6 +186,12 @@ const form = reactive({
comment: ''
})
const printForm = reactive({
from: '',
to: '',
siteIds: [] as number[]
})
const resetForm = () => {
form.employeeId = ''
@@ -284,6 +207,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()
}
@@ -292,33 +274,82 @@ const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadPublicHolidays = async () => {
publicHolidays.value = await listPublicHolidays('metropole', selectedYear.value)
}
const loadAbsences = async () => {
absences.value = await listAbsences()
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
absences.value = await listAbsences({
from: monthStart,
to: monthEnd,
siteIds: selectedSiteIds.value
})
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadAbsences()])
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
})
watch([selectedMonth, selectedYear], async () => {
watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await loadAbsences()
})
const getCellAbsence = (employeeId: number, date: string) => {
const match = absences.value.find((absence) => {
const employee = absence.employee?.id
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
return Number(employee) === employeeId && date >= start && date <= end
})
watch(selectedYear, async () => {
await loadPublicHolidays()
})
if (!match) return null
// Indexation des absences par cellule pour eviter un find() a chaque case.
const cellAbsenceMap = computed(() => {
const map = new Map<string, { id: number; code: string; color: string; textColor?: string }>()
const monthStart = monthStartDate.value
const monthEnd = monthEndDate.value
return {
id: match.id,
code: match.type?.code ?? '',
color: match.type?.color ?? '#222783'
for (const absence of absences.value) {
const employeeId = absence.employee?.id
if (!employeeId) continue
const start = parseYmd(normalizeDate(absence.startDate))
const end = parseYmd(normalizeDate(absence.endDate))
if (!start || !end) continue
const rangeStart = start < monthStart ? monthStart : start
const rangeEnd = end > monthEnd ? monthEnd : end
if (rangeEnd < rangeStart) continue
for (
let currentDate = new Date(rangeStart.getTime());
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const key = `${employeeId}-${toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())}`
map.set(key, {
id: absence.id,
code: absence.type?.code ?? '',
color: absence.type?.color ?? '#222783'
})
}
}
return map
})
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
}
const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) {
return {
id: 0,
code: 'F',
color: '#b3e5fc',
textColor: '#0f172a'
}
}
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return absence
return null
}
const getCellStyle = (employeeId: number, date: string) => {
@@ -327,7 +358,7 @@ const getCellStyle = (employeeId: number, date: string) => {
return {
backgroundColor: absence.color,
color: '#fff'
color: absence.textColor ?? '#fff'
}
}
@@ -336,6 +367,11 @@ const getCellCode = (employeeId: number, date: string) => {
}
const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
@@ -367,12 +403,33 @@ const openCreateFromToday = () => {
form.typeId = ''
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
if (isHolidayDate(today)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
form.startDate = today
form.endDate = today
form.comment = ''
isDrawerOpen.value = true
}
const hasHolidayInRange = (startDate: string, endDate: string) => {
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) return false
for (
let currentDate = new Date(start.getTime());
currentDate <= end;
currentDate.setDate(currentDate.getDate() + 1)
) {
const key = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
if (isHolidayDate(key)) {
return true
}
}
return false
}
const handleSubmit = async () => {
if (isSubmitting.value) return
@@ -380,6 +437,10 @@ const handleSubmit = async () => {
try {
const start = normalizeDate(form.startDate)
const end = normalizeDate(form.endDate)
if (hasHolidayInRange(start, end)) {
window.alert("Impossible de creer une absence sur un jour ferie.")
return
}
const overlaps = absences.value.filter((absence) => {
if (absence.employee?.id !== Number(form.employeeId)) return false
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
@@ -388,8 +449,15 @@ const handleSubmit = async () => {
return start <= aEnd && end >= aStart
})
for (const overlap of overlaps) {
await deleteAbsence(overlap.id)
if (overlaps.length > 0) {
// Securise le chevauchement: on demande confirmation avant suppression.
const confirmReplace = window.confirm(
"Cette absence chevauche une autre. Voulez-vous la remplacer ?"
)
if (!confirmReplace) return
for (const overlap of overlaps) {
await deleteAbsence(overlap.id)
}
}
if (editingAbsence.value) {
@@ -421,8 +489,8 @@ const handleSubmit = async () => {
const handleDelete = async () => {
if (!editingAbsence.value) return
const ok = window.confirm('Supprimer cette absence ?')
if (!ok) return
const confirmDelete = window.confirm('Supprimer cette absence ?')
if (!confirmDelete) return
await deleteAbsence(editingAbsence.value.id)
closeDrawer()
@@ -434,4 +502,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
}
</script>

View File

@@ -60,35 +60,50 @@
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="first-name">Prénom</label>
<label class="text-md font-semibold text-neutral-700" for="first-name">
Prénom <span class="text-red-600">*</span>
</label>
<input
id="first-name"
v-model="form.firstName"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="firstNameFieldClass"
/>
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
Le prénom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="last-name">Nom</label>
<label class="text-md font-semibold text-neutral-700" for="last-name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="last-name"
v-model="form.lastName"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="lastNameFieldClass"
/>
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
Le nom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="site">Site</label>
<label class="text-md font-semibold text-neutral-700" for="site">
Site <span class="text-red-600">*</span>
</label>
<select
id="site"
v-model="form.siteId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
:class="siteFieldClass"
>
<option value="">Aucun site</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
Le site est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -101,7 +116,7 @@
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
:class="submitButtonClass"
>
Enregistrer
</button>
@@ -134,6 +149,59 @@ const form = reactive({
siteId: '' as number | ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false
})
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
)
const showFirstNameError = computed(
() => validationTouched.firstName && !isFirstNameValid.value
)
const showLastNameError = computed(
() => validationTouched.lastName && !isLastNameValid.value
)
const showSiteError = computed(
() => validationTouched.siteId && !isSiteValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const lastNameFieldClass = computed(() => {
if (showLastNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const siteFieldClass = computed(() => {
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showSiteError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadEmployees = async () => {
isLoading.value = true
try {
@@ -153,6 +221,10 @@ onMounted(async () => {
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.firstName = true
validationTouched.lastName = true
validationTouched.siteId = true
if (!isFormValid.value) return
isSubmitting.value = true
try {
@@ -181,6 +253,14 @@ const handleSubmit = async () => {
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.firstName = false
validationTouched.lastName = false
validationTouched.siteId = false
}
})
const openEdit = (employee: Employee) => {
editingEmployee.value = employee
form.firstName = employee.firstName

View File

@@ -1,72 +1,74 @@
<template>
<div class="mx-auto w-full max-w-lg">
<div class="mx-auto w-full max-w-lg">
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
LOGO
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
</span>
<form
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit"
>
<div>
<label class="text-sm font-semibold text-neutral-700" for="username">
Nom d'utilisateur
</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
/>
</div>
<form
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit"
>
<div>
<label class="text-sm font-semibold text-neutral-700" for="username">
Nom d'utilisateur
</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
/>
</div>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
/>
</div>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
/>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Se connecter
</button>
</form>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Se connecter
</button>
<p class="font-bold">v{{ version }}</p>
</form>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
definePageMeta({layout: 'auth'})
const router = useRouter()
const auth = useAuthStore()
const { version } = useAppVersion()
const username = ref('')
const password = ref('')
const isSubmitting = ref(false)
const handleSubmit = async () => {
if (isSubmitting.value) return
if (isSubmitting.value) return
isSubmitting.value = true
try {
console.log(useRuntimeConfig().public.apiBase)
await auth.login(username.value, password.value)
isSubmitting.value = true
try {
console.log(useRuntimeConfig().public.apiBase)
await auth.login(username.value, password.value)
await router.push('/')
} finally {
isSubmitting.value = false
}
await router.push('/')
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -64,16 +64,23 @@
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="name">Nom</label>
<label class="text-md font-semibold text-neutral-700" for="name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="name"
v-model="form.name"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="nameFieldClass"
/>
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
Le nom du site est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
</label>
<div class="mt-2 flex items-center gap-3">
<input
id="color"
@@ -95,7 +102,7 @@
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
:class="submitButtonClass"
>
Enregistrer
</button>
@@ -125,6 +132,31 @@ const form = reactive({
color: '#222783'
})
const validationTouched = reactive({
name: false
})
const isNameValid = computed(() => form.name.trim() !== '')
const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadSites = async () => {
isLoading.value = true
try {
@@ -162,6 +194,8 @@ const closeDrawer = () => {
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.name = true
if (!isFormValid.value) return
isSubmitting.value = true
try {
@@ -184,6 +218,12 @@ const handleSubmit = async () => {
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.name = false
}
})
const confirmDelete = async (site: Site) => {
const ok = window.confirm(`Supprimer le site ${site.name} ?`)
if (!ok) return

View File

@@ -1,11 +1,27 @@
import type { Absence } from './dto/absence'
import { extractItems } from '~/utils/api'
export const listAbsences = async () => {
type ListAbsencesFilters = {
from?: string
to?: string
siteIds?: number[]
}
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
const api = useApi()
const query: Record<string, string | string[]> = {}
if (filters.from) {
query['endDate[after]'] = filters.from
}
if (filters.to) {
query['startDate[before]'] = filters.to
}
if (filters.siteIds && filters.siteIds.length > 0) {
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
}
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
'/absences',
{},
query,
{ toast: false }
)
return extractItems<Absence>(data)

View File

@@ -4,5 +4,5 @@ export type Employee = {
id: number
firstName: string
lastName: string
site?: Site | null
site: Site
}

View File

@@ -0,0 +1,18 @@
export type PublicHolidaysResponse =
| { days?: Record<string, string> }
| Record<string, string>
export const listPublicHolidays = async (zone: string, year: number) => {
const api = useApi()
const data = await api.get<PublicHolidaysResponse>(
`/public-holidays/${zone}/${year}`,
{},
{ toast: false }
)
if (data && typeof data === 'object' && 'days' in data) {
return (data.days ?? {}) as Record<string, string>
}
return (data ?? {}) as Record<string, string>
}

83
scripts/deploy-release.sh Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: ./scripts/deploy-release.sh v0.0.1
# Requires: curl, tar, (optional) rsync
#
# Auth token: set RELEASE_TOKEN env var or create /etc/sirh-release-token
umask 002
TAG="${1:-}"
if [ -z "$TAG" ]; then
echo "Usage: $0 v0.0.1" >&2
exit 1
fi
REPO_OWNER="MALIO-DEV"
REPO_NAME="SIRH"
GITEA_API="https://gitea.malio.fr/api/v1"
DEPLOY_DIR="/var/www/sirh"
if [ -f /etc/sirh-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
RELEASE_TOKEN="$(cat /etc/sirh-release-token)"
fi
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT
release_json="$tmp_dir/release.json"
curl_opts=(-sS)
if [ -n "${RELEASE_TOKEN:-}" ]; then
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
fi
curl "${curl_opts[@]}" \
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
-o "$release_json"
asset_url="$(python3 - "$release_json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], 'r'))
assets = data.get("assets", [])
for a in assets:
name = a.get("name", "")
if name.startswith("sirh-") and name.endswith(".tar.gz"):
print(a.get("browser_download_url", ""))
break
PY
)"
if [ -z "$asset_url" ]; then
echo "Release asset not found for tag ${TAG}" >&2
exit 1
fi
archive="$tmp_dir/artefact.tar.gz"
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
tar -xzf "$archive" -C "$tmp_dir"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete --no-perms --no-owner --no-group \
--exclude ".env" \
--exclude ".env.local" \
--exclude "config/jwt" \
--exclude "var" \
"$tmp_dir"/ "$DEPLOY_DIR"/
else
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
fi
# Ensure Nginx can traverse the deploy path.
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
if [ -f "${DEPLOY_DIR}/.env" ]; then
echo "Running migrations (if any)..."
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
else
echo "Skip migrations: ${DEPLOY_DIR}/.env not found" >&2
fi

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\AbsencePrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/absences/print',
provider: AbsencePrintProvider::class,
parameters: [
new QueryParameter(key: 'from', required: true),
new QueryParameter(key: 'to', required: true),
new QueryParameter(key: 'sites', required: false),
]
),
]
)]
final class AbsencePrint {}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\AppVersionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/version',
normalizationContext: ['groups' => ['version:read']],
provider: AppVersionProvider::class,
),
],
)]
final class AppVersion
{
#[Groups(['version:read'])]
public string $version = '';
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\PublicHolidayProvider;
use App\State\PublicHolidayYearProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/public-holidays/{zone}',
provider: PublicHolidayProvider::class
),
new Get(
uriTemplate: '/public-holidays/{zone}/{years}',
provider: PublicHolidayYearProvider::class
),
],
)]
final class PublicHoliday
{
public function __construct(
public array $days = []
) {}
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use DateTimeInterface;
@@ -19,6 +22,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
'datetime_format' => 'Y-m-d',
]
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
#[ORM\Entity]
#[ORM\Table(name: 'absences')]
class Absence

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Exception;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final class PublicHolidayService implements PublicHolidayServiceInterface
{
private const string BASE_URL = 'https://calendrier.api.gouv.fr/jours-feries/';
public function __construct(
private readonly HttpClientInterface $client,
) {}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
* @throws Exception
*/
public function getHolidaysDay(string $zone): array
{
$zone = strtolower(trim($zone));
$url = self::BASE_URL."{$zone}.json";
try {
$response = $this->client->request(
'GET',
$url
);
} catch (TransportExceptionInterface) {
throw new RuntimeException('Unable to reach public holidays API.');
} catch (ClientExceptionInterface) {
throw new RuntimeException('Invalid zone provided for public holidays.');
} catch (ServerExceptionInterface) {
throw new RuntimeException('Public holidays API is temporarily unavailable.');
} catch (Throwable) {
throw new RuntimeException('Unexpected error while fetching public holidays.');
}
return json_decode($response->getContent(), true);
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function getHolidaysDayByYears(string $zone, string $years): array
{
$zone = strtolower(trim($zone));
$years = trim($years);
$url = self::BASE_URL."{$zone}/{$years}.json";
try {
$response = $this->client->request('GET', $url);
} catch (TransportExceptionInterface) {
throw new RuntimeException('Unable to reach public holidays API.');
} catch (ClientExceptionInterface) {
throw new RuntimeException('Invalid zone or year provided for public holidays.');
} catch (ServerExceptionInterface) {
throw new RuntimeException('Public holidays API is temporarily unavailable.');
} catch (Throwable) {
throw new RuntimeException('Unexpected error while fetching public holidays.');
}
return json_decode($response->getContent(), true);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service;
interface PublicHolidayServiceInterface
{
public function getHolidaysDay(string $zone): array;
public function getHolidaysDayByYears(string $zone, string $years): array;
}

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
class AbsencePrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EntityManagerInterface $entityManager,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
/**
* @throws SyntaxError
* @throws RuntimeError
* @throws LoaderError
*/
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);
}
$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);
$holidayMap = $this->buildHolidayMap($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,
'holidayMap' => $holidayMap,
]);
$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<Employee> $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<Absence> $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;
}
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[$date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\AppVersion;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class AppVersionProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%app.version%')]
private string $version,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AppVersion
{
$dto = new AppVersion();
$dto->version = $this->version;
return $dto;
}
}

View File

@@ -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
{

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\PublicHoliday as PublicHolidayResource;
use App\Service\PublicHolidayServiceInterface;
final readonly class PublicHolidayProvider implements ProviderInterface
{
public function __construct(
private PublicHolidayServiceInterface $publicHolidayService
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$holidaysDay = $this->publicHolidayService->getHolidaysDay($uriVariables['zone']);
return $holidaysDay ? new PublicHolidayResource($holidaysDay) : null;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\PublicHoliday;
use App\Service\PublicHolidayServiceInterface;
final readonly class PublicHolidayYearProvider implements ProviderInterface
{
public function __construct(
private PublicHolidayServiceInterface $PublicHolidayYearService
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$holidaysDay = $this->PublicHolidayYearService->getHolidaysDayByYears($uriVariables['zone'], $uriVariables['years']);
return $holidaysDay ? new PublicHoliday($holidaysDay) : null;
}
}

View File

@@ -145,6 +145,18 @@
".editorconfig"
]
},
"symfony/monolog-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/property-info": {
"version": "8.0",
"recipe": {

View File

@@ -0,0 +1,219 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Absences</title>
<style>
@page { size: A3 landscape; margin: 8mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 8px;
}
table.calendar {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 4px solid #0a0a0a;
}
th, td {
border: 2px solid #0a0a0a;
padding: 2px 1px;
vertical-align: middle;
overflow: hidden;
white-space: nowrap;
}
thead th {
text-align: center;
font-weight: 700;
}
.col-employee {
font-size: 16px;
font-weight: bold;
width: 10mm;
text-align: left;
}
.col-day {
font-size: 10px;
text-align: center;
}
.month {
font-size: 16px;
font-weight: bold;
padding: 3px 0;
}
.site-title td {
font-weight: bold;
font-size: 16px;
text-transform: uppercase;
border-color: #0a0a0a;
padding: 4px 6px;
}
.site-title .label {
text-align: left;
}
.code {
font-weight: bold;
font-size: 9px;
}
.month-separator {
border-right: 4px solid #0a0a0a !important;
}
.weekend {
background: grey;
}
.holiday {
background: #b3e5fc;
}
</style>
</head>
<body>
{% set months = {
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
} %}
{% set dow = ['Lu','Ma','Me','Je','Ve','Sa','Di'] %}
{% set dayColWidthMm = 5 %}
<table class="calendar">
<thead>
{# Ligne 1 : mois #}
<tr>
<th class="col-employee" rowspan="4"></th>
{% set currentMonth = '' %}
{% set monthCount = 0 %}
{% for day in days %}
{% set m = day.date|date('n') %}
{% set monthLabel = months[m] ~ ' ' ~ (day.date|date('Y')) %}
{% if monthLabel != currentMonth %}
{% if not loop.first %}
<th class="month month-separator" colspan="{{ monthCount }}">{{ currentMonth }}</th>
{% endif %}
{% set currentMonth = monthLabel %}
{% set monthCount = 1 %}
{% else %}
{% set monthCount = monthCount + 1 %}
{% endif %}
{% if loop.last %}
<th class="month" colspan="{{ monthCount }}">{{ currentMonth }}</th>
{% endif %}
{% endfor %}
</tr>
{# Ligne 2 : numéro de semaine #}
<tr>
{% set currentWeek = '' %}
{% set weekCount = 0 %}
{% for day in days %}
{% set weekLabel = 'S' ~ (day.date|date('W')) %}
{% if weekLabel != currentWeek %}
{% if not loop.first %}
<th class="col-day" colspan="{{ weekCount }}">{{ currentWeek }}</th>
{% endif %}
{% set currentWeek = weekLabel %}
{% set weekCount = 1 %}
{% else %}
{% set weekCount = weekCount + 1 %}
{% endif %}
{% if loop.last %}
<th class="col-day" colspan="{{ weekCount }}">{{ currentWeek }}</th>
{% endif %}
{% endfor %}
</tr>
{# Ligne 3 : jour semaine #}
<tr>
{% for day in days %}
{% set idx = (day.date|date('N') - 1) %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<th class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}" style="width: {{ dayColWidthMm }}mm;">{{ dow[idx] }}</th>
{% endfor %}
</tr>
{# Ligne 4 : numéro #}
<tr>
{% for day in days %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<th class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}" style="width: {{ dayColWidthMm }}mm;">{{ day.label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% set currentSiteId = null %}
{% for employee in employees %}
{% set site = employee.site %}
{% set siteId = site ? site.id : 'none' %}
{% set siteName = site ? site.name : 'Sans site' %}
{# couleur de fond du site (si tu as un champ color) #}
{% set siteColor = '#ffd7d7' %}
{% if site and attribute(site, 'color') is defined and site.color %}
{% set siteColor = site.color %}
{% endif %}
{# Nouvelle section site #}
{% if siteId != currentSiteId %}
<tr class="site-title">
<td class="label" style="background: {{ siteColor }};" colspan="{{ 1 + (days|length) }}">
{{ siteName }}
</td>
</tr>
{% set currentSiteId = siteId %}
{% endif %}
{# Ligne employé #}
<tr>
<td class="col-employee">
{{ employee.firstName }}{% if employee.lastName %} {{ employee.lastName|first }}.{% endif %}
</td>
{% for day in days %}
{% set isHoliday = holidayMap[day.date] ?? null %}
{% set info = absenceMap[employee.id][day.date] ?? null %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<td class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday %} background-color: {{ info.color }};{% endif %}">
{% if isHoliday %}
<span class="code">F</span>
{% elseif info %}
<span class="code">{{ info.code }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td colspan="{{ 1 + (days|length) }}">Aucun employé.</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>