Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1feedd0381 | ||
| f9cd5a0143 | |||
|
|
ede7decaa7 | ||
| 2cfb05e5de | |||
|
|
0a8399a950 | ||
| 6a64cb4c58 | |||
|
|
facded4c55 | ||
| 9787231052 | |||
|
|
8563ddb08c | ||
| 353d4d9d2b | |||
|
|
8745e5e425 | ||
| 4d8c850a77 | |||
| 1974ace1f2 | |||
|
|
a99a12a759 | ||
| 548b5d63a6 | |||
|
|
ed9df4e178 | ||
| 625b4af5ba | |||
|
|
2ec3044cb3 | ||
| f024a6a8de | |||
|
|
a60294a8f7 | ||
| dd7f9ef8a0 | |||
| cfa7d25521 | |||
|
|
5faa0facca | ||
| 04f90afc58 | |||
|
|
e022cfac98 | ||
| e827128392 | |||
| 86cdec50c6 | |||
|
|
443ed1e003 | ||
| cef364fcec | |||
|
|
d4884bc489 | ||
| b93c4bf3e9 | |||
|
|
f0ee489c26 | ||
| 01f8058f56 | |||
|
|
3d26d6b50f | ||
| 339d650b41 | |||
|
|
43957903b0 | ||
| d455bb77a3 | |||
|
|
8b20632ab8 | ||
| 0cc2b2730a | |||
|
|
c35edb9a1c | ||
| 4b04be1d1b | |||
| b24dd8595d | |||
|
|
96185e2334 | ||
| 7d53000fc2 | |||
|
|
c317a2a026 | ||
| 8846e83df1 | |||
|
|
ff824f233a | ||
| c4c9dfceab | |||
|
|
ca6597cd38 | ||
| 4a2c3a8eed | |||
|
|
1858817649 | ||
| 99f0f191f4 | |||
| 96617f04bc |
@@ -21,7 +21,10 @@
|
|||||||
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
|
"Bash(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(which python3:*)",
|
||||||
"Bash(sudo apt-get:*)",
|
"Bash(sudo apt-get:*)",
|
||||||
"Bash(npx xlsx-cli:*)"
|
"Bash(npx xlsx-cli:*)",
|
||||||
|
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
|
||||||
|
"Bash(pip3 install:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
.env
4
.env
@@ -36,6 +36,10 @@ DEFAULT_URI=http://localhost
|
|||||||
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
|
###> app ###
|
||||||
|
RTT_START_DATE=2026-02-23
|
||||||
|
###< app ###
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> nelmio/cors-bundle ###
|
||||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||||
###< nelmio/cors-bundle ###
|
###< nelmio/cors-bundle ###
|
||||||
|
|||||||
2
.idea/SIRH.iml
generated
2
.idea/SIRH.iml
generated
@@ -154,6 +154,8 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/var" />
|
<excludeFolder url="file://$MODULE_DIR$/var" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
|
<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/doctrine/doctrine-fixtures-bundle" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
2
.idea/php.xml
generated
2
.idea/php.xml
generated
@@ -155,6 +155,8 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
<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>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||||
|
|||||||
77
CLAUDE.md
Normal file
77
CLAUDE.md
Normal 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
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/http-client": "8.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
|
|||||||
175
composer.lock
generated
175
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "b540b6cb25ef55c5eebccb57c76da584",
|
"content-hash": "bdc04f5145303388bac52809ea3f4b05",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -5374,6 +5374,92 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-28T10:46:31+00:00"
|
"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",
|
"name": "symfony/monolog-bridge",
|
||||||
"version": "v8.0.4",
|
"version": "v8.0.4",
|
||||||
@@ -5685,6 +5771,93 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-06-27T09:58:17+00:00"
|
"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",
|
"name": "symfony/polyfill-intl-normalizer",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$holidayUrl: '%env(HOLIDAY_URL)%'
|
$holidayUrl: '%env(HOLIDAY_URL)%'
|
||||||
|
|
||||||
|
App\Service\Rtt\RttRecoveryComputationService:
|
||||||
|
arguments:
|
||||||
|
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
|
App\State\EmployeeRttSummaryProvider:
|
||||||
|
arguments:
|
||||||
|
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.30'
|
app.version: '0.1.54'
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ Documents complementaires:
|
|||||||
|
|
||||||
## 3) Heures (vue jour)
|
## 3) Heures (vue jour)
|
||||||
|
|
||||||
|
- Visibilité des employés:
|
||||||
|
- vue jour: un employé sans contrat à la date sélectionnée est masqué
|
||||||
|
- vue semaine: un employé sans contrat sur aucun jour de la semaine est masqué
|
||||||
|
- même règle pour les heures classiques et les heures conducteurs
|
||||||
- Saisie par salarié et par date:
|
- Saisie par salarié et par date:
|
||||||
- matin / après-midi / soir
|
- matin / après-midi / soir
|
||||||
- pour `PRESENCE`: demi-journées matin/après-midi
|
- pour `PRESENCE`: demi-journées matin/après-midi
|
||||||
@@ -112,11 +116,44 @@ Documents complementaires:
|
|||||||
- contrats >= 39h: de 39h à 43h
|
- contrats >= 39h: de 39h à 43h
|
||||||
- Tranche 50%:
|
- Tranche 50%:
|
||||||
- au-delà de 43h
|
- au-delà de 43h
|
||||||
|
- Date de début RTT (`RTT_START_DATE` dans `.env`):
|
||||||
|
- les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération
|
||||||
|
- permet d'éviter les déficits fictifs avant la mise en service du logiciel
|
||||||
|
- Semaine en déficit (heures travaillées < heures contrat):
|
||||||
|
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
|
||||||
|
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
|
||||||
- Nature `INTERIM`:
|
- Nature `INTERIM`:
|
||||||
- pas de bonus 25%
|
- pas de bonus 25%
|
||||||
- pas de bonus 50%
|
- pas de bonus 50%
|
||||||
- pas de total récup
|
- pas de total récup
|
||||||
|
|
||||||
|
## 6bis) Heures Conducteurs
|
||||||
|
|
||||||
|
- Écran dédié `/driver-hours` pour les employés dont le contrat est marqué `isDriver = true`
|
||||||
|
- Les conducteurs sont exclus de l'écran `/hours` classique
|
||||||
|
- Colonnes spécifiques (vue jour):
|
||||||
|
- Heure de jour (durée HH:MM via TimeSelect)
|
||||||
|
- Heure de nuit (durée HH:MM via TimeSelect)
|
||||||
|
- Heure atelier (durée HH:MM via TimeSelect)
|
||||||
|
- Total (somme jour + nuit + atelier, calculé)
|
||||||
|
- Petit déjeuner (checkbox)
|
||||||
|
- Déjeuner (checkbox)
|
||||||
|
- Dîner (checkbox)
|
||||||
|
- Nuitée (checkbox)
|
||||||
|
- Stockage backend:
|
||||||
|
- `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour`
|
||||||
|
- `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour`
|
||||||
|
- les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
|
||||||
|
- Absences `countAsWorkedHours=true`: les minutes créditées sont ajoutées aux heures de jour (vue jour et vue semaine), même logique que les employés classiques
|
||||||
|
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
|
||||||
|
- Vue semaine:
|
||||||
|
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
|
||||||
|
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
|
||||||
|
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
|
||||||
|
- pas de calcul d'heures supplémentaires pour les conducteurs
|
||||||
|
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
|
||||||
|
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
|
||||||
|
|
||||||
## 7) Fériés
|
## 7) Fériés
|
||||||
|
|
||||||
- Les jours fériés sont identifiés et affichés
|
- Les jours fériés sont identifiés et affichés
|
||||||
@@ -145,6 +182,11 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- Modification employé:
|
- Modification employé:
|
||||||
- uniquement prénom, nom, site
|
- uniquement prénom, nom, site
|
||||||
- pas de modification de contrat depuis ce drawer
|
- pas de modification de contrat depuis ce drawer
|
||||||
|
- Liste employés — filtre par statut de contrat:
|
||||||
|
- 3 options: "Avec contrat" (défaut), "Sans contrat", "Tous"
|
||||||
|
- "Avec contrat": employés ayant une période de contrat active à la date du jour
|
||||||
|
- "Sans contrat": employés sans période de contrat active
|
||||||
|
- "Tous": aucun filtrage sur le contrat
|
||||||
- Détail employé:
|
- Détail employé:
|
||||||
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
|
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
|
||||||
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
|
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
|
||||||
@@ -170,12 +212,19 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
|
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
|
||||||
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
|
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
|
||||||
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||||
|
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22 (standard mensuel)
|
||||||
|
- arrêt maladie long (absences continues de type `M` > 1 mois):
|
||||||
|
- premier mois de maladie (date début + 1 mois calendaire): acquisition normale (`2,50`/mois)
|
||||||
|
- après le premier mois: acquisition réduite à `2,00`/mois (facteur `0,80` appliqué aux deux taux jours et samedis)
|
||||||
|
- en cas de mois partiellement couvert par la période réduite, le prorata est calculé en jours calendaires (jours normaux × taux normal + jours réduits × taux réduit)
|
||||||
|
- la détection est automatique à partir des absences MALADIE consécutives en base (tolérance de gap ≤ 3 jours)
|
||||||
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
|
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
|
||||||
- contrat `4h`:
|
- contrat `4h`:
|
||||||
- acquis annuel CP: `10`
|
- acquis annuel CP: `10`
|
||||||
- acquis annuel samedi: `0`
|
- acquis annuel samedi: `0`
|
||||||
- en cours d'acquisition: `0.83` jour/mois
|
- en cours d'acquisition: `0.83` jour/mois
|
||||||
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||||
|
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
|
||||||
- contrat `FORFAIT`:
|
- contrat `FORFAIT`:
|
||||||
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
||||||
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
||||||
@@ -242,10 +291,60 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- `rate`: taux de majoration, valeurs `25` ou `50`
|
- `rate`: taux de majoration, valeurs `25` ou `50`
|
||||||
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
|
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
|
||||||
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
|
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
|
||||||
|
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
|
||||||
|
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
|
||||||
|
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures − paiements antérieurs), affichée à partir de juillet (masquée si nul)
|
||||||
|
- Reste = Report cumulé + Total du mois − Payé du mois (balance courante en fin de mois)
|
||||||
- affichage:
|
- affichage:
|
||||||
- le compteur global RTT est affiché en **heures** (format `Xh00`)
|
- le compteur global RTT est affiché en **heures** (format `Xh00`)
|
||||||
|
|
||||||
## 10) Notifications
|
## 10) Export récap. congés & RTT (PDF)
|
||||||
|
|
||||||
|
- Accessible depuis la page Employés via le bouton "Export récap. congés" (réservé `ROLE_ADMIN`)
|
||||||
|
- Clic direct (pas de drawer), génère un PDF A4 portrait à la date du jour
|
||||||
|
- Endpoint: `GET /api/leave-recap/print`
|
||||||
|
- Seuls les employés avec contrat actif sont inclus
|
||||||
|
- Données groupées par site
|
||||||
|
|
||||||
|
### Colonnes du tableau
|
||||||
|
|
||||||
|
| Colonne | Logique |
|
||||||
|
|---------|---------|
|
||||||
|
| Nom | lastName + firstName |
|
||||||
|
| Contrat | Contract.name |
|
||||||
|
| CP N-1 restant | CDI/CDD: acquis N-1 − pris sur N-1. Forfait: report N-1 restant |
|
||||||
|
| Samedi restant | CDI/CDD: samedis acquis N-1 − pris. Forfait: `-` |
|
||||||
|
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
|
||||||
|
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
|
||||||
|
|
||||||
|
## 11) Récapitulatif Salaire (PDF mensuel)
|
||||||
|
|
||||||
|
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
||||||
|
- Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage
|
||||||
|
- Endpoint: `GET /api/salary-recap/print?month=YYYY-MM`
|
||||||
|
- Données groupées par site, un en-tête par site
|
||||||
|
|
||||||
|
### Colonnes du tableau
|
||||||
|
|
||||||
|
| Colonne | Source | Logique |
|
||||||
|
|---------|--------|---------|
|
||||||
|
| Nom | Employee | firstName + lastName |
|
||||||
|
| Base | Contract.name | Via EmployeeContractResolver pour le mois |
|
||||||
|
| Jour de présence Cadre | WorkHour | Uniquement FORFAIT (PRESENCE). Somme isPresentMorning (0.5) + isPresentAfternoon (0.5) |
|
||||||
|
| Heures de nuit | WorkHour | Non-chauffeurs: calcul intervalles nuit (00:00-06:00, 21:00-24:00). Chauffeurs: somme nightHoursMinutes |
|
||||||
|
| Panier de nuit | WorkHour | Nombre de jours où (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit 4h entre 21h-6h) |
|
||||||
|
| Heures payés | EmployeeRttPayment | Somme base25Minutes + base50Minutes du mois, convertie en heures |
|
||||||
|
| Congés - Nombre | Absence code 'C' | Jours (demi-journées = 0.5) |
|
||||||
|
| Congés - Date | Absence code 'C' | Dates formatées dd/mm |
|
||||||
|
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
|
||||||
|
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
|
||||||
|
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
|
||||||
|
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) |
|
||||||
|
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
|
||||||
|
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
|
||||||
|
| Observations | — | Colonne vide pour saisie manuelle |
|
||||||
|
|
||||||
|
## 12) Notifications
|
||||||
|
|
||||||
- Icône cloche en topbar:
|
- Icône cloche en topbar:
|
||||||
- badge = nombre de notifications non lues
|
- badge = nombre de notifications non lues
|
||||||
|
|||||||
563
docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md
Normal file
563
docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
# Refonte onglet RTT — Plan d'implémentation
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remplacer la vue annuelle RTT par une vue mensuelle avec tableau détaillé par semaine (base/25%/50%) et un système de paiement à 4 champs.
|
||||||
|
|
||||||
|
**Architecture:** Enrichir `RttRecoveryComputationService` pour retourner le détail base/bonus par palier. Modifier l'entité `EmployeeRttPayment` pour stocker 4 valeurs. Réécrire le composant `RttTab.vue` avec navigation mensuelle et tableau 7 colonnes.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), PostgreSQL.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Enrichir le retour de `RttRecoveryComputationService::computeRecoveryByWeek`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Dto/Rtt/WeekRecoveryDetail.php`
|
||||||
|
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php:97-206`
|
||||||
|
|
||||||
|
Actuellement `computeRecoveryByWeek` retourne `array<string, int>` (weekKey => totalMinutes). Il faut retourner `array<string, WeekRecoveryDetail>` avec le détail ventilé.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer le DTO `WeekRecoveryDetail`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Dto/Rtt/WeekRecoveryDetail.php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Rtt;
|
||||||
|
|
||||||
|
final class WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modifier `computeRecoveryByWeek` pour retourner `array<string, WeekRecoveryDetail>`**
|
||||||
|
|
||||||
|
Changer le retour de la méthode. Les variables internes existent déjà (`weeklyOvertimeTotalMinutes`, `weeklyOvertime25Minutes`, `weeklyOvertime50Minutes`). Il faut calculer en plus les bases séparées.
|
||||||
|
|
||||||
|
La logique de ventilation des heures de base entre palier 25% et palier 50% :
|
||||||
|
- `base25Minutes` = heures sup dans la tranche 25% = `min(overtimeMinutes, max(0, overtime25StartMinutes - overtimeReferenceMinutes))`... En fait, c'est plus simple :
|
||||||
|
- `base25Minutes` = `min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes))` quand overtimeTotal > 0
|
||||||
|
- Plus simplement : `base25Minutes` = heures entre le seuil 25% et 43h, `base50Minutes` = heures au-dessus de 43h
|
||||||
|
|
||||||
|
Reprenons la logique existante (lignes 189-202) :
|
||||||
|
- `overtimeReferenceMinutes` = seuil à partir duquel on compte les heures sup (max(35, weeklyHours) * 60 réparti sur les jours)
|
||||||
|
- `overtime25StartMinutes` = seuil à partir duquel les heures sup sont à 25% (39h si contrat >= 39h, sinon 35h)
|
||||||
|
- `weeklyOvertimeTotalMinutes` = max(0, worked - overtimeReference) — total heures sup brutes
|
||||||
|
- `weeklyOvertime25Minutes` = bonus 25% = round(min(worked, 43*60) - overtime25Start) * 0.25
|
||||||
|
- `weeklyOvertime50Minutes` = bonus 50% = round(max(0, worked - 43*60)) * 0.5
|
||||||
|
|
||||||
|
Pour la ventilation :
|
||||||
|
- `base25Minutes` = min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes)) — Non, c'est la tranche 25% en termes d'heures travaillées, pas en termes d'heures sup.
|
||||||
|
|
||||||
|
En fait :
|
||||||
|
- Les heures sup brutes = `weeklyOvertimeTotalMinutes` = `worked - overtimeReference`
|
||||||
|
- Les heures dans le palier 25% = heures entre `overtime25Start` et `min(worked, 43*60)` = c'est `max(0, min(worked, 43*60) - overtime25Start)`. C'est la base sur laquelle le 25% est calculé.
|
||||||
|
- Les heures dans le palier 50% = heures au-dessus de 43h = `max(0, worked - 43*60)`. C'est la base sur laquelle le 50% est calculé.
|
||||||
|
|
||||||
|
Modifier les lignes 191-202 :
|
||||||
|
|
||||||
|
```php
|
||||||
|
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||||
|
? 0
|
||||||
|
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
||||||
|
|
||||||
|
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
||||||
|
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
|
||||||
|
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
||||||
|
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
|
||||||
|
|
||||||
|
$results[$weekKey] = new WeekRecoveryDetail(
|
||||||
|
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
||||||
|
base25Minutes: $base25,
|
||||||
|
bonus25Minutes: $bonus25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
bonus50Minutes: $bonus50,
|
||||||
|
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
|
? 0
|
||||||
|
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Les cas "zéro" (weekStart vide, limitDate dépassée, etc.) retournent `new WeekRecoveryDetail()` (tout à 0).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Adapter `computeTotalRecoveryForExercise` pour retourner un `WeekRecoveryDetail` agrégé**
|
||||||
|
|
||||||
|
Cette méthode retournait `int`. Elle doit maintenant retourner un `WeekRecoveryDetail` qui agrège toutes les semaines (somme par champ). Le rollover et le provider en ont besoin pour la ventilation du carry-over.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
|
||||||
|
$weeks = $this->buildWeeksForExercise($from, $to);
|
||||||
|
$weekRanges = array_map(
|
||||||
|
static fn (array $week): array => [
|
||||||
|
'month' => (int) $week['month'],
|
||||||
|
'weekNumber' => (int) $week['weekNumber'],
|
||||||
|
'start' => $week['start'],
|
||||||
|
'end' => $week['end'],
|
||||||
|
],
|
||||||
|
$weeks
|
||||||
|
);
|
||||||
|
|
||||||
|
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
|
||||||
|
|
||||||
|
$total = new WeekRecoveryDetail();
|
||||||
|
foreach ($byWeek as $detail) {
|
||||||
|
$total = new WeekRecoveryDetail(
|
||||||
|
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
|
||||||
|
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
|
||||||
|
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
|
||||||
|
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
|
||||||
|
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
|
||||||
|
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Vérifier que le code compile**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Modifier l'entité `EmployeeRttBalance` (carry-over ventilé) + rollover
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/EmployeeRttBalance.php`
|
||||||
|
- Modify: `src/Repository/EmployeeRttBalanceRepository.php`
|
||||||
|
- Modify: `src/Command/RttRolloverCommand.php`
|
||||||
|
|
||||||
|
Le carry-over doit être ventilé sur les mêmes 4 colonnes que le tableau (base25, bonus25, base50, bonus50) pour pouvoir afficher une ligne "Report" dans le mois de juin.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer `openingMinutes` par 4 champs dans `EmployeeRttBalance`**
|
||||||
|
|
||||||
|
Remplacer la propriété `$openingMinutes` par :
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBase25Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBonus25Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBase50Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBonus50Minutes = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter les getters/setters. Supprimer `getOpeningMinutes`/`setOpeningMinutes`. Ajouter un helper `getTotalOpeningMinutes()` qui retourne la somme des 4 champs.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Adapter `RttRolloverCommand`**
|
||||||
|
|
||||||
|
`computeTotalRecoveryForExercise` retourne maintenant un `WeekRecoveryDetail`. Utiliser les 4 champs :
|
||||||
|
|
||||||
|
```php
|
||||||
|
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
||||||
|
|
||||||
|
$balance = new EmployeeRttBalance()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setYear($targetYear)
|
||||||
|
->setOpeningBase25Minutes($carry->base25Minutes)
|
||||||
|
->setOpeningBonus25Minutes($carry->bonus25Minutes)
|
||||||
|
->setOpeningBase50Minutes($carry->base50Minutes)
|
||||||
|
->setOpeningBonus50Minutes($carry->bonus50Minutes)
|
||||||
|
->setIsLocked(false)
|
||||||
|
;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Adapter `EmployeeRttSummaryProvider::resolveCarryMinutes`**
|
||||||
|
|
||||||
|
Cette méthode retournait `int`. La renommer en `resolveCarry` et retourner un `WeekRecoveryDetail` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function resolveCarry(Employee $employee, int $year): WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
|
||||||
|
if (null !== $balance) {
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
base25Minutes: $balance->getOpeningBase25Minutes(),
|
||||||
|
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
|
||||||
|
base50Minutes: $balance->getOpeningBase50Minutes(),
|
||||||
|
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
|
||||||
|
totalMinutes: $balance->getTotalOpeningMinutes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapter le provider pour utiliser le carry ventilé dans le summary :
|
||||||
|
- `carryFromPreviousYearMinutes` = carry->totalMinutes
|
||||||
|
- Ajouter les 4 champs de carry dans `EmployeeRttSummary` pour le frontend
|
||||||
|
|
||||||
|
- [ ] **Step 4: Ajouter les champs carry dans `EmployeeRttSummary`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
public int $carryBase25Minutes = 0;
|
||||||
|
public int $carryBonus25Minutes = 0;
|
||||||
|
public int $carryBase50Minutes = 0;
|
||||||
|
public int $carryBonus50Minutes = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Générer et exécuter la migration**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
|
||||||
|
|
||||||
|
Note : faire la migration après la Task 3 (EmployeeRttPayment) pour regrouper les changements dans une seule migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Modifier l'entité `EmployeeRttPayment` et la migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/EmployeeRttPayment.php`
|
||||||
|
- Modify: `src/Repository/EmployeeRttPaymentRepository.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer `minutes` + `rate` par 4 champs dans l'entité**
|
||||||
|
|
||||||
|
Remplacer les propriétés `$minutes` et `$rate` par :
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
|
||||||
|
private int $base25Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
|
||||||
|
private int $bonus25Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $base50Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $bonus50Minutes = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter les getters/setters correspondants. Supprimer `getMinutes`/`setMinutes`/`getRate`/`setRate`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Adapter le repository**
|
||||||
|
|
||||||
|
Remplacer `findOneByEmployeeYearMonthRate` par `findOneByEmployeeYearMonth` (plus besoin du rate) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment
|
||||||
|
{
|
||||||
|
return $this->findOneBy([
|
||||||
|
'employee' => $employee,
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Générer et vérifier la migration**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
|
||||||
|
|
||||||
|
Vérifier que la migration :
|
||||||
|
- Ajoute `base25_minutes`, `bonus25_minutes`, `base50_minutes`, `bonus50_minutes`
|
||||||
|
- Supprime `minutes` et `rate`
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Adapter le DTO `RttMonthPayment` et `EmployeeRttWeekSummary`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Dto/Rtt/RttMonthPayment.php`
|
||||||
|
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Modifier `RttMonthPayment`**
|
||||||
|
|
||||||
|
Remplacer `paidMinutes25` et `paidMinutes50` par les 4 champs :
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class RttMonthPayment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $paidBase25Minutes = 0,
|
||||||
|
public int $paidBonus25Minutes = 0,
|
||||||
|
public int $paidBase50Minutes = 0,
|
||||||
|
public int $paidBonus50Minutes = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Enrichir `EmployeeRttWeekSummary`**
|
||||||
|
|
||||||
|
Ajouter les champs de détail :
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class EmployeeRttWeekSummary
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $weekNumber,
|
||||||
|
public string $weekStart,
|
||||||
|
public string $weekEnd,
|
||||||
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supprimer l'ancien champ `recoveryMinutes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Adapter le provider et le processor backend
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/State/EmployeeRttSummaryProvider.php`
|
||||||
|
- Modify: `src/ApiResource/EmployeeRttSummary.php`
|
||||||
|
- Modify: `src/ApiResource/EmployeeRttPaymentInput.php`
|
||||||
|
- Modify: `src/State/EmployeeRttPaymentProcessor.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Adapter `EmployeeRttSummaryProvider::provide`**
|
||||||
|
|
||||||
|
Le mapping des semaines (ligne 87-96) doit utiliser les nouveaux champs du `WeekRecoveryDetail` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
$summary->weeks = array_map(
|
||||||
|
static function (array $week) use ($currentByWeekStart) {
|
||||||
|
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
|
||||||
|
|
||||||
|
return new EmployeeRttWeekSummary(
|
||||||
|
month: (int) $week['month'],
|
||||||
|
weekNumber: (int) $week['weekNumber'],
|
||||||
|
weekStart: $week['start']->format('Y-m-d'),
|
||||||
|
weekEnd: $week['end']->format('Y-m-d'),
|
||||||
|
overtimeMinutes: $detail->overtimeMinutes,
|
||||||
|
base25Minutes: $detail->base25Minutes,
|
||||||
|
bonus25Minutes: $detail->bonus25Minutes,
|
||||||
|
base50Minutes: $detail->base50Minutes,
|
||||||
|
bonus50Minutes: $detail->bonus50Minutes,
|
||||||
|
totalMinutes: $detail->totalMinutes,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
$weekRanges
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `currentYearRecoveryMinutes` doit sommer les `totalMinutes` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
$summary->currentYearRecoveryMinutes = array_sum(
|
||||||
|
array_map(static fn (WeekRecoveryDetail $d) => $d->totalMinutes, $currentByWeekStart)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapter l'agrégation des paiements (lignes 98-121) pour les 4 champs :
|
||||||
|
|
||||||
|
```php
|
||||||
|
foreach ($payments as $payment) {
|
||||||
|
$m = $payment->getMonth();
|
||||||
|
if (!isset($monthBuckets[$m])) {
|
||||||
|
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
|
||||||
|
}
|
||||||
|
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
|
||||||
|
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
|
||||||
|
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
|
||||||
|
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($monthBuckets as $m => $bucket) {
|
||||||
|
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
|
||||||
|
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Adapter `EmployeeRttPaymentInput`**
|
||||||
|
|
||||||
|
```php
|
||||||
|
final class EmployeeRttPaymentInput
|
||||||
|
{
|
||||||
|
public int $month = 0;
|
||||||
|
public int $base25Minutes = 0;
|
||||||
|
public int $bonus25Minutes = 0;
|
||||||
|
public int $base50Minutes = 0;
|
||||||
|
public int $bonus50Minutes = 0;
|
||||||
|
public ?int $year = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Adapter `EmployeeRttPaymentProcessor`**
|
||||||
|
|
||||||
|
Supprimer la validation du `rate`. Adapter le upsert :
|
||||||
|
|
||||||
|
```php
|
||||||
|
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||||
|
|
||||||
|
if (null === $payment) {
|
||||||
|
$payment = new EmployeeRttPayment();
|
||||||
|
$payment->setEmployee($employee);
|
||||||
|
$payment->setYear($year);
|
||||||
|
$payment->setMonth($data->month);
|
||||||
|
$this->entityManager->persist($payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment->setBase25Minutes($data->base25Minutes);
|
||||||
|
$payment->setBonus25Minutes($data->bonus25Minutes);
|
||||||
|
$payment->setBase50Minutes($data->base50Minutes);
|
||||||
|
$payment->setBonus50Minutes($data->bonus50Minutes);
|
||||||
|
$payment->touch();
|
||||||
|
$this->entityManager->flush();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Vérifier**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Adapter le frontend — DTOs et service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
|
||||||
|
- Modify: `frontend/services/employee-rtt-summary.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mettre à jour les types TS**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type EmployeeRttWeekSummary = {
|
||||||
|
month: number
|
||||||
|
weekNumber: number
|
||||||
|
weekStart: string
|
||||||
|
weekEnd: string
|
||||||
|
overtimeMinutes: number
|
||||||
|
base25Minutes: number
|
||||||
|
bonus25Minutes: number
|
||||||
|
base50Minutes: number
|
||||||
|
bonus50Minutes: number
|
||||||
|
totalMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RttMonthPayment = {
|
||||||
|
month: number
|
||||||
|
paidBase25Minutes: number
|
||||||
|
paidBonus25Minutes: number
|
||||||
|
paidBase50Minutes: number
|
||||||
|
paidBonus50Minutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmployeeRttSummary = {
|
||||||
|
year: number
|
||||||
|
carryFromPreviousYearMinutes: number
|
||||||
|
carryBase25Minutes: number
|
||||||
|
carryBonus25Minutes: number
|
||||||
|
carryBase50Minutes: number
|
||||||
|
carryBonus50Minutes: number
|
||||||
|
currentYearRecoveryMinutes: number
|
||||||
|
totalPaidMinutes: number
|
||||||
|
availableMinutes: number
|
||||||
|
weeks: EmployeeRttWeekSummary[]
|
||||||
|
monthPayments: RttMonthPayment[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Adapter le service `createRttPayment`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const createRttPayment = async (
|
||||||
|
employeeId: number,
|
||||||
|
month: number,
|
||||||
|
base25Minutes: number,
|
||||||
|
bonus25Minutes: number,
|
||||||
|
base50Minutes: number,
|
||||||
|
bonus50Minutes: number,
|
||||||
|
year?: number
|
||||||
|
) => {
|
||||||
|
const api = useApi()
|
||||||
|
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
|
||||||
|
if (year) body.year = year
|
||||||
|
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Réécrire `RttTab.vue`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/employees/RttTab.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Réécrire le composant complet**
|
||||||
|
|
||||||
|
Structure du template :
|
||||||
|
1. En-tête avec navigation mensuelle (flèches `<` `>`) et "RTT À LA DATE DU JOUR : X heure"
|
||||||
|
2. Tableau 7 colonnes : Semaine | Heure | Base | 25% | Base | 50% | Total
|
||||||
|
3. Si mois de juin (premier mois de l'exercice) et carry > 0 : ligne "Report" avec les 4 valeurs carry (colonne Heure = "-")
|
||||||
|
4. 5 lignes semaines (padding si < 5)
|
||||||
|
5. Ligne Total (somme par colonne, incluant le report si présent)
|
||||||
|
6. Ligne Payé (valeurs négatives, "-" pour colonne Heure)
|
||||||
|
7. Ligne Reste (Total - |Payé|, "-" pour colonne Heure)
|
||||||
|
8. Bouton "+ Payer les RRT"
|
||||||
|
9. Drawer de paiement avec 5 champs
|
||||||
|
|
||||||
|
Script setup :
|
||||||
|
- `currentMonthIndex` : ref (0-11) pour la navigation dans `orderedMonthIndexes` (toujours [5,6,7,8,9,10,11,0,1,2,3,4] = juin à mai)
|
||||||
|
- Initialiser `currentMonthIndex` au mois courant dans l'exercice
|
||||||
|
- `currentMonth` : computed qui retourne le numéro de mois (1-12) basé sur l'index
|
||||||
|
- `weeksForMonth` : computed filtrant les semaines du summary pour le mois courant, paddé à 5
|
||||||
|
- `monthPayment` : computed trouvant le paiement du mois dans `summary.monthPayments`
|
||||||
|
- Totaux par colonne : computed sommant les semaines
|
||||||
|
- `formatMinutes` : existant, réutiliser (format `Xh` ou `Xh Ym`)
|
||||||
|
- Navigation : `prevMonth` / `nextMonth` modifiant `currentMonthIndex` avec bornes [0, 11]
|
||||||
|
|
||||||
|
Drawer de paiement :
|
||||||
|
- Champs : Mois (select), Base 25% (number en heures), Heures 25% (number en heures), Base 50% (number en heures), Heures 50% (number en heures)
|
||||||
|
- Si paiement existant pour le mois sélectionné : pré-remplir en convertissant minutes → heures
|
||||||
|
- Emit : `submit-rtt-payment` avec les 4 valeurs converties en minutes + le mois
|
||||||
|
|
||||||
|
- [ ] **Step 2: Adapter le composant parent**
|
||||||
|
|
||||||
|
Chercher où `RttTab` est utilisé et adapter l'event handler `submit-rtt-payment` pour passer les 4 champs au lieu de `(month, minutes, rate)`.
|
||||||
|
|
||||||
|
Run: `grep -rn "submit-rtt-payment" frontend/` pour trouver le parent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Test de bout en bout
|
||||||
|
|
||||||
|
- [ ] **Step 1: Vérifier le cache et la migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec php-sirh-fpm php bin/console cache:clear
|
||||||
|
docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Tester l'API**
|
||||||
|
|
||||||
|
Vérifier que `GET /api/employees/{id}/rtt-summary` retourne les nouveaux champs par semaine.
|
||||||
|
Vérifier que `PATCH /api/employees/{id}/rtt-payments` accepte les 4 champs.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Tester le frontend**
|
||||||
|
|
||||||
|
- Navigation mensuelle (flèches, mois courant par défaut)
|
||||||
|
- Tableau : vérifier les valeurs par semaine
|
||||||
|
- Paiement : créer, modifier, vérifier pré-remplissage
|
||||||
|
- "RTT À LA DATE DU JOUR" : vérifier le cumul
|
||||||
117
docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md
Normal file
117
docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Refonte onglet RTT employé
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'onglet RTT actuel affiche une grille annuelle de 12 mois avec les minutes de récupération par semaine. Il doit être remplacé par une vue mensuelle détaillée avec navigation, un tableau ventilé par palier de majoration (25% / 50%), et un système de paiement à 4 champs.
|
||||||
|
|
||||||
|
## Maquette de référence
|
||||||
|
|
||||||
|
Fichier : `RTT.png` à la racine du projet.
|
||||||
|
|
||||||
|
## Structure de la vue
|
||||||
|
|
||||||
|
### En-tête
|
||||||
|
|
||||||
|
- Navigation mensuelle : `< MOIS ANNÉE >` (flèches gauche/droite)
|
||||||
|
- Navigation limitée aux mois de l'exercice (juin N-1 à mai N)
|
||||||
|
- Mois courant affiché par défaut à l'ouverture
|
||||||
|
- En haut à droite : `RTT À LA DATE DU JOUR : X heure` (cumul annuel toutes semaines confondues)
|
||||||
|
|
||||||
|
### Tableau
|
||||||
|
|
||||||
|
7 colonnes :
|
||||||
|
|
||||||
|
| Semaine | Heure | Base | 25% | Base | 50% | Total |
|
||||||
|
|---------|-------|------|-----|------|-----|-------|
|
||||||
|
|
||||||
|
- **Semaine** : label "Semaine 1" à "Semaine 5" (toujours 5 lignes, vide si le mois n'a que 4 semaines)
|
||||||
|
- **Heure** : heures supplémentaires brutes de la semaine
|
||||||
|
- **Base** (1er) : heures de base dans le palier 25% (heures entre 35h et 39h pour un contrat 39h)
|
||||||
|
- **25%** : bonus = base 25% × 0.25
|
||||||
|
- **Base** (2e) : heures de base dans le palier 50% (heures au-delà de 43h)
|
||||||
|
- **50%** : bonus = base 50% × 0.50
|
||||||
|
- **Total** : somme de toutes les bases + tous les bonus
|
||||||
|
|
||||||
|
### Lignes de synthèse
|
||||||
|
|
||||||
|
- **Total** : somme des 5 semaines par colonne
|
||||||
|
- **Payé** : montants payés pour ce mois (affichés en négatif). Colonne "Heure" = "-"
|
||||||
|
- **Reste** : Total - |Payé| par colonne. Colonne "Heure" = "-"
|
||||||
|
|
||||||
|
### Bouton
|
||||||
|
|
||||||
|
`+ Payer les RRT` en bas, centré. Ouvre un drawer.
|
||||||
|
|
||||||
|
## Drawer de paiement
|
||||||
|
|
||||||
|
Champs :
|
||||||
|
1. **Mois** (select) : liste des mois de l'exercice
|
||||||
|
2. **Base 25%** (number, en heures)
|
||||||
|
3. **Heures 25%** (number, en heures)
|
||||||
|
4. **Base 50%** (number, en heures)
|
||||||
|
5. **Heures 50%** (number, en heures)
|
||||||
|
|
||||||
|
Si des paiements existent pour le mois sélectionné, le formulaire est pré-rempli pour modification.
|
||||||
|
|
||||||
|
Boutons : Annuler / Enregistrer.
|
||||||
|
|
||||||
|
## Rattachement semaine → mois
|
||||||
|
|
||||||
|
Règle existante conservée : une semaine est rattachée au mois de son **samedi** (voir `RttRecoveryComputationService::buildWeeksForExercise`).
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Modification de `EmployeeRttSummary`
|
||||||
|
|
||||||
|
Le provider retourne les données pour un mois donné (paramètre query `?month=X`) en plus du cumul annuel.
|
||||||
|
|
||||||
|
Nouvelles données par semaine :
|
||||||
|
- `overtimeMinutes` : heures sup brutes
|
||||||
|
- `base25Minutes` : base palier 25%
|
||||||
|
- `bonus25Minutes` : bonus 25%
|
||||||
|
- `base50Minutes` : base palier 50%
|
||||||
|
- `bonus50Minutes` : bonus 50%
|
||||||
|
- `totalMinutes` : somme base + bonus
|
||||||
|
|
||||||
|
### Modification de `EmployeeRttPayment`
|
||||||
|
|
||||||
|
Remplacer les champs `minutes` (int) + `rate` (int 25/50) par :
|
||||||
|
- `base25Minutes` (int)
|
||||||
|
- `bonus25Minutes` (int)
|
||||||
|
- `base50Minutes` (int)
|
||||||
|
- `bonus50Minutes` (int)
|
||||||
|
|
||||||
|
Migration Doctrine nécessaire.
|
||||||
|
|
||||||
|
### Modification de `EmployeeRttPaymentInput`
|
||||||
|
|
||||||
|
Adapter les champs pour correspondre aux 4 nouvelles valeurs.
|
||||||
|
|
||||||
|
### Modification de `RttRecoveryComputationService`
|
||||||
|
|
||||||
|
`computeRecoveryByWeek` retourne déjà les minutes totales. Il faut enrichir le retour pour ventiler base/bonus par palier. La logique de calcul des paliers existe déjà en interne, il suffit de l'exposer.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Stockage vs affichage
|
||||||
|
|
||||||
|
- Backend : stockage en **minutes** (inchangé)
|
||||||
|
- Frontend : conversion minutes ↔ heures à l'affichage et à la saisie
|
||||||
|
|
||||||
|
### Réécriture de `RttTab.vue`
|
||||||
|
|
||||||
|
- Supprimer la grille annuelle de 12 mois
|
||||||
|
- Navigation mensuelle avec état réactif (mois courant)
|
||||||
|
- Tableau HTML avec les 7 colonnes décrites
|
||||||
|
- 5 lignes semaines + Total + Payé + Reste
|
||||||
|
- Formatage en "Xh" ou "Xh Ym" (ex: "6h 30m")
|
||||||
|
|
||||||
|
### Modification du DTO TypeScript
|
||||||
|
|
||||||
|
Adapter `EmployeeRttSummary` et `EmployeeRttWeekSummary` pour les nouveaux champs.
|
||||||
|
|
||||||
|
## Unités de conversion
|
||||||
|
|
||||||
|
- Affichage : heures et minutes (ex: "6h 30m", "30 m")
|
||||||
|
- Saisie paiement : en heures décimales (number input)
|
||||||
|
- Stockage : minutes entières (int)
|
||||||
@@ -93,28 +93,29 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div v-if="editingAbsence" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
v-if="editingAbsence"
|
|
||||||
type="button"
|
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"
|
@click="handleDelete"
|
||||||
>
|
>
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</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
|
<button
|
||||||
type="submit"
|
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"
|
: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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -96,17 +96,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="handleCancel"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
:class="submitButtonClass"
|
||||||
>
|
>
|
||||||
Imprimer
|
Imprimer
|
||||||
|
|||||||
@@ -5,19 +5,12 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
<Transition name="drawer-panel">
|
<Transition name="drawer-panel">
|
||||||
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
|
<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">
|
<div class="flex items-center justify-between px-[20px] pt-8 pb-8">
|
||||||
<h2 class="text-lg font-semibold text-neutral-900">
|
<h2 class="text-[32px] font-semibold text-primary-500">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md p-2 text-neutral-500 hover:bg-neutral-100"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
87
frontend/components/SalaryRecapDrawer.vue
Normal file
87
frontend/components/SalaryRecapDrawer.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="drawerOpen" title="Récapitulatif Salaire">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="salary-recap-month">
|
||||||
|
Mois <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="salary-recap-month"
|
||||||
|
v-model="selectedMonth"
|
||||||
|
type="month"
|
||||||
|
:class="monthFieldClass"
|
||||||
|
/>
|
||||||
|
<p v-if="showMonthError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le mois est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:class="submitButtonClass"
|
||||||
|
>
|
||||||
|
Imprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
(event: 'submit', month: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const drawerOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const selectedMonth = ref(defaultMonth)
|
||||||
|
const validationTouched = ref(false)
|
||||||
|
|
||||||
|
const isMonthValid = computed(() => selectedMonth.value.trim() !== '')
|
||||||
|
const showMonthError = computed(() => validationTouched.value && !isMonthValid.value)
|
||||||
|
|
||||||
|
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||||
|
const monthFieldClass = computed(() => {
|
||||||
|
if (showMonthError.value) {
|
||||||
|
return `${baseInputClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseInputClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitButtonClass = computed(() => {
|
||||||
|
if (!isMonthValid.value) {
|
||||||
|
return 'opacity-50 cursor-not-allowed'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
validationTouched.value = true
|
||||||
|
if (!isMonthValid.value) return
|
||||||
|
emit('submit', selectedMonth.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
validationTouched.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
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">
|
<div class="flex flex-col gap-2">
|
||||||
<label
|
<label
|
||||||
|
|||||||
241
frontend/components/driver-hours/DriverHoursDayView.vue
Normal file
241
frontend/components/driver-hours/DriverHoursDayView.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||||
|
<div class="overflow-y-auto min-h-0">
|
||||||
|
<div
|
||||||
|
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||||
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
|
>
|
||||||
|
<span>Nom</span>
|
||||||
|
<span class="pl-2">Absence</span>
|
||||||
|
<span class="pl-4">Heure de jour</span>
|
||||||
|
<span class="pl-2">Heure de nuit</span>
|
||||||
|
<span class="pl-2">Heure atelier</span>
|
||||||
|
<span class="pl-2">Total</span>
|
||||||
|
<span>Petit déj.</span>
|
||||||
|
<span>Déjeuner</span>
|
||||||
|
<span>Dîner</span>
|
||||||
|
<span>Nuitée</span>
|
||||||
|
<span v-if="isAdmin" class="flex justify-between items-center">
|
||||||
|
<span>Valider</span>
|
||||||
|
<input
|
||||||
|
ref="bulkValidationInput"
|
||||||
|
:checked="isBulkValidationChecked"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
@change="onBulkValidationChange"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
||||||
|
<span>Site</span>
|
||||||
|
<input
|
||||||
|
ref="bulkSiteValidationInput"
|
||||||
|
:checked="isBulkSiteValidationChecked"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
||||||
|
:disabled="!canBulkToggleSiteValidation"
|
||||||
|
@change="onBulkSiteValidationChange"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
|
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
|
<div
|
||||||
|
v-for="employee in employees"
|
||||||
|
:key="employee.id"
|
||||||
|
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||||
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
|
>
|
||||||
|
<div class="text-neutral-900 min-w-0">
|
||||||
|
<p class="font-semibold truncate">
|
||||||
|
{{ employee.firstName }} {{ employee.lastName }}
|
||||||
|
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||||
|
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
|
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||||
|
title="Validation site"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:check"/>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
|
||||||
|
Modifié le {{ getRowUpdatedAt(employee.id) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
||||||
|
<p
|
||||||
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
||||||
|
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
||||||
|
:title="getRowAbsenceLabel(employee.id) || ''"
|
||||||
|
:style="getRowAbsenceStyle(employee.id)"
|
||||||
|
>
|
||||||
|
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="self-start text-left text-xs font-semibold underline"
|
||||||
|
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||||
|
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||||
|
@click="onAbsenceClick(employee.id)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].dayHours"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].nightHours"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].workshopHours"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2 text-sm font-semibold">
|
||||||
|
{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<input
|
||||||
|
v-model="rows[employee.id].hasBreakfast"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<input
|
||||||
|
v-model="rows[employee.id].hasLunch"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<input
|
||||||
|
v-model="rows[employee.id].hasDinner"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<input
|
||||||
|
v-model="rows[employee.id].hasOvernight"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAdmin" class="text-right">
|
||||||
|
<input
|
||||||
|
:checked="rows[employee.id]?.isValid ?? false"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-right p-5">
|
||||||
|
<input
|
||||||
|
v-if="isSiteManager"
|
||||||
|
:checked="rows[employee.id]?.isSiteValid ?? false"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
|
||||||
|
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||||
|
<span v-else class="text-xs text-neutral-500">-</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isAdmin">
|
||||||
|
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||||
|
<span v-else class="text-xs text-neutral-500">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||||
|
import type { DriverHourRow } from '~/services/dto/work-hour'
|
||||||
|
|
||||||
|
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
|
||||||
|
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
employees: Employee[]
|
||||||
|
isAdmin: boolean
|
||||||
|
isSiteManager: boolean
|
||||||
|
dayGridCols: string
|
||||||
|
isHoliday: boolean
|
||||||
|
contractLabel: (employee: Employee) => string
|
||||||
|
isRowLocked: (employeeId: number) => boolean
|
||||||
|
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||||
|
isValidationPending: (employeeId: number) => boolean
|
||||||
|
isSiteValidationPending: (employeeId: number) => boolean
|
||||||
|
canToggleValidation: (employeeId: number) => boolean
|
||||||
|
canToggleSiteValidation: (employeeId: number) => boolean
|
||||||
|
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
|
||||||
|
isBulkValidationChecked: boolean
|
||||||
|
isBulkValidationIndeterminate: boolean
|
||||||
|
isBulkSiteValidationChecked: boolean
|
||||||
|
isBulkSiteValidationIndeterminate: boolean
|
||||||
|
canBulkToggleSiteValidation: boolean
|
||||||
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||||
|
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||||
|
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||||
|
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||||
|
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||||
|
getRowAbsenceLabel: (employeeId: number) => string
|
||||||
|
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||||
|
getRowUpdatedAt: (employeeId: number) => string
|
||||||
|
onAbsenceClick: (employeeId: number) => void
|
||||||
|
formatMinutes: (minutes: number) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const onBulkValidationChange = (event: Event) => {
|
||||||
|
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBulkSiteValidationChange = (event: Event) => {
|
||||||
|
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||||
|
props.onToggleSiteValidation(employeeId, checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isBulkValidationIndeterminate,
|
||||||
|
(isIndeterminate) => {
|
||||||
|
if (!bulkValidationInput.value) return
|
||||||
|
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isBulkSiteValidationIndeterminate,
|
||||||
|
(isIndeterminate) => {
|
||||||
|
if (!bulkSiteValidationInput.value) return
|
||||||
|
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
108
frontend/components/driver-hours/DriverHoursWeekView.vue
Normal file
108
frontend/components/driver-hours/DriverHoursWeekView.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||||
|
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
||||||
|
<div v-else class="overflow-y-auto min-h-0">
|
||||||
|
<div
|
||||||
|
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||||
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
|
>
|
||||||
|
<span>Nom</span>
|
||||||
|
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.weekday }}<br>{{ day.dayDate }}</span>
|
||||||
|
<span>Jour/Nuit <br>sem.</span>
|
||||||
|
<span>Atelier <br>sem.</span>
|
||||||
|
<span>Total <br>sem.</span>
|
||||||
|
<span>Total <br>h. supp.</span>
|
||||||
|
<span>+25%</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
<span>Total <br>récup.</span>
|
||||||
|
<span>Petit <br>déj.</span>
|
||||||
|
<span>Déj.</span>
|
||||||
|
<span>Dîner</span>
|
||||||
|
<span>Nuit.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
|
<div
|
||||||
|
v-for="row in weeklySummary?.rows ?? []"
|
||||||
|
:key="row.employeeId"
|
||||||
|
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||||
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
|
>
|
||||||
|
<div class="text-neutral-900 min-w-0">
|
||||||
|
<p class="font-semibold truncate">
|
||||||
|
{{ row.firstName }} {{ row.lastName }}
|
||||||
|
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="daily in row.daily"
|
||||||
|
:key="daily.date"
|
||||||
|
class="text-left leading-4 rounded-md px-2 py-1"
|
||||||
|
:class="daily.hasAbsence ? 'text-white' : ''"
|
||||||
|
:style="getDailyCellStyle(daily)"
|
||||||
|
:title="daily.absenceLabel ?? ''"
|
||||||
|
>
|
||||||
|
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||||
|
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
|
||||||
|
<div v-if="daily.workshopMinutes">A {{ formatMinutes(daily.workshopMinutes) }}</div>
|
||||||
|
<div v-if="daily.hasBreakfast || daily.hasLunch || daily.hasDinner || daily.hasOvernight" class="text-[10px] flex gap-1 mt-0.5">
|
||||||
|
<span v-if="daily.hasBreakfast" title="Petit déjeuner">PD</span>
|
||||||
|
<span v-if="daily.hasLunch" title="Déjeuner">DJ</span>
|
||||||
|
<span v-if="daily.hasDinner" title="Dîner">DI</span>
|
||||||
|
<span v-if="daily.hasOvernight" title="Nuitée">NU</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-semibold leading-4">
|
||||||
|
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
|
||||||
|
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ formatMinutes(row.weeklyWorkshopMinutes ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ formatMinutes(row.weeklyTotalMinutes) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">{{ row.weeklyBreakfastCount ?? 0 }}</div>
|
||||||
|
<div class="font-semibold">{{ row.weeklyLunchCount ?? 0 }}</div>
|
||||||
|
<div class="font-semibold">{{ row.weeklyDinnerCount ?? 0 }}</div>
|
||||||
|
<div class="font-semibold">{{ row.weeklyOvernightCount ?? 0 }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
|
|
||||||
|
const getDailyCellStyle = (daily: {
|
||||||
|
hasAbsence?: boolean
|
||||||
|
absenceColor?: string | null
|
||||||
|
}) => {
|
||||||
|
if (!daily.hasAbsence) return undefined
|
||||||
|
return { backgroundColor: daily.absenceColor || '#dc2626' }
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isWeekLoading: boolean
|
||||||
|
weekGridCols: string
|
||||||
|
weeklySummary: WeeklyWorkHourSummary | null
|
||||||
|
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
|
||||||
|
formatMinutes: (minutes: number) => string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
207
frontend/components/employees/BonusTab.vue
Normal file
207
frontend/components/employees/BonusTab.vue
Normal 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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
<div class="overflow-hidden 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="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>Contrat</p>
|
||||||
<p>Heures</p>
|
<p>Heures</p>
|
||||||
<p>Date de début</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">
|
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
|
||||||
Aucun historique de contrat.
|
Aucun historique de contrat.
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
<div
|
<div
|
||||||
v-for="item in contractHistory"
|
v-for="item in contractHistory"
|
||||||
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
: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>{{ contractNatureLabel(item.contractNature) }}</p>
|
||||||
<p>{{ contractHistoryLabel(item) }}</p>
|
<p>{{ contractHistoryLabel(item) }}</p>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
class="pb-2 px-4 border-b-2 font-semibold"
|
class="pb-2 px-4 border-b-2 font-semibold"
|
||||||
:class="drawerTab === 'close'
|
:class="drawerTab === 'close'
|
||||||
? 'border-primary-500 text-primary-500'
|
? 'border-primary-500 text-primary-500'
|
||||||
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
@click="drawerTab = 'close'"
|
@click="drawerTab = 'close'"
|
||||||
>
|
>
|
||||||
Clôturer
|
Clôturer
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
class="pb-2 px-4 border-b-2 font-semibold"
|
class="pb-2 px-4 border-b-2 font-semibold"
|
||||||
:class="drawerTab === 'suspend'
|
:class="drawerTab === 'suspend'
|
||||||
? 'border-primary-500 text-primary-500'
|
? 'border-primary-500 text-primary-500'
|
||||||
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
@click="drawerTab = 'suspend'"
|
@click="drawerTab = 'suspend'"
|
||||||
>
|
>
|
||||||
Suspendre
|
Suspendre
|
||||||
@@ -133,21 +133,13 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-center 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>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
:disabled="isContractSubmitting || !isContractEndDateValid"
|
||||||
>
|
>
|
||||||
Enregistrer
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -201,7 +193,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full rounded-md border-2 border-dashed border-neutral-300 px-4 py-3 text-base font-semibold text-neutral-500 transition hover:border-primary-500 hover:text-primary-500"
|
class="w-full rounded-md border-2 border-dashed border-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
|
||||||
@click="onAddSuspensionForm"
|
@click="onAddSuspensionForm"
|
||||||
>
|
>
|
||||||
+ Ajouter une suspension
|
+ Ajouter une suspension
|
||||||
@@ -248,21 +240,25 @@
|
|||||||
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
||||||
<button
|
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="create-contract-is-driver">
|
||||||
type="button"
|
<input
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
id="create-contract-is-driver"
|
||||||
:disabled="isCreateContractSubmitting"
|
v-model="createContractForm.isDriver"
|
||||||
@click="onUpdateCreateContractDrawerOpen(false)"
|
type="checkbox"
|
||||||
>
|
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||||
Annuler
|
/>
|
||||||
</button>
|
Chauffeur
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
||||||
>
|
>
|
||||||
Enregistrer
|
+ Ajouter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -297,6 +293,7 @@ type CreateContractForm = {
|
|||||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
startDate: string
|
startDate: string
|
||||||
endDate: string
|
endDate: string
|
||||||
|
isDriver: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -1,35 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||||
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
|
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
<p class="col-start-1 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
||||||
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
formatCount(summary?.acquiredDays)
|
||||||
formatCount(summary?.acquiredDays)
|
}} Jours
|
||||||
}} Jours</p>
|
</p>
|
||||||
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
<p class="col-start-2 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Pris :</strong>
|
||||||
{{ formatCount(summary?.remainingDays) }} Jours</p>
|
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
|
||||||
</div>
|
</p>
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
<p class="col-start-3 p-[10px] border-b border-r border-b-white border-r-primary-500 bg-primary-500 text-white"><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
||||||
<p><span class="uppercase font-semibold">Samedi acquis :</span>
|
{{ formatCount(summary?.remainingDays) }} Jours
|
||||||
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
|
</p>
|
||||||
<p><span class="uppercase font-semibold">Reste à prendre :</span>
|
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
|
||||||
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
|
{{ formatCount(summary?.accruingDays) }} Jours
|
||||||
</div>
|
</p>
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
|
||||||
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
|
{{ formatCount(summary?.acquiredSaturdays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-else class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Année N-1 acquis :</span>
|
||||||
|
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-if="!isForfaitRule" class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
|
||||||
|
{{ formatCount(summary?.takenSaturdays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-if="!isForfaitRule" class="col-start-3 p-[10px] border-r border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||||
|
{{ formatCount(summary?.remainingSaturdays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-else class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
|
||||||
|
{{ formatCount(summary?.previousYearTakenDays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-if="isForfaitRule" class="col-start-3 p-[10px] border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||||
|
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
|
||||||
|
</p>
|
||||||
|
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
|
||||||
|
<div>
|
||||||
|
<span class="uppercase font-semibold">Fractionné acquis : </span>
|
||||||
|
<span>{{ formatCount(summary?.fractionedDays) }} Jours</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
|
class="flex items-center"
|
||||||
@click="openFractionedDrawer"
|
@click="openFractionedDrawer"
|
||||||
>
|
>
|
||||||
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
|
<Icon name="mdi:edit-box" size="24"/>
|
||||||
</div>
|
</button>
|
||||||
<div class="flex flex-col jutify-center gap-2 items-center py-3">
|
|
||||||
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
|
|
||||||
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||||
<div class="grid grid-cols-4 gap-10">
|
<div class="grid grid-cols-4 gap-10">
|
||||||
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
|
<div v-for="month in months" :key="month.label"
|
||||||
|
class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
|
||||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
|
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
|
||||||
{{ month.label }}
|
{{ month.label }}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +73,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence : {{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}</div>
|
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence :
|
||||||
|
{{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +139,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isFractionedDrawerOpen = ref(false)
|
const isFractionedDrawerOpen = ref(false)
|
||||||
const fractionedForm = reactive({ days: 0 })
|
const fractionedForm = reactive({days: 0})
|
||||||
|
|
||||||
const openFractionedDrawer = () => {
|
const openFractionedDrawer = () => {
|
||||||
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
||||||
@@ -151,6 +172,11 @@ const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
|
|||||||
|
|
||||||
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
||||||
|
|
||||||
|
const currentYearTakenDays = computed(() => {
|
||||||
|
if (!props.summary) return null
|
||||||
|
return props.summary.takenDays - (props.summary.previousYearTakenDays ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
const displayedYear = computed(() => {
|
const displayedYear = computed(() => {
|
||||||
if (props.summary?.year) return props.summary.year
|
if (props.summary?.year) return props.summary.year
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -282,15 +308,15 @@ const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) =
|
|||||||
if (day.leave) {
|
if (day.leave) {
|
||||||
const color = day.leave.colors[0] ?? '#222783'
|
const color = day.leave.colors[0] ?? '#222783'
|
||||||
if (day.leave.am && day.leave.pm) {
|
if (day.leave.am && day.leave.pm) {
|
||||||
return { backgroundColor: color }
|
return {backgroundColor: color}
|
||||||
}
|
}
|
||||||
const colorFaded = `${color}60`
|
const colorFaded = `${color}60`
|
||||||
const backgroundImage = day.leave.am
|
const backgroundImage = day.leave.am
|
||||||
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
|
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
|
||||||
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
|
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
|
||||||
return { backgroundImage, backgroundColor: 'transparent' }
|
return {backgroundImage, backgroundColor: 'transparent'}
|
||||||
}
|
}
|
||||||
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
|
if (day.isHoliday) return {backgroundColor: 'rgb(179, 229, 252)'}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
268
frontend/components/employees/MileageTab.vue
Normal file
268
frontend/components/employees/MileageTab.vue
Normal 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>
|
||||||
@@ -1,80 +1,245 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||||
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5">
|
<!-- Header bar -->
|
||||||
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
<div class="flex items-center justify-between rounded-t-md bg-tertiary-500 px-5 py-4 text-black border border-primary-500">
|
||||||
<button class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px] text-md" @click="openNewPayment">
|
<div class="flex items-center">
|
||||||
+ Payer les RTT
|
<button
|
||||||
</button>
|
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||||
</div>
|
:disabled="currentMonthIndex === 0"
|
||||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
@click="currentMonthIndex--"
|
||||||
<div class="grid grid-cols-4 gap-10 pb-4">
|
|
||||||
<div
|
|
||||||
v-for="month in months"
|
|
||||||
:key="month.month"
|
|
||||||
class="rounded-md bg-tertiary-500 text-primary-500"
|
|
||||||
>
|
>
|
||||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
|
<Icon name="mdi:chevron-left" size="24"/>
|
||||||
{{ month.label }}
|
</button>
|
||||||
</div>
|
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
|
||||||
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
|
{{ currentMonthLabel }} {{ currentYear }}
|
||||||
<template v-for="week in month.weeks" :key="week.key">
|
</span>
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
|
<button
|
||||||
<span v-if="week.isEmpty"> </span>
|
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||||
<span v-else>Semaine {{ week.weekNumber }}</span>
|
:disabled="currentMonthIndex === 11"
|
||||||
</div>
|
@click="currentMonthIndex++"
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500">
|
>
|
||||||
<span v-if="week.isEmpty"> </span>
|
<Icon name="mdi:chevron-right" size="24"/>
|
||||||
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
|
</button>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
|
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
|
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
|
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
|
||||||
@click="openEditPayment(month.month, '25')"
|
|
||||||
title="Modifier les heures payées"
|
|
||||||
>
|
|
||||||
<p>{{ formatMinutes(getMonthPaid25(month.month)) }}</p>
|
|
||||||
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
|
|
||||||
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
|
|
||||||
<div class="py-[6px] px-3 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
|
||||||
@click="openEditPayment(month.month, '50')"
|
|
||||||
title="Modifier les heures payées"
|
|
||||||
>
|
|
||||||
<p>{{ formatMinutes(getMonthPaid50(month.month)) }}</p>
|
|
||||||
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
|
|
||||||
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-[16px]">
|
||||||
|
<span class="font-bold">RTT À LA SEMAINE {{ lastCompleteWeek }} : </span>
|
||||||
|
<span class="font-bold">{{ formatMinutes(summary?.availableMinutes ?? 0) }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
|
||||||
|
@click="openPaymentDrawer"
|
||||||
|
>
|
||||||
|
+ Payer les RRT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AppDrawer v-model="isPaymentDrawerOpen" :title="isEditMode ? 'Modifier le paiement RTT' : 'Payer des RTT'">
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<table class="w-full table-fixed border-collapse text-[18px]">
|
||||||
|
<colgroup>
|
||||||
|
<col />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
<col class="w-[11%]" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-5 py-[10px] text-left font-bold text-primary-500 border border-primary-500">Semaine</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Heure</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">25%</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 25%</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
|
||||||
|
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Report N-1 row (RTT rollover carry, June only) -->
|
||||||
|
<tr v-if="showCarryRow" class="bg-tertiary-500">
|
||||||
|
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
|
||||||
|
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus25Minutes) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Report mois précédent (cumulated balance from previous months, July+) -->
|
||||||
|
<tr v-if="showMonthReportRow" class="bg-tertiary-500">
|
||||||
|
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
|
||||||
|
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Week rows (always 5) -->
|
||||||
|
<tr
|
||||||
|
v-for="(week, idx) in paddedWeeks"
|
||||||
|
:key="week ? week.weekStart : `empty-${idx}`"
|
||||||
|
>
|
||||||
|
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">
|
||||||
|
<span v-if="week">Semaine {{ week.weekNumber }}</span>
|
||||||
|
<span v-else> </span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.overtimeMinutes) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base25Minutes : 0) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus25Minutes : 0) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.base25Minutes + week.bonus25Minutes) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base50Minutes : 0) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus50Minutes : 0) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||||
|
<span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
|
||||||
|
<span v-else>0 h</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Total row -->
|
||||||
|
<tr>
|
||||||
|
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500 border-t-2">Total</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.overtime) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Payé row -->
|
||||||
|
<tr>
|
||||||
|
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
|
||||||
|
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Reste row -->
|
||||||
|
<tr>
|
||||||
|
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
|
||||||
|
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }}</td>
|
||||||
|
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Drawer -->
|
||||||
|
<AppDrawer v-model="isPaymentDrawerOpen" title="Payer des RTT">
|
||||||
<form @submit.prevent="onSubmitPayment">
|
<form @submit.prevent="onSubmitPayment">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
||||||
<select v-model.number="paymentForm.month" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
<select
|
||||||
|
v-model.number="paymentForm.month"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-neutral-700">Nombre d'heures</label>
|
<label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label>
|
||||||
<input v-model.number="paymentForm.hours" type="number" step="0.5" 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" />
|
<input
|
||||||
|
v-model.number="paymentForm.base25Hours"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
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 class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label>
|
||||||
|
<input
|
||||||
|
v-model.number="paymentForm.bonus25Hours"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
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 class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label>
|
||||||
|
<input
|
||||||
|
v-model.number="paymentForm.base50Hours"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
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>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium text-neutral-700">Taux</label>
|
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label>
|
||||||
<select v-model="paymentForm.rate" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
<input
|
||||||
<option value="25">25%</option>
|
v-model.number="paymentForm.bonus50Hours"
|
||||||
<option value="50">50%</option>
|
type="number"
|
||||||
</select>
|
step="0.5"
|
||||||
|
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>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button type="button" class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" @click="isPaymentDrawerOpen = false">Annuler</button>
|
<button
|
||||||
<button type="submit" class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600">Enregistrer</button>
|
type="button"
|
||||||
|
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
@click="isPaymentDrawerOpen = false"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
@@ -82,7 +247,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -90,27 +255,38 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'submit-rtt-payment', month: number, minutes: number, rate: '25' | '50'): void
|
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isPaymentDrawerOpen = ref(false)
|
// --- Last complete week number ---
|
||||||
const isEditMode = ref(false)
|
|
||||||
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
|
|
||||||
|
|
||||||
const monthLabels = [
|
const lastCompleteWeek = computed(() => {
|
||||||
'Janvier',
|
const now = new Date()
|
||||||
'Fevrier',
|
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||||
'Mars',
|
const dayOfYear = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000) + 1
|
||||||
'Avril',
|
const dayOfWeek = now.getDay() || 7 // Monday = 1, Sunday = 7
|
||||||
'Mai',
|
const currentWeek = Math.ceil((dayOfYear - dayOfWeek + 10) / 7)
|
||||||
'Juin',
|
return currentWeek - 1
|
||||||
'Juillet',
|
})
|
||||||
'Aout',
|
|
||||||
'Septembre',
|
// --- Month navigation ---
|
||||||
'Octobre',
|
|
||||||
'Novembre',
|
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
|
||||||
'Decembre'
|
|
||||||
] as const
|
const monthLabels: Record<number, string> = {
|
||||||
|
1: 'JANVIER',
|
||||||
|
2: 'FEVRIER',
|
||||||
|
3: 'MARS',
|
||||||
|
4: 'AVRIL',
|
||||||
|
5: 'MAI',
|
||||||
|
6: 'JUIN',
|
||||||
|
7: 'JUILLET',
|
||||||
|
8: 'AOUT',
|
||||||
|
9: 'SEPTEMBRE',
|
||||||
|
10: 'OCTOBRE',
|
||||||
|
11: 'NOVEMBRE',
|
||||||
|
12: 'DECEMBRE',
|
||||||
|
}
|
||||||
|
|
||||||
const orderedMonthOptions = [
|
const orderedMonthOptions = [
|
||||||
{ value: 6, label: 'Juin' },
|
{ value: 6, label: 'Juin' },
|
||||||
@@ -124,97 +300,232 @@ const orderedMonthOptions = [
|
|||||||
{ value: 2, label: 'Fevrier' },
|
{ value: 2, label: 'Fevrier' },
|
||||||
{ value: 3, label: 'Mars' },
|
{ value: 3, label: 'Mars' },
|
||||||
{ value: 4, label: 'Avril' },
|
{ value: 4, label: 'Avril' },
|
||||||
{ value: 5, label: 'Mai' }
|
{ value: 5, label: 'Mai' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const paymentsByMonth = computed(() => {
|
// Initialize to current month's position in the exercise
|
||||||
const map = new Map<number, { paid25: number; paid50: number }>()
|
const today = new Date()
|
||||||
for (const mp of props.summary?.monthPayments ?? []) {
|
const todayMonth = today.getMonth() + 1
|
||||||
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
const initialIndex = orderedMonths.indexOf(todayMonth as (typeof orderedMonths)[number])
|
||||||
}
|
const currentMonthIndex = ref(initialIndex >= 0 ? initialIndex : 0)
|
||||||
return map
|
|
||||||
|
const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
|
||||||
|
|
||||||
|
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
|
||||||
|
|
||||||
|
const currentYear = computed(() => {
|
||||||
|
if (!props.summary) return ''
|
||||||
|
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||||
})
|
})
|
||||||
|
|
||||||
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
|
// --- Weeks for current month ---
|
||||||
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
|
||||||
|
|
||||||
const months = computed(() => {
|
const weeksForCurrentMonth = computed((): EmployeeRttWeekSummary[] => {
|
||||||
type DisplayWeek = {
|
if (!props.summary) return []
|
||||||
key: string
|
return props.summary.weeks.filter((w) => w.month === currentMonth.value)
|
||||||
weekNumber: number
|
|
||||||
recoveryMinutes: number
|
|
||||||
isEmpty?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const byMonth = new Map<number, { month: number; label: string; weeks: DisplayWeek[]; totalMinutes: number }>()
|
|
||||||
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5]
|
|
||||||
for (const month of orderedMonths) {
|
|
||||||
byMonth.set(month, {
|
|
||||||
month,
|
|
||||||
label: monthLabels[month - 1],
|
|
||||||
weeks: [],
|
|
||||||
totalMinutes: 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const week of props.summary?.weeks ?? []) {
|
|
||||||
const month = byMonth.get(week.month)
|
|
||||||
if (!month) continue
|
|
||||||
|
|
||||||
month.weeks.push({
|
|
||||||
key: week.weekStart,
|
|
||||||
weekNumber: week.weekNumber,
|
|
||||||
recoveryMinutes: week.recoveryMinutes
|
|
||||||
})
|
|
||||||
month.totalMinutes += week.recoveryMinutes
|
|
||||||
}
|
|
||||||
|
|
||||||
return orderedMonths
|
|
||||||
.map((monthNumber) => byMonth.get(monthNumber)!)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((month) => {
|
|
||||||
const minRows = 5
|
|
||||||
const missing = Math.max(0, minRows - month.weeks.length)
|
|
||||||
for (let i = 0; i < missing; i += 1) {
|
|
||||||
month.weeks.push({
|
|
||||||
key: `empty-${month.month}-${i}`,
|
|
||||||
weekNumber: 0,
|
|
||||||
recoveryMinutes: 0,
|
|
||||||
isEmpty: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return month
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatMinutes = (minutes: number) => {
|
const paddedWeeks = computed((): (EmployeeRttWeekSummary | null)[] => {
|
||||||
|
const weeks = weeksForCurrentMonth.value
|
||||||
|
const padded: (EmployeeRttWeekSummary | null)[] = [...weeks]
|
||||||
|
while (padded.length < 5) {
|
||||||
|
padded.push(null)
|
||||||
|
}
|
||||||
|
return padded
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Carry row (RTT rollover from previous year, June only) ---
|
||||||
|
|
||||||
|
const carryMonth = computed(() => {
|
||||||
|
if (!props.summary) return 6
|
||||||
|
const cm = props.summary.carryMonth
|
||||||
|
return cm >= 12 ? 1 : cm + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCarryRow = computed(() => {
|
||||||
|
if (currentMonth.value !== carryMonth.value) return false
|
||||||
|
if ((props.summary?.carryFromPreviousYearMinutes ?? 0) === 0) return false
|
||||||
|
|
||||||
|
// On the first exercise, hide carry if carry month is before rttStartDate
|
||||||
|
if (props.summary?.rttStartDate) {
|
||||||
|
const startDate = new Date(props.summary.rttStartDate)
|
||||||
|
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||||
|
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
|
||||||
|
const startMonthDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1)
|
||||||
|
if (viewDate < startMonthDate) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Month report row (cumulated balance from previous months) ---
|
||||||
|
|
||||||
|
// Months of the exercise in order, starting from the carry month
|
||||||
|
const exerciseMonths = computed((): number[] => {
|
||||||
|
const start = carryMonth.value
|
||||||
|
const startIdx = orderedMonths.indexOf(start as (typeof orderedMonths)[number])
|
||||||
|
if (startIdx === -1) return [...orderedMonths]
|
||||||
|
return [...orderedMonths.slice(startIdx), ...orderedMonths.slice(0, startIdx)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthReport = computed(() => {
|
||||||
|
if (!props.summary) return { base25: 0, bonus25: 0, total25: 0, base50: 0, bonus50: 0, total50: 0, total: 0 }
|
||||||
|
|
||||||
|
const cm = currentMonth.value
|
||||||
|
const cmIdx = exerciseMonths.value.indexOf(cm)
|
||||||
|
const previousMonths = exerciseMonths.value.slice(0, cmIdx)
|
||||||
|
|
||||||
|
// Start from carry (included in the cumulation)
|
||||||
|
let base25 = props.summary.carryBase25Minutes
|
||||||
|
let bonus25 = props.summary.carryBonus25Minutes
|
||||||
|
let base50 = props.summary.carryBase50Minutes
|
||||||
|
let bonus50 = props.summary.carryBonus50Minutes
|
||||||
|
let total = props.summary.carryFromPreviousYearMinutes
|
||||||
|
|
||||||
|
// Add weeks from previous months
|
||||||
|
for (const w of props.summary.weeks) {
|
||||||
|
if (previousMonths.includes(w.month)) {
|
||||||
|
base25 += w.base25Minutes
|
||||||
|
bonus25 += w.bonus25Minutes
|
||||||
|
base50 += w.base50Minutes
|
||||||
|
bonus50 += w.bonus50Minutes
|
||||||
|
total += w.totalMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtract payments from previous months
|
||||||
|
for (const p of props.summary.monthPayments) {
|
||||||
|
if (previousMonths.includes(p.month)) {
|
||||||
|
base25 -= p.paidBase25Minutes
|
||||||
|
bonus25 -= p.paidBonus25Minutes
|
||||||
|
base50 -= p.paidBase50Minutes
|
||||||
|
bonus50 -= p.paidBonus50Minutes
|
||||||
|
total -= (p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { base25, bonus25, total25: base25 + bonus25, base50, bonus50, total50: base50 + bonus50, total }
|
||||||
|
})
|
||||||
|
|
||||||
|
const showMonthReportRow = computed(() => {
|
||||||
|
// Not on the carry month — carry row handles that
|
||||||
|
if (currentMonth.value === carryMonth.value) return false
|
||||||
|
|
||||||
|
// On the first exercise (containing rttStartDate), hide report for months before the start date
|
||||||
|
if (props.summary?.rttStartDate) {
|
||||||
|
const startDate = new Date(props.summary.rttStartDate)
|
||||||
|
const startYear = startDate.getFullYear()
|
||||||
|
const startMonth = startDate.getMonth() + 1
|
||||||
|
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||||
|
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
|
||||||
|
const startMonthDate = new Date(startYear, startMonth - 1, 1)
|
||||||
|
if (viewDate < startMonthDate) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = monthReport.value
|
||||||
|
return r.total !== 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Totals (current month weeks only) ---
|
||||||
|
|
||||||
|
const totals = computed(() => {
|
||||||
|
const weeks = weeksForCurrentMonth.value
|
||||||
|
const positive = weeks.filter((w) => w.totalMinutes >= 0)
|
||||||
|
return {
|
||||||
|
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
|
||||||
|
base25: positive.reduce((s, w) => s + w.base25Minutes, 0),
|
||||||
|
bonus25: positive.reduce((s, w) => s + w.bonus25Minutes, 0),
|
||||||
|
total25: weeks.reduce((s, w) => s + w.base25Minutes + w.bonus25Minutes, 0),
|
||||||
|
base50: positive.reduce((s, w) => s + w.base50Minutes, 0),
|
||||||
|
bonus50: positive.reduce((s, w) => s + w.bonus50Minutes, 0),
|
||||||
|
total50: weeks.reduce((s, w) => s + w.base50Minutes + w.bonus50Minutes, 0),
|
||||||
|
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentPayment = computed(() => {
|
||||||
|
if (!props.summary) return null
|
||||||
|
return props.summary.monthPayments.find((p) => p.month === currentMonth.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const paidTotal = computed(() => {
|
||||||
|
if (!currentPayment.value) return 0
|
||||||
|
const p = currentPayment.value
|
||||||
|
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reste = computed(() => {
|
||||||
|
const total25 = monthReport.value.total25 + totals.value.total25
|
||||||
|
- (currentPayment.value?.paidBase25Minutes ?? 0) - (currentPayment.value?.paidBonus25Minutes ?? 0)
|
||||||
|
const total50 = monthReport.value.total50 + totals.value.total50
|
||||||
|
- (currentPayment.value?.paidBase50Minutes ?? 0) - (currentPayment.value?.paidBonus50Minutes ?? 0)
|
||||||
|
|
||||||
|
const base25 = Math.round(total25 / 1.25)
|
||||||
|
const bonus25 = total25 - base25
|
||||||
|
const base50 = Math.round(total50 / 1.5)
|
||||||
|
const bonus50 = total50 - base50
|
||||||
|
const total = monthReport.value.total + totals.value.total + paidTotal.value
|
||||||
|
|
||||||
|
return { base25, bonus25, total25, base50, bonus50, total50, total }
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Format ---
|
||||||
|
|
||||||
|
const formatMinutes = (minutes: number): string => {
|
||||||
|
if (minutes === 0) return '0 h'
|
||||||
|
const sign = minutes < 0 ? '- ' : ''
|
||||||
const abs = Math.abs(minutes)
|
const abs = Math.abs(minutes)
|
||||||
const hours = Math.floor(abs / 60)
|
const hours = Math.floor(abs / 60)
|
||||||
const rest = abs % 60
|
const rest = abs % 60
|
||||||
const sign = minutes < 0 ? '-' : ''
|
if (rest === 0) return `${sign}${hours} h`
|
||||||
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
|
return `${sign}${hours} h ${rest} m`
|
||||||
}
|
}
|
||||||
|
|
||||||
const openNewPayment = () => {
|
// --- Payment drawer ---
|
||||||
isEditMode.value = false
|
|
||||||
paymentForm.month = 6
|
const isPaymentDrawerOpen = ref(false)
|
||||||
paymentForm.hours = 0
|
const paymentForm = reactive({
|
||||||
paymentForm.rate = '25'
|
month: 6,
|
||||||
isPaymentDrawerOpen.value = true
|
base25Hours: 0,
|
||||||
|
bonus25Hours: 0,
|
||||||
|
base50Hours: 0,
|
||||||
|
bonus50Hours: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prefillFromExistingPayment = (month: number) => {
|
||||||
|
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
|
||||||
|
if (existing) {
|
||||||
|
paymentForm.base25Hours = existing.paidBase25Minutes / 60
|
||||||
|
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
|
||||||
|
paymentForm.base50Hours = existing.paidBase50Minutes / 60
|
||||||
|
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
|
||||||
|
} else {
|
||||||
|
paymentForm.base25Hours = 0
|
||||||
|
paymentForm.bonus25Hours = 0
|
||||||
|
paymentForm.base50Hours = 0
|
||||||
|
paymentForm.bonus50Hours = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditPayment = (month: number, rate: '25' | '50') => {
|
watch(() => paymentForm.month, (newMonth) => {
|
||||||
isEditMode.value = true
|
prefillFromExistingPayment(newMonth)
|
||||||
paymentForm.month = month
|
})
|
||||||
paymentForm.rate = rate
|
|
||||||
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
|
const openPaymentDrawer = () => {
|
||||||
paymentForm.hours = currentMinutes / 60
|
paymentForm.month = currentMonth.value
|
||||||
|
prefillFromExistingPayment(currentMonth.value)
|
||||||
isPaymentDrawerOpen.value = true
|
isPaymentDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmitPayment = () => {
|
const onSubmitPayment = () => {
|
||||||
const minutes = Math.round(paymentForm.hours * 60)
|
emit(
|
||||||
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
|
'submit-rtt-payment',
|
||||||
|
paymentForm.month,
|
||||||
|
Math.round(paymentForm.base25Hours * 60),
|
||||||
|
Math.round(paymentForm.bonus25Hours * 60),
|
||||||
|
Math.round(paymentForm.base50Hours * 60),
|
||||||
|
Math.round(paymentForm.bonus50Hours * 60),
|
||||||
|
)
|
||||||
isPaymentDrawerOpen.value = false
|
isPaymentDrawerOpen.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<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="overflow-y-auto min-h-0">
|
||||||
<div
|
<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 }"
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
>
|
>
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
@@ -42,10 +42,11 @@
|
|||||||
<span v-if="!isAdmin">RH <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>
|
||||||
|
|
||||||
|
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
<div
|
<div
|
||||||
v-for="employee in employees"
|
v-for="employee in employees"
|
||||||
:key="employee.id"
|
: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 }"
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
>
|
>
|
||||||
<div class="text-neutral-900 min-w-0">
|
<div class="text-neutral-900 min-w-0">
|
||||||
@@ -142,19 +143,19 @@
|
|||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)">{{
|
<div v-if="isTimeTracking(employee)">{{
|
||||||
formatMinutes(getRowMetrics(employee.id).dayMinutes)
|
formatMinutes(getRowMetrics(employee.id).dayMinutes)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold text-neutral-700">
|
<div class="text-sm font-semibold">
|
||||||
<div v-if="isTimeTracking(employee)">{{
|
<div v-if="isTimeTracking(employee)">{{
|
||||||
formatMinutes(getRowMetrics(employee.id).nightMinutes)
|
formatMinutes(getRowMetrics(employee.id).nightMinutes)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold text-neutral-700">
|
<div class="text-sm font-semibold">
|
||||||
<div v-if="isTimeTracking(employee)">{{
|
<div v-if="isTimeTracking(employee)">{{
|
||||||
formatMinutes(getRowMetrics(employee.id).totalMinutes)
|
formatMinutes(getRowMetrics(employee.id).totalMinutes)
|
||||||
}}
|
}}
|
||||||
@@ -186,6 +187,7 @@
|
|||||||
<span v-else class="text-xs text-neutral-500">-</span>
|
<span v-else class="text-xs text-neutral-500">-</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<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-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 v-else class="overflow-y-auto min-h-0">
|
||||||
<div
|
<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 }"
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
>
|
>
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
@@ -14,12 +14,14 @@
|
|||||||
<span>+25%</span>
|
<span>+25%</span>
|
||||||
<span>+50%</span>
|
<span>+50%</span>
|
||||||
<span>Total <br>récup.</span>
|
<span>Total <br>récup.</span>
|
||||||
|
<span>Panier <br>nuit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
<div
|
<div
|
||||||
v-for="row in weeklySummary?.rows ?? []"
|
v-for="row in weeklySummary?.rows ?? []"
|
||||||
:key="row.employeeId"
|
: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 }"
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
>
|
>
|
||||||
<div class="text-neutral-900 min-w-0">
|
<div class="text-neutral-900 min-w-0">
|
||||||
@@ -67,6 +69,10 @@
|
|||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ (row.weeklyNightBasketCount ?? 0) > 0 ? row.weeklyNightBasketCount : '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
983
frontend/composables/useDriverHoursPage.ts
Normal file
983
frontend/composables/useDriverHoursPage.ts
Normal file
@@ -0,0 +1,983 @@
|
|||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import type { Site } from '~/services/dto/site'
|
||||||
|
import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
|
import type { Absence } from '~/services/dto/absence'
|
||||||
|
import type { HalfDay } from '~/services/dto/half-day'
|
||||||
|
import type { DriverHourRow } from '~/services/dto/work-hour'
|
||||||
|
import { listScopedEmployees } from '~/services/employees'
|
||||||
|
import { listAbsenceTypes } from '~/services/absence-types'
|
||||||
|
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
||||||
|
import { listPublicHolidays } from '~/services/public-holidays'
|
||||||
|
import {
|
||||||
|
bulkUpdateWorkHourSiteValidation,
|
||||||
|
bulkUpdateWorkHourValidation,
|
||||||
|
bulkUpsertWorkHours,
|
||||||
|
getWorkHourDayContext,
|
||||||
|
getWeeklyWorkHourSummary,
|
||||||
|
listWorkHoursByDate,
|
||||||
|
updateWorkHourSiteValidation,
|
||||||
|
updateWorkHourValidation
|
||||||
|
} from '~/services/work-hours'
|
||||||
|
import {
|
||||||
|
formatDateLongFr,
|
||||||
|
formatWeekRangeFr,
|
||||||
|
getIsoWeekNumber,
|
||||||
|
getOffsetFromTodayYmd,
|
||||||
|
getWeekStartDate,
|
||||||
|
getTodayYmd,
|
||||||
|
parseYmd,
|
||||||
|
shiftYmd
|
||||||
|
} from '~/utils/date'
|
||||||
|
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
||||||
|
|
||||||
|
export const useDriverHoursPage = () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
|
||||||
|
const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
|
||||||
|
const viewMode = ref<'day' | 'week'>('day')
|
||||||
|
|
||||||
|
const selectedDate = ref(getTodayYmd())
|
||||||
|
const employees = ref<Employee[]>([])
|
||||||
|
const employeeFilter = ref('')
|
||||||
|
const selectedSiteIds = ref<number[]>([])
|
||||||
|
const sitesInitialized = ref(false)
|
||||||
|
const rows = ref<Record<number, DriverHourRow>>({})
|
||||||
|
const dayContext = ref<WorkHourDayContext | null>(null)
|
||||||
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||||
|
const absenceTypes = ref<AbsenceType[]>([])
|
||||||
|
const absences = ref<Absence[]>([])
|
||||||
|
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
|
||||||
|
const isAbsenceDrawerOpen = ref(false)
|
||||||
|
const isAbsenceSubmitting = ref(false)
|
||||||
|
const editingAbsence = ref<Absence | null>(null)
|
||||||
|
const absenceForm = ref({
|
||||||
|
employeeId: '' as number | '',
|
||||||
|
typeId: '' as number | '',
|
||||||
|
startDate: '',
|
||||||
|
startHalf: 'AM' as HalfDay,
|
||||||
|
endDate: '',
|
||||||
|
endHalf: 'PM' as HalfDay,
|
||||||
|
comment: ''
|
||||||
|
})
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isWeekLoading = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const validatingRowIds = ref<number[]>([])
|
||||||
|
const siteValidatingRowIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const dayGridCols = computed(() => {
|
||||||
|
const metricCol = '0.4fr'
|
||||||
|
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
|
||||||
|
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekGridCols = '1.6fr repeat(7, 0.6fr) repeat(7, 0.6fr) repeat(4, 0.4fr)'
|
||||||
|
|
||||||
|
const sites = computed<Site[]>(() => {
|
||||||
|
const siteMap = new Map<number, Site>()
|
||||||
|
for (const employee of employees.value) {
|
||||||
|
if (employee.site) {
|
||||||
|
siteMap.set(employee.site.id, employee.site)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(siteMap.values()).sort((siteA, siteB) => {
|
||||||
|
const orderA = siteA.displayOrder ?? 0
|
||||||
|
const orderB = siteB.displayOrder ?? 0
|
||||||
|
if (orderA !== orderB) return orderA - orderB
|
||||||
|
return siteA.name.localeCompare(siteB.name, 'fr')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleEmployees = computed(() => {
|
||||||
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
|
return employees.value.filter((employee) => {
|
||||||
|
if (employee.isDriver !== true) return false
|
||||||
|
const siteId = employee.site?.id
|
||||||
|
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
||||||
|
if (!filter) return true
|
||||||
|
const firstName = employee.firstName?.toLowerCase() ?? ''
|
||||||
|
const lastName = employee.lastName?.toLowerCase() ?? ''
|
||||||
|
return firstName.includes(filter) || lastName.includes(filter)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayedEmployees = computed(() => {
|
||||||
|
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
|
||||||
|
|
||||||
|
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
|
||||||
|
if (!weeklySummary.value) return null
|
||||||
|
return {
|
||||||
|
...weeklySummary.value,
|
||||||
|
rows: weeklySummary.value.rows.filter((row) =>
|
||||||
|
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveButtonClass = computed(() => {
|
||||||
|
if (isSubmitting.value || employees.value.length === 0) {
|
||||||
|
return 'opacity-50 cursor-not-allowed'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
|
||||||
|
const isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId)
|
||||||
|
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
|
||||||
|
const canToggleSiteValidation = (employeeId: number) => {
|
||||||
|
if (!isSiteManager.value) return false
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row?.workHourId) return false
|
||||||
|
if (row.isValid) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreateEmptyValidationRow = (employeeId: number) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (row?.workHourId) return false
|
||||||
|
if (!hasContractAtSelectedDate(employeeId)) return false
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
return !!dayRow?.absenceLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
|
||||||
|
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
|
||||||
|
|
||||||
|
const bulkValidatableEmployeeIds = computed(() => {
|
||||||
|
return visibleEmployees.value
|
||||||
|
.map((employee) => employee.id)
|
||||||
|
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkValidationChecked = computed(() => {
|
||||||
|
const ids = bulkValidatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkValidationIndeterminate = computed(() => {
|
||||||
|
const ids = bulkValidatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
|
||||||
|
return checkedCount > 0 && checkedCount < ids.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const bulkSiteValidatableEmployeeIds = computed(() => {
|
||||||
|
if (!isSiteManager.value) return []
|
||||||
|
return visibleEmployees.value
|
||||||
|
.map((employee) => employee.id)
|
||||||
|
.filter((employeeId) => canToggleSiteValidation(employeeId) || canCreateSiteValidationRowFromAbsence(employeeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkSiteValidationChecked = computed(() => {
|
||||||
|
const ids = bulkSiteValidatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
return ids.every((employeeId) => rows.value[employeeId]?.isSiteValid ?? false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkSiteValidationIndeterminate = computed(() => {
|
||||||
|
const ids = bulkSiteValidatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isSiteValid ?? false).length
|
||||||
|
return checkedCount > 0 && checkedCount < ids.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const canBulkToggleSiteValidation = computed(() => bulkSiteValidatableEmployeeIds.value.length > 0)
|
||||||
|
|
||||||
|
const dayContextByEmployeeId = computed(() => {
|
||||||
|
const map = new Map<number, WorkHourDayContext['rows'][number]>()
|
||||||
|
for (const row of dayContext.value?.rows ?? []) {
|
||||||
|
map.set(row.employeeId, row)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
|
||||||
|
const targetDate = target === 'yesterday'
|
||||||
|
? getOffsetFromTodayYmd(-1)
|
||||||
|
: target === 'tomorrow'
|
||||||
|
? getOffsetFromTodayYmd(1)
|
||||||
|
: getTodayYmd()
|
||||||
|
|
||||||
|
if (selectedDate.value === targetDate) {
|
||||||
|
return 'bg-primary-500 text-white'
|
||||||
|
}
|
||||||
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
||||||
|
const selected = parseYmd(selectedDate.value)
|
||||||
|
if (!selected) {
|
||||||
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const targetDate = new Date(today)
|
||||||
|
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
|
||||||
|
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
|
||||||
|
|
||||||
|
const selectedWeekStart = getWeekStartDate(selected)
|
||||||
|
const targetWeekStart = getWeekStartDate(targetDate)
|
||||||
|
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return 'bg-primary-500 text-white'
|
||||||
|
}
|
||||||
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
||||||
|
const today = new Date()
|
||||||
|
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
|
||||||
|
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
|
||||||
|
|
||||||
|
const weekNumber = getIsoWeekNumber(today)
|
||||||
|
return `Sem. S${weekNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedSelectedDate = computed(() => {
|
||||||
|
const parsed = parseYmd(selectedDate.value)
|
||||||
|
if (!parsed) return selectedDate.value
|
||||||
|
|
||||||
|
if (viewMode.value === 'week') {
|
||||||
|
return formatWeekRangeFr(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDateLongFr(parsed)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedYear = computed(() => {
|
||||||
|
const parsed = parseYmd(selectedDate.value)
|
||||||
|
return parsed ? parsed.getFullYear() : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedHolidayLabel = computed(() => {
|
||||||
|
const year = selectedYear.value
|
||||||
|
if (!year) return ''
|
||||||
|
return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
|
||||||
|
|
||||||
|
const weekDayHeaders = computed(() => {
|
||||||
|
const days = weeklySummary.value?.days ?? []
|
||||||
|
return days.map((date) => {
|
||||||
|
const parsed = parseYmd(date)
|
||||||
|
if (!parsed) return { date, weekday: '', dayDate: '' }
|
||||||
|
const weekday = new Intl.DateTimeFormat('fr-FR', { weekday: 'short' }).format(parsed)
|
||||||
|
const dayDate = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit' }).format(parsed)
|
||||||
|
return { date, weekday, dayDate }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const shiftDate = (steps: number) => {
|
||||||
|
const offset = viewMode.value === 'week' ? (steps * 7) : steps
|
||||||
|
const next = shiftYmd(selectedDate.value, offset)
|
||||||
|
if (!next) return
|
||||||
|
selectedDate.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const setToday = () => { selectedDate.value = getTodayYmd() }
|
||||||
|
const setYesterday = () => { setToday(); shiftDate(-1) }
|
||||||
|
const setTomorrow = () => { setToday(); shiftDate(1) }
|
||||||
|
const setThisWeek = () => { selectedDate.value = getTodayYmd() }
|
||||||
|
const setPreviousWeek = () => {
|
||||||
|
const previousWeek = shiftYmd(getTodayYmd(), -7)
|
||||||
|
if (!previousWeek) return
|
||||||
|
selectedDate.value = previousWeek
|
||||||
|
}
|
||||||
|
const setNextWeek = () => {
|
||||||
|
const nextWeek = shiftYmd(getTodayYmd(), 7)
|
||||||
|
if (!nextWeek) return
|
||||||
|
selectedDate.value = nextWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAbsenceForm = () => {
|
||||||
|
absenceForm.value = {
|
||||||
|
employeeId: '',
|
||||||
|
typeId: '',
|
||||||
|
startDate: '',
|
||||||
|
startHalf: 'AM',
|
||||||
|
endDate: '',
|
||||||
|
endHalf: 'PM',
|
||||||
|
comment: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAbsenceDrawer = () => {
|
||||||
|
isAbsenceDrawerOpen.value = false
|
||||||
|
editingAbsence.value = null
|
||||||
|
resetAbsenceForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinutes = (time: string): number => {
|
||||||
|
if (!time) return 0
|
||||||
|
const [hours, minutes] = time.split(':').map(Number)
|
||||||
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return 0
|
||||||
|
return (hours * 60) + minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMinutes = (minutes: number) => {
|
||||||
|
const safeMinutes = Math.max(0, minutes)
|
||||||
|
const hours = Math.floor(safeMinutes / 60)
|
||||||
|
const rest = safeMinutes % 60
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesToTimeString = (minutes: number | null | undefined): string => {
|
||||||
|
if (minutes === null || minutes === undefined || minutes === 0) return ''
|
||||||
|
return formatMinutes(minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRow = (): DriverHourRow => ({
|
||||||
|
workHourId: null,
|
||||||
|
dayHours: '',
|
||||||
|
nightHours: '',
|
||||||
|
workshopHours: '',
|
||||||
|
hasBreakfast: false,
|
||||||
|
hasLunch: false,
|
||||||
|
hasDinner: false,
|
||||||
|
hasOvernight: false,
|
||||||
|
isSiteValid: false,
|
||||||
|
isValid: false,
|
||||||
|
updatedAt: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isRowLocked = (employeeId: number) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row) return false
|
||||||
|
if (row.isValid) return true
|
||||||
|
if (!isAdmin.value && row.isSiteValid) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const contractLabel = (employee: Employee) => {
|
||||||
|
const contract = employee.contract
|
||||||
|
if (!contract) return '-'
|
||||||
|
return contract.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowMetrics = (employeeId: number) => {
|
||||||
|
const row = rows.value[employeeId] ?? emptyRow()
|
||||||
|
const credited = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
|
||||||
|
const dayMinutes = toMinutes(row.dayHours) + credited
|
||||||
|
const nightMinutes = toMinutes(row.nightHours)
|
||||||
|
const workshopMinutes = toMinutes(row.workshopHours)
|
||||||
|
const totalMinutes = dayMinutes + nightMinutes + workshopMinutes
|
||||||
|
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowAbsenceLabel = (employeeId: number) => {
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (dayRow && dayRow.hasContractAtDate === false) {
|
||||||
|
return 'Contrat non démarré'
|
||||||
|
}
|
||||||
|
if (isSelectedDateHoliday.value) return 'Férié'
|
||||||
|
if (!dayRow?.absenceLabel) return ''
|
||||||
|
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
||||||
|
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
||||||
|
return `${dayRow.absenceLabel} (${halfLabel})`
|
||||||
|
}
|
||||||
|
return `${dayRow.absenceLabel} (journée)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowAbsenceStyle = (employeeId: number) => {
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (dayRow && dayRow.hasContractAtDate === false) {
|
||||||
|
return { backgroundColor: '#6b7280' }
|
||||||
|
}
|
||||||
|
if (!dayRow?.absenceLabel) return undefined
|
||||||
|
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowUpdatedAt = (employeeId: number): string => {
|
||||||
|
const raw = rows.value[employeeId]?.updatedAt
|
||||||
|
if (!raw) return ''
|
||||||
|
const date = new Date(raw)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContractAtSelectedDate = (employeeId: number) => {
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (!dayRow) return true
|
||||||
|
return dayRow.hasContractAtDate !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateRows = (workHours: WorkHour[]) => {
|
||||||
|
const byEmployeeId = new Map<number, WorkHour>()
|
||||||
|
for (const workHour of workHours) {
|
||||||
|
byEmployeeId.set(workHour.employee.id, workHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRows: Record<number, DriverHourRow> = {}
|
||||||
|
for (const employee of employees.value) {
|
||||||
|
if (employee.isDriver !== true) continue
|
||||||
|
const workHour = byEmployeeId.get(employee.id)
|
||||||
|
nextRows[employee.id] = {
|
||||||
|
workHourId: workHour?.id ?? null,
|
||||||
|
dayHours: minutesToTimeString(workHour?.dayHoursMinutes),
|
||||||
|
nightHours: minutesToTimeString(workHour?.nightHoursMinutes),
|
||||||
|
workshopHours: minutesToTimeString(workHour?.workshopHoursMinutes),
|
||||||
|
hasBreakfast: workHour?.hasBreakfast ?? false,
|
||||||
|
hasLunch: workHour?.hasLunch ?? false,
|
||||||
|
hasDinner: workHour?.hasDinner ?? false,
|
||||||
|
hasOvernight: workHour?.hasOvernight ?? false,
|
||||||
|
isSiteValid: workHour?.isSiteValid ?? false,
|
||||||
|
isValid: workHour?.isValid ?? false,
|
||||||
|
updatedAt: workHour?.updatedAt ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.value = nextRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAbsenceTypes = async () => {
|
||||||
|
absenceTypes.value = await listAbsenceTypes()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPublicHolidaysForSelectedYear = async () => {
|
||||||
|
const year = selectedYear.value
|
||||||
|
if (!year) return
|
||||||
|
if (publicHolidaysByYear.value[year]) return
|
||||||
|
|
||||||
|
const holidays = await listPublicHolidays('metropole', year)
|
||||||
|
publicHolidaysByYear.value = {
|
||||||
|
...publicHolidaysByYear.value,
|
||||||
|
[year]: holidays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAbsences = async () => {
|
||||||
|
absences.value = await listAbsences({
|
||||||
|
from: selectedDate.value,
|
||||||
|
to: selectedDate.value,
|
||||||
|
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAbsenceDrawer = (employeeId: number) => {
|
||||||
|
if (!hasContractAtSelectedDate(employeeId)) return
|
||||||
|
if (isSelectedDateHoliday.value) return
|
||||||
|
|
||||||
|
const existing = absences.value.find((absence) => {
|
||||||
|
if (absence.employee?.id !== employeeId) return false
|
||||||
|
const start = absence.startDate.slice(0, 10)
|
||||||
|
const end = absence.endDate.slice(0, 10)
|
||||||
|
return selectedDate.value >= start && selectedDate.value <= end
|
||||||
|
}) ?? null
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
editingAbsence.value = existing
|
||||||
|
absenceForm.value = {
|
||||||
|
employeeId,
|
||||||
|
typeId: existing.type?.id ?? '',
|
||||||
|
startDate: existing.startDate.slice(0, 10),
|
||||||
|
startHalf: existing.startHalf ?? 'AM',
|
||||||
|
endDate: existing.endDate.slice(0, 10),
|
||||||
|
endHalf: existing.endHalf ?? 'PM',
|
||||||
|
comment: existing.comment ?? ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editingAbsence.value = null
|
||||||
|
absenceForm.value = {
|
||||||
|
employeeId,
|
||||||
|
typeId: '',
|
||||||
|
startDate: selectedDate.value,
|
||||||
|
startHalf: 'AM',
|
||||||
|
endDate: selectedDate.value,
|
||||||
|
endHalf: 'PM',
|
||||||
|
comment: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAbsenceDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAfterAbsenceChange = async () => {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
weeklySummary.value = null
|
||||||
|
await Promise.all([loadDayContext(), loadAbsences()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitAbsence = async () => {
|
||||||
|
const form = absenceForm.value
|
||||||
|
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
|
||||||
|
|
||||||
|
isAbsenceSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingAbsence.value) {
|
||||||
|
await updateAbsence({
|
||||||
|
id: editingAbsence.value.id,
|
||||||
|
employeeId: Number(form.employeeId),
|
||||||
|
typeId: Number(form.typeId),
|
||||||
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
|
comment: editingAbsence.value.comment ?? ''
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createAbsence({
|
||||||
|
employeeId: Number(form.employeeId),
|
||||||
|
typeId: Number(form.typeId),
|
||||||
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
|
comment: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAbsenceDrawer()
|
||||||
|
await refreshAfterAbsenceChange()
|
||||||
|
} finally {
|
||||||
|
isAbsenceSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAbsenceFromDrawer = async () => {
|
||||||
|
if (!editingAbsence.value || isAbsenceSubmitting.value) return
|
||||||
|
|
||||||
|
isAbsenceSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await deleteAbsence(editingAbsence.value.id)
|
||||||
|
closeAbsenceDrawer()
|
||||||
|
await refreshAfterAbsenceChange()
|
||||||
|
} finally {
|
||||||
|
isAbsenceSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEmptyDriverEntry = (employeeId: number) => ({
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false,
|
||||||
|
dayHoursMinutes: null,
|
||||||
|
nightHoursMinutes: null,
|
||||||
|
workshopHoursMinutes: null,
|
||||||
|
hasBreakfast: false,
|
||||||
|
hasLunch: false,
|
||||||
|
hasDinner: false,
|
||||||
|
hasOvernight: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleValidation = async (
|
||||||
|
employeeId: number,
|
||||||
|
checked: boolean,
|
||||||
|
options: { toast?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row?.workHourId && checked) {
|
||||||
|
if (canCreateEmptyValidationRow(employeeId)) {
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries: [buildEmptyDriverEntry(employeeId)]
|
||||||
|
}, { toast: false })
|
||||||
|
await loadWorkHours()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRow = rows.value[employeeId]
|
||||||
|
if (!updatedRow?.workHourId) {
|
||||||
|
if (options.toast !== false) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Validation impossible',
|
||||||
|
message: 'La ligne doit contenir des heures ou une absence.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidationPending(employeeId)) return
|
||||||
|
|
||||||
|
validatingRowIds.value = [...validatingRowIds.value, employeeId]
|
||||||
|
try {
|
||||||
|
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
||||||
|
updatedRow.isValid = checked
|
||||||
|
} finally {
|
||||||
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSiteValidation = async (
|
||||||
|
employeeId: number,
|
||||||
|
checked: boolean,
|
||||||
|
options: { toast?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row?.workHourId && checked) {
|
||||||
|
if (canCreateEmptyValidationRow(employeeId)) {
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries: [buildEmptyDriverEntry(employeeId)]
|
||||||
|
}, { toast: false })
|
||||||
|
await loadWorkHours()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRow = rows.value[employeeId]
|
||||||
|
if (!updatedRow?.workHourId) {
|
||||||
|
if (options.toast !== false) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Validation impossible',
|
||||||
|
message: 'La ligne doit contenir des heures ou une absence.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSiteValidationPending(employeeId)) return
|
||||||
|
if (!canToggleSiteValidation(employeeId)) return
|
||||||
|
|
||||||
|
siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId]
|
||||||
|
try {
|
||||||
|
await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
||||||
|
updatedRow.isSiteValid = checked
|
||||||
|
} finally {
|
||||||
|
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleValidationBulk = async (checked: boolean) => {
|
||||||
|
const employeeIds = bulkValidatableEmployeeIds.value
|
||||||
|
if (employeeIds.length === 0) return
|
||||||
|
|
||||||
|
const pendingIds = new Set(validatingRowIds.value)
|
||||||
|
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
|
||||||
|
if (availableEmployeeIds.length === 0) return
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(employeeId))
|
||||||
|
if (toCreateIds.length > 0) {
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
|
||||||
|
}, { toast: false })
|
||||||
|
await loadWorkHours()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
|
||||||
|
if (targetEmployeeIds.length === 0) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Validation impossible',
|
||||||
|
message: 'Aucune ligne ne peut être validée.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validatingRowIds.value = Array.from(new Set([...validatingRowIds.value, ...targetEmployeeIds]))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await bulkUpdateWorkHourValidation({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
isValid: checked,
|
||||||
|
employeeIds: targetEmployeeIds
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
await loadWorkHours()
|
||||||
|
|
||||||
|
if (result.updated === 0) {
|
||||||
|
toast.error({ title: 'Erreur', message: 'Aucune ligne mise à jour.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès partiel',
|
||||||
|
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: checked
|
||||||
|
? `${result.updated} ligne(s) validée(s).`
|
||||||
|
: `${result.updated} validation(s) retirée(s).`
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations.' })
|
||||||
|
} finally {
|
||||||
|
validatingRowIds.value = validatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSiteValidationBulk = async (checked: boolean) => {
|
||||||
|
if (!isSiteManager.value) return
|
||||||
|
|
||||||
|
const employeeIds = bulkSiteValidatableEmployeeIds.value
|
||||||
|
if (employeeIds.length === 0) return
|
||||||
|
|
||||||
|
const pendingIds = new Set(siteValidatingRowIds.value)
|
||||||
|
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
|
||||||
|
if (availableEmployeeIds.length === 0) return
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateSiteValidationRowFromAbsence(employeeId))
|
||||||
|
if (toCreateIds.length > 0) {
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
|
||||||
|
}, { toast: false })
|
||||||
|
await loadWorkHours()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleSiteValidation(employeeId))
|
||||||
|
if (targetEmployeeIds.length === 0) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Validation impossible',
|
||||||
|
message: 'Aucune ligne ne peut être validée côté site.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await bulkUpdateWorkHourSiteValidation({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
isSiteValid: checked,
|
||||||
|
employeeIds: targetEmployeeIds
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
await loadWorkHours()
|
||||||
|
|
||||||
|
if (result.updated === 0) {
|
||||||
|
toast.error({ title: 'Erreur', message: 'Aucune ligne site mise à jour.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès partiel',
|
||||||
|
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: checked
|
||||||
|
? `${result.updated} validation(s) site enregistrée(s).`
|
||||||
|
: `${result.updated} validation(s) site retirée(s).`
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations site.' })
|
||||||
|
} finally {
|
||||||
|
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEmployees = async () => {
|
||||||
|
const scopedEmployees = await listScopedEmployees()
|
||||||
|
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWorkHours = async () => {
|
||||||
|
const workHours = await listWorkHoursByDate(selectedDate.value)
|
||||||
|
hydrateRows(workHours)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWeeklySummary = async () => {
|
||||||
|
isWeekLoading.value = true
|
||||||
|
try {
|
||||||
|
weeklySummary.value = await getWeeklyWorkHourSummary(selectedDate.value)
|
||||||
|
} finally {
|
||||||
|
isWeekLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDayContext = async () => {
|
||||||
|
dayContext.value = await getWorkHourDayContext(selectedDate.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshByDate = async () => {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
weeklySummary.value = null
|
||||||
|
await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
await loadPublicHolidaysForSelectedYear()
|
||||||
|
await loadEmployees()
|
||||||
|
await loadAbsenceTypes()
|
||||||
|
await refreshByDate()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPage)
|
||||||
|
|
||||||
|
watch(sites, (nextSites) => {
|
||||||
|
const currentSiteIds = nextSites.map((site) => site.id)
|
||||||
|
|
||||||
|
if (!sitesInitialized.value) {
|
||||||
|
if (currentSiteIds.length === 0) return
|
||||||
|
selectedSiteIds.value = currentSiteIds
|
||||||
|
sitesInitialized.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(isAdmin, async (admin) => {
|
||||||
|
if (!admin) {
|
||||||
|
viewMode.value = 'day'
|
||||||
|
weeklySummary.value = null
|
||||||
|
await Promise.all([loadAbsenceTypes(), loadAbsences()])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadAbsenceTypes()
|
||||||
|
await loadAbsences()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(selectedDate, async () => {
|
||||||
|
await loadPublicHolidaysForSelectedYear()
|
||||||
|
await refreshByDate()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSubmitting.value || employees.value.length === 0) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const driverEmployees = employees.value.filter(
|
||||||
|
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const entries = driverEmployees.map((employee) => {
|
||||||
|
const employeeId = employee.id
|
||||||
|
const row = rows.value[employeeId] ?? emptyRow()
|
||||||
|
const dayMin = toMinutes(row.dayHours)
|
||||||
|
const nightMin = toMinutes(row.nightHours)
|
||||||
|
const workshopMin = toMinutes(row.workshopHours)
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false,
|
||||||
|
dayHoursMinutes: dayMin || null,
|
||||||
|
nightHoursMinutes: nightMin || null,
|
||||||
|
workshopHoursMinutes: workshopMin || null,
|
||||||
|
hasBreakfast: row.hasBreakfast,
|
||||||
|
hasLunch: row.hasLunch,
|
||||||
|
hasDinner: row.hasDinner,
|
||||||
|
hasOvernight: row.hasOvernight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (entries.length === 0) return
|
||||||
|
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshByDate()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
isSelfUser,
|
||||||
|
isSiteManager,
|
||||||
|
viewMode,
|
||||||
|
selectedDate,
|
||||||
|
employeeFilter,
|
||||||
|
sites,
|
||||||
|
selectedSiteIds,
|
||||||
|
employees,
|
||||||
|
visibleEmployees,
|
||||||
|
displayedEmployees,
|
||||||
|
rows,
|
||||||
|
absenceTypes,
|
||||||
|
absenceForm,
|
||||||
|
isAbsenceDrawerOpen,
|
||||||
|
isAbsenceSubmitting,
|
||||||
|
editingAbsence,
|
||||||
|
weeklySummary,
|
||||||
|
filteredWeeklySummary,
|
||||||
|
isLoading,
|
||||||
|
isWeekLoading,
|
||||||
|
isSubmitting,
|
||||||
|
dayGridCols,
|
||||||
|
weekGridCols,
|
||||||
|
saveButtonClass,
|
||||||
|
formattedSelectedDate,
|
||||||
|
isSelectedDateHoliday,
|
||||||
|
weekDayHeaders,
|
||||||
|
shortcutButtonClass,
|
||||||
|
weekShortcutButtonClass,
|
||||||
|
getWeekShortcutLabel,
|
||||||
|
setToday,
|
||||||
|
setYesterday,
|
||||||
|
setTomorrow,
|
||||||
|
setThisWeek,
|
||||||
|
setPreviousWeek,
|
||||||
|
setNextWeek,
|
||||||
|
shiftDate,
|
||||||
|
contractLabel,
|
||||||
|
isRowLocked,
|
||||||
|
hasContractAtSelectedDate,
|
||||||
|
isValidationPending,
|
||||||
|
isSiteValidationPending,
|
||||||
|
canToggleValidation,
|
||||||
|
canToggleSiteValidation,
|
||||||
|
canCreateSiteValidationRowFromAbsence,
|
||||||
|
isBulkValidationChecked,
|
||||||
|
isBulkValidationIndeterminate,
|
||||||
|
isBulkSiteValidationChecked,
|
||||||
|
isBulkSiteValidationIndeterminate,
|
||||||
|
canBulkToggleSiteValidation,
|
||||||
|
toggleValidation,
|
||||||
|
toggleSiteValidation,
|
||||||
|
toggleValidationBulk,
|
||||||
|
toggleSiteValidationBulk,
|
||||||
|
getRowMetrics,
|
||||||
|
getRowAbsenceLabel,
|
||||||
|
getRowAbsenceStyle,
|
||||||
|
getRowUpdatedAt,
|
||||||
|
openAbsenceDrawer,
|
||||||
|
submitAbsence,
|
||||||
|
deleteAbsenceFromDrawer,
|
||||||
|
closeAbsenceDrawer,
|
||||||
|
formatMinutes,
|
||||||
|
handleSave
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/composables/useEmployeeBonus.ts
Normal file
62
frontend/composables/useEmployeeBonus.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
347
frontend/composables/useEmployeeContract.ts
Normal file
347
frontend/composables/useEmployeeContract.ts
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { Contract } from '~/services/dto/contract'
|
||||||
|
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
||||||
|
import { listContracts } from '~/services/contracts'
|
||||||
|
import { updateEmployee } from '~/services/employees'
|
||||||
|
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
|
||||||
|
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||||
|
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
|
||||||
|
|
||||||
|
type SuspensionForm = {
|
||||||
|
id: number | null
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
comment: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||||
|
const toast = useToast()
|
||||||
|
const contracts = ref<Contract[]>([])
|
||||||
|
const isContractDrawerOpen = ref(false)
|
||||||
|
const isContractSubmitting = ref(false)
|
||||||
|
const isCreateContractDrawerOpen = ref(false)
|
||||||
|
const isCreateContractSubmitting = ref(false)
|
||||||
|
const suspensionForms = ref<SuspensionForm[]>([])
|
||||||
|
const isSuspensionSubmitting = ref(false)
|
||||||
|
|
||||||
|
const contractForm = reactive({
|
||||||
|
contractId: '' as number | '',
|
||||||
|
contractName: '',
|
||||||
|
weeklyHours: null as number | null,
|
||||||
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
paidLeaveSettled: false,
|
||||||
|
comment: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const validationTouched = reactive({
|
||||||
|
endDate: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const createContractForm = reactive({
|
||||||
|
contractId: '' as number | '',
|
||||||
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
isDriver: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const createValidationTouched = reactive({
|
||||||
|
contractId: false,
|
||||||
|
contractNature: false,
|
||||||
|
startDate: false,
|
||||||
|
endDate: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
|
||||||
|
|
||||||
|
const contractHistoryLabel = (item: ContractHistoryItem) => {
|
||||||
|
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
|
||||||
|
return `${item.weeklyHours} heures`
|
||||||
|
}
|
||||||
|
return item.contractName ?? '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentActiveContractPeriod = computed(() => {
|
||||||
|
const today = getTodayYmd()
|
||||||
|
const history = employee.value?.contractHistory ?? []
|
||||||
|
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentActiveContractPeriodId = computed<number | null>(() => {
|
||||||
|
const period = currentActiveContractPeriod.value
|
||||||
|
return period?.periodId ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCloseCurrentContract = computed(() => {
|
||||||
|
const active = currentActiveContractPeriod.value
|
||||||
|
if (!active) return false
|
||||||
|
if (!active.endDate) return true
|
||||||
|
return active.endDate > getTodayYmd()
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCreateContract = computed(() => {
|
||||||
|
const active = currentActiveContractPeriod.value
|
||||||
|
if (!active) return true
|
||||||
|
return !!active.endDate
|
||||||
|
})
|
||||||
|
|
||||||
|
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
|
||||||
|
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
|
||||||
|
|
||||||
|
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
|
||||||
|
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
|
||||||
|
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
|
||||||
|
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
|
||||||
|
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
|
||||||
|
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
|
||||||
|
const isCreateContractFormValid = computed(() =>
|
||||||
|
isCreateContractValid.value &&
|
||||||
|
isCreateContractNatureValid.value &&
|
||||||
|
isCreateContractStartDateValid.value &&
|
||||||
|
isCreateContractEndDateValid.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseInputClass =
|
||||||
|
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||||
|
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
|
||||||
|
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||||
|
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||||
|
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
||||||
|
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
||||||
|
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||||
|
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||||
|
const closeContractWorkedHoursLabel = computed(() => {
|
||||||
|
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
|
||||||
|
return contractForm.contractName || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetContractValidation = () => {
|
||||||
|
validationTouched.endDate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateSuspensionForms = () => {
|
||||||
|
const current = employee.value?.currentSuspensions ?? []
|
||||||
|
suspensionForms.value = current.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
startDate: s.startDate,
|
||||||
|
endDate: s.endDate ?? '',
|
||||||
|
comment: s.comment ?? ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateContractFormFromCurrent = () => {
|
||||||
|
const current = employee.value
|
||||||
|
const active = currentActiveContractPeriod.value
|
||||||
|
if (!current || !active) return
|
||||||
|
|
||||||
|
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
|
||||||
|
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
|
||||||
|
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
|
||||||
|
contractForm.contractNature = active.contractNature
|
||||||
|
contractForm.startDate = active.startDate
|
||||||
|
contractForm.endDate = getTodayYmd()
|
||||||
|
contractForm.paidLeaveSettled = false
|
||||||
|
contractForm.comment = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCloseContractDrawer = () => {
|
||||||
|
if (!employee.value || !canCloseCurrentContract.value) return
|
||||||
|
hydrateContractFormFromCurrent()
|
||||||
|
resetContractValidation()
|
||||||
|
hydrateSuspensionForms()
|
||||||
|
isContractDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setContractDrawerOpen = (open: boolean) => {
|
||||||
|
isContractDrawerOpen.value = open
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCreateValidation = () => {
|
||||||
|
createValidationTouched.contractId = false
|
||||||
|
createValidationTouched.contractNature = false
|
||||||
|
createValidationTouched.startDate = false
|
||||||
|
createValidationTouched.endDate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateContractDrawer = () => {
|
||||||
|
if (!employee.value || !canCreateContract.value) return
|
||||||
|
createContractForm.contractId = ''
|
||||||
|
createContractForm.contractNature = 'CDI'
|
||||||
|
createContractForm.endDate = ''
|
||||||
|
createContractForm.isDriver = false
|
||||||
|
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
||||||
|
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
||||||
|
: getTodayYmd()
|
||||||
|
resetCreateValidation()
|
||||||
|
isCreateContractDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCreateContractDrawerOpen = (open: boolean) => {
|
||||||
|
isCreateContractDrawerOpen.value = open
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitContractUpdate = async () => {
|
||||||
|
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
|
||||||
|
|
||||||
|
validationTouched.endDate = true
|
||||||
|
if (!isContractEndDateValid.value) return
|
||||||
|
|
||||||
|
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isContractSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await updateEmployee(employee.value.id, {
|
||||||
|
firstName: employee.value.firstName,
|
||||||
|
lastName: employee.value.lastName,
|
||||||
|
siteId: employee.value.site?.id ?? null,
|
||||||
|
contractId: Number(contractForm.contractId),
|
||||||
|
contractEndDate: contractForm.endDate || null,
|
||||||
|
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
|
||||||
|
contractComment: contractForm.comment || null
|
||||||
|
})
|
||||||
|
|
||||||
|
isContractDrawerOpen.value = false
|
||||||
|
await reloadEmployee()
|
||||||
|
} finally {
|
||||||
|
isContractSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCreateContract = async () => {
|
||||||
|
if (!employee.value || isCreateContractSubmitting.value) return
|
||||||
|
|
||||||
|
createValidationTouched.contractId = true
|
||||||
|
createValidationTouched.contractNature = true
|
||||||
|
createValidationTouched.startDate = true
|
||||||
|
createValidationTouched.endDate = true
|
||||||
|
if (!isCreateContractFormValid.value) return
|
||||||
|
|
||||||
|
if (currentActiveContractPeriod.value?.endDate) {
|
||||||
|
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
|
||||||
|
if (createContractForm.startDate < minStartDate) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreateContractSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await updateEmployee(employee.value.id, {
|
||||||
|
firstName: employee.value.firstName,
|
||||||
|
lastName: employee.value.lastName,
|
||||||
|
siteId: employee.value.site?.id ?? null,
|
||||||
|
contractId: Number(createContractForm.contractId),
|
||||||
|
contractNature: createContractForm.contractNature,
|
||||||
|
contractStartDate: createContractForm.startDate,
|
||||||
|
contractEndDate: createContractForm.endDate || null,
|
||||||
|
isDriverInput: createContractForm.isDriver
|
||||||
|
})
|
||||||
|
isCreateContractDrawerOpen.value = false
|
||||||
|
await reloadEmployee()
|
||||||
|
} finally {
|
||||||
|
isCreateContractSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitSuspension = async (index: number) => {
|
||||||
|
const form = suspensionForms.value[index]
|
||||||
|
if (!form || !form.startDate) return
|
||||||
|
|
||||||
|
const periodId = currentActiveContractPeriodId.value
|
||||||
|
if (!periodId) return
|
||||||
|
|
||||||
|
isSuspensionSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (form.id) {
|
||||||
|
await updateSuspension(form.id, {
|
||||||
|
startDate: form.startDate,
|
||||||
|
endDate: form.endDate || null,
|
||||||
|
comment: form.comment || null
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createSuspension({
|
||||||
|
contractPeriodId: periodId,
|
||||||
|
startDate: form.startDate,
|
||||||
|
endDate: form.endDate || null,
|
||||||
|
comment: form.comment || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await reloadEmployee()
|
||||||
|
hydrateSuspensionForms()
|
||||||
|
} finally {
|
||||||
|
isSuspensionSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSuspensionForm = () => {
|
||||||
|
suspensionForms.value.push({
|
||||||
|
id: null,
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
comment: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadContracts = async () => {
|
||||||
|
contracts.value = await listContracts()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showsCreateContractEndDate, (shows) => {
|
||||||
|
if (!shows) {
|
||||||
|
createContractForm.endDate = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
contracts,
|
||||||
|
contractHistory,
|
||||||
|
contractForm,
|
||||||
|
createContractForm,
|
||||||
|
isContractDrawerOpen,
|
||||||
|
isContractSubmitting,
|
||||||
|
isCreateContractDrawerOpen,
|
||||||
|
isCreateContractSubmitting,
|
||||||
|
canCloseCurrentContract,
|
||||||
|
canCreateContract,
|
||||||
|
readonlyFieldClass,
|
||||||
|
closeContractWorkedHoursLabel,
|
||||||
|
contractEndDateFieldClass,
|
||||||
|
showContractEndDateError,
|
||||||
|
isContractEndDateValid,
|
||||||
|
createContractNatureFieldClass,
|
||||||
|
createContractFieldClass,
|
||||||
|
createContractStartDateFieldClass,
|
||||||
|
showsCreateContractEndDate,
|
||||||
|
requiresCreateContractEndDate,
|
||||||
|
createContractEndDateFieldClass,
|
||||||
|
isCreateContractFormValid,
|
||||||
|
contractNatureLabel,
|
||||||
|
contractHistoryLabel,
|
||||||
|
formatDate,
|
||||||
|
openCloseContractDrawer,
|
||||||
|
openCreateContractDrawer,
|
||||||
|
setContractDrawerOpen,
|
||||||
|
setCreateContractDrawerOpen,
|
||||||
|
submitContractUpdate,
|
||||||
|
submitCreateContract,
|
||||||
|
suspensionForms,
|
||||||
|
isSuspensionSubmitting,
|
||||||
|
submitSuspension,
|
||||||
|
addSuspensionForm,
|
||||||
|
currentActiveContractPeriodId,
|
||||||
|
loadContracts
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,76 +1,15 @@
|
|||||||
import type { Contract } from '~/services/dto/contract'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import type { Absence } from '~/services/dto/absence'
|
|
||||||
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
|
||||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
|
||||||
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
|
||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { listAbsences } from '~/services/absences'
|
import { getEmployee } from '~/services/employees'
|
||||||
import { listContracts } from '~/services/contracts'
|
|
||||||
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
|
|
||||||
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
|
||||||
import { getEmployee, updateEmployee } from '~/services/employees'
|
|
||||||
import { listPublicHolidays } from '~/services/public-holidays'
|
|
||||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
|
||||||
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
|
|
||||||
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
|
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
export const useEmployeeDetailPage = () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
|
||||||
const employee = ref<Employee | null>(null)
|
const employee = ref<Employee | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus'>('contract')
|
||||||
const contracts = ref<Contract[]>([])
|
|
||||||
const employeeAbsences = ref<Absence[]>([])
|
|
||||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
|
||||||
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
|
||||||
const publicHolidays = ref<Record<string, string>>({})
|
|
||||||
const isContractDrawerOpen = ref(false)
|
|
||||||
const isContractSubmitting = ref(false)
|
|
||||||
const isCreateContractDrawerOpen = ref(false)
|
|
||||||
const isCreateContractSubmitting = ref(false)
|
|
||||||
|
|
||||||
type SuspensionForm = {
|
|
||||||
id: number | null
|
|
||||||
startDate: string
|
|
||||||
endDate: string
|
|
||||||
comment: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const suspensionForms = ref<SuspensionForm[]>([])
|
|
||||||
const isSuspensionSubmitting = ref(false)
|
|
||||||
|
|
||||||
const contractForm = reactive({
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractName: '',
|
|
||||||
weeklyHours: null as number | null,
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
startDate: '',
|
|
||||||
endDate: '',
|
|
||||||
paidLeaveSettled: false,
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
endDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const createContractForm = reactive({
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
startDate: '',
|
|
||||||
endDate: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const createValidationTouched = reactive({
|
|
||||||
contractId: false,
|
|
||||||
contractNature: false,
|
|
||||||
startDate: false,
|
|
||||||
endDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
|
|
||||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||||
|
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
||||||
const employeeContractWorkLabel = computed(() => {
|
const employeeContractWorkLabel = computed(() => {
|
||||||
const contract = employee.value?.contract
|
const contract = employee.value?.contract
|
||||||
if (!contract) return '-'
|
if (!contract) return '-'
|
||||||
@@ -79,133 +18,6 @@ export const useEmployeeDetailPage = () => {
|
|||||||
return contract.name || '-'
|
return contract.name || '-'
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
|
|
||||||
|
|
||||||
const contractHistoryLabel = (item: ContractHistoryItem) => {
|
|
||||||
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
|
|
||||||
return `${item.weeklyHours} heures`
|
|
||||||
}
|
|
||||||
return item.contractName ?? '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentActiveContractPeriod = computed(() => {
|
|
||||||
const today = getTodayYmd()
|
|
||||||
const history = employee.value?.contractHistory ?? []
|
|
||||||
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentActiveContractPeriodId = computed<number | null>(() => {
|
|
||||||
const period = currentActiveContractPeriod.value
|
|
||||||
return period?.periodId ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCloseCurrentContract = computed(() => {
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!active) return false
|
|
||||||
if (!active.endDate) return true
|
|
||||||
return active.endDate > getTodayYmd()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCreateContract = computed(() => {
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!active) return true
|
|
||||||
return !!active.endDate
|
|
||||||
})
|
|
||||||
|
|
||||||
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
|
|
||||||
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
|
|
||||||
|
|
||||||
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
|
|
||||||
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
|
|
||||||
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
|
|
||||||
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
|
|
||||||
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
|
|
||||||
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
|
|
||||||
const isCreateContractFormValid = computed(() =>
|
|
||||||
isCreateContractValid.value &&
|
|
||||||
isCreateContractNatureValid.value &&
|
|
||||||
isCreateContractStartDateValid.value &&
|
|
||||||
isCreateContractEndDateValid.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const baseInputClass =
|
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
|
||||||
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
|
|
||||||
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
|
||||||
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
|
||||||
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const closeContractWorkedHoursLabel = computed(() => {
|
|
||||||
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
|
|
||||||
return contractForm.contractName || '-'
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetContractValidation = () => {
|
|
||||||
validationTouched.endDate = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateSuspensionForms = () => {
|
|
||||||
const current = employee.value?.currentSuspensions ?? []
|
|
||||||
suspensionForms.value = current.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
startDate: s.startDate,
|
|
||||||
endDate: s.endDate ?? '',
|
|
||||||
comment: s.comment ?? ''
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateContractFormFromCurrent = () => {
|
|
||||||
const current = employee.value
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!current || !active) return
|
|
||||||
|
|
||||||
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
|
|
||||||
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
|
|
||||||
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
|
|
||||||
contractForm.contractNature = active.contractNature
|
|
||||||
contractForm.startDate = active.startDate
|
|
||||||
contractForm.endDate = getTodayYmd()
|
|
||||||
contractForm.paidLeaveSettled = false
|
|
||||||
contractForm.comment = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCloseContractDrawer = () => {
|
|
||||||
if (!employee.value || !canCloseCurrentContract.value) return
|
|
||||||
hydrateContractFormFromCurrent()
|
|
||||||
resetContractValidation()
|
|
||||||
hydrateSuspensionForms()
|
|
||||||
isContractDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const setContractDrawerOpen = (open: boolean) => {
|
|
||||||
isContractDrawerOpen.value = open
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetCreateValidation = () => {
|
|
||||||
createValidationTouched.contractId = false
|
|
||||||
createValidationTouched.contractNature = false
|
|
||||||
createValidationTouched.startDate = false
|
|
||||||
createValidationTouched.endDate = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreateContractDrawer = () => {
|
|
||||||
if (!employee.value || !canCreateContract.value) return
|
|
||||||
createContractForm.contractId = ''
|
|
||||||
createContractForm.contractNature = 'CDI'
|
|
||||||
createContractForm.endDate = ''
|
|
||||||
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
|
||||||
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
|
||||||
: getTodayYmd()
|
|
||||||
resetCreateValidation()
|
|
||||||
isCreateContractDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const setCreateContractDrawerOpen = (open: boolean) => {
|
|
||||||
isCreateContractDrawerOpen.value = open
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadEmployee = async () => {
|
const loadEmployee = async () => {
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
const employeeId = Number(idParam)
|
const employeeId = Number(idParam)
|
||||||
@@ -215,180 +27,54 @@ export const useEmployeeDetailPage = () => {
|
|||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const loadedEmployee = await getEmployee(employeeId)
|
employee.value = await getEmployee(employeeId)
|
||||||
employee.value = loadedEmployee
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const isForfait = loadedEmployee.contract?.type === CONTRACT_TYPES.FORFAIT
|
|
||||||
const leaveYear = isForfait
|
|
||||||
? now.getFullYear()
|
|
||||||
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
|
|
||||||
const rttYear = now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()
|
|
||||||
const from = isForfait
|
|
||||||
? `${leaveYear}-01-01`
|
|
||||||
: `${leaveYear - 1}-06-01`
|
|
||||||
const to = isForfait
|
|
||||||
? `${leaveYear}-12-31`
|
|
||||||
: `${leaveYear}-05-31`
|
|
||||||
const holidayYears = isForfait
|
|
||||||
? [leaveYear]
|
|
||||||
: [leaveYear - 1, leaveYear]
|
|
||||||
const [absences, summary, rtt, ...holidayResults] = await Promise.all([
|
|
||||||
listAbsences({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
employeeId: loadedEmployee.id
|
|
||||||
}),
|
|
||||||
showLeaveTab.value
|
|
||||||
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
getEmployeeRttSummary(loadedEmployee.id, rttYear),
|
|
||||||
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
|
||||||
])
|
|
||||||
employeeAbsences.value = absences
|
|
||||||
leaveSummary.value = summary
|
|
||||||
rttSummary.value = rtt
|
|
||||||
publicHolidays.value = Object.assign({}, ...holidayResults)
|
|
||||||
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
||||||
activeTab.value = 'contract'
|
activeTab.value = 'contract'
|
||||||
}
|
}
|
||||||
|
if (!showRttTab.value && activeTab.value === 'rtt') {
|
||||||
|
activeTab.value = 'contract'
|
||||||
|
}
|
||||||
|
|
||||||
|
leave.resetLoaded()
|
||||||
|
rtt.resetLoaded()
|
||||||
|
mileage.resetLoaded()
|
||||||
|
bonus.resetLoaded()
|
||||||
|
|
||||||
|
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||||
|
await leave.loadLeaveData()
|
||||||
|
} else if (activeTab.value === 'rtt' && showRttTab.value) {
|
||||||
|
await rtt.loadRttData()
|
||||||
|
} else if (activeTab.value === 'mileage') {
|
||||||
|
await mileage.loadMileageData()
|
||||||
|
} else if (activeTab.value === 'bonus') {
|
||||||
|
await bonus.loadBonusData()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitContractUpdate = async () => {
|
const contract = useEmployeeContract(employee, loadEmployee)
|
||||||
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
|
const leave = useEmployeeLeave(employee, loadEmployee)
|
||||||
|
const rtt = useEmployeeRtt(employee, loadEmployee)
|
||||||
|
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||||
|
const bonus = useEmployeeBonus(employee, loadEmployee)
|
||||||
|
|
||||||
validationTouched.endDate = true
|
watch(activeTab, (tab) => {
|
||||||
if (!isContractEndDateValid.value) return
|
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||||
|
leave.loadLeaveData()
|
||||||
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
|
} else if (tab === 'rtt' && !rtt.rttDataLoaded.value && showRttTab.value) {
|
||||||
toast.error({
|
rtt.loadRttData()
|
||||||
title: 'Erreur',
|
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
|
||||||
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
|
mileage.loadMileageData()
|
||||||
})
|
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
|
||||||
return
|
bonus.loadBonusData()
|
||||||
}
|
|
||||||
|
|
||||||
isContractSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await updateEmployee(employee.value.id, {
|
|
||||||
firstName: employee.value.firstName,
|
|
||||||
lastName: employee.value.lastName,
|
|
||||||
siteId: employee.value.site?.id ?? null,
|
|
||||||
contractId: Number(contractForm.contractId),
|
|
||||||
contractEndDate: contractForm.endDate || null,
|
|
||||||
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
|
|
||||||
contractComment: contractForm.comment || null
|
|
||||||
})
|
|
||||||
|
|
||||||
isContractDrawerOpen.value = false
|
|
||||||
await loadEmployee()
|
|
||||||
} finally {
|
|
||||||
isContractSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitCreateContract = async () => {
|
|
||||||
if (!employee.value || isCreateContractSubmitting.value) return
|
|
||||||
|
|
||||||
createValidationTouched.contractId = true
|
|
||||||
createValidationTouched.contractNature = true
|
|
||||||
createValidationTouched.startDate = true
|
|
||||||
createValidationTouched.endDate = true
|
|
||||||
if (!isCreateContractFormValid.value) return
|
|
||||||
|
|
||||||
if (currentActiveContractPeriod.value?.endDate) {
|
|
||||||
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
|
|
||||||
if (createContractForm.startDate < minStartDate) {
|
|
||||||
toast.error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreateContractSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await updateEmployee(employee.value.id, {
|
|
||||||
firstName: employee.value.firstName,
|
|
||||||
lastName: employee.value.lastName,
|
|
||||||
siteId: employee.value.site?.id ?? null,
|
|
||||||
contractId: Number(createContractForm.contractId),
|
|
||||||
contractNature: createContractForm.contractNature,
|
|
||||||
contractStartDate: createContractForm.startDate,
|
|
||||||
contractEndDate: createContractForm.endDate || null
|
|
||||||
})
|
|
||||||
isCreateContractDrawerOpen.value = false
|
|
||||||
await loadEmployee()
|
|
||||||
} finally {
|
|
||||||
isCreateContractSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitSuspension = async (index: number) => {
|
|
||||||
const form = suspensionForms.value[index]
|
|
||||||
if (!form || !form.startDate) return
|
|
||||||
|
|
||||||
const periodId = currentActiveContractPeriodId.value
|
|
||||||
if (!periodId) return
|
|
||||||
|
|
||||||
isSuspensionSubmitting.value = true
|
|
||||||
try {
|
|
||||||
if (form.id) {
|
|
||||||
await updateSuspension(form.id, {
|
|
||||||
startDate: form.startDate,
|
|
||||||
endDate: form.endDate || null,
|
|
||||||
comment: form.comment || null
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await createSuspension({
|
|
||||||
contractPeriodId: periodId,
|
|
||||||
startDate: form.startDate,
|
|
||||||
endDate: form.endDate || null,
|
|
||||||
comment: form.comment || null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await loadEmployee()
|
|
||||||
hydrateSuspensionForms()
|
|
||||||
} finally {
|
|
||||||
isSuspensionSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSuspensionForm = () => {
|
|
||||||
suspensionForms.value.push({
|
|
||||||
id: null,
|
|
||||||
startDate: '',
|
|
||||||
endDate: '',
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitFractionedDays = async (days: number) => {
|
|
||||||
if (!employee.value) return
|
|
||||||
const year = leaveSummary.value?.year ?? undefined
|
|
||||||
await updateFractionedDays(employee.value.id, days, year)
|
|
||||||
await loadEmployee()
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => {
|
|
||||||
if (!employee.value) return
|
|
||||||
const year = rttSummary.value?.year ?? undefined
|
|
||||||
await createRttPayment(employee.value.id, month, minutes, rate, year)
|
|
||||||
await loadEmployee()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(showsCreateContractEndDate, (shows) => {
|
|
||||||
if (!shows) {
|
|
||||||
createContractForm.endDate = ''
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
contracts.value = await listContracts()
|
await contract.loadContracts()
|
||||||
await loadEmployee()
|
await loadEmployee()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -396,49 +82,13 @@ export const useEmployeeDetailPage = () => {
|
|||||||
employee,
|
employee,
|
||||||
isLoading,
|
isLoading,
|
||||||
activeTab,
|
activeTab,
|
||||||
contracts,
|
|
||||||
employeeAbsences,
|
|
||||||
leaveSummary,
|
|
||||||
rttSummary,
|
|
||||||
publicHolidays,
|
|
||||||
showLeaveTab,
|
showLeaveTab,
|
||||||
contractHistory,
|
showRttTab,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
contractForm,
|
...contract,
|
||||||
createContractForm,
|
...leave,
|
||||||
isContractDrawerOpen,
|
...rtt,
|
||||||
isContractSubmitting,
|
...mileage,
|
||||||
isCreateContractDrawerOpen,
|
...bonus
|
||||||
isCreateContractSubmitting,
|
|
||||||
canCloseCurrentContract,
|
|
||||||
canCreateContract,
|
|
||||||
readonlyFieldClass,
|
|
||||||
closeContractWorkedHoursLabel,
|
|
||||||
contractEndDateFieldClass,
|
|
||||||
showContractEndDateError,
|
|
||||||
isContractEndDateValid,
|
|
||||||
createContractNatureFieldClass,
|
|
||||||
createContractFieldClass,
|
|
||||||
createContractStartDateFieldClass,
|
|
||||||
showsCreateContractEndDate,
|
|
||||||
requiresCreateContractEndDate,
|
|
||||||
createContractEndDateFieldClass,
|
|
||||||
isCreateContractFormValid,
|
|
||||||
contractNatureLabel,
|
|
||||||
contractHistoryLabel,
|
|
||||||
formatDate,
|
|
||||||
openCloseContractDrawer,
|
|
||||||
openCreateContractDrawer,
|
|
||||||
setContractDrawerOpen,
|
|
||||||
setCreateContractDrawerOpen,
|
|
||||||
submitContractUpdate,
|
|
||||||
submitCreateContract,
|
|
||||||
submitFractionedDays,
|
|
||||||
submitRttPayment,
|
|
||||||
suspensionForms,
|
|
||||||
isSuspensionSubmitting,
|
|
||||||
submitSuspension,
|
|
||||||
addSuspensionForm,
|
|
||||||
currentActiveContractPeriodId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
frontend/composables/useEmployeeLeave.ts
Normal file
70
frontend/composables/useEmployeeLeave.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { Absence } from '~/services/dto/absence'
|
||||||
|
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
|
import { listAbsences } from '~/services/absences'
|
||||||
|
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
|
||||||
|
import { listPublicHolidays } from '~/services/public-holidays'
|
||||||
|
|
||||||
|
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||||
|
const employeeAbsences = ref<Absence[]>([])
|
||||||
|
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
||||||
|
const publicHolidays = ref<Record<string, string>>({})
|
||||||
|
const isLeaveLoading = ref(false)
|
||||||
|
const leaveDataLoaded = ref(false)
|
||||||
|
|
||||||
|
const getLeaveYear = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||||
|
return isForfait
|
||||||
|
? now.getFullYear()
|
||||||
|
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLeaveData = async () => {
|
||||||
|
if (!employee.value || isLeaveLoading.value) return
|
||||||
|
isLeaveLoading.value = true
|
||||||
|
try {
|
||||||
|
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||||
|
const leaveYear = getLeaveYear()
|
||||||
|
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
||||||
|
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
||||||
|
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
||||||
|
|
||||||
|
const [absences, summary, ...holidayResults] = await Promise.all([
|
||||||
|
listAbsences({ from, to, employeeId: employee.value.id }),
|
||||||
|
getEmployeeLeaveSummary(employee.value.id, leaveYear),
|
||||||
|
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
||||||
|
])
|
||||||
|
employeeAbsences.value = absences
|
||||||
|
leaveSummary.value = summary
|
||||||
|
publicHolidays.value = Object.assign({}, ...holidayResults)
|
||||||
|
leaveDataLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
isLeaveLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetLoaded = () => {
|
||||||
|
leaveDataLoaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitFractionedDays = async (days: number) => {
|
||||||
|
if (!employee.value) return
|
||||||
|
const year = leaveSummary.value?.year ?? undefined
|
||||||
|
await updateFractionedDays(employee.value.id, days, year)
|
||||||
|
await reloadEmployee()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeAbsences,
|
||||||
|
leaveSummary,
|
||||||
|
publicHolidays,
|
||||||
|
isLeaveLoading,
|
||||||
|
leaveDataLoaded,
|
||||||
|
loadLeaveData,
|
||||||
|
resetLoaded,
|
||||||
|
submitFractionedDays
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/composables/useEmployeeMileage.ts
Normal file
73
frontend/composables/useEmployeeMileage.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/composables/useEmployeeRtt.ts
Normal file
42
frontend/composables/useEmployeeRtt.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
||||||
|
|
||||||
|
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||||
|
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
||||||
|
const isRttLoading = ref(false)
|
||||||
|
const rttDataLoaded = ref(false)
|
||||||
|
|
||||||
|
const loadRttData = async () => {
|
||||||
|
if (!employee.value || isRttLoading.value) return
|
||||||
|
isRttLoading.value = true
|
||||||
|
try {
|
||||||
|
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
|
||||||
|
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
|
||||||
|
rttDataLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
isRttLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetLoaded = () => {
|
||||||
|
rttDataLoaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
|
||||||
|
if (!employee.value) return
|
||||||
|
const year = rttSummary.value?.year ?? undefined
|
||||||
|
await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year)
|
||||||
|
await reloadEmployee()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rttSummary,
|
||||||
|
isRttLoading,
|
||||||
|
rttDataLoaded,
|
||||||
|
loadRttData,
|
||||||
|
resetLoaded,
|
||||||
|
submitRttPayment
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ export const useHoursPage = () => {
|
|||||||
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
|
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr) 0.3fr'
|
||||||
|
|
||||||
const sites = computed<Site[]>(() => {
|
const sites = computed<Site[]>(() => {
|
||||||
const siteMap = new Map<number, Site>()
|
const siteMap = new Map<number, Site>()
|
||||||
@@ -99,6 +99,7 @@ export const useHoursPage = () => {
|
|||||||
if (selectedSiteIds.value.length === 0) return []
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
return employees.value.filter((employee) => {
|
return employees.value.filter((employee) => {
|
||||||
|
if (employee.isDriver === true) return false
|
||||||
const siteId = employee.site?.id
|
const siteId = employee.site?.id
|
||||||
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
||||||
if (!filter) return true
|
if (!filter) return true
|
||||||
@@ -108,13 +109,19 @@ export const useHoursPage = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const displayedEmployees = computed(() => {
|
||||||
|
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
|
||||||
|
})
|
||||||
|
|
||||||
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
|
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
|
||||||
|
|
||||||
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
|
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
|
||||||
if (!weeklySummary.value) return null
|
if (!weeklySummary.value) return null
|
||||||
return {
|
return {
|
||||||
...weeklySummary.value,
|
...weeklySummary.value,
|
||||||
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId))
|
rows: weeklySummary.value.rows.filter((row) =>
|
||||||
|
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,19 +145,17 @@ export const useHoursPage = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const canCreateValidationRowFromAbsence = (employeeId: number) => {
|
const canCreateEmptyValidationRow = (employeeId: number) => {
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
if (row?.workHourId) return false
|
if (row?.workHourId) return false
|
||||||
|
if (!hasContractAtSelectedDate(employeeId)) return false
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
|
return !!dayRow?.absenceLabel || is4hContract(employeeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => {
|
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
|
||||||
const row = rows.value[employeeId]
|
|
||||||
if (row?.workHourId) return false
|
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
||||||
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bulkValidatableEmployeeIds = computed(() => {
|
const bulkValidatableEmployeeIds = computed(() => {
|
||||||
return visibleEmployees.value
|
return visibleEmployees.value
|
||||||
@@ -347,6 +352,10 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
||||||
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
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 isRowLocked = (employeeId: number) => {
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
if (!row) return false
|
if (!row) return false
|
||||||
@@ -460,6 +469,9 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
const getRowAbsenceStyle = (employeeId: number) => {
|
const getRowAbsenceStyle = (employeeId: number) => {
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (dayRow && dayRow.hasContractAtDate === false) {
|
||||||
|
return { backgroundColor: '#6b7280' }
|
||||||
|
}
|
||||||
if (!dayRow?.absenceLabel) return undefined
|
if (!dayRow?.absenceLabel) return undefined
|
||||||
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
|
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
|
||||||
}
|
}
|
||||||
@@ -692,13 +704,8 @@ export const useHoursPage = () => {
|
|||||||
options: { toast?: boolean } = {}
|
options: { toast?: boolean } = {}
|
||||||
) => {
|
) => {
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
||||||
if (!row?.workHourId && checked) {
|
if (!row?.workHourId && checked) {
|
||||||
const employee = employees.value.find((item) => item.id === employeeId)
|
if (canCreateEmptyValidationRow(employeeId)) {
|
||||||
const hasAbsence = !!dayRow?.absenceLabel
|
|
||||||
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
|
|
||||||
|
|
||||||
if (canCreateFromAbsence) {
|
|
||||||
await bulkUpsertWorkHours({
|
await bulkUpsertWorkHours({
|
||||||
workDate: selectedDate.value,
|
workDate: selectedDate.value,
|
||||||
entries: [{
|
entries: [{
|
||||||
@@ -746,13 +753,8 @@ export const useHoursPage = () => {
|
|||||||
options: { toast?: boolean } = {}
|
options: { toast?: boolean } = {}
|
||||||
) => {
|
) => {
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
||||||
if (!row?.workHourId && checked) {
|
if (!row?.workHourId && checked) {
|
||||||
const employee = employees.value.find((item) => item.id === employeeId)
|
if (canCreateEmptyValidationRow(employeeId)) {
|
||||||
const hasAbsence = !!dayRow?.absenceLabel
|
|
||||||
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
|
|
||||||
|
|
||||||
if (canCreateFromAbsence) {
|
|
||||||
await bulkUpsertWorkHours({
|
await bulkUpsertWorkHours({
|
||||||
workDate: selectedDate.value,
|
workDate: selectedDate.value,
|
||||||
entries: [{
|
entries: [{
|
||||||
@@ -1043,7 +1045,7 @@ export const useHoursPage = () => {
|
|||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const entries = employees.value
|
const entries = employees.value
|
||||||
.filter((employee) => hasContractAtSelectedDate(employee.id))
|
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
||||||
.map((employee) => {
|
.map((employee) => {
|
||||||
const employeeId = employee.id
|
const employeeId = employee.id
|
||||||
const row = rows.value[employeeId] ?? emptyRow()
|
const row = rows.value[employeeId] ?? emptyRow()
|
||||||
@@ -1100,6 +1102,7 @@ export const useHoursPage = () => {
|
|||||||
selectedSiteIds,
|
selectedSiteIds,
|
||||||
employees,
|
employees,
|
||||||
visibleEmployees,
|
visibleEmployees,
|
||||||
|
displayedEmployees,
|
||||||
rows,
|
rows,
|
||||||
absenceTypes,
|
absenceTypes,
|
||||||
absenceForm,
|
absenceForm,
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
"create": "Impossible de créer l'utilisateur.",
|
"create": "Impossible de créer l'utilisateur.",
|
||||||
"update": "Impossible de mettre à jour l'utilisateur.",
|
"update": "Impossible de mettre à jour l'utilisateur.",
|
||||||
"delete": "Impossible de supprimer 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": {
|
"success": {
|
||||||
@@ -67,6 +77,16 @@
|
|||||||
"create": "Utilisateur créé.",
|
"create": "Utilisateur créé.",
|
||||||
"update": "Utilisateur mis à jour.",
|
"update": "Utilisateur mis à jour.",
|
||||||
"delete": "Utilisateur supprimé."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,25 @@
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/hours"
|
to="/hours"
|
||||||
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
:class="route.path.startsWith('/hours')
|
:class="[
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
||||||
: ''"
|
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
||||||
<p>Heures</p>
|
<p>Heures</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="isAdmin"
|
||||||
|
to="/driver-hours"
|
||||||
|
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
|
:class="route.path.startsWith('/driver-hours')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:truck-outline" size="24"/>
|
||||||
|
<p>Heures Conducteurs</p>
|
||||||
|
</NuxtLink>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
to="/employees"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
<div class="flex items-center justify-between pb-12">
|
<div class="flex items-center justify-between pb-6">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Types d'absence</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Types d'absence</h1>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
>
|
||||||
Ajouter un type
|
+ Ajouter un type
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,33 +18,33 @@
|
|||||||
Aucun type pour le moment.
|
Aucun type pour le moment.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="min-h-0 overflow-auto rounded-md 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">
|
<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">Code</span>
|
<span>Code</span>
|
||||||
<span class="text-left">Libellé</span>
|
<span>Libellé</span>
|
||||||
<span class="text-left">Couleur</span>
|
<span>Couleur</span>
|
||||||
<span class="text-left">Compte en heures</span>
|
<span>Compte en heures</span>
|
||||||
<span class="text-right">Actions</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
Chargement...
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
<div
|
<div
|
||||||
v-for="type in absenceTypes"
|
v-for="type in absenceTypes"
|
||||||
:key="type.id"
|
: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>{{ type.code }}</span>
|
||||||
<span class="text-left">{{ type.label }}</span>
|
<span>{{ type.label }}</span>
|
||||||
<div class="flex items-center gap-2 justify-start">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="inline-block h-3 w-3 rounded-full"
|
class="inline-block h-3 w-3 rounded-full"
|
||||||
:style="{ backgroundColor: type.color }"
|
:style="{ backgroundColor: type.color }"
|
||||||
/>
|
/>
|
||||||
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left">
|
<div>
|
||||||
<span
|
<span
|
||||||
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
|
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'"
|
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
|
||||||
@@ -52,22 +52,6 @@
|
|||||||
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
|
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,20 +129,29 @@
|
|||||||
La couleur est obligatoire.
|
La couleur est obligatoire.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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="closeDrawer"
|
@click="confirmDelete(editingType)"
|
||||||
>
|
>
|
||||||
Annuler
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
: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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
@click="openCreateFromToday"
|
@click="openCreateFromToday"
|
||||||
>
|
>
|
||||||
Ajouter une absence
|
+ Ajouter une absence
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -578,10 +578,6 @@ const handleSubmit = async () => {
|
|||||||
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hasHolidayInRange(start, end)) {
|
|
||||||
window.alert("Impossible de creer une absence sur un jour ferie.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const overlaps = absences.value.filter((absence) => {
|
const overlaps = absences.value.filter((absence) => {
|
||||||
if (absence.employee?.id !== Number(form.employeeId)) return false
|
if (absence.employee?.id !== Number(form.employeeId)) return false
|
||||||
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
||||||
|
|||||||
183
frontend/pages/driver-hours.vue
Normal file
183
frontend/pages/driver-hours.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-hidden flex flex-col">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<h1 class="text-4xl font-bold text-primary-500">Heures Conducteurs</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoursToolbar
|
||||||
|
v-model:selected-date="selectedDate"
|
||||||
|
v-model:view-mode="viewMode"
|
||||||
|
v-model:selected-site-ids="selectedSiteIds"
|
||||||
|
v-model:employee-filter="employeeFilter"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:sites="sites"
|
||||||
|
:absence-types="absenceTypes"
|
||||||
|
:formatted-selected-date="formattedSelectedDate"
|
||||||
|
:shortcut-button-class="shortcutButtonClass"
|
||||||
|
:week-shortcut-button-class="weekShortcutButtonClass"
|
||||||
|
:get-week-shortcut-label="getWeekShortcutLabel"
|
||||||
|
@set-yesterday="setYesterday"
|
||||||
|
@set-today="setToday"
|
||||||
|
@set-tomorrow="setTomorrow"
|
||||||
|
@set-previous-week="setPreviousWeek"
|
||||||
|
@set-this-week="setThisWeek"
|
||||||
|
@set-next-week="setNextWeek"
|
||||||
|
@shift-date="shiftDate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="visibleEmployees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Aucun conducteur accessible.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex min-h-0 flex-col gap-4">
|
||||||
|
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
|
||||||
|
<DriverHoursDayView
|
||||||
|
v-if="viewMode === 'day'"
|
||||||
|
v-model:rows="rows"
|
||||||
|
:employees="displayedEmployees"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:is-site-manager="isSiteManager"
|
||||||
|
:day-grid-cols="dayGridCols"
|
||||||
|
:is-holiday="isSelectedDateHoliday"
|
||||||
|
:contract-label="contractLabel"
|
||||||
|
:is-row-locked="isRowLocked"
|
||||||
|
:has-contract-at-selected-date="hasContractAtSelectedDate"
|
||||||
|
:is-validation-pending="isValidationPending"
|
||||||
|
:is-site-validation-pending="isSiteValidationPending"
|
||||||
|
:can-toggle-validation="canToggleValidation"
|
||||||
|
:can-toggle-site-validation="canToggleSiteValidation"
|
||||||
|
:can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
|
||||||
|
:is-bulk-validation-checked="isBulkValidationChecked"
|
||||||
|
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
||||||
|
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
|
||||||
|
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
|
||||||
|
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
|
||||||
|
:on-toggle-validation="toggleValidation"
|
||||||
|
:on-toggle-site-validation="toggleSiteValidation"
|
||||||
|
:on-toggle-validation-bulk="toggleValidationBulk"
|
||||||
|
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
|
||||||
|
:get-row-metrics="getRowMetrics"
|
||||||
|
:get-row-absence-label="getRowAbsenceLabel"
|
||||||
|
:get-row-absence-style="getRowAbsenceStyle"
|
||||||
|
:get-row-updated-at="getRowUpdatedAt"
|
||||||
|
:on-absence-click="openAbsenceDrawer"
|
||||||
|
:format-minutes="formatMinutes"
|
||||||
|
class="max-h-[calc(100vh-300px)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DriverHoursWeekView
|
||||||
|
v-else-if="isAdmin && viewMode === 'week'"
|
||||||
|
:is-week-loading="isWeekLoading"
|
||||||
|
:week-grid-cols="weekGridCols"
|
||||||
|
:weekly-summary="filteredWeeklySummary"
|
||||||
|
:week-day-headers="weekDayHeaders"
|
||||||
|
:format-minutes="formatMinutes"
|
||||||
|
class="max-h-[calc(100vh-300px)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:class="saveButtonClass"
|
||||||
|
:disabled="isSubmitting || visibleEmployees.length === 0"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AbsenceFormDrawer
|
||||||
|
v-model="isAbsenceDrawerOpen"
|
||||||
|
:employees="employees"
|
||||||
|
:absence-types="absenceTypes"
|
||||||
|
:form="absenceForm"
|
||||||
|
:editing-absence="editingAbsence"
|
||||||
|
:is-submitting="isAbsenceSubmitting"
|
||||||
|
:lock-employee="true"
|
||||||
|
:lock-dates="true"
|
||||||
|
:show-comment="false"
|
||||||
|
@submit="submitAbsence"
|
||||||
|
@delete="deleteAbsenceFromDrawer"
|
||||||
|
@cancel="closeAbsenceDrawer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
isAdmin,
|
||||||
|
isSiteManager,
|
||||||
|
viewMode,
|
||||||
|
selectedDate,
|
||||||
|
employeeFilter,
|
||||||
|
sites,
|
||||||
|
selectedSiteIds,
|
||||||
|
employees,
|
||||||
|
visibleEmployees,
|
||||||
|
displayedEmployees,
|
||||||
|
rows,
|
||||||
|
absenceTypes,
|
||||||
|
absenceForm,
|
||||||
|
isAbsenceDrawerOpen,
|
||||||
|
isAbsenceSubmitting,
|
||||||
|
editingAbsence,
|
||||||
|
filteredWeeklySummary,
|
||||||
|
isLoading,
|
||||||
|
isWeekLoading,
|
||||||
|
isSubmitting,
|
||||||
|
dayGridCols,
|
||||||
|
weekGridCols,
|
||||||
|
saveButtonClass,
|
||||||
|
formattedSelectedDate,
|
||||||
|
weekDayHeaders,
|
||||||
|
shortcutButtonClass,
|
||||||
|
weekShortcutButtonClass,
|
||||||
|
getWeekShortcutLabel,
|
||||||
|
setToday,
|
||||||
|
setYesterday,
|
||||||
|
setTomorrow,
|
||||||
|
setThisWeek,
|
||||||
|
setPreviousWeek,
|
||||||
|
setNextWeek,
|
||||||
|
shiftDate,
|
||||||
|
contractLabel,
|
||||||
|
isRowLocked,
|
||||||
|
hasContractAtSelectedDate,
|
||||||
|
isValidationPending,
|
||||||
|
isSiteValidationPending,
|
||||||
|
canToggleValidation,
|
||||||
|
canToggleSiteValidation,
|
||||||
|
canCreateSiteValidationRowFromAbsence,
|
||||||
|
isBulkValidationChecked,
|
||||||
|
isBulkValidationIndeterminate,
|
||||||
|
isBulkSiteValidationChecked,
|
||||||
|
isBulkSiteValidationIndeterminate,
|
||||||
|
canBulkToggleSiteValidation,
|
||||||
|
toggleValidation,
|
||||||
|
toggleSiteValidation,
|
||||||
|
toggleValidationBulk,
|
||||||
|
toggleSiteValidationBulk,
|
||||||
|
getRowMetrics,
|
||||||
|
getRowAbsenceLabel,
|
||||||
|
getRowAbsenceStyle,
|
||||||
|
getRowUpdatedAt,
|
||||||
|
openAbsenceDrawer,
|
||||||
|
submitAbsence,
|
||||||
|
deleteAbsenceFromDrawer,
|
||||||
|
closeAbsenceDrawer,
|
||||||
|
formatMinutes,
|
||||||
|
isSelectedDateHoliday,
|
||||||
|
handleSave
|
||||||
|
} = useDriverHoursPage()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Heures Conducteurs'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-12 border-b border-primary-500">
|
<div class="mt-[44px] border-b border-primary-500">
|
||||||
<div class="flex justify-center gap-16 text-2xl font-bold">
|
<div class="flex justify-center gap-16 text-2xl font-bold">
|
||||||
<button
|
<button
|
||||||
class="pb-2 border-b-2 flex items-center gap-3"
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
Congé
|
Congé
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="showRttTab"
|
||||||
class="pb-2 border-b-2 flex items-center gap-3"
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
:class="activeTab === 'rtt'
|
:class="activeTab === 'rtt'
|
||||||
? 'border-primary-500 text-primary-500'
|
? 'border-primary-500 text-primary-500'
|
||||||
@@ -54,6 +55,26 @@
|
|||||||
<Icon name="mdi:schedule" size="24" class="align-self"/>
|
<Icon name="mdi:schedule" size="24" class="align-self"/>
|
||||||
RTT
|
RTT
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div class="min-h-0 flex-1">
|
<div class="min-h-0 flex-1">
|
||||||
@@ -97,15 +118,52 @@
|
|||||||
:on-add-suspension-form="addSuspensionForm"
|
:on-add-suspension-form="addSuspensionForm"
|
||||||
:current-contract-period-id="currentActiveContractPeriodId"
|
:current-contract-period-id="currentActiveContractPeriodId"
|
||||||
/>
|
/>
|
||||||
<EmployeesLeaveTab
|
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
|
||||||
v-else-if="showLeaveTab && activeTab === 'leave'"
|
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
class="h-full"
|
Chargement...
|
||||||
:absences="employeeAbsences"
|
</div>
|
||||||
:summary="leaveSummary"
|
<EmployeesLeaveTab
|
||||||
:public-holidays="publicHolidays"
|
v-else
|
||||||
@update-fractioned-days="submitFractionedDays"
|
class="h-full"
|
||||||
/>
|
:absences="employeeAbsences"
|
||||||
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
:summary="leaveSummary"
|
||||||
|
:public-holidays="publicHolidays"
|
||||||
|
@update-fractioned-days="submitFractionedDays"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
|
||||||
|
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="activeTab === 'mileage'" class="h-full">
|
||||||
|
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<EmployeesMileageTab
|
||||||
|
v-else
|
||||||
|
class="h-full"
|
||||||
|
:allowances="mileageAllowances"
|
||||||
|
:api-base="mileageApiBase"
|
||||||
|
@create="submitCreateMileage"
|
||||||
|
@update="submitUpdateMileage"
|
||||||
|
@delete="submitDeleteMileage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="activeTab === 'bonus'" class="h-full">
|
||||||
|
<div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<EmployeesBonusTab
|
||||||
|
v-else
|
||||||
|
class="h-full"
|
||||||
|
:bonuses="bonuses"
|
||||||
|
@create="submitCreateBonus"
|
||||||
|
@update="submitUpdateBonus"
|
||||||
|
@delete="submitDeleteBonus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +180,7 @@ const {
|
|||||||
rttSummary,
|
rttSummary,
|
||||||
publicHolidays,
|
publicHolidays,
|
||||||
showLeaveTab,
|
showLeaveTab,
|
||||||
|
showRttTab,
|
||||||
contractHistory,
|
contractHistory,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
contractForm,
|
contractForm,
|
||||||
@@ -159,7 +218,20 @@ const {
|
|||||||
isSuspensionSubmitting,
|
isSuspensionSubmitting,
|
||||||
submitSuspension,
|
submitSuspension,
|
||||||
addSuspensionForm,
|
addSuspensionForm,
|
||||||
currentActiveContractPeriodId
|
currentActiveContractPeriodId,
|
||||||
|
isLeaveLoading,
|
||||||
|
isRttLoading,
|
||||||
|
mileageAllowances,
|
||||||
|
isMileageLoading,
|
||||||
|
mileageApiBase,
|
||||||
|
submitCreateMileage,
|
||||||
|
submitUpdateMileage,
|
||||||
|
submitDeleteMileage,
|
||||||
|
bonuses,
|
||||||
|
isBonusLoading,
|
||||||
|
submitCreateBonus,
|
||||||
|
submitUpdateBonus,
|
||||||
|
submitDeleteBonus
|
||||||
} = useEmployeeDetailPage()
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
|
|||||||
@@ -3,19 +3,43 @@
|
|||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
||||||
<button
|
<div class="flex items-center gap-3">
|
||||||
type="button"
|
<button
|
||||||
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
type="button"
|
||||||
@click="openCreate"
|
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
>
|
@click="handleLeaveRecapPrint"
|
||||||
+ Ajouter un employé
|
>
|
||||||
</button>
|
Export récap. congés
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="isSalaryRecapOpen = true"
|
||||||
|
>
|
||||||
|
Export récap. salaire
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un employé
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-10 py-7">
|
<div class="flex gap-3 py-7">
|
||||||
<div class="w-80">
|
<div class="w-80">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||||
</div>
|
</div>
|
||||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
|
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
|
||||||
|
<select
|
||||||
|
v-model="contractStatusFilter"
|
||||||
|
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="active">Avec contrat</option>
|
||||||
|
<option value="inactive">Sans contrat</option>
|
||||||
|
<option value="all">Tous</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,7 +64,7 @@
|
|||||||
<div class="text-center text-[20px]">
|
<div class="text-center text-[20px]">
|
||||||
<p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p>
|
<p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p>
|
||||||
<p>Nom du poste occupé</p>
|
<p>Nom du poste occupé</p>
|
||||||
<p>Site ({{ employee.site?.name ?? '-' }})</p>
|
<p>{{ employee.site?.name ?? '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,6 +194,17 @@
|
|||||||
La date de fin est obligatoire pour un CDD.
|
La date de fin est obligatoire pour un CDD.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@@ -189,6 +224,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
|
|
||||||
|
<SalaryRecapDrawer
|
||||||
|
v-model="isSalaryRecapOpen"
|
||||||
|
@submit="handleSalaryRecapPrint"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -200,7 +240,9 @@ import {listContracts} from '~/services/contracts'
|
|||||||
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
|
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
|
||||||
import {listSites} from '~/services/sites'
|
import {listSites} from '~/services/sites'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
|
||||||
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
|
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
|
||||||
|
import {usePdfPrinter} from '~/composables/usePdfPrinter'
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Employés'
|
title: 'Employés'
|
||||||
@@ -209,6 +251,8 @@ useHead({
|
|||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const isSalaryRecapOpen = ref(false)
|
||||||
|
const { printPdf } = usePdfPrinter()
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
const editingEmployee = ref<Employee | null>(null)
|
const editingEmployee = ref<Employee | null>(null)
|
||||||
const drawerTitle = computed(() =>
|
const drawerTitle = computed(() =>
|
||||||
@@ -219,20 +263,21 @@ const employees = ref<Employee[]>([])
|
|||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const contracts = ref<Contract[]>([])
|
const contracts = ref<Contract[]>([])
|
||||||
const employeeFilter = ref('')
|
const employeeFilter = ref('')
|
||||||
|
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
|
||||||
const selectedSiteIds = ref<number[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
const filteredEmployees = computed<Employee[]>(() => {
|
const filteredEmployees = computed<Employee[]>(() => {
|
||||||
if (selectedSiteIds.value.length === 0) return []
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
|
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
const bySite = employees.value.filter((employee) => {
|
return employees.value.filter((employee) => {
|
||||||
const siteId = employee.site?.id
|
const siteId = employee.site?.id
|
||||||
return !!siteId && selectedSiteIds.value.includes(siteId)
|
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
||||||
})
|
|
||||||
|
|
||||||
if (!filter) return bySite
|
if (contractStatusFilter.value === 'active' && !employee.hasActiveContract) return false
|
||||||
|
if (contractStatusFilter.value === 'inactive' && employee.hasActiveContract) return false
|
||||||
|
|
||||||
return bySite.filter((employee) => {
|
if (!filter) return true
|
||||||
const firstName = employee.firstName?.toLowerCase() ?? ''
|
const firstName = employee.firstName?.toLowerCase() ?? ''
|
||||||
const lastName = employee.lastName?.toLowerCase() ?? ''
|
const lastName = employee.lastName?.toLowerCase() ?? ''
|
||||||
return firstName.includes(filter) || lastName.includes(filter)
|
return firstName.includes(filter) || lastName.includes(filter)
|
||||||
@@ -246,7 +291,8 @@ const form = reactive({
|
|||||||
contractId: '' as number | '',
|
contractId: '' as number | '',
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
contractStartDate: '',
|
contractStartDate: '',
|
||||||
contractEndDate: ''
|
contractEndDate: '',
|
||||||
|
isDriver: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -431,7 +477,8 @@ const handleSubmit = async () => {
|
|||||||
contractId: Number(form.contractId),
|
contractId: Number(form.contractId),
|
||||||
contractNature: form.contractNature,
|
contractNature: form.contractNature,
|
||||||
contractStartDate: form.contractStartDate,
|
contractStartDate: form.contractStartDate,
|
||||||
contractEndDate: form.contractEndDate || null
|
contractEndDate: form.contractEndDate || null,
|
||||||
|
isDriverInput: form.isDriver
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +489,7 @@ const handleSubmit = async () => {
|
|||||||
form.contractNature = 'CDI'
|
form.contractNature = 'CDI'
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
|
form.isDriver = false
|
||||||
editingEmployee.value = null
|
editingEmployee.value = null
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
@@ -485,9 +533,19 @@ const openCreate = () => {
|
|||||||
form.contractNature = 'CDI'
|
form.contractNature = 'CDI'
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
|
form.isDriver = false
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLeaveRecapPrint = async () => {
|
||||||
|
await printPdf('/leave-recap/print')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSalaryRecapPrint = async (month: string) => {
|
||||||
|
await printPdf(`/salary-recap/print?month=${month}`)
|
||||||
|
isSalaryRecapOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const confirmDelete = async (employee: Employee) => {
|
const confirmDelete = async (employee: Employee) => {
|
||||||
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
|
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<HoursDayView
|
<HoursDayView
|
||||||
v-if="viewMode === 'day'"
|
v-if="viewMode === 'day'"
|
||||||
v-model:rows="rows"
|
v-model:rows="rows"
|
||||||
:employees="visibleEmployees"
|
:employees="displayedEmployees"
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
:is-site-manager="isSiteManager"
|
:is-site-manager="isSiteManager"
|
||||||
:day-grid-cols="dayGridCols"
|
:day-grid-cols="dayGridCols"
|
||||||
@@ -126,6 +126,7 @@ const {
|
|||||||
selectedSiteIds,
|
selectedSiteIds,
|
||||||
employees,
|
employees,
|
||||||
visibleEmployees,
|
visibleEmployees,
|
||||||
|
displayedEmployees,
|
||||||
rows,
|
rows,
|
||||||
absenceTypes,
|
absenceTypes,
|
||||||
absenceForm,
|
absenceForm,
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value, password.value)
|
||||||
|
|
||||||
await router.push('/calendar')
|
const isAdmin = auth.user?.roles?.includes('ROLE_ADMIN')
|
||||||
|
await router.push(isAdmin ? '/calendar' : '/hours')
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
<div class="flex items-center justify-between pb-12">
|
<div class="flex items-center justify-between pb-6">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
>
|
||||||
Ajouter un site
|
+ Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,26 +18,26 @@
|
|||||||
Aucun site pour le moment.
|
Aucun site pour le moment.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="min-h-0 overflow-auto rounded-md 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 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">Nom</span>
|
||||||
<span class="text-left">Couleur</span>
|
<span class="text-left">Couleur</span>
|
||||||
<span class="text-right">Actions</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
Chargement...
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
<div
|
<div
|
||||||
v-for="site in sites"
|
v-for="site in sites"
|
||||||
:key="site.id"
|
: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"
|
draggable="true"
|
||||||
|
@click="openEdit(site)"
|
||||||
@dragstart="handleDragStart($event, site)"
|
@dragstart="handleDragStart($event, site)"
|
||||||
@dragover="handleDragOver"
|
@dragover="handleDragOver"
|
||||||
@drop="handleDrop($event, site)"
|
@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 class="select-none text-xs">::</span>
|
||||||
<span>{{ site.name }}</span>
|
<span>{{ site.name }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -48,22 +48,6 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-md uppercase text-neutral-500">{{ site.color }}</span>
|
<span class="text-md uppercase text-neutral-500">{{ site.color }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,20 +82,29 @@
|
|||||||
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
|
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="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"
|
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="closeDrawer"
|
@click="confirmDelete(editingSite)"
|
||||||
>
|
>
|
||||||
Annuler
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
: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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
<div class="flex items-center justify-between pb-12">
|
<div class="flex items-center justify-between pb-6">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
>
|
||||||
Ajouter un utilisateur
|
+ Ajouter un utilisateur
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,42 +18,29 @@
|
|||||||
Aucun utilisateur pour le moment.
|
Aucun utilisateur pour le moment.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="min-h-0 overflow-auto rounded-md 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 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">Utilisateur</span>
|
||||||
<span class="text-left">Employé</span>
|
<span class="text-left">Employé</span>
|
||||||
<span class="text-left">Accès</span>
|
<span class="text-left">Accès</span>
|
||||||
<span class="text-left">Sites</span>
|
<span class="text-left">Sites</span>
|
||||||
<span class="text-right">Actions</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
Chargement...
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
<div
|
<div
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
: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>{{ user.username }}</span>
|
||||||
<span class="text-left">
|
<span>
|
||||||
{{ user.employee ? `${user.employee.firstName} ${user.employee.lastName}` : '-' }}
|
{{ user.employee ? `${user.employee.firstName} ${user.employee.lastName}` : '-' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-left text-sm text-neutral-600">
|
<span>{{ getAccessLabel(user) }}</span>
|
||||||
{{ getAccessLabel(user) }}
|
<span>{{ getSiteLabels(user) }}</span>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,20 +164,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-center 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>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
:class="submitButtonClass"
|
||||||
>
|
>
|
||||||
Enregistrer
|
{{ editingUser ? 'Modifier' : '+ Ajouter' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
54
frontend/services/bonuses.ts
Normal file
54
frontend/services/bonuses.ts
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
7
frontend/services/dto/bonus.ts
Normal file
7
frontend/services/dto/bonus.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type Bonus = {
|
||||||
|
id: number
|
||||||
|
month: string
|
||||||
|
amount: number
|
||||||
|
comment: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ export type EmployeeLeaveSummary = {
|
|||||||
takenSaturdays: number
|
takenSaturdays: number
|
||||||
fractionedDays: number
|
fractionedDays: number
|
||||||
accruingDays: number
|
accruingDays: number
|
||||||
|
previousYearAcquiredDays: number
|
||||||
|
previousYearTakenDays: number
|
||||||
|
previousYearRemainingDays: number
|
||||||
presenceDaysByMonth: Record<string, number>
|
presenceDaysByMonth: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,34 @@ export type EmployeeRttWeekSummary = {
|
|||||||
weekNumber: number
|
weekNumber: number
|
||||||
weekStart: string
|
weekStart: string
|
||||||
weekEnd: string
|
weekEnd: string
|
||||||
recoveryMinutes: number
|
overtimeMinutes: number
|
||||||
|
base25Minutes: number
|
||||||
|
bonus25Minutes: number
|
||||||
|
base50Minutes: number
|
||||||
|
bonus50Minutes: number
|
||||||
|
totalMinutes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RttMonthPayment = {
|
export type RttMonthPayment = {
|
||||||
month: number
|
month: number
|
||||||
paidMinutes25: number
|
paidBase25Minutes: number
|
||||||
paidMinutes50: number
|
paidBonus25Minutes: number
|
||||||
|
paidBase50Minutes: number
|
||||||
|
paidBonus50Minutes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmployeeRttSummary = {
|
export type EmployeeRttSummary = {
|
||||||
year: number
|
year: number
|
||||||
|
carryMonth: number
|
||||||
carryFromPreviousYearMinutes: number
|
carryFromPreviousYearMinutes: number
|
||||||
|
carryBase25Minutes: number
|
||||||
|
carryBonus25Minutes: number
|
||||||
|
carryBase50Minutes: number
|
||||||
|
carryBonus50Minutes: number
|
||||||
currentYearRecoveryMinutes: number
|
currentYearRecoveryMinutes: number
|
||||||
totalPaidMinutes: number
|
totalPaidMinutes: number
|
||||||
availableMinutes: number
|
availableMinutes: number
|
||||||
weeks: EmployeeRttWeekSummary[]
|
weeks: EmployeeRttWeekSummary[]
|
||||||
monthPayments: RttMonthPayment[]
|
monthPayments: RttMonthPayment[]
|
||||||
|
rttStartDate: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ContractHistoryItem = {
|
|||||||
comment?: string | null
|
comment?: string | null
|
||||||
periodId?: number | null
|
periodId?: number | null
|
||||||
suspensions?: ContractSuspension[]
|
suspensions?: ContractSuspension[]
|
||||||
|
isDriver?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
@@ -26,6 +27,8 @@ export type Employee = {
|
|||||||
lastName: string
|
lastName: string
|
||||||
site: Site
|
site: Site
|
||||||
contract?: Contract | null
|
contract?: Contract | null
|
||||||
|
hasActiveContract?: boolean
|
||||||
|
isDriver?: boolean
|
||||||
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
currentContractStartDate?: string | null
|
currentContractStartDate?: string | null
|
||||||
currentContractEndDate?: string | null
|
currentContractEndDate?: string | null
|
||||||
|
|||||||
9
frontend/services/dto/mileage-allowance.ts
Normal file
9
frontend/services/dto/mileage-allowance.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ export type WorkHour = {
|
|||||||
eveningTo?: string | null
|
eveningTo?: string | null
|
||||||
isPresentMorning?: boolean
|
isPresentMorning?: boolean
|
||||||
isPresentAfternoon?: boolean
|
isPresentAfternoon?: boolean
|
||||||
|
dayHoursMinutes?: number | null
|
||||||
|
nightHoursMinutes?: number | null
|
||||||
|
workshopHoursMinutes?: number | null
|
||||||
|
hasBreakfast?: boolean
|
||||||
|
hasLunch?: boolean
|
||||||
|
hasDinner?: boolean
|
||||||
|
hasOvernight?: boolean
|
||||||
isSiteValid?: boolean
|
isSiteValid?: boolean
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
@@ -28,17 +35,30 @@ export type WorkHourEntryPayload = {
|
|||||||
eveningTo?: string | null
|
eveningTo?: string | null
|
||||||
isPresentMorning?: boolean
|
isPresentMorning?: boolean
|
||||||
isPresentAfternoon?: boolean
|
isPresentAfternoon?: boolean
|
||||||
|
dayHoursMinutes?: number | null
|
||||||
|
nightHoursMinutes?: number | null
|
||||||
|
workshopHoursMinutes?: number | null
|
||||||
|
hasBreakfast?: boolean
|
||||||
|
hasLunch?: boolean
|
||||||
|
hasDinner?: boolean
|
||||||
|
hasOvernight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourDailySummary = {
|
export type WeeklyWorkHourDailySummary = {
|
||||||
date: string
|
date: string
|
||||||
dayMinutes: number
|
dayMinutes: number
|
||||||
nightMinutes: number
|
nightMinutes: number
|
||||||
|
workshopMinutes?: number
|
||||||
totalMinutes: number
|
totalMinutes: number
|
||||||
present?: number | null
|
present?: number | null
|
||||||
hasAbsence?: boolean
|
hasAbsence?: boolean
|
||||||
absenceLabel?: string | null
|
absenceLabel?: string | null
|
||||||
absenceColor?: string | null
|
absenceColor?: string | null
|
||||||
|
hasNightBasket?: boolean
|
||||||
|
hasBreakfast?: boolean
|
||||||
|
hasLunch?: boolean
|
||||||
|
hasDinner?: boolean
|
||||||
|
hasOvernight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourRowSummary = {
|
export type WeeklyWorkHourRowSummary = {
|
||||||
@@ -52,12 +72,20 @@ export type WeeklyWorkHourRowSummary = {
|
|||||||
daily: WeeklyWorkHourDailySummary[]
|
daily: WeeklyWorkHourDailySummary[]
|
||||||
weeklyDayMinutes: number
|
weeklyDayMinutes: number
|
||||||
weeklyNightMinutes: number
|
weeklyNightMinutes: number
|
||||||
|
weeklyWorkshopMinutes?: number
|
||||||
weeklyTotalMinutes: number
|
weeklyTotalMinutes: number
|
||||||
weeklyPresenceCount?: number
|
weeklyPresenceCount?: number
|
||||||
weeklyOvertimeTotalMinutes?: number
|
weeklyOvertimeTotalMinutes?: number
|
||||||
weeklyOvertime25Minutes?: number
|
weeklyOvertime25Minutes?: number
|
||||||
weeklyOvertime50Minutes?: number
|
weeklyOvertime50Minutes?: number
|
||||||
weeklyRecoveryMinutes?: number
|
weeklyRecoveryMinutes?: number
|
||||||
|
weeklyNightBasketCount?: number
|
||||||
|
isDriver?: boolean
|
||||||
|
weeklyBreakfastCount?: number
|
||||||
|
weeklyLunchCount?: number
|
||||||
|
weeklyDinnerCount?: number
|
||||||
|
weeklyOvernightCount?: number
|
||||||
|
hasContractForWeek?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourSummary = {
|
export type WeeklyWorkHourSummary = {
|
||||||
@@ -77,9 +105,24 @@ export type WorkHourDayContextRow = {
|
|||||||
absentAfternoon: boolean
|
absentAfternoon: boolean
|
||||||
creditedMinutes: number
|
creditedMinutes: number
|
||||||
creditedPresenceUnits: number
|
creditedPresenceUnits: number
|
||||||
|
isDriverContract?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkHourDayContext = {
|
export type WorkHourDayContext = {
|
||||||
workDate: string
|
workDate: string
|
||||||
rows: WorkHourDayContextRow[]
|
rows: WorkHourDayContextRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DriverHourRow = {
|
||||||
|
workHourId: number | null
|
||||||
|
dayHours: string
|
||||||
|
nightHours: string
|
||||||
|
workshopHours: string
|
||||||
|
hasBreakfast: boolean
|
||||||
|
hasLunch: boolean
|
||||||
|
hasDinner: boolean
|
||||||
|
hasOvernight: boolean
|
||||||
|
isSiteValid: boolean
|
||||||
|
isValid: boolean
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ export const getEmployeeRttSummary = async (employeeId: number, year?: number) =
|
|||||||
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRttPayment = async (employeeId: number, month: number, minutes: number, rate: '25' | '50', year?: number) => {
|
export const createRttPayment = async (
|
||||||
|
employeeId: number,
|
||||||
|
month: number,
|
||||||
|
base25Minutes: number,
|
||||||
|
bonus25Minutes: number,
|
||||||
|
base50Minutes: number,
|
||||||
|
bonus50Minutes: number,
|
||||||
|
year?: number
|
||||||
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const body: Record<string, unknown> = { month, minutes, rate }
|
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
|
||||||
if (year) body.year = year
|
if (year) body.year = year
|
||||||
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const createEmployee = async (payload: {
|
|||||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
contractStartDate?: string
|
contractStartDate?: string
|
||||||
contractEndDate?: string | null
|
contractEndDate?: string | null
|
||||||
|
isDriverInput?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Employee>('/employees', {
|
return api.post<Employee>('/employees', {
|
||||||
@@ -43,7 +44,8 @@ export const createEmployee = async (payload: {
|
|||||||
contract: `/api/contracts/${payload.contractId}`,
|
contract: `/api/contracts/${payload.contractId}`,
|
||||||
contractNature: payload.contractNature,
|
contractNature: payload.contractNature,
|
||||||
contractStartDate: payload.contractStartDate,
|
contractStartDate: payload.contractStartDate,
|
||||||
contractEndDate: payload.contractEndDate ?? null
|
contractEndDate: payload.contractEndDate ?? null,
|
||||||
|
isDriverInput: payload.isDriverInput ?? false
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.create',
|
toastSuccessKey: 'success.employee.create',
|
||||||
toastErrorKey: 'errors.employee.create'
|
toastErrorKey: 'errors.employee.create'
|
||||||
@@ -63,6 +65,7 @@ export const updateEmployee = async (
|
|||||||
contractPaidLeaveSettled?: boolean
|
contractPaidLeaveSettled?: boolean
|
||||||
contractComment?: string | null
|
contractComment?: string | null
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
|
isDriverInput?: boolean
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -91,6 +94,9 @@ export const updateEmployee = async (
|
|||||||
if (payload.contractComment !== undefined) {
|
if (payload.contractComment !== undefined) {
|
||||||
body.contractComment = payload.contractComment ?? null
|
body.contractComment = payload.contractComment ?? null
|
||||||
}
|
}
|
||||||
|
if (payload.isDriverInput !== undefined) {
|
||||||
|
body.isDriverInput = payload.isDriverInput
|
||||||
|
}
|
||||||
|
|
||||||
return api.patch<Employee>(`/employees/${id}`, body, {
|
return api.patch<Employee>(`/employees/${id}`, body, {
|
||||||
toastSuccessKey: 'success.employee.update',
|
toastSuccessKey: 'success.employee.update',
|
||||||
|
|||||||
69
frontend/services/mileage-allowances.ts
Normal file
69
frontend/services/mileage-allowances.ts
Normal 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`
|
||||||
|
}
|
||||||
54
migrations/Version20260313080007.php
Normal file
54
migrations/Version20260313080007.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260313080007 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'RTT redesign: split opening_minutes and minutes+rate into 4 fields (base25, bonus25, base50, bonus50)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// employee_rtt_balances: replace opening_minutes with 4 fields
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_base25_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_bonus25_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_base50_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_bonus50_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_minutes');
|
||||||
|
|
||||||
|
// employee_rtt_payments: replace minutes+rate with 4 fields
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uniq_rtt_payment_employee_year_month_rate');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments ADD base25_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments ADD bonus25_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments ADD base50_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments ADD bonus50_minutes INT DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments DROP minutes');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments DROP rate');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// employee_rtt_balances: restore opening_minutes
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_minutes INT NOT NULL DEFAULT 0');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_base25_minutes');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_bonus25_minutes');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_base50_minutes');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_bonus50_minutes');
|
||||||
|
|
||||||
|
// employee_rtt_payments: restore minutes+rate
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments ADD minutes INT NOT NULL DEFAULT 0');
|
||||||
|
$this->addSql("ALTER TABLE employee_rtt_payments ADD rate VARCHAR(10) NOT NULL DEFAULT '25'");
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments DROP base25_minutes');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments DROP bonus25_minutes');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments DROP base50_minutes');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_payments DROP bonus50_minutes');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_rtt_payment_employee_year_month_rate ON employee_rtt_payments (employee_id, year, month, rate)');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260313092249.php
Normal file
26
migrations/Version20260313092249.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260313092249 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add month column to employee_rtt_balances for flexible carry-over positioning';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances ADD month INT DEFAULT 5 NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances DROP month');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
migrations/Version20260313125819.php
Normal file
29
migrations/Version20260313125819.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260313133548.php
Normal file
26
migrations/Version20260313133548.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260313151220.php
Normal file
31
migrations/Version20260313151220.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260315100000.php
Normal file
26
migrations/Version20260315100000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
migrations/Version20260315100100.php
Normal file
34
migrations/Version20260315100100.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260316100000.php
Normal file
26
migrations/Version20260316100000.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260316100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add has_dinner column to work_hours';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD has_dinner BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_dinner');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260316100100.php
Normal file
26
migrations/Version20260316100100.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260316100100 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add workshop_hours_minutes column to work_hours';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD workshop_hours_minutes INTEGER DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN workshop_hours_minutes');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ use App\State\EmployeeLeaveSummaryProvider;
|
|||||||
operations: [
|
operations: [
|
||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/employees/{id}/leave-summary',
|
uriTemplate: '/employees/{id}/leave-summary',
|
||||||
security: "is_granted('ROLE_USER')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
provider: EmployeeLeaveSummaryProvider::class
|
provider: EmployeeLeaveSummaryProvider::class
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -20,17 +20,20 @@ use App\State\EmployeeLeaveSummaryProvider;
|
|||||||
)]
|
)]
|
||||||
final class EmployeeLeaveSummary
|
final class EmployeeLeaveSummary
|
||||||
{
|
{
|
||||||
public int $year = 0;
|
public int $year = 0;
|
||||||
public bool $isSupported = false;
|
public bool $isSupported = false;
|
||||||
public string $ruleCode = '';
|
public string $ruleCode = '';
|
||||||
public float $acquiredDays = 0.0;
|
public float $acquiredDays = 0.0;
|
||||||
public float $remainingDays = 0.0;
|
public float $remainingDays = 0.0;
|
||||||
public float $takenDays = 0.0;
|
public float $takenDays = 0.0;
|
||||||
public float $acquiredSaturdays = 0.0;
|
public float $acquiredSaturdays = 0.0;
|
||||||
public float $remainingSaturdays = 0.0;
|
public float $remainingSaturdays = 0.0;
|
||||||
public float $takenSaturdays = 0.0;
|
public float $takenSaturdays = 0.0;
|
||||||
public float $fractionedDays = 0.0;
|
public float $fractionedDays = 0.0;
|
||||||
public float $accruingDays = 0.0;
|
public float $accruingDays = 0.0;
|
||||||
|
public float $previousYearAcquiredDays = 0.0;
|
||||||
|
public float $previousYearTakenDays = 0.0;
|
||||||
|
public float $previousYearRemainingDays = 0.0;
|
||||||
|
|
||||||
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
|
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
|
||||||
public array $presenceDaysByMonth = [];
|
public array $presenceDaysByMonth = [];
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use App\State\EmployeeRttPaymentProvider;
|
|||||||
)]
|
)]
|
||||||
final class EmployeeRttPaymentInput
|
final class EmployeeRttPaymentInput
|
||||||
{
|
{
|
||||||
public int $month = 0;
|
public int $month = 0;
|
||||||
public int $minutes = 0;
|
public int $base25Minutes = 0;
|
||||||
public string $rate = '25';
|
public int $bonus25Minutes = 0;
|
||||||
public ?int $year = null;
|
public int $base50Minutes = 0;
|
||||||
|
public int $bonus50Minutes = 0;
|
||||||
|
public ?int $year = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use App\State\EmployeeRttSummaryProvider;
|
|||||||
operations: [
|
operations: [
|
||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/employees/{id}/rtt-summary',
|
uriTemplate: '/employees/{id}/rtt-summary',
|
||||||
security: "is_granted('ROLE_USER')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
provider: EmployeeRttSummaryProvider::class
|
provider: EmployeeRttSummaryProvider::class
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -23,10 +23,16 @@ use App\State\EmployeeRttSummaryProvider;
|
|||||||
final class EmployeeRttSummary
|
final class EmployeeRttSummary
|
||||||
{
|
{
|
||||||
public int $year = 0;
|
public int $year = 0;
|
||||||
|
public int $carryMonth = 5;
|
||||||
public int $carryFromPreviousYearMinutes = 0;
|
public int $carryFromPreviousYearMinutes = 0;
|
||||||
|
public int $carryBase25Minutes = 0;
|
||||||
|
public int $carryBonus25Minutes = 0;
|
||||||
|
public int $carryBase50Minutes = 0;
|
||||||
|
public int $carryBonus50Minutes = 0;
|
||||||
public int $currentYearRecoveryMinutes = 0;
|
public int $currentYearRecoveryMinutes = 0;
|
||||||
public int $availableMinutes = 0;
|
public int $availableMinutes = 0;
|
||||||
public int $totalPaidMinutes = 0;
|
public int $totalPaidMinutes = 0;
|
||||||
|
public ?string $rttStartDate = null;
|
||||||
|
|
||||||
/** @var list<RttMonthPayment> */
|
/** @var list<RttMonthPayment> */
|
||||||
public array $monthPayments = [];
|
public array $monthPayments = [];
|
||||||
|
|||||||
20
src/ApiResource/LeaveRecapPrint.php
Normal file
20
src/ApiResource/LeaveRecapPrint.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\LeaveRecapPrintProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/leave-recap/print',
|
||||||
|
provider: LeaveRecapPrintProvider::class,
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class LeaveRecapPrint {}
|
||||||
24
src/ApiResource/SalaryRecapPrint.php
Normal file
24
src/ApiResource/SalaryRecapPrint.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\QueryParameter;
|
||||||
|
use App\State\SalaryRecapPrintProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/salary-recap/print',
|
||||||
|
provider: SalaryRecapPrintProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'month', required: true),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class SalaryRecapPrint {}
|
||||||
@@ -32,7 +32,12 @@ final class WorkHourBulkUpsert
|
|||||||
* eveningFrom?:?string,
|
* eveningFrom?:?string,
|
||||||
* eveningTo?:?string,
|
* eveningTo?:?string,
|
||||||
* isPresentMorning?:bool,
|
* isPresentMorning?:bool,
|
||||||
* isPresentAfternoon?:bool
|
* isPresentAfternoon?:bool,
|
||||||
|
* dayHoursMinutes?:?int,
|
||||||
|
* nightHoursMinutes?:?int,
|
||||||
|
* hasBreakfast?:bool,
|
||||||
|
* hasLunch?:bool,
|
||||||
|
* hasOvernight?:bool
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
public array $entries = [];
|
public array $entries = [];
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ final class RttRolloverCommand extends Command
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$previousYear = $targetYear - 1;
|
$previousYear = $targetYear - 1;
|
||||||
$carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
||||||
++$skipped;
|
++$skipped;
|
||||||
@@ -103,12 +103,15 @@ final class RttRolloverCommand extends Command
|
|||||||
$balance = new EmployeeRttBalance()
|
$balance = new EmployeeRttBalance()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
->setYear($targetYear)
|
->setYear($targetYear)
|
||||||
->setOpeningMinutes($carryMinutes)
|
->setOpeningBase25Minutes($carry->base25Minutes)
|
||||||
|
->setOpeningBonus25Minutes($carry->bonus25Minutes)
|
||||||
|
->setOpeningBase50Minutes($carry->base50Minutes)
|
||||||
|
->setOpeningBonus50Minutes($carry->bonus50Minutes)
|
||||||
->setIsLocked(false)
|
->setIsLocked(false)
|
||||||
;
|
;
|
||||||
|
|
||||||
$this->entityManager->persist($balance);
|
$this->entityManager->persist($balance);
|
||||||
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carryMinutes]);
|
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]);
|
||||||
++$created;
|
++$created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,5 +27,7 @@ final class ContractHistoryItem
|
|||||||
public ?int $periodId = null,
|
public ?int $periodId = null,
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public array $suspensions = [],
|
public array $suspensions = [],
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public bool $isDriver = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ final class EmployeeRttWeekSummary
|
|||||||
public int $weekNumber,
|
public int $weekNumber,
|
||||||
public string $weekStart,
|
public string $weekStart,
|
||||||
public string $weekEnd,
|
public string $weekEnd,
|
||||||
public int $recoveryMinutes,
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ final class RttMonthPayment
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $month,
|
public int $month,
|
||||||
public int $paidMinutes25 = 0,
|
public int $paidBase25Minutes = 0,
|
||||||
public int $paidMinutes50 = 0,
|
public int $paidBonus25Minutes = 0,
|
||||||
|
public int $paidBase50Minutes = 0,
|
||||||
|
public int $paidBonus50Minutes = 0,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/Dto/Rtt/WeekRecoveryDetail.php
Normal file
17
src/Dto/Rtt/WeekRecoveryDetail.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Rtt;
|
||||||
|
|
||||||
|
final class WeekRecoveryDetail
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ final class DayContextRow
|
|||||||
public bool $absentAfternoon = false,
|
public bool $absentAfternoon = false,
|
||||||
public int $creditedMinutes = 0,
|
public int $creditedMinutes = 0,
|
||||||
public float $creditedPresenceUnits = 0.0,
|
public float $creditedPresenceUnits = 0.0,
|
||||||
|
public bool $isDriverContract = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function addAbsence(
|
public function addAbsence(
|
||||||
@@ -78,6 +79,7 @@ final class DayContextRow
|
|||||||
'absentAfternoon' => $this->absentAfternoon,
|
'absentAfternoon' => $this->absentAfternoon,
|
||||||
'creditedMinutes' => $this->creditedMinutes,
|
'creditedMinutes' => $this->creditedMinutes,
|
||||||
'creditedPresenceUnits' => $this->creditedPresenceUnits,
|
'creditedPresenceUnits' => $this->creditedPresenceUnits,
|
||||||
|
'isDriverContract' => $this->isDriverContract,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ final class WeeklyDaySummary
|
|||||||
public string $date,
|
public string $date,
|
||||||
public int $dayMinutes,
|
public int $dayMinutes,
|
||||||
public int $nightMinutes,
|
public int $nightMinutes,
|
||||||
|
public int $workshopMinutes,
|
||||||
public int $totalMinutes,
|
public int $totalMinutes,
|
||||||
public ?float $present = null,
|
public ?float $present = null,
|
||||||
public bool $hasAbsence = false,
|
public bool $hasAbsence = false,
|
||||||
public ?string $absenceLabel = null,
|
public ?string $absenceLabel = null,
|
||||||
public ?string $absenceColor = null,
|
public ?string $absenceColor = null,
|
||||||
|
public bool $hasNightBasket = false,
|
||||||
|
public bool $hasBreakfast = false,
|
||||||
|
public bool $hasLunch = false,
|
||||||
|
public bool $hasDinner = false,
|
||||||
|
public bool $hasOvernight = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,19 @@ final class WeeklySummaryRow
|
|||||||
public array $daily,
|
public array $daily,
|
||||||
public int $weeklyDayMinutes,
|
public int $weeklyDayMinutes,
|
||||||
public int $weeklyNightMinutes,
|
public int $weeklyNightMinutes,
|
||||||
|
public int $weeklyWorkshopMinutes,
|
||||||
public int $weeklyTotalMinutes,
|
public int $weeklyTotalMinutes,
|
||||||
public float $weeklyPresenceCount,
|
public float $weeklyPresenceCount,
|
||||||
public int $weeklyOvertimeTotalMinutes,
|
public int $weeklyOvertimeTotalMinutes,
|
||||||
public int $weeklyOvertime25Minutes,
|
public int $weeklyOvertime25Minutes,
|
||||||
public int $weeklyOvertime50Minutes,
|
public int $weeklyOvertime50Minutes,
|
||||||
public int $weeklyRecoveryMinutes,
|
public int $weeklyRecoveryMinutes,
|
||||||
|
public int $weeklyNightBasketCount = 0,
|
||||||
|
public bool $isDriver = false,
|
||||||
|
public int $weeklyBreakfastCount = 0,
|
||||||
|
public int $weeklyLunchCount = 0,
|
||||||
|
public int $weeklyDinnerCount = 0,
|
||||||
|
public int $weeklyOvernightCount = 0,
|
||||||
|
public bool $hasContractForWeek = true,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
145
src/Entity/Bonus.php
Normal file
145
src/Entity/Bonus.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\BonusRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
normalizationContext: [
|
||||||
|
'groups' => ['bonus:read', 'employee:read'],
|
||||||
|
'datetime_format' => 'Y-m-d',
|
||||||
|
],
|
||||||
|
denormalizationContext: [
|
||||||
|
'groups' => ['bonus:write'],
|
||||||
|
'datetime_format' => 'Y-m-d',
|
||||||
|
],
|
||||||
|
order: ['month' => 'DESC'],
|
||||||
|
paginationEnabled: false,
|
||||||
|
)]
|
||||||
|
#[ApiFilter(DateFilter::class, properties: ['month'])]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
|
||||||
|
#[ORM\Entity(repositoryClass: BonusRepository::class)]
|
||||||
|
#[ORM\Table(name: 'bonuses')]
|
||||||
|
class Bonus
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['bonus:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[Groups(['bonus:read', 'bonus:write'])]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable')]
|
||||||
|
#[Groups(['bonus:read', 'bonus:write'])]
|
||||||
|
private ?DateTimeImmutable $month = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float')]
|
||||||
|
#[Groups(['bonus:read', 'bonus:write'])]
|
||||||
|
private float $amount = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
#[Groups(['bonus:read', 'bonus:write'])]
|
||||||
|
private ?string $comment = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
#[Groups(['bonus:read'])]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployee(): ?Employee
|
||||||
|
{
|
||||||
|
return $this->employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmployee(?Employee $employee): self
|
||||||
|
{
|
||||||
|
$this->employee = $employee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonth(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->month;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMonth(?DateTimeImmutable $month): self
|
||||||
|
{
|
||||||
|
$this->month = $month;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAmount(): float
|
||||||
|
{
|
||||||
|
return $this->amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAmount(float $amount): self
|
||||||
|
{
|
||||||
|
$this->amount = $amount;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComment(): ?string
|
||||||
|
{
|
||||||
|
return $this->comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setComment(?string $comment): self
|
||||||
|
{
|
||||||
|
$this->comment = $comment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
processor: EmployeeWriteProcessor::class,
|
processor: EmployeeWriteProcessor::class,
|
||||||
|
order: ['site.name' => 'ASC', 'displayOrder' => 'ASC', 'lastName' => 'ASC', 'firstName' => 'ASC'],
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
||||||
#[ORM\Table(name: 'employees')]
|
#[ORM\Table(name: 'employees')]
|
||||||
@@ -88,6 +89,9 @@ class Employee
|
|||||||
#[Groups(['employee:write'])]
|
#[Groups(['employee:write'])]
|
||||||
private ?string $contractComment = null;
|
private ?string $contractComment = null;
|
||||||
|
|
||||||
|
#[Groups(['employee:write'])]
|
||||||
|
private ?bool $isDriverInput = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
@@ -245,6 +249,30 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getIsDriverInput(): ?bool
|
||||||
|
{
|
||||||
|
return $this->isDriverInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsDriverInput(?bool $isDriverInput): self
|
||||||
|
{
|
||||||
|
$this->isDriverInput = $isDriverInput;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getHasActiveContract(): bool
|
||||||
|
{
|
||||||
|
return null !== $this->resolveCurrentContractPeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getIsDriver(): bool
|
||||||
|
{
|
||||||
|
return $this->resolveCurrentContractPeriod()?->getIsDriver() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public function getCurrentContractNature(): string
|
public function getCurrentContractNature(): string
|
||||||
{
|
{
|
||||||
@@ -329,6 +357,7 @@ class Employee
|
|||||||
comment: $period->getComment(),
|
comment: $period->getComment(),
|
||||||
periodId: $period->getId(),
|
periodId: $period->getId(),
|
||||||
suspensions: $suspensionData,
|
suspensions: $suspensionData,
|
||||||
|
isDriver: $period->getIsDriver(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
$periods
|
$periods
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class EmployeeContractPeriod
|
|||||||
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
|
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
|
||||||
private string $contractNature = 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])]
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
private bool $paidLeaveSettled = false;
|
private bool $paidLeaveSettled = false;
|
||||||
|
|
||||||
@@ -137,6 +140,18 @@ class EmployeeContractPeriod
|
|||||||
return $this->createdAt;
|
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
|
public function isPaidLeaveSettled(): bool
|
||||||
{
|
{
|
||||||
return $this->paidLeaveSettled;
|
return $this->paidLeaveSettled;
|
||||||
|
|||||||
@@ -26,8 +26,20 @@ class EmployeeRttBalance
|
|||||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice (year = annee de fin, ex: 2026 = 01/06/2025 -> 31/05/2026).'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice (year = annee de fin, ex: 2026 = 01/06/2025 -> 31/05/2026).'])]
|
||||||
private int $year = 0;
|
private int $year = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois de fin du report (1-12). Le report s affiche dans le mois suivant.', 'default' => 5])]
|
||||||
private int $openingMinutes = 0;
|
private int $month = 5;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBase25Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBonus25Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBase50Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $openingBonus50Minutes = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])]
|
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])]
|
||||||
private bool $isLocked = false;
|
private bool $isLocked = false;
|
||||||
@@ -74,18 +86,71 @@ class EmployeeRttBalance
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getOpeningMinutes(): int
|
public function getMonth(): int
|
||||||
{
|
{
|
||||||
return $this->openingMinutes;
|
return $this->month;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setOpeningMinutes(int $openingMinutes): self
|
public function setMonth(int $month): self
|
||||||
{
|
{
|
||||||
$this->openingMinutes = $openingMinutes;
|
$this->month = $month;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getOpeningBase25Minutes(): int
|
||||||
|
{
|
||||||
|
return $this->openingBase25Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpeningBase25Minutes(int $openingBase25Minutes): self
|
||||||
|
{
|
||||||
|
$this->openingBase25Minutes = $openingBase25Minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpeningBonus25Minutes(): int
|
||||||
|
{
|
||||||
|
return $this->openingBonus25Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpeningBonus25Minutes(int $openingBonus25Minutes): self
|
||||||
|
{
|
||||||
|
$this->openingBonus25Minutes = $openingBonus25Minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpeningBase50Minutes(): int
|
||||||
|
{
|
||||||
|
return $this->openingBase50Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpeningBase50Minutes(int $openingBase50Minutes): self
|
||||||
|
{
|
||||||
|
$this->openingBase50Minutes = $openingBase50Minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpeningBonus50Minutes(): int
|
||||||
|
{
|
||||||
|
return $this->openingBonus50Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpeningBonus50Minutes(int $openingBonus50Minutes): self
|
||||||
|
{
|
||||||
|
$this->openingBonus50Minutes = $openingBonus50Minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTotalOpeningMinutes(): int
|
||||||
|
{
|
||||||
|
return $this->openingBase25Minutes + $this->openingBonus25Minutes + $this->openingBase50Minutes + $this->openingBonus50Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
public function isLocked(): bool
|
public function isLocked(): bool
|
||||||
{
|
{
|
||||||
return $this->isLocked;
|
return $this->isLocked;
|
||||||
|
|||||||
@@ -28,11 +28,17 @@ class EmployeeRttPayment
|
|||||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
|
||||||
private int $month = 0;
|
private int $month = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
|
||||||
private int $minutes = 0;
|
private int $base25Minutes = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
|
||||||
private string $rate = '';
|
private int $bonus25Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $base50Minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])]
|
||||||
|
private int $bonus50Minutes = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
@@ -88,26 +94,50 @@ class EmployeeRttPayment
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMinutes(): int
|
public function getBase25Minutes(): int
|
||||||
{
|
{
|
||||||
return $this->minutes;
|
return $this->base25Minutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setMinutes(int $minutes): self
|
public function setBase25Minutes(int $base25Minutes): self
|
||||||
{
|
{
|
||||||
$this->minutes = $minutes;
|
$this->base25Minutes = $base25Minutes;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRate(): string
|
public function getBonus25Minutes(): int
|
||||||
{
|
{
|
||||||
return $this->rate;
|
return $this->bonus25Minutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setRate(string $rate): self
|
public function setBonus25Minutes(int $bonus25Minutes): self
|
||||||
{
|
{
|
||||||
$this->rate = $rate;
|
$this->bonus25Minutes = $bonus25Minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBase50Minutes(): int
|
||||||
|
{
|
||||||
|
return $this->base50Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBase50Minutes(int $base50Minutes): self
|
||||||
|
{
|
||||||
|
$this->base50Minutes = $base50Minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBonus50Minutes(): int
|
||||||
|
{
|
||||||
|
return $this->bonus50Minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBonus50Minutes(int $bonus50Minutes): self
|
||||||
|
{
|
||||||
|
$this->bonus50Minutes = $bonus50Minutes;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
192
src/Entity/MileageAllowance.php
Normal file
192
src/Entity/MileageAllowance.php
Normal 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_ADMIN')"
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
processor: MileageAllowanceDeleteProcessor::class,
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/mileage_allowances/{id}/receipt',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
deserialize: false,
|
||||||
|
processor: MileageAllowanceReceiptUploadProcessor::class,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/mileage_allowances/{id}/receipt',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: MileageAllowanceReceiptDownloadProvider::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,34 @@ class WorkHour
|
|||||||
#[Groups(['work_hour:read'])]
|
#[Groups(['work_hour:read'])]
|
||||||
private bool $isPresentAfternoon = false;
|
private bool $isPresentAfternoon = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private ?int $dayHoursMinutes = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private ?int $nightHoursMinutes = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private ?int $workshopHoursMinutes = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private bool $hasBreakfast = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private bool $hasLunch = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private bool $hasDinner = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private bool $hasOvernight = false;
|
||||||
|
|
||||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
||||||
private bool $isValid = false;
|
private bool $isValid = false;
|
||||||
@@ -212,6 +240,90 @@ class WorkHour
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDayHoursMinutes(): ?int
|
||||||
|
{
|
||||||
|
return $this->dayHoursMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDayHoursMinutes(?int $dayHoursMinutes): self
|
||||||
|
{
|
||||||
|
$this->dayHoursMinutes = $dayHoursMinutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNightHoursMinutes(): ?int
|
||||||
|
{
|
||||||
|
return $this->nightHoursMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNightHoursMinutes(?int $nightHoursMinutes): self
|
||||||
|
{
|
||||||
|
$this->nightHoursMinutes = $nightHoursMinutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWorkshopHoursMinutes(): ?int
|
||||||
|
{
|
||||||
|
return $this->workshopHoursMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWorkshopHoursMinutes(?int $workshopHoursMinutes): self
|
||||||
|
{
|
||||||
|
$this->workshopHoursMinutes = $workshopHoursMinutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasBreakfast(): bool
|
||||||
|
{
|
||||||
|
return $this->hasBreakfast;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHasBreakfast(bool $hasBreakfast): self
|
||||||
|
{
|
||||||
|
$this->hasBreakfast = $hasBreakfast;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasLunch(): bool
|
||||||
|
{
|
||||||
|
return $this->hasLunch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHasLunch(bool $hasLunch): self
|
||||||
|
{
|
||||||
|
$this->hasLunch = $hasLunch;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasDinner(): bool
|
||||||
|
{
|
||||||
|
return $this->hasDinner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHasDinner(bool $hasDinner): self
|
||||||
|
{
|
||||||
|
$this->hasDinner = $hasDinner;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasOvernight(): bool
|
||||||
|
{
|
||||||
|
return $this->hasOvernight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHasOvernight(bool $hasOvernight): self
|
||||||
|
{
|
||||||
|
$this->hasOvernight = $hasOvernight;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function isPresentMorning(): bool
|
public function isPresentMorning(): bool
|
||||||
{
|
{
|
||||||
return $this->isPresentMorning;
|
return $this->isPresentMorning;
|
||||||
|
|||||||
@@ -100,6 +100,38 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence
|
|||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<DateTimeImmutable> sorted maladie dates
|
||||||
|
*/
|
||||||
|
public function findMaladieDatesByEmployee(
|
||||||
|
Employee $employee,
|
||||||
|
DateTimeImmutable $from,
|
||||||
|
DateTimeImmutable $to
|
||||||
|
): array {
|
||||||
|
$results = $this->createQueryBuilder('a')
|
||||||
|
->select('a.startDate')
|
||||||
|
->join('a.type', 't')
|
||||||
|
->andWhere('a.employee = :employee')
|
||||||
|
->andWhere('t.code = :code')
|
||||||
|
->andWhere('a.startDate >= :from')
|
||||||
|
->andWhere('a.startDate <= :to')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('code', 'M')
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->orderBy('a.startDate', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getArrayResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (array $row): DateTimeImmutable => $row['startDate'] instanceof DateTimeImmutable
|
||||||
|
? $row['startDate']
|
||||||
|
: DateTimeImmutable::createFromInterface($row['startDate']),
|
||||||
|
$results
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<Absence>
|
* @return list<Absence>
|
||||||
*/
|
*/
|
||||||
|
|||||||
38
src/Repository/BonusRepository.php
Normal file
38
src/Repository/BonusRepository.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Bonus;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Bonus>
|
||||||
|
*/
|
||||||
|
final class BonusRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Bonus::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Bonus[]
|
||||||
|
*/
|
||||||
|
public function findByMonth(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('b')
|
||||||
|
->andWhere('b.month >= :from')
|
||||||
|
->andWhere('b.month <= :to')
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->innerJoin('b.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,8 +87,7 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
|
|||||||
->addSelect('s')
|
->addSelect('s')
|
||||||
->leftJoin('e.contract', 'c')
|
->leftJoin('e.contract', 'c')
|
||||||
->addSelect('c')
|
->addSelect('c')
|
||||||
->orderBy('s.displayOrder', 'ASC')
|
->orderBy('s.name', 'ASC')
|
||||||
->addOrderBy('s.name', 'ASC')
|
|
||||||
->addOrderBy('e.displayOrder', 'ASC')
|
->addOrderBy('e.displayOrder', 'ASC')
|
||||||
->addOrderBy('e.lastName', 'ASC')
|
->addOrderBy('e.lastName', 'ASC')
|
||||||
->addOrderBy('e.firstName', 'ASC')
|
->addOrderBy('e.firstName', 'ASC')
|
||||||
|
|||||||
@@ -19,13 +19,12 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
|||||||
parent::__construct($registry, EmployeeRttPayment::class);
|
parent::__construct($registry, EmployeeRttPayment::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findOneByEmployeeYearMonthRate(Employee $employee, int $year, int $month, string $rate): ?EmployeeRttPayment
|
public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment
|
||||||
{
|
{
|
||||||
return $this->findOneBy([
|
return $this->findOneBy([
|
||||||
'employee' => $employee,
|
'employee' => $employee,
|
||||||
'year' => $year,
|
'year' => $year,
|
||||||
'month' => $month,
|
'month' => $month,
|
||||||
'rate' => $rate,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,4 +43,21 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
|||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return EmployeeRttPayment[]
|
||||||
|
*/
|
||||||
|
public function findByYearAndMonth(int $year, int $month): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.year = :year')
|
||||||
|
->andWhere('p.month = :month')
|
||||||
|
->setParameter('year', $year)
|
||||||
|
->setParameter('month', $month)
|
||||||
|
->innerJoin('p.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/Repository/MileageAllowanceRepository.php
Normal file
38
src/Repository/MileageAllowanceRepository.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\MileageAllowance;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<MileageAllowance>
|
||||||
|
*/
|
||||||
|
final class MileageAllowanceRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, MileageAllowance::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return MileageAllowance[]
|
||||||
|
*/
|
||||||
|
public function findByMonth(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('m')
|
||||||
|
->andWhere('m.month >= :from')
|
||||||
|
->andWhere('m.month <= :to')
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->innerJoin('m.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,29 +139,40 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, float> YYYY-MM => presence day count (0.5 for half-days)
|
* Count weekend worked days by month.
|
||||||
|
* >= 5h total = 1.0 day, < 5h = 0.5 day.
|
||||||
|
*
|
||||||
|
* @return array<string, float> YYYY-MM => weekend worked day count
|
||||||
*/
|
*/
|
||||||
public function countPresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
public function countWeekendWorkedDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
{
|
{
|
||||||
$sql = <<<'SQL'
|
$sql = <<<'SQL'
|
||||||
SELECT TO_CHAR(work_date, 'YYYY-MM') AS month,
|
SELECT TO_CHAR(work_date, 'YYYY-MM') AS month,
|
||||||
SUM(
|
SUM(
|
||||||
CASE
|
CASE
|
||||||
WHEN (morning_from IS NOT NULL OR is_present_morning = true)
|
WHEN total_minutes >= 300 THEN 1.0
|
||||||
AND (afternoon_from IS NOT NULL OR is_present_afternoon = true)
|
WHEN total_minutes > 0 THEN 0.5
|
||||||
THEN 1.0
|
|
||||||
WHEN (morning_from IS NOT NULL OR is_present_morning = true)
|
|
||||||
OR (afternoon_from IS NOT NULL OR is_present_afternoon = true)
|
|
||||||
THEN 0.5
|
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
) AS cnt
|
) AS cnt
|
||||||
FROM work_hours
|
FROM (
|
||||||
WHERE employee_id = :employee
|
SELECT work_date,
|
||||||
AND work_date >= :from
|
COALESCE(
|
||||||
AND work_date <= :to
|
EXTRACT(EPOCH FROM (morning_to::time - morning_from::time)) / 60, 0
|
||||||
AND (morning_from IS NOT NULL OR is_present_morning = true
|
)
|
||||||
OR afternoon_from IS NOT NULL OR is_present_afternoon = true)
|
+ COALESCE(
|
||||||
|
EXTRACT(EPOCH FROM (afternoon_to::time - afternoon_from::time)) / 60, 0
|
||||||
|
)
|
||||||
|
+ COALESCE(
|
||||||
|
EXTRACT(EPOCH FROM (evening_to::time - evening_from::time)) / 60, 0
|
||||||
|
) AS total_minutes
|
||||||
|
FROM work_hours
|
||||||
|
WHERE employee_id = :employee
|
||||||
|
AND work_date >= :from
|
||||||
|
AND work_date <= :to
|
||||||
|
AND EXTRACT(ISODOW FROM work_date) IN (6, 7)
|
||||||
|
AND (morning_from IS NOT NULL OR afternoon_from IS NOT NULL OR evening_from IS NOT NULL)
|
||||||
|
) sub
|
||||||
GROUP BY month
|
GROUP BY month
|
||||||
SQL;
|
SQL;
|
||||||
|
|
||||||
@@ -180,6 +191,43 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
return $result;
|
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
|
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
|
||||||
{
|
{
|
||||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final readonly class EmployeeContractChangeRequest
|
|||||||
public ?DateTimeImmutable $contractEndDate,
|
public ?DateTimeImmutable $contractEndDate,
|
||||||
public ?bool $contractPaidLeaveSettled,
|
public ?bool $contractPaidLeaveSettled,
|
||||||
public ?string $contractComment,
|
public ?string $contractComment,
|
||||||
|
public ?bool $isDriver = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function hasPeriodChangeRequest(): bool
|
public function hasPeriodChangeRequest(): bool
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class EmployeeContractChangeRequestFactory
|
|||||||
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
|
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
|
||||||
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
||||||
contractComment: $employee->getContractComment(),
|
contractComment: $employee->getContractComment(),
|
||||||
|
isDriver: $employee->getIsDriverInput(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): EmployeeContractPeriod {
|
): EmployeeContractPeriod {
|
||||||
return new EmployeeContractPeriod()
|
return new EmployeeContractPeriod()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
@@ -25,6 +26,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
->setStartDate($startDate)
|
->setStartDate($startDate)
|
||||||
->setEndDate($endDate)
|
->setEndDate($endDate)
|
||||||
->setContractNature($nature)
|
->setContractNature($nature)
|
||||||
|
->setIsDriver($isDriver)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): void {
|
): void {
|
||||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
|
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
?EmployeeContractPeriod $todayPeriod
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
|
bool $isDriver = false,
|
||||||
): void {
|
): void {
|
||||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
$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();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +93,9 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): void {
|
): void {
|
||||||
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature);
|
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver);
|
||||||
$this->entityManager->persist($period);
|
$this->entityManager->persist($period);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
public function closeCurrentPeriod(
|
public function closeCurrentPeriod(
|
||||||
@@ -33,6 +34,7 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
?EmployeeContractPeriod $todayPeriod
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
|
bool $isDriver = false,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,60 @@ readonly class EmployeeContractResolver
|
|||||||
return $period?->getContract();
|
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
|
public function resolveNatureForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ContractNature
|
||||||
{
|
{
|
||||||
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ final readonly class LeaveBalanceComputationService
|
|||||||
private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
|
private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
|
||||||
private const float FOUR_HOUR_ANNUAL_DAYS = 10.0;
|
private const float FOUR_HOUR_ANNUAL_DAYS = 10.0;
|
||||||
private const float FOUR_HOUR_ACCRUAL_PER_MONTH = 0.83;
|
private const float FOUR_HOUR_ACCRUAL_PER_MONTH = 0.83;
|
||||||
|
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private AbsenceRepository $absenceRepository,
|
private AbsenceRepository $absenceRepository,
|
||||||
@@ -31,6 +32,7 @@ final readonly class LeaveBalanceComputationService
|
|||||||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||||
|
private LongMaladieService $longMaladieService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,18 +71,9 @@ final readonly class LeaveBalanceComputationService
|
|||||||
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
|
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
|
||||||
|
|
||||||
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||||
$totalBusinessDays = $this->countBusinessDays($from, $to);
|
$totalBusinessDays = $this->countBusinessDays($from, $to);
|
||||||
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
||||||
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
|
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
||||||
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
|
||||||
if ([] !== $suspensions) {
|
|
||||||
$totalMonths = $this->countFractionalMonths($from, $to);
|
|
||||||
$suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions);
|
|
||||||
if ($totalMonths > 0) {
|
|
||||||
$ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths);
|
|
||||||
$acquiredDays = $carryDays + $baseAcquiredDays * $ratio + $fractionedDays;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||||
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
|
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
|
||||||
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
|
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
|
||||||
@@ -89,20 +82,37 @@ final readonly class LeaveBalanceComputationService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||||
|
$this->resolveSuspensionsForEmployeePeriod($employee, $from, $to)
|
||||||
|
);
|
||||||
|
|
||||||
|
$longMaladiePeriods = [];
|
||||||
|
$longMaladieReductionFactor = 1.0;
|
||||||
|
if (4 !== $employee->getContract()?->getWeeklyHours()) {
|
||||||
|
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $to);
|
||||||
|
if ([] !== $longMaladiePeriods) {
|
||||||
|
$totalNormalAccrual = $this->resolveDaysAccrualPerMonth($employee) + $this->resolveSaturdayAccrualPerMonth($employee);
|
||||||
|
$longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$generatedDays = $this->computeAccruedDays(
|
$generatedDays = $this->computeAccruedDays(
|
||||||
$this->resolveAnnualDays($employee),
|
$this->resolveAnnualDays($employee),
|
||||||
$this->resolveDaysAccrualPerMonth($employee),
|
$this->resolveDaysAccrualPerMonth($employee),
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$to,
|
$to,
|
||||||
$suspensions
|
$suspensions,
|
||||||
|
$longMaladiePeriods,
|
||||||
|
$longMaladieReductionFactor
|
||||||
);
|
);
|
||||||
$generatedSaturdays = $this->computeAccruedDays(
|
$generatedSaturdays = $this->computeAccruedDays(
|
||||||
$this->resolveAnnualSaturdays($employee),
|
$this->resolveAnnualSaturdays($employee),
|
||||||
$this->resolveSaturdayAccrualPerMonth($employee),
|
$this->resolveSaturdayAccrualPerMonth($employee),
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$to,
|
$to,
|
||||||
$suspensions
|
$suspensions,
|
||||||
|
$longMaladiePeriods,
|
||||||
|
$longMaladieReductionFactor
|
||||||
);
|
);
|
||||||
|
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||||
@@ -274,21 +284,29 @@ final readonly class LeaveBalanceComputationService
|
|||||||
: self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
|
: self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<ContractSuspension> $suspensions
|
||||||
|
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
||||||
|
*/
|
||||||
private function computeAccruedDays(
|
private function computeAccruedDays(
|
||||||
float $annualCap,
|
float $annualCap,
|
||||||
float $accrualPerMonth,
|
float $accrualPerMonth,
|
||||||
DateTimeImmutable $periodStart,
|
DateTimeImmutable $periodStart,
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
array $suspensions = []
|
array $suspensions = [],
|
||||||
|
array $longMaladiePeriods = [],
|
||||||
|
float $longMaladieReductionFactor = 1.0
|
||||||
): float {
|
): float {
|
||||||
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
|
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$periodStart = $this->normalizeDate($periodStart);
|
$periodStart = $this->normalizeDate($periodStart);
|
||||||
$periodEnd = $this->normalizeDate($periodEnd);
|
$periodEnd = $this->normalizeDate($periodEnd);
|
||||||
$coveredMonths = 0.0;
|
$publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : [];
|
||||||
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
|
$normalMonths = 0.0;
|
||||||
|
$reducedMonths = 0.0;
|
||||||
|
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
|
||||||
while ($cursor <= $periodEnd) {
|
while ($cursor <= $periodEnd) {
|
||||||
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
|
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
|
||||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||||
@@ -296,18 +314,39 @@ final readonly class LeaveBalanceComputationService
|
|||||||
$monthEnd = $periodEnd;
|
$monthEnd = $periodEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
|
||||||
if ([] !== $suspensions) {
|
if ([] !== $suspensions) {
|
||||||
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
||||||
$coveredDays = max(0, $coveredDays - $suspendedDays);
|
if ($suspendedDays > 0) {
|
||||||
|
$businessDays = $this->countBusinessDaysInRange($monthStart, $monthEnd, $publicHolidays);
|
||||||
|
$suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays);
|
||||||
|
$normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0;
|
||||||
|
$cursor = $cursor->modify('first day of next month');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||||
$daysInMonth = (int) $cursor->format('t');
|
$daysInMonth = (int) $cursor->format('t');
|
||||||
$coveredMonths += $coveredDays / $daysInMonth;
|
|
||||||
|
if ([] !== $longMaladiePeriods) {
|
||||||
|
$reducedDays = $this->longMaladieService->countReducedDaysInMonth($monthStart, $monthEnd, $longMaladiePeriods);
|
||||||
|
if ($reducedDays > 0) {
|
||||||
|
$normalDays = max(0, $coveredDays - $reducedDays);
|
||||||
|
$normalMonths += $normalDays / $daysInMonth;
|
||||||
|
$reducedMonths += min($coveredDays, $reducedDays) / $daysInMonth;
|
||||||
|
$cursor = $cursor->modify('first day of next month');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalMonths += $coveredDays / $daysInMonth;
|
||||||
|
|
||||||
$cursor = $cursor->modify('first day of next month');
|
$cursor = $cursor->modify('first day of next month');
|
||||||
}
|
}
|
||||||
|
|
||||||
return min($annualCap, $coveredMonths * $accrualPerMonth);
|
return min($annualCap, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseYmdDate(string $value): ?DateTimeImmutable
|
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||||||
@@ -324,8 +363,15 @@ final readonly class LeaveBalanceComputationService
|
|||||||
|
|
||||||
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
|
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
|
||||||
{
|
{
|
||||||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
return $this->countBusinessDaysInRange($from, $to, $this->buildPublicHolidayMap($from, $to));
|
||||||
$count = 0;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $publicHolidays pre-built map
|
||||||
|
*/
|
||||||
|
private function countBusinessDaysInRange(DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidays): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
|
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
|
||||||
$weekDay = (int) $cursor->format('N');
|
$weekDay = (int) $cursor->format('N');
|
||||||
$dayKey = $cursor->format('Y-m-d');
|
$dayKey = $cursor->format('Y-m-d');
|
||||||
@@ -425,55 +471,6 @@ final readonly class LeaveBalanceComputationService
|
|||||||
return [$takenDays, $takenSaturdays];
|
return [$takenDays, $takenSaturdays];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float
|
|
||||||
{
|
|
||||||
$from = $this->normalizeDate($from);
|
|
||||||
$to = $this->normalizeDate($to);
|
|
||||||
$months = 0.0;
|
|
||||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
|
||||||
|
|
||||||
while ($cursor <= $to) {
|
|
||||||
$monthStart = $cursor > $from ? $cursor : $from;
|
|
||||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
|
||||||
if ($monthEnd > $to) {
|
|
||||||
$monthEnd = $to;
|
|
||||||
}
|
|
||||||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
|
||||||
$daysInMonth = (int) $cursor->format('t');
|
|
||||||
$months += $coveredDays / $daysInMonth;
|
|
||||||
|
|
||||||
$cursor = $cursor->modify('first day of next month');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $months;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<ContractSuspension> $suspensions
|
|
||||||
*/
|
|
||||||
private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float
|
|
||||||
{
|
|
||||||
$from = $this->normalizeDate($from);
|
|
||||||
$to = $this->normalizeDate($to);
|
|
||||||
$months = 0.0;
|
|
||||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
|
||||||
|
|
||||||
while ($cursor <= $to) {
|
|
||||||
$monthStart = $cursor > $from ? $cursor : $from;
|
|
||||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
|
||||||
if ($monthEnd > $to) {
|
|
||||||
$monthEnd = $to;
|
|
||||||
}
|
|
||||||
$daysInMonth = (int) $cursor->format('t');
|
|
||||||
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
|
||||||
$months += $suspendedDays / $daysInMonth;
|
|
||||||
|
|
||||||
$cursor = $cursor->modify('first day of next month');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $months;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<ContractSuspension>
|
* @return list<ContractSuspension>
|
||||||
*/
|
*/
|
||||||
|
|||||||
116
src/Service/Leave/LongMaladieService.php
Normal file
116
src/Service/Leave/LongMaladieService.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Leave;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\AbsenceRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects continuous MALADIE (sick leave) periods and computes
|
||||||
|
* the date ranges where reduced accrual applies (after the first month grace).
|
||||||
|
*/
|
||||||
|
final readonly class LongMaladieService
|
||||||
|
{
|
||||||
|
private const int MAX_GAP_DAYS = 3;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private AbsenceRepository $absenceRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns date ranges where the reduced maladie accrual rate applies.
|
||||||
|
* For continuous maladie periods > 1 month, the first month is excluded (grace period).
|
||||||
|
*
|
||||||
|
* @return list<array{start: DateTimeImmutable, end: DateTimeImmutable}>
|
||||||
|
*/
|
||||||
|
public function findReducedRatePeriods(
|
||||||
|
Employee $employee,
|
||||||
|
DateTimeImmutable $from,
|
||||||
|
DateTimeImmutable $to
|
||||||
|
): array {
|
||||||
|
// Look back 13 months to catch maladie that started before the exercise period
|
||||||
|
$extendedFrom = $from->modify('-13 months');
|
||||||
|
$dates = $this->absenceRepository->findMaladieDatesByEmployee($employee, $extendedFrom, $to);
|
||||||
|
if ([] === $dates) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$periods = $this->consolidateIntoPeriods($dates);
|
||||||
|
|
||||||
|
return $this->applyFirstMonthGrace($periods);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count calendar days in [monthStart, monthEnd] that fall within reduced maladie periods.
|
||||||
|
*
|
||||||
|
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $reducedPeriods
|
||||||
|
*/
|
||||||
|
public function countReducedDaysInMonth(
|
||||||
|
DateTimeImmutable $monthStart,
|
||||||
|
DateTimeImmutable $monthEnd,
|
||||||
|
array $reducedPeriods
|
||||||
|
): int {
|
||||||
|
$total = 0;
|
||||||
|
foreach ($reducedPeriods as $period) {
|
||||||
|
$overlapStart = $period['start'] > $monthStart ? $period['start'] : $monthStart;
|
||||||
|
$overlapEnd = $period['end'] < $monthEnd ? $period['end'] : $monthEnd;
|
||||||
|
|
||||||
|
if ($overlapStart > $overlapEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total += ((int) $overlapEnd->diff($overlapStart)->format('%a')) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<DateTimeImmutable> $dates sorted chronologically
|
||||||
|
*
|
||||||
|
* @return list<array{start: DateTimeImmutable, end: DateTimeImmutable}>
|
||||||
|
*/
|
||||||
|
private function consolidateIntoPeriods(array $dates): array
|
||||||
|
{
|
||||||
|
$periods = [];
|
||||||
|
$start = $dates[0];
|
||||||
|
$prev = $start;
|
||||||
|
|
||||||
|
for ($i = 1, $count = count($dates); $i < $count; ++$i) {
|
||||||
|
$current = $dates[$i];
|
||||||
|
$gap = (int) $prev->diff($current)->format('%a');
|
||||||
|
if ($gap > self::MAX_GAP_DAYS) {
|
||||||
|
$periods[] = ['start' => $start, 'end' => $prev];
|
||||||
|
$start = $current;
|
||||||
|
}
|
||||||
|
$prev = $current;
|
||||||
|
}
|
||||||
|
$periods[] = ['start' => $start, 'end' => $prev];
|
||||||
|
|
||||||
|
return $periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $periods
|
||||||
|
*
|
||||||
|
* @return list<array{start: DateTimeImmutable, end: DateTimeImmutable}>
|
||||||
|
*/
|
||||||
|
private function applyFirstMonthGrace(array $periods): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($periods as $period) {
|
||||||
|
$gracedStart = $period['start']->modify('+1 month');
|
||||||
|
if ($gracedStart > $period['end']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result[] = ['start' => $gracedStart, 'end' => $period['end']];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,35 @@ final class SuspensionDaysCalculator
|
|||||||
return $total;
|
return $total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return adjusted suspensions where the first month of each suspension is excluded (grace period).
|
||||||
|
*
|
||||||
|
* @param list<ContractSuspension> $suspensions
|
||||||
|
*
|
||||||
|
* @return list<ContractSuspension>
|
||||||
|
*/
|
||||||
|
public function applyFirstMonthGrace(array $suspensions): array
|
||||||
|
{
|
||||||
|
$adjusted = [];
|
||||||
|
|
||||||
|
foreach ($suspensions as $suspension) {
|
||||||
|
$gracedStart = $suspension->getStartDate()->modify('+1 month');
|
||||||
|
$end = $suspension->getEndDate();
|
||||||
|
|
||||||
|
if ($end instanceof DateTimeImmutable && $gracedStart > $end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$copy = new ContractSuspension();
|
||||||
|
$copy->setStartDate($gracedStart);
|
||||||
|
$copy->setEndDate($end);
|
||||||
|
|
||||||
|
$adjusted[] = $copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count business days (Mon-Fri, excl. public holidays) suspended within a period.
|
* Count business days (Mon-Fri, excl. public holidays) suspended within a period.
|
||||||
*
|
*
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user