Compare commits

..

40 Commits

Author SHA1 Message Date
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
gitea-actions
ff824f233a chore: bump version to v0.1.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-13 10:03:51 +00:00
c4c9dfceab feat : amélioration des perfs de la page employée en séparant les responsabilités et le chargement par onglet
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 11:03:41 +01:00
99 changed files with 6467 additions and 832 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" />

77
CLAUDE.md Normal file
View File

@@ -0,0 +1,77 @@
# 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
- INTERIM: no overtime bonuses, no recovery time
- Driver contracts: no overtime calculation
## 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.32'
app.version: '0.1.50'

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,44 @@ 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
- 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
- pas de calcul d'heures supplémentaires pour les conducteurs
- 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 +182,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,12 +212,14 @@ 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)
- 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`
- 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
@@ -242,10 +286,60 @@ 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) 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-b-black"><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours
</p>
<p class="col-start-2 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Pris :</strong>
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
</p>
<p class="col-start-3 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-b-black"><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]"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
</p>
<p v-else class="col-start-1 p-[10px]"><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]"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.takenSaturdays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours
</p>
<p v-else class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.previousYearTakenDays) }} Jours
</p>
<p v-if="isForfaitRule" class="col-start-3 p-[10px]"><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,268 @@
<template>
<section class="mt-8">
<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>Mois</p>
<p>Nombre de Km</p>
<p>Commentaire</p>
<p>Justificatif</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-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="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p>{{ item.kilometers }}</p>
<p>{{ item.comment ?? '-' }}</p>
<p>
<a
v-if="item.receiptPath"
:href="getReceiptUrl(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"/>
<span>{{ item.receiptName ?? '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 Kms">
<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 <span class="text-red-600">*</span>
</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-receipt">
Justificatif
</label>
<div v-if="isEditing && editingItem?.receiptName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.receiptName }}
</div>
<input
id="mileage-receipt"
ref="fileInput"
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="onFileChange"
/>
<p v-if="fileError" class="mt-1 text-sm text-red-600">{{ fileError }}</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 {getReceiptUrl} 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; comment?: string }, file?: File): void
(event: 'update', id: number, data: { month: string; kilometers: number; comment?: string }, file?: File): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<MileageAllowance | null>(null)
const selectedFile = ref<File | undefined>(undefined)
const fileInput = ref<HTMLInputElement | null>(null)
const fileError = ref('')
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
kilometers: 0,
comment: ''
})
const isFormValid = computed(() => {
return form.month && form.kilometers > 0 && !fileError.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.comment = ''
selectedFile.value = undefined
fileError.value = ''
if (fileInput.value) {
fileInput.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.comment = item.comment ?? ''
selectedFile.value = undefined
if (fileInput.value) {
fileInput.value.value = ''
}
isDrawerOpen.value = true
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
fileError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedFile.value = undefined
target.value = ''
return
}
fileError.value = ''
selectedFile.value = file ?? undefined
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
kilometers: form.kilometers,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data, selectedFile.value)
} else {
emit('create', data, selectedFile.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,7 +22,7 @@
</button>
</div>
<p class="text-[16px]">
<span class="font-semibold">RTT À LA DATE DU JOUR :</span>
<span class="font-bold">RTT À LA DATE DU JOUR :</span>
{{ formatMinutes(summary?.availableMinutes ?? 0) }}
</p>
<div class="flex justify-center">
@@ -40,36 +40,55 @@
<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!.carryBonus25Minutes) }}</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) }}</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!.carryBonus50Minutes) }}</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) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }}</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) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }}</td>
</tr>
<!-- Week rows (always 5) -->
<tr
v-for="(week, idx) in paddedWeeks"
@@ -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) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }}</td>
</tr>
</tbody>
</table>
@@ -290,44 +323,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 +443,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 ---

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

@@ -0,0 +1,347 @@
import type { Ref } from 'vue'
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const toast = useToast()
const contracts = ref<Contract[]>([])
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
isDriver: false
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
hydrateSuspensionForms()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.isDriver = false
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
})
isContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
const submitSuspension = async (index: number) => {
const form = suspensionForms.value[index]
if (!form || !form.startDate) return
const periodId = currentActiveContractPeriodId.value
if (!periodId) return
isSuspensionSubmitting.value = true
try {
if (form.id) {
await updateSuspension(form.id, {
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
} else {
await createSuspension({
contractPeriodId: periodId,
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
}
await reloadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
const loadContracts = async () => {
contracts.value = await listContracts()
}
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
}
})
return {
contracts,
contractHistory,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
loadContracts
}
}

View File

@@ -1,75 +1,13 @@
import type { Contract } from '~/services/dto/contract'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { listContracts } from '~/services/contracts'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
import { getEmployee, updateEmployee } from '~/services/employees'
import { listPublicHolidays } from '~/services/public-holidays'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { getEmployee } from '~/services/employees'
export const useEmployeeDetailPage = () => {
const route = useRoute()
const toast = useToast()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const contracts = ref<Contract[]>([])
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const rttSummary = ref<EmployeeRttSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus'>('contract')
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => {
@@ -80,133 +18,6 @@ export const useEmployeeDetailPage = () => {
return contract.name || '-'
})
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
hydrateSuspensionForms()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const loadEmployee = async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
@@ -216,185 +27,54 @@ export const useEmployeeDetailPage = () => {
isLoading.value = true
try {
const loadedEmployee = await getEmployee(employeeId)
employee.value = loadedEmployee
employee.value = await getEmployee(employeeId)
const now = new Date()
const isForfait = loadedEmployee.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
const rttYear = now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()
const from = isForfait
? `${leaveYear}-01-01`
: `${leaveYear - 1}-06-01`
const to = isForfait
? `${leaveYear}-12-31`
: `${leaveYear}-05-31`
const holidayYears = isForfait
? [leaveYear]
: [leaveYear - 1, leaveYear]
const [absences, summary, rtt, ...holidayResults] = await Promise.all([
listAbsences({
from,
to,
employeeId: loadedEmployee.id
}),
showLeaveTab.value
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
: Promise.resolve(null),
showRttTab.value
? getEmployeeRttSummary(loadedEmployee.id, rttYear)
: Promise.resolve(null),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
leaveSummary.value = summary
rttSummary.value = rtt
publicHolidays.value = Object.assign({}, ...holidayResults)
if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract'
}
if (!showRttTab.value && activeTab.value === 'rtt') {
activeTab.value = 'contract'
}
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
}
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
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)
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
})
isContractDrawerOpen.value = false
await loadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
})
isCreateContractDrawerOpen.value = false
await loadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
const submitSuspension = async (index: number) => {
const form = suspensionForms.value[index]
if (!form || !form.startDate) return
const periodId = currentActiveContractPeriodId.value
if (!periodId) return
isSuspensionSubmitting.value = true
try {
if (form.id) {
await updateSuspension(form.id, {
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
} else {
await createSuspension({
contractPeriodId: periodId,
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
}
await loadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updateFractionedDays(employee.value.id, days, year)
await loadEmployee()
}
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year)
await loadEmployee()
}
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
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()
}
})
onMounted(async () => {
contracts.value = await listContracts()
await contract.loadContracts()
await loadEmployee()
})
@@ -402,50 +82,13 @@ export const useEmployeeDetailPage = () => {
employee,
isLoading,
activeTab,
contracts,
employeeAbsences,
leaveSummary,
rttSummary,
publicHolidays,
showLeaveTab,
showRttTab,
contractHistory,
employeeContractWorkLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId
...contract,
...leave,
...rtt,
...mileage,
...bonus
}
}

View File

@@ -0,0 +1,70 @@
import type { Ref } from 'vue'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays'
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isLeaveLoading = ref(false)
const leaveDataLoaded = ref(false)
const getLeaveYear = () => {
const now = new Date()
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
return isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
}
const loadLeaveData = async () => {
if (!employee.value || isLeaveLoading.value) return
isLeaveLoading.value = true
try {
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = getLeaveYear()
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
const [absences, summary, ...holidayResults] = await Promise.all([
listAbsences({ from, to, employeeId: employee.value.id }),
getEmployeeLeaveSummary(employee.value.id, leaveYear),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
leaveSummary.value = summary
publicHolidays.value = Object.assign({}, ...holidayResults)
leaveDataLoaded.value = true
} finally {
isLeaveLoading.value = false
}
}
const resetLoaded = () => {
leaveDataLoaded.value = false
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updateFractionedDays(employee.value.id, days, year)
await reloadEmployee()
}
return {
employeeAbsences,
leaveSummary,
publicHolidays,
isLeaveLoading,
leaveDataLoaded,
loadLeaveData,
resetLoaded,
submitFractionedDays
}
}

View File

@@ -0,0 +1,73 @@
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,
uploadReceipt
} 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; comment?: string }, file?: File) => {
if (!employee.value) return
const result = await createMileageAllowance({
employeeId: employee.value.id,
month: data.month,
kilometers: data.kilometers,
comment: data.comment
})
if (file && result?.id) {
await uploadReceipt(apiBase, result.id, file)
}
await reloadEmployee()
}
const submitUpdateMileage = async (id: number, data: { month: string; kilometers: number; comment?: string }, file?: File) => {
await updateMileageAllowance(id, data)
if (file) {
await uploadReceipt(apiBase, id, file)
}
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

@@ -0,0 +1,42 @@
import type { Ref } from 'vue'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { Employee } from '~/services/dto/employee'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const rttSummary = ref<EmployeeRttSummary | null>(null)
const isRttLoading = ref(false)
const rttDataLoaded = ref(false)
const loadRttData = async () => {
if (!employee.value || isRttLoading.value) return
isRttLoading.value = true
try {
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
rttDataLoaded.value = true
} finally {
isRttLoading.value = false
}
}
const resetLoaded = () => {
rttDataLoaded.value = false
}
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year)
await reloadEmployee()
}
return {
rttSummary,
isRttLoading,
rttDataLoaded,
loadRttData,
resetLoaded,
submitRttPayment
}
}

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:car-outline" size="24" class="align-self"/>
Frais Kms
</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">
@@ -98,15 +118,52 @@
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
/>
<EmployeesLeaveTab
v-else-if="showLeaveTab && activeTab === 'leave'"
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
/>
<EmployeesRttTab v-else-if="showRttTab && activeTab === 'rtt'" class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesLeaveTab
v-else
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
/>
</div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</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>
@@ -161,7 +218,20 @@ const {
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId
currentActiveContractPeriodId,
isLeaveLoading,
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,9 @@
export type MileageAllowance = {
id: number
month: string
kilometers: number
comment: string | null
receiptPath: string | null
receiptName: 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,69 @@
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
comment?: string
}) => {
const api = useApi()
return api.post<MileageAllowance>('/mileage_allowances', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
kilometers: data.kilometers,
comment: data.comment
}, {
toastSuccessKey: 'success.mileage.create',
toastErrorKey: 'errors.mileage.create'
})
}
export const updateMileageAllowance = async (id: number, data: {
month: string
kilometers: number
comment?: string
}) => {
const api = useApi()
return api.patch<MileageAllowance>(`/mileage_allowances/${id}`, {
month: data.month,
kilometers: data.kilometers,
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 uploadReceipt = 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 getReceiptUrl = (baseURL: string, id: number): string => {
return `${baseURL}/mileage_allowances/${id}/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

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

@@ -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_USER')"
),
new GetCollection(
security: "is_granted('ROLE_USER')"
),
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,192 @@
<?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\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_USER')"
),
new GetCollection(
security: "is_granted('ROLE_USER')"
),
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_USER')",
provider: MileageAllowanceReceiptDownloadProvider::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: '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: '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 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 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

@@ -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,43 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return $result;
}
/**
* 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 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

@@ -278,10 +278,11 @@ final readonly class LeaveBalanceComputationService
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) : [];
$coveredMonths = 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,11 +290,19 @@ 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);
$coveredMonths += 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;
@@ -317,8 +326,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

@@ -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');
@@ -203,7 +214,7 @@ final readonly class RttRecoveryComputationService
$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);

View File

@@ -91,16 +91,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 +120,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;
}
@@ -214,6 +220,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 +248,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 +286,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,
@@ -359,10 +390,11 @@ 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) : [];
$coveredMonths = 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,11 +402,19 @@ 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);
$coveredMonths += 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;
@@ -495,10 +535,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 +587,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 +635,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

@@ -26,6 +26,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 +36,10 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private RttRecoveryComputationService $rttRecoveryService,
) {}
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttSummary
{
@@ -72,9 +77,12 @@ 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');
}
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
@@ -90,7 +98,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 +126,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 = [];

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,181 @@
<?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\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,
) {}
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');
// 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->getBase50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
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,42 @@
<?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);
}
}
$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');
}
}

View File

@@ -95,7 +95,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
));
}
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$isDriver = $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate);
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking, $isDriver);
$existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
$isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
@@ -134,13 +135,15 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
continue;
}
$is4hContract = 4 === $contract->getWeeklyHours();
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {
$this->entityManager->remove($existing);
++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true) {
// Si une absence existe ce jour, on garde une ligne technique pour pouvoir valider la journée.
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
// Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour()
->setEmployee($employee)
->setWorkDate($workDate)
@@ -223,21 +226,55 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* workshopHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch:bool,
* hasDinner:bool,
* hasOvernight:bool
* }
*/
private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking): array
private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking, bool $isDriver): array
{
if ($isDriver) {
return [
'morningFrom' => null,
'morningTo' => null,
'afternoonFrom' => null,
'afternoonTo' => null,
'eveningFrom' => null,
'eveningTo' => null,
'isPresentMorning' => false,
'isPresentAfternoon' => false,
'dayHoursMinutes' => $this->normalizeMinutes($entry['dayHoursMinutes'] ?? null, $employeeId, 'dayHoursMinutes'),
'nightHoursMinutes' => $this->normalizeMinutes($entry['nightHoursMinutes'] ?? null, $employeeId, 'nightHoursMinutes'),
'workshopHoursMinutes' => $this->normalizeMinutes($entry['workshopHoursMinutes'] ?? null, $employeeId, 'workshopHoursMinutes'),
'hasBreakfast' => $this->normalizePresence($entry['hasBreakfast'] ?? false, $employeeId, 'hasBreakfast'),
'hasLunch' => $this->normalizePresence($entry['hasLunch'] ?? false, $employeeId, 'hasLunch'),
'hasDinner' => $this->normalizePresence($entry['hasDinner'] ?? false, $employeeId, 'hasDinner'),
'hasOvernight' => $this->normalizePresence($entry['hasOvernight'] ?? false, $employeeId, 'hasOvernight'),
];
}
if ($isPresenceTracking) {
return [
'morningFrom' => null,
'morningTo' => null,
'afternoonFrom' => null,
'afternoonTo' => null,
'eveningFrom' => null,
'eveningTo' => null,
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
'morningFrom' => null,
'morningTo' => null,
'afternoonFrom' => null,
'afternoonTo' => null,
'eveningFrom' => null,
'eveningTo' => null,
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
'dayHoursMinutes' => null,
'nightHoursMinutes' => null,
'workshopHoursMinutes' => null,
'hasBreakfast' => false,
'hasLunch' => false,
'hasDinner' => false,
'hasOvernight' => false,
];
}
@@ -250,8 +287,15 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
// On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
// même si le contrat résolu ce jour est en suivi horaire.
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
'dayHoursMinutes' => null,
'nightHoursMinutes' => null,
'workshopHoursMinutes' => null,
'hasBreakfast' => false,
'hasLunch' => false,
'hasDinner' => false,
'hasOvernight' => false,
];
}
@@ -281,6 +325,32 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
return $time;
}
private function normalizeMinutes(mixed $value, int $employeeId, string $field): ?int
{
if (null === $value || '' === $value) {
return null;
}
if (!is_int($value) && !is_float($value)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s must be an integer (minutes).',
$employeeId,
$field
));
}
$minutes = (int) $value;
if ($minutes < 0) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s must be >= 0.',
$employeeId,
$field
));
}
return $minutes;
}
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
{
if (!is_bool($value)) {
@@ -303,7 +373,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* workshopHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch:bool,
* hasDinner:bool,
* hasOvernight:bool
* } $entry
*/
private function isEntryEmpty(array $entry): bool
@@ -315,7 +392,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
&& null === $entry['eveningFrom']
&& null === $entry['eveningTo']
&& false === $entry['isPresentMorning']
&& false === $entry['isPresentAfternoon'];
&& false === $entry['isPresentAfternoon']
&& (null === $entry['dayHoursMinutes'] || 0 === $entry['dayHoursMinutes'])
&& (null === $entry['nightHoursMinutes'] || 0 === $entry['nightHoursMinutes'])
&& (null === $entry['workshopHoursMinutes'] || 0 === $entry['workshopHoursMinutes'])
&& false === $entry['hasBreakfast']
&& false === $entry['hasLunch']
&& false === $entry['hasDinner']
&& false === $entry['hasOvernight'];
}
/**
@@ -327,7 +411,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* workshopHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch:bool,
* hasDinner:bool,
* hasOvernight:bool
* } $entry
*/
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
@@ -341,6 +432,13 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon'])
->setDayHoursMinutes($entry['dayHoursMinutes'])
->setNightHoursMinutes($entry['nightHoursMinutes'])
->setWorkshopHoursMinutes($entry['workshopHoursMinutes'])
->setHasBreakfast($entry['hasBreakfast'])
->setHasLunch($entry['hasLunch'])
->setHasDinner($entry['hasDinner'])
->setHasOvernight($entry['hasOvernight'])
// Toute modification invalide la validation chef de site.
->setIsSiteValid(false)
// Toute modification utilisateur repasse la ligne en attente de validation RH.
@@ -357,7 +455,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* workshopHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch:bool,
* hasDinner:bool,
* hasOvernight:bool
* } $entry
*/
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
@@ -369,6 +474,13 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
&& $workHour->getEveningFrom() === $entry['eveningFrom']
&& $workHour->getEveningTo() === $entry['eveningTo']
&& $workHour->getIsPresentMorning() === $entry['isPresentMorning']
&& $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon'];
&& $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon']
&& $workHour->getDayHoursMinutes() === $entry['dayHoursMinutes']
&& $workHour->getNightHoursMinutes() === $entry['nightHoursMinutes']
&& $workHour->getWorkshopHoursMinutes() === $entry['workshopHoursMinutes']
&& $workHour->getHasBreakfast() === $entry['hasBreakfast']
&& $workHour->getHasLunch() === $entry['hasLunch']
&& $workHour->getHasDinner() === $entry['hasDinner']
&& $workHour->getHasOvernight() === $entry['hasOvernight'];
}
}

View File

@@ -52,9 +52,11 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
}
// On initialise toutes les lignes, même sans absence ce jour-là.
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
hasContractAtDate: null !== $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate)
hasContractAtDate: null !== $contract,
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
);
}

View File

@@ -116,6 +116,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
@@ -126,9 +127,16 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
// Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale.
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByEmployeeDate[$employeeId][$dateKey] = [
'metrics' => $this->computeMetrics($workHour),
'isPresentMorning' => $workHour->getIsPresentMorning(),
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
'metrics' => $this->computeMetrics($workHour),
'isPresentMorning' => $workHour->getIsPresentMorning(),
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
'dayHoursMinutes' => $workHour->getDayHoursMinutes(),
'nightHoursMinutes' => $workHour->getNightHoursMinutes(),
'workshopHoursMinutes' => $workHour->getWorkshopHoursMinutes(),
'hasBreakfast' => $workHour->getHasBreakfast(),
'hasLunch' => $workHour->getHasLunch(),
'hasDinner' => $workHour->getHasDinner(),
'hasOvernight' => $workHour->getHasOvernight(),
];
}
@@ -156,7 +164,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($absentMorning || $absentAfternoon) {
$absenceByEmployeeDate[$employeeId][$date] = true;
$absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
$absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
@@ -179,33 +187,82 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
continue;
}
$weeklyDayMinutes = 0;
$weeklyNightMinutes = 0;
$weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0;
$daily = [];
$weeklyDayMinutes = 0;
$weeklyNightMinutes = 0;
$weeklyWorkshopMinutes = 0;
$weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0;
$weeklyNightBasketCount = 0;
$weeklyBreakfastCount = 0;
$weeklyLunchCount = 0;
$weeklyDinnerCount = 0;
$weeklyOvernightCount = 0;
$daily = [];
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractsByEmployeeDate[$employeeId][$days[0]]
?? null;
$isDriver = $isDriverByEmployeeDate[$employeeId][$anchorDateYmd]
?? $isDriverByEmployeeDate[$employeeId][$days[0]]
?? false;
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
?? ContractNature::CDI;
$employeeContractsByDate = [];
$hasContractForWeek = false;
foreach ($days as $date) {
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
if (null !== $employeeContractsByDate[$date]) {
$hasContractForWeek = true;
}
}
foreach ($days as $date) {
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
$metrics = $entry['metrics'] ?? new WorkMetrics();
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
$contractAtDate = $employeeContractsByDate[$date] ?? null;
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
// Les absences "comptées comme travaillées" alimentent le total du jour.
$metrics->addCreditedMinutes($creditedMinutes);
$isDateDriver = $isDriverByEmployeeDate[$employeeId][$date] ?? false;
$hasBreakfast = false;
$hasLunch = false;
$hasDinner = false;
$hasOvernight = false;
if ($isDateDriver) {
$dayMinutes = ($entry['dayHoursMinutes'] ?? 0);
$nightMinutes = ($entry['nightHoursMinutes'] ?? 0);
$workshopMinutes = ($entry['workshopHoursMinutes'] ?? 0);
$totalMinutes = $dayMinutes + $nightMinutes + $workshopMinutes + $creditedMinutes;
$dayMinutes += $creditedMinutes;
$hasBreakfast = $entry['hasBreakfast'] ?? false;
$hasLunch = $entry['hasLunch'] ?? false;
$hasDinner = $entry['hasDinner'] ?? false;
$hasOvernight = $entry['hasOvernight'] ?? false;
if ($hasBreakfast) {
++$weeklyBreakfastCount;
}
if ($hasLunch) {
++$weeklyLunchCount;
}
if ($hasDinner) {
++$weeklyDinnerCount;
}
if ($hasOvernight) {
++$weeklyOvernightCount;
}
} else {
$metrics = $entry['metrics'] ?? new WorkMetrics();
// Les absences "comptées comme travaillées" alimentent le total du jour.
$metrics->addCreditedMinutes($creditedMinutes);
$dayMinutes = $metrics->dayMinutes;
$nightMinutes = $metrics->nightMinutes;
$workshopMinutes = 0;
$totalMinutes = $metrics->totalMinutes;
}
$present = null;
if ($isPresenceTracking) {
if ($isPresenceTracking && !$isDateDriver) {
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
@@ -214,30 +271,42 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
$weeklyDayMinutes += $metrics->dayMinutes;
$weeklyNightMinutes += $metrics->nightMinutes;
$weeklyTotalMinutes += $metrics->totalMinutes;
$hasNightBasket = ($nightMinutes > $dayMinutes && $nightMinutes > 0) || $nightMinutes >= 240;
if ($hasNightBasket) {
++$weeklyNightBasketCount;
}
$weeklyDayMinutes += $dayMinutes;
$weeklyNightMinutes += $nightMinutes;
$weeklyWorkshopMinutes += $workshopMinutes;
$weeklyTotalMinutes += $totalMinutes;
if (null !== $present) {
$weeklyPresenceCount += $present;
}
$daily[] = new WeeklyDaySummary(
date: $date,
dayMinutes: $metrics->dayMinutes,
nightMinutes: $metrics->nightMinutes,
totalMinutes: $metrics->totalMinutes,
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
workshopMinutes: $workshopMinutes,
totalMinutes: $totalMinutes,
present: $present,
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
hasNightBasket: $hasNightBasket,
hasBreakfast: $hasBreakfast,
hasLunch: $hasLunch,
hasDinner: $hasDinner,
hasOvernight: $hasOvernight,
);
}
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
$disableOvertimeBonuses = $isDriver || $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
$weeklyOvertimeTotalMinutes = ($isWeekPresenceTracking || $isDriver)
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
@@ -261,12 +330,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
daily: $daily,
weeklyDayMinutes: $weeklyDayMinutes,
weeklyNightMinutes: $weeklyNightMinutes,
weeklyWorkshopMinutes: $weeklyWorkshopMinutes,
weeklyTotalMinutes: $weeklyTotalMinutes,
weeklyPresenceCount: $weeklyPresenceCount,
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
weeklyRecoveryMinutes: $weeklyRecoveryMinutes,
weeklyNightBasketCount: $weeklyNightBasketCount,
isDriver: $isDriver,
weeklyBreakfastCount: $weeklyBreakfastCount,
weeklyLunchCount: $weeklyLunchCount,
weeklyDinnerCount: $weeklyDinnerCount,
weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek,
);
}

View File

@@ -0,0 +1,124 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Récapitulatif Congés & RTT</title>
<style>
@page { size: A4 portrait; margin: 4mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 10px;
}
.title-bar {
position: relative;
margin: 0 0 6mm 0;
}
h1 {
text-align: center;
font-size: 18px;
margin: 0;
}
.date-box {
position: absolute;
top: 0;
right: 0;
border: 2px solid #000;
padding: 4px 12px;
font-size: 14px;
font-weight: 700;
}
table.recap {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 4px solid #0a0a0a;
}
th, td {
border: 2px solid #0a0a0a;
padding: 3px 5px;
vertical-align: middle;
overflow: hidden;
white-space: nowrap;
}
.site-header td {
font-weight: 700;
font-size: 12px;
text-align: center;
}
thead th {
text-align: center;
font-weight: 700;
font-size: 10px;
white-space: normal;
}
td.name {
text-align: left;
font-weight: bold;
}
td.base { text-align: center; }
td.num { text-align: center; }
td.obs { min-width: 40mm; }
tbody td { font-size: 10px; }
</style>
</head>
<body>
<div class="title-bar">
<h1>RECAPITULATIF CONGES & RTT</h1>
<div class="date-box">{{ today|date('d/m/Y') }}</div>
</div>
<table class="recap">
<thead>
<tr>
<th style="text-align: left;">Nom</th>
<th>Contrat</th>
<th>CP N-1<br>restant</th>
<th>Samedi<br>restant</th>
<th>CP<br>N</th>
<th>RTT</th>
<th style="width: 40mm;">Observations</th>
</tr>
</thead>
<tbody>
{% for siteId, group in siteGroups %}
{% set siteColor = group.color ?? '#B3E5FC' %}
<tr class="site-header">
<td style="background: {{ siteColor }}; text-align: left;" colspan="7">
{{ group.name }}
</td>
</tr>
{% for row in group.employees %}
<tr>
<td class="name">{{ row.lastName }} {{ row.firstName }}</td>
<td class="base">{{ row.contractName ?? '' }}</td>
<td class="num">{{ row.cpN1Remaining }}</td>
<td class="num">{{ row.acquiredSaturdays }}</td>
<td class="num">{{ row.cpN }}</td>
<td class="num">{{ row.rtt }}</td>
<td class="obs"></td>
</tr>
{% else %}
<tr>
<td colspan="7">Aucun employé.</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1,163 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Récapitulatif Salaire</title>
<style>
@page { size: A4 landscape; margin: 4mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 10px;
}
.title-bar {
position: relative;
margin: 0 0 6mm 0;
}
h1 {
text-align: center;
font-size: 18px;
margin: 0;
}
.month-box {
position: absolute;
top: 0;
right: 0;
border: 2px solid #000;
padding: 4px 12px;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
}
table.recap {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 4px solid #0a0a0a;
}
th, td {
border: 2px solid #0a0a0a;
padding: 3px 3px;
vertical-align: middle;
overflow: hidden;
white-space: nowrap;
}
.site-header td {
font-weight: 700;
font-size: 12px;
text-align: center;
}
thead th {
text-align: center;
font-weight: 700;
font-size: 10px;
white-space: normal;
}
td.name {
text-align: left;
font-weight: bold;
}
td.base { text-align: center; }
td.num { text-align: center; }
td.dates {
text-align: left;
white-space: normal;
word-break: break-word;
font-size: 10px;
}
td.obs { }
tbody td { font-size: 10px; }
</style>
</head>
<body>
{% set months = {
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
} %}
<div class="title-bar">
<h1>RECAPITULATIF SALAIRE DU {{ from|date('d/m/Y') }} au {{ to|date('d/m/Y') }}</h1>
<div class="month-box">{{ months[from|date('n')|number_format] }} {{ from|date('Y') }}</div>
</div>
<table class="recap">
<thead>
<tr>
<th rowspan="2" style="width: 24mm; text-align: left;">Nom</th>
<th rowspan="2" style="width: 12mm;">Base</th>
<th rowspan="2" style="width: 12mm;">Jour de<br>présence<br>Cadre</th>
<th rowspan="2" style="width: 9mm;">Frais<br>Kms</th>
<th rowspan="2" style="width: 9mm;">Heures<br>de<br>nuit</th>
<th rowspan="2" style="width: 9mm;">Panier<br>de<br>nuit</th>
<th rowspan="2" style="width: 12mm;">Heures<br>payés</th>
<th rowspan="2" style="width: 9mm;">Heures<br>dim.</th>
<th rowspan="2" style="width: 9mm;">Prime</th>
<th colspan="2">Congés</th>
<th colspan="2">Maladie</th>
<th colspan="4">CHAUFFEUR</th>
<th rowspan="2" style="width: 26mm;">Observations</th>
</tr>
<tr>
<th style="width: 10mm;">Nbre</th>
<th style="width: 26mm;">Date</th>
<th style="width: 10mm;">Nbre</th>
<th style="width: 26mm;">Date</th>
<th style="width: 8mm;">PDJ</th>
<th style="width: 10mm;">REPAS</th>
<th style="width: 12mm;">NUITEE</th>
<th style="width: 12mm;">samedi</th>
</tr>
</thead>
<tbody>
{% for siteId, group in siteGroups %}
{% set siteColor = group.color ?? '#B3E5FC' %}
<tr class="site-header">
<td style="background: {{ siteColor }}; text-align: left;" colspan="18">
{{ group.name }}
</td>
</tr>
{% for row in group.employees %}
<tr>
<td class="name">{{ row.lastName }}<br>{{ row.firstName }}</td>
<td class="base">{{ row.contractName ?? '' }}</td>
<td class="num">{{ row.presenceDays > 0 ? row.presenceDays : '' }}</td>
<td class="num">{{ row.mileageKm > 0 ? row.mileageKm : '' }}</td>
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount : '' }}</td>
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
<td class="dates">{{ row.congesDates }}</td>
<td class="num">{{ row.maladieCount > 0 ? row.maladieCount : '' }}</td>
<td class="dates">{{ row.maladieDates }}</td>
<td class="num">{{ row.isDriver and row.driverBreakfast > 0 ? row.driverBreakfast : '' }}</td>
<td class="num">{{ row.isDriver and row.driverMeals > 0 ? row.driverMeals : '' }}</td>
<td class="num">{{ row.isDriver and row.driverOvernight > 0 ? row.driverOvernight : '' }}</td>
<td class="num">{{ row.isDriver and row.driverSaturdays > 0 ? row.driverSaturdays : '' }}</td>
<td class="obs"></td>
</tr>
{% else %}
<tr>
<td colspan="18">Aucun employé.</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</body>
</html>