Compare commits

..

11 Commits

Author SHA1 Message Date
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
74 changed files with 3932 additions and 250 deletions

View File

@@ -21,7 +21,8 @@
"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)"
]
}
}

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

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.34'
app.version: '0.1.39'

View File

@@ -117,6 +117,29 @@ Documents complementaires:
- 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)
- Total (somme jour + nuit, calculé)
- Petit déjeuner (checkbox)
- Déjeuner (checkbox)
- Nuitée (checkbox)
- Stockage backend:
- `dayHoursMinutes` et `nightHoursMinutes` (entiers, minutes) sur `WorkHour`
- `hasBreakfast`, `hasLunch`, `hasOvernight` (booleans) sur `WorkHour`
- les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
- Vue semaine:
- jour/nuit par jour + indicateurs repas/nuitée
- totaux hebdo: jour, nuit, total, compteurs petit déj/déjeuner/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

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

@@ -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,225 @@
<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">Total</span>
<span>Petit déj.</span>
<span>Déjeuner</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 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].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,100 @@
<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.label }}</span>
<span>Jour/Nuit <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>Nuitée</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.hasBreakfast || daily.hasLunch || 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.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.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.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; label: 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

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

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>
@@ -16,10 +16,11 @@
<span>Total <br>récup.</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">
@@ -68,6 +69,7 @@
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,960 @@
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,
formatWeekDayHeaderFr,
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 ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr) repeat(3, 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 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))
}
})
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) => ({ date, label: formatWeekDayHeaderFr(date) }))
})
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: '',
hasBreakfast: false,
hasLunch: 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 dayMinutes = toMinutes(row.dayHours)
const nightMinutes = toMinutes(row.nightHours)
const totalMinutes = dayMinutes + nightMinutes
return { dayMinutes, nightMinutes, 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),
hasBreakfast: workHour?.hasBreakfast ?? false,
hasLunch: workHour?.hasLunch ?? 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,
hasBreakfast: false,
hasLunch: 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)
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false,
dayHoursMinutes: dayMin || null,
nightHoursMinutes: nightMin || null,
hasBreakfast: row.hasBreakfast,
hasLunch: row.hasLunch,
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,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
weeklySummary,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isRowLocked,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -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
@@ -138,19 +139,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 +346,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 +463,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 +698,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 +747,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: [{

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

@@ -28,6 +28,16 @@
<Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p>
</NuxtLink>
<NuxtLink
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,182 @@
<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="visibleEmployees"
: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,
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">
@@ -117,6 +137,33 @@
</div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
</div>
<div v-else-if="activeTab === 'mileage'" class="h-full">
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesMileageTab
v-else
class="h-full"
:allowances="mileageAllowances"
:api-base="mileageApiBase"
@create="submitCreateMileage"
@update="submitUpdateMileage"
@delete="submitDeleteMileage"
/>
</div>
<div v-else-if="activeTab === 'bonus'" class="h-full">
<div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesBonusTab
v-else
class="h-full"
:bonuses="bonuses"
@create="submitCreateBonus"
@update="submitUpdateBonus"
@delete="submitDeleteBonus"
/>
</div>
</div>
</div>
</div>
@@ -173,7 +220,18 @@ const {
addSuspensionForm,
currentActiveContractPeriodId,
isLeaveLoading,
isRttLoading
isRttLoading,
mileageAllowances,
isMileageLoading,
mileageApiBase,
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage,
bonuses,
isBonusLoading,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus
} = useEmployeeDetailPage()
useHead(() => ({

View File

@@ -170,6 +170,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
@@ -246,7 +257,8 @@ const form = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
contractEndDate: '',
isDriver: false
})
const validationTouched = reactive({
@@ -431,7 +443,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 +455,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,6 +499,7 @@ const openCreate = () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
isDrawerOpen.value = true
}

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

@@ -18,6 +18,7 @@ export type ContractHistoryItem = {
comment?: string | null
periodId?: number | null
suspensions?: ContractSuspension[]
isDriver?: boolean
}
export type Employee = {
@@ -26,6 +27,7 @@ export type Employee = {
lastName: string
site: Site
contract?: Contract | null
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,11 @@ export type WorkHour = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
dayHoursMinutes?: number | null
nightHoursMinutes?: number | null
hasBreakfast?: boolean
hasLunch?: boolean
hasOvernight?: boolean
isSiteValid?: boolean
isValid?: boolean
updatedAt?: string | null
@@ -28,6 +33,11 @@ export type WorkHourEntryPayload = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
dayHoursMinutes?: number | null
nightHoursMinutes?: number | null
hasBreakfast?: boolean
hasLunch?: boolean
hasOvernight?: boolean
}
export type WeeklyWorkHourDailySummary = {
@@ -39,6 +49,9 @@ export type WeeklyWorkHourDailySummary = {
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
hasBreakfast?: boolean
hasLunch?: boolean
hasOvernight?: boolean
}
export type WeeklyWorkHourRowSummary = {
@@ -58,6 +71,10 @@ export type WeeklyWorkHourRowSummary = {
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
isDriver?: boolean
weeklyBreakfastCount?: number
weeklyLunchCount?: number
weeklyOvernightCount?: number
}
export type WeeklyWorkHourSummary = {
@@ -77,9 +94,22 @@ 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
hasBreakfast: boolean
hasLunch: 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

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

@@ -15,5 +15,8 @@ final class WeeklyDaySummary
public bool $hasAbsence = false,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
public bool $hasBreakfast = false,
public bool $hasLunch = false,
public bool $hasOvernight = false,
) {}
}

View File

@@ -26,5 +26,9 @@ final class WeeklySummaryRow
public int $weeklyOvertime25Minutes,
public int $weeklyOvertime50Minutes,
public int $weeklyRecoveryMinutes,
public bool $isDriver = false,
public int $weeklyBreakfastCount = 0,
public int $weeklyLunchCount = 0,
public int $weeklyOvernightCount = 0,
) {}
}

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

@@ -88,6 +88,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 +248,24 @@ 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 getIsDriver(): bool
{
return $this->resolveCurrentContractPeriod()?->getIsDriver() ?? false;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
@@ -329,6 +350,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,26 @@ 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: '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 $hasOvernight = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false;
@@ -212,6 +232,66 @@ 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 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 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,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Bonus;
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);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\MileageAllowance;
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);
}
}

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

@@ -565,16 +565,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.
@@ -595,7 +613,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

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

@@ -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,11 +226,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch: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'),
'hasBreakfast' => $this->normalizePresence($entry['hasBreakfast'] ?? false, $employeeId, 'hasBreakfast'),
'hasLunch' => $this->normalizePresence($entry['hasLunch'] ?? false, $employeeId, 'hasLunch'),
'hasOvernight' => $this->normalizePresence($entry['hasOvernight'] ?? false, $employeeId, 'hasOvernight'),
];
}
if ($isPresenceTracking) {
return [
'morningFrom' => null,
@@ -238,6 +264,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
'eveningTo' => null,
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
'dayHoursMinutes' => null,
'nightHoursMinutes' => null,
'hasBreakfast' => false,
'hasLunch' => false,
'hasOvernight' => false,
];
}
@@ -252,6 +283,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
// 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'),
'dayHoursMinutes' => null,
'nightHoursMinutes' => null,
'hasBreakfast' => false,
'hasLunch' => false,
'hasOvernight' => false,
];
}
@@ -281,6 +317,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 +365,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch:bool,
* hasOvernight:bool
* } $entry
*/
private function isEntryEmpty(array $entry): bool
@@ -315,7 +382,12 @@ 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'])
&& false === $entry['hasBreakfast']
&& false === $entry['hasLunch']
&& false === $entry['hasOvernight'];
}
/**
@@ -327,7 +399,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch:bool,
* hasOvernight:bool
* } $entry
*/
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
@@ -341,6 +418,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon'])
->setDayHoursMinutes($entry['dayHoursMinutes'])
->setNightHoursMinutes($entry['nightHoursMinutes'])
->setHasBreakfast($entry['hasBreakfast'])
->setHasLunch($entry['hasLunch'])
->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 +439,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* isPresentAfternoon:bool,
* dayHoursMinutes:?int,
* nightHoursMinutes:?int,
* hasBreakfast:bool,
* hasLunch:bool,
* hasOvernight:bool
* } $entry
*/
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
@@ -369,6 +456,11 @@ 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->getHasBreakfast() === $entry['hasBreakfast']
&& $workHour->getHasLunch() === $entry['hasLunch']
&& $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();
@@ -129,6 +130,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
'metrics' => $this->computeMetrics($workHour),
'isPresentMorning' => $workHour->getIsPresentMorning(),
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
'dayHoursMinutes' => $workHour->getDayHoursMinutes(),
'nightHoursMinutes' => $workHour->getNightHoursMinutes(),
'hasBreakfast' => $workHour->getHasBreakfast(),
'hasLunch' => $workHour->getHasLunch(),
'hasOvernight' => $workHour->getHasOvernight(),
];
}
@@ -156,7 +162,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,15 +185,21 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
continue;
}
$weeklyDayMinutes = 0;
$weeklyNightMinutes = 0;
$weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0;
$daily = [];
$weeklyDayMinutes = 0;
$weeklyNightMinutes = 0;
$weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0;
$weeklyBreakfastCount = 0;
$weeklyLunchCount = 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;
@@ -198,14 +210,42 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
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;
$hasOvernight = false;
if ($isDateDriver) {
$dayMinutes = ($entry['dayHoursMinutes'] ?? 0);
$nightMinutes = ($entry['nightHoursMinutes'] ?? 0);
$totalMinutes = $dayMinutes + $nightMinutes;
$hasBreakfast = $entry['hasBreakfast'] ?? false;
$hasLunch = $entry['hasLunch'] ?? false;
$hasOvernight = $entry['hasOvernight'] ?? false;
if ($hasBreakfast) {
++$weeklyBreakfastCount;
}
if ($hasLunch) {
++$weeklyLunchCount;
}
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;
$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 +254,33 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
$weeklyDayMinutes += $metrics->dayMinutes;
$weeklyNightMinutes += $metrics->nightMinutes;
$weeklyTotalMinutes += $metrics->totalMinutes;
$weeklyDayMinutes += $dayMinutes;
$weeklyNightMinutes += $nightMinutes;
$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,
totalMinutes: $totalMinutes,
present: $present,
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
hasBreakfast: $hasBreakfast,
hasLunch: $hasLunch,
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)
@@ -266,7 +309,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
weeklyRecoveryMinutes: $weeklyRecoveryMinutes,
isDriver: $isDriver,
weeklyBreakfastCount: $weeklyBreakfastCount,
weeklyLunchCount: $weeklyLunchCount,
weeklyOvernightCount: $weeklyOvernightCount,
);
}