Compare commits

..

66 Commits

Author SHA1 Message Date
gitea-actions
640bb42d3a chore: bump version to v0.1.62
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m38s
2026-03-23 15:40:37 +00:00
50712ccb00 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-23 16:40:24 +01:00
265b19a9d0 fix : calcule des RTT pour les chauffeurs dans le récap des congés 2026-03-23 16:40:06 +01:00
gitea-actions
13743738fd chore: bump version to v0.1.61
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m45s
2026-03-20 13:42:48 +00:00
085fe0c150 fix : calcule des RTT pour les chauffeurs
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 14:42:36 +01:00
gitea-actions
a1110069b5 chore: bump version to v0.1.60
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-20 10:53:51 +00:00
4901c58ebf Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 11:53:34 +01:00
4de891579c feat : ajout des congés bonus pour les forfaits si ils travaillent un weekend ou férié 2026-03-20 11:53:24 +01:00
gitea-actions
a17d6a67cf chore: bump version to v0.1.59
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m59s
2026-03-20 10:26:29 +00:00
29db3b5025 fix : calcule des RTT sur les contrats 4h
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 11:26:17 +01:00
gitea-actions
6df9110187 chore: bump version to v0.1.58
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m39s
2026-03-20 07:13:49 +00:00
f0dfb30566 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 08:13:34 +01:00
049e64288e fix : calcule des RTT 2026-03-20 08:13:20 +01:00
gitea-actions
9577a70ea3 chore: bump version to v0.1.57
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m9s
2026-03-19 17:18:17 +00:00
e85f7b6f4c fix : calcule des RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-19 18:18:06 +01:00
gitea-actions
834b4cb695 chore: bump version to v0.1.56
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m36s
2026-03-19 16:10:25 +00:00
17f871e82d feat : modification écran RTT + modification écran frais
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-19 17:10:11 +01:00
gitea-actions
3ec1e1f10d chore: bump version to v0.1.55
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-18 14:40:57 +00:00
24b7512c8a Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-18 15:40:44 +01:00
f047e3ed4b feat : ajout d'une colonne montant dans les Frais employé 2026-03-18 15:40:31 +01:00
gitea-actions
1feedd0381 chore: bump version to v0.1.54
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m9s
2026-03-17 14:24:43 +00:00
f9cd5a0143 fix : RTT à la date du jour et affichage des congés restant
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-17 15:24:32 +01:00
gitea-actions
ede7decaa7 chore: bump version to v0.1.53
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m44s
2026-03-17 14:05:55 +00:00
2cfb05e5de feat : ajout de cache sur la récupération des jours fériés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 15:05:43 +01:00
gitea-actions
0a8399a950 chore: bump version to v0.1.52
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-17 12:52:06 +00:00
6a64cb4c58 feat : ajout de sécurité sur les endpoints
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 13:52:00 +01:00
gitea-actions
facded4c55 chore: bump version to v0.1.51
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-03-17 12:28:00 +00:00
9787231052 fix : correction calcule prorata congés avec un arrêt maladie long
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 13:27:51 +01:00
gitea-actions
8563ddb08c chore: bump version to v0.1.50
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-17 10:54:32 +00:00
353d4d9d2b fix : correction calcule prorata congés avec une suspension de contrat
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 11:54:23 +01:00
gitea-actions
8745e5e425 chore: bump version to v0.1.49
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-17 10:25:15 +00:00
4d8c850a77 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 11:25:05 +01:00
1974ace1f2 fix : correction validation des heures qui bloque la modification 2026-03-17 11:24:56 +01:00
gitea-actions
a99a12a759 chore: bump version to v0.1.48
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-17 10:17:34 +00:00
548b5d63a6 fix : correction calcule des RTT
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-17 11:17:24 +01:00
gitea-actions
ed9df4e178 chore: bump version to v0.1.47
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-17 09:34:55 +00:00
625b4af5ba fix : correction calcule des paniers de nuit
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 10:34:45 +01:00
gitea-actions
2ec3044cb3 chore: bump version to v0.1.46
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-17 08:09:03 +00:00
f024a6a8de fix : correction du calcule des RTT
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-17 09:08:54 +01:00
gitea-actions
a60294a8f7 chore: bump version to v0.1.45
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-16 17:23:19 +00:00
dd7f9ef8a0 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 18:23:05 +01:00
cfa7d25521 fix : correction du récap congés et RTT 2026-03-16 18:22:55 +01:00
gitea-actions
5faa0facca chore: bump version to v0.1.44
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m48s
2026-03-16 17:18:08 +00:00
04f90afc58 feat : ajout de la règle de décompte des RTT et correction du récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 18:17:58 +01:00
gitea-actions
e022cfac98 chore: bump version to v0.1.43
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-16 15:26:24 +00:00
e827128392 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-16 16:26:13 +01:00
86cdec50c6 feat : ajout de l'export récap congés et RTT 2026-03-16 16:26:06 +01:00
gitea-actions
443ed1e003 chore: bump version to v0.1.42
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-16 13:38:06 +00:00
cef364fcec fix : fix affichage employé sur les pages d'heures + ajout d'un filtre employé sur la liste + fix impression recap salaire
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 14:37:00 +01:00
gitea-actions
d4884bc489 chore: bump version to v0.1.41
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-16 11:25:51 +00:00
b93c4bf3e9 feat : ajout de l'export récap. salaire
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 12:25:41 +01:00
gitea-actions
f0ee489c26 chore: bump version to v0.1.40
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-16 08:13:46 +00:00
01f8058f56 fix : redirection après login + écran des heures chauffeurs
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 09:13:35 +01:00
gitea-actions
3d26d6b50f chore: bump version to v0.1.39
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m26s
2026-03-15 18:05:02 +00:00
339d650b41 feat : ajout de la gestion des heures chauffeurs
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-15 19:04:52 +01:00
gitea-actions
43957903b0 chore: bump version to v0.1.38
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-13 15:20:39 +00:00
d455bb77a3 feat : ajout des primes
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-13 16:20:30 +01:00
gitea-actions
8b20632ab8 chore: bump version to v0.1.37
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m18s
2026-03-13 15:06:06 +00:00
0cc2b2730a feat : ajout des frais kms + alignment du style de l'application
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 16:05:54 +01:00
gitea-actions
c35edb9a1c chore: bump version to v0.1.36
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-13 11:24:19 +00:00
4b04be1d1b Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 12:24:11 +01:00
b24dd8595d fix : calcule des jours de présence + SiteFilterSelector.vue 2026-03-13 12:23:55 +01:00
gitea-actions
96185e2334 chore: bump version to v0.1.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-13 11:11:40 +00:00
7d53000fc2 fix : validation autorisée pour les contrats 4h sans heures ou absence
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 12:11:31 +01:00
gitea-actions
c317a2a026 chore: bump version to v0.1.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-13 10:59:58 +00:00
8846e83df1 feat : modification de l'affichage des congés
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 11:57:02 +01:00
105 changed files with 6901 additions and 490 deletions

View File

@@ -21,7 +21,10 @@
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
"Bash(which python3:*)",
"Bash(sudo apt-get:*)",
"Bash(npx xlsx-cli:*)"
"Bash(npx xlsx-cli:*)",
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
"Bash(pip3 install:*)",
"Bash(find:*)"
]
}
}

4
.env
View File

@@ -36,6 +36,10 @@ DEFAULT_URI=http://localhost
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> app ###
RTT_START_DATE=2026-02-23
###< app ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

2
.idea/SIRH.iml generated
View File

@@ -154,6 +154,8 @@
<excludeFolder url="file://$MODULE_DIR$/var" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

2
.idea/php.xml generated
View File

@@ -155,6 +155,8 @@
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />

6
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sirh.sql" dialect="GenericSQL" />
</component>
</project>

84
CLAUDE.md Normal file
View File

@@ -0,0 +1,84 @@
# SIRH
## Mandatory Rules
- Any functional change MUST update `doc/` in the same intervention
- At the end of every feature addition or functional modification, update this CLAUDE.md to reflect new patterns, rules, or conventions introduced
## Commands
- `make start` — start Docker stack
- `make test` — run backend tests (PHPUnit)
- `make dev-nuxt` — dev frontend
- `cd frontend && npm run build` — build frontend
- `php bin/console cache:clear && php bin/console cache:warmup` — clear cache after deploy
## Stack
- Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
## Project Structure
- `src/` — Symfony domain, API resources, state providers/processors, services
- `frontend/` — Nuxt app (pages, components, composables, services)
- `migrations/` — Doctrine migrations (always include working `down()`)
- `doc/` — functional rules and business documentation
## Functional Rules
- Reference: `doc/functional-rules.md` (mandatory reading before any business logic change)
- Complementary: `doc/leave-rollover.md`, `doc/rtt-rollover.md`
## Domain Model
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
- Any real modification resets both `isSiteValid=false` and `isValid=false`
- No-op saves preserve existing validations
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
- INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
## Frontend Patterns
### Table styling (standard across all pages)
- Header: `grid border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`
- Body wrapper: `border-x border-b border-primary-500 rounded-b-md`
- Rows: `grid items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500`
- Page wrapper for scroll: `h-full flex flex-col overflow-hidden`, table container: `min-h-0 overflow-auto rounded-md bg-white`
### Drawer buttons (AppDrawer)
- Edit mode: `grid grid-cols-2 gap-3` → Supprimer (red, left) + Modifier (primary, right)
- Create mode: centered `+ Ajouter` button, w-[200px]
- Exception: Users drawer has NO delete button
- All "Ajouter" buttons across the app use "+" prefix
### API Platform (backend)
- Custom operations use Processor (write) / Provider (read)
- File uploads: `deserialize: false` on Post, access file via RequestStack
- Upload dir: `%kernel.project_dir%/var/uploads`
## Backend Conventions
- Prefer explicit DTOs over associative arrays
- Business rules in backend (providers/processors/services), frontend is display/interaction only
- Keep backend PHP DTOs aligned with frontend TS DTOs (`frontend/services/dto/*`)
- Update unit tests when constructor/service signatures change
## Language
- UI is in French
- User communicates in French
- Code (variables, comments) in English

View File

@@ -24,6 +24,7 @@
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",

175
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": "b540b6cb25ef55c5eebccb57c76da584",
"content-hash": "bdc04f5145303388bac52809ea3f4b05",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5374,6 +5374,92 @@
],
"time": "2026-01-28T10:46:31+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b",
"reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"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": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.7"
},
"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-03-06T13:17:40+00:00"
},
{
"name": "symfony/monolog-bridge",
"version": "v8.0.4",
@@ -5685,6 +5771,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"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": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",

View File

@@ -26,6 +26,14 @@ services:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
App\Service\Rtt\RttRecoveryComputationService:
arguments:
$rttStartDate: '%env(RTT_START_DATE)%'
App\State\EmployeeRttSummaryProvider:
arguments:
$rttStartDate: '%env(RTT_START_DATE)%'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.33'
app.version: '0.1.62'

View File

@@ -40,6 +40,10 @@ Documents complementaires:
## 3) Heures (vue jour)
- Visibilité des employés:
- vue jour: un employé sans contrat à la date sélectionnée est masqué
- vue semaine: un employé sans contrat sur aucun jour de la semaine est masqué
- même règle pour les heures classiques et les heures conducteurs
- Saisie par salarié et par date:
- matin / après-midi / soir
- pour `PRESENCE`: demi-journées matin/après-midi
@@ -112,11 +116,48 @@ Documents complementaires:
- contrats >= 39h: de 39h à 43h
- Tranche 50%:
- au-delà de 43h
- Date de début RTT (`RTT_START_DATE` dans `.env`):
- les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération
- permet d'éviter les déficits fictifs avant la mise en service du logiciel
- Semaine en déficit (heures travaillées < heures contrat):
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
- Nature `INTERIM`:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
## 6bis) Heures Conducteurs
- Écran dédié `/driver-hours` pour les employés dont le contrat est marqué `isDriver = true`
- Les conducteurs sont exclus de l'écran `/hours` classique
- Colonnes spécifiques (vue jour):
- Heure de jour (durée HH:MM via TimeSelect)
- Heure de nuit (durée HH:MM via TimeSelect)
- Heure atelier (durée HH:MM via TimeSelect)
- Total (somme jour + nuit + atelier, calculé)
- Petit déjeuner (checkbox)
- Déjeuner (checkbox)
- Dîner (checkbox)
- Nuitée (checkbox)
- Stockage backend:
- `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour`
- `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour`
- les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
- Absences `countAsWorkedHours=true`: les minutes créditées sont ajoutées aux heures de jour (vue jour et vue semaine), même logique que les employés classiques
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
- Vue semaine:
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
## 7) Fériés
- Les jours fériés sont identifiés et affichés
@@ -145,6 +186,11 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Modification employé:
- uniquement prénom, nom, site
- pas de modification de contrat depuis ce drawer
- Liste employés — filtre par statut de contrat:
- 3 options: "Avec contrat" (défaut), "Sans contrat", "Tous"
- "Avec contrat": employés ayant une période de contrat active à la date du jour
- "Sans contrat": employés sans période de contrat active
- "Tous": aucun filtrage sur le contrat
- Détail employé:
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
@@ -170,14 +216,22 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22 (standard mensuel)
- arrêt maladie long (absences continues de type `M` > 1 mois):
- premier mois de maladie (date début + 1 mois calendaire): acquisition normale (`2,50`/mois)
- après le premier mois: acquisition réduite à `2,00`/mois (facteur `0,80` appliqué aux deux taux jours et samedis)
- en cas de mois partiellement couvert par la période réduite, le prorata est calculé en jours calendaires (jours normaux × taux normal + jours réduits × taux réduit)
- la détection est automatique à partir des absences MALADIE consécutives en base (tolérance de gap ≤ 3 jours)
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
- contrat `4h`:
- acquis annuel CP: `10`
- acquis annuel samedi: `0`
- en cours d'acquisition: `0.83` jour/mois
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
- contrat `FORFAIT`:
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
- bonus weekend/férié: chaque jour travaillé un weekend ou jour férié donne 1 jour de congé supplémentaire (journée ≥ 5h = 1.0 jour, demi-journée > 0h et < 5h = 0.5 jour), sans plafond
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
- pas de samedi (`0`)
@@ -225,6 +279,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
- date limite de calcul: uniquement les semaines terminées (jusqu'au dernier dimanche), **ou** la semaine en cours si tous les jours existants sont validés RH (`isValid = true`). En cas de fin de contrat en milieu de semaine, seuls les jours jusqu'à la date de fin sont vérifiés.
- compteur global:
- affiché en **jours** (1 jour = 7h = 420 minutes)
- report:
@@ -242,10 +297,81 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- `rate`: taux de majoration, valeurs `25` ou `50`
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures paiements antérieurs), affichée à partir de juillet (masquée si nul)
- Reste = Report cumulé + Total du mois Payé du mois (balance courante en fin de mois)
- affichage:
- le compteur global RTT est affiché en **heures** (format `Xh00`)
## 10) Notifications
## 10) Export récap. congés & RTT (PDF)
- Accessible depuis la page Employés via le bouton "Export récap. congés" (réservé `ROLE_ADMIN`)
- Clic direct (pas de drawer), génère un PDF A4 portrait à la date du jour
- Endpoint: `GET /api/leave-recap/print`
- Seuls les employés avec contrat actif sont inclus
- Données groupées par site
### Colonnes du tableau
| Colonne | Logique |
|---------|---------|
| Nom | lastName + firstName |
| Contrat | Contract.name |
| CP N-1 restant | CDI/CDD: acquis N-1 pris sur N-1. Forfait: report N-1 restant |
| Samedi restant | CDI/CDD: samedis acquis N-1 pris. Forfait: `-` |
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
- Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage
- Endpoint: `GET /api/salary-recap/print?month=YYYY-MM`
- Données groupées par site, un en-tête par site
### Colonnes du tableau
| Colonne | Source | Logique |
|---------|--------|---------|
| Nom | Employee | firstName + lastName |
| Base | Contract.name | Via EmployeeContractResolver pour le mois |
| Jour de présence Cadre | WorkHour | Uniquement FORFAIT (PRESENCE). Somme isPresentMorning (0.5) + isPresentAfternoon (0.5) |
| Heures de nuit | WorkHour | Non-chauffeurs: calcul intervalles nuit (00:00-06:00, 21:00-24:00). Chauffeurs: somme nightHoursMinutes |
| Panier de nuit | WorkHour | Nombre de jours où (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit 4h entre 21h-6h) |
| Heures payés | EmployeeRttPayment | Somme base25Minutes + base50Minutes du mois, convertie en heures |
| Congés - Nombre | Absence code 'C' | Jours (demi-journées = 0.5) |
| Congés - Date | Absence code 'C' | Dates formatées dd/mm |
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle |
## 12) Frais
- Onglet "Frais" sur la fiche employé (icône `mdi:account-cash-outline`)
- Entité `MileageAllowance` (table `mileage_allowances`)
- Champs:
- `month` (mois, obligatoire)
- `kilometers` (nombre de km, optionnel)
- `amount` (montant en €, optionnel)
- `comment` (commentaire, optionnel)
- `receiptPath` / `receiptName` (justificatif Km, PDF)
- `amountReceiptPath` / `amountReceiptName` (justificatif Montant, PDF)
- Règle de validation:
- le mois est obligatoire
- au moins un des deux champs `kilometers` ou `amount` doit être > 0
- les deux peuvent être remplis simultanément
- Tableau: colonnes Mois, Nombre de Km, Montant €, Commentaire, Justif. Km, Justif. Montant
- Deux justificatifs distincts (upload PDF uniquement):
- Justificatif Km : upload via `/mileage_allowances/{id}/receipt`, téléchargement via GET même URL
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
## 13) Notifications
- Icône cloche en topbar:
- badge = nombre de notifications non lues

View File

@@ -93,28 +93,29 @@
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<div v-if="editingAbsence" class="grid grid-cols-2 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"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@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="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
+ Ajouter
</button>
</div>
</form>

View File

@@ -96,17 +96,10 @@
</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>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Imprimer

View File

@@ -5,19 +5,12 @@
</Transition>
<Transition name="drawer-panel">
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
<div class="flex items-center justify-between border-b border-neutral-200 bg-tertiary-500 px-6 py-4">
<h2 class="text-lg font-semibold text-neutral-900">
<div class="flex items-center justify-between px-[20px] pt-8 pb-8">
<h2 class="text-[32px] font-semibold text-primary-500">
{{ title }}
</h2>
<button
type="button"
class="rounded-md p-2 text-neutral-500 hover:bg-neutral-100"
@click="close"
>
</button>
</div>
<div class="overflow-y-auto p-6" style="max-height: calc(100% - 65px)">
<div class="overflow-y-auto px-[20px]" style="max-height: calc(100% - 65px)">
<slot />
</div>
</div>

View File

@@ -0,0 +1,87 @@
<template>
<AppDrawer v-model="drawerOpen" title="Récapitulatif Salaire">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="salary-recap-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="salary-recap-month"
v-model="selectedMonth"
type="month"
:class="monthFieldClass"
/>
<p v-if="showMonthError" class="mt-1 text-sm text-red-600">
Le mois est obligatoire.
</p>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', month: string): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const selectedMonth = ref(defaultMonth)
const validationTouched = ref(false)
const isMonthValid = computed(() => selectedMonth.value.trim() !== '')
const showMonthError = computed(() => validationTouched.value && !isMonthValid.value)
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const monthFieldClass = computed(() => {
if (showMonthError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (!isMonthValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const handleSubmit = () => {
validationTouched.value = true
if (!isMonthValid.value) return
emit('submit', selectedMonth.value)
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
validationTouched.value = false
}
}
)
</script>

View File

@@ -14,7 +14,7 @@
<div
v-if="isOpen"
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
class="z-50 absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
>
<div class="flex flex-col gap-2">
<label

View File

@@ -0,0 +1,241 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-4">Heure de jour</span>
<span class="pl-2">Heure de nuit</span>
<span class="pl-2">Heure atelier</span>
<span class="pl-2">Total</span>
<span>Petit déj.</span>
<span>Déjeuner</span>
<span>Dîner</span>
<span>Nuitée</span>
<span v-if="isAdmin" class="flex justify-between items-center">
<span>Valider</span>
<input
ref="bulkValidationInput"
:checked="isBulkValidationChecked"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="employee in employees"
:key="employee.id"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: dayGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
title="Validation site"
>
<Icon name="mdi:check"/>
</span>
</p>
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
Modifié le {{ getRowUpdatedAt(employee.id) }}
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<div class="pl-4">
<TimeSelect
v-model="rows[employee.id].dayHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="pl-2">
<TimeSelect
v-model="rows[employee.id].nightHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="pl-2">
<TimeSelect
v-model="rows[employee.id].workshopHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="pl-2 text-sm font-semibold">
{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasBreakfast"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasLunch"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasDinner"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasOvernight"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div v-if="isAdmin" class="text-right">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { DriverHourRow } from '~/services/dto/work-hour'
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null)
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
employees: Employee[]
isAdmin: boolean
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
contractLabel: (employee: Employee) => string
isRowLocked: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean
isSiteValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean
canToggleSiteValidation: (employeeId: number) => boolean
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
isBulkSiteValidationChecked: boolean
isBulkSiteValidationIndeterminate: boolean
canBulkToggleSiteValidation: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowUpdatedAt: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string
}>()
const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
const onBulkSiteValidationChange = (event: Event) => {
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
}
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
watch(
() => props.isBulkValidationIndeterminate,
(isIndeterminate) => {
if (!bulkValidationInput.value) return
bulkValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
watch(
() => props.isBulkSiteValidationIndeterminate,
(isIndeterminate) => {
if (!bulkSiteValidationInput.value) return
bulkSiteValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.weekday }}<br>{{ day.dayDate }}</span>
<span>Jour/Nuit <br>sem.</span>
<span>Atelier <br>sem.</span>
<span>Total <br>sem.</span>
<span>Total <br>h. supp.</span>
<span>+25%</span>
<span>+50%</span>
<span>Total <br>récup.</span>
<span>Petit <br>déj.</span>
<span>Déj.</span>
<span>Dîner</span>
<span>Nuit.</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="row.employeeId"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
</div>
<div
v-for="daily in row.daily"
:key="daily.date"
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
<div v-if="daily.workshopMinutes">A {{ formatMinutes(daily.workshopMinutes) }}</div>
<div v-if="daily.hasBreakfast || daily.hasLunch || daily.hasDinner || daily.hasOvernight" class="text-[10px] flex gap-1 mt-0.5">
<span v-if="daily.hasBreakfast" title="Petit déjeuner">PD</span>
<span v-if="daily.hasLunch" title="Déjeuner">DJ</span>
<span v-if="daily.hasDinner" title="Dîner">DI</span>
<span v-if="daily.hasOvernight" title="Nuitée">NU</span>
</div>
</div>
<div class="font-semibold leading-4">
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyWorkshopMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyTotalMinutes) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
<div class="font-semibold">{{ row.weeklyBreakfastCount ?? 0 }}</div>
<div class="font-semibold">{{ row.weeklyLunchCount ?? 0 }}</div>
<div class="font-semibold">{{ row.weeklyDinnerCount ?? 0 }}</div>
<div class="font-semibold">{{ row.weeklyOvernightCount ?? 0 }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
}
defineProps<{
isWeekLoading: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
formatMinutes: (minutes: number) => string
}>()
</script>

View File

@@ -0,0 +1,207 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-3 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Mois</p>
<p>Montant </p>
<p>Commentaire</p>
</div>
<div v-if="bonuses.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune prime.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in bonuses"
:key="item.id"
class="grid grid-cols-3 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p>{{ item.amount }} </p>
<p>{{ item.comment ?? '-' }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification prime' : 'Nouvelle prime'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="bonus-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="bonus-month"
v-model="form.month"
type="month"
class="capitalize 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-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="bonus-amount">
Montant () <span class="text-red-600">*</span>
</label>
<input
id="bonus-amount"
v-model.number="form.amount"
type="number"
step="0.01"
min="0"
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-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="bonus-comment">
Commentaire
</label>
<textarea
id="bonus-comment"
v-model="form.comment"
rows="3"
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-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { Bonus } from '~/services/dto/bonus'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
bonuses: Bonus[]
}>()
const emit = defineEmits<{
(event: 'create', data: { month: string; amount: number; comment?: string }): void
(event: 'update', id: number, data: { month: string; amount: number; comment?: string }): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Bonus | null>(null)
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
amount: 0,
comment: ''
})
const isFormValid = computed(() => {
return form.month && form.amount > 0
})
const monthLabels: Record<number, string> = {
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'
}
const formatMonth = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const month = date.getMonth() + 1
const year = date.getFullYear()
return `${monthLabels[month]} ${year}`
}
const resetForm = () => {
form.month = currentYearMonth()
form.amount = 0
form.comment = ''
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Bonus) => {
isEditing.value = true
editingItem.value = item
form.month = item.month.substring(0, 7)
form.amount = item.amount
form.comment = item.comment ?? ''
isDrawerOpen.value = true
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
amount: form.amount,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data)
} else {
emit('create', data)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette prime ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<section class="mt-8">
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
<div class="overflow-hidden bg-white">
<div class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Contrat</p>
<p>Heures</p>
<p>Date de début</p>
@@ -10,11 +10,11 @@
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
Aucun historique de contrat.
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in contractHistory"
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
>
<p>{{ contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
@@ -133,21 +133,13 @@
</label>
</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"
:disabled="isContractSubmitting"
@click="onUpdateContractDrawerOpen(false)"
>
Annuler
</button>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isContractSubmitting || !isContractEndDateValid"
>
Enregistrer
Modifier
</button>
</div>
</form>
@@ -248,21 +240,25 @@
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
</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"
:disabled="isCreateContractSubmitting"
@click="onUpdateCreateContractDrawerOpen(false)"
>
Annuler
</button>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="create-contract-is-driver">
<input
id="create-contract-is-driver"
v-model="createContractForm.isDriver"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Chauffeur
</label>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
>
Enregistrer
+ Ajouter
</button>
</div>
</form>
@@ -297,6 +293,7 @@ type CreateContractForm = {
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
isDriver: boolean
}
const props = defineProps<{

View File

@@ -1,35 +1,54 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours</p>
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours</p>
</div>
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
<p><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
</div>
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
<p class="col-start-1 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours
</p>
<p class="col-start-2 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Pris :</strong>
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
</p>
<p class="col-start-3 p-[10px] border-b border-r border-b-white border-r-primary-500 bg-primary-500 text-white"><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
</p>
<p v-else class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Année N-1 acquis :</span>
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.takenSaturdays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-3 p-[10px] border-r border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours
</p>
<p v-else class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.previousYearTakenDays) }} Jours
</p>
<p v-if="isForfaitRule" class="col-start-3 p-[10px] border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
</p>
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Fractionné acquis : </span>
<span>{{ formatCount(summary?.fractionedDays) }} Jours</span>
</div>
<button
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
class="flex items-center"
@click="openFractionedDrawer"
>
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
</div>
<div class="flex flex-col jutify-center gap-2 items-center py-3">
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
<Icon name="mdi:edit-box" size="24"/>
</button>
</div>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
<div v-for="month in months" :key="month.label"
class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
{{ month.label }}
</div>
@@ -54,7 +73,9 @@
</div>
</template>
</div>
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence : {{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}</div>
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence :
{{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}
</div>
</div>
</div>
</div>
@@ -118,7 +139,7 @@ const emit = defineEmits<{
}>()
const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({ days: 0 })
const fractionedForm = reactive({days: 0})
const openFractionedDrawer = () => {
fractionedForm.days = props.summary?.fractionedDays ?? 0
@@ -151,6 +172,11 @@ const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
const currentYearTakenDays = computed(() => {
if (!props.summary) return null
return props.summary.takenDays - (props.summary.previousYearTakenDays ?? 0)
})
const displayedYear = computed(() => {
if (props.summary?.year) return props.summary.year
const today = new Date()
@@ -282,15 +308,15 @@ const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) =
if (day.leave) {
const color = day.leave.colors[0] ?? '#222783'
if (day.leave.am && day.leave.pm) {
return { backgroundColor: color }
return {backgroundColor: color}
}
const colorFaded = `${color}60`
const backgroundImage = day.leave.am
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
return { backgroundImage, backgroundColor: 'transparent' }
return {backgroundImage, backgroundColor: 'transparent'}
}
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
if (day.isHoliday) return {backgroundColor: 'rgb(179, 229, 252)'}
return undefined
}

View File

@@ -0,0 +1,346 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-6 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Mois</p>
<p>Nombre de Km</p>
<p>Montant </p>
<p>Commentaire</p>
<p>Justif. Km</p>
<p>Justif. Montant</p>
</div>
<div v-if="allowances.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucun frais kilométrique.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in allowances"
:key="item.id"
class="grid grid-cols-6 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p>{{ item.kilometers }}</p>
<p>{{ item.amount ? item.amount + ' €' : '-' }}</p>
<p>{{ item.comment ?? '-' }}</p>
<p class="min-w-0">
<a
v-if="item.receiptPath"
:href="getKmReceiptUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.receiptName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
<p class="min-w-0">
<a
v-if="item.amountReceiptPath"
:href="getAmountReceiptUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.amountReceiptName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" title="Frais">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="mileage-month"
v-model="form.month"
type="month"
class="capitalize 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-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-kilometers">
Nombre de Km
</label>
<input
id="mileage-kilometers"
v-model.number="form.kilometers"
type="number"
step="0.1"
min="0"
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-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-amount">
Montant ()
</label>
<input
id="mileage-amount"
v-model.number="form.amount"
type="number"
step="0.01"
min="0"
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-secondary-500/20"
/>
<p class="mt-1 text-sm text-neutral-500">Au moins un des deux champs doit être rempli</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-km-receipt">
Justificatif Km
</label>
<div v-if="isEditing && editingItem?.receiptName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.receiptName }}
</div>
<input
id="mileage-km-receipt"
ref="kmFileInput"
type="file"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onKmFileChange"
/>
<p v-if="kmFileError" class="mt-1 text-sm text-red-600">{{ kmFileError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-amount-receipt">
Justificatif Montant
</label>
<div v-if="isEditing && editingItem?.amountReceiptName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.amountReceiptName }}
</div>
<input
id="mileage-amount-receipt"
ref="amountFileInput"
type="file"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onAmountFileChange"
/>
<p v-if="amountFileError" class="mt-1 text-sm text-red-600">{{ amountFileError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-comment">
Commentaire
</label>
<textarea
id="mileage-comment"
v-model="form.comment"
rows="3"
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-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type {MileageAllowance} from '~/services/dto/mileage-allowance'
import {getKmReceiptUrl, getAmountReceiptUrl} from '~/services/mileage-allowances'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
allowances: MileageAllowance[]
apiBase: string
}>()
const emit = defineEmits<{
(event: 'create', data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
(event: 'update', id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<MileageAllowance | null>(null)
const selectedKmFile = ref<File | undefined>(undefined)
const selectedAmountFile = ref<File | undefined>(undefined)
const kmFileInput = ref<HTMLInputElement | null>(null)
const amountFileInput = ref<HTMLInputElement | null>(null)
const kmFileError = ref('')
const amountFileError = ref('')
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
kilometers: 0,
amount: 0,
comment: ''
})
const isFormValid = computed(() => {
return form.month && (form.kilometers > 0 || form.amount > 0) && !kmFileError.value && !amountFileError.value
})
const monthLabels: Record<number, string> = {
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'
}
const formatMonth = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const month = date.getMonth() + 1
const year = date.getFullYear()
return `${monthLabels[month]} ${year}`
}
const resetForm = () => {
form.month = currentYearMonth()
form.kilometers = 0
form.amount = 0
form.comment = ''
selectedKmFile.value = undefined
selectedAmountFile.value = undefined
kmFileError.value = ''
amountFileError.value = ''
if (kmFileInput.value) {
kmFileInput.value.value = ''
}
if (amountFileInput.value) {
amountFileInput.value.value = ''
}
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: MileageAllowance) => {
isEditing.value = true
editingItem.value = item
// Extract YYYY-MM from YYYY-MM-DD
form.month = item.month.substring(0, 7)
form.kilometers = item.kilometers
form.amount = item.amount
form.comment = item.comment ?? ''
selectedKmFile.value = undefined
selectedAmountFile.value = undefined
if (kmFileInput.value) {
kmFileInput.value.value = ''
}
if (amountFileInput.value) {
amountFileInput.value.value = ''
}
isDrawerOpen.value = true
}
const onKmFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
kmFileError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedKmFile.value = undefined
target.value = ''
return
}
kmFileError.value = ''
selectedKmFile.value = file ?? undefined
}
const onAmountFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
amountFileError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedAmountFile.value = undefined
target.value = ''
return
}
amountFileError.value = ''
selectedAmountFile.value = file ?? undefined
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
kilometers: form.kilometers,
amount: form.amount,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data, selectedKmFile.value, selectedAmountFile.value)
} else {
emit('create', data, selectedKmFile.value, selectedAmountFile.value)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer ce frais kilométrique ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -22,8 +22,8 @@
</button>
</div>
<p class="text-[16px]">
<span class="font-semibold">RTT À LA DATE DU JOUR :</span>
{{ formatMinutes(summary?.availableMinutes ?? 0) }}
<span class="font-bold">RTT À LA SEMAINE {{ lastCompleteWeek }} : </span>
<span class="font-bold">{{ formatMinutes(summary?.availableMinutes ?? 0) }}</span>
</p>
<div class="flex justify-center">
<button
@@ -40,34 +40,53 @@
<table class="w-full table-fixed border-collapse text-[18px]">
<colgroup>
<col />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
</colgroup>
<thead>
<tr>
<th class="px-5 py-[10px] text-left font-bold text-primary-500 border border-primary-500">Semaine</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Heure</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th>
</tr>
</thead>
<tbody>
<!-- Report row (only on June when carry > 0) -->
<tr v-if="showReportRow">
<!-- Report N-1 row (RTT rollover carry, June only) -->
<tr v-if="showCarryRow" class="bg-tertiary-500">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
</tr>
<!-- Report mois précédent (cumulated balance from previous months, July+) -->
<tr v-if="showMonthReportRow" class="bg-tertiary-500">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
</tr>
<!-- Week rows (always 5) -->
@@ -84,19 +103,27 @@
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus25Minutes) }}</span>
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base25Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base50Minutes) }}</span>
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus25Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus50Minutes) }}</span>
<span v-if="week">{{ formatMinutes(week.base25Minutes + week.bonus25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base50Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus50Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
@@ -110,9 +137,11 @@
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500 border-t-2">Total</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.overtime) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
</tr>
@@ -121,9 +150,11 @@
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
</tr>
@@ -131,11 +162,13 @@
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base25 - (currentPayment?.paidBase25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus25 - (currentPayment?.paidBonus25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base50 - (currentPayment?.paidBase50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus50 - (currentPayment?.paidBonus50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(resteTotal) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
</tr>
</tbody>
</table>
@@ -225,6 +258,17 @@ const emit = defineEmits<{
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
}>()
// --- Last complete week number ---
const lastCompleteWeek = computed(() => {
const now = new Date()
const startOfYear = new Date(now.getFullYear(), 0, 1)
const dayOfYear = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000) + 1
const dayOfWeek = now.getDay() || 7 // Monday = 1, Sunday = 7
const currentWeek = Math.ceil((dayOfYear - dayOfWeek + 10) / 7)
return currentWeek - 1
})
// --- Month navigation ---
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
@@ -290,44 +334,113 @@ const paddedWeeks = computed((): (EmployeeRttWeekSummary | null)[] => {
return padded
})
// --- Report row ---
// --- Carry row (RTT rollover from previous year, June only) ---
const reportMonth = computed(() => {
const carryMonth = computed(() => {
if (!props.summary) return 6
const carryMonth = props.summary.carryMonth
// Report appears in the month AFTER carryMonth (wrapping 12 -> 1)
return carryMonth >= 12 ? 1 : carryMonth + 1
const cm = props.summary.carryMonth
return cm >= 12 ? 1 : cm + 1
})
const showReportRow = computed(() => {
return (
currentMonth.value === reportMonth.value &&
(props.summary?.carryFromPreviousYearMinutes ?? 0) > 0
)
const showCarryRow = computed(() => {
if (currentMonth.value !== carryMonth.value) return false
if ((props.summary?.carryFromPreviousYearMinutes ?? 0) === 0) return false
// On the first exercise, hide carry if carry month is before rttStartDate
if (props.summary?.rttStartDate) {
const startDate = new Date(props.summary.rttStartDate)
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
const startMonthDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1)
if (viewDate < startMonthDate) return false
}
return true
})
// --- Totals ---
// --- Month report row (cumulated balance from previous months) ---
// Months of the exercise in order, starting from the carry month
const exerciseMonths = computed((): number[] => {
const start = carryMonth.value
const startIdx = orderedMonths.indexOf(start as (typeof orderedMonths)[number])
if (startIdx === -1) return [...orderedMonths]
return [...orderedMonths.slice(startIdx), ...orderedMonths.slice(0, startIdx)]
})
const monthReport = computed(() => {
if (!props.summary) return { base25: 0, bonus25: 0, total25: 0, base50: 0, bonus50: 0, total50: 0, total: 0 }
const cm = currentMonth.value
const cmIdx = exerciseMonths.value.indexOf(cm)
const previousMonths = exerciseMonths.value.slice(0, cmIdx)
// Start from carry (included in the cumulation)
let base25 = props.summary.carryBase25Minutes
let bonus25 = props.summary.carryBonus25Minutes
let base50 = props.summary.carryBase50Minutes
let bonus50 = props.summary.carryBonus50Minutes
let total = props.summary.carryFromPreviousYearMinutes
// Add weeks from previous months
for (const w of props.summary.weeks) {
if (previousMonths.includes(w.month)) {
base25 += w.base25Minutes
bonus25 += w.bonus25Minutes
base50 += w.base50Minutes
bonus50 += w.bonus50Minutes
total += w.totalMinutes
}
}
// Subtract payments from previous months
for (const p of props.summary.monthPayments) {
if (previousMonths.includes(p.month)) {
base25 -= p.paidBase25Minutes
bonus25 -= p.paidBonus25Minutes
base50 -= p.paidBase50Minutes
bonus50 -= p.paidBonus50Minutes
total -= (p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
}
}
return { base25, bonus25, total25: base25 + bonus25, base50, bonus50, total50: base50 + bonus50, total }
})
const showMonthReportRow = computed(() => {
// Not on the carry month — carry row handles that
if (currentMonth.value === carryMonth.value) return false
// On the first exercise (containing rttStartDate), hide report for months before the start date
if (props.summary?.rttStartDate) {
const startDate = new Date(props.summary.rttStartDate)
const startYear = startDate.getFullYear()
const startMonth = startDate.getMonth() + 1
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
const startMonthDate = new Date(startYear, startMonth - 1, 1)
if (viewDate < startMonthDate) return false
}
const r = monthReport.value
return r.total !== 0
})
// --- Totals (current month weeks only) ---
const totals = computed(() => {
const weeks = weeksForCurrentMonth.value
const base = {
const positive = weeks.filter((w) => w.totalMinutes >= 0)
return {
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
base25: weeks.reduce((s, w) => s + w.base25Minutes, 0),
bonus25: weeks.reduce((s, w) => s + w.bonus25Minutes, 0),
base50: weeks.reduce((s, w) => s + w.base50Minutes, 0),
bonus50: weeks.reduce((s, w) => s + w.bonus50Minutes, 0),
base25: positive.reduce((s, w) => s + w.base25Minutes, 0),
bonus25: positive.reduce((s, w) => s + w.bonus25Minutes, 0),
total25: weeks.reduce((s, w) => s + w.base25Minutes + w.bonus25Minutes, 0),
base50: positive.reduce((s, w) => s + w.base50Minutes, 0),
bonus50: positive.reduce((s, w) => s + w.bonus50Minutes, 0),
total50: weeks.reduce((s, w) => s + w.base50Minutes + w.bonus50Minutes, 0),
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
}
if (showReportRow.value && props.summary) {
base.base25 += props.summary.carryBase25Minutes
base.bonus25 += props.summary.carryBonus25Minutes
base.base50 += props.summary.carryBase50Minutes
base.bonus50 += props.summary.carryBonus50Minutes
base.total += props.summary.carryFromPreviousYearMinutes
}
return base
})
const currentPayment = computed(() => {
@@ -341,8 +454,19 @@ const paidTotal = computed(() => {
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
})
const resteTotal = computed(() => {
return totals.value.total + paidTotal.value
const reste = computed(() => {
const total25 = monthReport.value.total25 + totals.value.total25
- (currentPayment.value?.paidBase25Minutes ?? 0) - (currentPayment.value?.paidBonus25Minutes ?? 0)
const total50 = monthReport.value.total50 + totals.value.total50
- (currentPayment.value?.paidBase50Minutes ?? 0) - (currentPayment.value?.paidBonus50Minutes ?? 0)
const base25 = Math.round(total25 / 1.25)
const bonus25 = total25 - base25
const base50 = Math.round(total50 / 1.5)
const bonus50 = total50 - base50
const total = monthReport.value.total + totals.value.total + paidTotal.value
return { base25, bonus25, total25, base50, bonus50, total50, total }
})
// --- Format ---
@@ -357,6 +481,11 @@ const formatMinutes = (minutes: number): string => {
return `${sign}${hours} h ${rest} m`
}
const formatCentiemes = (minutes: number): string => {
const value = minutes / 60
return value.toFixed(2).replace('.', ',')
}
// --- Payment drawer ---
const isPaymentDrawerOpen = ref(false)

View File

@@ -1,8 +1,8 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
@@ -42,10 +42,11 @@
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="employee in employees"
:key="employee.id"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: dayGridCols }"
>
<div class="text-neutral-900 min-w-0">
@@ -142,19 +143,19 @@
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pl-2 text-sm font-semibold text-neutral-700">
<div class="pl-2 text-sm font-semibold">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).dayMinutes)
}}
</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div class="text-sm font-semibold">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).nightMinutes)
}}
</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div class="text-sm font-semibold">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).totalMinutes)
}}
@@ -186,6 +187,7 @@
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,9 +1,9 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
@@ -14,12 +14,14 @@
<span>+25%</span>
<span>+50%</span>
<span>Total <br>récup.</span>
<span>Panier <br>nuit</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="row.employeeId"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0 hover:bg-tertiary-500"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
@@ -67,6 +69,10 @@
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ (row.weeklyNightBasketCount ?? 0) > 0 ? row.weeklyNightBasketCount : '-' }}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,983 @@
import { computed, onMounted, ref, watch } from 'vue'
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import type { AbsenceType } from '~/services/dto/absence-type'
import type { Absence } from '~/services/dto/absence'
import type { HalfDay } from '~/services/dto/half-day'
import type { DriverHourRow } from '~/services/dto/work-hour'
import { listScopedEmployees } from '~/services/employees'
import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import { listPublicHolidays } from '~/services/public-holidays'
import {
bulkUpdateWorkHourSiteValidation,
bulkUpdateWorkHourValidation,
bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary,
listWorkHoursByDate,
updateWorkHourSiteValidation,
updateWorkHourValidation
} from '~/services/work-hours'
import {
formatDateLongFr,
formatWeekRangeFr,
getIsoWeekNumber,
getOffsetFromTodayYmd,
getWeekStartDate,
getTodayYmd,
parseYmd,
shiftYmd
} from '~/utils/date'
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
export const useDriverHoursPage = () => {
const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
const viewMode = ref<'day' | 'week'>('day')
const selectedDate = ref(getTodayYmd())
const employees = ref<Employee[]>([])
const employeeFilter = ref('')
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
const rows = ref<Record<number, DriverHourRow>>({})
const dayContext = ref<WorkHourDayContext | null>(null)
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
const isAbsenceDrawerOpen = ref(false)
const isAbsenceSubmitting = ref(false)
const editingAbsence = ref<Absence | null>(null)
const absenceForm = ref({
employeeId: '' as number | '',
typeId: '' as number | '',
startDate: '',
startHalf: 'AM' as HalfDay,
endDate: '',
endHalf: 'PM' as HalfDay,
comment: ''
})
const isLoading = ref(false)
const isWeekLoading = ref(false)
const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([])
const siteValidatingRowIds = ref<number[]>([])
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
const weekGridCols = '1.6fr repeat(7, 0.6fr) repeat(7, 0.6fr) repeat(4, 0.4fr)'
const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>()
for (const employee of employees.value) {
if (employee.site) {
siteMap.set(employee.site.id, employee.site)
}
}
return Array.from(siteMap.values()).sort((siteA, siteB) => {
const orderA = siteA.displayOrder ?? 0
const orderB = siteB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
return siteA.name.localeCompare(siteB.name, 'fr')
})
})
const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return employees.value.filter((employee) => {
if (employee.isDriver !== true) return false
const siteId = employee.site?.id
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return true
const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter)
})
})
const displayedEmployees = computed(() => {
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
})
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
if (!weeklySummary.value) return null
return {
...weeklySummary.value,
rows: weeklySummary.value.rows.filter((row) =>
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
)
}
})
const saveButtonClass = computed(() => {
if (isSubmitting.value || employees.value.length === 0) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
const isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId)
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
const canToggleSiteValidation = (employeeId: number) => {
if (!isSiteManager.value) return false
const row = rows.value[employeeId]
if (!row?.workHourId) return false
if (row.isValid) return false
return true
}
const canCreateEmptyValidationRow = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
if (!hasContractAtSelectedDate(employeeId)) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel
}
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const bulkValidatableEmployeeIds = computed(() => {
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
})
const isBulkValidationChecked = computed(() => {
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
})
const isBulkValidationIndeterminate = computed(() => {
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const bulkSiteValidatableEmployeeIds = computed(() => {
if (!isSiteManager.value) return []
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleSiteValidation(employeeId) || canCreateSiteValidationRowFromAbsence(employeeId))
})
const isBulkSiteValidationChecked = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isSiteValid ?? false)
})
const isBulkSiteValidationIndeterminate = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isSiteValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const canBulkToggleSiteValidation = computed(() => bulkSiteValidatableEmployeeIds.value.length > 0)
const dayContextByEmployeeId = computed(() => {
const map = new Map<number, WorkHourDayContext['rows'][number]>()
for (const row of dayContext.value?.rows ?? []) {
map.set(row.employeeId, row)
}
return map
})
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
const targetDate = target === 'yesterday'
? getOffsetFromTodayYmd(-1)
: target === 'tomorrow'
? getOffsetFromTodayYmd(1)
: getTodayYmd()
if (selectedDate.value === targetDate) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const selected = parseYmd(selectedDate.value)
if (!selected) {
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const today = new Date()
const targetDate = new Date(today)
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
const selectedWeekStart = getWeekStartDate(selected)
const targetWeekStart = getWeekStartDate(targetDate)
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
if (isActive) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const today = new Date()
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
const weekNumber = getIsoWeekNumber(today)
return `Sem. S${weekNumber}`
}
const formattedSelectedDate = computed(() => {
const parsed = parseYmd(selectedDate.value)
if (!parsed) return selectedDate.value
if (viewMode.value === 'week') {
return formatWeekRangeFr(parsed)
}
return formatDateLongFr(parsed)
})
const selectedYear = computed(() => {
const parsed = parseYmd(selectedDate.value)
return parsed ? parsed.getFullYear() : null
})
const selectedHolidayLabel = computed(() => {
const year = selectedYear.value
if (!year) return ''
return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
})
const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
const weekDayHeaders = computed(() => {
const days = weeklySummary.value?.days ?? []
return days.map((date) => {
const parsed = parseYmd(date)
if (!parsed) return { date, weekday: '', dayDate: '' }
const weekday = new Intl.DateTimeFormat('fr-FR', { weekday: 'short' }).format(parsed)
const dayDate = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit' }).format(parsed)
return { date, weekday, dayDate }
})
})
const shiftDate = (steps: number) => {
const offset = viewMode.value === 'week' ? (steps * 7) : steps
const next = shiftYmd(selectedDate.value, offset)
if (!next) return
selectedDate.value = next
}
const setToday = () => { selectedDate.value = getTodayYmd() }
const setYesterday = () => { setToday(); shiftDate(-1) }
const setTomorrow = () => { setToday(); shiftDate(1) }
const setThisWeek = () => { selectedDate.value = getTodayYmd() }
const setPreviousWeek = () => {
const previousWeek = shiftYmd(getTodayYmd(), -7)
if (!previousWeek) return
selectedDate.value = previousWeek
}
const setNextWeek = () => {
const nextWeek = shiftYmd(getTodayYmd(), 7)
if (!nextWeek) return
selectedDate.value = nextWeek
}
const resetAbsenceForm = () => {
absenceForm.value = {
employeeId: '',
typeId: '',
startDate: '',
startHalf: 'AM',
endDate: '',
endHalf: 'PM',
comment: ''
}
}
const closeAbsenceDrawer = () => {
isAbsenceDrawerOpen.value = false
editingAbsence.value = null
resetAbsenceForm()
}
const toMinutes = (time: string): number => {
if (!time) return 0
const [hours, minutes] = time.split(':').map(Number)
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return 0
return (hours * 60) + minutes
}
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes)
const hours = Math.floor(safeMinutes / 60)
const rest = safeMinutes % 60
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
}
const minutesToTimeString = (minutes: number | null | undefined): string => {
if (minutes === null || minutes === undefined || minutes === 0) return ''
return formatMinutes(minutes)
}
const emptyRow = (): DriverHourRow => ({
workHourId: null,
dayHours: '',
nightHours: '',
workshopHours: '',
hasBreakfast: false,
hasLunch: false,
hasDinner: false,
hasOvernight: false,
isSiteValid: false,
isValid: false,
updatedAt: null
})
const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
if (row.isValid) return true
if (!isAdmin.value && row.isSiteValid) return true
return false
}
const contractLabel = (employee: Employee) => {
const contract = employee.contract
if (!contract) return '-'
return contract.name
}
const getRowMetrics = (employeeId: number) => {
const row = rows.value[employeeId] ?? emptyRow()
const credited = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
const dayMinutes = toMinutes(row.dayHours) + credited
const nightMinutes = toMinutes(row.nightHours)
const workshopMinutes = toMinutes(row.workshopHours)
const totalMinutes = dayMinutes + nightMinutes + workshopMinutes
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
return `${dayRow.absenceLabel} (${halfLabel})`
}
return `${dayRow.absenceLabel} (journée)`
}
const getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return { backgroundColor: '#6b7280' }
}
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt
if (!raw) return ''
const date = new Date(raw)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true
return dayRow.hasContractAtDate !== false
}
const hydrateRows = (workHours: WorkHour[]) => {
const byEmployeeId = new Map<number, WorkHour>()
for (const workHour of workHours) {
byEmployeeId.set(workHour.employee.id, workHour)
}
const nextRows: Record<number, DriverHourRow> = {}
for (const employee of employees.value) {
if (employee.isDriver !== true) continue
const workHour = byEmployeeId.get(employee.id)
nextRows[employee.id] = {
workHourId: workHour?.id ?? null,
dayHours: minutesToTimeString(workHour?.dayHoursMinutes),
nightHours: minutesToTimeString(workHour?.nightHoursMinutes),
workshopHours: minutesToTimeString(workHour?.workshopHoursMinutes),
hasBreakfast: workHour?.hasBreakfast ?? false,
hasLunch: workHour?.hasLunch ?? false,
hasDinner: workHour?.hasDinner ?? false,
hasOvernight: workHour?.hasOvernight ?? false,
isSiteValid: workHour?.isSiteValid ?? false,
isValid: workHour?.isValid ?? false,
updatedAt: workHour?.updatedAt ?? null
}
}
rows.value = nextRows
}
const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadPublicHolidaysForSelectedYear = async () => {
const year = selectedYear.value
if (!year) return
if (publicHolidaysByYear.value[year]) return
const holidays = await listPublicHolidays('metropole', year)
publicHolidaysByYear.value = {
...publicHolidaysByYear.value,
[year]: holidays
}
}
const loadAbsences = async () => {
absences.value = await listAbsences({
from: selectedDate.value,
to: selectedDate.value,
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
})
}
const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false
const start = absence.startDate.slice(0, 10)
const end = absence.endDate.slice(0, 10)
return selectedDate.value >= start && selectedDate.value <= end
}) ?? null
if (existing) {
editingAbsence.value = existing
absenceForm.value = {
employeeId,
typeId: existing.type?.id ?? '',
startDate: existing.startDate.slice(0, 10),
startHalf: existing.startHalf ?? 'AM',
endDate: existing.endDate.slice(0, 10),
endHalf: existing.endHalf ?? 'PM',
comment: existing.comment ?? ''
}
} else {
editingAbsence.value = null
absenceForm.value = {
employeeId,
typeId: '',
startDate: selectedDate.value,
startHalf: 'AM',
endDate: selectedDate.value,
endHalf: 'PM',
comment: ''
}
}
isAbsenceDrawerOpen.value = true
}
const refreshAfterAbsenceChange = async () => {
if (isAdmin.value) {
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
}
weeklySummary.value = null
await Promise.all([loadDayContext(), loadAbsences()])
}
const submitAbsence = async () => {
const form = absenceForm.value
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
isAbsenceSubmitting.value = true
try {
if (editingAbsence.value) {
await updateAbsence({
id: editingAbsence.value.id,
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: editingAbsence.value.comment ?? ''
})
} else {
await createAbsence({
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: ''
})
}
closeAbsenceDrawer()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}
}
const deleteAbsenceFromDrawer = async () => {
if (!editingAbsence.value || isAbsenceSubmitting.value) return
isAbsenceSubmitting.value = true
try {
await deleteAbsence(editingAbsence.value.id)
closeAbsenceDrawer()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}
}
const buildEmptyDriverEntry = (employeeId: number) => ({
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false,
dayHoursMinutes: null,
nightHoursMinutes: null,
workshopHoursMinutes: null,
hasBreakfast: false,
hasLunch: false,
hasDinner: false,
hasOvernight: false
})
const toggleValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
if (!row?.workHourId && checked) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [buildEmptyDriverEntry(employeeId)]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isValidationPending(employeeId)) return
validatingRowIds.value = [...validatingRowIds.value, employeeId]
try {
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isValid = checked
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleSiteValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
if (!row?.workHourId && checked) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [buildEmptyDriverEntry(employeeId)]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isSiteValidationPending(employeeId)) return
if (!canToggleSiteValidation(employeeId)) return
siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId]
try {
await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isSiteValid = checked
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = bulkValidatableEmployeeIds.value
if (employeeIds.length === 0) return
const pendingIds = new Set(validatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
}, { toast: false })
await loadWorkHours()
}
}
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée.'
})
return
}
validatingRowIds.value = Array.from(new Set([...validatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourValidation({
workDate: selectedDate.value,
isValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({ title: 'Erreur', message: 'Aucune ligne mise à jour.' })
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} ligne(s) validée(s).`
: `${result.updated} validation(s) retirée(s).`
})
} catch {
toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations.' })
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const toggleSiteValidationBulk = async (checked: boolean) => {
if (!isSiteManager.value) return
const employeeIds = bulkSiteValidatableEmployeeIds.value
if (employeeIds.length === 0) return
const pendingIds = new Set(siteValidatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateSiteValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
}, { toast: false })
await loadWorkHours()
}
}
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleSiteValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée côté site.'
})
return
}
siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourSiteValidation({
workDate: selectedDate.value,
isSiteValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({ title: 'Erreur', message: 'Aucune ligne site mise à jour.' })
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} validation(s) site enregistrée(s).`
: `${result.updated} validation(s) site retirée(s).`
})
} catch {
toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations site.' })
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const loadEmployees = async () => {
const scopedEmployees = await listScopedEmployees()
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
}
const loadWorkHours = async () => {
const workHours = await listWorkHoursByDate(selectedDate.value)
hydrateRows(workHours)
}
const loadWeeklySummary = async () => {
isWeekLoading.value = true
try {
weeklySummary.value = await getWeeklyWorkHourSummary(selectedDate.value)
} finally {
isWeekLoading.value = false
}
}
const loadDayContext = async () => {
dayContext.value = await getWorkHourDayContext(selectedDate.value)
}
const refreshByDate = async () => {
if (isAdmin.value) {
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
}
weeklySummary.value = null
await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
}
const loadPage = async () => {
isLoading.value = true
try {
await loadPublicHolidaysForSelectedYear()
await loadEmployees()
await loadAbsenceTypes()
await refreshByDate()
} finally {
isLoading.value = false
}
}
onMounted(loadPage)
watch(sites, (nextSites) => {
const currentSiteIds = nextSites.map((site) => site.id)
if (!sitesInitialized.value) {
if (currentSiteIds.length === 0) return
selectedSiteIds.value = currentSiteIds
sitesInitialized.value = true
return
}
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
}, { immediate: true })
watch(isAdmin, async (admin) => {
if (!admin) {
viewMode.value = 'day'
weeklySummary.value = null
await Promise.all([loadAbsenceTypes(), loadAbsences()])
return
}
await loadAbsenceTypes()
await loadAbsences()
}, { immediate: true })
watch(selectedDate, async () => {
await loadPublicHolidaysForSelectedYear()
await refreshByDate()
})
const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return
isSubmitting.value = true
try {
const driverEmployees = employees.value.filter(
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
)
const entries = driverEmployees.map((employee) => {
const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow()
const dayMin = toMinutes(row.dayHours)
const nightMin = toMinutes(row.nightHours)
const workshopMin = toMinutes(row.workshopHours)
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false,
dayHoursMinutes: dayMin || null,
nightHoursMinutes: nightMin || null,
workshopHoursMinutes: workshopMin || null,
hasBreakfast: row.hasBreakfast,
hasLunch: row.hasLunch,
hasDinner: row.hasDinner,
hasOvernight: row.hasOvernight
}
})
if (entries.length === 0) return
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries
})
await refreshByDate()
} finally {
isSubmitting.value = false
}
}
return {
isAdmin,
isSelfUser,
isSiteManager,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
weeklySummary,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isRowLocked,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
}
}

View File

@@ -0,0 +1,62 @@
import type { Ref } from 'vue'
import type { Bonus } from '~/services/dto/bonus'
import type { Employee } from '~/services/dto/employee'
import {
listBonuses,
createBonus,
updateBonus,
deleteBonus
} from '~/services/bonuses'
export const useEmployeeBonus = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const bonuses = ref<Bonus[]>([])
const isBonusLoading = ref(false)
const bonusDataLoaded = ref(false)
const loadBonusData = async () => {
if (!employee.value || isBonusLoading.value) return
isBonusLoading.value = true
try {
bonuses.value = await listBonuses(employee.value.id)
bonusDataLoaded.value = true
} finally {
isBonusLoading.value = false
}
}
const resetLoaded = () => {
bonusDataLoaded.value = false
}
const submitCreateBonus = async (data: { month: string; amount: number; comment?: string }) => {
if (!employee.value) return
await createBonus({
employeeId: employee.value.id,
month: data.month,
amount: data.amount,
comment: data.comment
})
await reloadEmployee()
}
const submitUpdateBonus = async (id: number, data: { month: string; amount: number; comment?: string }) => {
await updateBonus(id, data)
await reloadEmployee()
}
const submitDeleteBonus = async (id: number) => {
await deleteBonus(id)
await reloadEmployee()
}
return {
bonuses,
isBonusLoading,
bonusDataLoaded,
loadBonusData,
resetLoaded,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus
}
}

View File

@@ -43,7 +43,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
endDate: '',
isDriver: false
})
const createValidationTouched = reactive({
@@ -171,6 +172,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.isDriver = false
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
@@ -244,7 +246,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()

View File

@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus'>('contract')
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
@@ -38,11 +38,17 @@ export const useEmployeeDetailPage = () => {
leave.resetLoaded()
rtt.resetLoaded()
mileage.resetLoaded()
bonus.resetLoaded()
if (activeTab.value === 'leave' && showLeaveTab.value) {
await leave.loadLeaveData()
} else if (activeTab.value === 'rtt' && showRttTab.value) {
await rtt.loadRttData()
} else if (activeTab.value === 'mileage') {
await mileage.loadMileageData()
} else if (activeTab.value === 'bonus') {
await bonus.loadBonusData()
}
} finally {
isLoading.value = false
@@ -52,12 +58,18 @@ export const useEmployeeDetailPage = () => {
const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee)
watch(activeTab, (tab) => {
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
leave.loadLeaveData()
} else if (tab === 'rtt' && !rtt.rttDataLoaded.value && showRttTab.value) {
rtt.loadRttData()
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
mileage.loadMileageData()
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
bonus.loadBonusData()
}
})
@@ -75,6 +87,8 @@ export const useEmployeeDetailPage = () => {
employeeContractWorkLabel,
...contract,
...leave,
...rtt
...rtt,
...mileage,
...bonus
}
}

View File

@@ -0,0 +1,83 @@
import type { Ref } from 'vue'
import type { MileageAllowance } from '~/services/dto/mileage-allowance'
import type { Employee } from '~/services/dto/employee'
import {
listMileageAllowances,
createMileageAllowance,
updateMileageAllowance,
deleteMileageAllowance,
uploadKmReceipt,
uploadAmountReceipt
} from '~/services/mileage-allowances'
export const useEmployeeMileage = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const config = useRuntimeConfig()
const apiBase = (config.public.apiBase as string) ?? '/api'
const mileageAllowances = ref<MileageAllowance[]>([])
const isMileageLoading = ref(false)
const mileageDataLoaded = ref(false)
const loadMileageData = async () => {
if (!employee.value || isMileageLoading.value) return
isMileageLoading.value = true
try {
mileageAllowances.value = await listMileageAllowances(employee.value.id)
mileageDataLoaded.value = true
} finally {
isMileageLoading.value = false
}
}
const resetLoaded = () => {
mileageDataLoaded.value = false
}
const submitCreateMileage = async (data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
if (!employee.value) return
const result = await createMileageAllowance({
employeeId: employee.value.id,
month: data.month,
kilometers: data.kilometers,
amount: data.amount,
comment: data.comment
})
if (result?.id) {
if (kmFile) {
await uploadKmReceipt(apiBase, result.id, kmFile)
}
if (amountFile) {
await uploadAmountReceipt(apiBase, result.id, amountFile)
}
}
await reloadEmployee()
}
const submitUpdateMileage = async (id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
await updateMileageAllowance(id, data)
if (kmFile) {
await uploadKmReceipt(apiBase, id, kmFile)
}
if (amountFile) {
await uploadAmountReceipt(apiBase, id, amountFile)
}
await reloadEmployee()
}
const submitDeleteMileage = async (id: number) => {
await deleteMileageAllowance(id)
await reloadEmployee()
}
return {
mileageAllowances,
isMileageLoading,
mileageDataLoaded,
mileageApiBase: apiBase,
loadMileageData,
resetLoaded,
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage
}
}

View File

@@ -77,7 +77,7 @@ export const useHoursPage = () => {
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr) 0.3fr'
const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>()
@@ -99,6 +99,7 @@ export const useHoursPage = () => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return employees.value.filter((employee) => {
if (employee.isDriver === true) return false
const siteId = employee.site?.id
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return true
@@ -108,13 +109,19 @@ export const useHoursPage = () => {
})
})
const displayedEmployees = computed(() => {
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
})
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
if (!weeklySummary.value) return null
return {
...weeklySummary.value,
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId))
rows: weeklySummary.value.rows.filter((row) =>
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
)
}
})
@@ -138,19 +145,17 @@ export const useHoursPage = () => {
return true
}
const canCreateValidationRowFromAbsence = (employeeId: number) => {
const canCreateEmptyValidationRow = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
if (!hasContractAtSelectedDate(employeeId)) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
return !!dayRow?.absenceLabel || is4hContract(employeeId)
}
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
}
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const bulkValidatableEmployeeIds = computed(() => {
return visibleEmployees.value
@@ -347,6 +352,10 @@ export const useHoursPage = () => {
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const is4hContract = (employeeId: number) => {
const employee = employees.value.find((e) => e.id === employeeId)
return employee?.contract?.weeklyHours === 4
}
const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
@@ -460,6 +469,9 @@ export const useHoursPage = () => {
const getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return { backgroundColor: '#6b7280' }
}
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
@@ -692,13 +704,8 @@ export const useHoursPage = () => {
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
@@ -746,13 +753,8 @@ export const useHoursPage = () => {
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
@@ -1043,7 +1045,7 @@ export const useHoursPage = () => {
isSubmitting.value = true
try {
const entries = employees.value
.filter((employee) => hasContractAtSelectedDate(employee.id))
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
.map((employee) => {
const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow()
@@ -1100,6 +1102,7 @@ export const useHoursPage = () => {
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,

View File

@@ -36,6 +36,16 @@
"create": "Impossible de créer l'utilisateur.",
"update": "Impossible de mettre à jour l'utilisateur.",
"delete": "Impossible de supprimer l'utilisateur."
},
"mileage": {
"create": "Impossible de créer le frais kilométrique.",
"update": "Impossible de mettre à jour le frais kilométrique.",
"delete": "Impossible de supprimer le frais kilométrique."
},
"bonus": {
"create": "Impossible de créer la prime.",
"update": "Impossible de mettre à jour la prime.",
"delete": "Impossible de supprimer la prime."
}
},
"success": {
@@ -67,6 +77,16 @@
"create": "Utilisateur créé.",
"update": "Utilisateur mis à jour.",
"delete": "Utilisateur supprimé."
},
"mileage": {
"create": "Frais kilométrique créé.",
"update": "Frais kilométrique mis à jour.",
"delete": "Frais kilométrique supprimé."
},
"bonus": {
"create": "Prime créée.",
"update": "Prime mise à jour.",
"delete": "Prime supprimée."
}
}
}

View File

@@ -21,13 +21,25 @@
<NuxtLink
to="/hours"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/hours')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
:class="[
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
]"
>
<Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/driver-hours"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/driver-hours')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
<Icon name="mdi:truck-outline" size="24"/>
<p>Heures Conducteurs</p>
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink
to="/employees"

View File

@@ -1,13 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Types d'absence</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un type
+ Ajouter un type
</button>
</div>
@@ -18,33 +18,33 @@
Aucun type pour le moment.
</div>
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_160px_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<span class="text-left">Code</span>
<span class="text-left">Libellé</span>
<span class="text-left">Couleur</span>
<span class="text-left">Compte en heures</span>
<span class="text-right">Actions</span>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-4 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span>Code</span>
<span>Libellé</span>
<span>Couleur</span>
<span>Compte en heures</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="type in absenceTypes"
:key="type.id"
class="grid grid-cols-[120px_160px_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-4 items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="openEdit(type)"
>
<span class="font-semibold text-left">{{ type.code }}</span>
<span class="text-left">{{ type.label }}</span>
<div class="flex items-center gap-2 justify-start">
<span>{{ type.code }}</span>
<span>{{ type.label }}</span>
<div class="flex items-center gap-2">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{ backgroundColor: type.color }"
/>
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
</div>
<div class="text-left">
<div>
<span
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
@@ -52,22 +52,6 @@
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(type)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(type)"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
@@ -145,20 +129,29 @@
La couleur est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<div v-if="editingType" class="grid grid-cols-2 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="closeDrawer"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="confirmDelete(editingType)"
>
Annuler
Supprimer
</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="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
+ Ajouter
</button>
</div>
</form>

View File

@@ -14,7 +14,7 @@
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
+ Ajouter une absence
</button>
<button
type="button"

View File

@@ -0,0 +1,183 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures Conducteurs</h1>
</div>
<HoursToolbar
v-model:selected-date="selectedDate"
v-model:view-mode="viewMode"
v-model:selected-site-ids="selectedSiteIds"
v-model:employee-filter="employeeFilter"
:is-admin="isAdmin"
:sites="sites"
:absence-types="absenceTypes"
:formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass"
:week-shortcut-button-class="weekShortcutButtonClass"
:get-week-shortcut-label="getWeekShortcutLabel"
@set-yesterday="setYesterday"
@set-today="setToday"
@set-tomorrow="setTomorrow"
@set-previous-week="setPreviousWeek"
@set-this-week="setThisWeek"
@set-next-week="setNextWeek"
@shift-date="shiftDate"
/>
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="visibleEmployees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Aucun conducteur accessible.
</div>
<div v-else class="flex min-h-0 flex-col gap-4">
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
<DriverHoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="displayedEmployees"
:is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:contract-label="contractLabel"
:is-row-locked="isRowLocked"
:has-contract-at-selected-date="hasContractAtSelectedDate"
:is-validation-pending="isValidationPending"
:is-site-validation-pending="isSiteValidationPending"
:can-toggle-validation="canToggleValidation"
:can-toggle-site-validation="canToggleSiteValidation"
:can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
:on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-row-updated-at="getRowUpdatedAt"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
<DriverHoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
</div>
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
<button
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="saveButtonClass"
:disabled="isSubmitting || visibleEmployees.length === 0"
@click="handleSave"
>
Enregistrer
</button>
</div>
</div>
<AbsenceFormDrawer
v-model="isAbsenceDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="absenceForm"
:editing-absence="editingAbsence"
:is-submitting="isAbsenceSubmitting"
:lock-employee="true"
:lock-dates="true"
:show-comment="false"
@submit="submitAbsence"
@delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer"
/>
</div>
</template>
<script setup lang="ts">
const {
isAdmin,
isSiteManager,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isRowLocked,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
isSelectedDateHoliday,
handleSave
} = useDriverHoursPage()
useHead({
title: 'Heures Conducteurs'
})
</script>

View File

@@ -55,6 +55,26 @@
<Icon name="mdi:schedule" size="24" class="align-self"/>
RTT
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'mileage'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'mileage'"
>
<Icon name="mdi:account-cash-outline" size="24" class="align-self"/>
Frais
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'bonus'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'bonus'"
>
<Icon name="mdi:money-100" size="24" class="align-self"/>
Prime
</button>
</div>
</div>
<div class="min-h-0 flex-1">
@@ -117,6 +137,33 @@
</div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
</div>
<div v-else-if="activeTab === 'mileage'" class="h-full">
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesMileageTab
v-else
class="h-full"
:allowances="mileageAllowances"
:api-base="mileageApiBase"
@create="submitCreateMileage"
@update="submitUpdateMileage"
@delete="submitDeleteMileage"
/>
</div>
<div v-else-if="activeTab === 'bonus'" class="h-full">
<div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesBonusTab
v-else
class="h-full"
:bonuses="bonuses"
@create="submitCreateBonus"
@update="submitUpdateBonus"
@delete="submitDeleteBonus"
/>
</div>
</div>
</div>
</div>
@@ -173,7 +220,18 @@ const {
addSuspensionForm,
currentActiveContractPeriodId,
isLeaveLoading,
isRttLoading
isRttLoading,
mileageAllowances,
isMileageLoading,
mileageApiBase,
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage,
bonuses,
isBonusLoading,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus
} = useEmployeeDetailPage()
useHead(() => ({

View File

@@ -3,19 +3,43 @@
<div class="shrink-0">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un employé
</button>
<div class="flex items-center gap-3">
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="handleLeaveRecapPrint"
>
Export récap. congés
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isSalaryRecapOpen = true"
>
Export récap. salaire
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un employé
</button>
</div>
</div>
<div class="flex gap-10 py-7">
<div class="flex gap-3 py-7">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
<select
v-model="contractStatusFilter"
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
>
<option value="active">Avec contrat</option>
<option value="inactive">Sans contrat</option>
<option value="all">Tous</option>
</select>
</div>
</div>
@@ -40,7 +64,7 @@
<div class="text-center text-[20px]">
<p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p>
<p>Nom du poste occupé</p>
<p>Site ({{ employee.site?.name ?? '-' }})</p>
<p>{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
@@ -170,6 +194,17 @@
La date de fin est obligatoire pour un CDD.
</p>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="is-driver">
<input
id="is-driver"
v-model="form.isDriver"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Chauffeur
</label>
</div>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -189,6 +224,11 @@
</div>
</form>
</AppDrawer>
<SalaryRecapDrawer
v-model="isSalaryRecapOpen"
@submit="handleSalaryRecapPrint"
/>
</div>
</template>
@@ -200,7 +240,9 @@ import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter'
useHead({
title: 'Employés'
@@ -209,6 +251,8 @@ useHead({
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isSalaryRecapOpen = ref(false)
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
const drawerTitle = computed(() =>
@@ -219,20 +263,21 @@ const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const selectedSiteIds = ref<number[]>([])
const filteredEmployees = computed<Employee[]>(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
const bySite = employees.value.filter((employee) => {
return employees.value.filter((employee) => {
const siteId = employee.site?.id
return !!siteId && selectedSiteIds.value.includes(siteId)
})
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return bySite
if (contractStatusFilter.value === 'active' && !employee.hasActiveContract) return false
if (contractStatusFilter.value === 'inactive' && employee.hasActiveContract) return false
return bySite.filter((employee) => {
if (!filter) return true
const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter)
@@ -246,7 +291,8 @@ const form = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
contractEndDate: '',
isDriver: false
})
const validationTouched = reactive({
@@ -431,7 +477,8 @@ const handleSubmit = async () => {
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null
contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver
})
}
@@ -442,6 +489,7 @@ const handleSubmit = async () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -485,9 +533,19 @@ const openCreate = () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
isDrawerOpen.value = true
}
const handleLeaveRecapPrint = async () => {
await printPdf('/leave-recap/print')
}
const handleSalaryRecapPrint = async (month: string) => {
await printPdf(`/salary-recap/print?month=${month}`)
isSalaryRecapOpen.value = false
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return

View File

@@ -38,7 +38,7 @@
<HoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="visibleEmployees"
:employees="displayedEmployees"
:is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
@@ -126,6 +126,7 @@ const {
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,

View File

@@ -68,7 +68,8 @@ const handleSubmit = async () => {
try {
await auth.login(username.value, password.value)
await router.push('/calendar')
const isAdmin = auth.user?.roles?.includes('ROLE_ADMIN')
await router.push(isAdmin ? '/calendar' : '/hours')
} finally {
isSubmitting.value = false
}

View File

@@ -1,13 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un site
+ Ajouter un site
</button>
</div>
@@ -18,26 +18,26 @@
Aucun site pour le moment.
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[1fr_140px_160px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-[1fr_140px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Nom</span>
<span class="text-left">Couleur</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="site in sites"
:key="site.id"
class="grid grid-cols-[1fr_140px_160px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-[1fr_140px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
draggable="true"
@click="openEdit(site)"
@dragstart="handleDragStart($event, site)"
@dragover="handleDragOver"
@drop="handleDrop($event, site)"
>
<span class="flex items-center gap-2 text-left cursor-pointer">
<span class="flex items-center gap-2 text-left">
<span class="select-none text-xs">::</span>
<span>{{ site.name }}</span>
</span>
@@ -48,22 +48,6 @@
/>
<span class="text-md uppercase text-neutral-500">{{ site.color }}</span>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(site)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(site)"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
@@ -98,20 +82,29 @@
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<div v-if="editingSite" class="grid grid-cols-2 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="closeDrawer"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="confirmDelete(editingSite)"
>
Annuler
Supprimer
</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="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
+ Ajouter
</button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un utilisateur
+ Ajouter un utilisateur
</button>
</div>
@@ -18,42 +18,29 @@
Aucun utilisateur pour le moment.
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[1fr_1fr_140px_1fr_140px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-4 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Utilisateur</span>
<span class="text-left">Employé</span>
<span class="text-left">Accès</span>
<span class="text-left">Sites</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="user in users"
:key="user.id"
class="grid grid-cols-[1fr_1fr_140px_1fr_140px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-4 items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="openEdit(user)"
>
<span class="text-left">{{ user.username }}</span>
<span class="text-left">
<span>{{ user.username }}</span>
<span>
{{ user.employee ? `${user.employee.firstName} ${user.employee.lastName}` : '-' }}
</span>
<span class="text-left text-sm text-neutral-600">
{{ getAccessLabel(user) }}
</span>
<span class="text-left text-sm text-neutral-600">
{{ getSiteLabels(user) }}
</span>
<div class="flex justify-end">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(user)"
>
Modifier
</button>
</div>
<span>{{ getAccessLabel(user) }}</span>
<span>{{ getSiteLabels(user) }}</span>
</div>
</div>
</div>
@@ -177,20 +164,13 @@
</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="closeDrawer"
>
Annuler
</button>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
{{ editingUser ? 'Modifier' : '+ Ajouter' }}
</button>
</div>
</form>

View File

@@ -0,0 +1,54 @@
import type { Bonus } from './dto/bonus'
import { extractItems } from '~/utils/api'
export const listBonuses = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Bonus[] | { 'hydra:member'?: Bonus[] }>(
'/bonuses',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Bonus>(data)
}
export const createBonus = async (data: {
employeeId: number
month: string
amount: number
comment?: string
}) => {
const api = useApi()
return api.post<Bonus>('/bonuses', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.bonus.create',
toastErrorKey: 'errors.bonus.create'
})
}
export const updateBonus = async (id: number, data: {
month: string
amount: number
comment?: string
}) => {
const api = useApi()
return api.patch<Bonus>(`/bonuses/${id}`, {
month: data.month,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.bonus.update',
toastErrorKey: 'errors.bonus.update'
})
}
export const deleteBonus = async (id: number) => {
const api = useApi()
return api.delete(`/bonuses/${id}`, {}, {
toastSuccessKey: 'success.bonus.delete',
toastErrorKey: 'errors.bonus.delete'
})
}

View File

@@ -0,0 +1,7 @@
export type Bonus = {
id: number
month: string
amount: number
comment: string | null
createdAt: string
}

View File

@@ -10,6 +10,9 @@ export type EmployeeLeaveSummary = {
takenSaturdays: number
fractionedDays: number
accruingDays: number
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
presenceDaysByMonth: Record<string, number>
}

View File

@@ -32,4 +32,5 @@ export type EmployeeRttSummary = {
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
rttStartDate: string | null
}

View File

@@ -18,6 +18,7 @@ export type ContractHistoryItem = {
comment?: string | null
periodId?: number | null
suspensions?: ContractSuspension[]
isDriver?: boolean
}
export type Employee = {
@@ -26,6 +27,8 @@ export type Employee = {
lastName: string
site: Site
contract?: Contract | null
hasActiveContract?: boolean
isDriver?: boolean
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null

View File

@@ -0,0 +1,12 @@
export type MileageAllowance = {
id: number
month: string
kilometers: number
amount: number
comment: string | null
receiptPath: string | null
receiptName: string | null
amountReceiptPath: string | null
amountReceiptName: string | null
createdAt: string
}

View File

@@ -13,6 +13,13 @@ export type WorkHour = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
dayHoursMinutes?: number | null
nightHoursMinutes?: number | null
workshopHoursMinutes?: number | null
hasBreakfast?: boolean
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
isSiteValid?: boolean
isValid?: boolean
updatedAt?: string | null
@@ -28,17 +35,30 @@ export type WorkHourEntryPayload = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
dayHoursMinutes?: number | null
nightHoursMinutes?: number | null
workshopHoursMinutes?: number | null
hasBreakfast?: boolean
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
}
export type WeeklyWorkHourDailySummary = {
date: string
dayMinutes: number
nightMinutes: number
workshopMinutes?: number
totalMinutes: number
present?: number | null
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
hasNightBasket?: boolean
hasBreakfast?: boolean
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
}
export type WeeklyWorkHourRowSummary = {
@@ -52,12 +72,20 @@ export type WeeklyWorkHourRowSummary = {
daily: WeeklyWorkHourDailySummary[]
weeklyDayMinutes: number
weeklyNightMinutes: number
weeklyWorkshopMinutes?: number
weeklyTotalMinutes: number
weeklyPresenceCount?: number
weeklyOvertimeTotalMinutes?: number
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
weeklyNightBasketCount?: number
isDriver?: boolean
weeklyBreakfastCount?: number
weeklyLunchCount?: number
weeklyDinnerCount?: number
weeklyOvernightCount?: number
hasContractForWeek?: boolean
}
export type WeeklyWorkHourSummary = {
@@ -77,9 +105,24 @@ export type WorkHourDayContextRow = {
absentAfternoon: boolean
creditedMinutes: number
creditedPresenceUnits: number
isDriverContract?: boolean
}
export type WorkHourDayContext = {
workDate: string
rows: WorkHourDayContextRow[]
}
export type DriverHourRow = {
workHourId: number | null
dayHours: string
nightHours: string
workshopHours: string
hasBreakfast: boolean
hasLunch: boolean
hasDinner: boolean
hasOvernight: boolean
isSiteValid: boolean
isValid: boolean
updatedAt: string | null
}

View File

@@ -34,6 +34,7 @@ export const createEmployee = async (payload: {
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
isDriverInput?: boolean
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
@@ -43,7 +44,8 @@ export const createEmployee = async (payload: {
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null
contractEndDate: payload.contractEndDate ?? null,
isDriverInput: payload.isDriverInput ?? false
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -63,6 +65,7 @@ export const updateEmployee = async (
contractPaidLeaveSettled?: boolean
contractComment?: string | null
displayOrder?: number
isDriverInput?: boolean
}
) => {
const api = useApi()
@@ -91,6 +94,9 @@ export const updateEmployee = async (
if (payload.contractComment !== undefined) {
body.contractComment = payload.contractComment ?? null
}
if (payload.isDriverInput !== undefined) {
body.isDriverInput = payload.isDriverInput
}
return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,87 @@
import { $fetch } from 'ofetch'
import type { MileageAllowance } from './dto/mileage-allowance'
import { extractItems } from '~/utils/api'
export const listMileageAllowances = async (employeeId: number) => {
const api = useApi()
const data = await api.get<MileageAllowance[] | { 'hydra:member'?: MileageAllowance[] }>(
'/mileage_allowances',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<MileageAllowance>(data)
}
export const createMileageAllowance = async (data: {
employeeId: number
month: string
kilometers: number
amount: number
comment?: string
}) => {
const api = useApi()
return api.post<MileageAllowance>('/mileage_allowances', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
kilometers: data.kilometers,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.mileage.create',
toastErrorKey: 'errors.mileage.create'
})
}
export const updateMileageAllowance = async (id: number, data: {
month: string
kilometers: number
amount: number
comment?: string
}) => {
const api = useApi()
return api.patch<MileageAllowance>(`/mileage_allowances/${id}`, {
month: data.month,
kilometers: data.kilometers,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.mileage.update',
toastErrorKey: 'errors.mileage.update'
})
}
export const deleteMileageAllowance = async (id: number) => {
const api = useApi()
return api.delete(`/mileage_allowances/${id}`, {}, {
toastSuccessKey: 'success.mileage.delete',
toastErrorKey: 'errors.mileage.delete'
})
}
export const uploadKmReceipt = async (baseURL: string, id: number, file: File) => {
const formData = new FormData()
formData.append('file', file)
return $fetch(`${baseURL}/mileage_allowances/${id}/receipt`, {
method: 'POST',
body: formData,
credentials: 'include'
})
}
export const uploadAmountReceipt = async (baseURL: string, id: number, file: File) => {
const formData = new FormData()
formData.append('file', file)
return $fetch(`${baseURL}/mileage_allowances/${id}/amount-receipt`, {
method: 'POST',
body: formData,
credentials: 'include'
})
}
export const getKmReceiptUrl = (baseURL: string, id: number): string => {
return `${baseURL}/mileage_allowances/${id}/receipt`
}
export const getAmountReceiptUrl = (baseURL: string, id: number): string => {
return `${baseURL}/mileage_allowances/${id}/amount-receipt`
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313125819 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create mileage_allowances table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE mileage_allowances (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, month DATE NOT NULL, kilometers DOUBLE PRECISION NOT NULL, comment TEXT DEFAULT NULL, receipt_path VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, employee_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_3B44830E8C03F15C ON mileage_allowances (employee_id)');
$this->addSql('ALTER TABLE mileage_allowances ADD CONSTRAINT FK_3B44830E8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP CONSTRAINT FK_3B44830E8C03F15C');
$this->addSql('DROP TABLE mileage_allowances');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313133548 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add receipt_name column to mileage_allowances';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances ADD receipt_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP receipt_name');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313151220 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create bonuses table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE bonuses (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, month DATE NOT NULL, amount DOUBLE PRECISION NOT NULL, comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_8535CFD28C03F15C ON bonuses (employee_id)');
$this->addSql('COMMENT ON COLUMN bonuses.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN bonuses.month IS \'(DC2Type:date_immutable)\'');
$this->addSql('ALTER TABLE bonuses ADD CONSTRAINT FK_8535CFD28C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bonuses DROP CONSTRAINT FK_8535CFD28C03F15C');
$this->addSql('DROP TABLE bonuses');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260315100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_driver flag to employee_contract_periods';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods ADD is_driver BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN is_driver');
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260315100100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add driver-specific fields to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD day_hours_minutes INTEGER DEFAULT NULL');
$this->addSql('ALTER TABLE work_hours ADD night_hours_minutes INTEGER DEFAULT NULL');
$this->addSql('ALTER TABLE work_hours ADD has_breakfast BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE work_hours ADD has_lunch BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE work_hours ADD has_overnight BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN day_hours_minutes');
$this->addSql('ALTER TABLE work_hours DROP COLUMN night_hours_minutes');
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_breakfast');
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_lunch');
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_overnight');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260316100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add has_dinner column to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD has_dinner BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_dinner');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260316100100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add workshop_hours_minutes column to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD workshop_hours_minutes INTEGER DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN workshop_hours_minutes');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260318143503 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add amount column to mileage_allowances';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances ADD COLUMN amount DOUBLE PRECISION DEFAULT 0 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260319100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add amount receipt fields to mileage_allowances';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances ADD amount_receipt_path VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE mileage_allowances ADD amount_receipt_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount_receipt_path');
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount_receipt_name');
}
}

View File

@@ -12,7 +12,7 @@ use App\State\EmployeeLeaveSummaryProvider;
operations: [
new Get(
uriTemplate: '/employees/{id}/leave-summary',
security: "is_granted('ROLE_USER')",
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeLeaveSummaryProvider::class
),
],
@@ -20,17 +20,20 @@ use App\State\EmployeeLeaveSummaryProvider;
)]
final class EmployeeLeaveSummary
{
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
public float $previousYearAcquiredDays = 0.0;
public float $previousYearTakenDays = 0.0;
public float $previousYearRemainingDays = 0.0;
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = [];

View File

@@ -14,7 +14,7 @@ use App\State\EmployeeRttSummaryProvider;
operations: [
new Get(
uriTemplate: '/employees/{id}/rtt-summary',
security: "is_granted('ROLE_USER')",
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeRttSummaryProvider::class
),
],
@@ -32,6 +32,7 @@ final class EmployeeRttSummary
public int $currentYearRecoveryMinutes = 0;
public int $availableMinutes = 0;
public int $totalPaidMinutes = 0;
public ?string $rttStartDate = null;
/** @var list<RttMonthPayment> */
public array $monthPayments = [];

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\LeaveRecapPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/leave-recap/print',
provider: LeaveRecapPrintProvider::class,
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class LeaveRecapPrint {}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\SalaryRecapPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/salary-recap/print',
provider: SalaryRecapPrintProvider::class,
parameters: [
new QueryParameter(key: 'month', required: true),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class SalaryRecapPrint {}

View File

@@ -32,7 +32,12 @@ final class WorkHourBulkUpsert
* eveningFrom?:?string,
* eveningTo?:?string,
* isPresentMorning?:bool,
* isPresentAfternoon?:bool
* isPresentAfternoon?:bool,
* dayHoursMinutes?:?int,
* nightHoursMinutes?:?int,
* hasBreakfast?:bool,
* hasLunch?:bool,
* hasOvernight?:bool
* }>
*/
public array $entries = [];

View File

@@ -27,5 +27,7 @@ final class ContractHistoryItem
public ?int $periodId = null,
#[Groups(['employee:read'])]
public array $suspensions = [],
#[Groups(['employee:read'])]
public bool $isDriver = false,
) {}
}

View File

@@ -16,6 +16,7 @@ final class DayContextRow
public bool $absentAfternoon = false,
public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0,
public bool $isDriverContract = false,
) {}
public function addAbsence(
@@ -78,6 +79,7 @@ final class DayContextRow
'absentAfternoon' => $this->absentAfternoon,
'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits,
'isDriverContract' => $this->isDriverContract,
];
}

View File

@@ -10,10 +10,16 @@ final class WeeklyDaySummary
public string $date,
public int $dayMinutes,
public int $nightMinutes,
public int $workshopMinutes,
public int $totalMinutes,
public ?float $present = null,
public bool $hasAbsence = false,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
public bool $hasNightBasket = false,
public bool $hasBreakfast = false,
public bool $hasLunch = false,
public bool $hasDinner = false,
public bool $hasOvernight = false,
) {}
}

View File

@@ -20,11 +20,19 @@ final class WeeklySummaryRow
public array $daily,
public int $weeklyDayMinutes,
public int $weeklyNightMinutes,
public int $weeklyWorkshopMinutes,
public int $weeklyTotalMinutes,
public float $weeklyPresenceCount,
public int $weeklyOvertimeTotalMinutes,
public int $weeklyOvertime25Minutes,
public int $weeklyOvertime50Minutes,
public int $weeklyRecoveryMinutes,
public int $weeklyNightBasketCount = 0,
public bool $isDriver = false,
public int $weeklyBreakfastCount = 0,
public int $weeklyLunchCount = 0,
public int $weeklyDinnerCount = 0,
public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true,
) {}
}

145
src/Entity/Bonus.php Normal file
View File

@@ -0,0 +1,145 @@
<?php
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\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\BonusRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
security: "is_granted('ROLE_ADMIN')"
),
new GetCollection(
security: "is_granted('ROLE_ADMIN')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')"
),
],
normalizationContext: [
'groups' => ['bonus:read', 'employee:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'groups' => ['bonus:write'],
'datetime_format' => 'Y-m-d',
],
order: ['month' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['month'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: BonusRepository::class)]
#[ORM\Table(name: 'bonuses')]
class Bonus
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['bonus:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['bonus:read', 'bonus:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['bonus:read', 'bonus:write'])]
private ?DateTimeImmutable $month = null;
#[ORM\Column(type: 'float')]
#[Groups(['bonus:read', 'bonus:write'])]
private float $amount = 0;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['bonus:read', 'bonus:write'])]
private ?string $comment = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['bonus:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getMonth(): ?DateTimeImmutable
{
return $this->month;
}
public function setMonth(?DateTimeImmutable $month): self
{
$this->month = $month;
return $this;
}
public function getAmount(): float
{
return $this->amount;
}
public function setAmount(float $amount): self
{
$this->amount = $amount;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')",
processor: EmployeeWriteProcessor::class,
order: ['site.name' => 'ASC', 'displayOrder' => 'ASC', 'lastName' => 'ASC', 'firstName' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\Table(name: 'employees')]
@@ -88,6 +89,9 @@ class Employee
#[Groups(['employee:write'])]
private ?string $contractComment = null;
#[Groups(['employee:write'])]
private ?bool $isDriverInput = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -245,6 +249,30 @@ class Employee
return $this;
}
public function getIsDriverInput(): ?bool
{
return $this->isDriverInput;
}
public function setIsDriverInput(?bool $isDriverInput): self
{
$this->isDriverInput = $isDriverInput;
return $this;
}
#[Groups(['employee:read'])]
public function getHasActiveContract(): bool
{
return null !== $this->resolveCurrentContractPeriod();
}
#[Groups(['employee:read'])]
public function getIsDriver(): bool
{
return $this->resolveCurrentContractPeriod()?->getIsDriver() ?? false;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
@@ -329,6 +357,7 @@ class Employee
comment: $period->getComment(),
periodId: $period->getId(),
suspensions: $suspensionData,
isDriver: $period->getIsDriver(),
);
},
$periods

View File

@@ -39,6 +39,9 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $isDriver = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $paidLeaveSettled = false;
@@ -137,6 +140,18 @@ class EmployeeContractPeriod
return $this->createdAt;
}
public function getIsDriver(): bool
{
return $this->isDriver;
}
public function setIsDriver(bool $isDriver): self
{
$this->isDriver = $isDriver;
return $this;
}
public function isPaidLeaveSettled(): bool
{
return $this->paidLeaveSettled;

View File

@@ -0,0 +1,253 @@
<?php
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\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\MileageAllowanceRepository;
use App\State\MileageAllowanceAmountReceiptDownloadProvider;
use App\State\MileageAllowanceAmountReceiptUploadProcessor;
use App\State\MileageAllowanceDeleteProcessor;
use App\State\MileageAllowanceReceiptDownloadProvider;
use App\State\MileageAllowanceReceiptUploadProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
security: "is_granted('ROLE_ADMIN')"
),
new GetCollection(
security: "is_granted('ROLE_ADMIN')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
processor: MileageAllowanceDeleteProcessor::class,
),
new Post(
uriTemplate: '/mileage_allowances/{id}/receipt',
security: "is_granted('ROLE_ADMIN')",
deserialize: false,
processor: MileageAllowanceReceiptUploadProcessor::class,
),
new Get(
uriTemplate: '/mileage_allowances/{id}/receipt',
security: "is_granted('ROLE_ADMIN')",
provider: MileageAllowanceReceiptDownloadProvider::class,
),
new Post(
uriTemplate: '/mileage_allowances/{id}/amount-receipt',
security: "is_granted('ROLE_ADMIN')",
deserialize: false,
processor: MileageAllowanceAmountReceiptUploadProcessor::class,
),
new Get(
uriTemplate: '/mileage_allowances/{id}/amount-receipt',
security: "is_granted('ROLE_ADMIN')",
provider: MileageAllowanceAmountReceiptDownloadProvider::class,
),
],
normalizationContext: [
'groups' => ['mileage_allowance:read', 'employee:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'groups' => ['mileage_allowance:write'],
'datetime_format' => 'Y-m-d',
],
order: ['month' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['month'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: MileageAllowanceRepository::class)]
#[ORM\Table(name: 'mileage_allowances')]
class MileageAllowance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['mileage_allowance:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
private ?DateTimeImmutable $month = null;
#[ORM\Column(type: 'float')]
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
private float $kilometers = 0;
#[ORM\Column(type: 'float', options: ['default' => 0])]
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
private float $amount = 0;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
private ?string $comment = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['mileage_allowance:read'])]
private ?string $receiptPath = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['mileage_allowance:read'])]
private ?string $receiptName = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['mileage_allowance:read'])]
private ?string $amountReceiptPath = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['mileage_allowance:read'])]
private ?string $amountReceiptName = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['mileage_allowance:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getMonth(): ?DateTimeImmutable
{
return $this->month;
}
public function setMonth(?DateTimeImmutable $month): self
{
$this->month = $month;
return $this;
}
public function getKilometers(): float
{
return $this->kilometers;
}
public function setKilometers(float $kilometers): self
{
$this->kilometers = $kilometers;
return $this;
}
public function getAmount(): float
{
return $this->amount;
}
public function setAmount(float $amount): self
{
$this->amount = $amount;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getReceiptPath(): ?string
{
return $this->receiptPath;
}
public function setReceiptPath(?string $receiptPath): self
{
$this->receiptPath = $receiptPath;
return $this;
}
public function getReceiptName(): ?string
{
return $this->receiptName;
}
public function setReceiptName(?string $receiptName): self
{
$this->receiptName = $receiptName;
return $this;
}
public function getAmountReceiptPath(): ?string
{
return $this->amountReceiptPath;
}
public function setAmountReceiptPath(?string $amountReceiptPath): self
{
$this->amountReceiptPath = $amountReceiptPath;
return $this;
}
public function getAmountReceiptName(): ?string
{
return $this->amountReceiptName;
}
public function setAmountReceiptName(?string $amountReceiptName): self
{
$this->amountReceiptName = $amountReceiptName;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -99,6 +99,34 @@ class WorkHour
#[Groups(['work_hour:read'])]
private bool $isPresentAfternoon = false;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['work_hour:read'])]
private ?int $dayHoursMinutes = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['work_hour:read'])]
private ?int $nightHoursMinutes = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['work_hour:read'])]
private ?int $workshopHoursMinutes = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $hasBreakfast = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $hasLunch = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $hasDinner = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $hasOvernight = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false;
@@ -212,6 +240,90 @@ class WorkHour
return $this;
}
public function getDayHoursMinutes(): ?int
{
return $this->dayHoursMinutes;
}
public function setDayHoursMinutes(?int $dayHoursMinutes): self
{
$this->dayHoursMinutes = $dayHoursMinutes;
return $this;
}
public function getNightHoursMinutes(): ?int
{
return $this->nightHoursMinutes;
}
public function setNightHoursMinutes(?int $nightHoursMinutes): self
{
$this->nightHoursMinutes = $nightHoursMinutes;
return $this;
}
public function getWorkshopHoursMinutes(): ?int
{
return $this->workshopHoursMinutes;
}
public function setWorkshopHoursMinutes(?int $workshopHoursMinutes): self
{
$this->workshopHoursMinutes = $workshopHoursMinutes;
return $this;
}
public function getHasBreakfast(): bool
{
return $this->hasBreakfast;
}
public function setHasBreakfast(bool $hasBreakfast): self
{
$this->hasBreakfast = $hasBreakfast;
return $this;
}
public function getHasLunch(): bool
{
return $this->hasLunch;
}
public function setHasLunch(bool $hasLunch): self
{
$this->hasLunch = $hasLunch;
return $this;
}
public function getHasDinner(): bool
{
return $this->hasDinner;
}
public function setHasDinner(bool $hasDinner): self
{
$this->hasDinner = $hasDinner;
return $this;
}
public function getHasOvernight(): bool
{
return $this->hasOvernight;
}
public function setHasOvernight(bool $hasOvernight): self
{
$this->hasOvernight = $hasOvernight;
return $this;
}
public function isPresentMorning(): bool
{
return $this->isPresentMorning;

View File

@@ -100,6 +100,38 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence
return $qb->getQuery()->getResult();
}
/**
* @return list<DateTimeImmutable> sorted maladie dates
*/
public function findMaladieDatesByEmployee(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): array {
$results = $this->createQueryBuilder('a')
->select('a.startDate')
->join('a.type', 't')
->andWhere('a.employee = :employee')
->andWhere('t.code = :code')
->andWhere('a.startDate >= :from')
->andWhere('a.startDate <= :to')
->setParameter('employee', $employee)
->setParameter('code', 'M')
->setParameter('from', $from)
->setParameter('to', $to)
->orderBy('a.startDate', 'ASC')
->getQuery()
->getArrayResult()
;
return array_map(
static fn (array $row): DateTimeImmutable => $row['startDate'] instanceof DateTimeImmutable
? $row['startDate']
: DateTimeImmutable::createFromInterface($row['startDate']),
$results
);
}
/**
* @return list<Absence>
*/

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Bonus;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Bonus>
*/
final class BonusRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bonus::class);
}
/**
* @return Bonus[]
*/
public function findByMonth(DateTimeImmutable $from, DateTimeImmutable $to): array
{
return $this->createQueryBuilder('b')
->andWhere('b.month >= :from')
->andWhere('b.month <= :to')
->setParameter('from', $from)
->setParameter('to', $to)
->innerJoin('b.employee', 'e')
->addSelect('e')
->getQuery()
->getResult()
;
}
}

View File

@@ -87,8 +87,7 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
->addSelect('s')
->leftJoin('e.contract', 'c')
->addSelect('c')
->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC')
->orderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')
->addOrderBy('e.lastName', 'ASC')
->addOrderBy('e.firstName', 'ASC')

View File

@@ -43,4 +43,21 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
->getResult()
;
}
/**
* @return EmployeeRttPayment[]
*/
public function findByYearAndMonth(int $year, int $month): array
{
return $this->createQueryBuilder('p')
->andWhere('p.year = :year')
->andWhere('p.month = :month')
->setParameter('year', $year)
->setParameter('month', $month)
->innerJoin('p.employee', 'e')
->addSelect('e')
->getQuery()
->getResult()
;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\MileageAllowance;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MileageAllowance>
*/
final class MileageAllowanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, MileageAllowance::class);
}
/**
* @return MileageAllowance[]
*/
public function findByMonth(DateTimeImmutable $from, DateTimeImmutable $to): array
{
return $this->createQueryBuilder('m')
->andWhere('m.month >= :from')
->andWhere('m.month <= :to')
->setParameter('from', $from)
->setParameter('to', $to)
->innerJoin('m.employee', 'e')
->addSelect('e')
->getQuery()
->getResult()
;
}
}

View File

@@ -191,6 +191,143 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return $result;
}
/**
* Count weekend and public holiday worked days for forfait bonus leave (PRESENCE mode only).
* Morning + afternoon = 1.0 day, one only = 0.5 day.
*
* @param list<string> $publicHolidayDates Y-m-d formatted weekday public holiday dates
*/
public function countWeekendAndHolidayWorkedDays(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidayDates = []): float
{
$targetDates = [];
// Collect weekend dates in range
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
if ((int) $cursor->format('N') >= 6) {
$targetDates[] = $cursor;
}
}
// Add weekday public holidays
foreach ($publicHolidayDates as $date) {
$targetDates[] = new DateTimeImmutable($date);
}
if ([] === $targetDates) {
return 0.0;
}
$dateStrings = array_map(static fn (DateTimeImmutable $d): string => $d->format('Y-m-d'), $targetDates);
/** @var list<WorkHour> $rows */
$rows = $this->createQueryBuilder('w')
->andWhere('w.employee = :employee')
->andWhere('w.workDate IN (:dates)')
->andWhere('w.isPresentMorning = true OR w.isPresentAfternoon = true')
->setParameter('employee', $employee)
->setParameter('dates', $dateStrings)
->getQuery()
->getResult()
;
$total = 0.0;
foreach ($rows as $row) {
if ($row->isPresentMorning() && $row->isPresentAfternoon()) {
$total += 1.0;
} else {
$total += 0.5;
}
}
return $total;
}
/**
* Return the set of Y-m-d dates where the employee has worked hours on the given dates.
*
* @param list<string> $dates Y-m-d formatted dates
*
* @return array<string, true> Y-m-d => true
*/
public function findWorkedDatesAmong(Employee $employee, array $dates): array
{
if ([] === $dates) {
return [];
}
$placeholders = [];
$params = ['employee' => $employee->getId()];
foreach (array_values($dates) as $i => $date) {
$key = "d{$i}";
$placeholders[] = ":{$key}";
$params[$key] = $date;
}
$sql = sprintf(
'SELECT work_date FROM work_hours WHERE employee_id = :employee AND work_date IN (%s) AND (morning_from IS NOT NULL OR afternoon_from IS NOT NULL OR evening_from IS NOT NULL)',
implode(', ', $placeholders)
);
$conn = $this->getEntityManager()->getConnection();
$rows = $conn->fetchAllAssociative($sql, $params);
$result = [];
foreach ($rows as $row) {
$result[(string) $row['work_date']] = true;
}
return $result;
}
public function isWeekFullyValidated(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
{
// Count weekdays (Mon-Fri) in range
$expectedWeekdays = 0;
for ($d = $from; $d <= $to; $d = $d->modify('+1 day')) {
if ((int) $d->format('N') <= 5) {
++$expectedWeekdays;
}
}
if (0 === $expectedWeekdays) {
return false;
}
// Every weekday must have a work_hour row
$totalCount = (int) $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->setParameter('employee', $employee)
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getSingleScalarResult()
;
if ($totalCount < $expectedWeekdays) {
return false;
}
// All rows must be validated
$nonValidatedCount = (int) $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isValid = :isValid')
->setParameter('employee', $employee)
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('isValid', false)
->getQuery()
->getSingleScalarResult()
;
return 0 === $nonValidatedCount;
}
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
{
$workDate = DateTimeImmutable::createFromInterface($date);

View File

@@ -15,6 +15,7 @@ final readonly class EmployeeContractChangeRequest
public ?DateTimeImmutable $contractEndDate,
public ?bool $contractPaidLeaveSettled,
public ?string $contractComment,
public ?bool $isDriver = null,
) {}
public function hasPeriodChangeRequest(): bool

View File

@@ -19,6 +19,7 @@ final class EmployeeContractChangeRequestFactory
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(),
isDriver: $employee->getIsDriverInput(),
);
}

View File

@@ -18,6 +18,7 @@ final class EmployeeContractPeriodBuilder
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
): EmployeeContractPeriod {
return new EmployeeContractPeriod()
->setEmployee($employee)
@@ -25,6 +26,7 @@ final class EmployeeContractPeriodBuilder
->setStartDate($startDate)
->setEndDate($endDate)
->setContractNature($nature)
->setIsDriver($isDriver)
;
}
}

View File

@@ -28,6 +28,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
@@ -36,7 +37,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
return;
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->entityManager->flush();
}
@@ -69,7 +70,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod
?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
@@ -81,7 +83,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
}
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->entityManager->flush();
}
@@ -91,8 +93,9 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature);
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->entityManager->persist($period);
}
}

View File

@@ -18,6 +18,7 @@ interface EmployeeContractPeriodManagerInterface
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
): void;
public function closeCurrentPeriod(
@@ -33,6 +34,7 @@ interface EmployeeContractPeriodManagerInterface
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod
?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false,
): void;
}

View File

@@ -23,6 +23,60 @@ readonly class EmployeeContractResolver
return $period?->getContract();
}
public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
return $period?->getIsDriver() ?? false;
}
/**
* @param list<Employee> $employees
* @param list<string> $days
*
* @return array<int, array<string, bool>>
*/
public function resolveIsDriverForEmployeesAndDays(array $employees, array $days): array
{
$resolved = [];
if ([] === $employees || [] === $days) {
return $resolved;
}
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
$resolved[$employeeId][$day] = false;
}
}
$from = new DateTimeImmutable(min($days));
$to = new DateTimeImmutable(max($days));
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
foreach ($periods as $period) {
$employeeId = $period->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
$isDriver = $period->getIsDriver();
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $isDriver;
}
}
return $resolved;
}
public function resolveNatureForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ContractNature
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);

View File

@@ -24,6 +24,7 @@ final readonly class LeaveBalanceComputationService
private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
private const float FOUR_HOUR_ANNUAL_DAYS = 10.0;
private const float FOUR_HOUR_ACCRUAL_PER_MONTH = 0.83;
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
public function __construct(
private AbsenceRepository $absenceRepository,
@@ -31,6 +32,7 @@ final readonly class LeaveBalanceComputationService
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
private LongMaladieService $longMaladieService,
) {}
/**
@@ -83,19 +85,34 @@ final readonly class LeaveBalanceComputationService
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForEmployeePeriod($employee, $from, $to)
);
$longMaladiePeriods = [];
$longMaladieReductionFactor = 1.0;
if (4 !== $employee->getContract()?->getWeeklyHours()) {
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $to);
if ([] !== $longMaladiePeriods) {
$totalNormalAccrual = $this->resolveDaysAccrualPerMonth($employee) + $this->resolveSaturdayAccrualPerMonth($employee);
$longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual;
}
}
$generatedDays = $this->computeAccruedDays(
$this->resolveAnnualDays($employee),
$this->resolveDaysAccrualPerMonth($employee),
$effectiveFrom,
$to,
$suspensions
$suspensions,
$longMaladiePeriods,
$longMaladieReductionFactor
);
$generatedSaturdays = $this->computeAccruedDays(
$this->resolveAnnualSaturdays($employee),
$this->resolveSaturdayAccrualPerMonth($employee),
$effectiveFrom,
$to,
$suspensions
$suspensions,
$longMaladiePeriods,
$longMaladieReductionFactor
);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
@@ -267,21 +284,29 @@ final readonly class LeaveBalanceComputationService
: self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
}
/**
* @param list<ContractSuspension> $suspensions
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
*/
private function computeAccruedDays(
float $annualCap,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd,
array $suspensions = []
array $suspensions = [],
array $longMaladiePeriods = [],
float $longMaladieReductionFactor = 1.0
): float {
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
return 0.0;
}
$periodStart = $this->normalizeDate($periodStart);
$periodEnd = $this->normalizeDate($periodEnd);
$coveredMonths = 0.0;
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
$periodStart = $this->normalizeDate($periodStart);
$periodEnd = $this->normalizeDate($periodEnd);
$publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : [];
$normalMonths = 0.0;
$reducedMonths = 0.0;
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $periodEnd) {
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
@@ -289,18 +314,39 @@ final readonly class LeaveBalanceComputationService
$monthEnd = $periodEnd;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
if ([] !== $suspensions) {
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
if ($suspendedDays > 0) {
$businessDays = $this->countBusinessDaysInRange($monthStart, $monthEnd, $publicHolidays);
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays);
$normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0;
$cursor = $cursor->modify('first day of next month');
continue;
}
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
if ([] !== $longMaladiePeriods) {
$reducedDays = $this->longMaladieService->countReducedDaysInMonth($monthStart, $monthEnd, $longMaladiePeriods);
if ($reducedDays > 0) {
$normalDays = max(0, $coveredDays - $reducedDays);
$normalMonths += $normalDays / $daysInMonth;
$reducedMonths += min($coveredDays, $reducedDays) / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
continue;
}
}
$normalMonths += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return min($annualCap, $coveredMonths * $accrualPerMonth);
return min($annualCap, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth);
}
private function parseYmdDate(string $value): ?DateTimeImmutable
@@ -317,8 +363,15 @@ final readonly class LeaveBalanceComputationService
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
{
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$count = 0;
return $this->countBusinessDaysInRange($from, $to, $this->buildPublicHolidayMap($from, $to));
}
/**
* @param array<string, string> $publicHolidays pre-built map
*/
private function countBusinessDaysInRange(DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidays): int
{
$count = 0;
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
$weekDay = (int) $cursor->format('N');
$dayKey = $cursor->format('Y-m-d');

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Employee;
use App\Repository\AbsenceRepository;
use DateTimeImmutable;
use function count;
/**
* Detects continuous MALADIE (sick leave) periods and computes
* the date ranges where reduced accrual applies (after the first month grace).
*/
final readonly class LongMaladieService
{
private const int MAX_GAP_DAYS = 3;
public function __construct(
private AbsenceRepository $absenceRepository,
) {}
/**
* Returns date ranges where the reduced maladie accrual rate applies.
* For continuous maladie periods > 1 month, the first month is excluded (grace period).
*
* @return list<array{start: DateTimeImmutable, end: DateTimeImmutable}>
*/
public function findReducedRatePeriods(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to
): array {
// Look back 13 months to catch maladie that started before the exercise period
$extendedFrom = $from->modify('-13 months');
$dates = $this->absenceRepository->findMaladieDatesByEmployee($employee, $extendedFrom, $to);
if ([] === $dates) {
return [];
}
$periods = $this->consolidateIntoPeriods($dates);
return $this->applyFirstMonthGrace($periods);
}
/**
* Count calendar days in [monthStart, monthEnd] that fall within reduced maladie periods.
*
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $reducedPeriods
*/
public function countReducedDaysInMonth(
DateTimeImmutable $monthStart,
DateTimeImmutable $monthEnd,
array $reducedPeriods
): int {
$total = 0;
foreach ($reducedPeriods as $period) {
$overlapStart = $period['start'] > $monthStart ? $period['start'] : $monthStart;
$overlapEnd = $period['end'] < $monthEnd ? $period['end'] : $monthEnd;
if ($overlapStart > $overlapEnd) {
continue;
}
$total += ((int) $overlapEnd->diff($overlapStart)->format('%a')) + 1;
}
return $total;
}
/**
* @param list<DateTimeImmutable> $dates sorted chronologically
*
* @return list<array{start: DateTimeImmutable, end: DateTimeImmutable}>
*/
private function consolidateIntoPeriods(array $dates): array
{
$periods = [];
$start = $dates[0];
$prev = $start;
for ($i = 1, $count = count($dates); $i < $count; ++$i) {
$current = $dates[$i];
$gap = (int) $prev->diff($current)->format('%a');
if ($gap > self::MAX_GAP_DAYS) {
$periods[] = ['start' => $start, 'end' => $prev];
$start = $current;
}
$prev = $current;
}
$periods[] = ['start' => $start, 'end' => $prev];
return $periods;
}
/**
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $periods
*
* @return list<array{start: DateTimeImmutable, end: DateTimeImmutable}>
*/
private function applyFirstMonthGrace(array $periods): array
{
$result = [];
foreach ($periods as $period) {
$gracedStart = $period['start']->modify('+1 month');
if ($gracedStart > $period['end']) {
continue;
}
$result[] = ['start' => $gracedStart, 'end' => $period['end']];
}
return $result;
}
}

View File

@@ -6,6 +6,8 @@ namespace App\Service;
use Exception;
use RuntimeException;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
@@ -17,7 +19,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
{
public function __construct(
private HttpClientInterface $client,
private string $holidayUrl
private string $holidayUrl,
private CacheInterface $cache,
) {}
/**
@@ -30,24 +33,29 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
public function getHolidaysDay(string $zone): array
{
$zone = strtolower(trim($zone));
$url = $this->holidayUrl."{$zone}.json";
$key = "public_holidays_{$zone}_all";
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 $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}.json";
return json_decode($response->getContent(), true);
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);
});
}
/**
@@ -60,20 +68,25 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
{
$zone = strtolower(trim($zone));
$years = trim($years);
$url = $this->holidayUrl."{$zone}/{$years}.json";
$key = "public_holidays_{$zone}_{$years}";
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 $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
$item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}/{$years}.json";
return json_decode($response->getContent(), true);
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

@@ -21,13 +21,18 @@ use DateTimeImmutable;
final readonly class RttRecoveryComputationService
{
private ?DateTimeImmutable $rttStartDate;
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
) {}
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
}
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
@@ -71,7 +76,7 @@ final readonly class RttRecoveryComputationService
return $weeks;
}
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear, ?DateTimeImmutable $limitDate = null): WeekRecoveryDetail
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
@@ -85,7 +90,7 @@ final readonly class RttRecoveryComputationService
$weeks
);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $limitDate);
$total = new WeekRecoveryDetail();
foreach ($byWeek as $detail) {
@@ -172,6 +177,12 @@ final readonly class RttRecoveryComputationService
continue;
}
if ($this->rttStartDate instanceof DateTimeImmutable && $effectiveEnd < $this->rttStartDate) {
$results[$weekKey] = new WeekRecoveryDetail();
continue;
}
$weekDays = [];
for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) {
$weekDays[] = $cursor->format('Y-m-d');
@@ -195,20 +206,36 @@ final readonly class RttRecoveryComputationService
continue;
}
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
$weekContractType = ContractType::resolve(
$weekAnchorContract?->getName(),
$weekAnchorContract?->getTrackingMode(),
$weekAnchorContract?->getWeeklyHours()
);
$isCustomContract = ContractType::CUSTOM === $weekContractType;
$overtimeReferenceMinutes = $isCustomContract
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
$totalMinutes = 0;
} elseif ($isCustomContract) {
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
} else {
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
}
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
@@ -216,9 +243,7 @@ final readonly class RttRecoveryComputationService
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
totalMinutes: $totalMinutes,
);
}
@@ -227,6 +252,20 @@ final readonly class RttRecoveryComputationService
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
$driverNight = $workHour->getNightHoursMinutes() ?? 0;
$driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0;
if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) {
$totalMinutes = $driverDay + $driverNight + $driverWorkshop;
return new WorkMetrics(
dayMinutes: $driverDay + $driverWorkshop,
nightMinutes: $driverNight,
totalMinutes: $totalMinutes,
);
}
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
@@ -315,6 +354,23 @@ final readonly class RttRecoveryComputationService
return max(0, $end - $start);
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyCustomReferenceMinutes(array $days, array $contractsByDate): int
{
$total = 0;
foreach ($days as $date) {
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
$total += $this->resolveDailyReferenceMinutes($hours, $isoDay);
}
return $total;
}
/**
* @param list<string> $days
* @param array<string, ?Contract> $contractsByDate

View File

@@ -21,6 +21,7 @@ use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\LongMaladieService;
use App\Service\Leave\SuspensionDaysCalculator;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
@@ -42,6 +43,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private const float CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS = 0.0;
private const float CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH = 0.83;
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
public function __construct(
private Security $security,
@@ -52,6 +54,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private EmployeeContractPeriodRepository $periodRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private LeaveBalanceComputationService $leaveBalanceComputationService,
private LongMaladieService $longMaladieService,
private PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
private WorkHourRepository $workHourRepository,
@@ -91,16 +94,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
$summary->isSupported = true;
$summary->ruleCode = $yearSummary['ruleCode'];
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
$summary->fractionedDays = $fractionedDays;
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->takenDays = $yearSummary['takenDays'];
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
$summary->isSupported = true;
$summary->ruleCode = $yearSummary['ruleCode'];
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
$summary->fractionedDays = $fractionedDays;
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->takenDays = $yearSummary['takenDays'];
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
@@ -117,12 +123,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* takenDays: float,
* takenSaturdays: float,
* remainingDays: float,
* remainingSaturdays: float
* remainingSaturdays: float,
* previousYearAcquiredDays: float,
* previousYearTakenDays: float,
* previousYearRemainingDays: float
* }
*/
private function computeYearSummary(Employee $employee, int $targetYear): ?array
public function computeYearSummary(Employee $employee, int $targetYear): ?array
{
$firstYear = $this->resolveFirstComputationYear($employee);
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
if ($targetYear < $firstYear) {
$targetYear = $firstYear;
}
@@ -181,13 +190,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
);
$longMaladiePeriods = [];
$longMaladieReductionFactor = 1.0;
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
&& 4 !== $employee->getContract()?->getWeeklyHours()
&& null !== $accrualCalculationEnd
) {
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
if ([] !== $longMaladiePeriods) {
$totalNormalAccrual = $leavePolicy['accrualPerMonth'] + $leavePolicy['saturdayAccrualPerMonth'];
$longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual;
}
}
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredDays'],
$leavePolicy['accrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
$suspensions,
$longMaladiePeriods,
$longMaladieReductionFactor
)
: 0.0;
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
@@ -196,7 +221,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$leavePolicy['saturdayAccrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd,
$suspensions
$suspensions,
$longMaladiePeriods,
$longMaladieReductionFactor
)
: 0.0;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
@@ -214,6 +241,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$takenDays += $openingBalance->getTakenDays();
$takenSaturdays += $openingBalance->getTakenSaturdays();
}
$previousYearAcquired = 0.0;
$previousYearTaken = 0.0;
$previousYearRemaining = 0.0;
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
$availableAcquired = max(0.0, $carryDays);
$takenFromAcquired = min($availableAcquired, $takenDays);
@@ -238,26 +269,37 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
} else {
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
// Suspensions do not impact forfait 218 leave calculation.
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
// Taken days are first deducted from N-1 carry, then from current year.
$previousYearAcquired = $carryDays;
$takenFromPrevious = min(max(0.0, $previousYearAcquired), $takenDays);
$previousYearTaken = $takenFromPrevious;
$takenFromCurrent = $takenDays - $takenFromPrevious;
$previousYearRemaining = max(0.0, $previousYearAcquired - $takenFromPrevious);
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
$remainingDays = max(0.0, $acquiredDays - $takenDays);
$remainingDays = max(0.0, $acquiredDays - $takenFromCurrent);
$acquiredSaturdays = 0.0;
$remainingSaturdays = 0.0;
$previousRemainingDays = $remainingDays;
$previousRemainingDays = $previousYearRemaining + $remainingDays;
$previousRemainingSaturdays = 0.0;
}
if ($year === $targetYear) {
$targetSummary = [
'ruleCode' => $leavePolicy['ruleCode'],
'acquiredDays' => $acquiredDays,
'acquiredSaturdays' => $acquiredSaturdays,
'accruingDays' => $accruingDays,
'takenDays' => $takenDays,
'takenSaturdays' => $takenSaturdays,
'remainingDays' => $remainingDays,
'remainingSaturdays' => $remainingSaturdays,
'ruleCode' => $leavePolicy['ruleCode'],
'acquiredDays' => $acquiredDays,
'acquiredSaturdays' => $acquiredSaturdays,
'accruingDays' => $accruingDays,
'takenDays' => $takenDays,
'takenSaturdays' => $takenSaturdays,
'remainingDays' => $remainingDays,
'remainingSaturdays' => $remainingSaturdays,
'previousYearAcquiredDays' => $previousYearAcquired,
'previousYearTakenDays' => $previousYearTaken,
'previousYearRemainingDays' => $previousYearRemaining,
];
}
}
@@ -265,6 +307,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $targetSummary;
}
public function resolveLeaveYearForToday(Employee $employee): int
{
$today = new DateTimeImmutable('today');
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return (int) $today->format('Y');
}
return $this->resolveCurrentLeaveYear($today);
}
private function resolveEffectivePeriodStart(
Employee $employee,
DateTimeImmutable $from,
@@ -344,12 +396,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $year;
}
/**
* @param list<ContractSuspension> $suspensions
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
*/
private function computeAccruedDaysFromStart(
float $acquiredDays,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
?DateTimeImmutable $periodEnd,
array $suspensions = []
array $suspensions = [],
array $longMaladiePeriods = [],
float $longMaladieReductionFactor = 1.0
): float {
if ($accrualPerMonth <= 0.0) {
return $acquiredDays;
@@ -359,10 +417,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return 0.0;
}
$periodStart = $this->normalizeDate($periodStart);
$periodEnd = $this->normalizeDate($periodEnd);
$coveredMonths = 0.0;
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
$periodStart = $this->normalizeDate($periodStart);
$periodEnd = $this->normalizeDate($periodEnd);
$publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : [];
$normalMonths = 0.0;
$reducedMonths = 0.0;
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $periodEnd) {
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
@@ -370,18 +430,39 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$monthEnd = $periodEnd;
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
if ([] !== $suspensions) {
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
if ($suspendedDays > 0) {
$businessDays = $this->countBusinessDays($monthStart, $monthEnd, $publicHolidays);
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays);
$normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0;
$cursor = $cursor->modify('first day of next month');
continue;
}
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
if ([] !== $longMaladiePeriods) {
$reducedDays = $this->longMaladieService->countReducedDaysInMonth($monthStart, $monthEnd, $longMaladiePeriods);
if ($reducedDays > 0) {
$normalDays = max(0, $coveredDays - $reducedDays);
$normalMonths += $normalDays / $daysInMonth;
$reducedMonths += min($coveredDays, $reducedDays) / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
continue;
}
}
$normalMonths += $coveredDays / $daysInMonth;
$cursor = $cursor->modify('first day of next month');
}
return min($acquiredDays, $coveredMonths * $accrualPerMonth);
return min($acquiredDays, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth);
}
private function resolveAccrualCalculationEndDate(
@@ -454,10 +535,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$type = $employee->getContract()?->getType();
if (ContractType::FORFAIT === $type) {
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$weekdayHolidays = array_filter(
array_keys($publicHolidays),
static fn (string $date): bool => (int) new DateTimeImmutable($date)->format('N') <= 5
);
$bonusDays = $this->workHourRepository->countWeekendAndHolidayWorkedDays(
$employee,
$from,
$to,
array_values($weekdayHolidays)
);
return [
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS),
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS) + $bonusDays,
'acquiredSaturdays' => 0.0,
'accrualPerMonth' => 0.0,
'saturdayAccrualPerMonth' => 0.0,
@@ -495,10 +587,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
];
}
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
/**
* @param null|array<string, string> $publicHolidays pre-built map (built if null)
*/
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to, ?array $publicHolidays = null): int
{
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$count = 0;
$publicHolidays ??= $this->buildPublicHolidayMap($from, $to);
$count = 0;
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
$weekDay = (int) $cursor->format('N');
$dayKey = $cursor->format('Y-m-d');
@@ -544,16 +639,34 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
// Count absence days per month (0.5 for half-days).
// Find which public holidays were actually worked (should count as presence).
$workedHolidays = [] !== $publicHolidays
? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
: [];
// Count absence days per month, iterating day by day to handle multi-day absences
// and properly distribute across months.
$absenceDaysByMonth = [];
foreach ($absences as $absence) {
$date = DateTimeImmutable::createFromInterface($absence->getStartDate());
$monthKey = $date->format('Y-m');
$days = 1.0;
if ($absence->getStartHalf() === $absence->getEndHalf()) {
$days = 0.5;
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
$weekDay = (int) $day->format('N');
// Skip weekends
if ($weekDay >= 6) {
continue;
}
$monthKey = $day->format('Y-m');
[$am, $pm] = $this->resolveSegmentsForDate($absence, $day->format('Y-m-d'));
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($dayAmount <= 0.0) {
continue;
}
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
}
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $days;
}
// Count business days and public holidays per month.
@@ -574,7 +687,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$day = $day->modify('+1 day')
) {
$weekDay = (int) $day->format('N');
if ($weekDay <= 5 && !isset($publicHolidays[$day->format('Y-m-d')])) {
$dayKey = $day->format('Y-m-d');
if ($weekDay <= 5 && (!isset($publicHolidays[$dayKey]) || isset($workedHolidays[$dayKey]))) {
++$businessDays;
}
}

View File

@@ -15,6 +15,7 @@ use App\Entity\User;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
@@ -26,6 +27,8 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeRttSummaryProvider implements ProviderInterface
{
private ?string $rttStartDate;
public function __construct(
private Security $security,
private RequestStack $requestStack,
@@ -34,7 +37,11 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private RttRecoveryComputationService $rttRecoveryService,
) {}
private WorkHourRepository $workHourRepository,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttSummary
{
@@ -72,9 +79,22 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$weeks
);
$limitDate = null;
if ($year > $currentExerciseYear) {
$limitDate = $periodFrom->modify('-1 day');
} else {
// Exclude the current (incomplete) week: limit to last Sunday
$isoDay = (int) $today->format('N'); // 1=Monday .. 7=Sunday
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
}
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
@@ -90,7 +110,15 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$summary->carryBonus50Minutes = $carry->bonus50Minutes;
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
$summary->weeks = array_map(
// Pass rttStartDate only if it falls within this exercise
if (null !== $this->rttStartDate) {
$startDate = new DateTimeImmutable($this->rttStartDate);
if ($startDate >= $periodFrom && $startDate <= $periodTo) {
$summary->rttStartDate = $this->rttStartDate;
}
}
$summary->weeks = array_map(
static function (array $week) use ($currentByWeekStart) {
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
@@ -110,6 +138,37 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$weekRanges
);
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($summary->weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
} else {
$deficit = -$week->totalMinutes;
$from50 = min($deficit, max(0, $cumulative50));
$from25 = $deficit - $from50;
$cumulative50 -= $from50;
$cumulative25 -= $from25;
$summary->weeks[$i] = new EmployeeRttWeekSummary(
month: $week->month,
weekNumber: $week->weekNumber,
weekStart: $week->weekStart,
weekEnd: $week->weekEnd,
overtimeMinutes: $week->overtimeMinutes,
base25Minutes: $from25 > 0 ? -$from25 : 0,
bonus25Minutes: 0,
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
);
}
}
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year);
$monthBuckets = [];
@@ -189,4 +248,25 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
return $month >= 6 ? $year + 1 : $year;
}
/**
* If the employee's contract ends within the current week, cap the check range to that end date.
*/
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
}

View File

@@ -65,7 +65,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
contract: $currentContract,
startDate: $startDate,
endDate: $changeRequest->contractEndDate,
nature: $nature
nature: $nature,
isDriver: $changeRequest->isDriver ?? false,
);
$data->setEntryDate($startDate);
@@ -108,7 +109,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
startDate: $startDate,
endDate: $changeRequest->contractEndDate,
nature: $nature,
todayPeriod: $todayPeriod
todayPeriod: $todayPeriod,
isDriver: $changeRequest->isDriver ?? false,
);
return $result;

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
class LeaveRecapPrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private EmployeeRepository $employeeRepository,
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EntityManagerInterface $entityManager,
private WorkHourRepository $workHourRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$today = new DateTimeImmutable('today');
$employees = $this->employeeRepository->findForPrintBySiteIds([]);
$siteGroups = [];
foreach ($employees as $employee) {
if (!$employee->getHasActiveContract()) {
continue;
}
$site = $employee->getSite();
$siteId = $site ? $site->getId() : 0;
if (!isset($siteGroups[$siteId])) {
$siteGroups[$siteId] = [
'name' => $site ? $site->getName() : 'Sans site',
'color' => $site?->getColor() ?? '#ffd7d7',
'employees' => [],
];
}
$siteGroups[$siteId]['employees'][] = $this->buildEmployeeRow($employee, $today);
$this->entityManager->clear();
}
// Re-load Twig environment after clear
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('leave-recap/print.html.twig', [
'today' => $today,
'siteGroups' => $siteGroups,
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = sprintf('recap_conges_%s.pdf', $today->format('Y-m-d'));
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
private function buildEmployeeRow(Employee $employee, DateTimeImmutable $today): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
if (null !== $yearSummary) {
if ($isForfait) {
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $today));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $today): int
{
$month = (int) $today->format('n');
$year = (int) $today->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\MileageAllowance;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class MileageAllowanceAmountReceiptDownloadProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BinaryFileResponse
{
$mileageAllowance = $this->entityManager->find(MileageAllowance::class, $uriVariables['id']);
if (null === $mileageAllowance) {
throw new NotFoundHttpException('Mileage allowance not found.');
}
$receiptPath = $mileageAllowance->getAmountReceiptPath();
if (null === $receiptPath) {
throw new NotFoundHttpException('No amount receipt found for this mileage allowance.');
}
$absolutePath = sprintf('%s/%s', $this->uploadDir, $receiptPath);
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('Amount receipt file not found.');
}
$response = new BinaryFileResponse($absolutePath);
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$mileageAllowance->getAmountReceiptName() ?? 'justificatif.pdf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\MileageAllowance;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
final readonly class MileageAllowanceAmountReceiptUploadProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private RequestStack $requestStack,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
{
if (!$data instanceof MileageAllowance) {
throw new BadRequestHttpException('Invalid entity.');
}
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (null === $file) {
throw new BadRequestHttpException('No file uploaded.');
}
if ('application/pdf' !== $file->getMimeType()) {
throw new BadRequestHttpException('Only PDF files are accepted.');
}
$month = $data->getMonth();
$year = $month?->format('Y') ?? date('Y');
$monthNumber = $month?->format('m') ?? date('m');
$relativePath = sprintf('mileage-receipts/%s/%s', $year, $monthNumber);
$absoluteDir = sprintf('%s/%s', $this->uploadDir, $relativePath);
if (!is_dir($absoluteDir)) {
mkdir($absoluteDir, 0o755, true);
}
$filename = Uuid::v4()->toRfc4122().'.pdf';
$fullRelative = sprintf('%s/%s', $relativePath, $filename);
$originalName = $file->getClientOriginalName();
$file->move($absoluteDir, $filename);
$data->setAmountReceiptPath($fullRelative);
$data->setAmountReceiptName($originalName);
$this->entityManager->flush();
return new JsonResponse(['path' => $fullRelative, 'name' => $originalName], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\MileageAllowance;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class MileageAllowanceDeleteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$data instanceof MileageAllowance) {
return null;
}
$receiptPath = $data->getReceiptPath();
if (null !== $receiptPath) {
$absolutePath = sprintf('%s/%s', $this->uploadDir, $receiptPath);
if (file_exists($absolutePath)) {
unlink($absolutePath);
}
}
$amountReceiptPath = $data->getAmountReceiptPath();
if (null !== $amountReceiptPath) {
$absolutePath = sprintf('%s/%s', $this->uploadDir, $amountReceiptPath);
if (file_exists($absolutePath)) {
unlink($absolutePath);
}
}
$this->entityManager->remove($data);
$this->entityManager->flush();
return null;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\MileageAllowance;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class MileageAllowanceReceiptDownloadProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BinaryFileResponse
{
$mileageAllowance = $this->entityManager->find(MileageAllowance::class, $uriVariables['id']);
if (null === $mileageAllowance) {
throw new NotFoundHttpException('Mileage allowance not found.');
}
$receiptPath = $mileageAllowance->getReceiptPath();
if (null === $receiptPath) {
throw new NotFoundHttpException('No receipt found for this mileage allowance.');
}
$absolutePath = sprintf('%s/%s', $this->uploadDir, $receiptPath);
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('Receipt file not found.');
}
$response = new BinaryFileResponse($absolutePath);
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$mileageAllowance->getReceiptName() ?? 'justificatif.pdf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\MileageAllowance;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
final readonly class MileageAllowanceReceiptUploadProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private RequestStack $requestStack,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
{
if (!$data instanceof MileageAllowance) {
throw new BadRequestHttpException('Invalid entity.');
}
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (null === $file) {
throw new BadRequestHttpException('No file uploaded.');
}
if ('application/pdf' !== $file->getMimeType()) {
throw new BadRequestHttpException('Only PDF files are accepted.');
}
$month = $data->getMonth();
$year = $month?->format('Y') ?? date('Y');
$monthNumber = $month?->format('m') ?? date('m');
$relativePath = sprintf('mileage-receipts/%s/%s', $year, $monthNumber);
$absoluteDir = sprintf('%s/%s', $this->uploadDir, $relativePath);
if (!is_dir($absoluteDir)) {
mkdir($absoluteDir, 0o755, true);
}
$filename = Uuid::v4()->toRfc4122().'.pdf';
$fullRelative = sprintf('%s/%s', $relativePath, $filename);
$originalName = $file->getClientOriginalName();
$file->move($absoluteDir, $filename);
$data->setReceiptPath($fullRelative);
$data->setReceiptName($originalName);
$this->entityManager->flush();
return new JsonResponse(['path' => $fullRelative, 'name' => $originalName], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,588 @@
<?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\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\BonusRepository;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\MileageAllowanceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use DateInterval;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class SalaryRecapPrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private BonusRepository $bonusRepository,
private MileageAllowanceRepository $mileageAllowanceRepository,
private EmployeeContractResolver $contractResolver,
) {}
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);
}
$month = $request->query->get('month');
if (!$month || !preg_match('/^\d{4}-\d{2}$/', $month)) {
return new Response('Missing or invalid month query param (expected YYYY-MM).', Response::HTTP_BAD_REQUEST);
}
$from = DateTimeImmutable::createFromFormat('Y-m-d', $month.'-01');
$to = $from->modify('last day of this month');
$employees = $this->employeeRepository->findForPrintBySiteIds([]);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
$year = (int) $from->format('Y');
$monthNumber = (int) $from->format('n');
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
$bonuses = $this->bonusRepository->findByMonth($from, $to);
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
$days = $this->buildDays($from, $to);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences);
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
$bonusMap = $this->buildBonusMap($bonuses);
$mileageMap = $this->buildMileageMap($mileages);
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap);
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('salary-recap/print.html.twig', [
'from' => $from,
'to' => $to,
'siteGroups' => $siteGroups,
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'landscape');
$dompdf->render();
$filename = sprintf(
'recap_salaire_%s.pdf',
$from->format('Y-m')
);
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
/**
* @return list<string>
*/
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = [];
$current = $from;
while ($current <= $to) {
$days[] = $current->format('Y-m-d');
$current = $current->add(new DateInterval('P1D'));
}
return $days;
}
/**
* @return array<int, array<string, WorkHour>>
*/
private function buildWorkHourMap(array $workHours): array
{
$map = [];
foreach ($workHours as $wh) {
$employeeId = $wh->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$date = $wh->getWorkDate()->format('Y-m-d');
$map[$employeeId][$date] = $wh;
}
return $map;
}
/**
* @return array<int, list<Absence>>
*/
private function buildAbsenceMap(array $absences): array
{
$map = [];
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$map[$employeeId][] = $absence;
}
return $map;
}
/**
* @return array<int, int>
*/
private function buildRttPaymentMap(array $rttPayments): array
{
$map = [];
foreach ($rttPayments as $payment) {
$employeeId = $payment->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$map[$employeeId] = ($map[$employeeId] ?? 0) + $payment->getBase25Minutes() + $payment->getBase50Minutes();
}
return $map;
}
/**
* @return array<int, float>
*/
private function buildBonusMap(array $bonuses): array
{
$map = [];
foreach ($bonuses as $bonus) {
$employeeId = $bonus->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$map[$employeeId] = ($map[$employeeId] ?? 0.0) + $bonus->getAmount();
}
return $map;
}
/**
* @return array<int, float>
*/
private function buildMileageMap(array $mileages): array
{
$map = [];
foreach ($mileages as $mileage) {
$employeeId = $mileage->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$map[$employeeId] = ($map[$employeeId] ?? 0.0) + $mileage->getKilometers();
}
return $map;
}
private function aggregateBySite(
array $employees,
array $days,
array $contractMap,
array $driverMap,
array $workHourMap,
array $absenceMap,
array $rttPaymentMap,
array $bonusMap,
array $mileageMap,
): array {
$siteGroups = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
$site = $employee->getSite();
$siteName = $site ? $site->getName() : 'Sans site';
$siteId = $site ? $site->getId() : 0;
$row = $this->buildEmployeeRow(
$employee,
$employeeId,
$days,
$contractMap[$employeeId] ?? [],
$driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [],
$absenceMap[$employeeId] ?? [],
$rttPaymentMap[$employeeId] ?? 0,
$bonusMap[$employeeId] ?? 0.0,
$mileageMap[$employeeId] ?? 0.0,
);
if (!isset($siteGroups[$siteId])) {
$siteGroups[$siteId] = [
'name' => $siteName,
'color' => $site?->getColor() ?? '#ffd7d7',
'employees' => [],
];
}
$siteGroups[$siteId]['employees'][] = $row;
}
return $siteGroups;
}
private function buildEmployeeRow(
Employee $employee,
int $employeeId,
array $days,
array $contractsByDate,
array $driverByDate,
array $workHoursByDate,
array $absences,
int $rttPaidMinutes,
float $bonusAmount,
float $mileageKm,
): array {
$contractName = null;
$presenceDays = 0.0;
$nightMinutesTotal = 0;
$nightBasketCount = 0;
$sundayMinutesTotal = 0;
$isDriverAnyDay = false;
$driverBreakfast = 0;
$driverMeals = 0;
$driverOvernight = 0;
$driverSaturdays = 0;
$isForfait = false;
foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
if ($contract && null === $contractName) {
$contractName = $contract->getName();
$isForfait = TrackingMode::PRESENCE === $contract->getTrackingModeEnum();
}
if ($isDriver) {
$isDriverAnyDay = true;
}
if (!$wh) {
continue;
}
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
if ($isDriver) {
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
$dayMin = $wh->getDayHoursMinutes() ?? 0;
$nightMin = $wh->getNightHoursMinutes() ?? 0;
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
++$nightBasketCount;
}
if ($wh->getHasBreakfast()) {
++$driverBreakfast;
}
if ($wh->getHasLunch() || $wh->getHasDinner()) {
++$driverMeals;
}
if ($wh->getHasOvernight()) {
++$driverOvernight;
}
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || ($wh->getWorkshopHoursMinutes() ?? 0) > 0)) {
++$driverSaturdays;
}
if (7 === $dayOfWeek) {
$sundayMinutesTotal += $dayMin + $nightMin + ($wh->getWorkshopHoursMinutes() ?? 0);
}
} else {
$metrics = $this->computeNightMinutes($wh);
$nightMinutesTotal += $metrics['nightMinutes'];
if (($metrics['nightMinutes'] > $metrics['dayMinutes'] && $metrics['nightMinutes'] > 0) || $metrics['nightMinutes'] >= 240) {
++$nightBasketCount;
}
if (7 === $dayOfWeek) {
$sundayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
}
// Samedi : les minutes après minuit débordent sur le dimanche
if (6 === $dayOfWeek) {
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
}
if ($isForfait) {
if ($wh->getIsPresentMorning()) {
$presenceDays += 0.5;
}
if ($wh->getIsPresentAfternoon()) {
$presenceDays += 0.5;
}
}
}
}
$conges = $this->countAbsencesByCode($absences, ['C']);
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
$nightHours = round($nightMinutesTotal / 60, 2);
$paidHours = round($rttPaidMinutes / 60, 2);
$sundayHours = round($sundayMinutesTotal / 60, 2);
return [
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
'firstName' => mb_strimwidth($employee->getFirstName() ?? '', 0, 15, '...'),
'contractName' => $contractName,
'presenceDays' => $presenceDays,
'mileageKm' => $mileageKm,
'nightHours' => $nightHours,
'nightBasketCount' => $nightBasketCount,
'paidHours' => $paidHours,
'sundayHours' => $sundayHours,
'bonusAmount' => $bonusAmount,
'congesCount' => $conges['count'],
'congesDates' => $conges['dates'],
'maladieCount' => $maladie['count'],
'maladieDates' => $maladie['dates'],
'isDriver' => $isDriverAnyDay,
'driverBreakfast' => $driverBreakfast,
'driverMeals' => $driverMeals,
'driverOvernight' => $driverOvernight,
'driverSaturdays' => $driverSaturdays,
];
}
/**
* @return array{nightMinutes: int, dayMinutes: int}
*/
private function computeNightMinutes(WorkHour $workHour): array
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return [
'nightMinutes' => $nightMinutes,
'dayMinutes' => $dayMinutes,
];
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
/**
* Calcule les minutes qui débordent après minuit (> 1440) pour les créneaux d'un WorkHour.
* Ex: créneau soir 21:00-05:00 → interval [1260, 1740] → overflow = 1740-1440 = 300 min (5h).
*/
private function computeOverflowAfterMidnight(WorkHour $workHour): int
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$overflow = 0;
foreach ($ranges as [$from, $to]) {
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
continue;
}
[$start, $end] = $interval;
// Si le créneau dépasse minuit (1440), la partie au-delà est sur le jour suivant
if ($end > 1440) {
$overflow += $end - max($start, 1440);
}
}
return $overflow;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
/**
* @param list<Absence> $absences
* @param list<string> $codes
*
* @return array{count: float, dates: string}
*/
private function countAbsencesByCode(array $absences, array $codes): array
{
$count = 0.0;
$dayKeys = [];
foreach ($absences as $absence) {
$type = $absence->getType();
if (!$type || !in_array($type->getCode(), $codes, true)) {
continue;
}
$startHalf = $absence->getStartHalf();
$endHalf = $absence->getEndHalf();
if ($startHalf === $endHalf) {
$count += 0.5;
} else {
$count += 1.0;
}
$dayKeys[] = $absence->getStartDate()->format('Y-m-d');
}
sort($dayKeys);
$dayKeys = array_unique($dayKeys);
$periods = $this->mergeDaysIntoPeriods($dayKeys);
return [
'count' => $count,
'dates' => implode(', ', $periods),
];
}
/**
* @param list<string> $sortedDates Y-m-d sorted
*
* @return list<string>
*/
private function mergeDaysIntoPeriods(array $sortedDates): array
{
if ([] === $sortedDates) {
return [];
}
$periods = [];
$rangeStart = $sortedDates[0];
$rangeEnd = $sortedDates[0];
for ($i = 1, $len = count($sortedDates); $i < $len; ++$i) {
$prev = new DateTimeImmutable($rangeEnd);
$current = new DateTimeImmutable($sortedDates[$i]);
if (1 === $current->diff($prev)->days) {
$rangeEnd = $sortedDates[$i];
} else {
$periods[] = $this->formatPeriod($rangeStart, $rangeEnd);
$rangeStart = $sortedDates[$i];
$rangeEnd = $sortedDates[$i];
}
}
$periods[] = $this->formatPeriod($rangeStart, $rangeEnd);
return $periods;
}
private function formatPeriod(string $start, string $end): string
{
$s = new DateTimeImmutable($start)->format('d/m');
if ($start === $end) {
return $s;
}
return 'Du '.$s.' au '.new DateTimeImmutable($end)->format('d/m');
}
}

Some files were not shown because too many files have changed in this diff Show More