Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6819bc68a | ||
| 6153175ca0 | |||
|
|
49a1c07ed1 | ||
| 9fe2397386 | |||
|
|
bf3f7b35a5 | ||
| 5c251800fa | |||
| e34e928264 | |||
|
|
f7dc9b6988 | ||
| b0de877b27 | |||
| 59f05717bf | |||
|
|
f96fd64767 | ||
| 523d4f296b | |||
|
|
3994be6556 | ||
| f46eeaa893 | |||
|
|
eb703272c7 | ||
| 6629eb98cb | |||
|
|
029bc03a5a | ||
| 82e575fff0 | |||
|
|
0213c0a97d | ||
| 12def35dda | |||
| 2d1c1e6e22 | |||
|
|
f7568f2d09 | ||
| 9c164fe78e | |||
|
|
180c108ded | ||
| f493ea237b |
27
.claude/settings.local.json
Normal file
27
.claude/settings.local.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx vue-tsc:*)",
|
||||||
|
"Bash(npx nuxi:*)",
|
||||||
|
"Bash(php:*)",
|
||||||
|
"Bash(docker compose:*)",
|
||||||
|
"Bash(make test:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(php8.3 bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee 2>&1)",
|
||||||
|
"Read(//usr/bin/**)",
|
||||||
|
"Read(//usr/local/bin/**)",
|
||||||
|
"Bash(command -v php8.2)",
|
||||||
|
"Bash(command -v php8.1)",
|
||||||
|
"Bash(ls /usr/bin/php*)",
|
||||||
|
"Read(//opt/**)",
|
||||||
|
"Read(//home/m-tristan/.nix-profile/**)",
|
||||||
|
"Read(//home/m-tristan/.local/bin/**)",
|
||||||
|
"Bash(env)",
|
||||||
|
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
|
||||||
|
"Bash(which python3:*)",
|
||||||
|
"Bash(sudo apt-get:*)",
|
||||||
|
"Bash(npx xlsx-cli:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.idea/SIRH.iml
generated
2
.idea/SIRH.iml
generated
@@ -152,6 +152,8 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
|
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/public" />
|
<excludeFolder url="file://$MODULE_DIR$/public" />
|
||||||
<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/doctrine-fixtures-bundle" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
2
.idea/db-forest-config.xml
generated
2
.idea/db-forest-config.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="db-tree-configuration">
|
<component name="db-tree-configuration">
|
||||||
<option name="data" value="---------------------------------------- 1:0:9cad43df-2147-4989-b7a4-443067034884 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:f407a514-c6b4-4b26-9555-445a85892502 " />
|
<option name="data" value="---------------------------------------- 1:0:9cad43df-2147-4989-b7a4-443067034884 2:0:ae622167-c834-4e7b-87a5-c1721036f5dc 3:0:f407a514-c6b4-4b26-9555-445a85892502 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
2
.idea/php.xml
generated
2
.idea/php.xml
generated
@@ -153,6 +153,8 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
||||||
<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/data-fixtures" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ Arborescence clé:
|
|||||||
- `tests/`: TU backend (PHPUnit)
|
- `tests/`: TU backend (PHPUnit)
|
||||||
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
||||||
- `migrations/`: migrations Doctrine
|
- `migrations/`: migrations Doctrine
|
||||||
|
- `doc/`: documentation fonctionnelle et règles métier de référence
|
||||||
|
|
||||||
|
## 1.1) Référentiel Fonctionnel (obligatoire)
|
||||||
|
|
||||||
|
- Référence principale des règles métier: `doc/functional-rules.md`
|
||||||
|
- Toute intervention doit commencer par une vérification de cohérence avec cette documentation.
|
||||||
|
- Règle permanente: à chaque développement qui modifie le fonctionnel, la documentation dans `doc/` doit être mise à jour automatiquement dans la même intervention (pas de report).
|
||||||
|
|
||||||
## 2) Commandes utiles
|
## 2) Commandes utiles
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||||
"friendsofphp/php-cs-fixer": "^3.93",
|
"friendsofphp/php-cs-fixer": "^3.93",
|
||||||
"phpunit/phpunit": "^12.5"
|
"phpunit/phpunit": "^12.5"
|
||||||
}
|
}
|
||||||
|
|||||||
171
composer.lock
generated
171
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": "71d28cc0a29fa3f385b067186aa43678",
|
"content-hash": "b540b6cb25ef55c5eebccb57c76da584",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -8504,6 +8504,175 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-05-06T16:37:16+00:00"
|
"time": "2024-05-06T16:37:16+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "doctrine/data-fixtures",
|
||||||
|
"version": "2.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/doctrine/data-fixtures.git",
|
||||||
|
"reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97",
|
||||||
|
"reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"doctrine/persistence": "^3.1 || ^4.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/log": "^1.1 || ^2 || ^3"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"doctrine/dbal": "<3.5 || >=5",
|
||||||
|
"doctrine/orm": "<2.14 || >=4",
|
||||||
|
"doctrine/phpcr-odm": "<1.3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/coding-standard": "^14",
|
||||||
|
"doctrine/dbal": "^3.5 || ^4",
|
||||||
|
"doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
|
||||||
|
"doctrine/orm": "^2.14 || ^3",
|
||||||
|
"ext-sqlite3": "*",
|
||||||
|
"fig/log-test": "^1",
|
||||||
|
"phpstan/phpstan": "2.1.31",
|
||||||
|
"phpunit/phpunit": "10.5.45 || 12.4.0",
|
||||||
|
"symfony/cache": "^6.4 || ^7",
|
||||||
|
"symfony/var-exporter": "^6.4 || ^7"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)",
|
||||||
|
"doctrine/mongodb-odm": "For loading MongoDB ODM fixtures",
|
||||||
|
"doctrine/orm": "For loading ORM fixtures",
|
||||||
|
"doctrine/phpcr-odm": "For loading PHPCR ODM fixtures"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Doctrine\\Common\\DataFixtures\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jonathan Wage",
|
||||||
|
"email": "jonwage@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Data Fixtures for all Doctrine Object Managers",
|
||||||
|
"homepage": "https://www.doctrine-project.org",
|
||||||
|
"keywords": [
|
||||||
|
"database"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/doctrine/data-fixtures/issues",
|
||||||
|
"source": "https://github.com/doctrine/data-fixtures/tree/2.2.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/phpdoctrine",
|
||||||
|
"type": "patreon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-10-17T20:06:20+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "doctrine/doctrine-fixtures-bundle",
|
||||||
|
"version": "4.3.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
|
||||||
|
"reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d",
|
||||||
|
"reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"doctrine/data-fixtures": "^2.2",
|
||||||
|
"doctrine/doctrine-bundle": "^2.2 || ^3.0",
|
||||||
|
"doctrine/orm": "^2.14.0 || ^3.0",
|
||||||
|
"doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"psr/log": "^2 || ^3",
|
||||||
|
"symfony/config": "^6.4 || ^7.0 || ^8.0",
|
||||||
|
"symfony/console": "^6.4 || ^7.0 || ^8.0",
|
||||||
|
"symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
|
||||||
|
"symfony/deprecation-contracts": "^2.1 || ^3",
|
||||||
|
"symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0",
|
||||||
|
"symfony/http-kernel": "^6.4 || ^7.0 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"doctrine/dbal": "< 3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/coding-standard": "14.0.0",
|
||||||
|
"phpstan/phpstan": "2.1.11",
|
||||||
|
"phpunit/phpunit": "^10.5.38 || 11.4.14"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Doctrine\\Bundle\\FixturesBundle\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Doctrine Project",
|
||||||
|
"homepage": "https://www.doctrine-project.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony DoctrineFixturesBundle",
|
||||||
|
"homepage": "https://www.doctrine-project.org",
|
||||||
|
"keywords": [
|
||||||
|
"Fixture",
|
||||||
|
"persistence"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
|
||||||
|
"source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.patreon.com/phpdoctrine",
|
||||||
|
"type": "patreon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-12-03T16:05:42+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "evenement/evenement",
|
"name": "evenement/evenement",
|
||||||
"version": "v3.0.2",
|
"version": "v3.0.2",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
|
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
|
||||||
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
@@ -22,4 +23,5 @@ return [
|
|||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
MonologBundle::class => ['all' => true],
|
MonologBundle::class => ['all' => true],
|
||||||
|
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
monolog:
|
monolog:
|
||||||
channels: [deprecation]
|
channels: [deprecation, cron]
|
||||||
|
|
||||||
when@dev:
|
when@dev:
|
||||||
monolog:
|
monolog:
|
||||||
handlers:
|
handlers:
|
||||||
|
cron:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/cron.log"
|
||||||
|
level: info
|
||||||
|
channels: [cron]
|
||||||
main:
|
main:
|
||||||
type: stream
|
type: stream
|
||||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
level: debug
|
level: debug
|
||||||
channels: ["!event"]
|
channels: ["!event", "!cron"]
|
||||||
console:
|
console:
|
||||||
type: console
|
type: console
|
||||||
process_psr_3_messages: false
|
process_psr_3_messages: false
|
||||||
@@ -17,11 +22,16 @@ when@dev:
|
|||||||
when@prod:
|
when@prod:
|
||||||
monolog:
|
monolog:
|
||||||
handlers:
|
handlers:
|
||||||
|
cron:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/cron.log"
|
||||||
|
level: info
|
||||||
|
channels: [cron]
|
||||||
main:
|
main:
|
||||||
type: stream
|
type: stream
|
||||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||||
level: debug
|
level: debug
|
||||||
channels: ["!deprecation"]
|
channels: ["!deprecation", "!cron"]
|
||||||
deprecation:
|
deprecation:
|
||||||
type: stream
|
type: stream
|
||||||
channels: [deprecation]
|
channels: [deprecation]
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ services:
|
|||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
|
||||||
|
App\Service\PublicHolidayService:
|
||||||
|
arguments:
|
||||||
|
$holidayUrl: '%env(HOLIDAY_URL)%'
|
||||||
|
|
||||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||||
|
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||||
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
|
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
|
||||||
|
App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.18'
|
app.version: '0.1.29'
|
||||||
|
|||||||
260
doc/functional-rules.md
Normal file
260
doc/functional-rules.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# Règles Fonctionnelles SIRH
|
||||||
|
|
||||||
|
Ce document centralise les règles métier actuellement implémentées dans l'application.
|
||||||
|
|
||||||
|
Documents complementaires:
|
||||||
|
- `doc/leave-rollover.md` (rollover conges et checklist de lancement)
|
||||||
|
- `doc/rtt-rollover.md` (rollover RTT et checklist de lancement)
|
||||||
|
|
||||||
|
## 1) Utilisateurs et accès
|
||||||
|
|
||||||
|
- `ROLE_ADMIN`
|
||||||
|
- accès complet aux écrans d'administration
|
||||||
|
- vue semaine des heures
|
||||||
|
- validation RH des lignes d'heures
|
||||||
|
- `ROLE_SELF`
|
||||||
|
- accès limité à son périmètre personnel
|
||||||
|
- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`)
|
||||||
|
- accès au périmètre des sites autorisés
|
||||||
|
- validation site des lignes d'heures
|
||||||
|
|
||||||
|
## 2) Contrats
|
||||||
|
|
||||||
|
- Le profil de temps de travail est porté par `Contract`:
|
||||||
|
- `trackingMode`: `TIME` ou `PRESENCE`
|
||||||
|
- `weeklyHours` (ex: 35, 39, 4, etc.)
|
||||||
|
- La nature RH est portée par période employé:
|
||||||
|
- `CDI`, `CDD`, `INTERIM`
|
||||||
|
- Historique des contrats employé:
|
||||||
|
- table `employee_contract_periods`
|
||||||
|
- un employé peut avoir plusieurs périodes
|
||||||
|
|
||||||
|
### Règles de période
|
||||||
|
|
||||||
|
- `CDI`:
|
||||||
|
- à la création d'une période: `endDate` doit être vide
|
||||||
|
- en clôture d'un contrat en cours: `endDate` peut être renseignée
|
||||||
|
- `CDD` / `INTERIM`:
|
||||||
|
- `endDate` obligatoire
|
||||||
|
- `endDate` ne peut pas être antérieure à `startDate`
|
||||||
|
|
||||||
|
## 3) Heures (vue jour)
|
||||||
|
|
||||||
|
- Saisie par salarié et par date:
|
||||||
|
- matin / après-midi / soir
|
||||||
|
- pour `PRESENCE`: demi-journées matin/après-midi
|
||||||
|
- Sélecteur de temps:
|
||||||
|
- créneaux de 15 minutes uniquement (00:00, 00:15, ..., 23:45)
|
||||||
|
- saisie libre possible mais valeur vidée au blur si hors options
|
||||||
|
- Calculs affichés:
|
||||||
|
- `Jour`, `Nuit`, `Total`
|
||||||
|
- Heures de nuit:
|
||||||
|
- fenêtres `00:00-06:00` et `21:00-24:00`
|
||||||
|
- Date de modification (`updatedAt`):
|
||||||
|
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
|
||||||
|
- non mise à jour lors de modifications admin ou chef de site
|
||||||
|
- affichée sous le nom de l'employé (visible admin uniquement)
|
||||||
|
|
||||||
|
## 4) Absences
|
||||||
|
|
||||||
|
- Les absences sont stockées par jour (découpage lors de l'écriture)
|
||||||
|
- Une absence peut être:
|
||||||
|
- journée complète
|
||||||
|
- demi-journée `AM` ou `PM`
|
||||||
|
- Colonne absence (vue jour):
|
||||||
|
- affiche le libellé
|
||||||
|
- fond coloré selon le type d'absence
|
||||||
|
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
||||||
|
- demi-journée: dégradé diagonal
|
||||||
|
- journée complète: fond plein
|
||||||
|
|
||||||
|
### Effet absence sur les heures
|
||||||
|
|
||||||
|
- Absence `AM`:
|
||||||
|
- efface les heures du matin
|
||||||
|
- Absence `PM`:
|
||||||
|
- efface les heures d'après-midi et du soir
|
||||||
|
- Absence journée:
|
||||||
|
- efface toutes les plages horaires
|
||||||
|
|
||||||
|
### Absences "comptées comme travaillées"
|
||||||
|
|
||||||
|
- Si `countAsWorkedHours = true`:
|
||||||
|
- `TIME`: crédit de minutes selon contrat actif du jour
|
||||||
|
- `PRESENCE` (forfait): aucun crédit de présence (seules les checkboxes cochées comptent)
|
||||||
|
|
||||||
|
## 5) Validations des lignes d'heures
|
||||||
|
|
||||||
|
- Validation RH (`isValid`)
|
||||||
|
- action admin
|
||||||
|
- Validation site (`isSiteValid`)
|
||||||
|
- action chef de site
|
||||||
|
|
||||||
|
### Verrouillage
|
||||||
|
|
||||||
|
- Ligne validée RH:
|
||||||
|
- verrouillée pour modifications heures/absences
|
||||||
|
- Ligne validée site:
|
||||||
|
- verrouillée pour non-admin
|
||||||
|
- admin peut corriger
|
||||||
|
- Toute vraie modification d'une ligne:
|
||||||
|
- remet `isSiteValid = false`
|
||||||
|
- remet `isValid = false`
|
||||||
|
- Si aucun changement réel à l'enregistrement:
|
||||||
|
- les validations existantes ne sont pas altérées
|
||||||
|
|
||||||
|
## 6) Heures supplémentaires (vue semaine)
|
||||||
|
|
||||||
|
- Base de calcul:
|
||||||
|
- dépend du contrat actif par jour
|
||||||
|
- Tranche 25%:
|
||||||
|
- contrats <= 35h: de 35h à 43h
|
||||||
|
- contrats >= 39h: de 39h à 43h
|
||||||
|
- Tranche 50%:
|
||||||
|
- au-delà de 43h
|
||||||
|
- Nature `INTERIM`:
|
||||||
|
- pas de bonus 25%
|
||||||
|
- pas de bonus 50%
|
||||||
|
- pas de total récup
|
||||||
|
|
||||||
|
## 7) Fériés
|
||||||
|
|
||||||
|
- Les jours fériés sont identifiés et affichés
|
||||||
|
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
|
||||||
|
- Règle courante:
|
||||||
|
- absences bloquées sur jour férié
|
||||||
|
- saisie d'heures autorisée
|
||||||
|
|
||||||
|
## 8) Impression absences (PDF)
|
||||||
|
|
||||||
|
Filtres disponibles:
|
||||||
|
- période `from` / `to`
|
||||||
|
- sites
|
||||||
|
- nature de contrat (`CDI`, `CDD`, `INTERIM`)
|
||||||
|
- temps de travail (contrats de type Forfait, 35h, 39h, etc.)
|
||||||
|
|
||||||
|
Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||||
|
|
||||||
|
## 9) Employés
|
||||||
|
|
||||||
|
- Création employé:
|
||||||
|
- prénom, nom, site
|
||||||
|
- type de contrat (nature RH)
|
||||||
|
- temps de travail
|
||||||
|
- dates début/fin (selon règles nature)
|
||||||
|
- Modification employé:
|
||||||
|
- uniquement prénom, nom, site
|
||||||
|
- pas de modification de contrat depuis ce drawer
|
||||||
|
- Détail employé:
|
||||||
|
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
|
||||||
|
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
|
||||||
|
- action `Clôturer`:
|
||||||
|
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
|
||||||
|
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
|
||||||
|
- champs saisissables:
|
||||||
|
- `contractEndDate` (prérempli à aujourd'hui)
|
||||||
|
- `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
|
||||||
|
- backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
|
||||||
|
- action `Ajouter`:
|
||||||
|
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
|
||||||
|
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
|
||||||
|
- onglet `Congé`:
|
||||||
|
- endpoint de synthèse: `GET /api/employees/{id}/leave-summary?year=YYYY`
|
||||||
|
- phase 1 métier (`CDI`/`CDD` non forfait + `FORFAIT`):
|
||||||
|
- exercice CP:
|
||||||
|
- `CDI`/`CDD` non forfait: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
|
||||||
|
- `FORFAIT`: du `1er janvier (YYYY)` au `31 décembre (YYYY)` (paramètre `year` = année civile)
|
||||||
|
- contrats `39h` / `35h` / `25h` (et plus largement CDI/CDD non forfait hors `4h`):
|
||||||
|
- acquis annuel CP: `25`
|
||||||
|
- acquis annuel samedi: `5`
|
||||||
|
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
|
||||||
|
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
|
||||||
|
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||||
|
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
|
||||||
|
- contrat `4h`:
|
||||||
|
- acquis annuel CP: `10`
|
||||||
|
- acquis annuel samedi: `0`
|
||||||
|
- en cours d'acquisition: `0.83` jour/mois
|
||||||
|
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||||
|
- contrat `FORFAIT`:
|
||||||
|
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
||||||
|
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
||||||
|
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
|
||||||
|
- pas de samedi (`0`)
|
||||||
|
- pas de jours en cours d'acquisition (`0`)
|
||||||
|
- fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
|
||||||
|
- pour `CDI`/`CDD` non forfait:
|
||||||
|
- pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées
|
||||||
|
- samedi pris: absences `C` posées le samedi (demi-journée incluse)
|
||||||
|
- restants = acquis - pris (borné à 0)
|
||||||
|
- pour `FORFAIT`:
|
||||||
|
- pris: basé sur toutes les absences (demi-journées incluses)
|
||||||
|
- restants = acquis - pris (borné à 0)
|
||||||
|
- report annuel:
|
||||||
|
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
|
||||||
|
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
|
||||||
|
- pour `FORFAIT`: report uniquement sur les jours
|
||||||
|
- si un solde d'ouverture existe en base (`employee_leave_balances`) pour l'exercice courant, ce solde devient la source prioritaire du report
|
||||||
|
- si une clôture de contrat est marquée `contractPaidLeaveSettled=true` sur l'exercice précédent, le report vers l'exercice suivant est remis à `0`
|
||||||
|
- si une clôture `contractPaidLeaveSettled=true` existe dans l'exercice courant, le calcul est réinitialisé à partir du lendemain de cette clôture (pas de continuité intra-exercice)
|
||||||
|
- lecture des compteurs:
|
||||||
|
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
|
||||||
|
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
|
||||||
|
- `en cours d'acquisition` est arrêté au dernier jour du mois précédent
|
||||||
|
- règle de consommation:
|
||||||
|
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
|
||||||
|
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
|
||||||
|
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
|
||||||
|
- date d'arret de calcul:
|
||||||
|
- `reste à prendre` est calculé en prévisionnel jusqu'à la fin de l'exercice
|
||||||
|
- les absences futures déjà posées sur l'exercice sont déduites du `reste à prendre`
|
||||||
|
- `en cours d'acquisition` reste calculé jusqu'au dernier jour du mois précédent
|
||||||
|
- exemple: au `11/03/2026`, l'exercice `2026` déduit les absences posées jusqu'au `31/05/2026`, mais l'acquisition reste arrêtée au `28/02/2026`
|
||||||
|
- hors périmètre phase 1: `INTERIM` (retour non supporté)
|
||||||
|
- onglet `RTT`:
|
||||||
|
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
|
||||||
|
- exercice RTT: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
|
||||||
|
- affichage:
|
||||||
|
- détail hebdomadaire (semaine ISO) regroupé par mois
|
||||||
|
- total mensuel des minutes de récupération
|
||||||
|
- compteur global exercice = `report N-1 + acquis N`
|
||||||
|
- attribution mensuelle des semaines:
|
||||||
|
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine
|
||||||
|
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
|
||||||
|
- logique de calcul:
|
||||||
|
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
|
||||||
|
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
|
||||||
|
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
|
||||||
|
- compteur global:
|
||||||
|
- affiché en **jours** (1 jour = 7h = 420 minutes)
|
||||||
|
- report:
|
||||||
|
- le report N-1 correspond à la somme des minutes de récupération calculées sur l'exercice précédent
|
||||||
|
- si une ligne existe dans `employee_rtt_balances` pour `(employee, year)`, le champ `opening_minutes` est utilisé en priorité
|
||||||
|
- sinon, le calcul dynamique sur l'exercice N-1 est effectué
|
||||||
|
- rollover automatique:
|
||||||
|
- commande: `php bin/console app:rtt:rollover`
|
||||||
|
- s'exécute le `1er juin` (même cron que le rollover congés)
|
||||||
|
- calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice
|
||||||
|
- idempotent (ne recrée pas si la ligne existe)
|
||||||
|
- paiement RTT:
|
||||||
|
- saisie RH via `PATCH /employees/{id}/rtt-payments` (body: `month`, `minutes`, `rate`)
|
||||||
|
- stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
|
||||||
|
- `rate`: taux de majoration, valeurs `25` ou `50`
|
||||||
|
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
|
||||||
|
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
|
||||||
|
- affichage:
|
||||||
|
- le compteur global RTT est affiché en **heures** (format `Xh00`)
|
||||||
|
|
||||||
|
## 10) Notifications
|
||||||
|
|
||||||
|
- Icône cloche en topbar:
|
||||||
|
- badge = nombre de notifications non lues
|
||||||
|
- ouverture panneau = liste des non lues
|
||||||
|
- fermeture panneau = marquage "lu" en masse
|
||||||
|
|
||||||
|
### Règle métier de déclenchement
|
||||||
|
|
||||||
|
- Les notifications de validation site ne sont pas envoyées ligne par ligne.
|
||||||
|
- Une notification est créée uniquement quand un chef de site termine la validation complète:
|
||||||
|
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
||||||
|
- destinataires: utilisateurs `ROLE_ADMIN`
|
||||||
226
doc/leave-rollover.md
Normal file
226
doc/leave-rollover.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Rollover Conges - Regles et Mise en Production
|
||||||
|
|
||||||
|
Document de reference pour expliquer le fonctionnement metier du report N-1 et preparer le lancement en production.
|
||||||
|
|
||||||
|
## 1) Objectif
|
||||||
|
|
||||||
|
Eviter les recalculs "depuis le debut du contrat" et fiabiliser les soldes.
|
||||||
|
|
||||||
|
Principe:
|
||||||
|
- le solde est stocké par exercice
|
||||||
|
- au changement d'exercice, on ouvre la nouvelle période avec un "solde d'ouverture" (report N-1)
|
||||||
|
- un indicateur de cloture (`contractPaidLeaveSettled`) permet de couper la continuité entre 2 contrats
|
||||||
|
|
||||||
|
## 2) Exercices metier
|
||||||
|
|
||||||
|
- `CDI` / `CDD` non forfait:
|
||||||
|
- exercice: `1er juin` au `31 mai`
|
||||||
|
- `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026)
|
||||||
|
- `FORFAIT`:
|
||||||
|
- exercice: `1er janvier` au `31 decembre`
|
||||||
|
- `year` = annee civile
|
||||||
|
- `INTERIM`:
|
||||||
|
- hors perimetre conges
|
||||||
|
|
||||||
|
## 3) Logique de compteurs
|
||||||
|
|
||||||
|
- `acquis`:
|
||||||
|
- correspond au report N-1 (solde d'ouverture)
|
||||||
|
- `en cours d'acquisition`:
|
||||||
|
- correspond aux droits generes sur l'exercice en cours
|
||||||
|
- `pris`:
|
||||||
|
- non forfait: absences type `C` (conge)
|
||||||
|
- forfait: toutes absences
|
||||||
|
- `restant`:
|
||||||
|
- `acquis + en_cours - pris` (borne a 0 dans l'affichage)
|
||||||
|
|
||||||
|
## 4) Effet du "solde de tout compte"
|
||||||
|
|
||||||
|
Le champ de cloture `contractPaidLeaveSettled` est saisi lors de la fermeture d'une periode contrat.
|
||||||
|
|
||||||
|
- `false`:
|
||||||
|
- continuite des droits entre contrats
|
||||||
|
- `true`:
|
||||||
|
- pas de reprise des droits precedents
|
||||||
|
- reset de continuite au lendemain de la date de cloture
|
||||||
|
|
||||||
|
## 5) Table cible
|
||||||
|
|
||||||
|
Table `employee_leave_balances` (une ligne par employe et exercice):
|
||||||
|
- `employee_id`
|
||||||
|
- `rule_code` (`CDI_CDD_NON_FORFAIT` ou `FORFAIT_218`)
|
||||||
|
- `year`
|
||||||
|
- `opening_days`
|
||||||
|
- `opening_saturdays`
|
||||||
|
- `accrued_days`
|
||||||
|
- `accrued_saturdays` (optionnel selon implementation)
|
||||||
|
- `taken_days`
|
||||||
|
- `taken_saturdays`
|
||||||
|
- `closing_days`
|
||||||
|
- `closing_saturdays`
|
||||||
|
- `is_locked`
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
Contrainte unique recommandee:
|
||||||
|
- `(employee_id, rule_code, year)`
|
||||||
|
|
||||||
|
Etat implementation:
|
||||||
|
- la table est creee
|
||||||
|
- le calcul de synthese conges lit en priorite `opening_days/opening_saturdays` de cette table quand une ligne existe pour `(employee, rule_code, year)`
|
||||||
|
- si aucune ligne n'existe, le calcul reste base sur le report dynamique N-1
|
||||||
|
- la commande `app:leave:rollover` calcule aussi le report dynamique N-1 si la ligne N-1 n'est pas encore persistée (pas de reset a 0 par defaut)
|
||||||
|
|
||||||
|
### Definition des colonnes
|
||||||
|
|
||||||
|
- `employee_id`:
|
||||||
|
- identifiant employe (FK vers `employees`)
|
||||||
|
- une ligne de solde par employe / regle / exercice
|
||||||
|
- `rule_code`:
|
||||||
|
- code de regle appliquee (`CDI_CDD_NON_FORFAIT`, `FORFAIT_218`)
|
||||||
|
- permet de savoir quelles regles de calcul sont utilisees
|
||||||
|
- `year`:
|
||||||
|
- annee d'exercice
|
||||||
|
- non forfait: annee de fin d'exercice (`2026` = 01/06/2025 -> 31/05/2026)
|
||||||
|
- forfait: annee civile (`2026` = 01/01/2026 -> 31/12/2026)
|
||||||
|
- `opening_days`:
|
||||||
|
- report N-1 en jours (solde d'ouverture)
|
||||||
|
- `opening_saturdays`:
|
||||||
|
- report N-1 "samedis" (0 pour forfait)
|
||||||
|
- `accrued_days`:
|
||||||
|
- droits generes sur l'exercice courant (N)
|
||||||
|
- `accrued_saturdays`:
|
||||||
|
- droits samedis generes sur N (0 pour forfait)
|
||||||
|
- `taken_days`:
|
||||||
|
- jours poses sur l'exercice
|
||||||
|
- `taken_saturdays`:
|
||||||
|
- samedis poses sur l'exercice (0 pour forfait)
|
||||||
|
- `closing_days`:
|
||||||
|
- solde de cloture jours (`opening_days + accrued_days - taken_days`)
|
||||||
|
- `closing_saturdays`:
|
||||||
|
- solde de cloture samedis (`opening_saturdays + accrued_saturdays - taken_saturdays`)
|
||||||
|
- `is_locked`:
|
||||||
|
- `false` sur exercice ouvert (recalcul possible)
|
||||||
|
- `true` apres validation RH (exercice fige)
|
||||||
|
- `created_at`, `updated_at`:
|
||||||
|
- trace technique creation / mise a jour
|
||||||
|
|
||||||
|
## 6) Rollover automatique
|
||||||
|
|
||||||
|
Commande quotidienne (cron) idempotente.
|
||||||
|
|
||||||
|
- commande Symfony: `php bin/console app:leave:rollover`
|
||||||
|
- comportement date metier:
|
||||||
|
- le `01/01`: traite uniquement `FORFAIT_218`
|
||||||
|
- le `01/06`: traite uniquement `CDI_CDD_NON_FORFAIT`
|
||||||
|
- les autres jours: sortie sans action
|
||||||
|
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
|
||||||
|
|
||||||
|
Date d'effet:
|
||||||
|
- forfait: au `1er janvier`
|
||||||
|
- non forfait: au `1er juin`
|
||||||
|
|
||||||
|
Traitement par employe:
|
||||||
|
1. lire l'exercice precedent
|
||||||
|
2. determiner le report:
|
||||||
|
- si cloture `paidLeaveSettled=true` sur la periode precedente => report `0`
|
||||||
|
- sinon report = `closing` exercice precedent
|
||||||
|
3. creer la ligne du nouvel exercice avec ce report en `opening_*`
|
||||||
|
4. initialiser `accrued/taken/closing` pour le nouvel exercice
|
||||||
|
|
||||||
|
## 7) Donnees a fournir au go-live
|
||||||
|
|
||||||
|
La RH doit fournir un import d'ouverture:
|
||||||
|
|
||||||
|
Colonnes minimales:
|
||||||
|
- `employee_identifier` (id interne ou matricule)
|
||||||
|
- `rule_code`
|
||||||
|
- `year`
|
||||||
|
- `opening_days`
|
||||||
|
- `opening_saturdays` (0 pour forfait)
|
||||||
|
- `source_date` (date de reference du relevé RH)
|
||||||
|
- `comment` (optionnel)
|
||||||
|
|
||||||
|
Format recommande:
|
||||||
|
- CSV UTF-8
|
||||||
|
- separateur `;`
|
||||||
|
- decimales en point (`7.5`)
|
||||||
|
|
||||||
|
Exemple:
|
||||||
|
```csv
|
||||||
|
employee_id;rule_code;year;opening_days;opening_saturdays;source_date;comment
|
||||||
|
42;CDI_CDD_NON_FORFAIT;2026;12.5;2;2026-05-31;Reprise fichier RH
|
||||||
|
17;FORFAIT_218;2026;8;0;2025-12-31;Reprise fichier RH
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8) Checklist mise en prod
|
||||||
|
|
||||||
|
1. Valider le mapping employe RH -> employe applicatif
|
||||||
|
2. Importer les soldes d'ouverture N-1
|
||||||
|
3. Verifier 5 cas metier:
|
||||||
|
- CDI simple sans changement de contrat
|
||||||
|
- CDD -> CDI avec `paidLeaveSettled=false`
|
||||||
|
- CDD -> CDI avec `paidLeaveSettled=true`
|
||||||
|
- Forfait sur annee complete
|
||||||
|
- Forfait avec debut en cours d'annee
|
||||||
|
4. Activer le cron de rollover
|
||||||
|
5. Geler (`is_locked`) les exercices historicises valides
|
||||||
|
|
||||||
|
Exemple cron (tous les jours a 02:10):
|
||||||
|
Dev
|
||||||
|
```cron
|
||||||
|
10 2 * * * cd /var/www/html && php bin/console app:leave:rollover --no-interaction 2>&1
|
||||||
|
```
|
||||||
|
Prod
|
||||||
|
```cron
|
||||||
|
10 2 * * * cd /var/www/sirh && php bin/console app:leave:rollover --no-interaction 2>&1
|
||||||
|
```
|
||||||
|
Explication de la ligne cron:
|
||||||
|
- `10 2 * * *`: planification
|
||||||
|
- `10` = minute
|
||||||
|
- `2` = heure
|
||||||
|
- `*` = tous les jours du mois
|
||||||
|
- `*` = tous les mois
|
||||||
|
- `*` = tous les jours de la semaine
|
||||||
|
- `cd /var/www/html`: se place dans le dossier de l application Symfony
|
||||||
|
- `php bin/console app:leave:rollover --no-interaction`: execute le rollover sans demander de confirmation
|
||||||
|
- hors `01/01` et `01/06`, la commande sort en no-op (normal)
|
||||||
|
- `>> var/log/leave-rollover.log`: ajoute la sortie standard dans le fichier de log (sans ecraser l historique)
|
||||||
|
- `2>&1`: redirige aussi les erreurs dans le meme fichier de log
|
||||||
|
|
||||||
|
Execution manuelle forcee:
|
||||||
|
```bash
|
||||||
|
php bin/console app:leave:rollover --force --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple de verification rapide:
|
||||||
|
```bash
|
||||||
|
tail -n 50 /var/www/html/var/log/leave-rollover.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9) Points de vigilance
|
||||||
|
|
||||||
|
- Ne jamais recalculer les soldes historiques apres validation RH sans procedure explicite
|
||||||
|
- Garder une trace de toute correction manuelle (auteur, date, motif)
|
||||||
|
- Aligner strictement les regles UI et API sur les memes compteurs (pas de formule differente front/back)
|
||||||
|
|
||||||
|
## 10) Regle de consommation des droits
|
||||||
|
|
||||||
|
Regle metier:
|
||||||
|
- un employe peut poser des conges en cours d'acquisition
|
||||||
|
- la consommation se fait par ordre:
|
||||||
|
1. `acquis` (report N-1)
|
||||||
|
2. `en cours d'acquisition` (droits N)
|
||||||
|
|
||||||
|
Effet attendu:
|
||||||
|
- si `acquis = 0` et `en cours = 7.5`, puis prise de `7`, alors:
|
||||||
|
- `acquis` reste `0`
|
||||||
|
- `en cours` devient `0.5`
|
||||||
|
- si `acquis = 0` et `en cours = 2.5`, puis prise de `3`, alors:
|
||||||
|
- `acquis` reste `0`
|
||||||
|
- `en cours` devient `-0.5` (dette)
|
||||||
|
- le mois suivant, une acquisition de `2.5` ramené `en cours` a `2.0`
|
||||||
|
|
||||||
|
Formule de lecture recommandée:
|
||||||
|
- `restant_acquis = max(0, acquis - pris)`
|
||||||
|
- `reste_a_imputer_sur_en_cours = max(0, pris - acquis)`
|
||||||
|
- `restant_en_cours = en_cours - reste_a_imputer_sur_en_cours` (valeur negative autorisee)
|
||||||
163
doc/rtt-rollover.md
Normal file
163
doc/rtt-rollover.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Rollover RTT - Regles et Mise en Production
|
||||||
|
|
||||||
|
Document de reference pour expliquer le fonctionnement metier du report RTT N-1 et preparer le lancement en production.
|
||||||
|
|
||||||
|
## 1) Objectif
|
||||||
|
|
||||||
|
Permettre le report des heures supplementaires (RTT) d'un exercice a l'autre et fiabiliser les soldes.
|
||||||
|
|
||||||
|
Principe:
|
||||||
|
- le solde d'ouverture est stocke par exercice
|
||||||
|
- au changement d'exercice, on ouvre la nouvelle periode avec un "solde d'ouverture" (report N-1)
|
||||||
|
- au go-live, les soldes d'ouverture sont importes manuellement (CSV ou insertion SQL)
|
||||||
|
|
||||||
|
## 2) Exercice metier
|
||||||
|
|
||||||
|
- exercice RTT: du `1er juin` au `31 mai`
|
||||||
|
- `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026)
|
||||||
|
- employes eligibles: tous sauf `INTERIM` et suivi `PRESENCE`
|
||||||
|
|
||||||
|
## 3) Logique de compteurs
|
||||||
|
|
||||||
|
- `report N-1`:
|
||||||
|
- correspond au solde d'ouverture (`opening_minutes`)
|
||||||
|
- source prioritaire: table `employee_rtt_balances`
|
||||||
|
- fallback: calcul dynamique de la somme des minutes de recuperation de l'exercice precedent
|
||||||
|
- `acquis N`:
|
||||||
|
- somme des minutes de recuperation hebdomadaires de l'exercice en cours
|
||||||
|
- calcul: `HS totales + bonus 25% + bonus 50%` par semaine
|
||||||
|
- `disponible`:
|
||||||
|
- `report N-1 + acquis N`
|
||||||
|
- affichage du compteur global: en **jours** (1 jour = 7h = 420 minutes)
|
||||||
|
|
||||||
|
## 4) Attribution mensuelle des semaines
|
||||||
|
|
||||||
|
- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
|
||||||
|
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
|
||||||
|
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
|
||||||
|
|
||||||
|
## 5) Table cible
|
||||||
|
|
||||||
|
Table `employee_rtt_balances` (une ligne par employe et exercice):
|
||||||
|
- `employee_id`
|
||||||
|
- `year`
|
||||||
|
- `opening_minutes`
|
||||||
|
- `is_locked`
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
Contrainte unique:
|
||||||
|
- `(employee_id, year)`
|
||||||
|
|
||||||
|
Etat implementation:
|
||||||
|
- la table est creee
|
||||||
|
- le calcul de synthese RTT lit en priorite `opening_minutes` de cette table quand une ligne existe pour `(employee, year)`
|
||||||
|
- si aucune ligne n'existe, le calcul dynamique sur l'exercice N-1 est effectue
|
||||||
|
|
||||||
|
### Definition des colonnes
|
||||||
|
|
||||||
|
- `employee_id`:
|
||||||
|
- identifiant employe (FK vers `employees`)
|
||||||
|
- une ligne de solde par employe / exercice
|
||||||
|
- `year`:
|
||||||
|
- annee d'exercice (annee de fin)
|
||||||
|
- `2026` = 01/06/2025 -> 31/05/2026
|
||||||
|
- `opening_minutes`:
|
||||||
|
- report N-1 en minutes (solde d'ouverture)
|
||||||
|
- correspond a la somme des minutes de recuperation de l'exercice precedent
|
||||||
|
- `is_locked`:
|
||||||
|
- `false` sur exercice ouvert (recalcul possible)
|
||||||
|
- `true` apres validation RH (exercice fige)
|
||||||
|
- `created_at`, `updated_at`:
|
||||||
|
- trace technique creation / mise a jour
|
||||||
|
|
||||||
|
## 6) Rollover automatique
|
||||||
|
|
||||||
|
Commande quotidienne (cron) idempotente.
|
||||||
|
|
||||||
|
- commande Symfony: `php bin/console app:rtt:rollover`
|
||||||
|
- comportement date metier:
|
||||||
|
- le `01/06`: calcule et persiste le report pour chaque employe eligible
|
||||||
|
- les autres jours: sortie sans action
|
||||||
|
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
|
||||||
|
|
||||||
|
Date d'effet:
|
||||||
|
- au `1er juin` (meme date que le rollover conges non forfait)
|
||||||
|
|
||||||
|
Traitement par employe:
|
||||||
|
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
|
||||||
|
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
|
||||||
|
3. calculer la somme des minutes de recuperation de l'exercice N-1
|
||||||
|
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
|
||||||
|
|
||||||
|
## 7) Donnees a fournir au go-live
|
||||||
|
|
||||||
|
La RH doit fournir les soldes RTT a reporter.
|
||||||
|
|
||||||
|
Colonnes minimales:
|
||||||
|
- `employee_id` (id interne)
|
||||||
|
- `year`
|
||||||
|
- `opening_minutes` (total en minutes)
|
||||||
|
|
||||||
|
Format recommande:
|
||||||
|
- CSV UTF-8
|
||||||
|
- separateur `;`
|
||||||
|
|
||||||
|
Exemple:
|
||||||
|
```csv
|
||||||
|
employee_id;year;opening_minutes
|
||||||
|
42;2026;1260
|
||||||
|
17;2026;840
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent en insertion SQL directe:
|
||||||
|
```sql
|
||||||
|
INSERT INTO employee_rtt_balances (employee_id, year, opening_minutes, is_locked, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(42, 2026, 1260, false, NOW(), NOW()),
|
||||||
|
(17, 2026, 840, false, NOW(), NOW());
|
||||||
|
```
|
||||||
|
|
||||||
|
Conversion rapide: `1260 minutes = 21h00 = 3.00 jours` (1 jour = 420 min = 7h)
|
||||||
|
|
||||||
|
## 8) Checklist mise en prod
|
||||||
|
|
||||||
|
1. Executer la migration (`employee_rtt_balances`)
|
||||||
|
2. Importer les soldes d'ouverture N-1 (CSV ou SQL)
|
||||||
|
3. Verifier 3 cas metier:
|
||||||
|
- CDI 39h avec heures supp sur l'exercice precedent
|
||||||
|
- CDI 35h sans heures supp (report = 0)
|
||||||
|
- INTERIM (doit etre ignore, pas de ligne creee)
|
||||||
|
4. Activer le cron de rollover
|
||||||
|
5. Geler (`is_locked`) les exercices historicises valides
|
||||||
|
|
||||||
|
Exemple cron (tous les jours a 02:15, juste apres le rollover conges):
|
||||||
|
Dev
|
||||||
|
```cron
|
||||||
|
15 2 * * * cd /var/www/html && php bin/console app:rtt:rollover --no-interaction 2>&1
|
||||||
|
```
|
||||||
|
Prod
|
||||||
|
```cron
|
||||||
|
10 2 * * * cd /var/www/sirh && php bin/console app:rtt:rollover --no-interaction 2>&1
|
||||||
|
```
|
||||||
|
Explication de la ligne cron:
|
||||||
|
- `15 2 * * *`: tous les jours a 02:15
|
||||||
|
- `php bin/console app:rtt:rollover --no-interaction`: execute le rollover sans confirmation
|
||||||
|
- hors `01/06`, la commande sort en no-op (normal)
|
||||||
|
- `>> var/log/rtt-rollover.log 2>&1`: log sortie standard et erreurs
|
||||||
|
|
||||||
|
Execution manuelle forcee:
|
||||||
|
```bash
|
||||||
|
php bin/console app:rtt:rollover --force --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple de verification rapide:
|
||||||
|
```bash
|
||||||
|
tail -n 50 /var/www/html/var/log/rtt-rollover.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9) Points de vigilance
|
||||||
|
|
||||||
|
- Ne jamais modifier `opening_minutes` apres validation RH sans procedure explicite
|
||||||
|
- Garder une trace de toute correction manuelle (auteur, date, motif)
|
||||||
|
- Le calcul dynamique N-1 (fallback) parcourt toutes les heures de l'exercice precedent: preferer l'import explicite pour les exercices historiques
|
||||||
|
- La commande de rollover est idempotente: si une ligne existe deja, l'employe est ignore (pas d'ecrasement)
|
||||||
484
docs/plans/2026-03-09-rtt-paid-hours.md
Normal file
484
docs/plans/2026-03-09-rtt-paid-hours.md
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
# RTT : Affichage en heures + Paiement RTT
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Afficher les RTT en heures (plus en jours), permettre le paiement RTT via drawer avec majoration 25%/50%, stocker en BDD et afficher par mois.
|
||||||
|
|
||||||
|
**Architecture:** Nouvelle entity `EmployeeRttPayment` (employee, year, month, minutes, rate). Le provider RTT agrège les paiements par mois et les soustrait du disponible. Le frontend ajoute un drawer de saisie et deux lignes par mois (25% / 50%).
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony + Doctrine + API Platform (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte existant
|
||||||
|
|
||||||
|
- **Entity:** `EmployeeRttBalance` dans `src/Entity/EmployeeRttBalance.php` - stocke `openingMinutes` par exercice
|
||||||
|
- **Provider:** `src/State/EmployeeRttSummaryProvider.php` - calcule `availableMinutes = carry + currentYearRecovery`
|
||||||
|
- **Service:** `src/Service/Rtt/RttRecoveryComputationService.php` - calcul semaine par semaine
|
||||||
|
- **Frontend:** `frontend/components/employees/RttTab.vue` - affichage grille mois/semaines
|
||||||
|
- **DTO backend:** `src/ApiResource/EmployeeRttSummary.php` - champs `availableMinutes`, `weeks[]`
|
||||||
|
- **DTO frontend:** `frontend/services/dto/employee-rtt-summary.ts`
|
||||||
|
- Les minutes sont la base de calcul. 1 jour = 7h = 420 minutes.
|
||||||
|
- Exercice RTT : 1er juin N-1 -> 31 mai N (year = N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Migration - Créer la table `employee_rtt_payments`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `migrations/Version20260309140000.php`
|
||||||
|
|
||||||
|
**Step 1: Créer la migration**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260309140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create employee_rtt_payments table for RTT paid hours tracking.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE employee_rtt_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
employee_id INT NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL,
|
||||||
|
minutes INT NOT NULL,
|
||||||
|
rate VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_rtt_payment_employee_year ON employee_rtt_payments (employee_id, year)');
|
||||||
|
$this->addSql("COMMENT ON TABLE employee_rtt_payments IS 'Paiements RTT par employe, mois et taux de majoration.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.rate IS 'Taux de majoration: 25 ou 50.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.minutes IS 'Minutes RTT payees pour ce mois et ce taux.'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE employee_rtt_payments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Exécuter la migration**
|
||||||
|
|
||||||
|
Run: `make migrate` ou `docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction`
|
||||||
|
Expected: Migration exécutée sans erreur
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add migrations/Version20260309140000.php
|
||||||
|
git commit -m "feat(rtt): add employee_rtt_payments table"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Entity + Repository `EmployeeRttPayment`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/EmployeeRttPayment.php`
|
||||||
|
- Create: `src/Repository/EmployeeRttPaymentRepository.php`
|
||||||
|
|
||||||
|
**Step 1: Créer l'entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeRttPaymentRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_rtt_payments')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
|
||||||
|
class EmployeeRttPayment
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $year = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $month = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Minutes RTT payees pour ce mois et ce taux.'])]
|
||||||
|
private int $minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 10, options: ['comment' => 'Taux de majoration: 25 ou 50.'])]
|
||||||
|
private string $rate = '25';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters & setters (getId, getEmployee/setEmployee, getYear/setYear,
|
||||||
|
// getMonth/setMonth, getMinutes/setMinutes, getRate/setRate, touch)
|
||||||
|
// Suivre le même pattern que EmployeeLeaveBalance
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Créer le repository**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||||
|
*/
|
||||||
|
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeRttPayment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<EmployeeRttPayment>
|
||||||
|
*/
|
||||||
|
public function findByEmployeeAndYear(Employee $employee, int $year): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.year = :year')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('year', $year)
|
||||||
|
->orderBy('p.month', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Vérifier le lint**
|
||||||
|
|
||||||
|
Run: `docker compose exec php php -l src/Entity/EmployeeRttPayment.php && docker compose exec php php -l src/Repository/EmployeeRttPaymentRepository.php`
|
||||||
|
Expected: No syntax errors
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/EmployeeRttPayment.php src/Repository/EmployeeRttPaymentRepository.php
|
||||||
|
git commit -m "feat(rtt): add EmployeeRttPayment entity and repository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: API Resource + Provider + Processor pour le paiement RTT
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ApiResource/EmployeeRttPaymentInput.php`
|
||||||
|
- Create: `src/State/EmployeeRttPaymentProvider.php`
|
||||||
|
- Create: `src/State/EmployeeRttPaymentProcessor.php`
|
||||||
|
|
||||||
|
**Step 1: Créer l'API Resource**
|
||||||
|
|
||||||
|
Endpoint: `PATCH /employees/{id}/rtt-payments` (ROLE_ADMIN)
|
||||||
|
Body: `{ "month": 3, "minutes": 120, "rate": "25" }`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/ApiResource/EmployeeRttPaymentInput.php
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/employees/{id}/rtt-payments',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeRttPaymentProvider::class,
|
||||||
|
processor: EmployeeRttPaymentProcessor::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeRttPaymentInput
|
||||||
|
{
|
||||||
|
public int $month = 0;
|
||||||
|
public int $minutes = 0;
|
||||||
|
public string $rate = '25';
|
||||||
|
public ?int $year = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Créer le Provider** (retourne un DTO vide, même pattern que `EmployeeFractionedDaysProvider`)
|
||||||
|
|
||||||
|
**Step 3: Créer le Processor**
|
||||||
|
|
||||||
|
Logique:
|
||||||
|
- Valider `rate` in `['25', '50']`, `month` in `[1..12]`, `minutes >= 0`
|
||||||
|
- Résoudre l'année (même logique exercice RTT que le provider existant)
|
||||||
|
- Persister un `EmployeeRttPayment`
|
||||||
|
|
||||||
|
**Step 4: Vérifier le lint**
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ApiResource/EmployeeRttPaymentInput.php src/State/EmployeeRttPaymentProvider.php src/State/EmployeeRttPaymentProcessor.php
|
||||||
|
git commit -m "feat(rtt): add PATCH endpoint for RTT payment"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Modifier le DTO + Provider RTT pour inclure les paiements par mois
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ApiResource/EmployeeRttSummary.php`
|
||||||
|
- Modify: `src/State/EmployeeRttSummaryProvider.php`
|
||||||
|
- Create: `src/Dto/Rtt/RttMonthPayment.php`
|
||||||
|
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
|
||||||
|
|
||||||
|
**Step 1: Créer le DTO mois-paiement**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Dto/Rtt/RttMonthPayment.php
|
||||||
|
final class RttMonthPayment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $paidMinutes25 = 0,
|
||||||
|
public int $paidMinutes50 = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Ajouter au summary backend**
|
||||||
|
|
||||||
|
Dans `EmployeeRttSummary.php`, ajouter:
|
||||||
|
```php
|
||||||
|
public int $totalPaidMinutes = 0;
|
||||||
|
|
||||||
|
/** @var list<RttMonthPayment> */
|
||||||
|
public array $monthPayments = [];
|
||||||
|
```
|
||||||
|
|
||||||
|
Et modifier `availableMinutes` pour soustraire les paiements:
|
||||||
|
```php
|
||||||
|
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes
|
||||||
|
+ $summary->currentYearRecoveryMinutes
|
||||||
|
- $summary->totalPaidMinutes;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Modifier le provider** pour charger les paiements via le repository et les agréger par mois
|
||||||
|
|
||||||
|
Dans `EmployeeRttSummaryProvider::provide()`:
|
||||||
|
- Charger `$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year)`
|
||||||
|
- Agréger par mois + rate pour construire les `RttMonthPayment`
|
||||||
|
- Calculer `totalPaidMinutes = sum(minutes)`
|
||||||
|
- Soustraire du `availableMinutes`
|
||||||
|
|
||||||
|
**Step 4: Mettre à jour le DTO frontend**
|
||||||
|
|
||||||
|
Dans `frontend/services/dto/employee-rtt-summary.ts`:
|
||||||
|
```typescript
|
||||||
|
export type RttMonthPayment = {
|
||||||
|
month: number
|
||||||
|
paidMinutes25: number
|
||||||
|
paidMinutes50: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmployeeRttSummary = {
|
||||||
|
// ... champs existants ...
|
||||||
|
totalPaidMinutes: number
|
||||||
|
monthPayments: RttMonthPayment[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(rtt): include paid hours in RTT summary by month"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Frontend - Afficher les RTT en heures + lignes payées
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/employees/RttTab.vue`
|
||||||
|
|
||||||
|
**Step 1: Changer l'affichage du header de jours en heures**
|
||||||
|
|
||||||
|
Ligne 4 actuelle:
|
||||||
|
```html
|
||||||
|
<p>...: {{ formatDays(summary?.availableMinutes ?? 0) }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplacer par:
|
||||||
|
```html
|
||||||
|
<p>...: {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Ajouter les 2 lignes de paiement par mois**
|
||||||
|
|
||||||
|
Après la ligne `Heure payée` existante (ligne 33-34), remplacer par 2 lignes distinctes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<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">{{ formatMinutes(getMonthPaid25(month.month)) }}</div>
|
||||||
|
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
|
||||||
|
<div class="py-[6px] pl-3">{{ formatMinutes(getMonthPaid50(month.month)) }}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Ajouter les helpers computed**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const paymentsByMonth = computed(() => {
|
||||||
|
const map = new Map<number, { paid25: number; paid50: number }>()
|
||||||
|
for (const mp of props.summary?.monthPayments ?? []) {
|
||||||
|
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
|
||||||
|
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(rtt): display hours instead of days + paid hours per month"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Frontend - Drawer de paiement RTT
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/employees/RttTab.vue`
|
||||||
|
- Modify: `frontend/services/employee-rtt-summary.ts`
|
||||||
|
- Modify: `frontend/composables/useEmployeeDetailPage.ts`
|
||||||
|
- Modify: `frontend/pages/employees/[id].vue`
|
||||||
|
|
||||||
|
**Step 1: Ajouter le service API**
|
||||||
|
|
||||||
|
Dans `frontend/services/employee-rtt-summary.ts`:
|
||||||
|
```typescript
|
||||||
|
export const createRttPayment = async (
|
||||||
|
employeeId: number,
|
||||||
|
month: number,
|
||||||
|
minutes: number,
|
||||||
|
rate: '25' | '50',
|
||||||
|
year?: number
|
||||||
|
) => {
|
||||||
|
const api = useApi()
|
||||||
|
const body: Record<string, unknown> = { month, minutes, rate }
|
||||||
|
if (year) body.year = year
|
||||||
|
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Ajouter au composable**
|
||||||
|
|
||||||
|
Dans `useEmployeeDetailPage.ts`:
|
||||||
|
- Import `createRttPayment`
|
||||||
|
- Ajouter `submitRttPayment(month, minutes, rate)` qui appelle l'API puis `loadEmployee()`
|
||||||
|
- Exposer dans le return
|
||||||
|
|
||||||
|
**Step 3: Passer l'event dans la page**
|
||||||
|
|
||||||
|
Dans `frontend/pages/employees/[id].vue`:
|
||||||
|
- Destructurer `submitRttPayment`
|
||||||
|
- Ajouter `@submit-rtt-payment="submitRttPayment"` sur `<EmployeesRttTab>`
|
||||||
|
|
||||||
|
**Step 4: Ajouter le drawer dans RttTab.vue**
|
||||||
|
|
||||||
|
Même pattern que le drawer fractionnés dans LeaveTab:
|
||||||
|
- Import `AppDrawer`
|
||||||
|
- State: `isPaymentDrawerOpen`, `paymentForm: { month, minutes, rate }`
|
||||||
|
- Bouton "Payer les RTT" ouvre le drawer
|
||||||
|
- Formulaire avec:
|
||||||
|
- Select mois (Janvier..Décembre)
|
||||||
|
- Input number "Nombre d'heures" (step 0.5, converti en minutes au submit)
|
||||||
|
- Select rate: "25%" / "50%"
|
||||||
|
- Boutons Annuler / Enregistrer
|
||||||
|
- Emit `submit-rtt-payment` au submit
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(rtt): add payment drawer with month/hours/rate fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Documentation fonctionnelle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `doc/functional-rules.md`
|
||||||
|
|
||||||
|
**Step 1: Mettre à jour la section RTT**
|
||||||
|
|
||||||
|
Ajouter après les règles RTT existantes:
|
||||||
|
- Paiement RTT: saisie RH via `PATCH /employees/{id}/rtt-payments`
|
||||||
|
- Stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
|
||||||
|
- Les heures payées sont soustraites du disponible RTT
|
||||||
|
- Affichage: 2 lignes par mois (25% et 50%)
|
||||||
|
- L'affichage global RTT est en heures (plus en jours)
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "docs: update functional rules with RTT payment feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé des fichiers
|
||||||
|
|
||||||
|
| Action | Fichier |
|
||||||
|
|--------|---------|
|
||||||
|
| Create | `migrations/Version20260309140000.php` |
|
||||||
|
| Create | `src/Entity/EmployeeRttPayment.php` |
|
||||||
|
| Create | `src/Repository/EmployeeRttPaymentRepository.php` |
|
||||||
|
| Create | `src/ApiResource/EmployeeRttPaymentInput.php` |
|
||||||
|
| Create | `src/State/EmployeeRttPaymentProvider.php` |
|
||||||
|
| Create | `src/State/EmployeeRttPaymentProcessor.php` |
|
||||||
|
| Create | `src/Dto/Rtt/RttMonthPayment.php` |
|
||||||
|
| Modify | `src/ApiResource/EmployeeRttSummary.php` |
|
||||||
|
| Modify | `src/State/EmployeeRttSummaryProvider.php` |
|
||||||
|
| Modify | `frontend/services/dto/employee-rtt-summary.ts` |
|
||||||
|
| Modify | `frontend/services/employee-rtt-summary.ts` |
|
||||||
|
| Modify | `frontend/components/employees/RttTab.vue` |
|
||||||
|
| Modify | `frontend/composables/useEmployeeDetailPage.ts` |
|
||||||
|
| Modify | `frontend/pages/employees/[id].vue` |
|
||||||
|
| Modify | `doc/functional-rules.md` |
|
||||||
273
docs/superpowers/plans/2026-03-12-employee-entry-date.md
Normal file
273
docs/superpowers/plans/2026-03-12-employee-entry-date.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Employee Entry Date Implementation Plan
|
||||||
|
|
||||||
|
> **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:** Add an `entryDate` field to Employee, automatically populated from `contractStartDate` at creation.
|
||||||
|
|
||||||
|
**Architecture:** New nullable `DATE` column on `employees` table. The `EmployeeWriteProcessor` sets `entryDate` from the first contract period's start date during employee creation. Exposed read-only in the API. No fallback needed — existing employees will be updated manually in prod DB.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony/Doctrine (backend), Nuxt/Vue/TypeScript (frontend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/Entity/Employee.php` | Modify | Add `entryDate` column + getter/setter, expose in `employee:read` |
|
||||||
|
| `src/State/EmployeeWriteProcessor.php` | Modify | Set `entryDate` from `contractStartDate` on creation |
|
||||||
|
| `migrations/Version20260312120000.php` | Create | Add `entry_date` column to `employees` table |
|
||||||
|
| `tests/State/EmployeeWriteProcessorTest.php` | Modify | Assert `entryDate` is set on new employee |
|
||||||
|
| `frontend/services/dto/employee.ts` | Modify | Add `entryDate` field to `Employee` type |
|
||||||
|
| `frontend/pages/employees/index.vue` | Modify | Display entry date in employee list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Backend
|
||||||
|
|
||||||
|
### Task 1: Migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `migrations/Version20260312120000.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the migration file**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260312120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add entry_date column to employees table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees DROP entry_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run migration**
|
||||||
|
|
||||||
|
Run: `php bin/console doctrine:migrations:migrate --no-interaction`
|
||||||
|
Expected: Migration applied successfully
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add migrations/Version20260312120000.php
|
||||||
|
git commit -m "feat : ajout colonne entry_date sur employees"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Entity — add `entryDate` property
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/Employee.php:56-61` (insert after `displayOrder`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the column, getter, and setter to Employee entity**
|
||||||
|
|
||||||
|
Add after the `displayOrder` property (line 58):
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
private ?\DateTimeImmutable $entryDate = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add getter and setter after `setDisplayOrder()` (after line 167):
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getEntryDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->entryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEntryDate(?\DateTimeImmutable $entryDate): self
|
||||||
|
{
|
||||||
|
$this->entryDate = $entryDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify schema is in sync**
|
||||||
|
|
||||||
|
Run: `php bin/console doctrine:schema:validate`
|
||||||
|
Expected: OK (or only existing unrelated warnings)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/Employee.php
|
||||||
|
git commit -m "feat : ajout propriete entryDate sur Employee"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Set `entryDate` on employee creation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/State/EmployeeWriteProcessor.php:60-71`
|
||||||
|
- Modify: `tests/State/EmployeeWriteProcessorTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add `use ApiPlatform\Metadata\Post;` to the imports at the top of the test file (alongside the existing `Delete` and `Patch` imports).
|
||||||
|
|
||||||
|
Then add this test method to `EmployeeWriteProcessorTest`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testSetsEntryDateOnNewEmployee(): void
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName('Jane');
|
||||||
|
$employee->setLastName('Doe');
|
||||||
|
$employee->setContractStartDate('2026-04-01');
|
||||||
|
$employee->setContractNature('CDI');
|
||||||
|
|
||||||
|
$contract = new Contract()
|
||||||
|
->setName('35h')
|
||||||
|
->setTrackingMode(Contract::TRACKING_TIME)
|
||||||
|
->setWeeklyHours(35);
|
||||||
|
$employee->setContract($contract);
|
||||||
|
|
||||||
|
$persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||||
|
$removeProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
|
||||||
|
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
|
||||||
|
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
|
||||||
|
|
||||||
|
$persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->willReturn($employee);
|
||||||
|
|
||||||
|
$periodManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('ensureContractPeriodExists');
|
||||||
|
|
||||||
|
$processor = new EmployeeWriteProcessor(
|
||||||
|
$persistProcessor,
|
||||||
|
$removeProcessor,
|
||||||
|
$entityManager,
|
||||||
|
$periodRepository,
|
||||||
|
$changeRequestFactory,
|
||||||
|
$periodManager
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->process($employee, new Post());
|
||||||
|
|
||||||
|
self::assertNotNull($employee->getEntryDate());
|
||||||
|
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
|
||||||
|
Expected: FAIL — `entryDate` is null
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement — set entryDate in EmployeeWriteProcessor**
|
||||||
|
|
||||||
|
In `src/State/EmployeeWriteProcessor.php`, inside the `if ($isNew)` block (line 60-71), add **before** `return $result;` (line 71):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$data->setEntryDate($startDate);
|
||||||
|
```
|
||||||
|
|
||||||
|
The full block becomes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($isNew) {
|
||||||
|
$startDate = $changeRequest->contractStartDate ?? new DateTimeImmutable('1970-01-01');
|
||||||
|
$nature = $changeRequest->contractNature ?? ContractNature::CDI;
|
||||||
|
$this->periodManager->ensureContractPeriodExists(
|
||||||
|
employee: $data,
|
||||||
|
contract: $currentContract,
|
||||||
|
startDate: $startDate,
|
||||||
|
endDate: $changeRequest->contractEndDate,
|
||||||
|
nature: $nature
|
||||||
|
);
|
||||||
|
|
||||||
|
$data->setEntryDate($startDate);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all EmployeeWriteProcessor tests**
|
||||||
|
|
||||||
|
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php`
|
||||||
|
Expected: All tests pass (no regression)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/State/EmployeeWriteProcessor.php tests/State/EmployeeWriteProcessorTest.php
|
||||||
|
git commit -m "feat : remplissage automatique entryDate a la creation employe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Frontend
|
||||||
|
|
||||||
|
### Task 4: Update frontend DTO and display entry date
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/services/dto/employee.ts:14-25`
|
||||||
|
- Modify: `frontend/pages/employees/index.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `entryDate` to the Employee DTO**
|
||||||
|
|
||||||
|
In `frontend/services/dto/employee.ts:24`, add `entryDate` after `displayOrder`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
displayOrder?: number
|
||||||
|
entryDate?: string | null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Display entry date in the employee card hover overlay**
|
||||||
|
|
||||||
|
In `frontend/pages/employees/index.vue`, inside the hover overlay `<div>` (line 49-54), add a new line after the "Site" line (after line 53):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<p><strong>Entree :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses string splitting instead of `new Date()` to avoid timezone parsing issues with date-only strings.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify in browser**
|
||||||
|
|
||||||
|
1. Check the API response for an employee: `GET /api/employees` should include `entryDate` field (confirms backend `employee:read` group works)
|
||||||
|
2. Open the employee list page, hover over a card — entry date should appear in the overlay
|
||||||
|
3. Create a new employee, verify the entry date shows the contract start date
|
||||||
|
4. Existing employees without entry date should show "-"
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/services/dto/employee.ts frontend/pages/employees/index.vue
|
||||||
|
git commit -m "feat : affichage date d'entree dans la liste employes"
|
||||||
|
```
|
||||||
233
frontend/components/AppTopNav.vue
Normal file
233
frontend/components/AppTopNav.vue
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
||||||
|
<div class="flex h-full items-center justify-end">
|
||||||
|
<div class="flex gap-6 text-xl text-white">
|
||||||
|
<div v-if="isAdmin" ref="bellRoot" class="relative">
|
||||||
|
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
|
||||||
|
<Icon name="mdi:bell-plus" size="36"/>
|
||||||
|
<span
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
class="absolute -right-1 -top-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
{{ unreadCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isNotificationsOpen"
|
||||||
|
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
|
||||||
|
:style="{ top: `${navbarBottom + 20}px` }"
|
||||||
|
>
|
||||||
|
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
|
||||||
|
Notifications
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-6 px-3 pb-2 border-b border-black">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-b-2 cursor-pointer text-[18px]"
|
||||||
|
:class="activeNotifTab === 'unread' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
|
||||||
|
@click="switchNotifTab('unread')"
|
||||||
|
>
|
||||||
|
Non lues
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-b-2 cursor-pointer text-[18px]"
|
||||||
|
:class="activeNotifTab === 'history' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
|
||||||
|
@click="switchNotifTab('history')"
|
||||||
|
>
|
||||||
|
Historique
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="isLoadingNotifications" class="px-3 py-3 text-sm text-neutral-500">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="displayedNotifications.length === 0" class="px-3 py-3 text-sm text-neutral-500">
|
||||||
|
Aucune notification.
|
||||||
|
</div>
|
||||||
|
<div v-else class="max-h-80 overflow-auto">
|
||||||
|
<NuxtLink
|
||||||
|
:to="notification.target"
|
||||||
|
v-for="notification in displayedNotifications"
|
||||||
|
:key="notification.id"
|
||||||
|
class="flex gap-5 items-center border-b border-black px-3 py-4 last:border-b-0 relative hover:bg-tertiary-500"
|
||||||
|
:class="notification.isRead ? '' : 'bg-tertiary-500'"
|
||||||
|
>
|
||||||
|
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
||||||
|
<div class="flex flex-col min-w-0 text-[16px]">
|
||||||
|
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
||||||
|
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="userMenuRoot" class="relative flex gap-4">
|
||||||
|
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
|
||||||
|
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
|
||||||
|
<p class="self-center">{{ user?.username }}</p>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="isUserMenuOpen"
|
||||||
|
class="fixed right-[20px] z-30 w-60 rounded-md border border-neutral-200 bg-white text-[16px] text-black font-semibold shadow-lg"
|
||||||
|
:style="{ top: `${navbarBottom + 20}px` }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 border-b border-black"
|
||||||
|
>
|
||||||
|
Mon profil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 flex justify-between items-center"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<p>Déconnexion</p>
|
||||||
|
<Icon name="mdi:logout-variant" size="20"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {User} from '~/services/dto/user'
|
||||||
|
import type {NotificationItem} from '~/services/dto/notification'
|
||||||
|
import {listUnreadNotifications, listTodayNotifications, listHistoryNotifications, markAllNotificationsRead} from '~/services/notifications'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
user?: User
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60000)
|
||||||
|
if (diffMinutes < 1) return "À l'instant"
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60)
|
||||||
|
if (diffHours < 24) return `${diffHours} heure${diffHours > 1 ? 's' : ''}`
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
return `${diffDays} jour${diffDays > 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const headerRef = ref<HTMLElement | null>(null)
|
||||||
|
const bellRoot = ref<HTMLElement | null>(null)
|
||||||
|
const userMenuRoot = ref<HTMLElement | null>(null)
|
||||||
|
const isUserMenuOpen = ref(false)
|
||||||
|
const navbarBottom = ref(0)
|
||||||
|
|
||||||
|
const updateNavbarBottom = () => {
|
||||||
|
if (headerRef.value) {
|
||||||
|
navbarBottom.value = headerRef.value.getBoundingClientRect().bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const todayNotifications = ref<NotificationItem[]>([])
|
||||||
|
const historyNotifications = ref<NotificationItem[]>([])
|
||||||
|
const isNotificationsOpen = ref(false)
|
||||||
|
const isLoadingNotifications = ref(false)
|
||||||
|
const activeNotifTab = ref<'unread' | 'history'>('unread')
|
||||||
|
const unreadCount = computed(() => todayNotifications.value.length)
|
||||||
|
const displayedNotifications = computed(() => activeNotifTab.value === 'unread' ? todayNotifications.value : historyNotifications.value)
|
||||||
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
|
const toggleUserMenu = () => {
|
||||||
|
updateNavbarBottom()
|
||||||
|
isUserMenuOpen.value = !isUserMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await auth.logout()
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTodayNotifications = async () => {
|
||||||
|
todayNotifications.value = await listTodayNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadHistoryNotifications = async () => {
|
||||||
|
historyNotifications.value = await listHistoryNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadNotifications = async () => {
|
||||||
|
isLoadingNotifications.value = true
|
||||||
|
try {
|
||||||
|
await loadTodayNotifications()
|
||||||
|
} finally {
|
||||||
|
isLoadingNotifications.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchNotifTab = async (tab: 'unread' | 'history') => {
|
||||||
|
activeNotifTab.value = tab
|
||||||
|
isLoadingNotifications.value = true
|
||||||
|
try {
|
||||||
|
if (tab === 'history') {
|
||||||
|
await loadHistoryNotifications()
|
||||||
|
} else {
|
||||||
|
await loadTodayNotifications()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoadingNotifications.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNotifications = async () => {
|
||||||
|
if (!isNotificationsOpen.value) return
|
||||||
|
isNotificationsOpen.value = false
|
||||||
|
if (todayNotifications.value.length > 0) {
|
||||||
|
await markAllNotificationsRead()
|
||||||
|
todayNotifications.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleNotifications = async () => {
|
||||||
|
if (isNotificationsOpen.value) {
|
||||||
|
await closeNotifications()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNavbarBottom()
|
||||||
|
activeNotifTab.value = 'unread'
|
||||||
|
isNotificationsOpen.value = true
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickOutside = async (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (!target) return
|
||||||
|
if (bellRoot.value && !bellRoot.value.contains(target)) {
|
||||||
|
await closeNotifications()
|
||||||
|
}
|
||||||
|
if (userMenuRoot.value && !userMenuRoot.value.contains(target)) {
|
||||||
|
isUserMenuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
updateNavbarBottom()
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
async () => {
|
||||||
|
if (!isAdmin.value) return
|
||||||
|
await loadNotifications()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
v-for="day in daysInMonth"
|
v-for="day in daysInMonth"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
|
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
|
||||||
:class="isHoveredColumn(day.date) ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
|
:class="isHoveredColumn(day.date) || day.date === today ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
|
||||||
>
|
>
|
||||||
<div>{{ day.label }}</div>
|
<div>{{ day.label }}</div>
|
||||||
<div
|
<div
|
||||||
class="text-[10px]"
|
class="text-[10px]"
|
||||||
:class="isHoveredColumn(day.date) ? 'text-white/90' : 'text-neutral-500'"
|
:class="isHoveredColumn(day.date) || day.date === today ? 'text-white/90' : 'text-neutral-500'"
|
||||||
>
|
>
|
||||||
{{ day.weekday }}
|
{{ day.weekday }}
|
||||||
</div>
|
</div>
|
||||||
@@ -91,6 +91,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import type { HalfDay } from '~/services/dto/half-day'
|
import type { HalfDay } from '~/services/dto/half-day'
|
||||||
|
import { toYmd } from '~/utils/date'
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
|
||||||
type DayInfo = {
|
type DayInfo = {
|
||||||
date: string
|
date: string
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<input
|
<div class="relative w-full max-w-[340px]">
|
||||||
v-model="model"
|
<input
|
||||||
type="text"
|
id="employee-search"
|
||||||
:placeholder="placeholder"
|
v-model="model"
|
||||||
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
type="text"
|
||||||
/>
|
:placeholder="placeholder"
|
||||||
|
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name="mdi:magnify"
|
||||||
|
size="18"
|
||||||
|
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const model = defineModel<string>({ required: true })
|
const model = defineModel<string>({required: true})
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
placeholder: 'Chercher un employé (nom ou prénom)'
|
placeholder: "Recherche d'un employé"
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,25 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="inline-flex w-fit max-w-full flex-wrap items-center gap-6 py-2">
|
<div ref="root" class="relative inline-block w-fit max-w-full">
|
||||||
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
<button
|
||||||
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded" />
|
type="button"
|
||||||
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
class="inline-flex w-[320px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
|
||||||
<input
|
@click="isOpen = !isOpen"
|
||||||
:id="`site-${site.id}`"
|
>
|
||||||
v-model="selectedSiteIds"
|
<span>Sites</span>
|
||||||
:value="site.id"
|
<span class="inline-flex items-center gap-2">
|
||||||
type="checkbox"
|
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
|
||||||
class="h-4 w-4"
|
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
|
||||||
/>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
v-for="site in sites"
|
||||||
|
:key="site.id"
|
||||||
|
:for="`site-${site.id}`"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:id="`site-${site.id}`"
|
||||||
|
v-model="selectedSiteIds"
|
||||||
|
:value="site.id"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span class="text-md text-neutral-800">{{ site.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
|
|
||||||
const selectedSiteIds = defineModel<number[]>({ required: true })
|
const selectedSiteIds = defineModel<number[]>({ required: true })
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selectedSiteIds.value.length)
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (!root.value || !target) return
|
||||||
|
if (!root.value.contains(target)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
249
frontend/components/employees/ContractTab.vue
Normal file
249
frontend/components/employees/ContractTab.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||||
|
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||||
|
<p>Contrat</p>
|
||||||
|
<p>Heures</p>
|
||||||
|
<p>Date de début</p>
|
||||||
|
<p>Date de fin</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
|
||||||
|
Aucun historique de contrat.
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="item in contractHistory"
|
||||||
|
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
||||||
|
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
|
||||||
|
>
|
||||||
|
<p>{{ contractNatureLabel(item.contractNature) }}</p>
|
||||||
|
<p>{{ contractHistoryLabel(item) }}</p>
|
||||||
|
<p>{{ formatDate(item.startDate) }}</p>
|
||||||
|
<p>{{ formatDate(item.endDate) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex justify-center gap-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-[200px] rounded-md bg-blue-500 py-2 text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isContractSubmitting || !canCloseCurrentContract"
|
||||||
|
@click="onOpenCloseContractDrawer"
|
||||||
|
>
|
||||||
|
Clôturer
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
:disabled="isCreateContractSubmitting || contracts.length === 0 || !canCreateContract"
|
||||||
|
@click="onOpenCreateContractDrawer"
|
||||||
|
>
|
||||||
|
+ Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
||||||
|
Type de contrat
|
||||||
|
</label>
|
||||||
|
<input id="contract-nature" :value="contractNatureLabel(contractForm.contractNature)" type="text" :class="readonlyFieldClass" readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract">
|
||||||
|
Temps de travail
|
||||||
|
</label>
|
||||||
|
<input id="contract" :value="closeContractWorkedHoursLabel" type="text" :class="readonlyFieldClass" readonly />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
||||||
|
Début contrat
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contract-start-date"
|
||||||
|
:value="contractForm.startDate"
|
||||||
|
type="date"
|
||||||
|
:class="readonlyFieldClass"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
||||||
|
Fin contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contract-end-date"
|
||||||
|
v-model="contractForm.endDate"
|
||||||
|
type="date"
|
||||||
|
:class="contractEndDateFieldClass"
|
||||||
|
/>
|
||||||
|
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
|
||||||
|
Commentaire
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contract-comment"
|
||||||
|
v-model="contractForm.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="Motif de la clôture..."
|
||||||
|
/>
|
||||||
|
</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="contract-paid-leave-settled">
|
||||||
|
<input
|
||||||
|
id="contract-paid-leave-settled"
|
||||||
|
v-model="contractForm.paidLeaveSettled"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
Soldé dans le solde de tout compte
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||||
|
:disabled="isContractSubmitting"
|
||||||
|
@click="onUpdateContractDrawerOpen(false)"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isContractSubmitting || !isContractEndDateValid"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
|
||||||
|
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
|
||||||
|
Type de contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select id="create-contract-nature" v-model="createContractForm.contractNature" :class="createContractNatureFieldClass">
|
||||||
|
<option value="CDI">CDI</option>
|
||||||
|
<option value="CDD">CDD</option>
|
||||||
|
<option value="INTERIM">Intérim</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
|
||||||
|
Temps de travail <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select id="create-contract-id" v-model="createContractForm.contractId" :class="createContractFieldClass">
|
||||||
|
<option value="">Sélectionner un contrat</option>
|
||||||
|
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
||||||
|
{{ contract.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-start-date">
|
||||||
|
Début contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showsCreateContractEndDate">
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-end-date">
|
||||||
|
Fin contrat <span v-if="requiresCreateContractEndDate" class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||||
|
:disabled="isCreateContractSubmitting"
|
||||||
|
@click="onUpdateCreateContractDrawerOpen(false)"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Contract } from '~/services/dto/contract'
|
||||||
|
import type { ContractHistoryItem } from '~/services/dto/employee'
|
||||||
|
|
||||||
|
type ContractForm = {
|
||||||
|
contractId: number | ''
|
||||||
|
contractName: string
|
||||||
|
weeklyHours: number | null
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
paidLeaveSettled: boolean
|
||||||
|
comment: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateContractForm = {
|
||||||
|
contractId: number | ''
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
contractHistory: ContractHistoryItem[]
|
||||||
|
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
|
||||||
|
contractHistoryLabel: (item: ContractHistoryItem) => string
|
||||||
|
formatDate: (value?: string | null) => string
|
||||||
|
isContractSubmitting: boolean
|
||||||
|
canCloseCurrentContract: boolean
|
||||||
|
isCreateContractSubmitting: boolean
|
||||||
|
contracts: Contract[]
|
||||||
|
canCreateContract: boolean
|
||||||
|
isContractDrawerOpen: boolean
|
||||||
|
contractForm: ContractForm
|
||||||
|
readonlyFieldClass: string
|
||||||
|
closeContractWorkedHoursLabel: string
|
||||||
|
contractEndDateFieldClass: string
|
||||||
|
showContractEndDateError: boolean
|
||||||
|
isContractEndDateValid: boolean
|
||||||
|
isCreateContractDrawerOpen: boolean
|
||||||
|
createContractForm: CreateContractForm
|
||||||
|
createContractNatureFieldClass: string
|
||||||
|
createContractFieldClass: string
|
||||||
|
createContractStartDateFieldClass: string
|
||||||
|
showsCreateContractEndDate: boolean
|
||||||
|
requiresCreateContractEndDate: boolean
|
||||||
|
createContractEndDateFieldClass: string
|
||||||
|
isCreateContractFormValid: boolean
|
||||||
|
onOpenCloseContractDrawer: () => void
|
||||||
|
onOpenCreateContractDrawer: () => void
|
||||||
|
onUpdateContractDrawerOpen: (open: boolean) => void
|
||||||
|
onUpdateCreateContractDrawerOpen: (open: boolean) => void
|
||||||
|
onSubmitCloseContract: () => void
|
||||||
|
onSubmitCreateContract: () => void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
309
frontend/components/employees/LeaveTab.vue
Normal file
309
frontend/components/employees/LeaveTab.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||||
|
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
|
||||||
|
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
||||||
|
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
||||||
|
formatCount(summary?.acquiredDays)
|
||||||
|
}} Jours</p>
|
||||||
|
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
||||||
|
{{ formatCount(summary?.remainingDays) }} Jours</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
||||||
|
<p><span class="uppercase font-semibold">Samedi acquis :</span>
|
||||||
|
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
|
||||||
|
<p><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||||
|
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
||||||
|
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
|
||||||
|
<button
|
||||||
|
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
|
||||||
|
@click="openFractionedDrawer"
|
||||||
|
>
|
||||||
|
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col jutify-center gap-2 items-center py-3">
|
||||||
|
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
|
||||||
|
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||||
|
<div class="grid grid-cols-4 gap-10">
|
||||||
|
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
|
||||||
|
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
|
||||||
|
{{ month.label }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
|
||||||
|
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
|
||||||
|
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
|
||||||
|
<div v-if="!day" class="h-6"/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-6 w-6"
|
||||||
|
:class="getDayClass(day)"
|
||||||
|
:style="getDayStyle(day)"
|
||||||
|
:title="getDayTitle(day)"
|
||||||
|
>
|
||||||
|
{{ getDayText(day) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="fractioned-days">
|
||||||
|
Nombre de jours <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fractioned-days"
|
||||||
|
v-model="fractionedForm.days"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||||
|
@click="isFractionedDrawerOpen = false"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {Absence} from '~/services/dto/absence'
|
||||||
|
import type {EmployeeLeaveSummary} from '~/services/dto/employee-leave-summary'
|
||||||
|
import {normalizeDate, toYmd} from '~/utils/date'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
|
type DayLeaveState = {
|
||||||
|
am: boolean
|
||||||
|
pm: boolean
|
||||||
|
labels: string[]
|
||||||
|
colors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
absences: Absence[]
|
||||||
|
summary: EmployeeLeaveSummary | null
|
||||||
|
publicHolidays: Record<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update-fractioned-days', days: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isFractionedDrawerOpen = ref(false)
|
||||||
|
const fractionedForm = reactive({ days: 0 })
|
||||||
|
|
||||||
|
const openFractionedDrawer = () => {
|
||||||
|
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
||||||
|
isFractionedDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitFractioned = () => {
|
||||||
|
const value = Number(fractionedForm.days)
|
||||||
|
if (Number.isNaN(value) || value < 0) return
|
||||||
|
emit('update-fractioned-days', value)
|
||||||
|
isFractionedDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthLabels = [
|
||||||
|
'Janvier',
|
||||||
|
'Fevrier',
|
||||||
|
'Mars',
|
||||||
|
'Avril',
|
||||||
|
'Mai',
|
||||||
|
'Juin',
|
||||||
|
'Juillet',
|
||||||
|
'Aout',
|
||||||
|
'Septembre',
|
||||||
|
'Octobre',
|
||||||
|
'Novembre',
|
||||||
|
'Decembre'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
|
||||||
|
|
||||||
|
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
||||||
|
|
||||||
|
const displayedYear = computed(() => {
|
||||||
|
if (props.summary?.year) return props.summary.year
|
||||||
|
const today = new Date()
|
||||||
|
const year = today.getFullYear()
|
||||||
|
const month = today.getMonth() + 1
|
||||||
|
return month >= 6 ? year + 1 : year
|
||||||
|
})
|
||||||
|
|
||||||
|
const orderedMonthIndexes = computed(() => {
|
||||||
|
if (isForfaitRule.value) return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
||||||
|
return [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4]
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildDateFromYmd = (value: string) => new Date(`${value}T00:00:00`)
|
||||||
|
|
||||||
|
const dayLeaveMap = computed(() => {
|
||||||
|
const map = new Map<string, DayLeaveState>()
|
||||||
|
|
||||||
|
for (const absence of props.absences) {
|
||||||
|
const startYmd = normalizeDate(absence.startDate)
|
||||||
|
const endYmd = normalizeDate(absence.endDate)
|
||||||
|
const start = buildDateFromYmd(startYmd)
|
||||||
|
const end = buildDateFromYmd(endYmd)
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue
|
||||||
|
|
||||||
|
for (const cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
|
||||||
|
const ymd = toYmd(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())
|
||||||
|
const existing = map.get(ymd) ?? {
|
||||||
|
am: false,
|
||||||
|
pm: false,
|
||||||
|
labels: [] as string[],
|
||||||
|
colors: [] as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStart = ymd === startYmd
|
||||||
|
const isEnd = ymd === endYmd
|
||||||
|
const isSingleDay = startYmd === endYmd
|
||||||
|
|
||||||
|
let am = false
|
||||||
|
let pm = false
|
||||||
|
|
||||||
|
if (isSingleDay) {
|
||||||
|
am = absence.startHalf === 'AM'
|
||||||
|
pm = absence.endHalf === 'PM'
|
||||||
|
} else if (isStart) {
|
||||||
|
am = absence.startHalf === 'AM'
|
||||||
|
pm = true
|
||||||
|
} else if (isEnd) {
|
||||||
|
am = true
|
||||||
|
pm = absence.endHalf === 'PM'
|
||||||
|
} else {
|
||||||
|
am = true
|
||||||
|
pm = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence'
|
||||||
|
const typeColor = absence.type?.color ?? '#222783'
|
||||||
|
const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '')
|
||||||
|
const hoverLabel = `${typeLabel}${halfSuffix}`
|
||||||
|
|
||||||
|
const colors = existing.colors.includes(typeColor)
|
||||||
|
? existing.colors
|
||||||
|
: [...existing.colors, typeColor]
|
||||||
|
|
||||||
|
map.set(ymd, {
|
||||||
|
am: existing.am || am,
|
||||||
|
pm: existing.pm || pm,
|
||||||
|
labels: existing.labels.includes(hoverLabel)
|
||||||
|
? existing.labels
|
||||||
|
: [...existing.labels, hoverLabel],
|
||||||
|
colors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const months = computed(() => {
|
||||||
|
return orderedMonthIndexes.value.map((monthIndex) => {
|
||||||
|
const label = monthLabels[monthIndex]
|
||||||
|
const monthYear = isForfaitRule.value
|
||||||
|
? displayedYear.value
|
||||||
|
: (monthIndex >= 5 ? displayedYear.value - 1 : displayedYear.value)
|
||||||
|
|
||||||
|
const first = new Date(monthYear, monthIndex, 1)
|
||||||
|
const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate()
|
||||||
|
const mondayBasedFirstDay = (first.getDay() + 6) % 7
|
||||||
|
|
||||||
|
const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null; isHoliday: boolean } | null> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < mondayBasedFirstDay; i += 1) {
|
||||||
|
cells.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||||
|
const ymd = toYmd(monthYear, monthIndex, day)
|
||||||
|
cells.push({
|
||||||
|
ymd,
|
||||||
|
label: String(day),
|
||||||
|
leave: dayLeaveMap.value.get(ymd) ?? null,
|
||||||
|
isHoliday: ymd in props.publicHolidays
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cells.length % 7 !== 0) {
|
||||||
|
cells.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const getDayClass = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
|
||||||
|
if (day.leave) {
|
||||||
|
return 'rounded font-semibold text-white'
|
||||||
|
}
|
||||||
|
if (day.isHoliday) return 'text-primary-500 rounded font-semibold'
|
||||||
|
return 'text-primary-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
|
||||||
|
if (day.leave) {
|
||||||
|
const color = day.leave.colors[0] ?? '#222783'
|
||||||
|
if (day.leave.am && day.leave.pm) {
|
||||||
|
return { backgroundColor: color }
|
||||||
|
}
|
||||||
|
const colorFaded = `${color}60`
|
||||||
|
const backgroundImage = day.leave.am
|
||||||
|
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
|
||||||
|
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
|
||||||
|
return { backgroundImage, backgroundColor: 'transparent' }
|
||||||
|
}
|
||||||
|
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayText = (day: { label: string; leave: DayLeaveState | null }) => {
|
||||||
|
return day.label
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayTitle = (day: { leave: DayLeaveState | null; isHoliday: boolean; ymd: string }) => {
|
||||||
|
if (day.leave && day.leave.labels.length > 0) return day.leave.labels.join(' / ')
|
||||||
|
if (day.isHoliday) return props.publicHolidays[day.ymd] ?? 'Jour férié'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCount = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return '-'
|
||||||
|
const rounded = Math.round(value * 100) / 100
|
||||||
|
if (Number.isInteger(rounded)) return String(rounded)
|
||||||
|
return rounded.toFixed(2).replace('.', ',')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
220
frontend/components/employees/RttTab.vue
Normal file
220
frontend/components/employees/RttTab.vue
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<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">
|
||||||
|
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
||||||
|
<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">
|
||||||
|
+ Payer les RTT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||||
|
<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]">
|
||||||
|
{{ month.label }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
|
||||||
|
<template v-for="week in month.weeks" :key="week.key">
|
||||||
|
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
|
||||||
|
<span v-if="week.isEmpty"> </span>
|
||||||
|
<span v-else>Semaine {{ week.weekNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="py-[6px] pl-3 border-b border-primary-500">
|
||||||
|
<span v-if="week.isEmpty"> </span>
|
||||||
|
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
|
||||||
|
</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>
|
||||||
|
<AppDrawer v-model="isPaymentDrawerOpen" :title="isEditMode ? 'Modifier le paiement RTT' : 'Payer des RTT'">
|
||||||
|
<form @submit.prevent="onSubmitPayment">
|
||||||
|
<div class="mb-4">
|
||||||
|
<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">
|
||||||
|
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">Nombre d'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" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-neutral-700">Taux</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">
|
||||||
|
<option value="25">25%</option>
|
||||||
|
<option value="50">50%</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<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 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>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
summary: EmployeeRttSummary | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'submit-rtt-payment', month: number, minutes: number, rate: '25' | '50'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isPaymentDrawerOpen = ref(false)
|
||||||
|
const isEditMode = ref(false)
|
||||||
|
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
|
||||||
|
|
||||||
|
const monthLabels = [
|
||||||
|
'Janvier',
|
||||||
|
'Fevrier',
|
||||||
|
'Mars',
|
||||||
|
'Avril',
|
||||||
|
'Mai',
|
||||||
|
'Juin',
|
||||||
|
'Juillet',
|
||||||
|
'Aout',
|
||||||
|
'Septembre',
|
||||||
|
'Octobre',
|
||||||
|
'Novembre',
|
||||||
|
'Decembre'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const orderedMonthOptions = [
|
||||||
|
{ value: 6, label: 'Juin' },
|
||||||
|
{ value: 7, label: 'Juillet' },
|
||||||
|
{ value: 8, label: 'Aout' },
|
||||||
|
{ value: 9, label: 'Septembre' },
|
||||||
|
{ value: 10, label: 'Octobre' },
|
||||||
|
{ value: 11, label: 'Novembre' },
|
||||||
|
{ value: 12, label: 'Decembre' },
|
||||||
|
{ value: 1, label: 'Janvier' },
|
||||||
|
{ value: 2, label: 'Fevrier' },
|
||||||
|
{ value: 3, label: 'Mars' },
|
||||||
|
{ value: 4, label: 'Avril' },
|
||||||
|
{ value: 5, label: 'Mai' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const paymentsByMonth = computed(() => {
|
||||||
|
const map = new Map<number, { paid25: number; paid50: number }>()
|
||||||
|
for (const mp of props.summary?.monthPayments ?? []) {
|
||||||
|
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
|
||||||
|
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
||||||
|
|
||||||
|
const months = computed(() => {
|
||||||
|
type DisplayWeek = {
|
||||||
|
key: string
|
||||||
|
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 abs = Math.abs(minutes)
|
||||||
|
const hours = Math.floor(abs / 60)
|
||||||
|
const rest = abs % 60
|
||||||
|
const sign = minutes < 0 ? '-' : ''
|
||||||
|
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNewPayment = () => {
|
||||||
|
isEditMode.value = false
|
||||||
|
paymentForm.month = 6
|
||||||
|
paymentForm.hours = 0
|
||||||
|
paymentForm.rate = '25'
|
||||||
|
isPaymentDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditPayment = (month: number, rate: '25' | '50') => {
|
||||||
|
isEditMode.value = true
|
||||||
|
paymentForm.month = month
|
||||||
|
paymentForm.rate = rate
|
||||||
|
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
|
||||||
|
paymentForm.hours = currentMinutes / 60
|
||||||
|
isPaymentDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmitPayment = () => {
|
||||||
|
const minutes = Math.round(paymentForm.hours * 60)
|
||||||
|
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
|
||||||
|
isPaymentDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,253 +1,267 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
|
<div class="rounded-lg border border-neutral-200 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-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
||||||
:style="{ gridTemplateColumns: dayGridCols }"
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
>
|
>
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
<span class="pl-2">Absence</span>
|
<span class="pl-2">Absence</span>
|
||||||
<span class="pl-4">Début matin</span>
|
<span class="pl-4">Début matin</span>
|
||||||
<span class="pr-2">Fin matin</span>
|
<span class="pr-2">Fin matin</span>
|
||||||
<span class="pl-2">Début après-midi</span>
|
<span class="pl-2">Début après-midi</span>
|
||||||
<span class="pr-2">Fin après-midi</span>
|
<span class="pr-2">Fin après-midi</span>
|
||||||
<span class="pl-2">Début soir</span>
|
<span class="pl-2">Début soir</span>
|
||||||
<span class="pr-2">Fin soir</span>
|
<span class="pr-2">Fin soir</span>
|
||||||
<span class="pl-2">Jour</span>
|
<span class="pl-2">Jour</span>
|
||||||
<span>Nuit</span>
|
<span>Nuit</span>
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span v-if="isAdmin" class="inline-flex items-center gap-2">
|
<span v-if="isAdmin" class="flex justify-between items-center">
|
||||||
<span>Valider</span>
|
<span>Valider</span>
|
||||||
<input
|
<input
|
||||||
ref="bulkValidationInput"
|
ref="bulkValidationInput"
|
||||||
:checked="isBulkValidationChecked"
|
:checked="isBulkValidationChecked"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4 cursor-pointer"
|
class="h-4 w-4 cursor-pointer"
|
||||||
@change="onBulkValidationChange"
|
@change="onBulkValidationChange"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
||||||
<span>Site</span>
|
<span>Site</span>
|
||||||
<input
|
<input
|
||||||
ref="bulkSiteValidationInput"
|
ref="bulkSiteValidationInput"
|
||||||
:checked="isBulkSiteValidationChecked"
|
:checked="isBulkSiteValidationChecked"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
||||||
:disabled="!canBulkToggleSiteValidation"
|
:disabled="!canBulkToggleSiteValidation"
|
||||||
@change="onBulkSiteValidationChange"
|
@change="onBulkSiteValidationChange"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></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>
|
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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-neutral-100 px-4 py-2 text-sm last:border-b-0"
|
||||||
:style="{ gridTemplateColumns: dayGridCols }"
|
: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"
|
|
||||||
>
|
>
|
||||||
|
<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"/>
|
<Icon name="mdi:check"/>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</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-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].morningFrom"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="isPresenceTracking(employee)"
|
||||||
|
v-model="rows[employee.id].isPresentMorning"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pr-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].morningTo"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].afternoonFrom"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="isPresenceTracking(employee)"
|
||||||
|
v-model="rows[employee.id].isPresentAfternoon"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pr-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].afternoonTo"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].eveningFrom"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pr-2">
|
||||||
|
<TimeSelect
|
||||||
|
v-if="isTimeTracking(employee)"
|
||||||
|
v-model="rows[employee.id].eveningTo"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
||||||
|
<div v-if="isTimeTracking(employee)">{{
|
||||||
|
formatMinutes(getRowMetrics(employee.id).dayMinutes)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold text-neutral-700">
|
||||||
|
<div v-if="isTimeTracking(employee)">{{
|
||||||
|
formatMinutes(getRowMetrics(employee.id).nightMinutes)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold text-neutral-700">
|
||||||
|
<div v-if="isTimeTracking(employee)">{{
|
||||||
|
formatMinutes(getRowMetrics(employee.id).totalMinutes)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
||||||
|
</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 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-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].morningFrom"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-else-if="isPresenceTracking(employee)"
|
|
||||||
v-model="rows[employee.id].isPresentMorning"
|
|
||||||
type="checkbox"
|
|
||||||
class="cursor-pointer h-4 w-4"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pr-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].morningTo"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].afternoonFrom"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-else-if="isPresenceTracking(employee)"
|
|
||||||
v-model="rows[employee.id].isPresentAfternoon"
|
|
||||||
type="checkbox"
|
|
||||||
class="cursor-pointer h-4 w-4"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pr-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].afternoonTo"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].eveningFrom"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pr-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].eveningTo"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
|
||||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm font-semibold text-neutral-700">
|
|
||||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm font-semibold text-neutral-700">
|
|
||||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
|
||||||
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isAdmin">
|
|
||||||
<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>
|
|
||||||
<input
|
|
||||||
v-if="isSiteManager"
|
|
||||||
:checked="rows[employee.id]?.isSiteValid ?? false"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 cursor-pointer"
|
|
||||||
:disabled="!canToggleSiteValidation(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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type {Employee} from '~/services/dto/employee'
|
||||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||||
import type { HourRow } from './types'
|
import type {HourRow} from './types'
|
||||||
|
|
||||||
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
|
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
|
||||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
employees: Employee[]
|
employees: Employee[]
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
isSiteManager: boolean
|
isSiteManager: boolean
|
||||||
dayGridCols: string
|
dayGridCols: string
|
||||||
isHoliday: boolean
|
isHoliday: boolean
|
||||||
contractLabel: (employee: Employee) => string
|
contractLabel: (employee: Employee) => string
|
||||||
isTimeTracking: (employee: Employee) => boolean
|
isTimeTracking: (employee: Employee) => boolean
|
||||||
isPresenceTracking: (employee: Employee) => boolean
|
isPresenceTracking: (employee: Employee) => boolean
|
||||||
isRowLocked: (employeeId: number) => boolean
|
isRowLocked: (employeeId: number) => boolean
|
||||||
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
||||||
isEveningLockedByAbsence: (employeeId: number) => boolean
|
isEveningLockedByAbsence: (employeeId: number) => boolean
|
||||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||||
isValidationPending: (employeeId: number) => boolean
|
isValidationPending: (employeeId: number) => boolean
|
||||||
isSiteValidationPending: (employeeId: number) => boolean
|
isSiteValidationPending: (employeeId: number) => boolean
|
||||||
canToggleValidation: (employeeId: number) => boolean
|
canToggleValidation: (employeeId: number) => boolean
|
||||||
canToggleSiteValidation: (employeeId: number) => boolean
|
canToggleSiteValidation: (employeeId: number) => boolean
|
||||||
isBulkValidationChecked: boolean
|
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
|
||||||
isBulkValidationIndeterminate: boolean
|
isBulkValidationChecked: boolean
|
||||||
isBulkSiteValidationChecked: boolean
|
isBulkValidationIndeterminate: boolean
|
||||||
isBulkSiteValidationIndeterminate: boolean
|
isBulkSiteValidationChecked: boolean
|
||||||
canBulkToggleSiteValidation: boolean
|
isBulkSiteValidationIndeterminate: boolean
|
||||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
canBulkToggleSiteValidation: boolean
|
||||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||||
getRowAbsenceLabel: (employeeId: number) => string
|
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
getRowAbsenceLabel: (employeeId: number) => string
|
||||||
getPresenceDayValue: (employeeId: number) => string
|
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||||
onAbsenceClick: (employeeId: number) => void
|
getRowUpdatedAt: (employeeId: number) => string
|
||||||
formatMinutes: (minutes: number) => string
|
getPresenceDayValue: (employeeId: number) => string
|
||||||
|
onAbsenceClick: (employeeId: number) => void
|
||||||
|
formatMinutes: (minutes: number) => string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const onBulkValidationChange = (event: Event) => {
|
const onBulkValidationChange = (event: Event) => {
|
||||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onBulkSiteValidationChange = (event: Event) => {
|
const onBulkSiteValidationChange = (event: Event) => {
|
||||||
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||||
props.onToggleSiteValidation(employeeId, checked)
|
props.onToggleSiteValidation(employeeId, checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isBulkValidationIndeterminate,
|
() => props.isBulkValidationIndeterminate,
|
||||||
(isIndeterminate) => {
|
(isIndeterminate) => {
|
||||||
if (!bulkValidationInput.value) return
|
if (!bulkValidationInput.value) return
|
||||||
bulkValidationInput.value.indeterminate = isIndeterminate
|
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{immediate: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isBulkSiteValidationIndeterminate,
|
() => props.isBulkSiteValidationIndeterminate,
|
||||||
(isIndeterminate) => {
|
(isIndeterminate) => {
|
||||||
if (!bulkSiteValidationInput.value) return
|
if (!bulkSiteValidationInput.value) return
|
||||||
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{immediate: true}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="py-6 flex flex-col gap-3">
|
<div class="py-6 flex flex-col gap-3">
|
||||||
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
<div class="flex gap-4">
|
||||||
|
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
||||||
|
<div v-if="isAdmin" class="w-80 max-w-full">
|
||||||
|
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center gap-4">
|
<div class="flex justify-between items-center gap-4">
|
||||||
<div class="flex gap-4 flex-wrap">
|
<div class="flex gap-4 flex-wrap">
|
||||||
@@ -99,10 +104,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||||
class="flex flex-wrap items-center gap-6"
|
class="flex flex-wrap items-center gap-6"
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ export type HourRow = {
|
|||||||
isPresentAfternoon: boolean
|
isPresentAfternoon: boolean
|
||||||
isSiteValid: boolean
|
isSiteValid: boolean
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
|
updatedAt: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,8 +167,9 @@ const closeMenu = () => {
|
|||||||
|
|
||||||
const commitInput = () => {
|
const commitInput = () => {
|
||||||
const normalized = normalizeTypedTime(inputValue.value)
|
const normalized = normalizeTypedTime(inputValue.value)
|
||||||
if (normalized === null) {
|
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
|
||||||
inputValue.value = props.modelValue
|
emit('update:modelValue', '')
|
||||||
|
inputValue.value = ''
|
||||||
closeMenu()
|
closeMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
373
frontend/composables/useEmployeeDetailPage.ts
Normal file
373
frontend/composables/useEmployeeDetailPage.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import type { Contract } from '~/services/dto/contract'
|
||||||
|
import type { Absence } from '~/services/dto/absence'
|
||||||
|
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
||||||
|
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
|
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
||||||
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
|
import { listAbsences } from '~/services/absences'
|
||||||
|
import { listContracts } from '~/services/contracts'
|
||||||
|
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
|
||||||
|
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
||||||
|
import { getEmployee, updateEmployee } from '~/services/employees'
|
||||||
|
import { listPublicHolidays } from '~/services/public-holidays'
|
||||||
|
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||||
|
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
|
||||||
|
|
||||||
|
export const useEmployeeDetailPage = () => {
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
|
const employee = ref<Employee | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
|
||||||
|
const contracts = ref<Contract[]>([])
|
||||||
|
const employeeAbsences = ref<Absence[]>([])
|
||||||
|
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
||||||
|
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
||||||
|
const publicHolidays = ref<Record<string, string>>({})
|
||||||
|
const isContractDrawerOpen = ref(false)
|
||||||
|
const isContractSubmitting = ref(false)
|
||||||
|
const isCreateContractDrawerOpen = ref(false)
|
||||||
|
const isCreateContractSubmitting = ref(false)
|
||||||
|
|
||||||
|
const 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 employeeContractWorkLabel = computed(() => {
|
||||||
|
const contract = employee.value?.contract
|
||||||
|
if (!contract) return '-'
|
||||||
|
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
|
||||||
|
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
|
||||||
|
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 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 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()
|
||||||
|
isContractDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setContractDrawerOpen = (open: boolean) => {
|
||||||
|
isContractDrawerOpen.value = open
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetCreateValidation = () => {
|
||||||
|
createValidationTouched.contractId = false
|
||||||
|
createValidationTouched.contractNature = false
|
||||||
|
createValidationTouched.startDate = false
|
||||||
|
createValidationTouched.endDate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateContractDrawer = () => {
|
||||||
|
if (!employee.value || !canCreateContract.value) return
|
||||||
|
createContractForm.contractId = ''
|
||||||
|
createContractForm.contractNature = 'CDI'
|
||||||
|
createContractForm.endDate = ''
|
||||||
|
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
||||||
|
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
||||||
|
: getTodayYmd()
|
||||||
|
resetCreateValidation()
|
||||||
|
isCreateContractDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCreateContractDrawerOpen = (open: boolean) => {
|
||||||
|
isCreateContractDrawerOpen.value = open
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEmployee = async () => {
|
||||||
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
|
const employeeId = Number(idParam)
|
||||||
|
if (!Number.isInteger(employeeId) || employeeId <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const loadedEmployee = 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') {
|
||||||
|
activeTab.value = 'contract'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 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 () => {
|
||||||
|
contracts.value = await listContracts()
|
||||||
|
await loadEmployee()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee,
|
||||||
|
isLoading,
|
||||||
|
activeTab,
|
||||||
|
contracts,
|
||||||
|
employeeAbsences,
|
||||||
|
leaveSummary,
|
||||||
|
rttSummary,
|
||||||
|
publicHolidays,
|
||||||
|
showLeaveTab,
|
||||||
|
contractHistory,
|
||||||
|
employeeContractWorkLabel,
|
||||||
|
contractForm,
|
||||||
|
createContractForm,
|
||||||
|
isContractDrawerOpen,
|
||||||
|
isContractSubmitting,
|
||||||
|
isCreateContractDrawerOpen,
|
||||||
|
isCreateContractSubmitting,
|
||||||
|
canCloseCurrentContract,
|
||||||
|
canCreateContract,
|
||||||
|
readonlyFieldClass,
|
||||||
|
closeContractWorkedHoursLabel,
|
||||||
|
contractEndDateFieldClass,
|
||||||
|
showContractEndDateError,
|
||||||
|
isContractEndDateValid,
|
||||||
|
createContractNatureFieldClass,
|
||||||
|
createContractFieldClass,
|
||||||
|
createContractStartDateFieldClass,
|
||||||
|
showsCreateContractEndDate,
|
||||||
|
requiresCreateContractEndDate,
|
||||||
|
createContractEndDateFieldClass,
|
||||||
|
isCreateContractFormValid,
|
||||||
|
contractNatureLabel,
|
||||||
|
contractHistoryLabel,
|
||||||
|
formatDate,
|
||||||
|
openCloseContractDrawer,
|
||||||
|
openCreateContractDrawer,
|
||||||
|
setContractDrawerOpen,
|
||||||
|
setCreateContractDrawerOpen,
|
||||||
|
submitContractUpdate,
|
||||||
|
submitCreateContract,
|
||||||
|
submitFractionedDays,
|
||||||
|
submitRttPayment
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -341,7 +341,8 @@ export const useHoursPage = () => {
|
|||||||
isPresentMorning: false,
|
isPresentMorning: false,
|
||||||
isPresentAfternoon: false,
|
isPresentAfternoon: false,
|
||||||
isSiteValid: false,
|
isSiteValid: false,
|
||||||
isValid: false
|
isValid: false,
|
||||||
|
updatedAt: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
||||||
@@ -463,6 +464,14 @@ export const useHoursPage = () => {
|
|||||||
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
|
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 getPresenceDayValue = (employeeId: number) => {
|
const getPresenceDayValue = (employeeId: number) => {
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
@@ -521,7 +530,8 @@ export const useHoursPage = () => {
|
|||||||
isPresentMorning: workHour?.isPresentMorning ?? false,
|
isPresentMorning: workHour?.isPresentMorning ?? false,
|
||||||
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
|
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
|
||||||
isSiteValid: workHour?.isSiteValid ?? false,
|
isSiteValid: workHour?.isSiteValid ?? false,
|
||||||
isValid: workHour?.isValid ?? false
|
isValid: workHour?.isValid ?? false,
|
||||||
|
updatedAt: workHour?.updatedAt ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1138,7 @@ export const useHoursPage = () => {
|
|||||||
isSiteValidationPending,
|
isSiteValidationPending,
|
||||||
canToggleValidation,
|
canToggleValidation,
|
||||||
canToggleSiteValidation,
|
canToggleSiteValidation,
|
||||||
|
canCreateSiteValidationRowFromAbsence,
|
||||||
isBulkValidationChecked,
|
isBulkValidationChecked,
|
||||||
isBulkValidationIndeterminate,
|
isBulkValidationIndeterminate,
|
||||||
isBulkSiteValidationChecked,
|
isBulkSiteValidationChecked,
|
||||||
@@ -1140,6 +1151,7 @@ export const useHoursPage = () => {
|
|||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
submitAbsence,
|
submitAbsence,
|
||||||
|
|||||||
@@ -2,80 +2,87 @@
|
|||||||
<div class="h-screen overflow-hidden">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
||||||
<div>
|
<div class="h-[75px]">
|
||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 px-4 pb-6">
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
|
||||||
to="/"
|
|
||||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
|
||||||
>
|
|
||||||
Tableau de bord
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/calendar"
|
to="/calendar"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 pb-2 pt-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/calendar')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Calendrier
|
<Icon name="mdi:calendar-blank" size="24"/>
|
||||||
|
<p>Calendrier</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/hours"
|
to="/hours"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 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"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/hours')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Heures
|
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
||||||
|
<p>Heures</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
to="/employees"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 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"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/employees')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Employés
|
<Icon name="mdi:account-group-outline" size="24"/>
|
||||||
|
<p>Employés</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/sites')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Sites
|
<Icon name="mdi:business" size="24"/>
|
||||||
|
<p>Sites</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/absence-types"
|
to="/absence-types"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/absence-types')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Types d'absence
|
<Icon name="mdi:umbrella-beach-outline" size="24"/>
|
||||||
|
<p>Types d'absence</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/users"
|
to="/users"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-3 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
:class="route.path.startsWith('/users')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
>
|
>
|
||||||
Utilisateurs
|
<Icon name="mdi:account-outline" size="24"/>
|
||||||
|
<p>Utilisateurs</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 items-center p-4">
|
<div class="flex flex-col gap-2 items-center p-4">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full rounded-lg px-4 py-2 text-md font-semibold text-white bg-primary-500"
|
|
||||||
@click="handleLogout"
|
|
||||||
>
|
|
||||||
Déconnexion
|
|
||||||
</button>
|
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<p class="font-bold">v{{ version }}</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
|
<div class="h-full flex-1 overflow-hidden flex flex-col">
|
||||||
<slot/>
|
<AppTopNav :user="auth.user" />
|
||||||
</main>
|
<main class="flex-1 overflow-y-auto px-8 py-12">
|
||||||
|
<slot/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -84,9 +91,5 @@
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const {version} = useAppVersion()
|
const {version} = useAppVersion()
|
||||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
const route = useRoute()
|
||||||
const handleLogout = async () => {
|
|
||||||
await auth.logout()
|
|
||||||
await navigateTo('/login')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default defineNuxtConfig({
|
|||||||
devServer: {port: 3001},
|
devServer: {port: 3001},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
timeout: 10000,
|
timeout: 2000,
|
||||||
closeOnClick: true,
|
closeOnClick: true,
|
||||||
progressBar: false
|
progressBar: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,526 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full overflow-hidden flex flex-col">
|
|
||||||
<div class="shrink-0">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 py-6">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="openCreate"
|
|
||||||
>
|
|
||||||
Ajouter un employé
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="w-80">
|
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!isLoading && filteredEmployees.length === 0"
|
|
||||||
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
|
||||||
>
|
|
||||||
Aucun employé pour le moment.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
||||||
<div class="h-full overflow-auto">
|
|
||||||
<div class="min-w-[900px]">
|
|
||||||
<div class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
|
|
||||||
<span class="text-left">Prénom</span>
|
|
||||||
<span class="text-left">Nom</span>
|
|
||||||
<span class="text-left">Site</span>
|
|
||||||
<span class="text-left">Nature</span>
|
|
||||||
<span class="text-left">Contrat</span>
|
|
||||||
<span class="text-right">Actions</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div
|
|
||||||
v-for="employee in filteredEmployees"
|
|
||||||
:key="employee.id"
|
|
||||||
class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span>{{ employee.firstName }}</span>
|
|
||||||
<span>{{ employee.lastName }}</span>
|
|
||||||
<span
|
|
||||||
class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
|
|
||||||
:style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
|
|
||||||
:class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
|
|
||||||
>
|
|
||||||
{{ employee.site?.name ?? '-' }}
|
|
||||||
</span>
|
|
||||||
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
|
|
||||||
<span>{{ employee.contract?.name ?? '-' }}</span>
|
|
||||||
<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(employee)"
|
|
||||||
>
|
|
||||||
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(employee)"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="first-name">
|
|
||||||
Prénom <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="first-name"
|
|
||||||
v-model="form.firstName"
|
|
||||||
type="text"
|
|
||||||
:class="firstNameFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le prénom est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="last-name">
|
|
||||||
Nom <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="last-name"
|
|
||||||
v-model="form.lastName"
|
|
||||||
type="text"
|
|
||||||
:class="lastNameFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le nom est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="site">
|
|
||||||
Site <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="site"
|
|
||||||
v-model="form.siteId"
|
|
||||||
:class="siteFieldClass"
|
|
||||||
>
|
|
||||||
<option value="">Aucun site</option>
|
|
||||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
|
||||||
{{ site.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le site est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<template v-if="!editingEmployee">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
|
||||||
Type de contrat <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="contract-nature"
|
|
||||||
v-model="form.contractNature"
|
|
||||||
:class="contractNatureFieldClass"
|
|
||||||
>
|
|
||||||
<option value="CDI">CDI</option>
|
|
||||||
<option value="CDD">CDD</option>
|
|
||||||
<option value="INTERIM">Intérim</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le type de contrat est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract">
|
|
||||||
Temps de travail <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="contract"
|
|
||||||
v-model="form.contractId"
|
|
||||||
:class="contractFieldClass"
|
|
||||||
>
|
|
||||||
<option value="">Sélectionner un contrat</option>
|
|
||||||
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
|
||||||
{{ contract.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le temps de travail est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
|
||||||
Début contrat <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contract-start-date"
|
|
||||||
v-model="form.contractStartDate"
|
|
||||||
type="date"
|
|
||||||
:class="contractStartDateFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
|
|
||||||
La date de début est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="requiresContractEndDate">
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
|
||||||
Fin contrat
|
|
||||||
<span v-if="requiresContractEndDate" class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contract-end-date"
|
|
||||||
v-model="form.contractEndDate"
|
|
||||||
type="date"
|
|
||||||
:class="contractEndDateFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
|
|
||||||
La date de fin est obligatoire pour un CDD ou un intérim.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="isDrawerOpen = false"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:class="submitButtonClass"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Contract } from '~/services/dto/contract'
|
|
||||||
import type { Employee } from '~/services/dto/employee'
|
|
||||||
import type { Site } from '~/services/dto/site'
|
|
||||||
import { listContracts } from '~/services/contracts'
|
|
||||||
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
|
||||||
import { listSites } from '~/services/sites'
|
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
|
||||||
useHead({
|
|
||||||
title: 'Employés'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const sitesInitialized = ref(false)
|
|
||||||
const editingEmployee = ref<Employee | null>(null)
|
|
||||||
const drawerTitle = computed(() =>
|
|
||||||
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
|
||||||
)
|
|
||||||
|
|
||||||
const employees = ref<Employee[]>([])
|
|
||||||
const sites = ref<Site[]>([])
|
|
||||||
const contracts = ref<Contract[]>([])
|
|
||||||
const employeeFilter = ref('')
|
|
||||||
const selectedSiteIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const filteredEmployees = computed(() => {
|
|
||||||
if (selectedSiteIds.value.length === 0) return []
|
|
||||||
|
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
|
||||||
const bySite = employees.value.filter((employee) => {
|
|
||||||
const siteId = employee.site?.id
|
|
||||||
return !!siteId && selectedSiteIds.value.includes(siteId)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!filter) return bySite
|
|
||||||
|
|
||||||
return bySite.filter((employee) => {
|
|
||||||
const firstName = employee.firstName?.toLowerCase() ?? ''
|
|
||||||
const lastName = employee.lastName?.toLowerCase() ?? ''
|
|
||||||
return firstName.includes(filter) || lastName.includes(filter)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
|
|
||||||
if (value === 'CDD') return 'CDD'
|
|
||||||
if (value === 'INTERIM') return 'Intérim'
|
|
||||||
return 'CDI'
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
siteId: '' as number | '',
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
contractStartDate: '',
|
|
||||||
contractEndDate: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
firstName: false,
|
|
||||||
lastName: false,
|
|
||||||
siteId: false,
|
|
||||||
contractId: false,
|
|
||||||
contractNature: false,
|
|
||||||
contractStartDate: false,
|
|
||||||
contractEndDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
|
||||||
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
|
||||||
const isSiteValid = computed(() => form.siteId !== '')
|
|
||||||
const isContractValid = computed(() => form.contractId !== '')
|
|
||||||
const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature))
|
|
||||||
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
|
|
||||||
const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM')
|
|
||||||
const isContractEndDateValid = computed(() => {
|
|
||||||
if (!requiresContractEndDate.value) return true
|
|
||||||
return form.contractEndDate !== ''
|
|
||||||
})
|
|
||||||
const isFormValid = computed(
|
|
||||||
() =>
|
|
||||||
isFirstNameValid.value &&
|
|
||||||
isLastNameValid.value &&
|
|
||||||
isSiteValid.value &&
|
|
||||||
(editingEmployee.value
|
|
||||||
? true
|
|
||||||
: (isContractValid.value &&
|
|
||||||
isContractNatureValid.value &&
|
|
||||||
isContractStartDateValid.value &&
|
|
||||||
isContractEndDateValid.value))
|
|
||||||
)
|
|
||||||
|
|
||||||
const showFirstNameError = computed(
|
|
||||||
() => validationTouched.firstName && !isFirstNameValid.value
|
|
||||||
)
|
|
||||||
const showLastNameError = computed(
|
|
||||||
() => validationTouched.lastName && !isLastNameValid.value
|
|
||||||
)
|
|
||||||
const showSiteError = computed(
|
|
||||||
() => validationTouched.siteId && !isSiteValid.value
|
|
||||||
)
|
|
||||||
const showContractError = computed(
|
|
||||||
() => validationTouched.contractId && !isContractValid.value
|
|
||||||
)
|
|
||||||
const showContractNatureError = computed(
|
|
||||||
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
|
|
||||||
)
|
|
||||||
const showContractStartDateError = computed(
|
|
||||||
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
|
|
||||||
)
|
|
||||||
const showContractEndDateError = computed(
|
|
||||||
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.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 firstNameFieldClass = computed(() => {
|
|
||||||
if (showFirstNameError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const lastNameFieldClass = computed(() => {
|
|
||||||
if (showLastNameError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const siteFieldClass = computed(() => {
|
|
||||||
const baseSelectClass =
|
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
if (showSiteError.value) {
|
|
||||||
return `${baseSelectClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseSelectClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractFieldClass = computed(() => {
|
|
||||||
const baseClass =
|
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
if (showContractError.value) {
|
|
||||||
return `${baseClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractNatureFieldClass = computed(() => {
|
|
||||||
const baseClass =
|
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
if (showContractNatureError.value) {
|
|
||||||
return `${baseClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractStartDateFieldClass = computed(() => {
|
|
||||||
if (showContractStartDateError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractEndDateFieldClass = computed(() => {
|
|
||||||
if (showContractEndDateError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
|
||||||
if (isSubmitting.value || !isFormValid.value) {
|
|
||||||
return 'opacity-50 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadEmployees = async () => {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
employees.value = await listEmployees()
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSites = async () => {
|
|
||||||
sites.value = await listSites()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadContracts = async () => {
|
|
||||||
contracts.value = await listContracts()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
|
||||||
if (form.contractStartDate === '') {
|
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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 })
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (isSubmitting.value) return
|
|
||||||
validationTouched.firstName = true
|
|
||||||
validationTouched.lastName = true
|
|
||||||
validationTouched.siteId = true
|
|
||||||
if (!editingEmployee.value) {
|
|
||||||
validationTouched.contractId = true
|
|
||||||
validationTouched.contractNature = true
|
|
||||||
validationTouched.contractStartDate = true
|
|
||||||
validationTouched.contractEndDate = true
|
|
||||||
}
|
|
||||||
if (!isFormValid.value) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
if (editingEmployee.value) {
|
|
||||||
await updateEmployee(editingEmployee.value.id, {
|
|
||||||
firstName: form.firstName,
|
|
||||||
lastName: form.lastName,
|
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId),
|
|
||||||
contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await createEmployee({
|
|
||||||
firstName: form.firstName,
|
|
||||||
lastName: form.lastName,
|
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId),
|
|
||||||
contractId: Number(form.contractId),
|
|
||||||
contractNature: form.contractNature,
|
|
||||||
contractStartDate: form.contractStartDate,
|
|
||||||
contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
form.firstName = ''
|
|
||||||
form.lastName = ''
|
|
||||||
form.siteId = ''
|
|
||||||
form.contractId = ''
|
|
||||||
form.contractNature = 'CDI'
|
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
|
||||||
form.contractEndDate = ''
|
|
||||||
editingEmployee.value = null
|
|
||||||
isDrawerOpen.value = false
|
|
||||||
await loadEmployees()
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isDrawerOpen, (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
validationTouched.firstName = false
|
|
||||||
validationTouched.lastName = false
|
|
||||||
validationTouched.siteId = false
|
|
||||||
validationTouched.contractId = false
|
|
||||||
validationTouched.contractNature = false
|
|
||||||
validationTouched.contractStartDate = false
|
|
||||||
validationTouched.contractEndDate = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(requiresContractEndDate, (required) => {
|
|
||||||
if (!required) {
|
|
||||||
form.contractEndDate = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const openEdit = (employee: Employee) => {
|
|
||||||
editingEmployee.value = employee
|
|
||||||
form.firstName = employee.firstName
|
|
||||||
form.lastName = employee.lastName
|
|
||||||
form.siteId = employee.site?.id ?? ''
|
|
||||||
isDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
editingEmployee.value = null
|
|
||||||
form.firstName = ''
|
|
||||||
form.lastName = ''
|
|
||||||
form.siteId = ''
|
|
||||||
form.contractId = ''
|
|
||||||
form.contractNature = 'CDI'
|
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
|
||||||
form.contractEndDate = ''
|
|
||||||
isDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = async (employee: Employee) => {
|
|
||||||
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
await deleteEmployee(employee.id)
|
|
||||||
await loadEmployees()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
160
frontend/pages/employees/[id].vue
Normal file
160
frontend/pages/employees/[id].vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-hidden flex flex-col">
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!employee"
|
||||||
|
class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Employé introuvable.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||||
|
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
||||||
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-12 border-b border-primary-500">
|
||||||
|
<div class="flex justify-center gap-16 text-2xl font-bold">
|
||||||
|
<button
|
||||||
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
|
:class="activeTab === 'contract'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="activeTab = 'contract'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:file-check-outline" size="24" class="align-self"/>
|
||||||
|
Suivi contrat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showLeaveTab"
|
||||||
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
|
:class="activeTab === 'leave'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="activeTab = 'leave'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:event-blank-outline" size="24" class="align-self"/>
|
||||||
|
Congé
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
|
:class="activeTab === 'rtt'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="activeTab = 'rtt'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:schedule" size="24" class="align-self"/>
|
||||||
|
RTT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<EmployeesContractTab
|
||||||
|
v-if="activeTab === 'contract'"
|
||||||
|
class="h-full overflow-y-auto pr-1"
|
||||||
|
:contract-history="contractHistory"
|
||||||
|
:contract-nature-label="contractNatureLabel"
|
||||||
|
:contract-history-label="contractHistoryLabel"
|
||||||
|
:format-date="formatDate"
|
||||||
|
:is-contract-submitting="isContractSubmitting"
|
||||||
|
:can-close-current-contract="canCloseCurrentContract"
|
||||||
|
:is-create-contract-submitting="isCreateContractSubmitting"
|
||||||
|
:contracts="contracts"
|
||||||
|
:can-create-contract="canCreateContract"
|
||||||
|
:is-contract-drawer-open="isContractDrawerOpen"
|
||||||
|
:contract-form="contractForm"
|
||||||
|
:readonly-field-class="readonlyFieldClass"
|
||||||
|
:close-contract-worked-hours-label="closeContractWorkedHoursLabel"
|
||||||
|
:contract-end-date-field-class="contractEndDateFieldClass"
|
||||||
|
:show-contract-end-date-error="showContractEndDateError"
|
||||||
|
:is-contract-end-date-valid="isContractEndDateValid"
|
||||||
|
:is-create-contract-drawer-open="isCreateContractDrawerOpen"
|
||||||
|
:create-contract-form="createContractForm"
|
||||||
|
:create-contract-nature-field-class="createContractNatureFieldClass"
|
||||||
|
:create-contract-field-class="createContractFieldClass"
|
||||||
|
:create-contract-start-date-field-class="createContractStartDateFieldClass"
|
||||||
|
:shows-create-contract-end-date="showsCreateContractEndDate"
|
||||||
|
:requires-create-contract-end-date="requiresCreateContractEndDate"
|
||||||
|
:create-contract-end-date-field-class="createContractEndDateFieldClass"
|
||||||
|
:is-create-contract-form-valid="isCreateContractFormValid"
|
||||||
|
:on-open-close-contract-drawer="openCloseContractDrawer"
|
||||||
|
:on-open-create-contract-drawer="openCreateContractDrawer"
|
||||||
|
:on-update-contract-drawer-open="setContractDrawerOpen"
|
||||||
|
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
|
||||||
|
:on-submit-close-contract="submitContractUpdate"
|
||||||
|
:on-submit-create-contract="submitCreateContract"
|
||||||
|
/>
|
||||||
|
<EmployeesLeaveTab
|
||||||
|
v-else-if="showLeaveTab && activeTab === 'leave'"
|
||||||
|
class="h-full"
|
||||||
|
:absences="employeeAbsences"
|
||||||
|
:summary="leaveSummary"
|
||||||
|
:public-holidays="publicHolidays"
|
||||||
|
@update-fractioned-days="submitFractionedDays"
|
||||||
|
/>
|
||||||
|
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
employee,
|
||||||
|
isLoading,
|
||||||
|
activeTab,
|
||||||
|
contracts,
|
||||||
|
employeeAbsences,
|
||||||
|
leaveSummary,
|
||||||
|
rttSummary,
|
||||||
|
publicHolidays,
|
||||||
|
showLeaveTab,
|
||||||
|
contractHistory,
|
||||||
|
employeeContractWorkLabel,
|
||||||
|
contractForm,
|
||||||
|
createContractForm,
|
||||||
|
isContractDrawerOpen,
|
||||||
|
isContractSubmitting,
|
||||||
|
isCreateContractDrawerOpen,
|
||||||
|
isCreateContractSubmitting,
|
||||||
|
canCloseCurrentContract,
|
||||||
|
canCreateContract,
|
||||||
|
readonlyFieldClass,
|
||||||
|
closeContractWorkedHoursLabel,
|
||||||
|
contractEndDateFieldClass,
|
||||||
|
showContractEndDateError,
|
||||||
|
isContractEndDateValid,
|
||||||
|
createContractNatureFieldClass,
|
||||||
|
createContractFieldClass,
|
||||||
|
createContractStartDateFieldClass,
|
||||||
|
showsCreateContractEndDate,
|
||||||
|
requiresCreateContractEndDate,
|
||||||
|
createContractEndDateFieldClass,
|
||||||
|
isCreateContractFormValid,
|
||||||
|
contractNatureLabel,
|
||||||
|
contractHistoryLabel,
|
||||||
|
formatDate,
|
||||||
|
openCloseContractDrawer,
|
||||||
|
openCreateContractDrawer,
|
||||||
|
setContractDrawerOpen,
|
||||||
|
setCreateContractDrawerOpen,
|
||||||
|
submitContractUpdate,
|
||||||
|
submitCreateContract,
|
||||||
|
submitFractionedDays,
|
||||||
|
submitRttPayment
|
||||||
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
|
useHead(() => ({
|
||||||
|
title: employee.value
|
||||||
|
? `${employee.value.firstName} ${employee.value.lastName}`
|
||||||
|
: 'Détail employé'
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
498
frontend/pages/employees/index.vue
Normal file
498
frontend/pages/employees/index.vue
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-col">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ Ajouter un employé
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-10 py-7">
|
||||||
|
<div class="w-80">
|
||||||
|
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||||
|
</div>
|
||||||
|
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isLoading && filteredEmployees.length === 0"
|
||||||
|
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||||
|
>
|
||||||
|
Aucun employé pour le moment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(260px,1fr))]">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="employee in filteredEmployees"
|
||||||
|
:key="employee.id"
|
||||||
|
:to="`/employees/${employee.id}`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="group relative min-h-[328px] overflow-hidden rounded-lg bg-tertiary-500 p-4 transition-all duration-200 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-7 transition-opacity duration-200 group-hover:opacity-0">
|
||||||
|
<div class="rounded-full bg-primary-500 h-[175px] w-[175px] flex justify-center items-center text-white font-bold text-5xl">{{ employee.initials}}</div>
|
||||||
|
<div class="text-center text-[20px]">
|
||||||
|
<p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p>
|
||||||
|
<p>Nom du poste occupé</p>
|
||||||
|
<p>Site ({{ employee.site?.name ?? '-' }})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
|
||||||
|
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
|
||||||
|
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
||||||
|
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
||||||
|
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
||||||
|
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="first-name">
|
||||||
|
Prénom <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="first-name"
|
||||||
|
v-model="form.firstName"
|
||||||
|
type="text"
|
||||||
|
:class="firstNameFieldClass"
|
||||||
|
/>
|
||||||
|
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le prénom est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="last-name">
|
||||||
|
Nom <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="last-name"
|
||||||
|
v-model="form.lastName"
|
||||||
|
type="text"
|
||||||
|
:class="lastNameFieldClass"
|
||||||
|
/>
|
||||||
|
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le nom est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="site">
|
||||||
|
Site <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="site"
|
||||||
|
v-model="form.siteId"
|
||||||
|
:class="siteFieldClass"
|
||||||
|
>
|
||||||
|
<option value="">Aucun site</option>
|
||||||
|
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||||
|
{{ site.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le site est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<template v-if="!editingEmployee">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
||||||
|
Type de contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="contract-nature"
|
||||||
|
v-model="form.contractNature"
|
||||||
|
:class="contractNatureFieldClass"
|
||||||
|
>
|
||||||
|
<option value="CDI">CDI</option>
|
||||||
|
<option value="CDD">CDD</option>
|
||||||
|
<option value="INTERIM">Intérim</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le type de contrat est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract">
|
||||||
|
Temps de travail <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="contract"
|
||||||
|
v-model="form.contractId"
|
||||||
|
:class="contractFieldClass"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un contrat</option>
|
||||||
|
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
||||||
|
{{ contract.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le temps de travail est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
||||||
|
Début contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contract-start-date"
|
||||||
|
v-model="form.contractStartDate"
|
||||||
|
type="date"
|
||||||
|
:class="contractStartDateFieldClass"
|
||||||
|
/>
|
||||||
|
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
|
||||||
|
La date de début est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="showsContractEndDateComputed">
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
||||||
|
Fin contrat
|
||||||
|
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contract-end-date"
|
||||||
|
v-model="form.contractEndDate"
|
||||||
|
type="date"
|
||||||
|
:class="contractEndDateFieldClass"
|
||||||
|
/>
|
||||||
|
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
|
||||||
|
La date de fin est obligatoire pour un CDD.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||||
|
@click="isDrawerOpen = false"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:class="submitButtonClass"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {Contract} from '~/services/dto/contract'
|
||||||
|
import type {Employee} from '~/services/dto/employee'
|
||||||
|
import type {Site} from '~/services/dto/site'
|
||||||
|
import {listContracts} from '~/services/contracts'
|
||||||
|
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
|
||||||
|
import {listSites} from '~/services/sites'
|
||||||
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Employés'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDrawerOpen = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const sitesInitialized = ref(false)
|
||||||
|
const editingEmployee = ref<Employee | null>(null)
|
||||||
|
const drawerTitle = computed(() =>
|
||||||
|
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
||||||
|
)
|
||||||
|
|
||||||
|
const employees = ref<Employee[]>([])
|
||||||
|
const sites = ref<Site[]>([])
|
||||||
|
const contracts = ref<Contract[]>([])
|
||||||
|
const employeeFilter = ref('')
|
||||||
|
const selectedSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const filteredEmployees = computed<Employee[]>(() => {
|
||||||
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
|
|
||||||
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
|
const bySite = employees.value.filter((employee) => {
|
||||||
|
const siteId = employee.site?.id
|
||||||
|
return !!siteId && selectedSiteIds.value.includes(siteId)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!filter) return bySite
|
||||||
|
|
||||||
|
return bySite.filter((employee) => {
|
||||||
|
const firstName = employee.firstName?.toLowerCase() ?? ''
|
||||||
|
const lastName = employee.lastName?.toLowerCase() ?? ''
|
||||||
|
return firstName.includes(filter) || lastName.includes(filter)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
siteId: '' as number | '',
|
||||||
|
contractId: '' as number | '',
|
||||||
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
|
contractStartDate: '',
|
||||||
|
contractEndDate: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const validationTouched = reactive({
|
||||||
|
firstName: false,
|
||||||
|
lastName: false,
|
||||||
|
siteId: false,
|
||||||
|
contractId: false,
|
||||||
|
contractNature: false,
|
||||||
|
contractStartDate: false,
|
||||||
|
contractEndDate: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
||||||
|
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
||||||
|
const isSiteValid = computed(() => form.siteId !== '')
|
||||||
|
const isContractValid = computed(() => form.contractId !== '')
|
||||||
|
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
|
||||||
|
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
|
||||||
|
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
|
||||||
|
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
|
||||||
|
const isContractEndDateValid = computed(() => {
|
||||||
|
if (!requiresContractEndDateComputed.value) return true
|
||||||
|
return form.contractEndDate !== ''
|
||||||
|
})
|
||||||
|
const isFormValid = computed(
|
||||||
|
() =>
|
||||||
|
isFirstNameValid.value &&
|
||||||
|
isLastNameValid.value &&
|
||||||
|
isSiteValid.value &&
|
||||||
|
(editingEmployee.value
|
||||||
|
? true
|
||||||
|
: (isContractValid.value &&
|
||||||
|
isContractNatureValid.value &&
|
||||||
|
isContractStartDateValid.value &&
|
||||||
|
isContractEndDateValid.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const showFirstNameError = computed(
|
||||||
|
() => validationTouched.firstName && !isFirstNameValid.value
|
||||||
|
)
|
||||||
|
const showLastNameError = computed(
|
||||||
|
() => validationTouched.lastName && !isLastNameValid.value
|
||||||
|
)
|
||||||
|
const showSiteError = computed(
|
||||||
|
() => validationTouched.siteId && !isSiteValid.value
|
||||||
|
)
|
||||||
|
const showContractError = computed(
|
||||||
|
() => validationTouched.contractId && !isContractValid.value
|
||||||
|
)
|
||||||
|
const showContractNatureError = computed(
|
||||||
|
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
|
||||||
|
)
|
||||||
|
const showContractStartDateError = computed(
|
||||||
|
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
|
||||||
|
)
|
||||||
|
const showContractEndDateError = computed(
|
||||||
|
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.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 firstNameFieldClass = computed(() => {
|
||||||
|
if (showFirstNameError.value) {
|
||||||
|
return `${baseInputClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseInputClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
const lastNameFieldClass = computed(() => {
|
||||||
|
if (showLastNameError.value) {
|
||||||
|
return `${baseInputClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseInputClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
const siteFieldClass = computed(() => {
|
||||||
|
const baseSelectClass =
|
||||||
|
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||||
|
if (showSiteError.value) {
|
||||||
|
return `${baseSelectClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseSelectClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
const contractFieldClass = computed(() => {
|
||||||
|
const baseClass =
|
||||||
|
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||||
|
if (showContractError.value) {
|
||||||
|
return `${baseClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
const contractNatureFieldClass = computed(() => {
|
||||||
|
const baseClass =
|
||||||
|
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||||
|
if (showContractNatureError.value) {
|
||||||
|
return `${baseClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
const contractStartDateFieldClass = computed(() => {
|
||||||
|
if (showContractStartDateError.value) {
|
||||||
|
return `${baseInputClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseInputClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
const contractEndDateFieldClass = computed(() => {
|
||||||
|
if (showContractEndDateError.value) {
|
||||||
|
return `${baseInputClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseInputClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitButtonClass = computed(() => {
|
||||||
|
if (isSubmitting.value || !isFormValid.value) {
|
||||||
|
return 'opacity-50 cursor-not-allowed'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadEmployees = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
employees.value = await listEmployees()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
sites.value = await listSites()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadContracts = async () => {
|
||||||
|
contracts.value = await listContracts()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
||||||
|
if (form.contractStartDate === '') {
|
||||||
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
validationTouched.firstName = true
|
||||||
|
validationTouched.lastName = true
|
||||||
|
validationTouched.siteId = true
|
||||||
|
if (!editingEmployee.value) {
|
||||||
|
validationTouched.contractId = true
|
||||||
|
validationTouched.contractNature = true
|
||||||
|
validationTouched.contractStartDate = true
|
||||||
|
validationTouched.contractEndDate = true
|
||||||
|
}
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingEmployee.value) {
|
||||||
|
await updateEmployee(editingEmployee.value.id, {
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||||
|
contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createEmployee({
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||||
|
contractId: Number(form.contractId),
|
||||||
|
contractNature: form.contractNature,
|
||||||
|
contractStartDate: form.contractStartDate,
|
||||||
|
contractEndDate: form.contractEndDate || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
form.firstName = ''
|
||||||
|
form.lastName = ''
|
||||||
|
form.siteId = ''
|
||||||
|
form.contractId = ''
|
||||||
|
form.contractNature = 'CDI'
|
||||||
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
|
form.contractEndDate = ''
|
||||||
|
editingEmployee.value = null
|
||||||
|
isDrawerOpen.value = false
|
||||||
|
await loadEmployees()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isDrawerOpen, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
validationTouched.firstName = false
|
||||||
|
validationTouched.lastName = false
|
||||||
|
validationTouched.siteId = false
|
||||||
|
validationTouched.contractId = false
|
||||||
|
validationTouched.contractNature = false
|
||||||
|
validationTouched.contractStartDate = false
|
||||||
|
validationTouched.contractEndDate = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showsContractEndDateComputed, (shows) => {
|
||||||
|
if (!shows) {
|
||||||
|
form.contractEndDate = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const openEdit = (employee: Employee) => {
|
||||||
|
editingEmployee.value = employee
|
||||||
|
form.firstName = employee.firstName
|
||||||
|
form.lastName = employee.lastName
|
||||||
|
form.siteId = employee.site?.id ?? ''
|
||||||
|
isDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
editingEmployee.value = null
|
||||||
|
form.firstName = ''
|
||||||
|
form.lastName = ''
|
||||||
|
form.siteId = ''
|
||||||
|
form.contractId = ''
|
||||||
|
form.contractNature = 'CDI'
|
||||||
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
|
form.contractEndDate = ''
|
||||||
|
isDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (employee: Employee) => {
|
||||||
|
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
await deleteEmployee(employee.id)
|
||||||
|
await loadEmployees()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
:is-site-validation-pending="isSiteValidationPending"
|
:is-site-validation-pending="isSiteValidationPending"
|
||||||
:can-toggle-validation="canToggleValidation"
|
:can-toggle-validation="canToggleValidation"
|
||||||
:can-toggle-site-validation="canToggleSiteValidation"
|
:can-toggle-site-validation="canToggleSiteValidation"
|
||||||
|
:can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
|
||||||
:is-bulk-validation-checked="isBulkValidationChecked"
|
:is-bulk-validation-checked="isBulkValidationChecked"
|
||||||
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
||||||
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
|
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
:get-row-metrics="getRowMetrics"
|
:get-row-metrics="getRowMetrics"
|
||||||
:get-row-absence-label="getRowAbsenceLabel"
|
:get-row-absence-label="getRowAbsenceLabel"
|
||||||
:get-row-absence-style="getRowAbsenceStyle"
|
:get-row-absence-style="getRowAbsenceStyle"
|
||||||
|
:get-row-updated-at="getRowUpdatedAt"
|
||||||
:get-presence-day-value="getPresenceDayValue"
|
:get-presence-day-value="getPresenceDayValue"
|
||||||
:on-absence-click="openAbsenceDrawer"
|
:on-absence-click="openAbsenceDrawer"
|
||||||
:format-minutes="formatMinutes"
|
:format-minutes="formatMinutes"
|
||||||
@@ -161,6 +163,7 @@ const {
|
|||||||
isSiteValidationPending,
|
isSiteValidationPending,
|
||||||
canToggleValidation,
|
canToggleValidation,
|
||||||
canToggleSiteValidation,
|
canToggleSiteValidation,
|
||||||
|
canCreateSiteValidationRowFromAbsence,
|
||||||
isBulkValidationChecked,
|
isBulkValidationChecked,
|
||||||
isBulkValidationIndeterminate,
|
isBulkValidationIndeterminate,
|
||||||
isBulkSiteValidationChecked,
|
isBulkSiteValidationChecked,
|
||||||
@@ -173,6 +176,7 @@ const {
|
|||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
submitAbsence,
|
submitAbsence,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value, password.value)
|
||||||
|
|
||||||
await router.push('/')
|
await router.push('/calendar')
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.4 KiB |
@@ -6,6 +6,7 @@ type ListAbsencesFilters = {
|
|||||||
from?: string
|
from?: string
|
||||||
to?: string
|
to?: string
|
||||||
siteIds?: number[]
|
siteIds?: number[]
|
||||||
|
employeeId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
|
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
|
||||||
@@ -20,6 +21,9 @@ export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
|
|||||||
if (filters.siteIds && filters.siteIds.length > 0) {
|
if (filters.siteIds && filters.siteIds.length > 0) {
|
||||||
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
|
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
|
||||||
}
|
}
|
||||||
|
if (filters.employeeId) {
|
||||||
|
query.employee = `/api/employees/${filters.employeeId}`
|
||||||
|
}
|
||||||
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
|
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
|
||||||
'/absences',
|
'/absences',
|
||||||
query,
|
query,
|
||||||
|
|||||||
14
frontend/services/dto/employee-leave-summary.ts
Normal file
14
frontend/services/dto/employee-leave-summary.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type EmployeeLeaveSummary = {
|
||||||
|
year: number
|
||||||
|
isSupported: boolean
|
||||||
|
ruleCode: string
|
||||||
|
acquiredDays: number
|
||||||
|
remainingDays: number
|
||||||
|
takenDays: number
|
||||||
|
acquiredSaturdays: number
|
||||||
|
remainingSaturdays: number
|
||||||
|
takenSaturdays: number
|
||||||
|
fractionedDays: number
|
||||||
|
accruingDays: number
|
||||||
|
}
|
||||||
|
|
||||||
24
frontend/services/dto/employee-rtt-summary.ts
Normal file
24
frontend/services/dto/employee-rtt-summary.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export type EmployeeRttWeekSummary = {
|
||||||
|
month: number
|
||||||
|
weekNumber: number
|
||||||
|
weekStart: string
|
||||||
|
weekEnd: string
|
||||||
|
recoveryMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RttMonthPayment = {
|
||||||
|
month: number
|
||||||
|
paidMinutes25: number
|
||||||
|
paidMinutes50: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmployeeRttSummary = {
|
||||||
|
year: number
|
||||||
|
carryFromPreviousYearMinutes: number
|
||||||
|
currentYearRecoveryMinutes: number
|
||||||
|
totalPaidMinutes: number
|
||||||
|
availableMinutes: number
|
||||||
|
weeks: EmployeeRttWeekSummary[]
|
||||||
|
monthPayments: RttMonthPayment[]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
import type { Contract } from './contract'
|
import type { Contract } from './contract'
|
||||||
|
|
||||||
|
export type ContractHistoryItem = {
|
||||||
|
contractId?: number | null
|
||||||
|
contractName?: string | null
|
||||||
|
weeklyHours?: number | null
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
startDate: string
|
||||||
|
endDate?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
id: number
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -10,5 +20,7 @@ export type Employee = {
|
|||||||
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
currentContractStartDate?: string | null
|
currentContractStartDate?: string | null
|
||||||
currentContractEndDate?: string | null
|
currentContractEndDate?: string | null
|
||||||
|
contractHistory?: ContractHistoryItem[]
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
|
entryDate?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
9
frontend/services/dto/notification.ts
Normal file
9
frontend/services/dto/notification.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type NotificationItem = {
|
||||||
|
id: number
|
||||||
|
actorName: string
|
||||||
|
message: string
|
||||||
|
category: string
|
||||||
|
target: string
|
||||||
|
isRead: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export type WorkHour = {
|
|||||||
isPresentAfternoon?: boolean
|
isPresentAfternoon?: boolean
|
||||||
isSiteValid?: boolean
|
isSiteValid?: boolean
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
|
updatedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkHourEntryPayload = {
|
export type WorkHourEntryPayload = {
|
||||||
|
|||||||
18
frontend/services/employee-leave-summary.ts
Normal file
18
frontend/services/employee-leave-summary.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
|
||||||
|
|
||||||
|
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const query: Record<string, string> = {}
|
||||||
|
if (year) query.year = String(year)
|
||||||
|
|
||||||
|
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateFractionedDays = async (employeeId: number, fractionedDays: number, year?: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const body: Record<string, unknown> = { fractionedDays }
|
||||||
|
if (year) body.year = year
|
||||||
|
|
||||||
|
return api.patch(`/employees/${employeeId}/fractioned-days`, body)
|
||||||
|
}
|
||||||
|
|
||||||
15
frontend/services/employee-rtt-summary.ts
Normal file
15
frontend/services/employee-rtt-summary.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
|
||||||
|
|
||||||
|
export const getEmployeeRttSummary = async (employeeId: number, year?: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const query = year ? { year } : {}
|
||||||
|
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) => {
|
||||||
|
const api = useApi()
|
||||||
|
const body: Record<string, unknown> = { month, minutes, rate }
|
||||||
|
if (year) body.year = year
|
||||||
|
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,11 @@ export const listScopedEmployees = async () => {
|
|||||||
return extractItems<Employee>(data)
|
return extractItems<Employee>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getEmployee = async (id: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.get<Employee>(`/employees/${id}`, {}, { toast: false })
|
||||||
|
}
|
||||||
|
|
||||||
export const createEmployee = async (payload: {
|
export const createEmployee = async (payload: {
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
@@ -51,24 +56,43 @@ export const updateEmployee = async (
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
contractId: number
|
contractId?: number
|
||||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
contractStartDate?: string
|
contractStartDate?: string
|
||||||
contractEndDate?: string | null
|
contractEndDate?: string | null
|
||||||
|
contractPaidLeaveSettled?: boolean
|
||||||
|
contractComment?: string | null
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<Employee>(`/employees/${id}`, {
|
const body: Record<string, unknown> = {
|
||||||
firstName: payload.firstName,
|
firstName: payload.firstName,
|
||||||
lastName: payload.lastName,
|
lastName: payload.lastName,
|
||||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
||||||
contract: `/api/contracts/${payload.contractId}`,
|
|
||||||
contractNature: payload.contractNature,
|
|
||||||
contractStartDate: payload.contractStartDate,
|
|
||||||
contractEndDate: payload.contractEndDate ?? null,
|
|
||||||
displayOrder: payload.displayOrder
|
displayOrder: payload.displayOrder
|
||||||
}, {
|
}
|
||||||
|
|
||||||
|
if (payload.contractId !== undefined) {
|
||||||
|
body.contract = `/api/contracts/${payload.contractId}`
|
||||||
|
}
|
||||||
|
if (payload.contractNature !== undefined) {
|
||||||
|
body.contractNature = payload.contractNature
|
||||||
|
}
|
||||||
|
if (payload.contractStartDate !== undefined) {
|
||||||
|
body.contractStartDate = payload.contractStartDate
|
||||||
|
}
|
||||||
|
if (payload.contractEndDate !== undefined) {
|
||||||
|
body.contractEndDate = payload.contractEndDate ?? null
|
||||||
|
}
|
||||||
|
if (payload.contractPaidLeaveSettled !== undefined) {
|
||||||
|
body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled
|
||||||
|
}
|
||||||
|
if (payload.contractComment !== undefined) {
|
||||||
|
body.contractComment = payload.contractComment ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.patch<Employee>(`/employees/${id}`, body, {
|
||||||
toastSuccessKey: 'success.employee.update',
|
toastSuccessKey: 'success.employee.update',
|
||||||
toastErrorKey: 'errors.employee.update'
|
toastErrorKey: 'errors.employee.update'
|
||||||
})
|
})
|
||||||
|
|||||||
40
frontend/services/notifications.ts
Normal file
40
frontend/services/notifications.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { NotificationItem } from './dto/notification'
|
||||||
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
|
export const listUnreadNotifications = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
|
||||||
|
'/notifications/unread',
|
||||||
|
{},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return extractItems<NotificationItem>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listTodayNotifications = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
|
||||||
|
'/notifications/today',
|
||||||
|
{},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return extractItems<NotificationItem>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listHistoryNotifications = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
|
||||||
|
'/notifications/history',
|
||||||
|
{},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return extractItems<NotificationItem>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markAllNotificationsRead = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post('/notifications/mark-all-read', {}, { toast: false })
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export default <Partial<Config>>{
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
|
sans: ['"Inter"', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif']
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
@@ -15,6 +15,9 @@ export default <Partial<Config>>{
|
|||||||
},
|
},
|
||||||
tertiary: {
|
tertiary: {
|
||||||
500: '#F3F4F8'
|
500: '#F3F4F8'
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
500: '#056CF2'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
frontend/utils/contract.ts
Normal file
21
frontend/utils/contract.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export const CONTRACT_NATURES = ['CDI', 'CDD', 'INTERIM'] as const
|
||||||
|
|
||||||
|
export type ContractNature = (typeof CONTRACT_NATURES)[number]
|
||||||
|
|
||||||
|
export const contractNatureLabel = (value?: ContractNature) => {
|
||||||
|
if (value === 'CDD') return 'CDD'
|
||||||
|
if (value === 'INTERIM') return 'Intérim'
|
||||||
|
return 'CDI'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showsContractEndDate = (nature: ContractNature) => {
|
||||||
|
return nature === 'CDD' || nature === 'INTERIM'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requiresContractEndDate = (nature: ContractNature) => {
|
||||||
|
return nature === 'CDD'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isContractNature = (value: string): value is ContractNature => {
|
||||||
|
return (CONTRACT_NATURES as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
@@ -6,6 +6,17 @@ export const toYmd = (year: number, month: number, day: number) => {
|
|||||||
|
|
||||||
export const normalizeDate = (value: string) => value.slice(0, 10)
|
export const normalizeDate = (value: string) => value.slice(0, 10)
|
||||||
|
|
||||||
|
export const formatYmdToFr = (value: string) => {
|
||||||
|
const [year, month, day] = value.split('-')
|
||||||
|
if (!year || !month || !day) return value
|
||||||
|
return `${day}/${month}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatNullableYmdToFr = (value?: string | null, fallback = 'En cours') => {
|
||||||
|
if (!value) return fallback
|
||||||
|
return formatYmdToFr(value)
|
||||||
|
}
|
||||||
|
|
||||||
export const parseYmd = (value: string) => {
|
export const parseYmd = (value: string) => {
|
||||||
const [year, month, day] = value.split('-').map(Number)
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
if (!year || !month || !day) return null
|
if (!year || !month || !day) return null
|
||||||
|
|||||||
2
makefile
2
makefile
@@ -77,7 +77,7 @@ migration-migrate:
|
|||||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
|
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
|
|
||||||
# Attention, supprime votre bdd local
|
# Attention, supprime votre bdd local
|
||||||
db-reset:
|
db-reset:
|
||||||
|
|||||||
30
migrations/Version20260302110000.php
Normal file
30
migrations/Version20260302110000.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260302110000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add notifications table for user notification center';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE notifications (id SERIAL NOT NULL, recipient_id INT NOT NULL, title VARCHAR(120) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX idx_notifications_recipient_read_created ON notifications (recipient_id, is_read, created_at)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6000B0D0E92F8F78 ON notifications (recipient_id)');
|
||||||
|
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D0E92F8F78 FOREIGN KEY (recipient_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D0E92F8F78');
|
||||||
|
$this->addSql('DROP TABLE notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260304140000.php
Normal file
26
migrations/Version20260304140000.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 Version20260304140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add paid leave settled flag on employee contract periods';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD paid_leave_settled BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP paid_leave_settled');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
migrations/Version20260304153000.php
Normal file
29
migrations/Version20260304153000.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 Version20260304153000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create employee leave balances table for annual rollover opening balances';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE employee_leave_balances (id SERIAL NOT NULL, employee_id INT NOT NULL, rule_code VARCHAR(64) NOT NULL, year INT NOT NULL, opening_days DOUBLE PRECISION NOT NULL, opening_saturdays DOUBLE PRECISION NOT NULL, accrued_days DOUBLE PRECISION NOT NULL, accrued_saturdays DOUBLE PRECISION NOT NULL, taken_days DOUBLE PRECISION NOT NULL, taken_saturdays DOUBLE PRECISION NOT NULL, closing_days DOUBLE PRECISION NOT NULL, closing_saturdays DOUBLE PRECISION NOT NULL, is_locked BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_employee_leave_balance ON employee_leave_balances (employee_id, rule_code, year)');
|
||||||
|
$this->addSql('CREATE INDEX idx_leave_balance_employee_year ON employee_leave_balances (employee_id, year)');
|
||||||
|
$this->addSql('ALTER TABLE employee_leave_balances ADD CONSTRAINT FK_3834A8A18C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE employee_leave_balances');
|
||||||
|
}
|
||||||
|
}
|
||||||
48
migrations/Version20260304170000.php
Normal file
48
migrations/Version20260304170000.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260304170000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add comments on employee_leave_balances table and columns';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("COMMENT ON TABLE employee_leave_balances IS 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.rule_code IS 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.year IS 'Annee d exercice de reference (ex: 2026).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_days IS 'Report N-1 en jours a l ouverture de l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS 'Report N-1 en samedis a l ouverture (0 pour forfait).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_days IS 'Droits jours acquis sur l exercice courant.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS 'Droits samedis acquis sur l exercice courant.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_days IS 'Jours de conges consommes sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS 'Samedis consommes sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_days IS 'Solde de cloture jours sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS 'Solde de cloture samedis sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.is_locked IS 'Indique si le solde de l exercice est fige (verrouille RH).'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('COMMENT ON TABLE employee_leave_balances IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.rule_code IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.year IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.opening_days IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.accrued_days IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.taken_days IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.closing_days IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN employee_leave_balances.is_locked IS NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
250
migrations/Version20260304173000.php
Normal file
250
migrations/Version20260304173000.php
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260304173000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add comments on all database tables and columns';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// absence_types
|
||||||
|
$this->addSql("COMMENT ON TABLE absence_types IS 'Catalogue des types d absence (CP, RTT, maladie, etc.).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absence_types.id IS 'Identifiant technique du type d absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absence_types.code IS 'Code metier court du type d absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absence_types.label IS 'Libelle metier du type d absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absence_types.color IS 'Couleur d affichage du type d absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absence_types.count_as_worked_hours IS 'Indique si l absence est comptee comme temps travaille.'");
|
||||||
|
|
||||||
|
// absences
|
||||||
|
$this->addSql("COMMENT ON TABLE absences IS 'Absences employees enregistrees par jour et demi journee.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.id IS 'Identifiant technique de l absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.employee_id IS 'Employe concerne par l absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.type_id IS 'Type d absence applique.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.comment IS 'Commentaire libre de l absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.start_date IS 'Date de debut de l absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.end_date IS 'Date de fin de l absence.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.start_half IS 'Demi journee de debut (AM ou PM).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN absences.end_half IS 'Demi journee de fin (AM ou PM).'");
|
||||||
|
|
||||||
|
// contracts
|
||||||
|
$this->addSql("COMMENT ON TABLE contracts IS 'Referentiel des contrats de travail utilisables.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN contracts.id IS 'Identifiant technique du contrat.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN contracts.name IS 'Nom metier du contrat (35h, 39h, Forfait, etc.).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN contracts.tracking_mode IS 'Mode de suivi du contrat (TIME ou PRESENCE).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN contracts.weekly_hours IS 'Volume horaire hebdomadaire contractuel, si applicable.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN contracts.is_active IS 'Indique si le contrat est actif dans le referentiel.'");
|
||||||
|
|
||||||
|
// doctrine_migration_versions
|
||||||
|
$this->addSql("COMMENT ON TABLE doctrine_migration_versions IS 'Historique technique des migrations Doctrine appliquees.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN doctrine_migration_versions.version IS 'Version unique de migration appliquee.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN doctrine_migration_versions.executed_at IS 'Date et heure d execution de la migration.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN doctrine_migration_versions.execution_time IS 'Duree d execution de la migration en millisecondes.'");
|
||||||
|
|
||||||
|
// employee_contract_periods
|
||||||
|
$this->addSql("COMMENT ON TABLE employee_contract_periods IS 'Historique des periodes de contrat par employe.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.id IS 'Identifiant technique de la periode contrat.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.employee_id IS 'Employe concerne par la periode contrat.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.contract_id IS 'Contrat applique sur la periode.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.start_date IS 'Date de debut de la periode contrat.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.end_date IS 'Date de fin de la periode contrat, null si en cours.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.created_at IS 'Date de creation technique de la periode contrat.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.contract_nature IS 'Nature du contrat (CDI, CDD, INTERIM).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_contract_periods.paid_leave_settled IS 'Indique si les conges ont ete soldes a la cloture de cette periode.'");
|
||||||
|
|
||||||
|
// employee_leave_balances
|
||||||
|
$this->addSql("COMMENT ON TABLE employee_leave_balances IS 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.id IS 'Identifiant technique du solde annuel.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.employee_id IS 'Employe concerne par le solde annuel.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.rule_code IS 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.year IS 'Annee d exercice de reference (ex: 2026).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_days IS 'Report N-1 en jours a l ouverture de l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS 'Report N-1 en samedis a l ouverture (0 pour forfait).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_days IS 'Droits jours acquis sur l exercice courant.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS 'Droits samedis acquis sur l exercice courant.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_days IS 'Jours de conges consommes sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS 'Samedis consommes sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_days IS 'Solde de cloture jours sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS 'Solde de cloture samedis sur l exercice.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.is_locked IS 'Indique si le solde de l exercice est fige (verrouille RH).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.created_at IS 'Date de creation technique du solde.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_leave_balances.updated_at IS 'Date de derniere mise a jour du solde.'");
|
||||||
|
|
||||||
|
// employees
|
||||||
|
$this->addSql("COMMENT ON TABLE employees IS 'Referentiel des employes.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employees.id IS 'Identifiant technique de l employe.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employees.first_name IS 'Prenom de l employe.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employees.last_name IS 'Nom de l employe.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employees.created_at IS 'Date de creation technique de l employe.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employees.site_id IS 'Site principal de rattachement de l employe.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employees.display_order IS 'Ordre d affichage de l employe dans les listes UI.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employees.contract_id IS 'Contrat courant reference sur la fiche employe.'");
|
||||||
|
|
||||||
|
// notifications
|
||||||
|
$this->addSql("COMMENT ON TABLE notifications IS 'Notifications applicatives envoyees aux utilisateurs.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN notifications.id IS 'Identifiant technique de la notification.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN notifications.recipient_id IS 'Utilisateur destinataire de la notification.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN notifications.title IS 'Titre court de la notification.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN notifications.message IS 'Message detaille de la notification.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN notifications.is_read IS 'Indique si la notification a ete lue.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN notifications.created_at IS 'Date de creation de la notification.'");
|
||||||
|
|
||||||
|
// sites
|
||||||
|
$this->addSql("COMMENT ON TABLE sites IS 'Referentiel des sites de l entreprise.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN sites.id IS 'Identifiant technique du site.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN sites.name IS 'Nom du site.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN sites.color IS 'Couleur associee au site pour affichage UI.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN sites.display_order IS 'Ordre d affichage du site dans les listes UI.'");
|
||||||
|
|
||||||
|
// user_site_roles
|
||||||
|
$this->addSql("COMMENT ON TABLE user_site_roles IS 'Roles attribues aux utilisateurs sur des sites donnes.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN user_site_roles.id IS 'Identifiant technique de l attribution de role site.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN user_site_roles.user_id IS 'Utilisateur concerne par l attribution.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN user_site_roles.site_id IS 'Site concerne par l attribution.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN user_site_roles.role IS 'Role attribue sur le site (ex: ROLE_SITE_MANAGER).'");
|
||||||
|
|
||||||
|
// users
|
||||||
|
$this->addSql("COMMENT ON TABLE users IS 'Comptes utilisateurs de l application.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN users.id IS 'Identifiant technique du compte utilisateur.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN users.username IS 'Identifiant de connexion de l utilisateur.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN users.roles IS 'Liste des roles globaux de securite du compte.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN users.password IS 'Hash du mot de passe utilisateur.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN users.employee_id IS 'Lien optionnel vers la fiche employe associee.'");
|
||||||
|
|
||||||
|
// work_hours
|
||||||
|
$this->addSql("COMMENT ON TABLE work_hours IS 'Saisie des heures de travail et presences par employe et par jour.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.id IS 'Identifiant technique de la ligne de saisie.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.employee_id IS 'Employe concerne par la saisie horaire.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.work_date IS 'Date de travail de la ligne de saisie.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.morning_from IS 'Heure de debut du matin.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.morning_to IS 'Heure de fin du matin.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.afternoon_from IS 'Heure de debut de l apres midi.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.afternoon_to IS 'Heure de fin de l apres midi.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.evening_from IS 'Heure de debut du soir.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.evening_to IS 'Heure de fin du soir.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.is_valid IS 'Validation RH de la ligne horaire.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.is_present_morning IS 'Presence declarative du matin (mode PRESENCE).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.is_present_afternoon IS 'Presence declarative de l apres midi (mode PRESENCE).'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN work_hours.is_site_valid IS 'Validation site (chef de site) de la ligne horaire.'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$tables = [
|
||||||
|
'absence_types',
|
||||||
|
'absences',
|
||||||
|
'contracts',
|
||||||
|
'doctrine_migration_versions',
|
||||||
|
'employee_contract_periods',
|
||||||
|
'employee_leave_balances',
|
||||||
|
'employees',
|
||||||
|
'notifications',
|
||||||
|
'sites',
|
||||||
|
'user_site_roles',
|
||||||
|
'users',
|
||||||
|
'work_hours',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $table));
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = [
|
||||||
|
['absence_types', 'id'],
|
||||||
|
['absence_types', 'code'],
|
||||||
|
['absence_types', 'label'],
|
||||||
|
['absence_types', 'color'],
|
||||||
|
['absence_types', 'count_as_worked_hours'],
|
||||||
|
['absences', 'id'],
|
||||||
|
['absences', 'employee_id'],
|
||||||
|
['absences', 'type_id'],
|
||||||
|
['absences', 'comment'],
|
||||||
|
['absences', 'start_date'],
|
||||||
|
['absences', 'end_date'],
|
||||||
|
['absences', 'start_half'],
|
||||||
|
['absences', 'end_half'],
|
||||||
|
['contracts', 'id'],
|
||||||
|
['contracts', 'name'],
|
||||||
|
['contracts', 'tracking_mode'],
|
||||||
|
['contracts', 'weekly_hours'],
|
||||||
|
['contracts', 'is_active'],
|
||||||
|
['doctrine_migration_versions', 'version'],
|
||||||
|
['doctrine_migration_versions', 'executed_at'],
|
||||||
|
['doctrine_migration_versions', 'execution_time'],
|
||||||
|
['employee_contract_periods', 'id'],
|
||||||
|
['employee_contract_periods', 'employee_id'],
|
||||||
|
['employee_contract_periods', 'contract_id'],
|
||||||
|
['employee_contract_periods', 'start_date'],
|
||||||
|
['employee_contract_periods', 'end_date'],
|
||||||
|
['employee_contract_periods', 'created_at'],
|
||||||
|
['employee_contract_periods', 'contract_nature'],
|
||||||
|
['employee_contract_periods', 'paid_leave_settled'],
|
||||||
|
['employee_leave_balances', 'id'],
|
||||||
|
['employee_leave_balances', 'employee_id'],
|
||||||
|
['employee_leave_balances', 'rule_code'],
|
||||||
|
['employee_leave_balances', 'year'],
|
||||||
|
['employee_leave_balances', 'opening_days'],
|
||||||
|
['employee_leave_balances', 'opening_saturdays'],
|
||||||
|
['employee_leave_balances', 'accrued_days'],
|
||||||
|
['employee_leave_balances', 'accrued_saturdays'],
|
||||||
|
['employee_leave_balances', 'taken_days'],
|
||||||
|
['employee_leave_balances', 'taken_saturdays'],
|
||||||
|
['employee_leave_balances', 'closing_days'],
|
||||||
|
['employee_leave_balances', 'closing_saturdays'],
|
||||||
|
['employee_leave_balances', 'is_locked'],
|
||||||
|
['employee_leave_balances', 'created_at'],
|
||||||
|
['employee_leave_balances', 'updated_at'],
|
||||||
|
['employees', 'id'],
|
||||||
|
['employees', 'first_name'],
|
||||||
|
['employees', 'last_name'],
|
||||||
|
['employees', 'created_at'],
|
||||||
|
['employees', 'site_id'],
|
||||||
|
['employees', 'display_order'],
|
||||||
|
['employees', 'contract_id'],
|
||||||
|
['notifications', 'id'],
|
||||||
|
['notifications', 'recipient_id'],
|
||||||
|
['notifications', 'title'],
|
||||||
|
['notifications', 'message'],
|
||||||
|
['notifications', 'is_read'],
|
||||||
|
['notifications', 'created_at'],
|
||||||
|
['sites', 'id'],
|
||||||
|
['sites', 'name'],
|
||||||
|
['sites', 'color'],
|
||||||
|
['sites', 'display_order'],
|
||||||
|
['user_site_roles', 'id'],
|
||||||
|
['user_site_roles', 'user_id'],
|
||||||
|
['user_site_roles', 'site_id'],
|
||||||
|
['user_site_roles', 'role'],
|
||||||
|
['users', 'id'],
|
||||||
|
['users', 'username'],
|
||||||
|
['users', 'roles'],
|
||||||
|
['users', 'password'],
|
||||||
|
['users', 'employee_id'],
|
||||||
|
['work_hours', 'id'],
|
||||||
|
['work_hours', 'employee_id'],
|
||||||
|
['work_hours', 'work_date'],
|
||||||
|
['work_hours', 'morning_from'],
|
||||||
|
['work_hours', 'morning_to'],
|
||||||
|
['work_hours', 'afternoon_from'],
|
||||||
|
['work_hours', 'afternoon_to'],
|
||||||
|
['work_hours', 'evening_from'],
|
||||||
|
['work_hours', 'evening_to'],
|
||||||
|
['work_hours', 'is_valid'],
|
||||||
|
['work_hours', 'is_present_morning'],
|
||||||
|
['work_hours', 'is_present_afternoon'],
|
||||||
|
['work_hours', 'is_site_valid'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($columns as [$table, $column]) {
|
||||||
|
$this->addSql(sprintf('COMMENT ON COLUMN %s.%s IS NULL', $table, $column));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
migrations/Version20260306120000.php
Normal file
29
migrations/Version20260306120000.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 Version20260306120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create employee_rtt_balances table for RTT opening balances.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE employee_rtt_balances (id SERIAL NOT NULL, employee_id INT NOT NULL, year INT NOT NULL, opening_minutes INT NOT NULL, is_locked BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_employee_rtt_balance ON employee_rtt_balances (employee_id, year)');
|
||||||
|
$this->addSql('CREATE INDEX idx_rtt_balance_employee_year ON employee_rtt_balances (employee_id, year)');
|
||||||
|
$this->addSql('ALTER TABLE employee_rtt_balances ADD CONSTRAINT FK_rtt_balance_employee FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE employee_rtt_balances');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260306140000.php
Normal file
26
migrations/Version20260306140000.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 Version20260306140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add updated_at column to work_hours table.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD COLUMN updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN updated_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260306160000.php
Normal file
26
migrations/Version20260306160000.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 Version20260306160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add comment column to employee_contract_periods table.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD COLUMN comment TEXT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN comment');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260309120000.php
Normal file
26
migrations/Version20260309120000.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 Version20260309120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add fractioned_days column to employee_leave_balances table.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_leave_balances ADD fractioned_days DOUBLE PRECISION NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_leave_balances DROP COLUMN fractioned_days');
|
||||||
|
}
|
||||||
|
}
|
||||||
41
migrations/Version20260309140000.php
Normal file
41
migrations/Version20260309140000.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260309140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create employee_rtt_payments table for RTT paid hours tracking.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE employee_rtt_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
employee_id INT NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL,
|
||||||
|
minutes INT NOT NULL,
|
||||||
|
rate VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_rtt_payment_employee_year ON employee_rtt_payments (employee_id, year)');
|
||||||
|
$this->addSql("COMMENT ON TABLE employee_rtt_payments IS 'Paiements RTT par employe, mois et taux de majoration.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.rate IS 'Taux de majoration: 25 ou 50.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.minutes IS 'Minutes RTT payees pour ce mois et ce taux.'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE employee_rtt_payments');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260309160000.php
Normal file
26
migrations/Version20260309160000.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 Version20260309160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add unique constraint on employee_rtt_payments (employee_id, year, month, rate).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_rtt_payment_employee_year_month_rate ON employee_rtt_payments (employee_id, year, month, rate)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX uniq_rtt_payment_employee_year_month_rate');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260309170000.php
Normal file
26
migrations/Version20260309170000.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 Version20260309170000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add category column to notifications table.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("ALTER TABLE notifications ADD COLUMN category VARCHAR(60) NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE notifications DROP COLUMN category');
|
||||||
|
}
|
||||||
|
}
|
||||||
28
migrations/Version20260309180000.php
Normal file
28
migrations/Version20260309180000.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260309180000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Replace title with actor_id on notifications table.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE notifications ADD COLUMN actor_id INT DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL');
|
||||||
|
$this->addSql('ALTER TABLE notifications DROP COLUMN title');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("ALTER TABLE notifications ADD COLUMN title VARCHAR(120) NOT NULL DEFAULT ''");
|
||||||
|
$this->addSql('ALTER TABLE notifications DROP COLUMN actor_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260309190000.php
Normal file
26
migrations/Version20260309190000.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 Version20260309190000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add target column to notifications table.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("ALTER TABLE notifications ADD COLUMN target VARCHAR(255) NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE notifications DROP COLUMN target');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260312120000.php
Normal file
26
migrations/Version20260312120000.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 Version20260312120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add entry_date column to employees table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees DROP entry_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/ApiResource/EmployeeFractionedDaysInput.php
Normal file
27
src/ApiResource/EmployeeFractionedDaysInput.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use App\State\EmployeeFractionedDaysProcessor;
|
||||||
|
use App\State\EmployeeFractionedDaysProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/employees/{id}/fractioned-days',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeFractionedDaysProvider::class,
|
||||||
|
processor: EmployeeFractionedDaysProcessor::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeFractionedDaysInput
|
||||||
|
{
|
||||||
|
public float $fractionedDays = 0.0;
|
||||||
|
public ?int $year = null;
|
||||||
|
}
|
||||||
34
src/ApiResource/EmployeeLeaveSummary.php
Normal file
34
src/ApiResource/EmployeeLeaveSummary.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\EmployeeLeaveSummaryProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/employees/{id}/leave-summary',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: EmployeeLeaveSummaryProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeLeaveSummary
|
||||||
|
{
|
||||||
|
public int $year = 0;
|
||||||
|
public bool $isSupported = false;
|
||||||
|
public string $ruleCode = '';
|
||||||
|
public float $acquiredDays = 0.0;
|
||||||
|
public float $remainingDays = 0.0;
|
||||||
|
public float $takenDays = 0.0;
|
||||||
|
public float $acquiredSaturdays = 0.0;
|
||||||
|
public float $remainingSaturdays = 0.0;
|
||||||
|
public float $takenSaturdays = 0.0;
|
||||||
|
public float $fractionedDays = 0.0;
|
||||||
|
public float $accruingDays = 0.0;
|
||||||
|
}
|
||||||
29
src/ApiResource/EmployeeRttPaymentInput.php
Normal file
29
src/ApiResource/EmployeeRttPaymentInput.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use App\State\EmployeeRttPaymentProcessor;
|
||||||
|
use App\State\EmployeeRttPaymentProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/employees/{id}/rtt-payments',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeRttPaymentProvider::class,
|
||||||
|
processor: EmployeeRttPaymentProcessor::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeRttPaymentInput
|
||||||
|
{
|
||||||
|
public int $month = 0;
|
||||||
|
public int $minutes = 0;
|
||||||
|
public string $rate = '25';
|
||||||
|
public ?int $year = null;
|
||||||
|
}
|
||||||
36
src/ApiResource/EmployeeRttSummary.php
Normal file
36
src/ApiResource/EmployeeRttSummary.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
|
use App\Dto\Rtt\RttMonthPayment;
|
||||||
|
use App\State\EmployeeRttSummaryProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/employees/{id}/rtt-summary',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: EmployeeRttSummaryProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeRttSummary
|
||||||
|
{
|
||||||
|
public int $year = 0;
|
||||||
|
public int $carryFromPreviousYearMinutes = 0;
|
||||||
|
public int $currentYearRecoveryMinutes = 0;
|
||||||
|
public int $availableMinutes = 0;
|
||||||
|
public int $totalPaidMinutes = 0;
|
||||||
|
|
||||||
|
/** @var list<RttMonthPayment> */
|
||||||
|
public array $monthPayments = [];
|
||||||
|
|
||||||
|
/** @var list<EmployeeRttWeekSummary> */
|
||||||
|
public array $weeks = [];
|
||||||
|
}
|
||||||
213
src/Command/LeaveRolloverCommand.php
Normal file
213
src/Command/LeaveRolloverCommand.php
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeLeaveBalance;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\LeaveRuleCode;
|
||||||
|
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\Leave\LeaveBalanceComputationService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:leave:rollover',
|
||||||
|
description: 'Create yearly leave opening balances (idempotent).'
|
||||||
|
)]
|
||||||
|
final class LeaveRolloverCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EmployeeRepository $employeeRepository,
|
||||||
|
private readonly EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||||
|
private readonly LeaveBalanceComputationService $leaveBalanceComputationService,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'force',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Run rollover regardless of business date (manual recovery mode).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$force = (bool) $input->getOption('force');
|
||||||
|
|
||||||
|
$this->logger->info('app:leave:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
|
||||||
|
|
||||||
|
if (!$force && !$this->isBusinessRolloverDate($today)) {
|
||||||
|
$message = 'No rollover today: business date is neither 01/01 nor 01/06.';
|
||||||
|
$this->logger->info($message, ['date' => $today->format('Y-m-d')]);
|
||||||
|
$io->success($message);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($this->employeeRepository->findAll() as $employee) {
|
||||||
|
if (!$employee instanceof Employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ruleCode = $this->resolveRuleCode($employee);
|
||||||
|
if (null === $ruleCode) {
|
||||||
|
$this->logger->info('Employee skipped: no eligible rule.', ['employeeId' => $employee->getId()]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$force && !$this->shouldProcessRuleToday($ruleCode, $today)) {
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetYear = $this->resolveTargetYear($ruleCode, $today);
|
||||||
|
$existing = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $targetYear);
|
||||||
|
if (null !== $existing) {
|
||||||
|
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$carryDays, $carrySaturdays] = $this->resolveCarry($employee, $ruleCode, $targetYear);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$balance = new EmployeeLeaveBalance()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setRuleCode($ruleCode)
|
||||||
|
->setYear($targetYear)
|
||||||
|
->setOpeningDays($carryDays)
|
||||||
|
->setOpeningSaturdays($carrySaturdays)
|
||||||
|
->setAccruedDays(0.0)
|
||||||
|
->setAccruedSaturdays(0.0)
|
||||||
|
->setTakenDays(0.0)
|
||||||
|
->setTakenSaturdays(0.0)
|
||||||
|
->setClosingDays($carryDays)
|
||||||
|
->setClosingSaturdays($carrySaturdays)
|
||||||
|
->setIsLocked(false)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($balance);
|
||||||
|
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'rule' => $ruleCode->value, 'carryDays' => $carryDays, 'carrySaturdays' => $carrySaturdays]);
|
||||||
|
++$created;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Error flushing leave balances.', ['error' => $e->getMessage()]);
|
||||||
|
$io->error('Leave rollover failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf('Leave rollover done: %d created, %d skipped.', $created, $skipped);
|
||||||
|
$this->logger->info($message);
|
||||||
|
$io->success($message);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRuleCode(Employee $employee): ?LeaveRuleCode
|
||||||
|
{
|
||||||
|
$type = $employee->getContract()?->getType();
|
||||||
|
if (null === $type || ContractType::INTERIM === $type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContractType::FORFAIT === $type) {
|
||||||
|
return LeaveRuleCode::FORFAIT_218;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LeaveRuleCode::CDI_CDD_NON_FORFAIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTargetYear(LeaveRuleCode $ruleCode, DateTimeImmutable $today): int
|
||||||
|
{
|
||||||
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||||
|
return (int) $today->format('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = (int) $today->format('Y');
|
||||||
|
$month = (int) $today->format('n');
|
||||||
|
|
||||||
|
return $month >= 6 ? $year + 1 : $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isBusinessRolloverDate(DateTimeImmutable $today): bool
|
||||||
|
{
|
||||||
|
return in_array($today->format('m-d'), ['01-01', '06-01'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldProcessRuleToday(LeaveRuleCode $ruleCode, DateTimeImmutable $today): bool
|
||||||
|
{
|
||||||
|
$day = $today->format('m-d');
|
||||||
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||||
|
return '01-01' === $day;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '06-01' === $day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{float, float}
|
||||||
|
*/
|
||||||
|
private function resolveCarry(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
|
||||||
|
{
|
||||||
|
$previousYear = $targetYear - 1;
|
||||||
|
$previous = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $previousYear);
|
||||||
|
if (null !== $previous) {
|
||||||
|
$carryDays = $previous->getClosingDays() + $previous->getFractionedDays();
|
||||||
|
$carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode
|
||||||
|
? $previous->getClosingSaturdays()
|
||||||
|
: 0.0;
|
||||||
|
} else {
|
||||||
|
[$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
|
||||||
|
->computeDynamicClosingForYear($employee, $ruleCode, $previousYear)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$from, $to] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $previousYear);
|
||||||
|
$hasSettlement = $this->leaveBalanceComputationService
|
||||||
|
->hasPaidLeaveSettledClosureBetween($employee, $from, $to)
|
||||||
|
;
|
||||||
|
if ($hasSettlement) {
|
||||||
|
return [0.0, 0.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$carryDays, $carrySaturdays];
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/Command/RttRolloverCommand.php
Normal file
158
src/Command/RttRolloverCommand.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttBalance;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:rtt:rollover',
|
||||||
|
description: 'Create yearly RTT opening balances (idempotent).'
|
||||||
|
)]
|
||||||
|
final class RttRolloverCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EmployeeRepository $employeeRepository,
|
||||||
|
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||||
|
private readonly RttRecoveryComputationService $rttRecoveryService,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'force',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Run rollover regardless of business date (manual recovery mode).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$force = (bool) $input->getOption('force');
|
||||||
|
|
||||||
|
$this->logger->info('app:rtt:rollover started.', ['date' => $today->format('Y-m-d'), 'force' => $force]);
|
||||||
|
|
||||||
|
if (!$force && '06-01' !== $today->format('m-d')) {
|
||||||
|
$message = 'No RTT rollover today: business date is not 01/06.';
|
||||||
|
$this->logger->info($message, ['date' => $today->format('Y-m-d')]);
|
||||||
|
$io->success($message);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetYear = $this->resolveTargetYear($today);
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($this->employeeRepository->findAll() as $employee) {
|
||||||
|
if (!$employee instanceof Employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isEligible($employee)) {
|
||||||
|
$this->logger->info('Employee skipped: not eligible.', ['employeeId' => $employee->getId()]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $targetYear);
|
||||||
|
if (null !== $existing) {
|
||||||
|
$this->logger->info('Employee skipped: balance already exists.', ['employeeId' => $employee->getId(), 'year' => $targetYear]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$previousYear = $targetYear - 1;
|
||||||
|
$carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
||||||
|
++$skipped;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$balance = new EmployeeRttBalance()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setYear($targetYear)
|
||||||
|
->setOpeningMinutes($carryMinutes)
|
||||||
|
->setIsLocked(false)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($balance);
|
||||||
|
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carryMinutes]);
|
||||||
|
++$created;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Error flushing RTT balances.', ['error' => $e->getMessage()]);
|
||||||
|
$io->error('RTT rollover failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf('RTT rollover done: %d created, %d skipped.', $created, $skipped);
|
||||||
|
$this->logger->info($message);
|
||||||
|
$io->success($message);
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTargetYear(DateTimeImmutable $today): int
|
||||||
|
{
|
||||||
|
$year = (int) $today->format('Y');
|
||||||
|
$month = (int) $today->format('n');
|
||||||
|
|
||||||
|
return $month >= 6 ? $year + 1 : $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isEligible(Employee $employee): bool
|
||||||
|
{
|
||||||
|
$contract = $employee->getContract();
|
||||||
|
if (null === $contract) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = ContractType::resolve(
|
||||||
|
$contract->getName(),
|
||||||
|
$contract->getTrackingMode(),
|
||||||
|
$contract->getWeeklyHours()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ContractType::INTERIM !== $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/DataFixtures/AbsenceFixtures.php
Normal file
97
src/DataFixtures/AbsenceFixtures.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\AbsenceType;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Enum\HalfDay;
|
||||||
|
use DateTime;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class AbsenceFixtures extends Fixture implements DependentFixtureInterface
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$this->createAbsence(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::ABSENCE_TYPE_CONGE, AbsenceType::class),
|
||||||
|
'2026-03-03',
|
||||||
|
HalfDay::AM,
|
||||||
|
'2026-03-03',
|
||||||
|
HalfDay::PM,
|
||||||
|
'CP standard non forfait'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createAbsence(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::ABSENCE_TYPE_CONGE, AbsenceType::class),
|
||||||
|
'2026-03-04',
|
||||||
|
HalfDay::AM,
|
||||||
|
'2026-03-04',
|
||||||
|
HalfDay::PM,
|
||||||
|
'CP employe 4h'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createAbsence(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::ABSENCE_TYPE_AUTRE, AbsenceType::class),
|
||||||
|
'2026-03-05',
|
||||||
|
HalfDay::AM,
|
||||||
|
'2026-03-05',
|
||||||
|
HalfDay::AM,
|
||||||
|
'Absence forfait demi-journee'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createAbsence(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_INTERIM, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::ABSENCE_TYPE_ABSENT, AbsenceType::class),
|
||||||
|
'2026-03-06',
|
||||||
|
HalfDay::AM,
|
||||||
|
'2026-03-06',
|
||||||
|
HalfDay::PM,
|
||||||
|
'Absence interim'
|
||||||
|
);
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EmployeeFixtures::class,
|
||||||
|
AbsenceTypeFixtures::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAbsence(
|
||||||
|
ObjectManager $manager,
|
||||||
|
Employee $employee,
|
||||||
|
AbsenceType $type,
|
||||||
|
string $startDate,
|
||||||
|
HalfDay $startHalf,
|
||||||
|
string $endDate,
|
||||||
|
HalfDay $endHalf,
|
||||||
|
string $comment
|
||||||
|
): void {
|
||||||
|
$absence = new Absence()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setType($type)
|
||||||
|
->setStartDate(new DateTime($startDate))
|
||||||
|
->setStartHalf($startHalf)
|
||||||
|
->setEndDate(new DateTime($endDate))
|
||||||
|
->setEndHalf($endHalf)
|
||||||
|
->setComment($comment)
|
||||||
|
;
|
||||||
|
|
||||||
|
$manager->persist($absence);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/DataFixtures/AbsenceTypeFixtures.php
Normal file
39
src/DataFixtures/AbsenceTypeFixtures.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\AbsenceType;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class AbsenceTypeFixtures extends Fixture
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$definitions = [
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_RTT, 'R', 'RTT', '#feba01', false],
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_CONGE, 'C', 'CONGÉ', '#008000', true],
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_MALADIE, 'M', 'MALADIE', '#ed07da', true],
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_AT, 'AT', 'ACCIDENT DE TRAVAIL', '#e41111', true],
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_FORMATION, 'F', 'FORMATION', '#6c72e0', true],
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_EXCEPTIONNEL, 'EX', 'CONGE EXCEPTIONNEL', '#8834ae', true],
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_ABSENT, 'ABS', 'ABSENT', '#823a21', false],
|
||||||
|
[FixtureReferences::ABSENCE_TYPE_AUTRE, 'AUT', 'AUTRE', '#f07d60', true],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($definitions as [$reference, $code, $label, $color, $countAsWorkedHours]) {
|
||||||
|
$absenceType = new AbsenceType()
|
||||||
|
->setCode($code)
|
||||||
|
->setLabel($label)
|
||||||
|
->setColor($color)
|
||||||
|
->setCountAsWorkedHours($countAsWorkedHours)
|
||||||
|
;
|
||||||
|
$manager->persist($absenceType);
|
||||||
|
$this->addReference($reference, $absenceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/DataFixtures/ContractFixtures.php
Normal file
55
src/DataFixtures/ContractFixtures.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class ContractFixtures extends Fixture
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$contract35 = new Contract()
|
||||||
|
->setName('35h')
|
||||||
|
->setTrackingMode(TrackingMode::TIME)
|
||||||
|
->setWeeklyHours(35)
|
||||||
|
->setIsActive(true)
|
||||||
|
;
|
||||||
|
|
||||||
|
$contract4h = new Contract()
|
||||||
|
->setName('4h')
|
||||||
|
->setTrackingMode(TrackingMode::TIME)
|
||||||
|
->setWeeklyHours(4)
|
||||||
|
->setIsActive(true)
|
||||||
|
;
|
||||||
|
|
||||||
|
$forfait = new Contract()
|
||||||
|
->setName('Forfait')
|
||||||
|
->setTrackingMode(TrackingMode::PRESENCE)
|
||||||
|
->setWeeklyHours(null)
|
||||||
|
->setIsActive(true)
|
||||||
|
;
|
||||||
|
|
||||||
|
$interim = new Contract()
|
||||||
|
->setName('Interim')
|
||||||
|
->setTrackingMode(TrackingMode::TIME)
|
||||||
|
->setWeeklyHours(35)
|
||||||
|
->setIsActive(true)
|
||||||
|
;
|
||||||
|
|
||||||
|
$manager->persist($contract35);
|
||||||
|
$manager->persist($contract4h);
|
||||||
|
$manager->persist($forfait);
|
||||||
|
$manager->persist($interim);
|
||||||
|
$manager->flush();
|
||||||
|
|
||||||
|
$this->addReference(FixtureReferences::CONTRACT_35, $contract35);
|
||||||
|
$this->addReference(FixtureReferences::CONTRACT_4H, $contract4h);
|
||||||
|
$this->addReference(FixtureReferences::CONTRACT_FORFAIT, $forfait);
|
||||||
|
$this->addReference(FixtureReferences::CONTRACT_INTERIM, $interim);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/DataFixtures/EmployeeContractPeriodFixtures.php
Normal file
91
src/DataFixtures/EmployeeContractPeriodFixtures.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class EmployeeContractPeriodFixtures extends Fixture implements DependentFixtureInterface
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$this->createPeriod(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::CONTRACT_35, Contract::class),
|
||||||
|
'2025-01-01',
|
||||||
|
null,
|
||||||
|
ContractNature::CDI,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createPeriod(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::CONTRACT_4H, Contract::class),
|
||||||
|
'2026-01-01',
|
||||||
|
'2026-12-31',
|
||||||
|
ContractNature::CDD,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createPeriod(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::CONTRACT_FORFAIT, Contract::class),
|
||||||
|
'2024-01-01',
|
||||||
|
null,
|
||||||
|
ContractNature::CDI,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createPeriod(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_INTERIM, Employee::class),
|
||||||
|
$this->getReference(FixtureReferences::CONTRACT_INTERIM, Contract::class),
|
||||||
|
'2026-02-01',
|
||||||
|
'2026-06-30',
|
||||||
|
ContractNature::INTERIM,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EmployeeFixtures::class,
|
||||||
|
ContractFixtures::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPeriod(
|
||||||
|
ObjectManager $manager,
|
||||||
|
Employee $employee,
|
||||||
|
Contract $contract,
|
||||||
|
string $startDate,
|
||||||
|
?string $endDate,
|
||||||
|
ContractNature $nature,
|
||||||
|
bool $paidLeaveSettled
|
||||||
|
): void {
|
||||||
|
$period = new EmployeeContractPeriod()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setContract($contract)
|
||||||
|
->setStartDate(new DateTimeImmutable($startDate))
|
||||||
|
->setEndDate(null === $endDate ? null : new DateTimeImmutable($endDate))
|
||||||
|
->setContractNature($nature)
|
||||||
|
->setPaidLeaveSettled($paidLeaveSettled)
|
||||||
|
;
|
||||||
|
|
||||||
|
$manager->persist($period);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/DataFixtures/EmployeeFixtures.php
Normal file
71
src/DataFixtures/EmployeeFixtures.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\Site;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class EmployeeFixtures extends Fixture implements DependentFixtureInterface
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$site = $this->getReference(FixtureReferences::SITE_MAIN, Site::class);
|
||||||
|
|
||||||
|
$employeeStandard = new Employee()
|
||||||
|
->setFirstName('Alice')
|
||||||
|
->setLastName('Martin')
|
||||||
|
->setSite($site)
|
||||||
|
->setContract($this->getReference(FixtureReferences::CONTRACT_35, Contract::class))
|
||||||
|
->setDisplayOrder(1)
|
||||||
|
;
|
||||||
|
|
||||||
|
$employee4h = new Employee()
|
||||||
|
->setFirstName('Bruno')
|
||||||
|
->setLastName('Petit')
|
||||||
|
->setSite($site)
|
||||||
|
->setContract($this->getReference(FixtureReferences::CONTRACT_4H, Contract::class))
|
||||||
|
->setDisplayOrder(2)
|
||||||
|
;
|
||||||
|
|
||||||
|
$employeeForfait = new Employee()
|
||||||
|
->setFirstName('Chloe')
|
||||||
|
->setLastName('Durand')
|
||||||
|
->setSite($site)
|
||||||
|
->setContract($this->getReference(FixtureReferences::CONTRACT_FORFAIT, Contract::class))
|
||||||
|
->setDisplayOrder(3)
|
||||||
|
;
|
||||||
|
|
||||||
|
$employeeInterim = new Employee()
|
||||||
|
->setFirstName('David')
|
||||||
|
->setLastName('Leroy')
|
||||||
|
->setSite($site)
|
||||||
|
->setContract($this->getReference(FixtureReferences::CONTRACT_INTERIM, Contract::class))
|
||||||
|
->setDisplayOrder(4)
|
||||||
|
;
|
||||||
|
|
||||||
|
$manager->persist($employeeStandard);
|
||||||
|
$manager->persist($employee4h);
|
||||||
|
$manager->persist($employeeForfait);
|
||||||
|
$manager->persist($employeeInterim);
|
||||||
|
$manager->flush();
|
||||||
|
|
||||||
|
$this->addReference(FixtureReferences::EMPLOYEE_STANDARD, $employeeStandard);
|
||||||
|
$this->addReference(FixtureReferences::EMPLOYEE_4H, $employee4h);
|
||||||
|
$this->addReference(FixtureReferences::EMPLOYEE_FORFAIT, $employeeForfait);
|
||||||
|
$this->addReference(FixtureReferences::EMPLOYEE_INTERIM, $employeeInterim);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
SiteFixtures::class,
|
||||||
|
ContractFixtures::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/DataFixtures/EmployeeLeaveBalanceFixtures.php
Normal file
80
src/DataFixtures/EmployeeLeaveBalanceFixtures.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeLeaveBalance;
|
||||||
|
use App\Enum\LeaveRuleCode;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class EmployeeLeaveBalanceFixtures extends Fixture implements DependentFixtureInterface
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$this->createOpeningBalance(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_STANDARD, Employee::class),
|
||||||
|
LeaveRuleCode::CDI_CDD_NON_FORFAIT,
|
||||||
|
2026,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createOpeningBalance(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_4H, Employee::class),
|
||||||
|
LeaveRuleCode::CDI_CDD_NON_FORFAIT,
|
||||||
|
2026,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->createOpeningBalance(
|
||||||
|
$manager,
|
||||||
|
$this->getReference(FixtureReferences::EMPLOYEE_FORFAIT, Employee::class),
|
||||||
|
LeaveRuleCode::FORFAIT_218,
|
||||||
|
2026,
|
||||||
|
0.0,
|
||||||
|
0.0
|
||||||
|
);
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
EmployeeFixtures::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createOpeningBalance(
|
||||||
|
ObjectManager $manager,
|
||||||
|
Employee $employee,
|
||||||
|
LeaveRuleCode $ruleCode,
|
||||||
|
int $year,
|
||||||
|
float $openingDays,
|
||||||
|
float $openingSaturdays
|
||||||
|
): void {
|
||||||
|
$balance = new EmployeeLeaveBalance()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setRuleCode($ruleCode)
|
||||||
|
->setYear($year)
|
||||||
|
->setOpeningDays($openingDays)
|
||||||
|
->setOpeningSaturdays($openingSaturdays)
|
||||||
|
->setAccruedDays(0.0)
|
||||||
|
->setAccruedSaturdays(0.0)
|
||||||
|
->setTakenDays(0.0)
|
||||||
|
->setTakenSaturdays(0.0)
|
||||||
|
->setClosingDays($openingDays)
|
||||||
|
->setClosingSaturdays($openingSaturdays)
|
||||||
|
->setIsLocked(false)
|
||||||
|
;
|
||||||
|
|
||||||
|
$manager->persist($balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/DataFixtures/FixtureReferences.php
Normal file
31
src/DataFixtures/FixtureReferences.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
final class FixtureReferences
|
||||||
|
{
|
||||||
|
public const SITE_MAIN = 'site.main';
|
||||||
|
|
||||||
|
public const CONTRACT_35 = 'contract.35h';
|
||||||
|
public const CONTRACT_4H = 'contract.4h';
|
||||||
|
public const CONTRACT_FORFAIT = 'contract.forfait';
|
||||||
|
public const CONTRACT_INTERIM = 'contract.interim';
|
||||||
|
|
||||||
|
public const ABSENCE_TYPE_RTT = 'absence_type.rtt';
|
||||||
|
public const ABSENCE_TYPE_CONGE = 'absence_type.conge';
|
||||||
|
public const ABSENCE_TYPE_MALADIE = 'absence_type.maladie';
|
||||||
|
public const ABSENCE_TYPE_AT = 'absence_type.at';
|
||||||
|
public const ABSENCE_TYPE_FORMATION = 'absence_type.formation';
|
||||||
|
public const ABSENCE_TYPE_EXCEPTIONNEL = 'absence_type.exceptionnel';
|
||||||
|
public const ABSENCE_TYPE_ABSENT = 'absence_type.absent';
|
||||||
|
public const ABSENCE_TYPE_AUTRE = 'absence_type.autre';
|
||||||
|
|
||||||
|
public const EMPLOYEE_STANDARD = 'employee.standard_non_forfait';
|
||||||
|
public const EMPLOYEE_4H = 'employee.four_hours';
|
||||||
|
public const EMPLOYEE_FORFAIT = 'employee.forfait';
|
||||||
|
public const EMPLOYEE_INTERIM = 'employee.interim';
|
||||||
|
|
||||||
|
public const USER_ADMIN_EMILIE = 'user.admin.emilie';
|
||||||
|
}
|
||||||
26
src/DataFixtures/SiteFixtures.php
Normal file
26
src/DataFixtures/SiteFixtures.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\Site;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class SiteFixtures extends Fixture
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$site = new Site()
|
||||||
|
->setName('SITE TEST')
|
||||||
|
->setColor('#75aed9')
|
||||||
|
->setDisplayOrder(1)
|
||||||
|
;
|
||||||
|
|
||||||
|
$manager->persist($site);
|
||||||
|
$manager->flush();
|
||||||
|
|
||||||
|
$this->addReference(FixtureReferences::SITE_MAIN, $site);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/DataFixtures/UserFixtures.php
Normal file
26
src/DataFixtures/UserFixtures.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DataFixtures;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
|
||||||
|
final class UserFixtures extends Fixture
|
||||||
|
{
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
$emilie = new User()
|
||||||
|
->setUsername('emilie')
|
||||||
|
->setRoles(['ROLE_ADMIN'])
|
||||||
|
->setPassword('$2y$13$swd6WU4z7Z4MeZwl9hHaFez8xB/GMZxyrCDQUlFEY55JQUzTW0bPO')
|
||||||
|
;
|
||||||
|
|
||||||
|
$manager->persist($emilie);
|
||||||
|
$manager->flush();
|
||||||
|
|
||||||
|
$this->addReference(FixtureReferences::USER_ADMIN_EMILIE, $emilie);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Dto/Employees/ContractHistoryItem.php
Normal file
27
src/Dto/Employees/ContractHistoryItem.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Employees;
|
||||||
|
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
final class ContractHistoryItem
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?int $contractId,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?string $contractName,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?float $weeklyHours,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public string $contractNature,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public string $startDate,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?string $endDate,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?string $comment = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
src/Dto/Rtt/EmployeeRttWeekSummary.php
Normal file
16
src/Dto/Rtt/EmployeeRttWeekSummary.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Rtt;
|
||||||
|
|
||||||
|
final class EmployeeRttWeekSummary
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $weekNumber,
|
||||||
|
public string $weekStart,
|
||||||
|
public string $weekEnd,
|
||||||
|
public int $recoveryMinutes,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
14
src/Dto/Rtt/RttMonthPayment.php
Normal file
14
src/Dto/Rtt/RttMonthPayment.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Rtt;
|
||||||
|
|
||||||
|
final class RttMonthPayment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $paidMinutes25 = 0,
|
||||||
|
public int $paidMinutes50 = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
|
||||||
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
|
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
|
||||||
#[ORM\Table(name: 'absences')]
|
#[ORM\Table(name: 'absences')]
|
||||||
class Absence
|
class Absence
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Dto\Employees\ContractHistoryItem;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\State\EmployeeWriteProcessor;
|
use App\State\EmployeeWriteProcessor;
|
||||||
@@ -13,7 +14,9 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||||
@@ -56,6 +59,11 @@ class Employee
|
|||||||
#[Groups(['employee:read', 'employee:write'])]
|
#[Groups(['employee:read', 'employee:write'])]
|
||||||
private int $displayOrder = 0;
|
private int $displayOrder = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
|
private ?DateTimeImmutable $entryDate = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
@@ -74,6 +82,12 @@ class Employee
|
|||||||
#[Groups(['employee:write'])]
|
#[Groups(['employee:write'])]
|
||||||
private ?string $contractEndDate = null;
|
private ?string $contractEndDate = null;
|
||||||
|
|
||||||
|
#[Groups(['employee:write'])]
|
||||||
|
private ?bool $contractPaidLeaveSettled = null;
|
||||||
|
|
||||||
|
#[Groups(['employee:write'])]
|
||||||
|
private ?string $contractComment = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
@@ -109,6 +123,15 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getInitials(): string
|
||||||
|
{
|
||||||
|
$first = mb_strtoupper(mb_substr(trim($this->firstName), 0, 1));
|
||||||
|
$last = mb_strtoupper(mb_substr(trim($this->lastName), 0, 1));
|
||||||
|
|
||||||
|
return $first.$last;
|
||||||
|
}
|
||||||
|
|
||||||
public function getSite(): ?Site
|
public function getSite(): ?Site
|
||||||
{
|
{
|
||||||
return $this->site;
|
return $this->site;
|
||||||
@@ -150,6 +173,18 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEntryDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->entryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEntryDate(?DateTimeImmutable $entryDate): self
|
||||||
|
{
|
||||||
|
$this->entryDate = $entryDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getContractNature(): ?string
|
public function getContractNature(): ?string
|
||||||
{
|
{
|
||||||
return $this->contractNature;
|
return $this->contractNature;
|
||||||
@@ -186,6 +221,30 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getContractPaidLeaveSettled(): ?bool
|
||||||
|
{
|
||||||
|
return $this->contractPaidLeaveSettled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContractPaidLeaveSettled(?bool $contractPaidLeaveSettled): self
|
||||||
|
{
|
||||||
|
$this->contractPaidLeaveSettled = $contractPaidLeaveSettled;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContractComment(): ?string
|
||||||
|
{
|
||||||
|
return $this->contractComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContractComment(?string $contractComment): self
|
||||||
|
{
|
||||||
|
$this->contractComment = $contractComment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public function getCurrentContractNature(): string
|
public function getCurrentContractNature(): string
|
||||||
{
|
{
|
||||||
@@ -204,6 +263,36 @@ class Employee
|
|||||||
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
|
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ContractHistoryItem>
|
||||||
|
*/
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getContractHistory(): array
|
||||||
|
{
|
||||||
|
$periods = $this->contractPeriods->toArray();
|
||||||
|
usort(
|
||||||
|
$periods,
|
||||||
|
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $b->getStartDate() <=> $a->getStartDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static function (EmployeeContractPeriod $period): ContractHistoryItem {
|
||||||
|
$contract = $period->getContract();
|
||||||
|
|
||||||
|
return new ContractHistoryItem(
|
||||||
|
contractId: $contract?->getId(),
|
||||||
|
contractName: $contract?->getName(),
|
||||||
|
weeklyHours: $contract?->getWeeklyHours(),
|
||||||
|
contractNature: $period->getContractNatureEnum()->value,
|
||||||
|
startDate: $period->getStartDate()->format('Y-m-d'),
|
||||||
|
endDate: $period->getEndDate()?->format('Y-m-d'),
|
||||||
|
comment: $period->getComment(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
$periods
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
||||||
{
|
{
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ 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 $paidLeaveSettled = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $comment = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
@@ -121,4 +127,28 @@ class EmployeeContractPeriod
|
|||||||
{
|
{
|
||||||
return $this->createdAt;
|
return $this->createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isPaidLeaveSettled(): bool
|
||||||
|
{
|
||||||
|
return $this->paidLeaveSettled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPaidLeaveSettled(bool $paidLeaveSettled): self
|
||||||
|
{
|
||||||
|
$this->paidLeaveSettled = $paidLeaveSettled;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComment(): ?string
|
||||||
|
{
|
||||||
|
return $this->comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setComment(?string $comment): self
|
||||||
|
{
|
||||||
|
$this->comment = $comment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
253
src/Entity/EmployeeLeaveBalance.php
Normal file
253
src/Entity/EmployeeLeaveBalance.php
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Enum\LeaveRuleCode;
|
||||||
|
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeLeaveBalanceRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_leave_balances', options: ['comment' => 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'])]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_employee_leave_balance', columns: ['employee_id', 'rule_code', 'year'])]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_leave_balance_employee_year')]
|
||||||
|
class EmployeeLeaveBalance
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 64, options: ['comment' => 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'])]
|
||||||
|
private string $ruleCode = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice de reference (ex: 2026).'])]
|
||||||
|
private int $year = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Report N-1 en jours a l ouverture de l exercice.'])]
|
||||||
|
private float $openingDays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Report N-1 en samedis a l ouverture (0 pour forfait).'])]
|
||||||
|
private float $openingSaturdays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Droits jours acquis sur l exercice courant.'])]
|
||||||
|
private float $accruedDays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Droits samedis acquis sur l exercice courant.'])]
|
||||||
|
private float $accruedSaturdays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Jours de conges consommes sur l exercice.'])]
|
||||||
|
private float $takenDays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Samedis consommes sur l exercice.'])]
|
||||||
|
private float $takenSaturdays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Solde de cloture jours sur l exercice.'])]
|
||||||
|
private float $closingDays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['comment' => 'Solde de cloture samedis sur l exercice.'])]
|
||||||
|
private float $closingSaturdays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de fractionnement saisis par la RH.'])]
|
||||||
|
private float $fractionedDays = 0.0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde de l exercice est fige (verrouille RH).'])]
|
||||||
|
private bool $isLocked = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getRuleCode(): string
|
||||||
|
{
|
||||||
|
return $this->ruleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRuleCode(LeaveRuleCode|string $ruleCode): self
|
||||||
|
{
|
||||||
|
$this->ruleCode = $ruleCode instanceof LeaveRuleCode ? $ruleCode->value : $ruleCode;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getYear(): int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(int $year): self
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpeningDays(): float
|
||||||
|
{
|
||||||
|
return $this->openingDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpeningDays(float $openingDays): self
|
||||||
|
{
|
||||||
|
$this->openingDays = $openingDays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpeningSaturdays(): float
|
||||||
|
{
|
||||||
|
return $this->openingSaturdays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpeningSaturdays(float $openingSaturdays): self
|
||||||
|
{
|
||||||
|
$this->openingSaturdays = $openingSaturdays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAccruedDays(): float
|
||||||
|
{
|
||||||
|
return $this->accruedDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAccruedDays(float $accruedDays): self
|
||||||
|
{
|
||||||
|
$this->accruedDays = $accruedDays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAccruedSaturdays(): float
|
||||||
|
{
|
||||||
|
return $this->accruedSaturdays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAccruedSaturdays(float $accruedSaturdays): self
|
||||||
|
{
|
||||||
|
$this->accruedSaturdays = $accruedSaturdays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTakenDays(): float
|
||||||
|
{
|
||||||
|
return $this->takenDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTakenDays(float $takenDays): self
|
||||||
|
{
|
||||||
|
$this->takenDays = $takenDays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTakenSaturdays(): float
|
||||||
|
{
|
||||||
|
return $this->takenSaturdays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTakenSaturdays(float $takenSaturdays): self
|
||||||
|
{
|
||||||
|
$this->takenSaturdays = $takenSaturdays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClosingDays(): float
|
||||||
|
{
|
||||||
|
return $this->closingDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setClosingDays(float $closingDays): self
|
||||||
|
{
|
||||||
|
$this->closingDays = $closingDays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClosingSaturdays(): float
|
||||||
|
{
|
||||||
|
return $this->closingSaturdays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setClosingSaturdays(float $closingSaturdays): self
|
||||||
|
{
|
||||||
|
$this->closingSaturdays = $closingSaturdays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFractionedDays(): float
|
||||||
|
{
|
||||||
|
return $this->fractionedDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFractionedDays(float $fractionedDays): self
|
||||||
|
{
|
||||||
|
$this->fractionedDays = $fractionedDays;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLocked(): bool
|
||||||
|
{
|
||||||
|
return $this->isLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsLocked(bool $isLocked): self
|
||||||
|
{
|
||||||
|
$this->isLocked = $isLocked;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touch(): self
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/Entity/EmployeeRttBalance.php
Normal file
117
src/Entity/EmployeeRttBalance.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeRttBalanceRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_rtt_balances', options: ['comment' => 'Soldes RTT par employe et exercice (report N-1).'])]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_employee_rtt_balance', columns: ['employee_id', 'year'])]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_balance_employee_year')]
|
||||||
|
class EmployeeRttBalance
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[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;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])]
|
||||||
|
private int $openingMinutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])]
|
||||||
|
private bool $isLocked = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getYear(): int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(int $year): self
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOpeningMinutes(): int
|
||||||
|
{
|
||||||
|
return $this->openingMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOpeningMinutes(int $openingMinutes): self
|
||||||
|
{
|
||||||
|
$this->openingMinutes = $openingMinutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLocked(): bool
|
||||||
|
{
|
||||||
|
return $this->isLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsLocked(bool $isLocked): self
|
||||||
|
{
|
||||||
|
$this->isLocked = $isLocked;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touch(): self
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/Entity/EmployeeRttPayment.php
Normal file
131
src/Entity/EmployeeRttPayment.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeRttPaymentRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_rtt_payments', options: ['comment' => 'Paiements RTT par employe, mois et exercice.'])]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
|
||||||
|
class EmployeeRttPayment
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice.'])]
|
||||||
|
private int $year = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
|
||||||
|
private int $month = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])]
|
||||||
|
private int $minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])]
|
||||||
|
private string $rate = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getYear(): int
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(int $year): self
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonth(): int
|
||||||
|
{
|
||||||
|
return $this->month;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMonth(int $month): self
|
||||||
|
{
|
||||||
|
$this->month = $month;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMinutes(): int
|
||||||
|
{
|
||||||
|
return $this->minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMinutes(int $minutes): self
|
||||||
|
{
|
||||||
|
$this->minutes = $minutes;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRate(): string
|
||||||
|
{
|
||||||
|
return $this->rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRate(string $rate): self
|
||||||
|
{
|
||||||
|
$this->rate = $rate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touch(): self
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/Entity/Notification.php
Normal file
196
src/Entity/Notification.php
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use App\State\MarkAllNotificationsReadProcessor;
|
||||||
|
use App\State\NotificationHistoryProvider;
|
||||||
|
use App\State\NotificationTodayProvider;
|
||||||
|
use App\State\UnreadNotificationsProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/notifications/{id}',
|
||||||
|
uriVariables: ['id'],
|
||||||
|
requirements: ['id' => '\d+'],
|
||||||
|
normalizationContext: ['groups' => ['notification:read']],
|
||||||
|
security: "is_granted('ROLE_USER')"
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/notifications/unread',
|
||||||
|
normalizationContext: ['groups' => ['notification:read']],
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: UnreadNotificationsProvider::class,
|
||||||
|
paginationEnabled: false
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/notifications/today',
|
||||||
|
normalizationContext: ['groups' => ['notification:read']],
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: NotificationTodayProvider::class,
|
||||||
|
paginationEnabled: false
|
||||||
|
),
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/notifications/history',
|
||||||
|
normalizationContext: ['groups' => ['notification:read']],
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: NotificationHistoryProvider::class,
|
||||||
|
paginationEnabled: false
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/notifications/mark-all-read',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
input: false,
|
||||||
|
output: false,
|
||||||
|
read: false,
|
||||||
|
processor: MarkAllNotificationsReadProcessor::class
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
|
||||||
|
#[ORM\Table(name: 'notifications')]
|
||||||
|
#[ORM\Index(columns: ['recipient_id', 'is_read', 'created_at'], name: 'idx_notifications_recipient_read_created')]
|
||||||
|
class Notification
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?User $recipient = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $actor = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private string $message = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 60, options: ['default' => ''])]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private string $category = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, options: ['default' => ''])]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private string $target = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private bool $isRead = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRecipient(): ?User
|
||||||
|
{
|
||||||
|
return $this->recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRecipient(?User $recipient): self
|
||||||
|
{
|
||||||
|
$this->recipient = $recipient;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActor(): ?User
|
||||||
|
{
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActor(?User $actor): self
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['notification:read'])]
|
||||||
|
public function getActorName(): string
|
||||||
|
{
|
||||||
|
return $this->actor?->getUsername() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(): string
|
||||||
|
{
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMessage(string $message): self
|
||||||
|
{
|
||||||
|
$this->message = $message;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategory(): string
|
||||||
|
{
|
||||||
|
return $this->category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCategory(string $category): self
|
||||||
|
{
|
||||||
|
$this->category = $category;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTarget(): string
|
||||||
|
{
|
||||||
|
return $this->target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTarget(string $target): self
|
||||||
|
{
|
||||||
|
$this->target = $target;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRead(): bool
|
||||||
|
{
|
||||||
|
return $this->isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsRead(): bool
|
||||||
|
{
|
||||||
|
return $this->isRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsRead(bool $isRead): self
|
||||||
|
{
|
||||||
|
$this->isRead = $isRead;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
use App\State\CurrentUserProvider;
|
use App\State\CurrentUserProvider;
|
||||||
use App\State\UserPasswordHasherProcessor;
|
use App\State\UserPasswordHasherProcessor;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -52,7 +53,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
#[ORM\Table(name: 'users')]
|
#[ORM\Table(name: 'users')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
|
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\State\WorkHourSiteValidationProcessor;
|
use App\State\WorkHourSiteValidationProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -106,6 +107,10 @@ class WorkHour
|
|||||||
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
|
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
|
||||||
private bool $isSiteValid = false;
|
private bool $isSiteValid = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private ?DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -274,4 +279,16 @@ class WorkHour
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(?DateTimeImmutable $updatedAt): self
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/Enum/LeaveRuleCode.php
Normal file
12
src/Enum/LeaveRuleCode.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
enum LeaveRuleCode: string
|
||||||
|
{
|
||||||
|
case UNSUPPORTED = 'UNSUPPORTED';
|
||||||
|
case CDI_CDD_NON_FORFAIT = 'CDI_CDD_NON_FORFAIT';
|
||||||
|
case FORFAIT_218 = 'FORFAIT_218';
|
||||||
|
}
|
||||||
@@ -99,4 +99,29 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence
|
|||||||
// @var list<Absence> $absences
|
// @var list<Absence> $absences
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Absence>
|
||||||
|
*/
|
||||||
|
public function findByEmployeeAndOverlappingDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array
|
||||||
|
{
|
||||||
|
$fromDate = DateTimeImmutable::createFromInterface($from);
|
||||||
|
$toDate = DateTimeImmutable::createFromInterface($to);
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('a')
|
||||||
|
->leftJoin('a.employee', 'e')
|
||||||
|
->leftJoin('a.type', 't')
|
||||||
|
->addSelect('e', 't')
|
||||||
|
->andWhere('a.employee = :employee')
|
||||||
|
->andWhere('a.startDate <= :to')
|
||||||
|
->andWhere('a.endDate >= :from')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('from', $fromDate)
|
||||||
|
->setParameter('to', $toDate)
|
||||||
|
->orderBy('a.startDate', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
// @var list<Absence> $absences
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository\Contract;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
interface EmployeeContractPeriodReadRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Repository;
|
|||||||
|
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\EmployeeContractPeriod;
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
@@ -13,7 +14,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<EmployeeContractPeriod>
|
* @extends ServiceEntityRepository<EmployeeContractPeriod>
|
||||||
*/
|
*/
|
||||||
final class EmployeeContractPeriodRepository extends ServiceEntityRepository
|
final class EmployeeContractPeriodRepository extends ServiceEntityRepository implements EmployeeContractPeriodReadRepositoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
@@ -72,4 +73,56 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository
|
|||||||
->execute()
|
->execute()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasPaidLeaveSettledClosureBetween(
|
||||||
|
Employee $employee,
|
||||||
|
DateTimeImmutable $from,
|
||||||
|
DateTimeImmutable $to
|
||||||
|
): bool {
|
||||||
|
$count = $this->createQueryBuilder('p')
|
||||||
|
->select('COUNT(p.id)')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.paidLeaveSettled = :paidLeaveSettled')
|
||||||
|
->andWhere('p.endDate IS NOT NULL')
|
||||||
|
->andWhere('p.endDate >= :from')
|
||||||
|
->andWhere('p.endDate <= :to')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('paidLeaveSettled', true)
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return (int) $count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findLatestPaidLeaveSettledClosureDateBetween(
|
||||||
|
Employee $employee,
|
||||||
|
DateTimeImmutable $from,
|
||||||
|
DateTimeImmutable $to
|
||||||
|
): ?DateTimeImmutable {
|
||||||
|
$result = $this->createQueryBuilder('p')
|
||||||
|
->select('p.endDate AS endDate')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.paidLeaveSettled = :paidLeaveSettled')
|
||||||
|
->andWhere('p.endDate IS NOT NULL')
|
||||||
|
->andWhere('p.endDate >= :from')
|
||||||
|
->andWhere('p.endDate <= :to')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('paidLeaveSettled', true)
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->orderBy('p.endDate', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
if (!is_array($result) || !isset($result['endDate']) || !$result['endDate'] instanceof DateTimeImmutable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result['endDate'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/Repository/EmployeeLeaveBalanceRepository.php
Normal file
59
src/Repository/EmployeeLeaveBalanceRepository.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeLeaveBalance;
|
||||||
|
use App\Enum\LeaveRuleCode;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeLeaveBalance>
|
||||||
|
*/
|
||||||
|
final class EmployeeLeaveBalanceRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeLeaveBalance::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneByEmployeeRuleAndYear(
|
||||||
|
Employee $employee,
|
||||||
|
LeaveRuleCode|string $ruleCode,
|
||||||
|
int $year
|
||||||
|
): ?EmployeeLeaveBalance {
|
||||||
|
$ruleCodeValue = $ruleCode instanceof LeaveRuleCode ? $ruleCode->value : $ruleCode;
|
||||||
|
|
||||||
|
return $this->createQueryBuilder('b')
|
||||||
|
->andWhere('b.employee = :employee')
|
||||||
|
->andWhere('b.ruleCode = :ruleCode')
|
||||||
|
->andWhere('b.year = :year')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('ruleCode', $ruleCodeValue)
|
||||||
|
->setParameter('year', $year)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findEarliestYearForEmployee(Employee $employee): ?int
|
||||||
|
{
|
||||||
|
$result = $this->createQueryBuilder('b')
|
||||||
|
->select('MIN(b.year) AS year')
|
||||||
|
->andWhere('b.employee = :employee')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
if (!is_array($result) || !array_key_exists('year', $result) || null === $result['year']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $result['year'];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Repository/EmployeeRttBalanceRepository.php
Normal file
34
src/Repository/EmployeeRttBalanceRepository.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttBalance;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeRttBalance>
|
||||||
|
*/
|
||||||
|
final class EmployeeRttBalanceRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeRttBalance::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneByEmployeeAndYear(Employee $employee, int $year): ?EmployeeRttBalance
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('b')
|
||||||
|
->andWhere('b.employee = :employee')
|
||||||
|
->andWhere('b.year = :year')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('year', $year)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Repository/EmployeeRttPaymentRepository.php
Normal file
47
src/Repository/EmployeeRttPaymentRepository.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||||
|
*/
|
||||||
|
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeRttPayment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneByEmployeeYearMonthRate(Employee $employee, int $year, int $month, string $rate): ?EmployeeRttPayment
|
||||||
|
{
|
||||||
|
return $this->findOneBy([
|
||||||
|
'employee' => $employee,
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'rate' => $rate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return EmployeeRttPayment[]
|
||||||
|
*/
|
||||||
|
public function findByEmployeeAndYear(Employee $employee, int $year): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.year = :year')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('year', $year)
|
||||||
|
->addOrderBy('p.month', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user