Compare commits
2 Commits
feat/contr
...
3f98fadfa4
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f98fadfa4 | |||
| 16215bce45 |
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"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:*)",
|
||||
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(python3:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.git
|
||||
.gitea
|
||||
.env.local
|
||||
.env.test
|
||||
docker/
|
||||
!docker/php/config/php.ini
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
frontend/.output
|
||||
var/
|
||||
LOG/
|
||||
docs/
|
||||
doc/
|
||||
tests/
|
||||
*.sql
|
||||
*.xlsx
|
||||
*.png
|
||||
*.md
|
||||
!composer.lock
|
||||
!symfony.lock
|
||||
!frontend/package-lock.json
|
||||
9
.env
9
.env
@@ -36,13 +36,6 @@ DEFAULT_URI=http://localhost
|
||||
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> app ###
|
||||
RTT_START_DATE=2026-02-23
|
||||
# Comma-separated list of public holiday labels to exclude from the government API response
|
||||
# (typically the "journée de solidarité" worked in many companies)
|
||||
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
|
||||
###< app ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
@@ -53,6 +46,4 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=9efb9a2ec48439c723621d0c6393d04da5516c8fa00ecdba1660717b4f996867
|
||||
JWT_COOKIE_SECURE=0
|
||||
JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
|
||||
name: "Merge Request"
|
||||
about: "Template de MR"
|
||||
title: "[#NUMERO_TICKET] TITRE TICKET"
|
||||
ref: "main"
|
||||
|
||||
---
|
||||
|
||||
| Numéro du ticket | Titre du ticket |
|
||||
|------------------|-----------------|
|
||||
| | |
|
||||
|
||||
## Description de la PR
|
||||
|
||||
## Modification du .env
|
||||
|
||||
## Check list
|
||||
|
||||
- [ ] Pas de régression
|
||||
- [ ] TU/TI/TF rédigée
|
||||
- [ ] TU/TI/TF OK
|
||||
- [ ] CHANGELOG modifié
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Auto Tag Develop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Create next tag from config/version.yaml
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Skip if current commit already has a vX.Y.Z tag
|
||||
if git tag --points-at HEAD | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Tag already exists on this commit. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
changed_version=false
|
||||
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
|
||||
changed_version=true
|
||||
fi
|
||||
|
||||
read_version() {
|
||||
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
|
||||
}
|
||||
|
||||
if $changed_version; then
|
||||
version="$(read_version)"
|
||||
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid version in version.yaml: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
last_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)"
|
||||
if [ -z "$last_tag" ]; then
|
||||
version="0.1.0"
|
||||
else
|
||||
base="${last_tag#v}"
|
||||
IFS='.' read -r major minor patch <<< "$base"
|
||||
version="${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
|
||||
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@local"
|
||||
git add config/version.yaml
|
||||
git commit -m "chore: bump version to v$version" || true
|
||||
git push origin develop || true
|
||||
fi
|
||||
|
||||
tag="v$version"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-f deploy/docker/Dockerfile.prod \
|
||||
-t gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/sirh:latest \
|
||||
.
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }}
|
||||
docker push gitea.malio.fr/malio-dev/sirh:latest
|
||||
20
.idea/SIRH.iml
generated
20
.idea/SIRH.iml
generated
@@ -136,26 +136,6 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/willdurand/negotiation" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/dompdf" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-font-lib" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-svg-lib" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/LOG" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/frontend/.nuxt" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/frontend/.output" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/frontend/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/public" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/var" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="postgres@localhost" uuid="9cad43df-2147-4989-b7a4-443067034884">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5433/postgres</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/data_source_mapping.xml
generated
Normal file
6
.idea/data_source_mapping.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourcePerFileMappings">
|
||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
|
||||
</component>
|
||||
</project>
|
||||
16
.idea/db-forest-config.xml
generated
16
.idea/db-forest-config.xml
generated
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-forest-configuration">
|
||||
<data version="2">.
|
||||
----------------------------------------
|
||||
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
|
||||
5:0:9d8c1ad3-2491-4642-964a-666003c14128
|
||||
.</data>
|
||||
</component>
|
||||
<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 4:0:09e221b8-067a-488b-9c1d-4e155a333079 " />
|
||||
</component>
|
||||
</project>
|
||||
20
.idea/material_theme_project_new.xml
generated
20
.idea/material_theme_project_new.xml
generated
@@ -1,12 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="migrated" value="true" />
|
||||
<option name="pristineConfig" value="false" />
|
||||
<option name="userId" value="-3bc0fa3e:19bc6e06872:-7ff9" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="userId" value="-7cf7a629:19c1e9ce3f8:-7e79" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
333
.idea/php.xml
generated
333
.idea/php.xml
generated
@@ -1,174 +1,161 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MessDetectorOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCSFixerOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
||||
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
||||
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
|
||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
|
||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
|
||||
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
|
||||
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
|
||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpUnit">
|
||||
<phpunit_settings>
|
||||
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
|
||||
</phpunit_settings>
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MessDetectorOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCSFixerOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||
<option name="highlightLevel" value="WARNING" />
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpIncludePathManager">
|
||||
<include_path>
|
||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
||||
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
||||
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
||||
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
|
||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
|
||||
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
|
||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||
</include_path>
|
||||
</component>
|
||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||
<component name="PhpStanOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
<component name="PhpUnit">
|
||||
<phpunit_settings>
|
||||
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
|
||||
</phpunit_settings>
|
||||
</component>
|
||||
<component name="PsalmOptionsConfiguration">
|
||||
<option name="transferred" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
138
AGENTS.md
138
AGENTS.md
@@ -1,138 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
État des lieux opérationnel du projet SIRH (backend + frontend), mis à jour après les évolutions sur heures/absences/validations.
|
||||
|
||||
## 1) Stack et structure
|
||||
|
||||
- Backend: Symfony + API Platform + Doctrine ORM
|
||||
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind
|
||||
- Exécution locale: Docker via `makefile`
|
||||
|
||||
Arborescence clé:
|
||||
- `src/`: domaine, API resources, state providers/processors, services
|
||||
- `tests/`: TU backend (PHPUnit)
|
||||
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
||||
- `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
|
||||
|
||||
- Démarrer stack: `make start`
|
||||
- Tests backend: `make test`
|
||||
- Build frontend: `cd frontend && npm run build`
|
||||
- Dev frontend: `make dev-nuxt`
|
||||
|
||||
## 3) Domaine métier (résumé)
|
||||
|
||||
### Contrats
|
||||
- Entité: `Contract`
|
||||
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`, `type`
|
||||
- `trackingMode`:
|
||||
- `TIME`: suivi en heures
|
||||
- `PRESENCE`: suivi en demi-journées/journées
|
||||
- Enums backend:
|
||||
- `App\Enum\TrackingMode`
|
||||
- `App\Enum\ContractType` (`FORFAIT`, `THIRTY_FIVE_HOURS`, `THIRTY_NINE_HOURS`, `INTERIM`, `CUSTOM`)
|
||||
- Historique de contrat par employé:
|
||||
- table `employee_contract_periods`
|
||||
- résolu par `App\Service\Contracts\EmployeeContractResolver`
|
||||
|
||||
### Heures / absences
|
||||
- Les absences sont stockées en **lignes journalières** (découpage automatique dans `AbsenceWriteProcessor`).
|
||||
- Les absences `countAsWorkedHours=true` créditent:
|
||||
- minutes (contrats TIME)
|
||||
- unités de présence (contrats PRESENCE)
|
||||
- Les absences AM/PM effacent les plages horaires concernées.
|
||||
|
||||
## 4) Validations (important)
|
||||
|
||||
### Validation RH (admin)
|
||||
- Champ: `work_hours.is_valid`
|
||||
- Endpoint API Platform standard: `PATCH /api/work_hours/{id}`
|
||||
- Gérée côté front par `updateWorkHourValidation`.
|
||||
|
||||
### Validation site (chef de site)
|
||||
- Champ: `work_hours.is_site_valid`
|
||||
- Endpoint dédié: `PATCH /api/work_hours/{id}/site-validation`
|
||||
- Processor: `src/State/WorkHourSiteValidationProcessor.php`
|
||||
- Autorisé uniquement aux utilisateurs "Sites" (ni `ROLE_ADMIN`, ni `ROLE_SELF`) dans leur scope site.
|
||||
|
||||
### Règles de verrouillage
|
||||
- `is_valid=true`: ligne verrouillée pour tout le monde (admin inclus pour saisie heures/absence; peut toujours décocher validation RH).
|
||||
- `is_site_valid=true`:
|
||||
- non-admin: ligne verrouillée (heures + absences)
|
||||
- admin: ligne éditable
|
||||
- Toute modification de ligne (heures/présence/absence) remet:
|
||||
- `is_site_valid=false`
|
||||
- `is_valid=false`
|
||||
|
||||
## 5) Page Heures (front)
|
||||
|
||||
- Page: `frontend/pages/hours.vue`
|
||||
- Composable principal: `frontend/composables/useHoursPage.ts`
|
||||
- Composants:
|
||||
- `frontend/components/hours/HoursToolbar.vue`
|
||||
- `frontend/components/hours/HoursDayView.vue`
|
||||
- `frontend/components/hours/HoursWeekView.vue`
|
||||
|
||||
### Comportement par profil (vue jour)
|
||||
- Admin:
|
||||
- colonne RH avec checkbox
|
||||
- badge `Site validé` affiché près du site
|
||||
- Chef de site:
|
||||
- colonne `Validation site` avec checkbox
|
||||
- colonne RH en lecture (`Validé`/`-`)
|
||||
- Employé:
|
||||
- colonne `Validation site` en lecture
|
||||
- colonne RH en lecture
|
||||
|
||||
## 6) Résumé hebdo / calculs
|
||||
|
||||
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
|
||||
- DTOs:
|
||||
- `src/Dto/WorkHours/WeeklySummaryRow.php`
|
||||
- `src/Dto/WorkHours/WeeklyDaySummary.php`
|
||||
- Inclut: contrat résolu par jour, absences, crédits, jour/nuit/total, majorations, récup.
|
||||
|
||||
Règles majorations:
|
||||
- Contrats <= 35h: +25% de 35h à 43h, +50% au-delà
|
||||
- Contrats >= 39h: +25% de 39h à 43h, +50% au-delà
|
||||
- `INTERIM`: pas de 25% / 50% / récup
|
||||
|
||||
## 7) Migrations sensibles
|
||||
|
||||
- `migrations/Version20260226183000.php`
|
||||
- ajoute `work_hours.is_site_valid BOOLEAN NOT NULL DEFAULT FALSE`
|
||||
- non destructive (pas de perte de données)
|
||||
|
||||
## 8) Points de vigilance prod
|
||||
|
||||
- Toujours exécuter migration avant déploiement code backend/front lié.
|
||||
- Après déploiement backend, si route manquante côté runtime:
|
||||
- `php bin/console cache:clear && php bin/console cache:warmup`
|
||||
- Vérifier présence route:
|
||||
- `/api/work_hours/{id}/site-validation` (PATCH)
|
||||
|
||||
## 9) Conventions techniques
|
||||
|
||||
- Favoriser DTO explicites plutôt que tableaux associatifs.
|
||||
- Garder règles métier dans backend (providers/processors/services), front orienté affichage/interaction.
|
||||
- Maintenir alignement backend DTO PHP / frontend DTO TS (`frontend/services/dto/*`).
|
||||
- Mettre à jour TU si signature constructor/service change.
|
||||
|
||||
## 10) Fichiers à lire avant modification
|
||||
|
||||
- `src/State/WorkHourBulkUpsertProcessor.php`
|
||||
- `src/State/AbsenceWriteProcessor.php`
|
||||
- `src/State/WorkHourSiteValidationProcessor.php`
|
||||
- `src/State/WorkHourWeeklySummaryProvider.php`
|
||||
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
|
||||
- `frontend/composables/useHoursPage.ts`
|
||||
- `frontend/components/hours/HoursDayView.vue`
|
||||
- `frontend/components/hours/HoursWeekView.vue`
|
||||
171
CLAUDE.md
171
CLAUDE.md
@@ -1,171 +0,0 @@
|
||||
# SIRH
|
||||
|
||||
## Mandatory Rules
|
||||
- Any functional change MUST update `doc/` in the same intervention
|
||||
- Any functional change MUST update the in-app documentation (`frontend/data/documentation-content.ts`) in the same intervention
|
||||
- At the end of every feature addition or functional modification, update this CLAUDE.md to reflect new patterns, rules, or conventions introduced
|
||||
|
||||
## Commands
|
||||
- `make start` — start Docker stack
|
||||
- `make test` — run backend tests (PHPUnit)
|
||||
- `make dev-nuxt` — dev frontend
|
||||
- `cd frontend && npm run build` — build frontend
|
||||
- `php bin/console cache:clear && php bin/console cache:warmup` — clear cache after deploy
|
||||
|
||||
## Stack
|
||||
- Backend: Symfony + API Platform + Doctrine ORM
|
||||
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
|
||||
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
|
||||
|
||||
## Project Structure
|
||||
- `src/` — Symfony domain, API resources, state providers/processors, services
|
||||
- `frontend/` — Nuxt app (pages, components, composables, services)
|
||||
- `migrations/` — Doctrine migrations (always include working `down()`)
|
||||
- `doc/` — functional rules and business documentation
|
||||
|
||||
## Functional Rules
|
||||
- Reference: `doc/functional-rules.md` (mandatory reading before any business logic change)
|
||||
- Complementary: `doc/leave-rollover.md`, `doc/rtt-rollover.md`
|
||||
|
||||
## Domain Model
|
||||
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
|
||||
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
|
||||
- Contract nature (per period): CDI, CDD, INTERIM
|
||||
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
|
||||
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
|
||||
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
|
||||
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`).
|
||||
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
|
||||
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
||||
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
|
||||
|
||||
## Fériés
|
||||
- Source : API gouv via `PublicHolidayService` (cache 30j)
|
||||
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
|
||||
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
|
||||
- Création/édition d'absence **autorisée** sur un férié (bouton Modifier visible). En présence d'absence, le crédit d'heures suit `absence.type.countAsWorkedHours` (WorkedHoursCreditPolicy), pas le crédit virtuel férié.
|
||||
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
|
||||
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
|
||||
|
||||
## Commentaires de semaine
|
||||
- Entité `EmployeeWeekComment` : commentaire libre par employé et semaine ISO (unique `(employee_id, week_start_date)`). `week_start_date` = lundi.
|
||||
- CRUD `/employee_week_comments` `ROLE_ADMIN`. Write processor audite via `AuditLogger`.
|
||||
- Picto bulle vue semaine (HoursWeekView + DriverHoursWeekView) : fond bleu/rouge. Intégré dans `WeeklySummaryRow.comment/commentId`.
|
||||
- Doc : `doc/week-comments.md`.
|
||||
|
||||
## Validation Rules
|
||||
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
|
||||
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
||||
- Any real modification resets both `isSiteValid=false` and `isValid=false`
|
||||
- No-op saves preserve existing validations
|
||||
|
||||
## Overtime Rules
|
||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
|
||||
- INTERIM: no overtime bonuses, no recovery time
|
||||
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
|
||||
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
|
||||
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
|
||||
|
||||
## Onglet Congés (fiche employé)
|
||||
- Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée.
|
||||
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)` — `floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
|
||||
- Changement d'année → recharge complète de l'onglet via `useEmployeeLeave.setSelectedLeaveYear(year)` (reload de `getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`). Backend : filtre `?year=YYYY` validé 2000-2100, et `EmployeeLeaveSummary` expose `dataStartDate` (env `RTT_START_DATE`, injecté via `services.yaml`).
|
||||
- Sur un exercice passé (`selectedYear !== currentYear`), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report.
|
||||
- Doc : `doc/leave-tab.md`.
|
||||
|
||||
## Onglet RTT (fiche employé)
|
||||
- Tableau hebdomadaire (`frontend/components/employees/RttTab.vue`) — exercice fixe Juin(N-1)→Mai(N). Onglet **masqué pour les FORFAIT** (`showRttTab`).
|
||||
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`. Format unique : `Juin 2025 → Mai 2026`.
|
||||
- Changement d'année → recharge via `useEmployeeRtt.setSelectedRttYear(year)` (`getEmployeeRttSummary?year=YYYY`). `EmployeeRttSummary.rttStartDate` est déjà exposé (champ existant) — il sert à la fois au floor du sélecteur et au masquage des lignes Report avant la mise en service.
|
||||
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
|
||||
- Doc : `doc/rtt-tab.md`.
|
||||
|
||||
## Vue contrat (sélecteur de phase)
|
||||
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
|
||||
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
||||
- **Filtre `RTT_START_DATE`** : les phases dont `endDate < RTT_START_DATE` sont masquées (aucune donnée logiciel avant la mise en service). Le resolver reçoit la date via DI (`services.yaml`) ; `Employee::getContractPhases()` lit `$_SERVER['RTT_START_DATE']` pour instancier le resolver côté entité.
|
||||
- Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante.
|
||||
- Sélectionner une phase passée :
|
||||
- Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur `phase.endDate`. **Cap `from` au `phase.startDate` uniquement pour FORFAIT** (sémantique année civile). Pour le non-forfait, l'exercice CP reste annuel et continu à travers les changements d'heures (35h→39h, etc.) — seul `resolveEffectivePeriodStart` clampe sur la date d'entrée en contrat des nouveaux embauchés.
|
||||
- Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. Tableau hebdo affiché sur l'exercice complet (Juin→Mai) ; `periodFrom` non capé sur `phase.startDate` (les semaines avant embauche ou hors phase apparaissent à 0). `periodTo`/`limitDate` capés sur `phase.endDate` pour les phases clôturées. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`.
|
||||
- Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée.
|
||||
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
|
||||
- CP : solder via `EmployeeContractPeriod.paidLeaveSettledClosureDate` (mécanisme existant). RTT : créer un `EmployeeRttPayment` sur le dernier exercice de la phase.
|
||||
- "Exercise year for date" mutualisé dans `App\Service\Exercise\ExerciseYearResolver` (forfait = année civile, non-forfait = Juin N-1 → Mai N).
|
||||
- Doc complète : `doc/contract-phase-view.md`.
|
||||
|
||||
## Récap. congés (écran)
|
||||
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
|
||||
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
|
||||
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante − 14j)`. Pas de gate `isValid`.
|
||||
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
|
||||
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
|
||||
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
|
||||
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
|
||||
- Pas d'export PDF depuis cet écran
|
||||
- Doc détaillée : `doc/leave-recap-screen.md`
|
||||
|
||||
## Frais (MileageAllowance)
|
||||
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
|
||||
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
|
||||
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
|
||||
|
||||
## Formations
|
||||
- Onglet "Formation" sur la fiche employé (admin uniquement)
|
||||
- Champs : date début, date fin, justificatif PDF optionnel, commentaire
|
||||
- Validation: dates obligatoires, `endDate >= startDate`, fichier PDF uniquement
|
||||
- Justificatif stocké dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf` (année/mois = startDate)
|
||||
- Suppression et remplacement du justificatif nettoient l'ancien fichier disque
|
||||
- Tri tableau par `startDate DESC`
|
||||
- Affichage écran Heures (jour) : pill "Formation" (indigo) dans la colonne Absence. Quand une formation existe, le bouton "Modifier" de la colonne Absence est masqué (lockdown complet du jour pour la gestion d'absence)
|
||||
- Affichage Calendrier : cellule "F" (indigo) si formation seule, ou icône école en coin si formation + absence. Cellules avec formation non cliquables. Légende dédiée. PDF export : code "F" indigo ou astérisque à côté du code d'absence
|
||||
- Le CRUD formation est exclusivement géré depuis la fiche employé > onglet Formation
|
||||
|
||||
## Frontend Patterns
|
||||
|
||||
### Table styling (standard across all pages)
|
||||
- Header: `grid border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`
|
||||
- Body wrapper: `border-x border-b border-primary-500 rounded-b-md`
|
||||
- Rows: `grid items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500`
|
||||
- Page wrapper for scroll: `h-full flex flex-col overflow-hidden`, table container: `min-h-0 overflow-auto rounded-md bg-white`
|
||||
|
||||
### Drawer buttons (AppDrawer)
|
||||
- Edit mode: `grid grid-cols-2 gap-3` → Supprimer (red, left) + Modifier (primary, right)
|
||||
- Create mode: centered `+ Ajouter` button, w-[200px]
|
||||
- Exception: Users drawer has NO delete button
|
||||
- All "Ajouter" buttons across the app use "+" prefix
|
||||
|
||||
### API Platform (backend)
|
||||
- Custom operations use Processor (write) / Provider (read)
|
||||
- File uploads: `deserialize: false` on Post, access file via RequestStack
|
||||
- Upload dir: `%kernel.project_dir%/var/uploads`
|
||||
|
||||
## Audit Logging
|
||||
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
|
||||
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically
|
||||
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
|
||||
- Documentation: `doc/audit-logging.md`
|
||||
|
||||
## Backend Conventions
|
||||
- Prefer explicit DTOs over associative arrays
|
||||
- Business rules in backend (providers/processors/services), frontend is display/interaction only
|
||||
- Keep backend PHP DTOs aligned with frontend TS DTOs (`frontend/services/dto/*`)
|
||||
- Update unit tests when constructor/service signatures change
|
||||
|
||||
## In-App Documentation
|
||||
- Content: `frontend/data/documentation-content.ts` — structured TypeScript data with all user-facing documentation
|
||||
- Types: `frontend/types/documentation.ts` — DocSection, DocArticle, DocBlock
|
||||
- Composable: `frontend/composables/useDocumentation.ts` — role-based filtering (employee < site_manager < admin)
|
||||
- Components: `frontend/components/documentation/` — DocumentationPage, DocumentationSection, DocumentationArticle
|
||||
- Page: `frontend/pages/documentation.vue`
|
||||
- 3 access levels: `employee` (ROLE_SELF), `site_manager` (ROLE_USER), `admin` (ROLE_ADMIN) — cumulative (admin sees everything)
|
||||
- Each section/article has a `requiredLevel` that controls visibility
|
||||
- When adding or modifying a feature, update the corresponding section in `documentation-content.ts`
|
||||
|
||||
## Language
|
||||
- UI is in French
|
||||
- User communicates in French
|
||||
- Code (variables, comments) in English
|
||||
37
README.md
37
README.md
@@ -1,39 +1,2 @@
|
||||
# SIRH
|
||||
|
||||
Application de gestion des absences employée
|
||||
|
||||
## Importer un dump de prod en dev
|
||||
Sur adminer fait un export bdd :
|
||||
- Sortie : enregistrer
|
||||
- Format : SQL
|
||||
- Tables : DROP+CREATE, Incrément automatique, Déclencheurs
|
||||
- Données : INSERT
|
||||
|
||||
Supprime la bdd et créer la bdd :
|
||||
```shell
|
||||
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
```
|
||||
|
||||
Remplie la base avec le dump :
|
||||
```shell
|
||||
docker compose exec -T db psql -U root -d sirh < sirh.sql
|
||||
```
|
||||
|
||||
## Mettre SUPER_ADMIN sur un user
|
||||
```sql
|
||||
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
|
||||
```
|
||||
|
||||
## Récupérer la bdd de prod en local
|
||||
|
||||
Sur le serveur de prod, créer le dump :
|
||||
```shell
|
||||
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
|
||||
```
|
||||
|
||||
En local, récupérer le fichier et l'importer (remplace `YYYY-MM-DD` par la date du dump) :
|
||||
```shell
|
||||
scp user@<serveur>:/tmp/sirh_prod_YYYY-MM-DD.sql ~/workspace/SIRH/sirh.sql
|
||||
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
docker compose exec -T db psql -U root -d sirh < ~/workspace/SIRH/sirh.sql
|
||||
```
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"doctrine/doctrine-bundle": "^3.2",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.6",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
@@ -24,8 +23,6 @@
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
@@ -88,7 +85,6 @@
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.93",
|
||||
"phpunit/phpunit": "^12.5"
|
||||
}
|
||||
|
||||
1038
composer.lock
generated
1038
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
|
||||
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
|
||||
@@ -22,6 +20,4 @@ return [
|
||||
NelmioCorsBundle::class => ['all' => true],
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
];
|
||||
|
||||
@@ -2,7 +2,6 @@ lexik_jwt_authentication:
|
||||
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
||||
remove_token_from_body_when_cookies_used: true
|
||||
token_extractors:
|
||||
authorization_header:
|
||||
@@ -14,7 +13,7 @@ lexik_jwt_authentication:
|
||||
enabled: false
|
||||
set_cookies:
|
||||
BEARER:
|
||||
lifetime: '%env(int:JWT_COOKIE_TTL)%'
|
||||
lifetime: 86400
|
||||
samesite: lax
|
||||
path: /
|
||||
secure: '%env(bool:JWT_COOKIE_SECURE)%'
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
monolog:
|
||||
channels: [deprecation, cron]
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
handlers:
|
||||
cron:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/cron.log"
|
||||
level: info
|
||||
channels: [cron]
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event", "!cron"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
cron:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/cron.log"
|
||||
level: info
|
||||
channels: [cron]
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!deprecation", "!cron"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: "%kernel.logs_dir%/deprecations.log"
|
||||
@@ -5,7 +5,7 @@ nelmio_cors:
|
||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||
allow_headers: ['Content-Type', 'Authorization']
|
||||
allow_credentials: true
|
||||
expose_headers: ['Link', 'Content-Disposition']
|
||||
expose_headers: ['Link']
|
||||
max_age: 3600
|
||||
paths:
|
||||
'^/': null
|
||||
|
||||
@@ -19,7 +19,6 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
json_login:
|
||||
check_path: /login_check
|
||||
username_path: username
|
||||
@@ -30,7 +29,6 @@ security:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
@@ -50,8 +48,6 @@ security:
|
||||
access_control:
|
||||
- { path: ^/login_check, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
# Version de l'application en public
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -1608,149 +1608,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app"
|
||||
* },
|
||||
* }
|
||||
* @psalm-type MonologConfig = array{
|
||||
* use_microseconds?: scalar|Param|null, // Default: true
|
||||
* channels?: list<scalar|Param|null>,
|
||||
* handlers?: array<string, array{ // Default: []
|
||||
* type: scalar|Param|null,
|
||||
* id?: scalar|Param|null,
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* priority?: scalar|Param|null, // Default: 0
|
||||
* level?: scalar|Param|null, // Default: "DEBUG"
|
||||
* bubble?: bool|Param, // Default: true
|
||||
* interactive_only?: bool|Param, // Default: false
|
||||
* app_name?: scalar|Param|null, // Default: null
|
||||
* include_stacktraces?: bool|Param, // Default: false
|
||||
* process_psr_3_messages?: array{
|
||||
* enabled?: bool|Param|null, // Default: null
|
||||
* date_format?: scalar|Param|null,
|
||||
* remove_used_context_fields?: bool|Param,
|
||||
* },
|
||||
* path?: scalar|Param|null, // Default: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
* file_permission?: scalar|Param|null, // Default: null
|
||||
* use_locking?: bool|Param, // Default: false
|
||||
* filename_format?: scalar|Param|null, // Default: "{filename}-{date}"
|
||||
* date_format?: scalar|Param|null, // Default: "Y-m-d"
|
||||
* ident?: scalar|Param|null, // Default: false
|
||||
* logopts?: scalar|Param|null, // Default: 1
|
||||
* facility?: scalar|Param|null, // Default: "user"
|
||||
* max_files?: scalar|Param|null, // Default: 0
|
||||
* action_level?: scalar|Param|null, // Default: "WARNING"
|
||||
* activation_strategy?: scalar|Param|null, // Default: null
|
||||
* stop_buffering?: bool|Param, // Default: true
|
||||
* passthru_level?: scalar|Param|null, // Default: null
|
||||
* excluded_http_codes?: list<array{ // Default: []
|
||||
* code?: scalar|Param|null,
|
||||
* urls?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* accepted_levels?: list<scalar|Param|null>,
|
||||
* min_level?: scalar|Param|null, // Default: "DEBUG"
|
||||
* max_level?: scalar|Param|null, // Default: "EMERGENCY"
|
||||
* buffer_size?: scalar|Param|null, // Default: 0
|
||||
* flush_on_overflow?: bool|Param, // Default: false
|
||||
* handler?: scalar|Param|null,
|
||||
* url?: scalar|Param|null,
|
||||
* exchange?: scalar|Param|null,
|
||||
* exchange_name?: scalar|Param|null, // Default: "log"
|
||||
* channel?: scalar|Param|null, // Default: null
|
||||
* bot_name?: scalar|Param|null, // Default: "Monolog"
|
||||
* use_attachment?: scalar|Param|null, // Default: true
|
||||
* use_short_attachment?: scalar|Param|null, // Default: false
|
||||
* include_extra?: scalar|Param|null, // Default: false
|
||||
* icon_emoji?: scalar|Param|null, // Default: null
|
||||
* webhook_url?: scalar|Param|null,
|
||||
* exclude_fields?: list<scalar|Param|null>,
|
||||
* token?: scalar|Param|null,
|
||||
* region?: scalar|Param|null,
|
||||
* source?: scalar|Param|null,
|
||||
* use_ssl?: bool|Param, // Default: true
|
||||
* user?: mixed,
|
||||
* title?: scalar|Param|null, // Default: null
|
||||
* host?: scalar|Param|null, // Default: null
|
||||
* port?: scalar|Param|null, // Default: 514
|
||||
* config?: list<scalar|Param|null>,
|
||||
* members?: list<scalar|Param|null>,
|
||||
* connection_string?: scalar|Param|null,
|
||||
* timeout?: scalar|Param|null,
|
||||
* time?: scalar|Param|null, // Default: 60
|
||||
* deduplication_level?: scalar|Param|null, // Default: 400
|
||||
* store?: scalar|Param|null, // Default: null
|
||||
* connection_timeout?: scalar|Param|null,
|
||||
* persistent?: bool|Param,
|
||||
* message_type?: scalar|Param|null, // Default: 0
|
||||
* parse_mode?: scalar|Param|null, // Default: null
|
||||
* disable_webpage_preview?: bool|Param|null, // Default: null
|
||||
* disable_notification?: bool|Param|null, // Default: null
|
||||
* split_long_messages?: bool|Param, // Default: false
|
||||
* delay_between_messages?: bool|Param, // Default: false
|
||||
* topic?: int|Param, // Default: null
|
||||
* factor?: int|Param, // Default: 1
|
||||
* tags?: list<scalar|Param|null>,
|
||||
* console_formatter_options?: mixed, // Default: []
|
||||
* formatter?: scalar|Param|null,
|
||||
* nested?: bool|Param, // Default: false
|
||||
* publisher?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* hostname?: scalar|Param|null,
|
||||
* port?: scalar|Param|null, // Default: 12201
|
||||
* chunk_size?: scalar|Param|null, // Default: 1420
|
||||
* encoder?: "json"|"compressed_json"|Param,
|
||||
* },
|
||||
* mongodb?: string|array{
|
||||
* id?: scalar|Param|null, // ID of a MongoDB\Client service
|
||||
* uri?: scalar|Param|null,
|
||||
* username?: scalar|Param|null,
|
||||
* password?: scalar|Param|null,
|
||||
* database?: scalar|Param|null, // Default: "monolog"
|
||||
* collection?: scalar|Param|null, // Default: "logs"
|
||||
* },
|
||||
* elasticsearch?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* hosts?: list<scalar|Param|null>,
|
||||
* host?: scalar|Param|null,
|
||||
* port?: scalar|Param|null, // Default: 9200
|
||||
* transport?: scalar|Param|null, // Default: "Http"
|
||||
* user?: scalar|Param|null, // Default: null
|
||||
* password?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* index?: scalar|Param|null, // Default: "monolog"
|
||||
* document_type?: scalar|Param|null, // Default: "logs"
|
||||
* ignore_error?: scalar|Param|null, // Default: false
|
||||
* redis?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* password?: scalar|Param|null, // Default: null
|
||||
* port?: scalar|Param|null, // Default: 6379
|
||||
* database?: scalar|Param|null, // Default: 0
|
||||
* key_name?: scalar|Param|null, // Default: "monolog_redis"
|
||||
* },
|
||||
* predis?: string|array{
|
||||
* id?: scalar|Param|null,
|
||||
* host?: scalar|Param|null,
|
||||
* },
|
||||
* from_email?: scalar|Param|null,
|
||||
* to_email?: list<scalar|Param|null>,
|
||||
* subject?: scalar|Param|null,
|
||||
* content_type?: scalar|Param|null, // Default: null
|
||||
* headers?: list<scalar|Param|null>,
|
||||
* mailer?: scalar|Param|null, // Default: null
|
||||
* email_prototype?: string|array{
|
||||
* id: scalar|Param|null,
|
||||
* method?: scalar|Param|null, // Default: null
|
||||
* },
|
||||
* verbosity_levels?: array{
|
||||
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
|
||||
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"
|
||||
* VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE"
|
||||
* VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO"
|
||||
* VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG"
|
||||
* },
|
||||
* channels?: string|array{
|
||||
* type?: scalar|Param|null,
|
||||
* elements?: list<scalar|Param|null>,
|
||||
* },
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1763,7 +1620,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1776,7 +1632,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1790,7 +1645,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1804,7 +1658,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
|
||||
imports:
|
||||
- { resource: version.yaml }
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
@@ -22,33 +19,5 @@ services:
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
App\Service\PublicHolidayService:
|
||||
arguments:
|
||||
$holidayUrl: '%env(HOLIDAY_URL)%'
|
||||
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
|
||||
|
||||
App\Service\Rtt\RttRecoveryComputationService:
|
||||
arguments:
|
||||
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\State\EmployeeRttSummaryProvider:
|
||||
arguments:
|
||||
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\State\EmployeeLeaveSummaryProvider:
|
||||
arguments:
|
||||
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\Service\Contracts\EmployeeContractPhaseResolver:
|
||||
arguments:
|
||||
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
|
||||
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
|
||||
App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
parameters:
|
||||
app.version: '0.1.101'
|
||||
@@ -1,26 +0,0 @@
|
||||
# Symfony
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=change-me
|
||||
|
||||
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
|
||||
DATABASE_URL="postgresql://sirh_user:password@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=change-me
|
||||
JWT_COOKIE_SECURE=1
|
||||
JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
|
||||
|
||||
# App
|
||||
DEFAULT_URI=https://sirh.malio-dev.fr
|
||||
APP_SHARE_DIR=var/share
|
||||
RTT_START_DATE=2026-02-23
|
||||
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
||||
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
|
||||
@@ -1,81 +0,0 @@
|
||||
# --- Stage 1: Build backend ---
|
||||
FROM php:8.4-cli AS backend-build
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
unzip curl git \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock symfony.lock ./
|
||||
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
|
||||
|
||||
COPY bin bin/
|
||||
COPY config config/
|
||||
COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
COPY templates templates/
|
||||
|
||||
# --- Stage 2: Build frontend ---
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
ENV CI=1 \
|
||||
NUXT_TELEMETRY_DISABLED=1 \
|
||||
NUXT_PUBLIC_API_BASE=/api \
|
||||
NUXT_PUBLIC_APP_BASE=/
|
||||
RUN npm run generate
|
||||
|
||||
# --- Stage 3: Production image ---
|
||||
FROM php:8.4-fpm AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
nginx supervisor \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# PHP production config
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini"
|
||||
|
||||
# PHP-FPM: forward worker output to stderr for docker logs
|
||||
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Nginx: log to stdout/stderr
|
||||
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||
&& ln -sf /dev/stderr /var/log/nginx/error.log
|
||||
|
||||
# Remove default nginx site
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Configs
|
||||
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/sirh.conf
|
||||
|
||||
# Backend from stage 1
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
|
||||
# Frontend from stage 2
|
||||
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
|
||||
|
||||
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/var \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
WORKDIR /var/www/html
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export SIRH_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying sirh:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -1,13 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
|
||||
container_name: sirh-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
@@ -1,46 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
internal;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:php-fpm]
|
||||
command=php-fpm -F
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopasgroup=true
|
||||
stopsignal=QUIT
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopasgroup=true
|
||||
stopsignal=QUIT
|
||||
@@ -1,50 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maintenance en cours</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #1f2937;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
.icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #111827;
|
||||
}
|
||||
p {
|
||||
font-size: 1rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🛠</div>
|
||||
<h1>Maintenance en cours</h1>
|
||||
<p>L'application est temporairement indisponible pour mise a jour. Elle sera de retour dans quelques instants.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,29 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name sirh.malio-dev.fr;
|
||||
|
||||
root /var/www/sirh/public;
|
||||
|
||||
# Maintenance mode : si le fichier maintenance.on existe, renvoyer la page 503
|
||||
if (-f /var/www/sirh/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name sirh.malio-dev.fr;
|
||||
|
||||
root /var/www/sirh/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/sirh/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/sirh/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/sirh/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/sirh/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/sirh/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/sirh/public;
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
internal;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
# Journal des actions (Audit Log)
|
||||
|
||||
## Objectif
|
||||
|
||||
Tracer les actions utilisateurs pour diagnostiquer rapidement les problèmes de calcul signalés.
|
||||
Quand un utilisateur signale une incohérence dans ses heures, RTT ou congés, le journal permet de voir
|
||||
exactement ce qui a été modifié, par qui, et quand.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Rôle requis** : `ROLE_SUPER_ADMIN` (rôle caché, non visible dans l'interface de gestion des utilisateurs)
|
||||
- **Ajout du rôle** : directement en base de données
|
||||
```sql
|
||||
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'xxx';
|
||||
```
|
||||
- **Page** : `/audit-logs` (lien "Journal" dans la sidebar, visible uniquement avec le rôle)
|
||||
|
||||
## Actions tracées
|
||||
|
||||
| Processor | Entité | Actions |
|
||||
|---|---|---|
|
||||
| `AbsenceWriteProcessor` | Absence | create, delete |
|
||||
| `WorkHourBulkUpsertProcessor` | WorkHour | create, update, delete |
|
||||
| `WorkHourSiteValidationProcessor` | WorkHour | site_validate |
|
||||
| `WorkHourBulkValidationProcessor` | WorkHour | validate |
|
||||
| `WorkHourBulkSiteValidationProcessor` | WorkHour | site_validate |
|
||||
| `EmployeeWriteProcessor` | Employee | create, update (changement contrat) |
|
||||
| `ContractSuspensionWriteProcessor` | ContractSuspension | create, update |
|
||||
| `EmployeeRttPaymentProcessor` | EmployeeRttPayment | update |
|
||||
| `EmployeeFractionedDaysProcessor` | EmployeeLeaveBalance | update |
|
||||
|
||||
## Données stockées
|
||||
|
||||
Chaque entrée contient :
|
||||
- **employee** : l'employé concerné (FK, nullable)
|
||||
- **username** : l'utilisateur qui a effectué l'action
|
||||
- **action** : type d'action (create, update, delete, validate, site_validate)
|
||||
- **entityType** : type d'entité (work_hour, absence, employee, etc.)
|
||||
- **description** : description lisible en français
|
||||
- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs
|
||||
- **affectedDate** : date de travail ou début d'absence (pour filtrage par période)
|
||||
- **createdAt** : horodatage de l'action
|
||||
|
||||
## Filtres disponibles
|
||||
|
||||
- Par employé
|
||||
- Par plage de dates (date affectée)
|
||||
- Par type d'entité
|
||||
|
||||
## Pagination
|
||||
|
||||
Les résultats sont paginés par 50 entrées. L'API retourne `{items, total, page, perPage}` et accepte un query param `page`.
|
||||
|
||||
## Convention
|
||||
|
||||
Tout nouveau processor traitant des entités impactant les calculs (heures, absences, contrats, RTT)
|
||||
doit intégrer le service `AuditLogger` et logger les actions create/update/delete.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Vue contrat — sélecteur de phase
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon une phase de contrat passée (ex. un 39h CDI avant un switch FORFAIT, ou un CDD clôturé avec solde de tout compte) sans changer le comportement par défaut sur la phase courante.
|
||||
|
||||
## Concept de "phase"
|
||||
|
||||
Une **phase** = groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la signature `(contract.type, weeklyHours, isDriver)`. Le service `EmployeeContractPhaseResolver` (`src/Service/Contracts/EmployeeContractPhaseResolver.php`) calcule ces phases à la volée depuis `Employee::getContractPeriods()`.
|
||||
|
||||
Une transition de signature (35h → 39h, 39h → FORFAIT, driver false→true, weeklyHours 28→30, etc.) ouvre une nouvelle phase. Un type différent entre deux périodes de même signature empêche leur fusion (39h → INTERIM → 39h = 3 phases).
|
||||
|
||||
## Filtrage par `RTT_START_DATE`
|
||||
|
||||
Les phases dont la date de fin est strictement antérieure à `RTT_START_DATE` (env, date de mise en service du logiciel) sont **masquées** du picker. Aucune donnée logiciel (heures, absences) n'existe avant cette date, donc consulter une phase entièrement antérieure n'a pas de sens fonctionnel.
|
||||
|
||||
Exemple : un employé sous 35h CDI de 2014 à 2025-10-31 puis 39h CDI depuis 2025-11-01, avec `RTT_START_DATE=2026-02-23` :
|
||||
- Phase 35h (2014 → 2025-10-31) : entièrement avant → masquée
|
||||
- Phase 39h (depuis 2025-11-01) : chevauche → visible
|
||||
- Résultat : 1 seule phase visible → picker caché (seuil ≥ 2 phases)
|
||||
|
||||
Une phase dont la date de fin est égale à `RTT_START_DATE` est conservée (inégalité `>=`, non stricte).
|
||||
|
||||
`EmployeeContractPhaseResolver` reçoit `RTT_START_DATE` via DI (`services.yaml`). L'entité `Employee::getContractPhases()` lit la valeur depuis `$_SERVER`/`$_ENV` directement (l'entité n'a pas d'injection) et passe la chaîne au constructeur du resolver.
|
||||
|
||||
## Picker UI
|
||||
|
||||
- Position : en haut de la fiche employé, sous le nom et au-dessus des onglets.
|
||||
- Libellé : `Vue contrat`.
|
||||
- Caché si l'employé n'a qu'une seule phase.
|
||||
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
|
||||
|
||||
## Bandeau d'information
|
||||
|
||||
Affiché quand le picker est sur une phase passée. Indique que le mode lecture est partiel — les paiements de solde restent possibles, l'édition d'absences et des stocks de report est désactivée.
|
||||
|
||||
## Effet sur les onglets
|
||||
|
||||
| Onglet | Effet |
|
||||
|---|---|
|
||||
| Congés | Recharge avec les règles de la phase. Période Juin→Mai pour non-forfait, Jan→Déc pour FORFAIT. Exercices bornés à la phase. |
|
||||
| RTT | Visible ssi `phase.contractType !== FORFAIT`. Le tableau affiche **toujours toutes les semaines de l'exercice** (Juin→Mai). Pour une phase clôturée, `periodTo` et `limitDate` sont capés à `phase.endDate` (les semaines après la fin de phase contribuent 0 au cumul). `periodFrom` n'est pas capé : les semaines avant l'embauche ou avant le début d'une phase passée apparaissent à 0 (pas de contrat → pas de référence → 0 minute). |
|
||||
| Heures, Frais, Formation, Contrat, Calendrier | Non impactés. |
|
||||
|
||||
## Paiements de solde sur phase passée
|
||||
|
||||
- **RTT** : `+ Payer les RTT` activé sur le **dernier exercice de la phase** uniquement (l'exercice contenant `phase.endDate`). Les exercices antérieurs restent en lecture seule.
|
||||
- **CP** : utiliser le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` pour solder. Pas de nouveau channel.
|
||||
|
||||
## Transition d'exercice
|
||||
|
||||
Quand un exercice chevauche deux phases, les bornes sont capées différemment selon le type de phase consultée :
|
||||
|
||||
### Phase FORFAIT (passée ou courante)
|
||||
|
||||
Le cumul 218 jours est **par année civile**. Toute consultation FORFAIT cape :
|
||||
- `from` à `max(phase.startDate, 1er janvier de l'année)`
|
||||
- `to` à `min(phase.endDate, 31 décembre de l'année)`
|
||||
|
||||
Ex. switch 39h → FORFAIT au 01/05/2026, vue FORFAIT année 2026 → période = [01/05/2026, 31/12/2026].
|
||||
|
||||
### Phase non-forfait (35h / 39h / CUSTOM / INTERIM)
|
||||
|
||||
L'exercice CP est **annuel** (Juin N-1 → Mai N) et continu à travers les changements d'heures contractuelles dans le même régime non-forfait. La cap **n'applique pas** sur `from` :
|
||||
- `from` reste à 1er juin de l'année (le contrat-entry-date est géré par `resolveEffectivePeriodStart` pour les nouveaux embauchés)
|
||||
- `to` est borné à `phase.endDate` uniquement quand on consulte une **phase passée**
|
||||
|
||||
Ex. employé 35h jusqu'au 31/10/2025 puis 39h depuis le 01/11/2025 :
|
||||
- Vue 39h (courante) sur exercice 2026 → période = [01/06/2025, 31/05/2026]. Acquis CP = exercice complet (~27.5 jours à fin avril).
|
||||
- Vue 35h (passée) sur exercice 2026 → période = [01/06/2025, 31/10/2025]. Acquis CP = 5 mois de l'exercice.
|
||||
|
||||
**Important** : c'est intentionnel que la vue courante 39h inclue les mois Juin-Octobre travaillés en 35h dans son cumul. Le stock CP est annuel, pas par phase ; un changement d'heures ne reset pas le compteur.
|
||||
|
||||
## API
|
||||
|
||||
Les endpoints suivants acceptent `?phaseId=N` :
|
||||
- `GET /employees/{id}/leave-summary`
|
||||
- `GET /employees/{id}/rtt-summary`
|
||||
|
||||
Quand absent, ils utilisent la phase courante (comportement inchangé).
|
||||
|
||||
`Employee.contractPhases` (lecture, groupe `employee:read`) liste les phases au format `{id, contractType, weeklyHours, isDriver, contractNature, startDate, endDate, periodIds, isCurrent}`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` (unit)
|
||||
- `tests/State/EmployeeLeaveSummaryProviderTest.php` (functional, phaseId)
|
||||
- `tests/State/EmployeeRttSummaryProviderTest.php` (functional, phaseId)
|
||||
- `tests/State/EmployeeRttPaymentProcessorTest.php` (functional, dernier exo phase clôturée)
|
||||
- `tests/Service/Exercise/ExerciseYearResolverTest.php` (helper extrait pendant Task 5)
|
||||
@@ -1,363 +0,0 @@
|
||||
# Deploiement Docker — SIRH
|
||||
|
||||
## Pre-requis
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Ubuntu
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gnupg
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
|
||||
|
||||
### Nginx
|
||||
|
||||
```bash
|
||||
sudo apt install -y nginx
|
||||
sudo systemctl enable nginx
|
||||
sudo systemctl start nginx
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||
Il doit etre installe et accessible avant de deployer SIRH.
|
||||
|
||||
Creer la base de donnees pour SIRH :
|
||||
|
||||
```bash
|
||||
cd /var/www/postgres
|
||||
docker compose exec postgres psql -U admin
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Si le user n'existe pas encore
|
||||
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||
|
||||
-- Creer la base
|
||||
CREATE DATABASE sirh_prod OWNER malio;
|
||||
\q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Premiere installation (nouvelle machine)
|
||||
|
||||
Guide complet pour mettre en ligne SIRH sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||
|
||||
### 1. Installer les pre-requis
|
||||
|
||||
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
||||
|
||||
### 2. Creer le dossier de deploiement
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/sirh
|
||||
sudo chown -R $(whoami):$(whoami) /var/www/sirh
|
||||
cd /var/www/sirh
|
||||
```
|
||||
|
||||
### 3. Se connecter au registry Docker de Gitea
|
||||
|
||||
```bash
|
||||
docker login gitea.malio.fr
|
||||
```
|
||||
|
||||
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO-DEV`
|
||||
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
|
||||
|
||||
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
|
||||
|
||||
### 4. Creer les fichiers de deploiement
|
||||
|
||||
Creer `docker-compose.yml` :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
|
||||
container_name: sirh-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Creer `deploy.sh` :
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export SIRH_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying sirh:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
```
|
||||
|
||||
Rendre executable :
|
||||
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
```
|
||||
|
||||
### 5. Configurer l'environnement
|
||||
|
||||
Creer `.env` avec les variables suivantes :
|
||||
|
||||
```env
|
||||
# Symfony
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||
|
||||
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/sirh_prod?serverVersion=16&charset=utf8"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||
JWT_COOKIE_SECURE=1
|
||||
JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
|
||||
|
||||
# App
|
||||
DEFAULT_URI=https://sirh.malio-dev.fr
|
||||
APP_SHARE_DIR=var/share
|
||||
RTT_START_DATE=2026-02-23
|
||||
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
||||
```
|
||||
|
||||
### 6. Generer les cles JWT
|
||||
|
||||
```bash
|
||||
mkdir -p config/jwt
|
||||
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
|
||||
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
|
||||
```
|
||||
|
||||
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
|
||||
|
||||
```bash
|
||||
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
|
||||
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
|
||||
```
|
||||
|
||||
### 7. Creer le dossier uploads
|
||||
|
||||
```bash
|
||||
mkdir -p uploads
|
||||
```
|
||||
|
||||
### 8. Configurer Nginx systeme
|
||||
|
||||
Creer `/etc/nginx/sites-available/sirh.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name sirh.malio-dev.fr;
|
||||
|
||||
root /var/www/sirh/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/sirh/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Copier la page de maintenance et activer le site :
|
||||
|
||||
```bash
|
||||
cp deploy/maintenance.html /var/www/sirh/public/maintenance.html
|
||||
sudo ln -sf /etc/nginx/sites-available/sirh.conf /etc/nginx/sites-enabled/sirh.conf
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 9. Deployer
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 10. Importer les donnees (optionnel)
|
||||
|
||||
Si tu as un dump SQL a importer :
|
||||
|
||||
```bash
|
||||
# Depuis ton PC, envoyer le dump vers le serveur
|
||||
scp sirh.sql user@serveur:/tmp/sirh.sql
|
||||
|
||||
# Sur le serveur, vider la base puis importer
|
||||
cd /var/www/postgres
|
||||
docker compose exec -T postgres psql -U malio sirh_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
docker compose exec -T postgres psql -U malio sirh_prod < /tmp/sirh.sql
|
||||
|
||||
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
|
||||
cd /var/www/sirh
|
||||
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
|
||||
|
||||
# Nettoyer
|
||||
rm /tmp/sirh.sql
|
||||
```
|
||||
|
||||
### Structure finale du dossier
|
||||
|
||||
```
|
||||
/var/www/sirh/
|
||||
├── docker-compose.yml
|
||||
├── deploy.sh
|
||||
├── .env
|
||||
├── config/jwt/
|
||||
│ ├── private.pem
|
||||
│ └── public.pem
|
||||
├── public/
|
||||
│ └── maintenance.html
|
||||
└── uploads/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployer une nouvelle version
|
||||
|
||||
Quand l'app est deja installee, deployer une mise a jour :
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
./deploy.sh # deploie la derniere version (latest)
|
||||
./deploy.sh v0.1.61 # deploie une version specifique
|
||||
```
|
||||
|
||||
Le script active automatiquement la maintenance pendant le deploy et la desactive a la fin.
|
||||
|
||||
---
|
||||
|
||||
## Maintenance manuelle
|
||||
|
||||
Activer la maintenance (sans deployer) :
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
touch maintenance.on
|
||||
```
|
||||
|
||||
Desactiver :
|
||||
|
||||
```bash
|
||||
rm maintenance.on
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
### Image seule (pas de changement de schema BDD)
|
||||
|
||||
```bash
|
||||
./deploy.sh v0.1.60
|
||||
```
|
||||
|
||||
### Avec rollback de migration
|
||||
|
||||
```bash
|
||||
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
|
||||
# 2. Deployer l'ancienne version
|
||||
./deploy.sh v0.1.60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Voir les logs
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
docker compose logs -f # tous les logs
|
||||
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||
```
|
||||
|
||||
Logs Symfony :
|
||||
|
||||
```bash
|
||||
docker compose exec app cat var/log/prod.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration depuis l'ancien deploiement (tar.gz)
|
||||
|
||||
Si l'application tourne deja en bare metal :
|
||||
|
||||
1. Installer Docker (voir pre-requis)
|
||||
2. Creer le dossier `/var/www/sirh-docker/` (ne pas ecraser l'ancien)
|
||||
3. Copier les fichiers existants :
|
||||
```bash
|
||||
cp /var/www/sirh/.env /var/www/sirh-docker/.env
|
||||
cp -a /var/www/sirh/config/jwt /var/www/sirh-docker/config/jwt
|
||||
cp -a /var/www/sirh/var/uploads /var/www/sirh-docker/uploads
|
||||
```
|
||||
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/sirh-docker/` (voir etape 4 ci-dessus)
|
||||
5. Editer `/var/www/sirh-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
|
||||
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
|
||||
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
|
||||
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
|
||||
9. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
|
||||
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/sirh-docker /var/www/sirh`
|
||||
@@ -1,58 +0,0 @@
|
||||
# Formations
|
||||
|
||||
Onglet **Formation** accessible depuis la fiche employé. Permet de tracer les formations suivies par un salarié.
|
||||
|
||||
## Accès
|
||||
|
||||
- Réservé aux administrateurs (`ROLE_ADMIN`)
|
||||
- Invisible pour les autres rôles
|
||||
|
||||
## Champs
|
||||
|
||||
| Champ | Type | Obligatoire |
|
||||
| --- | --- | --- |
|
||||
| Date de début | date | oui |
|
||||
| Date de fin | date | oui |
|
||||
| Justificatif | fichier PDF | non |
|
||||
| Commentaire | texte libre | non |
|
||||
|
||||
## Règles de validation
|
||||
|
||||
- La date de fin doit être supérieure ou égale à la date de début
|
||||
- Seuls les fichiers PDF sont acceptés pour le justificatif
|
||||
- Un employé peut avoir plusieurs formations (aucune unicité imposée)
|
||||
|
||||
## Stockage
|
||||
|
||||
Les justificatifs PDF sont stockés dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf`, où l'année et le mois sont ceux de la date de début de la formation. Le nom d'origine du fichier est conservé en base pour l'affichage et le téléchargement.
|
||||
|
||||
Lors de la suppression d'une formation, le fichier associé est automatiquement supprimé du disque. Lors du remplacement d'un justificatif, l'ancien fichier est également supprimé.
|
||||
|
||||
## Tri
|
||||
|
||||
Les formations sont affichées dans le tableau par **date de début décroissante**.
|
||||
|
||||
## Affichage sur les autres écrans
|
||||
|
||||
### Écran des heures (vue jour)
|
||||
|
||||
Dans la colonne "Absence", lorsqu'un salarié est en formation sur la date sélectionnée, une pastille indigo **Formation** est affichée sous la pastille d'absence éventuelle. Cette pastille est uniquement informative :
|
||||
|
||||
- Le bouton **Modifier** de la colonne Absence est masqué : aucune création/modification/suppression d'absence n'est possible sur un jour en formation
|
||||
- La gestion CRUD d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**
|
||||
|
||||
### Calendrier
|
||||
|
||||
Dans le calendrier mensuel, les formations sont affichées de deux façons :
|
||||
|
||||
- **Jour avec formation uniquement** : la cellule est teintée en indigo avec le code `F`
|
||||
- **Jour avec absence + formation** : la cellule garde la couleur de l'absence et une icône école est ajoutée en coin supérieur droit
|
||||
|
||||
Une entrée "Formation" est visible dans la légende du calendrier. Les cellules contenant une formation sont **non cliquables** (aucune création/édition d'absence possible). La gestion d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**.
|
||||
|
||||
### Export PDF du calendrier
|
||||
|
||||
L'impression du calendrier d'absences reprend le même principe :
|
||||
|
||||
- **Jour avec formation uniquement** : cellule indigo avec le code `F`
|
||||
- **Jour avec absence + formation** : le code de l'absence est suivi d'un astérisque (`*`)
|
||||
@@ -1,470 +0,0 @@
|
||||
# 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)
|
||||
|
||||
- Visibilité des employés:
|
||||
- vue jour: un employé sans contrat à la date sélectionnée est masqué
|
||||
- vue semaine: un employé sans contrat sur aucun jour de la semaine est masqué
|
||||
- même règle pour les heures classiques et les heures conducteurs
|
||||
- Saisie par salarié et par date:
|
||||
- matin / après-midi / soir
|
||||
- pour `PRESENCE`: demi-journées matin/après-midi
|
||||
- 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)
|
||||
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
|
||||
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
|
||||
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
|
||||
|
||||
## 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
|
||||
- Visibilité des employés dans le Calendrier:
|
||||
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
|
||||
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
|
||||
- même logique que l'écran Heures : « pas de contrat sur la période → masqué »
|
||||
|
||||
### 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
|
||||
- Date de début RTT (`RTT_START_DATE` dans `.env`):
|
||||
- les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération
|
||||
- permet d'éviter les déficits fictifs avant la mise en service du logiciel
|
||||
- Semaine en déficit (heures travaillées < heures contrat):
|
||||
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
|
||||
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
|
||||
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
|
||||
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
|
||||
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
|
||||
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
|
||||
- Nature `INTERIM`:
|
||||
- pas de bonus 25%
|
||||
- pas de bonus 50%
|
||||
- pas de total récup
|
||||
- agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"
|
||||
|
||||
## 6bis) Heures Conducteurs
|
||||
|
||||
- Écran dédié `/driver-hours` pour les employés dont le contrat est marqué `isDriver = true`
|
||||
- Les conducteurs sont exclus de l'écran `/hours` classique
|
||||
- Colonnes spécifiques (vue jour):
|
||||
- Heure de jour (durée HH:MM via TimeSelect)
|
||||
- Heure de nuit (durée HH:MM via TimeSelect)
|
||||
- Heure atelier (durée HH:MM via TimeSelect)
|
||||
- Total (somme jour + nuit + atelier, calculé)
|
||||
- Petit déjeuner (checkbox)
|
||||
- Déjeuner (checkbox)
|
||||
- Dîner (checkbox)
|
||||
- Nuitée (checkbox)
|
||||
- Stockage backend:
|
||||
- `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour`
|
||||
- `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour`
|
||||
- les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
|
||||
- Absences `countAsWorkedHours=true`: les minutes créditées sont ajoutées aux heures de jour (vue jour et vue semaine), même logique que les employés classiques
|
||||
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
|
||||
- Vue semaine:
|
||||
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
|
||||
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
|
||||
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
|
||||
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
|
||||
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
|
||||
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
|
||||
|
||||
## 7) Fériés
|
||||
|
||||
- Les jours fériés sont identifiés et affichés
|
||||
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
|
||||
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
|
||||
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
|
||||
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
|
||||
- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`).
|
||||
- Règle courante:
|
||||
- absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
|
||||
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
|
||||
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
|
||||
|
||||
## 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
|
||||
- Liste employés — filtre par statut de contrat:
|
||||
- 3 options: "Avec contrat" (défaut), "Sans contrat", "Tous"
|
||||
- "Avec contrat": employés ayant une période de contrat active à la date du jour
|
||||
- "Sans contrat": employés sans période de contrat active
|
||||
- "Tous": aucun filtrage sur le contrat
|
||||
- Détail employé:
|
||||
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
|
||||
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
|
||||
- action `Modifier` (clôture/solde de tout compte):
|
||||
- bouton actif s'il existe un contrat en cours non clôturé, ou si le dernier contrat est terminé (sans contrat actif après)
|
||||
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
|
||||
- champs saisissables:
|
||||
- `contractEndDate` (prérempli à aujourd'hui si contrat en cours, à la date de fin existante si contrat terminé)
|
||||
- `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
|
||||
- cas du contrat déjà terminé: permet de modifier `paidLeaveSettled` et le commentaire sur le dernier contrat terminé (ex: solde de tout compte CDD)
|
||||
- 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
|
||||
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22 (standard mensuel)
|
||||
- arrêt maladie long (absences continues de type `M` > 1 mois):
|
||||
- premier mois de maladie (date début + 1 mois calendaire): acquisition normale (`2,50`/mois)
|
||||
- après le premier mois: acquisition réduite à `2,00`/mois (facteur `0,80` appliqué aux deux taux jours et samedis)
|
||||
- en cas de mois partiellement couvert par la période réduite, le prorata est calculé en jours calendaires (jours normaux × taux normal + jours réduits × taux réduit)
|
||||
- la détection est automatique à partir des absences MALADIE consécutives en base (tolérance de gap ≤ 3 jours)
|
||||
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
|
||||
- contrat `4h`:
|
||||
- acquis annuel CP: `10`
|
||||
- acquis annuel samedi: `0`
|
||||
- en cours d'acquisition: `0.83` jour/mois
|
||||
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
|
||||
- contrat `FORFAIT`:
|
||||
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
||||
- bonus weekend/férié: chaque jour travaillé un weekend ou jour férié donne 1 jour de congé supplémentaire (journée ≥ 5h = 1.0 jour, demi-journée > 0h et < 5h = 0.5 jour), sans plafond
|
||||
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
||||
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
|
||||
- pas de samedi (`0`)
|
||||
- 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)
|
||||
- paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait.
|
||||
- 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 qui chevauche deux mois est affichée dans **les deux mois**, avec les valeurs réparties proportionnellement aux minutes travaillées de chaque portion
|
||||
- le calcul des heures supplémentaires reste hebdomadaire (seuils 35h/39h/43h appliqués sur la semaine entière), seul l'affichage est scindé
|
||||
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparaît en mars (avec la part des heures de lun-mar) et en avril (avec la part mer-dim)
|
||||
- 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`
|
||||
- date limite de calcul: uniquement les semaines terminées (jusqu'au dernier dimanche), **ou** la semaine en cours si tous les jours existants sont validés RH (`isValid = true`). En cas de fin de contrat en milieu de semaine, seuls les jours jusqu'à la date de fin sont vérifiés.
|
||||
- compteur global:
|
||||
- affiché en **jours** (1 jour = 7h = 420 minutes)
|
||||
- report:
|
||||
- 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%)
|
||||
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
|
||||
- colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci − paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel)
|
||||
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
|
||||
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures − paiements antérieurs), affichée à partir de juillet (masquée si nul)
|
||||
- Reste = Report cumulé + Total du mois − Payé du mois (balance courante en fin de mois)
|
||||
- affichage:
|
||||
- le compteur global RTT est affiché en **heures** (format `Xh00`)
|
||||
|
||||
## 10) Export récap. congés & RTT (PDF)
|
||||
|
||||
- Accessible depuis la page Employés via le bouton "Export récap. congés" (réservé `ROLE_ADMIN`)
|
||||
- Clic direct (pas de drawer), génère un PDF A4 portrait à la date du jour
|
||||
- Endpoint: `GET /api/leave-recap/print`
|
||||
- Seuls les employés avec contrat actif sont inclus
|
||||
- Données groupées par site
|
||||
|
||||
### Colonnes du tableau
|
||||
|
||||
| Colonne | Logique |
|
||||
|---------|---------|
|
||||
| Nom | lastName + firstName |
|
||||
| Contrat | Contract.name |
|
||||
| CP N-1 restant | CDI/CDD: acquis N-1 − pris sur N-1. Forfait: report N-1 restant |
|
||||
| Samedi restant | CDI/CDD: samedis acquis N-1 − pris. Forfait: `-` |
|
||||
| CP N | Forfait: restant sur quota année civile (acquis − pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition |
|
||||
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
|
||||
|
||||
## 10bis) Écran Récap. congés (tableau)
|
||||
|
||||
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
|
||||
- Endpoint: `GET /api/leave-recap`
|
||||
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
|
||||
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
|
||||
- Scoping :
|
||||
- `ROLE_ADMIN` : tous les employés
|
||||
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
||||
- `ROLE_SELF` : uniquement son employé lié
|
||||
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
|
||||
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 14 jours)`
|
||||
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
|
||||
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
||||
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
||||
- Colonnes identiques au PDF (voir §10)
|
||||
- Détails techniques : voir `doc/leave-recap-screen.md`
|
||||
|
||||
## 11) Récapitulatif Salaire (PDF mensuel)
|
||||
|
||||
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
||||
- Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage
|
||||
- Endpoint: `GET /api/salary-recap/print?month=YYYY-MM`
|
||||
- Données groupées par site, un en-tête par site
|
||||
|
||||
### Colonnes du tableau
|
||||
|
||||
| Colonne | Source | Logique |
|
||||
|---------|--------|---------|
|
||||
| Nom | Employee | firstName + lastName |
|
||||
| Base | Contract.name | Via EmployeeContractResolver pour le mois |
|
||||
| Jour de présence Cadre | WorkHour | Uniquement FORFAIT (PRESENCE). Somme isPresentMorning (0.5) + isPresentAfternoon (0.5) |
|
||||
| Heures de nuit | WorkHour | Non-chauffeurs: calcul intervalles nuit (00:00-06:00, 21:00-24:00). Chauffeurs: somme nightHoursMinutes |
|
||||
| Panier de nuit | WorkHour | Nombre de jours où (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit 4h entre 21h-6h) |
|
||||
| Heures payés | EmployeeRttPayment | Somme base25Minutes + base50Minutes du mois, convertie en heures |
|
||||
| Congés - Nombre | Absence code 'C' | Jours (demi-journées = 0.5) |
|
||||
| Congés - Date | Absence code 'C' | Dates formatées dd/mm |
|
||||
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
|
||||
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
|
||||
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
|
||||
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) |
|
||||
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
|
||||
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
|
||||
| Observations | — | Colonne vide pour saisie manuelle |
|
||||
|
||||
## 12) Frais
|
||||
|
||||
- Onglet "Frais" sur la fiche employé (icône `mdi:account-cash-outline`)
|
||||
- Entité `MileageAllowance` (table `mileage_allowances`)
|
||||
- Champs:
|
||||
- `month` (mois, obligatoire)
|
||||
- `kilometers` (nombre de km, optionnel)
|
||||
- `amount` (montant en €, optionnel)
|
||||
- `comment` (commentaire, optionnel)
|
||||
- `receiptPath` / `receiptName` (justificatif Km, PDF)
|
||||
- `amountReceiptPath` / `amountReceiptName` (justificatif Montant, PDF)
|
||||
- Règle de validation:
|
||||
- le mois est obligatoire
|
||||
- au moins un des deux champs `kilometers` ou `amount` doit être > 0
|
||||
- les deux peuvent être remplis simultanément
|
||||
- Tableau: colonnes Mois, Nombre de Km, Montant €, Commentaire, Justif. Km, Justif. Montant
|
||||
- Deux justificatifs distincts (upload PDF uniquement):
|
||||
- Justificatif Km : upload via `/mileage_allowances/{id}/receipt`, téléchargement via GET même URL
|
||||
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
|
||||
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
|
||||
|
||||
## 13) Observations
|
||||
|
||||
- Onglet "Observation" sur la fiche employé (icône `mdi:note-text-outline`)
|
||||
- Entité `Observation` (table `observations`)
|
||||
- Champs:
|
||||
- `month` (mois, obligatoire)
|
||||
- `content` (texte d'observation, obligatoire)
|
||||
- Contrainte: une seule observation par mois par employé (unique sur `employee_id + month`)
|
||||
- Tableau: colonnes Mois | Observation
|
||||
- Drawer avec champs mois (`type="month"`) et textarea "Observation"
|
||||
- CRUD standard: création, modification, suppression avec confirmation
|
||||
|
||||
## 14) Verrouillage utilisateur
|
||||
|
||||
- Champ `isLocked` (boolean, default false) sur l'entité `User`
|
||||
- Un admin peut verrouiller/déverrouiller un utilisateur depuis la page Utilisateurs (checkbox dans le drawer)
|
||||
- Un utilisateur verrouillé ne peut plus se connecter (vérification via `UserChecker` sur les firewalls `login` et `api`)
|
||||
- Colonne "Statut" dans le tableau utilisateurs avec label "Actif" (vert) ou "Verrouillé" (rouge)
|
||||
|
||||
## 15) 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`
|
||||
|
||||
## 16) Export PDF des heures annuelles
|
||||
|
||||
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
||||
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
|
||||
- Génère un PDF avec le détail jour par jour des heures de l'employé
|
||||
- Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés
|
||||
- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie)
|
||||
|
||||
### Colonnes selon le mode de suivi
|
||||
|
||||
- **TIME (non-chauffeur)**: Date | Absence | Début matin | Fin matin | Début après-midi | Fin après-midi | Début soir | Fin soir | Total
|
||||
- **PRESENCE (forfait)**: Date | Absence | Présence matin | Présence après-midi | Total
|
||||
- **Chauffeur**: Date | Absence | Heures jour | Heures nuit | Heures atelier | Total
|
||||
|
||||
### Changement de contrat en cours d'année
|
||||
|
||||
- Si l'employé change de mode de suivi (TIME/PRESENCE) ou de statut chauffeur en cours d'année, le PDF affiche des sections séparées avec les colonnes adaptées à chaque période
|
||||
- Le nom du contrat est affiché en sous-titre de chaque section
|
||||
|
||||
### Calcul du total
|
||||
|
||||
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
|
||||
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
|
||||
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
|
||||
- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie.
|
||||
|
||||
### Nom du fichier
|
||||
|
||||
- Format: `{nom}_{prenom}_{annee}.pdf`
|
||||
@@ -1,110 +0,0 @@
|
||||
# Crédit automatique des heures sur jour férié (Lun-Ven)
|
||||
|
||||
## Règle
|
||||
|
||||
Tout jour férié du **lundi au vendredi** crédite automatiquement les **heures contractuelles attendues** pour ce jour, pour tout contrat **autre que Forfait** (`trackingMode` ≠ `PRESENCE`). Les heures ainsi créditées sont dites *virtuelles* : aucune ligne n'est créée dans `work_hours`, elles sont injectées à l'affichage et au calcul.
|
||||
|
||||
### Référence contractuelle par jour
|
||||
|
||||
| Contrat | Lun-Jeu | Ven | Sam-Dim |
|
||||
|-----------------|---------|-------|---------|
|
||||
| 35h | 7h | 7h | 0 |
|
||||
| 39h | 8h | 7h | 0 |
|
||||
| CUSTOM (avec planning `workDaysHours`) | minutes du jour programmé, 0 sinon | idem | 0 |
|
||||
| INTERIM 35h | 7h | 7h | 0 |
|
||||
| FORFAIT | — | — | — |
|
||||
|
||||
La référence par jour est calculée par `App\Service\WorkHours\DailyReferenceMinutesResolver`.
|
||||
|
||||
### Planning `workDaysHours`
|
||||
|
||||
Tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h) doit déclarer un planning précis sur sa `EmployeeContractPeriod` : colonne JSON `work_days_hours = {"1": 120, "4": 120}` (iso day → minutes). La somme doit égaler `weeklyHours × 60`.
|
||||
|
||||
- **Sur un jour du planning** : crédit férié = minutes programmées (ex. Ewa Lun → 120 min).
|
||||
- **Sur un jour hors planning** : crédit férié = 0 (elle n'aurait pas travaillé).
|
||||
- Même logique appliquée par `WorkedHoursCreditPolicy::resolveContractDayMinutes` pour les crédits d'absence — un 4h en absence mardi (non programmée) = 0 crédit.
|
||||
|
||||
Validation à l'écriture : `EmployeeContractPeriodValidator::assertWorkDaysHours`. Le frontend expose un bloc « Jours travaillés » (cases Lun-Ven + input `HH:MM`) sur les formulaires de création employé + d'ajout de contrat, visible uniquement quand le contrat le requiert.
|
||||
|
||||
**Limitation actuelle** : l'édition in-place d'un schedule sur une période active existante n'est **pas exposée** via l'UI. Le drawer « Modifier le contrat » affiche le schedule en lecture seule à titre informatif. Pour corriger un schedule, la démarche est : clôturer le contrat en cours + créer un nouveau contrat avec le schedule corrigé. Si un besoin d'édition directe émerge, ajouter `workDaysHours` dans `EmployeeContractChangeRequest::hasPeriodChangeRequest()` et la logique d'update dans `EmployeeContractPeriodManager`.
|
||||
|
||||
### Fériés exclus
|
||||
|
||||
Les fériés listés dans l'env `EXCLUDED_PUBLIC_HOLIDAYS` (par défaut `Lundi de Pentecôte` — journée de solidarité) **ne donnent pas** de crédit virtuel : le `PublicHolidayService` les filtre en amont, donc `HolidayVirtualHoursResolver` ne les voit pas comme fériés.
|
||||
|
||||
### Interaction avec saisie
|
||||
|
||||
Quand l'employé saisit des heures ce jour-là :
|
||||
|
||||
- `heures finales = max(heures saisies + crédit d'absence éventuel, heures contractuelles de référence)`
|
||||
|
||||
Exemples avec un contrat 39h et un férié un lundi :
|
||||
|
||||
| Saisie employé | Total affiché | Interprétation |
|
||||
|------------------|---------------|----------------|
|
||||
| Aucune | 8h | Crédit 100% virtuel |
|
||||
| Matin 09:00-13:00 (4h) | 8h | Le minimum contractuel l'emporte |
|
||||
| 09:00-12:00 + 13:00-19:00 (9h) | 9h | Les heures saisies l'emportent |
|
||||
|
||||
### Interaction avec absences
|
||||
|
||||
La création d'absence sur un férié Lun-Ven est **autorisée** (bouton Modifier visible). Dès qu'une absence est déclarée sur le jour (matin et/ou après-midi), le crédit virtuel férié **est désactivé** pour ce jour : c'est `absence.type.countAsWorkedHours` qui pilote le crédit d'heures, via `WorkedHoursCreditPolicy`.
|
||||
|
||||
- `countAsWorkedHours = true` (ex. maladie payée) : crédit calculé normalement (7h/8h selon contrat × halfUnits/2). Même quantité que la référence virtuelle si journée complète, donc résultat identique — mais la source du crédit est l'absence, pas le férié.
|
||||
- `countAsWorkedHours = false` (ex. congé sans solde) : crédit = 0. Le férié ne compense pas.
|
||||
|
||||
Cette règle évite le double-crédit (absence + férié virtuel) et respecte le paramétrage fonctionnel du type d'absence.
|
||||
|
||||
## Impact technique
|
||||
|
||||
### Affichage
|
||||
|
||||
- **Écran Heures (vue jour)** : sur un férié Lun-Ven non-Forfait, la colonne Total affiche la valeur effective (référence ou saisie, selon max). Un chip "Férié : Xh comptées" apparaît sous le pill bleu du férié.
|
||||
- **Écran Heures Conducteurs (vue jour)** : idem, plus un indicateur `= Xh (férié)` sous l'input "Heures jour" pour signaler que le crédit est imputé au bucket jour.
|
||||
- **Vues semaine** : les totaux hebdomadaires intègrent les minutes virtuelles. Un marqueur `F + Xh` apparaît dans la cellule du jour férié.
|
||||
- **Onglet RTT** : les semaines contenant un férié Lun-Ven gagnent du temps crédité, ce qui peut générer des heures sup (25% / 50%) là où l'ancienne règle produisait un déficit.
|
||||
|
||||
### Calcul RTT
|
||||
|
||||
Le service `App\Service\WorkHours\HolidayVirtualHoursResolver` est injecté dans `RttRecoveryComputationService::computeRecoveryByWeek()`. Pour chaque jour ouvré :
|
||||
|
||||
```
|
||||
effectiveMinutes = resolveEffectiveDailyMinutes(contract, date, metrics.totalMinutes + credited)
|
||||
weeklyTotalMinutes += effectiveMinutes
|
||||
```
|
||||
|
||||
Le reste du calcul (tranches +25%, +50%, base 25% à partir de 35h/39h) demeure inchangé ; seul le total hebdo injecté a évolué.
|
||||
|
||||
### Calcul hebdomadaire d'affichage
|
||||
|
||||
`WorkHourWeeklySummaryProvider` applique la même substitution sur `weeklyDayMinutes` et `weeklyTotalMinutes`. Le DTO `WeeklyDaySummary` expose désormais un champ `virtualHolidayMinutes` utilisé par les vues semaine.
|
||||
|
||||
### Contexte jour
|
||||
|
||||
`WorkHourDayContextProvider` expose `virtualHolidayMinutes` dans `DayContextRow` pour permettre au frontend de calculer le total journalier en temps réel pendant la saisie (sans aller-retour).
|
||||
|
||||
### Frontend
|
||||
|
||||
Le composable `frontend/composables/useHolidayVirtualHours.ts` réplique la règle côté client et est consommé par `useHoursPage.ts::getRowMetrics` et `useDriverHoursPage.ts::getRowMetrics`.
|
||||
|
||||
## Impact historique
|
||||
|
||||
La règle est appliquée **à chaque lecture** depuis les `WorkHour` — donc l'exercice courant et tout exercice recalculé live bénéficient automatiquement de la nouvelle règle sans migration.
|
||||
|
||||
Les reports N-1 stockés dans `employee_rtt_balances.opening_*_minutes` ont été saisis manuellement par la RH (valeurs officielles) et ne sont **pas recalculés** : ces snapshots restent la source de vérité pour les soldes d'ouverture.
|
||||
|
||||
## Services impliqués
|
||||
|
||||
| Composant | Rôle |
|
||||
|-----------|------|
|
||||
| `DailyReferenceMinutesResolver` | Résolution "minutes contractuelles par jour" (logique partagée, anciennement dupliquée). |
|
||||
| `HolidayVirtualHoursResolver` | Décide si la règle s'applique et renvoie le crédit virtuel ou la valeur effective. |
|
||||
| `RttRecoveryComputationService` | Applique la substitution dans le calcul hebdo RTT. |
|
||||
| `WorkHourWeeklySummaryProvider` | Applique la substitution dans les totaux hebdo UI. |
|
||||
| `WorkHourDayContextProvider` | Expose `virtualHolidayMinutes` par salarié/jour. |
|
||||
| `useHolidayVirtualHours.ts` (frontend) | Réplique la règle en live côté client. |
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/Service/WorkHours/HolidayVirtualHoursResolverTest.php` couvre les scénarios par contrat + jours ouvrés/chômés.
|
||||
- `make test` (PHPUnit) valide l'intégration RTT / hebdo / contexte jour.
|
||||
@@ -1,73 +0,0 @@
|
||||
# Écran Récap. congés
|
||||
|
||||
## Objet
|
||||
|
||||
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
|
||||
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
|
||||
|
||||
## Cutoff
|
||||
|
||||
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante − 14 jours)`.
|
||||
|
||||
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
|
||||
|
||||
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
|
||||
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
|
||||
|
||||
Implémentation : `App\Util\LeaveRecapCutoff::resolveCutoff()` côté backend, helper `parseYmd` +
|
||||
`getIsoWeekNumber` côté frontend pour l'affichage du badge.
|
||||
|
||||
## Colonnes
|
||||
|
||||
Identiques au PDF :
|
||||
|
||||
- Nom
|
||||
- Prénom
|
||||
- Contrat
|
||||
- CP N-1 restant
|
||||
- CP N
|
||||
- Samedis acquis
|
||||
- RTT
|
||||
|
||||
Pour les admins et chefs de site, une colonne **Site** est ajoutée à gauche.
|
||||
|
||||
## Scoping
|
||||
|
||||
| Profil | Données visibles |
|
||||
|---------------|-----------------------------------------|
|
||||
| `ROLE_ADMIN` | Tous les employés actifs, tous sites |
|
||||
| `ROLE_USER` (chef de site) | Employés actifs des sites autorisés via `UserSiteRole` |
|
||||
| `ROLE_SELF` | Uniquement l'employé lié à son compte |
|
||||
|
||||
## Flag d'accès
|
||||
|
||||
Le champ `User.hasLeaveRecapAccess` (boolean, défaut `false`) conditionne :
|
||||
|
||||
- L'affichage de l'entrée "Récap. congés" dans la sidebar
|
||||
- L'accès à la route `/leave-recap` (middleware `leave-recap-access.ts`)
|
||||
- L'endpoint API `GET /api/leave-recap` (le provider renvoie `403` si le flag est faux)
|
||||
|
||||
Le flag s'applique même aux admins : un admin sans le flag ne voit pas l'écran. Il se configure
|
||||
dans le drawer de création/édition d'un utilisateur.
|
||||
|
||||
## Service partagé
|
||||
|
||||
`App\Service\Leave\LeaveRecapRowBuilder::build(Employee $employee, DateTimeImmutable $asOfDate)`
|
||||
construit une ligne de récap. Il est utilisé par :
|
||||
|
||||
- `LeaveRecapPrintProvider` (PDF admin) avec `$asOfDate = today`
|
||||
- `EmployeeLeaveRecapProvider` (écran) avec `$asOfDate = cutoff`
|
||||
|
||||
## Propagation du cutoff dans les calculs
|
||||
|
||||
`EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate`.
|
||||
Lorsqu'il est fourni et appliqué à l'année cible, il remplace "today" dans :
|
||||
|
||||
- `resolveAccrualCalculationEndDate()` — la borne d'accrual devient le dernier jour du mois
|
||||
précédant `asOfDate` (au lieu du mois précédent today).
|
||||
- `resolveTakenCalculationEndDate()` — les absences postérieures à `asOfDate` sont ignorées.
|
||||
|
||||
Pour les années antérieures (carry forward), le comportement reste inchangé (pas de cap).
|
||||
|
||||
Le RTT est capé via `RttRecoveryComputationService::computeTotalRecoveryForExercise(..., $limitDate)`
|
||||
qui existait déjà, en passant `cutoff` comme date de référence.
|
||||
@@ -1,226 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,70 +0,0 @@
|
||||
# Onglet "Congés" — fiche employé
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
L'onglet **Congés** de la fiche employé (`frontend/components/employees/LeaveTab.vue`) affiche :
|
||||
- un bandeau de compteurs (acquis, pris, reste, en cours d'acquisition, N-1 ou samedis selon le contrat) ;
|
||||
- un calendrier annuel coloré des congés posés (12 mois en grille 4×3) ;
|
||||
- pour chaque mois, le nombre de jours de présence (`presenceDaysByMonth`) ;
|
||||
- un sélecteur d'année en pied de calendrier.
|
||||
|
||||
## Période affichée
|
||||
|
||||
La période dépend du **type de contrat actuel** de l'employé :
|
||||
|
||||
| Type de contrat | Période affichée |
|
||||
|-------------------|--------------------------------|
|
||||
| FORFAIT | Janvier → Décembre (année civile) |
|
||||
| Autres | Juin (Y-1) → Mai (Y) (exercice CP) |
|
||||
|
||||
Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend : la sélection FORFAIT vs non-FORFAIT se fait toujours sur le contrat **courant**, pas sur celui qui était en vigueur à l'année consultée.
|
||||
|
||||
## Sélecteur d'année
|
||||
|
||||
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
|
||||
|
||||
Plage proposée :
|
||||
- du plus récent (= année courante) au plus ancien ;
|
||||
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
|
||||
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
|
||||
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
|
||||
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
|
||||
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
|
||||
|
||||
Format des libellés :
|
||||
- FORFAIT : `2026`, `2025`, `2024`…
|
||||
- Autres : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`…
|
||||
|
||||
Comportement :
|
||||
- changer d'année recharge l'intégralité de l'onglet (`getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`) ;
|
||||
- les compteurs du bandeau reflètent l'année sélectionnée.
|
||||
|
||||
## Verrouillage des éditions sur années passées
|
||||
|
||||
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
|
||||
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
|
||||
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
|
||||
|
||||
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
|
||||
|
||||
## Sélecteur de phase de contrat
|
||||
|
||||
Quand l'employé a plusieurs phases de contrat (`Employee.contractPhases.length > 1`), le picker `Vue contrat` en haut de la fiche permet de consulter une phase passée. L'onglet Congés bascule alors sur les règles de la phase choisie :
|
||||
- Période Juin→Mai pour les phases non-forfait, Jan→Déc pour FORFAIT.
|
||||
- Sélecteur d'année interne borné aux exercices intersectant la phase.
|
||||
- Bornes d'exercice cappées sur `phase.endDate` côté backend (l'exercice de transition affiche les soldes acquis jusqu'à la date de fin de phase, pas au-delà).
|
||||
- Boutons crayon `Jours fractionnés` / `Année N-1 payés` désactivés (lecture seule sur phase passée).
|
||||
|
||||
Cf. `doc/contract-phase-view.md` pour les détails complets.
|
||||
|
||||
## Implémentation
|
||||
|
||||
- Composable : `frontend/composables/useEmployeeLeave.ts`
|
||||
- État : `selectedLeaveYear`, computed `currentLeaveYear`, `availableLeaveYears`
|
||||
- API : `setSelectedLeaveYear(year)`, `loadLeaveData()`, `resetLoaded()`
|
||||
- `resetLoaded()` (appelé au changement d'employé) remet `selectedLeaveYear = null` pour que la valeur par défaut soit recalculée à partir du nouveau contrat.
|
||||
- Composant : `frontend/components/employees/LeaveTab.vue`
|
||||
- Props : `selectedYear`, `availableYears`, `currentYear`
|
||||
- Event : `update-selected-year`
|
||||
- Page : `frontend/pages/employees/[id].vue` (câble le composable au composant)
|
||||
- Backend : `EmployeeLeaveSummaryProvider` reçoit `RTT_START_DATE` via `services.yaml` (argument `$dataStartDate`) et l'expose dans la réponse `EmployeeLeaveSummary.dataStartDate`. Le filtrage `?year=YYYY` était déjà accepté (validation 2000–2100).
|
||||
@@ -1,163 +0,0 @@
|
||||
# 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 qui chevauche deux mois est affichee dans **les deux mois**, avec les valeurs reparties proportionnellement aux minutes travaillees de chaque portion
|
||||
- le calcul des heures supplementaires reste hebdomadaire (seuils 35h/39h/43h appliques sur la semaine entiere), seul l'affichage est scinde
|
||||
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparait en mars (part lun-mar) et en avril (part mer-dim)
|
||||
|
||||
## 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)
|
||||
@@ -1,62 +0,0 @@
|
||||
# Onglet "RTT" — fiche employé
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
L'onglet **RTT** de la fiche employé (`frontend/components/employees/RttTab.vue`) affiche un tableau hebdomadaire détaillé des heures supplémentaires accumulées et payées sur un exercice :
|
||||
- bandeau de navigation par mois (chevrons gauche/droite) ;
|
||||
- table semaine par semaine : Heure / Base 25% / 25% / Total 25% / Base 50% / 50% / Total 50% / Total / Cumul ;
|
||||
- ligne Report (carry N-1 ou cumul mois précédents) ;
|
||||
- ligne Total mois, ligne Payé, ligne Reste ;
|
||||
- bouton « + Payer les RTT » dans le bandeau ;
|
||||
- sélecteur d'exercice en pied de tableau.
|
||||
|
||||
L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `useEmployeeDetailPage`). Les FORFAIT n'accumulent pas de RTT.
|
||||
|
||||
## Période affichée
|
||||
|
||||
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
||||
|
||||
## Sélecteur d'année
|
||||
|
||||
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
||||
|
||||
Plage proposée :
|
||||
- du plus récent (= exercice courant) au plus ancien ;
|
||||
- **double plancher** : `max(floor_historique_contrat, floor_data_start_date)`
|
||||
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
|
||||
- **floor_data_start_date** : exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026)
|
||||
- la valeur est exposée par l'API `GET /employees/{id}/rtt-summary` via le champ `rttStartDate` (déjà existant — mais peuplé uniquement quand la date tombe dans l'exercice retourné, donc le composable utilise la première réponse pour borner la plage).
|
||||
- format unique : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`…
|
||||
|
||||
Comportement :
|
||||
- changer d'exercice recharge `getEmployeeRttSummary?year=YYYY` (le backend valide 2000–2100) ;
|
||||
- la table redéploie les semaines de l'exercice sélectionné, navigation par mois conservée.
|
||||
|
||||
## Verrouillage des édition sur exercices passés
|
||||
|
||||
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé.
|
||||
|
||||
La consultation reste possible, l'édition non.
|
||||
|
||||
## Sélecteur de phase de contrat
|
||||
|
||||
L'onglet RTT est visible quand la **phase de contrat sélectionnée** n'est pas FORFAIT (et non pas le contrat courant). Concrètement, sur un employé passé en FORFAIT après une période 39h :
|
||||
- En vue `FORFAIT` (défaut), l'onglet RTT est masqué.
|
||||
- En vue `39h` (phase passée sélectionnée via le picker `Vue contrat`), l'onglet RTT redevient visible avec les exercices Juin→Mai bornés à la phase.
|
||||
|
||||
Le bouton `+ Payer les RTT` est activé uniquement sur le **dernier exercice de la phase passée** (l'exercice contenant `phase.endDate`). Les exercices antérieurs sont en lecture seule.
|
||||
|
||||
Cf. `doc/contract-phase-view.md` pour les détails complets.
|
||||
|
||||
## Implémentation
|
||||
|
||||
- Composable : `frontend/composables/useEmployeeRtt.ts`
|
||||
- État : `selectedRttYear`, computed `currentRttYear`, `availableRttYears`
|
||||
- API : `setSelectedRttYear(year)`, `loadRttData()`, `resetLoaded()`
|
||||
- `resetLoaded()` (appelé au changement d'employé) remet `selectedRttYear = null`.
|
||||
- Composant : `frontend/components/employees/RttTab.vue`
|
||||
- Props : `selectedYear`, `availableYears`, `currentYear`
|
||||
- Event : `update-selected-year`
|
||||
- Renommage `currentYear` (computed local de l'année du mois affiché) → `displayedMonthYear` pour éviter la collision avec la nouvelle prop.
|
||||
- Page : `frontend/pages/employees/[id].vue`
|
||||
- Backend : aucun changement — `EmployeeRttSummaryProvider` accepte déjà `?year=YYYY` (validation 2000–2100) et expose `rttStartDate`.
|
||||
@@ -1,7 +1,4 @@
|
||||
[Date]
|
||||
; Defines the default timezone used by the date functions
|
||||
; http://php.net/date.timezone
|
||||
date.timezone = Europe/Paris
|
||||
|
||||
[PHP]
|
||||
memory_limit = 256M
|
||||
date.timezone = Europe/Paris
|
||||
@@ -1,484 +0,0 @@
|
||||
# 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` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,273 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -1,563 +0,0 @@
|
||||
# Refonte onglet RTT — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer la vue annuelle RTT par une vue mensuelle avec tableau détaillé par semaine (base/25%/50%) et un système de paiement à 4 champs.
|
||||
|
||||
**Architecture:** Enrichir `RttRecoveryComputationService` pour retourner le détail base/bonus par palier. Modifier l'entité `EmployeeRttPayment` pour stocker 4 valeurs. Réécrire le composant `RttTab.vue` avec navigation mensuelle et tableau 7 colonnes.
|
||||
|
||||
**Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), PostgreSQL.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Enrichir le retour de `RttRecoveryComputationService::computeRecoveryByWeek`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Dto/Rtt/WeekRecoveryDetail.php`
|
||||
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php:97-206`
|
||||
|
||||
Actuellement `computeRecoveryByWeek` retourne `array<string, int>` (weekKey => totalMinutes). Il faut retourner `array<string, WeekRecoveryDetail>` avec le détail ventilé.
|
||||
|
||||
- [ ] **Step 1: Créer le DTO `WeekRecoveryDetail`**
|
||||
|
||||
```php
|
||||
// src/Dto/Rtt/WeekRecoveryDetail.php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto\Rtt;
|
||||
|
||||
final class WeekRecoveryDetail
|
||||
{
|
||||
public function __construct(
|
||||
public int $overtimeMinutes = 0,
|
||||
public int $base25Minutes = 0,
|
||||
public int $bonus25Minutes = 0,
|
||||
public int $base50Minutes = 0,
|
||||
public int $bonus50Minutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Modifier `computeRecoveryByWeek` pour retourner `array<string, WeekRecoveryDetail>`**
|
||||
|
||||
Changer le retour de la méthode. Les variables internes existent déjà (`weeklyOvertimeTotalMinutes`, `weeklyOvertime25Minutes`, `weeklyOvertime50Minutes`). Il faut calculer en plus les bases séparées.
|
||||
|
||||
La logique de ventilation des heures de base entre palier 25% et palier 50% :
|
||||
- `base25Minutes` = heures sup dans la tranche 25% = `min(overtimeMinutes, max(0, overtime25StartMinutes - overtimeReferenceMinutes))`... En fait, c'est plus simple :
|
||||
- `base25Minutes` = `min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes))` quand overtimeTotal > 0
|
||||
- Plus simplement : `base25Minutes` = heures entre le seuil 25% et 43h, `base50Minutes` = heures au-dessus de 43h
|
||||
|
||||
Reprenons la logique existante (lignes 189-202) :
|
||||
- `overtimeReferenceMinutes` = seuil à partir duquel on compte les heures sup (max(35, weeklyHours) * 60 réparti sur les jours)
|
||||
- `overtime25StartMinutes` = seuil à partir duquel les heures sup sont à 25% (39h si contrat >= 39h, sinon 35h)
|
||||
- `weeklyOvertimeTotalMinutes` = max(0, worked - overtimeReference) — total heures sup brutes
|
||||
- `weeklyOvertime25Minutes` = bonus 25% = round(min(worked, 43*60) - overtime25Start) * 0.25
|
||||
- `weeklyOvertime50Minutes` = bonus 50% = round(max(0, worked - 43*60)) * 0.5
|
||||
|
||||
Pour la ventilation :
|
||||
- `base25Minutes` = min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes)) — Non, c'est la tranche 25% en termes d'heures travaillées, pas en termes d'heures sup.
|
||||
|
||||
En fait :
|
||||
- Les heures sup brutes = `weeklyOvertimeTotalMinutes` = `worked - overtimeReference`
|
||||
- Les heures dans le palier 25% = heures entre `overtime25Start` et `min(worked, 43*60)` = c'est `max(0, min(worked, 43*60) - overtime25Start)`. C'est la base sur laquelle le 25% est calculé.
|
||||
- Les heures dans le palier 50% = heures au-dessus de 43h = `max(0, worked - 43*60)`. C'est la base sur laquelle le 50% est calculé.
|
||||
|
||||
Modifier les lignes 191-202 :
|
||||
|
||||
```php
|
||||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||
? 0
|
||||
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
||||
|
||||
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
||||
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
|
||||
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
||||
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
|
||||
|
||||
$results[$weekKey] = new WeekRecoveryDetail(
|
||||
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
||||
base25Minutes: $base25,
|
||||
bonus25Minutes: $bonus25,
|
||||
base50Minutes: $base50,
|
||||
bonus50Minutes: $bonus50,
|
||||
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
|
||||
);
|
||||
```
|
||||
|
||||
Les cas "zéro" (weekStart vide, limitDate dépassée, etc.) retournent `new WeekRecoveryDetail()` (tout à 0).
|
||||
|
||||
- [ ] **Step 3: Adapter `computeTotalRecoveryForExercise` pour retourner un `WeekRecoveryDetail` agrégé**
|
||||
|
||||
Cette méthode retournait `int`. Elle doit maintenant retourner un `WeekRecoveryDetail` qui agrège toutes les semaines (somme par champ). Le rollover et le provider en ont besoin pour la ventilation du carry-over.
|
||||
|
||||
```php
|
||||
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
|
||||
{
|
||||
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
|
||||
$weeks = $this->buildWeeksForExercise($from, $to);
|
||||
$weekRanges = array_map(
|
||||
static fn (array $week): array => [
|
||||
'month' => (int) $week['month'],
|
||||
'weekNumber' => (int) $week['weekNumber'],
|
||||
'start' => $week['start'],
|
||||
'end' => $week['end'],
|
||||
],
|
||||
$weeks
|
||||
);
|
||||
|
||||
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
|
||||
|
||||
$total = new WeekRecoveryDetail();
|
||||
foreach ($byWeek as $detail) {
|
||||
$total = new WeekRecoveryDetail(
|
||||
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
|
||||
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
|
||||
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
|
||||
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
|
||||
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
|
||||
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vérifier que le code compile**
|
||||
|
||||
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Modifier l'entité `EmployeeRttBalance` (carry-over ventilé) + rollover
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/EmployeeRttBalance.php`
|
||||
- Modify: `src/Repository/EmployeeRttBalanceRepository.php`
|
||||
- Modify: `src/Command/RttRolloverCommand.php`
|
||||
|
||||
Le carry-over doit être ventilé sur les mêmes 4 colonnes que le tableau (base25, bonus25, base50, bonus50) pour pouvoir afficher une ligne "Report" dans le mois de juin.
|
||||
|
||||
- [ ] **Step 1: Remplacer `openingMinutes` par 4 champs dans `EmployeeRttBalance`**
|
||||
|
||||
Remplacer la propriété `$openingMinutes` par :
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])]
|
||||
private int $openingBase25Minutes = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])]
|
||||
private int $openingBonus25Minutes = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])]
|
||||
private int $openingBase50Minutes = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])]
|
||||
private int $openingBonus50Minutes = 0;
|
||||
```
|
||||
|
||||
Ajouter les getters/setters. Supprimer `getOpeningMinutes`/`setOpeningMinutes`. Ajouter un helper `getTotalOpeningMinutes()` qui retourne la somme des 4 champs.
|
||||
|
||||
- [ ] **Step 2: Adapter `RttRolloverCommand`**
|
||||
|
||||
`computeTotalRecoveryForExercise` retourne maintenant un `WeekRecoveryDetail`. Utiliser les 4 champs :
|
||||
|
||||
```php
|
||||
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
||||
|
||||
$balance = new EmployeeRttBalance()
|
||||
->setEmployee($employee)
|
||||
->setYear($targetYear)
|
||||
->setOpeningBase25Minutes($carry->base25Minutes)
|
||||
->setOpeningBonus25Minutes($carry->bonus25Minutes)
|
||||
->setOpeningBase50Minutes($carry->base50Minutes)
|
||||
->setOpeningBonus50Minutes($carry->bonus50Minutes)
|
||||
->setIsLocked(false)
|
||||
;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Adapter `EmployeeRttSummaryProvider::resolveCarryMinutes`**
|
||||
|
||||
Cette méthode retournait `int`. La renommer en `resolveCarry` et retourner un `WeekRecoveryDetail` :
|
||||
|
||||
```php
|
||||
private function resolveCarry(Employee $employee, int $year): WeekRecoveryDetail
|
||||
{
|
||||
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
|
||||
if (null !== $balance) {
|
||||
return new WeekRecoveryDetail(
|
||||
base25Minutes: $balance->getOpeningBase25Minutes(),
|
||||
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
|
||||
base50Minutes: $balance->getOpeningBase50Minutes(),
|
||||
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
|
||||
totalMinutes: $balance->getTotalOpeningMinutes(),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
|
||||
}
|
||||
```
|
||||
|
||||
Adapter le provider pour utiliser le carry ventilé dans le summary :
|
||||
- `carryFromPreviousYearMinutes` = carry->totalMinutes
|
||||
- Ajouter les 4 champs de carry dans `EmployeeRttSummary` pour le frontend
|
||||
|
||||
- [ ] **Step 4: Ajouter les champs carry dans `EmployeeRttSummary`**
|
||||
|
||||
```php
|
||||
public int $carryBase25Minutes = 0;
|
||||
public int $carryBonus25Minutes = 0;
|
||||
public int $carryBase50Minutes = 0;
|
||||
public int $carryBonus50Minutes = 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Générer et exécuter la migration**
|
||||
|
||||
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
|
||||
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
|
||||
|
||||
Note : faire la migration après la Task 3 (EmployeeRttPayment) pour regrouper les changements dans une seule migration.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Modifier l'entité `EmployeeRttPayment` et la migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/EmployeeRttPayment.php`
|
||||
- Modify: `src/Repository/EmployeeRttPaymentRepository.php`
|
||||
|
||||
- [ ] **Step 1: Remplacer `minutes` + `rate` par 4 champs dans l'entité**
|
||||
|
||||
Remplacer les propriétés `$minutes` et `$rate` par :
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
|
||||
private int $base25Minutes = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
|
||||
private int $bonus25Minutes = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])]
|
||||
private int $base50Minutes = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])]
|
||||
private int $bonus50Minutes = 0;
|
||||
```
|
||||
|
||||
Ajouter les getters/setters correspondants. Supprimer `getMinutes`/`setMinutes`/`getRate`/`setRate`.
|
||||
|
||||
- [ ] **Step 2: Adapter le repository**
|
||||
|
||||
Remplacer `findOneByEmployeeYearMonthRate` par `findOneByEmployeeYearMonth` (plus besoin du rate) :
|
||||
|
||||
```php
|
||||
public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'employee' => $employee,
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Générer et vérifier la migration**
|
||||
|
||||
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
|
||||
|
||||
Vérifier que la migration :
|
||||
- Ajoute `base25_minutes`, `bonus25_minutes`, `base50_minutes`, `bonus50_minutes`
|
||||
- Supprime `minutes` et `rate`
|
||||
|
||||
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Adapter le DTO `RttMonthPayment` et `EmployeeRttWeekSummary`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Dto/Rtt/RttMonthPayment.php`
|
||||
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
|
||||
|
||||
- [ ] **Step 1: Modifier `RttMonthPayment`**
|
||||
|
||||
Remplacer `paidMinutes25` et `paidMinutes50` par les 4 champs :
|
||||
|
||||
```php
|
||||
final class RttMonthPayment
|
||||
{
|
||||
public function __construct(
|
||||
public int $month,
|
||||
public int $paidBase25Minutes = 0,
|
||||
public int $paidBonus25Minutes = 0,
|
||||
public int $paidBase50Minutes = 0,
|
||||
public int $paidBonus50Minutes = 0,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Enrichir `EmployeeRttWeekSummary`**
|
||||
|
||||
Ajouter les champs de détail :
|
||||
|
||||
```php
|
||||
final class EmployeeRttWeekSummary
|
||||
{
|
||||
public function __construct(
|
||||
public int $month,
|
||||
public int $weekNumber,
|
||||
public string $weekStart,
|
||||
public string $weekEnd,
|
||||
public int $overtimeMinutes = 0,
|
||||
public int $base25Minutes = 0,
|
||||
public int $bonus25Minutes = 0,
|
||||
public int $base50Minutes = 0,
|
||||
public int $bonus50Minutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
Supprimer l'ancien champ `recoveryMinutes`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Adapter le provider et le processor backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/State/EmployeeRttSummaryProvider.php`
|
||||
- Modify: `src/ApiResource/EmployeeRttSummary.php`
|
||||
- Modify: `src/ApiResource/EmployeeRttPaymentInput.php`
|
||||
- Modify: `src/State/EmployeeRttPaymentProcessor.php`
|
||||
|
||||
- [ ] **Step 1: Adapter `EmployeeRttSummaryProvider::provide`**
|
||||
|
||||
Le mapping des semaines (ligne 87-96) doit utiliser les nouveaux champs du `WeekRecoveryDetail` :
|
||||
|
||||
```php
|
||||
$summary->weeks = array_map(
|
||||
static function (array $week) use ($currentByWeekStart) {
|
||||
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
|
||||
|
||||
return new EmployeeRttWeekSummary(
|
||||
month: (int) $week['month'],
|
||||
weekNumber: (int) $week['weekNumber'],
|
||||
weekStart: $week['start']->format('Y-m-d'),
|
||||
weekEnd: $week['end']->format('Y-m-d'),
|
||||
overtimeMinutes: $detail->overtimeMinutes,
|
||||
base25Minutes: $detail->base25Minutes,
|
||||
bonus25Minutes: $detail->bonus25Minutes,
|
||||
base50Minutes: $detail->base50Minutes,
|
||||
bonus50Minutes: $detail->bonus50Minutes,
|
||||
totalMinutes: $detail->totalMinutes,
|
||||
);
|
||||
},
|
||||
$weekRanges
|
||||
);
|
||||
```
|
||||
|
||||
Le `currentYearRecoveryMinutes` doit sommer les `totalMinutes` :
|
||||
|
||||
```php
|
||||
$summary->currentYearRecoveryMinutes = array_sum(
|
||||
array_map(static fn (WeekRecoveryDetail $d) => $d->totalMinutes, $currentByWeekStart)
|
||||
);
|
||||
```
|
||||
|
||||
Adapter l'agrégation des paiements (lignes 98-121) pour les 4 champs :
|
||||
|
||||
```php
|
||||
foreach ($payments as $payment) {
|
||||
$m = $payment->getMonth();
|
||||
if (!isset($monthBuckets[$m])) {
|
||||
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
|
||||
}
|
||||
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
|
||||
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
|
||||
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
|
||||
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
|
||||
}
|
||||
|
||||
foreach ($monthBuckets as $m => $bucket) {
|
||||
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
|
||||
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Adapter `EmployeeRttPaymentInput`**
|
||||
|
||||
```php
|
||||
final class EmployeeRttPaymentInput
|
||||
{
|
||||
public int $month = 0;
|
||||
public int $base25Minutes = 0;
|
||||
public int $bonus25Minutes = 0;
|
||||
public int $base50Minutes = 0;
|
||||
public int $bonus50Minutes = 0;
|
||||
public ?int $year = null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Adapter `EmployeeRttPaymentProcessor`**
|
||||
|
||||
Supprimer la validation du `rate`. Adapter le upsert :
|
||||
|
||||
```php
|
||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||
|
||||
if (null === $payment) {
|
||||
$payment = new EmployeeRttPayment();
|
||||
$payment->setEmployee($employee);
|
||||
$payment->setYear($year);
|
||||
$payment->setMonth($data->month);
|
||||
$this->entityManager->persist($payment);
|
||||
}
|
||||
|
||||
$payment->setBase25Minutes($data->base25Minutes);
|
||||
$payment->setBonus25Minutes($data->bonus25Minutes);
|
||||
$payment->setBase50Minutes($data->base50Minutes);
|
||||
$payment->setBonus50Minutes($data->bonus50Minutes);
|
||||
$payment->touch();
|
||||
$this->entityManager->flush();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vérifier**
|
||||
|
||||
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Adapter le frontend — DTOs et service
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
|
||||
- Modify: `frontend/services/employee-rtt-summary.ts`
|
||||
|
||||
- [ ] **Step 1: Mettre à jour les types TS**
|
||||
|
||||
```typescript
|
||||
export type EmployeeRttWeekSummary = {
|
||||
month: number
|
||||
weekNumber: number
|
||||
weekStart: string
|
||||
weekEnd: string
|
||||
overtimeMinutes: number
|
||||
base25Minutes: number
|
||||
bonus25Minutes: number
|
||||
base50Minutes: number
|
||||
bonus50Minutes: number
|
||||
totalMinutes: number
|
||||
}
|
||||
|
||||
export type RttMonthPayment = {
|
||||
month: number
|
||||
paidBase25Minutes: number
|
||||
paidBonus25Minutes: number
|
||||
paidBase50Minutes: number
|
||||
paidBonus50Minutes: number
|
||||
}
|
||||
|
||||
export type EmployeeRttSummary = {
|
||||
year: number
|
||||
carryFromPreviousYearMinutes: number
|
||||
carryBase25Minutes: number
|
||||
carryBonus25Minutes: number
|
||||
carryBase50Minutes: number
|
||||
carryBonus50Minutes: number
|
||||
currentYearRecoveryMinutes: number
|
||||
totalPaidMinutes: number
|
||||
availableMinutes: number
|
||||
weeks: EmployeeRttWeekSummary[]
|
||||
monthPayments: RttMonthPayment[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Adapter le service `createRttPayment`**
|
||||
|
||||
```typescript
|
||||
export const createRttPayment = async (
|
||||
employeeId: number,
|
||||
month: number,
|
||||
base25Minutes: number,
|
||||
bonus25Minutes: number,
|
||||
base50Minutes: number,
|
||||
bonus50Minutes: number,
|
||||
year?: number
|
||||
) => {
|
||||
const api = useApi()
|
||||
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
|
||||
if (year) body.year = year
|
||||
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Réécrire `RttTab.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/employees/RttTab.vue`
|
||||
|
||||
- [ ] **Step 1: Réécrire le composant complet**
|
||||
|
||||
Structure du template :
|
||||
1. En-tête avec navigation mensuelle (flèches `<` `>`) et "RTT À LA DATE DU JOUR : X heure"
|
||||
2. Tableau 7 colonnes : Semaine | Heure | Base | 25% | Base | 50% | Total
|
||||
3. Si mois de juin (premier mois de l'exercice) et carry > 0 : ligne "Report" avec les 4 valeurs carry (colonne Heure = "-")
|
||||
4. 5 lignes semaines (padding si < 5)
|
||||
5. Ligne Total (somme par colonne, incluant le report si présent)
|
||||
6. Ligne Payé (valeurs négatives, "-" pour colonne Heure)
|
||||
7. Ligne Reste (Total - |Payé|, "-" pour colonne Heure)
|
||||
8. Bouton "+ Payer les RRT"
|
||||
9. Drawer de paiement avec 5 champs
|
||||
|
||||
Script setup :
|
||||
- `currentMonthIndex` : ref (0-11) pour la navigation dans `orderedMonthIndexes` (toujours [5,6,7,8,9,10,11,0,1,2,3,4] = juin à mai)
|
||||
- Initialiser `currentMonthIndex` au mois courant dans l'exercice
|
||||
- `currentMonth` : computed qui retourne le numéro de mois (1-12) basé sur l'index
|
||||
- `weeksForMonth` : computed filtrant les semaines du summary pour le mois courant, paddé à 5
|
||||
- `monthPayment` : computed trouvant le paiement du mois dans `summary.monthPayments`
|
||||
- Totaux par colonne : computed sommant les semaines
|
||||
- `formatMinutes` : existant, réutiliser (format `Xh` ou `Xh Ym`)
|
||||
- Navigation : `prevMonth` / `nextMonth` modifiant `currentMonthIndex` avec bornes [0, 11]
|
||||
|
||||
Drawer de paiement :
|
||||
- Champs : Mois (select), Base 25% (number en heures), Heures 25% (number en heures), Base 50% (number en heures), Heures 50% (number en heures)
|
||||
- Si paiement existant pour le mois sélectionné : pré-remplir en convertissant minutes → heures
|
||||
- Emit : `submit-rtt-payment` avec les 4 valeurs converties en minutes + le mois
|
||||
|
||||
- [ ] **Step 2: Adapter le composant parent**
|
||||
|
||||
Chercher où `RttTab` est utilisé et adapter l'event handler `submit-rtt-payment` pour passer les 4 champs au lieu de `(month, minutes, rate)`.
|
||||
|
||||
Run: `grep -rn "submit-rtt-payment" frontend/` pour trouver le parent.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Test de bout en bout
|
||||
|
||||
- [ ] **Step 1: Vérifier le cache et la migration**
|
||||
|
||||
```bash
|
||||
docker exec php-sirh-fpm php bin/console cache:clear
|
||||
docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tester l'API**
|
||||
|
||||
Vérifier que `GET /api/employees/{id}/rtt-summary` retourne les nouveaux champs par semaine.
|
||||
Vérifier que `PATCH /api/employees/{id}/rtt-payments` accepte les 4 champs.
|
||||
|
||||
- [ ] **Step 3: Tester le frontend**
|
||||
|
||||
- Navigation mensuelle (flèches, mois courant par défaut)
|
||||
- Tableau : vérifier les valeurs par semaine
|
||||
- Paiement : créer, modifier, vérifier pré-remplissage
|
||||
- "RTT À LA DATE DU JOUR" : vérifier le cumul
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,187 +0,0 @@
|
||||
# Suspension de contrat — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre de suspendre un contrat employé. Une suspension empêche l'acquisition de congés durant la période concernée (prorata). S'applique aux CDI/CDD non forfait et aux forfaits 218.
|
||||
|
||||
## Contraintes
|
||||
|
||||
- Plusieurs suspensions possibles par période de contrat
|
||||
- Pas de suppression de suspension (hors scope)
|
||||
- Règle de calcul : on exclut les jours de suspension jusqu'au dernier mois complet terminé (cohérent avec la règle existante)
|
||||
- Suspension sans date de fin = suspension en cours indéfiniment (exclut les mois jusqu'au dernier mois terminé)
|
||||
- Les suspensions ne doivent pas se chevaucher sur une même période de contrat
|
||||
|
||||
## Modèle de données
|
||||
|
||||
### Nouvelle entité `ContractSuspension`
|
||||
|
||||
Nouvelle table `contract_suspensions` :
|
||||
|
||||
| Colonne | Type | Nullable | Description |
|
||||
|---------|------|----------|-------------|
|
||||
| `id` | SERIAL | non | PK |
|
||||
| `contract_period_id` | INT | non | FK vers `employee_contract_periods`, CASCADE delete |
|
||||
| `start_date` | DATE | non | Début de suspension |
|
||||
| `end_date` | DATE | oui | Fin de suspension (null = en cours) |
|
||||
| `comment` | TEXT | oui | Commentaire libre |
|
||||
| `created_at` | TIMESTAMP | non | Date de création technique |
|
||||
|
||||
Index : `(contract_period_id, start_date)`.
|
||||
|
||||
Relation : `EmployeeContractPeriod` ← OneToMany → `ContractSuspension`.
|
||||
|
||||
## Backend — API
|
||||
|
||||
### Endpoint dédié
|
||||
|
||||
Les suspensions sont gérées via un endpoint dédié plutôt que via les champs transients Employee. Cela évite de complexifier le `EmployeeWriteProcessor` et permet de gérer N suspensions proprement.
|
||||
|
||||
**Nouvel ApiResource `ContractSuspension` :**
|
||||
- `POST /api/contract_suspensions` — créer une suspension (body : `contractPeriod` IRI, `startDate`, `endDate`, `comment`)
|
||||
- `PATCH /api/contract_suspensions/{id}` — modifier une suspension existante
|
||||
- Security : `ROLE_ADMIN`
|
||||
- Pagination désactivée
|
||||
|
||||
**Processor custom `ContractSuspensionWriteProcessor` :**
|
||||
- Résout la période de contrat depuis l'IRI
|
||||
- Validation :
|
||||
- `startDate` requis
|
||||
- `endDate >= startDate` si renseigné
|
||||
- `startDate >= period.startDate`
|
||||
- Pour les CDD/contrats avec date de fin : `startDate` et `endDate` dans les bornes de la période
|
||||
- Pas de chevauchement avec les autres suspensions de la même période
|
||||
- Rejet si la période de contrat est déjà clôturée (date de fin dans le passé)
|
||||
|
||||
### Lecture
|
||||
|
||||
Exposer les suspensions dans la sérialisation de l'Employee :
|
||||
|
||||
- `Employee::getCurrentSuspensions(): array` — retourne les suspensions de la période de contrat courante, groupe `employee:read`
|
||||
- Ajouter les suspensions au `ContractHistoryItem` via `EmployeeContractPeriod::getSuspensions()`
|
||||
|
||||
## Backend — Calcul des congés
|
||||
|
||||
### Deux points d'impact
|
||||
|
||||
Le calcul d'acquisition existe à **deux endroits** qui doivent tous les deux prendre en compte les suspensions :
|
||||
|
||||
1. **`EmployeeLeaveSummaryProvider::computeAccruedDaysFromStart()`** — affichage live des congés en cours d'acquisition
|
||||
2. **`LeaveBalanceComputationService::computeAccruedDays()`** — utilisé par le rollover (`LeaveRolloverCommand`) pour calculer le solde de report
|
||||
|
||||
Les deux méthodes ont la même structure (boucle mois par mois) et doivent être modifiées de la même manière.
|
||||
|
||||
### Modification des méthodes de calcul
|
||||
|
||||
Pour les deux méthodes, ajouter un paramètre optionnel : `array $suspensions = []` (tableau de `{start: DateTimeImmutable, end: ?DateTimeImmutable}`).
|
||||
|
||||
Dans la boucle mois par mois, pour chaque mois :
|
||||
1. Calculer les jours couverts par la période de contrat (existant)
|
||||
2. Pour chaque suspension, calculer le nombre de jours suspendus qui tombent dans ce mois
|
||||
3. Soustraire le total des jours suspendus
|
||||
4. Le ratio du mois = max(0, jours couverts - jours suspendus) / jours dans le mois
|
||||
|
||||
Cela gère automatiquement les suspensions qui commencent/finissent en milieu de mois (prorata).
|
||||
|
||||
Une suspension sans date de fin utilise la date de fin de calcul comme borne (dernier jour du mois précédent, cohérent avec la règle existante).
|
||||
|
||||
**Note :** chaque méthode est appelée deux fois — une pour les jours, une pour les samedis. La soustraction de suspension s'applique aux deux appels.
|
||||
|
||||
### Impact sur les forfaits 218
|
||||
|
||||
Pour les forfaits, les jours acquis en début d'exercice (ex: 34 jours pour 2026) sont réduits au prorata des jours de suspension.
|
||||
|
||||
Calcul : `jours acquis = base × (jours ouvrés effectifs / jours ouvrés totaux de l'exercice)`
|
||||
|
||||
Où `jours ouvrés effectifs = jours ouvrés totaux - jours ouvrés suspendus`.
|
||||
|
||||
Cela impacte `EmployeeLeaveSummaryProvider` dans la branche forfait et `LeaveBalanceComputationService` dans le calcul forfait de `computeDynamicClosingForYear()`.
|
||||
|
||||
### Passage des données de suspension aux méthodes
|
||||
|
||||
- **`EmployeeLeaveSummaryProvider`** : le provider a accès aux périodes de contrat via l'Employee. Il doit résoudre les suspensions de la période couvrant l'exercice et les passer aux méthodes de calcul.
|
||||
- **`LeaveBalanceComputationService`** : le service utilise `$employee->getContractHistory()`. Il doit trouver les suspensions de la période couvrant l'exercice. L'accès au repository `EmployeeContractPeriodRepository` est déjà injecté — ajouter l'accès au repository `ContractSuspensionRepository` ou passer par la relation Doctrine.
|
||||
|
||||
### Impact sur la bascule d'exercice (rollover au 01/06)
|
||||
|
||||
Le rollover (`LeaveRolloverCommand`) appelle `LeaveBalanceComputationService::computeDynamicClosingForYear()` qui appelle `computeAccruedDays()`. En modifiant `computeAccruedDays()` pour accepter et traiter les suspensions, le rollover prendra automatiquement en compte les suspensions. Les jours acquis au rollover reflèteront la déduction.
|
||||
|
||||
**Exemple CDI :** exercice 2027 (juin 2026 - mai 2027), 2 suspensions totalisant 3 mois → au lieu de 25j acquis, l'employé bascule avec ~18.75j (9 mois effectifs × 2.083j/mois).
|
||||
|
||||
### Règles non impactées
|
||||
|
||||
- INTERIM : pas de congés gérés
|
||||
|
||||
## Frontend — UI
|
||||
|
||||
### Bouton et drawer
|
||||
|
||||
Le bouton "Clôturer" devient **"Modifier"**. Il ouvre le drawer existant avec le titre **"Modifier le contrat"**. Le bouton **"+ Ajouter"** (création de nouveau contrat) reste inchangé.
|
||||
|
||||
Le drawer contient 2 onglets :
|
||||
|
||||
**Onglet "Clôturer"** — contenu identique à l'actuel (type contrat, temps de travail, début contrat en readonly, date fin, commentaire, checkbox solde de tout compte).
|
||||
|
||||
**Onglet "Suspendre"** — formulaires empilés :
|
||||
- Pour chaque suspension existante : un formulaire pré-rempli avec les 3 champs (date début, date fin, commentaire) et un bouton **"Modifier"**
|
||||
- En bas : un bouton **"+ Ajouter"** qui ajoute un nouveau formulaire vide avec les 3 champs et un bouton **"Ajouter"**
|
||||
- Chaque formulaire est indépendant (soumission individuelle)
|
||||
|
||||
### Champs par formulaire de suspension
|
||||
|
||||
- Date de début (required, input date)
|
||||
- Date de fin (optionnel, input date)
|
||||
- Commentaire (optionnel, textarea)
|
||||
|
||||
### Données nécessaires côté frontend
|
||||
|
||||
Nouveau type `ContractSuspension` (DTO) :
|
||||
```typescript
|
||||
type ContractSuspension = {
|
||||
id: number
|
||||
startDate: string
|
||||
endDate?: string | null
|
||||
comment?: string | null
|
||||
}
|
||||
```
|
||||
|
||||
Ajouter au type `Employee` (DTO) :
|
||||
- `currentSuspensions?: ContractSuspension[]`
|
||||
|
||||
Ajouter au type `ContractHistoryItem` :
|
||||
- `suspensions?: ContractSuspension[]`
|
||||
|
||||
Nouveau service `frontend/services/contractSuspensions.ts` :
|
||||
- `createSuspension(payload)` — POST
|
||||
- `updateSuspension(id, payload)` — PATCH
|
||||
|
||||
## Exemples de calcul
|
||||
|
||||
### CDI/CDD non forfait
|
||||
|
||||
Contrat CDI démarré le 01/06/2026, exercice 2027 (juin 2026 - mai 2027).
|
||||
Accrual : 25j / 12 mois = 2.083j/mois.
|
||||
|
||||
Sans suspension au 12/03/2027 (9 mois complets : juin-février) :
|
||||
- En cours d'acquisition = 9 × 2.083 = 18.75j
|
||||
|
||||
Avec 2 suspensions (01/01 au 31/01 + 01/03 au 31/03 = 2 mois) au 12/04/2027 (10 mois complets - 2 suspendus = 8 mois effectifs) :
|
||||
- En cours d'acquisition = 8 × 2.083 = 16.67j
|
||||
|
||||
Samedis (5/12 par mois) :
|
||||
- Sans suspension : 9 × 0.417 = 3.75j
|
||||
- Avec 2 suspensions : 8 × 0.417 = 3.33j
|
||||
|
||||
### Forfait 218
|
||||
|
||||
Exercice 2026 (année civile), 34 jours acquis, 252 jours ouvrés dans l'année.
|
||||
Suspension de 2 mois (44 jours ouvrés).
|
||||
|
||||
- Jours ouvrés effectifs = 252 - 44 = 208
|
||||
- Jours acquis = 34 × (208 / 252) = 28.06j
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Suppression d'une suspension
|
||||
- Affichage de la suspension dans l'historique des contrats (les données sont sérialisées mais pas de rendu spécifique dans le tableau historique)
|
||||
- Auto-fermeture des suspensions lors de la clôture du contrat
|
||||
@@ -1,117 +0,0 @@
|
||||
# Refonte onglet RTT employé
|
||||
|
||||
## Contexte
|
||||
|
||||
L'onglet RTT actuel affiche une grille annuelle de 12 mois avec les minutes de récupération par semaine. Il doit être remplacé par une vue mensuelle détaillée avec navigation, un tableau ventilé par palier de majoration (25% / 50%), et un système de paiement à 4 champs.
|
||||
|
||||
## Maquette de référence
|
||||
|
||||
Fichier : `RTT.png` à la racine du projet.
|
||||
|
||||
## Structure de la vue
|
||||
|
||||
### En-tête
|
||||
|
||||
- Navigation mensuelle : `< MOIS ANNÉE >` (flèches gauche/droite)
|
||||
- Navigation limitée aux mois de l'exercice (juin N-1 à mai N)
|
||||
- Mois courant affiché par défaut à l'ouverture
|
||||
- En haut à droite : `RTT À LA DATE DU JOUR : X heure` (cumul annuel toutes semaines confondues)
|
||||
|
||||
### Tableau
|
||||
|
||||
7 colonnes :
|
||||
|
||||
| Semaine | Heure | Base | 25% | Base | 50% | Total |
|
||||
|---------|-------|------|-----|------|-----|-------|
|
||||
|
||||
- **Semaine** : label "Semaine 1" à "Semaine 5" (toujours 5 lignes, vide si le mois n'a que 4 semaines)
|
||||
- **Heure** : heures supplémentaires brutes de la semaine
|
||||
- **Base** (1er) : heures de base dans le palier 25% (heures entre 35h et 39h pour un contrat 39h)
|
||||
- **25%** : bonus = base 25% × 0.25
|
||||
- **Base** (2e) : heures de base dans le palier 50% (heures au-delà de 43h)
|
||||
- **50%** : bonus = base 50% × 0.50
|
||||
- **Total** : somme de toutes les bases + tous les bonus
|
||||
|
||||
### Lignes de synthèse
|
||||
|
||||
- **Total** : somme des 5 semaines par colonne
|
||||
- **Payé** : montants payés pour ce mois (affichés en négatif). Colonne "Heure" = "-"
|
||||
- **Reste** : Total - |Payé| par colonne. Colonne "Heure" = "-"
|
||||
|
||||
### Bouton
|
||||
|
||||
`+ Payer les RRT` en bas, centré. Ouvre un drawer.
|
||||
|
||||
## Drawer de paiement
|
||||
|
||||
Champs :
|
||||
1. **Mois** (select) : liste des mois de l'exercice
|
||||
2. **Base 25%** (number, en heures)
|
||||
3. **Heures 25%** (number, en heures)
|
||||
4. **Base 50%** (number, en heures)
|
||||
5. **Heures 50%** (number, en heures)
|
||||
|
||||
Si des paiements existent pour le mois sélectionné, le formulaire est pré-rempli pour modification.
|
||||
|
||||
Boutons : Annuler / Enregistrer.
|
||||
|
||||
## Rattachement semaine → mois
|
||||
|
||||
Règle existante conservée : une semaine est rattachée au mois de son **samedi** (voir `RttRecoveryComputationService::buildWeeksForExercise`).
|
||||
|
||||
## Backend
|
||||
|
||||
### Modification de `EmployeeRttSummary`
|
||||
|
||||
Le provider retourne les données pour un mois donné (paramètre query `?month=X`) en plus du cumul annuel.
|
||||
|
||||
Nouvelles données par semaine :
|
||||
- `overtimeMinutes` : heures sup brutes
|
||||
- `base25Minutes` : base palier 25%
|
||||
- `bonus25Minutes` : bonus 25%
|
||||
- `base50Minutes` : base palier 50%
|
||||
- `bonus50Minutes` : bonus 50%
|
||||
- `totalMinutes` : somme base + bonus
|
||||
|
||||
### Modification de `EmployeeRttPayment`
|
||||
|
||||
Remplacer les champs `minutes` (int) + `rate` (int 25/50) par :
|
||||
- `base25Minutes` (int)
|
||||
- `bonus25Minutes` (int)
|
||||
- `base50Minutes` (int)
|
||||
- `bonus50Minutes` (int)
|
||||
|
||||
Migration Doctrine nécessaire.
|
||||
|
||||
### Modification de `EmployeeRttPaymentInput`
|
||||
|
||||
Adapter les champs pour correspondre aux 4 nouvelles valeurs.
|
||||
|
||||
### Modification de `RttRecoveryComputationService`
|
||||
|
||||
`computeRecoveryByWeek` retourne déjà les minutes totales. Il faut enrichir le retour pour ventiler base/bonus par palier. La logique de calcul des paliers existe déjà en interne, il suffit de l'exposer.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Stockage vs affichage
|
||||
|
||||
- Backend : stockage en **minutes** (inchangé)
|
||||
- Frontend : conversion minutes ↔ heures à l'affichage et à la saisie
|
||||
|
||||
### Réécriture de `RttTab.vue`
|
||||
|
||||
- Supprimer la grille annuelle de 12 mois
|
||||
- Navigation mensuelle avec état réactif (mois courant)
|
||||
- Tableau HTML avec les 7 colonnes décrites
|
||||
- 5 lignes semaines + Total + Payé + Reste
|
||||
- Formatage en "Xh" ou "Xh Ym" (ex: "6h 30m")
|
||||
|
||||
### Modification du DTO TypeScript
|
||||
|
||||
Adapter `EmployeeRttSummary` et `EmployeeRttWeekSummary` pour les nouveaux champs.
|
||||
|
||||
## Unités de conversion
|
||||
|
||||
- Affichage : heures et minutes (ex: "6h 30m", "30 m")
|
||||
- Saisie paiement : en heures décimales (number input)
|
||||
- Stockage : minutes entières (int)
|
||||
@@ -1,230 +0,0 @@
|
||||
# Vue contrat (sélecteur de phase) — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre à la RH de consulter les onglets Congés et RTT d'un employé **selon le contrat actuel ET selon ses contrats passés**, sans changement de comportement par défaut.
|
||||
|
||||
Cas qui motive : un employé passe de 39h à FORFAIT. Tant que le contrat courant est FORFAIT, les soldes CP/RTT accumulés sous l'ancien contrat 39h sont invisibles ou faussés (l'onglet RTT est masqué, la période Congés passe de Juin→Mai à Jan→Déc, les règles d'acquisition appliquent du FORFAIT_218 à toute année consultée). Conséquence : la RH ne peut plus payer les soldes restants.
|
||||
|
||||
Le même verrou existe pour toute fin de contrat avec **solde de tout compte** (notamment fin de CDD) suivie d'un nouveau contrat de type différent.
|
||||
|
||||
## Principe directeur
|
||||
|
||||
> Une fois passé en contrat X, on utilise toutes les règles X par défaut. Le sélecteur permet de revenir sur les phases passées pour les consulter et solder leurs reliquats.
|
||||
|
||||
## Concept de "phase de contrat"
|
||||
|
||||
Une **phase** est un groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la même **signature contractuelle** = `(contract.type, weeklyHours, isDriver)`. La fusion s'arrête dès qu'une période diffère sur l'un de ces trois axes, même si une période identique apparaît plus tard.
|
||||
|
||||
La signature inclut `weeklyHours` et `isDriver` parce que :
|
||||
- `weeklyHours` détermine les tranches d'heures supp (25%/50%), le rythme d'acquisition CP (cas 4h), la base contractuelle quotidienne.
|
||||
- `isDriver` change l'écran (`/driver-hours` vs `/hours`) et les colonnes de WorkHour utilisées pour le calcul RTT.
|
||||
|
||||
Exemples :
|
||||
- CDD 39h → CDI 39h → FORFAIT : 2 phases (`39h`, `FORFAIT`).
|
||||
- CDD 35h → CDI 39h : 2 phases (`35h`, `39h`).
|
||||
- 3 CDD 39h consécutifs sans interruption : 1 phase (`39h`).
|
||||
- 39h → INTERIM 4 mois → 39h : 3 phases (les `39h` ne fusionnent pas à travers l'`INTERIM`).
|
||||
- CUSTOM 28h → CUSTOM 30h : 2 phases (`weeklyHours` diffère).
|
||||
- 35h non-driver → 35h driver : 2 phases (`isDriver` diffère).
|
||||
|
||||
La règle de groupement vit dans un service backend, pas dans le frontend.
|
||||
|
||||
## Backend
|
||||
|
||||
### Nouveau service `EmployeeContractPhaseResolver`
|
||||
|
||||
Localisation : `src/Service/Contracts/EmployeeContractPhaseResolver.php`.
|
||||
|
||||
```php
|
||||
public function resolvePhases(Employee $employee): array;
|
||||
```
|
||||
|
||||
Retour : liste ordonnée (plus récente d'abord) de `ContractPhase` :
|
||||
|
||||
| Champ | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `int` | `EmployeeContractPeriod.id` de la première période (par date) du groupe — sert d'identifiant stable. |
|
||||
| `contractType` | `ContractType` | Type partagé par les périodes du groupe. |
|
||||
| `weeklyHours` | `int` | Heures hebdomadaires (partagées par construction). |
|
||||
| `isDriver` | `bool` | Driver flag (partagé par construction). |
|
||||
| `startDate` | `DateTimeImmutable` | `startDate` de la première période. |
|
||||
| `endDate` | `?DateTimeImmutable` | `endDate` de la dernière période, ou `null` si en cours. |
|
||||
| `periodIds` | `list<int>` | IDs des périodes composant la phase, par ordre chronologique. |
|
||||
| `isCurrent` | `bool` | `true` si la phase couvre la date du jour (= `endDate === null` ou `endDate >= today`). |
|
||||
|
||||
### Exposition API
|
||||
|
||||
Nouveau computed field sur `Employee` (lecture seule, groupe `employee:read`) :
|
||||
|
||||
```json
|
||||
"contractPhases": [
|
||||
{ "id": 42, "contractType": "FORFAIT", "weeklyHours": 39, "isDriver": false, "startDate": "2026-05-01", "endDate": null, "isCurrent": true },
|
||||
{ "id": 17, "contractType": "THIRTY_NINE_HOURS", "weeklyHours": 39, "isDriver": false, "startDate": "2020-06-01", "endDate": "2026-04-30", "isCurrent": false }
|
||||
]
|
||||
```
|
||||
|
||||
Le calcul se fait à la sérialisation via un getter virtuel `Employee::getContractPhases(): array` qui délègue au resolver.
|
||||
|
||||
### Endpoints impactés
|
||||
|
||||
Les endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent un nouveau paramètre optionnel :
|
||||
|
||||
- `?phaseId=N` : id de la phase à consulter.
|
||||
- Si absent → phase courante (= comportement actuel inchangé).
|
||||
- Si invalide (phase n'appartient pas à l'employé) → 422.
|
||||
- `?year=YYYY` reste accepté en parallèle et continue de cibler un exercice précis.
|
||||
|
||||
### Modifications des providers
|
||||
|
||||
**`EmployeeLeaveSummaryProvider`** :
|
||||
- Nouvelle méthode `resolveTargetPhase(Employee $e, ?int $phaseId): ContractPhase` qui retourne la phase demandée ou la phase courante.
|
||||
- `resolveLeavePolicy(...)` reçoit la phase au lieu de lire `$employee->getContract()`. Le `contract.type` et le `weeklyHours` viennent de la signature de la phase (homogène par construction).
|
||||
- `resolvePeriodBounds(...)` : les bornes de l'exercice sont en plus contraintes à `[max(periodStart, phase.startDate), min(periodEnd, phase.endDate ?? periodEnd)]`.
|
||||
- `resolveYear(...)` : si `phaseId` fourni et pas de `year` explicite, default = dernier exercice intersectant la phase (= année de `phase.endDate` ou année courante si phase en cours).
|
||||
- Si `?year` est fourni hors de la plage des exercices intersectant la phase → **clamp silencieux** à l'exercice valide le plus proche, pas d'erreur 422 (cohérence avec l'expérience picker frontend).
|
||||
- `resolveAccrualCalculationEndDate(...)` et `resolveTakenCalculationEndDate(...)` : caps additionnels sur `phase.endDate` quand la phase n'est pas la phase courante.
|
||||
- `resolveFirstComputationYear(...)` : restreint aux exercices intersectant la phase.
|
||||
|
||||
**`EmployeeRttSummaryProvider`** :
|
||||
- Mêmes principes : `?phaseId` côté API, `resolveTargetPhase`, bornes d'exercice cappées à la phase, `rttStartDate` exposé pour borner le sélecteur d'année frontend.
|
||||
- Pour une phase FORFAIT, le tab RTT est masqué frontend, donc l'endpoint n'est pas appelé en pratique. Pas de garde spécifique côté backend, le comportement existant (retour d'un summary potentiellement vide/inutile) suffit.
|
||||
|
||||
### Paiements de solde
|
||||
|
||||
**RTT — `EmployeeRttPaymentProcessor`** :
|
||||
- Garde actuelle "exercice courant uniquement" devient : "exercice courant OU dernier exercice d'une phase clôturée".
|
||||
- Concrètement, autoriser la création d'un `EmployeeRttPayment` sur l'exercice contenant `phase.endDate` d'une phase non courante.
|
||||
- Les exercices antérieurs au dernier de la phase restent verrouillés (lecture seule).
|
||||
|
||||
**CP — settlement period-level** :
|
||||
- Le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` reste le canal pour solder. Aucun changement de modèle.
|
||||
- Le bouton "Année N-1 payés" (FORFAIT) et "Jours fractionnés" (non-FORFAIT) restent désactivés sur une phase passée — ce ne sont pas des paiements de solde mais des éditions de stock.
|
||||
|
||||
### Audit
|
||||
|
||||
- La création d'`EmployeeRttPayment` est déjà auditée (existant).
|
||||
- La modification de `paidLeaveSettledClosureDate` est déjà auditée via `EmployeeContractPeriodManager` (existant).
|
||||
- Aucun audit nouveau requis.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Picker
|
||||
|
||||
- Composant `MalioSelect` placé dans `pages/employees/[id].vue`, dans le header de la fiche, sous le nom de l'employé et au-dessus de la barre d'onglets.
|
||||
- Libellé : `Vue contrat`.
|
||||
- Options formatées :
|
||||
- `FORFAIT — depuis 01/05/2026 (actuel)`
|
||||
- `39h CDI — 01/06/2020 → 30/04/2026`
|
||||
- **Caché** si `contractPhases.length <= 1` (employé mono-phase, ~majorité des cas).
|
||||
- Sélection en mémoire (état du composable), **non persistée** entre navigations ou rechargements. Chaque ouverture de fiche démarre sur la phase courante.
|
||||
|
||||
### Bandeau d'information
|
||||
|
||||
Affiché quand `selectedPhase.id !== currentPhase.id` :
|
||||
|
||||
> Vous consultez l'historique **{contractType} — jusqu'au {endDate}**.
|
||||
> Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
|
||||
|
||||
Style : bandeau jaune doux (`bg-warning-100 border-warning-300`), sous le picker, au-dessus des onglets.
|
||||
|
||||
### Composables
|
||||
|
||||
**Nouveau `useEmployeeContractPhase()`** :
|
||||
- État : `selectedPhase`, `currentPhase` (computed depuis `employee.contractPhases`).
|
||||
- Computed : `availablePhases`, `isViewingPastPhase`.
|
||||
- API : `setSelectedPhase(phaseId)`, `resetToCurrent()`.
|
||||
- `resetToCurrent()` appelé au changement d'employé.
|
||||
|
||||
**`useEmployeeLeave`** :
|
||||
- Reçoit `phaseId` en paramètre lors des appels à `getEmployeeLeaveSummary` / `listAbsences`.
|
||||
- `availableLeaveYears` borné aux exercices intersectant la phase sélectionnée.
|
||||
- `setSelectedPhase` côté parent → reset de `selectedLeaveYear` et reload.
|
||||
|
||||
**`useEmployeeRtt`** :
|
||||
- Idem pour `getEmployeeRttSummary`, `availableRttYears`.
|
||||
|
||||
**`useEmployeeDetailPage`** :
|
||||
- `showRttTab` devient : `selectedPhase.contractType !== FORFAIT`.
|
||||
- La logique de fallback ("si sur l'onglet RTT et FORFAIT, basculer ailleurs") s'applique aussi quand on bascule de phase 39h vers phase FORFAIT.
|
||||
|
||||
### Onglets
|
||||
|
||||
- **Congés** : reçoit la phase via le composable. Bouton "Année N-1 payés" / "Jours fractionnés" reste désactivé sur phase passée (idem que sur exercice passé).
|
||||
- **RTT** : visibilité driver par la phase. Bouton "+ Payer les RTT" activé **uniquement sur le dernier exercice de la phase passée**, désactivé sur les exercices antérieurs de la phase.
|
||||
- **Heures, Frais, Formation, Contrat, Calendrier** : non impactés.
|
||||
|
||||
### Format des libellés du picker
|
||||
|
||||
Format de la phase : `{labelContractType} — {startDateFR} → {endDateFR}`, suffixé `(actuel)` si phase courante.
|
||||
|
||||
`labelContractType` mapping :
|
||||
- `FORFAIT` → `FORFAIT`
|
||||
- `THIRTY_FIVE_HOURS` → `35h`
|
||||
- `THIRTY_NINE_HOURS` → `39h`
|
||||
- `INTERIM` → `Intérim`
|
||||
- `CUSTOM` → `CUSTOM ({weeklyHours}h)` (les heures hebdo sont homogènes par construction dans une phase)
|
||||
|
||||
Suffixe `(driver)` ajouté quand `isDriver=true`, ex. `35h CDI (driver) — ...`.
|
||||
|
||||
## Migration et impact sur l'existant
|
||||
|
||||
- Aucune migration de données. Le concept de phase est calculé à la volée depuis l'historique existant.
|
||||
- Comportement inchangé pour tout employé avec une seule phase (cas standard).
|
||||
- Comportement inchangé quand `phaseId` n'est pas fourni → phase courante.
|
||||
- Pas de breaking change API : `contractPhases` est un champ additionnel ; `?phaseId` est un paramètre optionnel.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit
|
||||
|
||||
- `EmployeeContractPhaseResolverTest` :
|
||||
- Employé mono-période → 1 phase, `isCurrent=true`.
|
||||
- Trois périodes même signature consécutives → 1 phase.
|
||||
- Switch 39h → FORFAIT → 2 phases avec `startDate`/`endDate` correctes.
|
||||
- 39h → INTERIM 4 mois → 39h → 3 phases (pas de fusion).
|
||||
- 35h → 39h → 2 phases (type différent).
|
||||
- CUSTOM 28h → CUSTOM 30h → 2 phases (`weeklyHours` diffère).
|
||||
- 35h non-driver → 35h driver → 2 phases (`isDriver` diffère).
|
||||
|
||||
### Functional
|
||||
|
||||
- `EmployeeLeaveSummaryProvider` avec `phaseId` :
|
||||
- Phase 39h passée → `ruleCode = CDI_CDD_NON_FORFAIT`, période Juin→Mai, exercice de transition capé à `phase.endDate`.
|
||||
- Phase FORFAIT passée → `ruleCode = FORFAIT_218`, période Jan→Déc.
|
||||
- `phaseId` invalide pour l'employé → 422.
|
||||
- `?year` hors de la plage de la phase → clamp silencieux à l'exercice intersectant le plus proche.
|
||||
|
||||
- `EmployeeRttSummaryProvider` avec `phaseId` :
|
||||
- Phase 39h passée → données RTT renvoyées, bornes cappées sur `phase.endDate`.
|
||||
- `?year` hors de la plage de la phase → clamp silencieux.
|
||||
|
||||
- `EmployeeRttPaymentProcessor` :
|
||||
- Création autorisée sur exercice de fin d'une phase passée.
|
||||
- Création refusée sur un exercice antérieur d'une phase passée.
|
||||
|
||||
### Documentation à mettre à jour
|
||||
|
||||
Obligatoire par CLAUDE.md :
|
||||
|
||||
- `doc/contract-phase-view.md` — nouveau fichier détaillant la fonctionnalité.
|
||||
- `doc/leave-tab.md` — section "Sélecteur de phase" + interaction avec le sélecteur d'année.
|
||||
- `doc/rtt-tab.md` — section "Sélecteur de phase" + règle de visibilité.
|
||||
- `frontend/data/documentation-content.ts` — article niveau `admin`.
|
||||
- `CLAUDE.md` — bloc "Vue contrat (sélecteur de phase)" sous Onglet Congés / Onglet RTT.
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Surface d'alerte automatique sur les fiches employés ayant des soldes non payés sur des phases passées (potentiel follow-up).
|
||||
- Persistance de la sélection du picker entre navigations.
|
||||
- Picker exposé sur le calendrier global ou tout autre écran que la fiche employé.
|
||||
- Modification de `WorkHourDayContext` (déjà date-driven, pas concerné).
|
||||
- Évolution du mécanisme `paidLeaveSettledClosureDate` (canal existant suffisant).
|
||||
- Cas exotiques : phases overlap (interdit par la modélisation actuelle), périodes avec dates incohérentes.
|
||||
|
||||
## Décisions confirmées avec l'utilisateur
|
||||
|
||||
- Picker global en haut de la fiche, **pas** par onglet.
|
||||
- Phases groupées par `contract.type` consécutif.
|
||||
- Sur une phase passée : exercices antérieurs visibles **en lecture seule**, seul le dernier exercice de la phase ouvre les actions de solde (RTT pay, CP settlement period-level).
|
||||
- Comportement par défaut (phase courante) strictement inchangé.
|
||||
@@ -1 +0,0 @@
|
||||
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||
@@ -3,11 +3,3 @@
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { load } = useAppVersion()
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
|
||||
:options="employeeOptions"
|
||||
label="Employé *"
|
||||
empty-option-label="Choisir un employé"
|
||||
min-width=""
|
||||
:disabled="props.lockEmployee"
|
||||
:error="showEmployeeError ? `L'employé est obligatoire.` : ''"
|
||||
@update:model-value="onEmployeeChange"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
|
||||
:options="typeOptions"
|
||||
label="Type d'absence *"
|
||||
empty-option-label="Choisir un type"
|
||||
min-width=""
|
||||
:error="showTypeError ? `Le type d'absence est obligatoire.` : ''"
|
||||
@update:model-value="onTypeChange"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="start-date">Début</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
id="start-date"
|
||||
v-model="absenceForm.startDate"
|
||||
type="date"
|
||||
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
|
||||
:disabled="props.lockDates"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.startHalf"
|
||||
:options="halfDayOptions"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="end-date">Fin</label>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
id="end-date"
|
||||
v-model="absenceForm.endDate"
|
||||
type="date"
|
||||
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
|
||||
:disabled="props.lockDates"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.endHalf"
|
||||
:options="halfDayOptions"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.showComment !== false">
|
||||
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
v-model="absenceForm.comment"
|
||||
rows="3"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="editingAbsence" class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||
@click="handleDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<MalioButton
|
||||
type="submit"
|
||||
label="Valider"
|
||||
button-class="w-[200px]"
|
||||
:disabled="props.isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, toRef, watch } from 'vue'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import type { Absence } from '~/services/dto/absence'
|
||||
import type { HalfDay } from '~/services/dto/half-day'
|
||||
import { HALF_DAYS } from '~/services/dto/half-day'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
employees: Employee[]
|
||||
absenceTypes: AbsenceType[]
|
||||
form: {
|
||||
employeeId: number | ''
|
||||
typeId: number | ''
|
||||
startDate: string
|
||||
startHalf: HalfDay
|
||||
endDate: string
|
||||
endHalf: HalfDay
|
||||
comment: string
|
||||
}
|
||||
editingAbsence: Absence | null
|
||||
isSubmitting: boolean
|
||||
lockEmployee?: boolean
|
||||
lockDates?: boolean
|
||||
showComment?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit'): void
|
||||
(event: 'delete'): void
|
||||
(event: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const absenceForm = toRef(props, 'form')
|
||||
const editingAbsence = toRef(props, 'editingAbsence')
|
||||
|
||||
const validationTouched = reactive({
|
||||
employee: false,
|
||||
type: false
|
||||
})
|
||||
|
||||
const isEmployeeValid = computed(() => absenceForm.value.employeeId !== '')
|
||||
const isTypeValid = computed(() => absenceForm.value.typeId !== '')
|
||||
const isFormValid = computed(() => isEmployeeValid.value && isTypeValid.value)
|
||||
|
||||
const showEmployeeError = computed(
|
||||
() => validationTouched.employee && !isEmployeeValid.value
|
||||
)
|
||||
const showTypeError = computed(
|
||||
() => validationTouched.type && !isTypeValid.value
|
||||
)
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (props.isSubmitting || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const employeeOptions = computed(() =>
|
||||
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
|
||||
)
|
||||
const typeOptions = computed(() =>
|
||||
props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id }))
|
||||
)
|
||||
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
|
||||
|
||||
const dateInputBaseClass =
|
||||
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
|
||||
|
||||
const onEmployeeChange = (value: string | number | null) => {
|
||||
absenceForm.value.employeeId = value === null ? '' : Number(value)
|
||||
}
|
||||
const onTypeChange = (value: string | number | null) => {
|
||||
absenceForm.value.typeId = value === null ? '' : Number(value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.employee = false
|
||||
validationTouched.type = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubmit = () => {
|
||||
validationTouched.employee = true
|
||||
validationTouched.type = true
|
||||
if (!isEmployeeValid.value || !isTypeValid.value) return
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
@@ -1,242 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Imprimer les absences">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="print-from">
|
||||
Date de début <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="print-from"
|
||||
v-model="printForm.from"
|
||||
type="date"
|
||||
:class="fromFieldClass"
|
||||
/>
|
||||
<p v-if="showFromError" class="mt-1 text-sm text-red-600">
|
||||
La date de début est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="print-to">
|
||||
Date de fin <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="print-to"
|
||||
v-model="printForm.to"
|
||||
type="date"
|
||||
:class="toFieldClass"
|
||||
/>
|
||||
<p v-if="showToError" class="mt-1 text-sm text-red-600">
|
||||
La date de fin est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-md font-semibold text-neutral-700">
|
||||
Sites <span class="text-red-600">*</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
||||
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
||||
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
|
||||
<label class="text-md" :for="`print-site-${site.id}`">{{ site.name }}</label>
|
||||
<input
|
||||
:id="`print-site-${site.id}`"
|
||||
v-model="printForm.siteIds"
|
||||
:value="site.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="showSitesError" class="text-sm text-red-600">
|
||||
Sélectionne au moins un site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-md font-semibold text-neutral-700">
|
||||
Type de contrat <span class="text-red-600">*</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
||||
<div v-for="nature in contractNatures" :key="nature.value" class="flex items-center gap-2">
|
||||
<label class="text-md" :for="`print-contract-nature-${nature.value}`">{{ nature.label }}</label>
|
||||
<input
|
||||
:id="`print-contract-nature-${nature.value}`"
|
||||
v-model="printForm.contractNatures"
|
||||
:value="nature.value"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="showContractNaturesError" class="text-sm text-red-600">
|
||||
Sélectionne au moins un type de contrat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-md font-semibold text-neutral-700">
|
||||
Temps de travail <span class="text-red-600">*</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
||||
<div v-for="workContract in workContracts" :key="workContract.id" class="flex items-center gap-2">
|
||||
<label class="text-md" :for="`print-work-contract-${workContract.id}`">{{ workContract.name }}</label>
|
||||
<input
|
||||
:id="`print-work-contract-${workContract.id}`"
|
||||
v-model="printForm.workContractIds"
|
||||
:value="workContract.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="showWorkContractsError" class="text-sm text-red-600">
|
||||
Sélectionne au moins un temps de travail.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, toRef, watch } from 'vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
type SiteOption = {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
type ContractNatureOption = {
|
||||
value: 'CDI' | 'CDD' | 'INTERIM'
|
||||
label: string
|
||||
}
|
||||
|
||||
type WorkContractOption = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
sites: SiteOption[]
|
||||
contractNatures: ContractNatureOption[]
|
||||
workContracts: WorkContractOption[]
|
||||
printForm: {
|
||||
from: string
|
||||
to: string
|
||||
siteIds: number[]
|
||||
contractNatures: Array<'CDI' | 'CDD' | 'INTERIM'>
|
||||
workContractIds: number[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit'): void
|
||||
(event: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const printForm = toRef(props, 'printForm')
|
||||
|
||||
const validationTouched = reactive({
|
||||
from: false,
|
||||
to: false,
|
||||
sites: false,
|
||||
contractNatures: false,
|
||||
workContracts: false
|
||||
})
|
||||
|
||||
const isFromValid = computed(() => printForm.value.from.trim() !== '')
|
||||
const isToValid = computed(() => printForm.value.to.trim() !== '')
|
||||
const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
|
||||
const isContractNaturesValid = computed(() => {
|
||||
if (props.contractNatures.length === 0) return true
|
||||
return printForm.value.contractNatures.length > 0
|
||||
})
|
||||
const isWorkContractsValid = computed(() => {
|
||||
if (props.workContracts.length === 0) return true
|
||||
return printForm.value.workContractIds.length > 0
|
||||
})
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
isFromValid.value &&
|
||||
isToValid.value &&
|
||||
isSitesValid.value &&
|
||||
isContractNaturesValid.value &&
|
||||
isWorkContractsValid.value
|
||||
)
|
||||
|
||||
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
|
||||
const showToError = computed(() => validationTouched.to && !isToValid.value)
|
||||
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
|
||||
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
|
||||
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||
const fromFieldClass = computed(() => {
|
||||
if (showFromError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const toFieldClass = computed(() => {
|
||||
if (showToError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (!isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
validationTouched.from = true
|
||||
validationTouched.to = true
|
||||
validationTouched.sites = true
|
||||
validationTouched.contractNatures = true
|
||||
validationTouched.workContracts = true
|
||||
if (!isFormValid.value) return
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.from = false
|
||||
validationTouched.to = false
|
||||
validationTouched.sites = false
|
||||
validationTouched.contractNatures = false
|
||||
validationTouched.workContracts = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -4,20 +4,20 @@
|
||||
<div class="absolute inset-0 bg-black/40" @click="close" />
|
||||
</Transition>
|
||||
<Transition name="drawer-panel">
|
||||
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl flex flex-col">
|
||||
<div class="shrink-0 flex items-center justify-between px-[20px] pt-8 pb-8">
|
||||
<h2 class="text-[32px] font-semibold text-primary-500">
|
||||
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 bg-tertiary-500 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-neutral-900">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
|
||||
class="rounded-md p-2 text-neutral-500 hover:bg-neutral-100"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24"/>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
|
||||
<div class="p-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
<template>
|
||||
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 px-4 py-3 text-white lg:p-5">
|
||||
<div class="flex h-full items-center justify-between lg:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1 text-white hover:text-neutral-200 lg:hidden"
|
||||
@click="$emit('toggleSidebar')"
|
||||
>
|
||||
<Icon name="mdi:menu" size="28"/>
|
||||
</button>
|
||||
<div class="flex gap-4 text-xl text-white lg:gap-6">
|
||||
<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-2 z-30 w-[calc(100vw-1rem)] max-w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg lg:right-[20px]"
|
||||
:style="{ top: `${navbarBottom + 10}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="hidden self-center sm:block">{{ 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
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(event: 'toggleSidebar'): void
|
||||
}>()
|
||||
|
||||
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>
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Export heures (tous les employés)">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-year">
|
||||
Année <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="bulk-yearly-hours-year"
|
||||
v-model="selectedYear"
|
||||
:class="selectFieldClass"
|
||||
>
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-month">
|
||||
Mois <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="bulk-yearly-hours-month"
|
||||
v-model="selectedMonth"
|
||||
:class="selectFieldClass"
|
||||
>
|
||||
<option value="" disabled>Sélectionner un mois</option>
|
||||
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isLoading || selectedMonth === ''"
|
||||
>
|
||||
<template v-if="isLoading">
|
||||
Génération en cours...
|
||||
</template>
|
||||
<template v-else>
|
||||
Imprimer
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
isLoading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit', payload: { year: number; month: number | null }): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
||||
const months = [
|
||||
{ value: 1, label: 'Janvier' },
|
||||
{ value: 2, label: 'Février' },
|
||||
{ value: 3, label: 'Mars' },
|
||||
{ value: 4, label: 'Avril' },
|
||||
{ value: 5, label: 'Mai' },
|
||||
{ value: 6, label: 'Juin' },
|
||||
{ value: 7, label: 'Juillet' },
|
||||
{ value: 8, label: 'Août' },
|
||||
{ value: 9, label: 'Septembre' },
|
||||
{ value: 10, label: 'Octobre' },
|
||||
{ value: 11, label: 'Novembre' },
|
||||
{ value: 12, label: 'Décembre' }
|
||||
]
|
||||
const selectedYear = ref(currentYear)
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
const selectedMonth = ref<number | ''>(currentMonth)
|
||||
|
||||
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedMonth.value === '') return
|
||||
emit('submit', {
|
||||
year: selectedYear.value,
|
||||
month: selectedMonth.value
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
selectedYear.value = currentYear
|
||||
selectedMonth.value = currentMonth
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||
<div class="min-w-[900px]">
|
||||
<div class="grid" :style="gridStyle" @mouseleave="clearHoveredCell">
|
||||
<div
|
||||
class="sticky left-0 top-0 z-30 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
|
||||
>
|
||||
Employés
|
||||
</div>
|
||||
<div
|
||||
v-for="day in daysInMonth"
|
||||
: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="isHoveredColumn(day.date) || day.date === today ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
|
||||
>
|
||||
<div>{{ day.label }}</div>
|
||||
<div
|
||||
class="text-[10px]"
|
||||
:class="isHoveredColumn(day.date) || day.date === today ? 'text-white/90' : 'text-neutral-500'"
|
||||
>
|
||||
{{ day.weekday }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="employee in visibleEmployees" :key="employee.id">
|
||||
<div
|
||||
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer transition-shadow"
|
||||
:class="isHoveredRow(employee.id) ? 'bg-primary-500 text-white ring-2 ring-inset ring-primary-500/40' : ''"
|
||||
:style="rowHeaderStyle(employee)"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, employee)"
|
||||
@dragover="handleDragOver"
|
||||
@drop="handleDrop($event, employee)"
|
||||
>
|
||||
{{ formatEmployeeName(employee) }}
|
||||
</div>
|
||||
<div
|
||||
v-for="day in daysInMonth"
|
||||
:key="employee.id + '-' + day.date"
|
||||
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 transition-colors"
|
||||
:class="cellContainerClass(employee.id, day.date)"
|
||||
@mouseenter="setHoveredCell(employee.id, day.date)"
|
||||
>
|
||||
<template v-if="getCellInfo(employee.id, day.date)">
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
|
||||
:class="getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
|
||||
:style="getCellStyle(employee.id, day.date)"
|
||||
:disabled="getCellInfo(employee.id, day.date)?.hasFormation"
|
||||
@click="handleCellClick(employee, day.date)"
|
||||
>
|
||||
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
|
||||
{{ getCellInfo(employee.id, day.date)?.code }}
|
||||
</span>
|
||||
<template v-else>
|
||||
<span
|
||||
v-if="getCellInfo(employee.id, day.date)?.halfLabel === 'AM'"
|
||||
class="absolute top-0 left-0 flex h-1/2 w-full items-center justify-center text-[10px] font-semibold"
|
||||
>
|
||||
{{ getCellInfo(employee.id, day.date)?.code }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="absolute bottom-0 left-0 flex h-1/2 w-full items-center justify-center text-[10px] font-semibold"
|
||||
>
|
||||
{{ getCellInfo(employee.id, day.date)?.code }}
|
||||
</span>
|
||||
</template>
|
||||
<Icon
|
||||
v-if="getCellInfo(employee.id, day.date)?.hasFormation && getCellInfo(employee.id, day.date)?.code !== 'F'"
|
||||
name="mdi:school"
|
||||
size="12"
|
||||
class="absolute top-0 right-0 text-indigo-600 bg-white rounded-bl-md p-0.5"
|
||||
title="Formation"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white"
|
||||
:style="getCellStyle(employee.id, day.date)"
|
||||
@click="handleCellClick(employee, day.date)"
|
||||
>
|
||||
<span></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
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 = {
|
||||
date: string
|
||||
label: string
|
||||
weekday: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
daysInMonth: DayInfo[]
|
||||
visibleEmployees: Employee[]
|
||||
gridStyle: Record<string, string>
|
||||
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
|
||||
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string; hasFormation?: boolean } | null
|
||||
formatEmployeeName: (employee: Employee) => string
|
||||
isHolidayDate: (date: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'cell-click', employee: Employee, date: string): void
|
||||
(event: 'reorder', payload: { dragId: number; dropId: number }): void
|
||||
}>()
|
||||
|
||||
const handleCellClick = (employee: Employee, date: string) => {
|
||||
emit('cell-click', employee, date)
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragEvent, employee: Employee) => {
|
||||
if (!event.dataTransfer) return
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', String(employee.id))
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent, employee: Employee) => {
|
||||
event.preventDefault()
|
||||
const dragId = Number(event.dataTransfer?.getData('text/plain'))
|
||||
if (!dragId || dragId === employee.id) return
|
||||
emit('reorder', { dragId, dropId: employee.id })
|
||||
}
|
||||
|
||||
// Etat de la cellule actuellement survolee.
|
||||
const hoveredEmployeeId = ref<number | null>(null)
|
||||
const hoveredDate = ref<string | null>(null)
|
||||
|
||||
const setHoveredCell = (employeeId: number, date: string) => {
|
||||
hoveredEmployeeId.value = employeeId
|
||||
hoveredDate.value = date
|
||||
}
|
||||
|
||||
const clearHoveredCell = () => {
|
||||
hoveredEmployeeId.value = null
|
||||
hoveredDate.value = null
|
||||
}
|
||||
|
||||
const isHoveredRow = (employeeId: number) => hoveredEmployeeId.value === employeeId
|
||||
|
||||
const isHoveredColumn = (date: string) => hoveredDate.value === date
|
||||
|
||||
// On garde la couleur du site tant que la ligne n'est pas survolee.
|
||||
const rowHeaderStyle = (employee: Employee) => {
|
||||
if (isHoveredRow(employee.id)) return undefined
|
||||
return { backgroundColor: employee.site?.color ?? '#304998' }
|
||||
}
|
||||
|
||||
// Index de ligne par employe pour savoir si une case est "au-dessus" de la case survolee.
|
||||
const employeeIndexById = computed(() => {
|
||||
const indexMap = new Map<number, number>()
|
||||
props.visibleEmployees.forEach((employee, index) => {
|
||||
indexMap.set(employee.id, index)
|
||||
})
|
||||
return indexMap
|
||||
})
|
||||
|
||||
const cellContainerClass = (employeeId: number, date: string) => {
|
||||
if (!hoveredEmployeeId.value || !hoveredDate.value) return 'hover:bg-primary-500'
|
||||
|
||||
const hoveredRowIndex = employeeIndexById.value.get(hoveredEmployeeId.value)
|
||||
const currentRowIndex = employeeIndexById.value.get(employeeId)
|
||||
|
||||
// Forme en L:
|
||||
// - ligne: toutes les cases a gauche (et la case cible)
|
||||
// - colonne: toutes les cases au-dessus (et la case cible)
|
||||
const isOnLeftSegment = isHoveredRow(employeeId) && date <= hoveredDate.value
|
||||
const isOnTopSegment = isHoveredColumn(date)
|
||||
&& typeof hoveredRowIndex === 'number'
|
||||
&& typeof currentRowIndex === 'number'
|
||||
&& currentRowIndex <= hoveredRowIndex
|
||||
|
||||
if (isOnLeftSegment || isOnTopSegment) return 'bg-primary-500'
|
||||
return 'hover:bg-primary-500'
|
||||
}
|
||||
</script>
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Export heures">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
|
||||
Année <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="yearly-hours-year"
|
||||
v-model="selectedYear"
|
||||
:class="selectFieldClass"
|
||||
>
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-month">
|
||||
Mois
|
||||
</label>
|
||||
<select
|
||||
id="yearly-hours-month"
|
||||
v-model="selectedMonth"
|
||||
:class="selectFieldClass"
|
||||
>
|
||||
<option value="">Toute l'année</option>
|
||||
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
employeeId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit', payload: { year: number; month: number | null }): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
||||
const months = [
|
||||
{ value: 1, label: 'Janvier' },
|
||||
{ value: 2, label: 'Février' },
|
||||
{ value: 3, label: 'Mars' },
|
||||
{ value: 4, label: 'Avril' },
|
||||
{ value: 5, label: 'Mai' },
|
||||
{ value: 6, label: 'Juin' },
|
||||
{ value: 7, label: 'Juillet' },
|
||||
{ value: 8, label: 'Août' },
|
||||
{ value: 9, label: 'Septembre' },
|
||||
{ value: 10, label: 'Octobre' },
|
||||
{ value: 11, label: 'Novembre' },
|
||||
{ value: 12, label: 'Décembre' }
|
||||
]
|
||||
const selectedYear = ref(currentYear)
|
||||
const selectedMonth = ref<number | ''>('')
|
||||
|
||||
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', {
|
||||
year: selectedYear.value,
|
||||
month: selectedMonth.value === '' ? null : selectedMonth.value
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
selectedYear.value = currentYear
|
||||
selectedMonth.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<div class="relative inline-flex h-10 items-center overflow-hidden rounded-md border border-primary-500 bg-white" :class="widthClass">
|
||||
<input
|
||||
ref="nativeInput"
|
||||
:value="pickerValue"
|
||||
:type="pickerType"
|
||||
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
@input="onPickerInput"
|
||||
@change="onPickerInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||
:aria-label="prevAriaLabel"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500"
|
||||
@click="openPicker"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||
:aria-label="nextAriaLabel"
|
||||
@click="emit('next')"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
label: string
|
||||
pickerType: 'date' | 'week' | 'month'
|
||||
pickerValue: string
|
||||
widthClass?: string
|
||||
prevAriaLabel?: string
|
||||
nextAriaLabel?: string
|
||||
}>(), {
|
||||
widthClass: 'w-[320px]',
|
||||
prevAriaLabel: 'Précédent',
|
||||
nextAriaLabel: 'Suivant'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'prev'): void
|
||||
(e: 'next'): void
|
||||
(e: 'pick', value: string): void
|
||||
}>()
|
||||
|
||||
const nativeInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const openPicker = () => {
|
||||
const input = nativeInput.value
|
||||
if (!input) return
|
||||
if (typeof input.showPicker === 'function') {
|
||||
input.showPicker()
|
||||
return
|
||||
}
|
||||
input.focus()
|
||||
input.click()
|
||||
}
|
||||
|
||||
const onPickerInput = (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
if (!value) return
|
||||
emit('pick', value)
|
||||
}
|
||||
</script>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Récapitulatif Salaire">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="salary-recap-month">
|
||||
Mois <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="salary-recap-month"
|
||||
v-model="selectedMonth"
|
||||
type="month"
|
||||
:class="monthFieldClass"
|
||||
/>
|
||||
<p v-if="showMonthError" class="mt-1 text-sm text-red-600">
|
||||
Le mois est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit', month: string): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const selectedMonth = ref(defaultMonth)
|
||||
const validationTouched = ref(false)
|
||||
|
||||
const isMonthValid = computed(() => selectedMonth.value.trim() !== '')
|
||||
const showMonthError = computed(() => validationTouched.value && !isMonthValid.value)
|
||||
|
||||
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||
const monthFieldClass = computed(() => {
|
||||
if (showMonthError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (!isMonthValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
validationTouched.value = true
|
||||
if (!isMonthValid.value) return
|
||||
emit('submit', selectedMonth.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<article :id="`doc-${article.id}`" class="scroll-mt-6">
|
||||
<h3 class="text-lg font-bold text-primary-500 mb-3">{{ article.title }}</h3>
|
||||
<div class="space-y-3">
|
||||
<template v-for="(block, idx) in article.blocks" :key="idx">
|
||||
<p v-if="block.type === 'paragraph'" class="text-sm text-neutral-700 leading-relaxed">
|
||||
{{ block.content }}
|
||||
</p>
|
||||
<ul v-else-if="block.type === 'list'" class="list-disc list-inside space-y-1 text-sm text-neutral-700 pl-2">
|
||||
<li v-for="(item, i) in block.content.split('\n')" :key="i">{{ item }}</li>
|
||||
</ul>
|
||||
<div v-else-if="block.type === 'note'" class="bg-tertiary-500 border-l-4 border-primary-500 p-3 rounded-r-md">
|
||||
<p class="text-sm text-neutral-700 leading-relaxed">{{ block.content }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DocArticle } from '~/types/documentation'
|
||||
|
||||
defineProps<{
|
||||
article: DocArticle
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<div class="h-full flex gap-8">
|
||||
<!-- Table des matières -->
|
||||
<nav class="w-64 flex-shrink-0 overflow-y-auto pr-4 border-r border-neutral-200">
|
||||
<h1 class="text-xl font-bold text-primary-500 mb-6">Documentation</h1>
|
||||
<div v-for="section in visibleSections" :key="section.id" class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Icon :name="section.icon" size="18" class="text-neutral-500"/>
|
||||
<span class="text-sm font-semibold text-neutral-700">{{ section.title }}</span>
|
||||
</div>
|
||||
<ul class="pl-7 space-y-0.5">
|
||||
<li v-for="article in section.articles" :key="article.id">
|
||||
<button
|
||||
class="text-xs text-neutral-500 hover:text-primary-500 text-left w-full py-0.5 transition-colors"
|
||||
:class="activeArticleId === article.id ? 'text-primary-500 font-bold' : ''"
|
||||
@click="scrollToArticle(article.id)"
|
||||
>
|
||||
{{ article.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div ref="contentRef" class="flex-1 overflow-y-auto pr-4">
|
||||
<DocumentationSection
|
||||
v-for="section in visibleSections"
|
||||
:key="section.id"
|
||||
:section="section"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { visibleSections, activeArticleId, scrollToArticle } = useDocumentation()
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (!contentRef.value) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.id.replace('doc-', '')
|
||||
activeArticleId.value = id
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: contentRef.value,
|
||||
rootMargin: '-10% 0px -80% 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
|
||||
nextTick(() => {
|
||||
const articles = contentRef.value?.querySelectorAll('[id^="doc-"]')
|
||||
articles?.forEach(el => observer.observe(el))
|
||||
})
|
||||
|
||||
onUnmounted(() => observer.disconnect())
|
||||
})
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<section class="mb-10">
|
||||
<div class="flex items-center gap-3 border-b-2 border-primary-500 pb-3 mb-6">
|
||||
<Icon :name="section.icon" size="28" class="text-primary-500"/>
|
||||
<h2 class="text-xl font-bold text-primary-500">{{ section.title }}</h2>
|
||||
</div>
|
||||
<div class="space-y-8 pl-2">
|
||||
<DocumentationArticle
|
||||
v-for="article in section.articles"
|
||||
:key="article.id"
|
||||
:article="article"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DocSection } from '~/types/documentation'
|
||||
|
||||
defineProps<{
|
||||
section: DocSection
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,243 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<div class="overflow-y-auto min-h-0">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span class="pl-2">Statut</span>
|
||||
<span class="pl-4">Heure de jour</span>
|
||||
<span class="pl-2">Heure de nuit</span>
|
||||
<span class="pl-2">Heure atelier</span>
|
||||
<span class="pl-2">Total</span>
|
||||
<span>Petit déj.</span>
|
||||
<span>Déjeuner</span>
|
||||
<span>Dîner</span>
|
||||
<span>Nuitée</span>
|
||||
<span v-if="isAdmin" class="flex justify-between items-center">
|
||||
<span>Valider</span>
|
||||
<input
|
||||
ref="bulkValidationInput"
|
||||
:checked="isBulkValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onBulkValidationChange"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
title="Validation site"
|
||||
>
|
||||
<Icon name="mdi:check"/>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
|
||||
Modifié le {{ getRowUpdatedAt(employee.id) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<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>
|
||||
<p
|
||||
v-if="isHoliday"
|
||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
|
||||
style="background-color: #b3e5fc"
|
||||
:title="holidayLabel || 'Férié'"
|
||||
>
|
||||
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="self-start text-left text-xs font-semibold underline"
|
||||
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||
@click="onAbsenceClick(employee.id)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].dayHours"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
<p
|
||||
v-if="isHoliday && getRowMetrics(employee.id).virtualHolidayMinutes > 0"
|
||||
class="mt-1 text-xs font-semibold text-sky-700"
|
||||
>
|
||||
= {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }} (férié)
|
||||
</p>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].nightHours"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].workshopHours"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2 text-sm font-semibold">
|
||||
{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input
|
||||
v-model="rows[employee.id].hasBreakfast"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input
|
||||
v-model="rows[employee.id].hasLunch"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input
|
||||
v-model="rows[employee.id].hasDinner"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input
|
||||
v-model="rows[employee.id].hasOvernight"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="text-right">
|
||||
<input
|
||||
:checked="rows[employee.id]?.isValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!isSiteManager" class="text-right p-5">
|
||||
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
<div v-if="!isAdmin">
|
||||
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||
import type { DriverHourRow } from '~/services/dto/work-hour'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
|
||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
employees: Employee[]
|
||||
isAdmin: boolean
|
||||
isSiteManager: boolean
|
||||
dayGridCols: string
|
||||
isHoliday: boolean
|
||||
holidayLabel: string
|
||||
contractLabel: (employee: Employee) => string
|
||||
isRowLocked: (employeeId: number) => boolean
|
||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||
isValidationPending: (employeeId: number) => boolean
|
||||
isSiteValidationPending: (employeeId: number) => boolean
|
||||
canToggleValidation: (employeeId: number) => boolean
|
||||
canToggleSiteValidation: (employeeId: number) => boolean
|
||||
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
|
||||
isBulkValidationChecked: boolean
|
||||
isBulkValidationIndeterminate: boolean
|
||||
isBulkSiteValidationChecked: boolean
|
||||
isBulkSiteValidationIndeterminate: boolean
|
||||
canBulkToggleSiteValidation: boolean
|
||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
formatMinutes: (minutes: number) => string
|
||||
}>()
|
||||
|
||||
const onBulkValidationChange = (event: Event) => {
|
||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onBulkSiteValidationChange = (event: Event) => {
|
||||
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||
props.onToggleSiteValidation(employeeId, checked)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isBulkValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkValidationInput.value) return
|
||||
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.isBulkSiteValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkSiteValidationInput.value) return
|
||||
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,132 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
||||
<div v-else class="overflow-y-auto min-h-0">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: weekGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.weekday }}<br>{{ day.dayDate }}</span>
|
||||
<span>Jour/Nuit <br>sem.</span>
|
||||
<span>Atelier <br>sem.</span>
|
||||
<span>Total <br>sem.</span>
|
||||
<span>Total <br>h. supp.</span>
|
||||
<span>+25%</span>
|
||||
<span>+50%</span>
|
||||
<span>Total <br>récup.</span>
|
||||
<span>Petit <br>déj.</span>
|
||||
<span>Déj.</span>
|
||||
<span>Dîner</span>
|
||||
<span>Nuit.</span>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="row in weeklySummary?.rows ?? []"
|
||||
:key="row.employeeId"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||
:style="{ gridTemplateColumns: weekGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ row.firstName }} {{ row.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
||||
</p>
|
||||
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span></span>
|
||||
<button v-if="isAdmin" type="button" class="inline-flex items-center justify-center rounded-md p-1 text-white transition-colors" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
|
||||
<Icon name="mdi:comment-text-outline" size="12"/>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="daily in row.daily"
|
||||
:key="daily.date"
|
||||
class="text-left leading-4 rounded-md px-2 py-1"
|
||||
:class="daily.hasAbsence ? 'text-white' : ''"
|
||||
:style="getDailyCellStyle(daily)"
|
||||
:title="cellTitle(daily)"
|
||||
>
|
||||
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
|
||||
<div v-if="daily.workshopMinutes">A {{ formatMinutes(daily.workshopMinutes) }}</div>
|
||||
<div v-if="daily.hasBreakfast || daily.hasLunch || daily.hasDinner || daily.hasOvernight" class="text-[10px] flex gap-1 mt-0.5">
|
||||
<span v-if="daily.hasBreakfast" title="Petit déjeuner">PD</span>
|
||||
<span v-if="daily.hasLunch" title="Déjeuner">DJ</span>
|
||||
<span v-if="daily.hasDinner" title="Dîner">DI</span>
|
||||
<span v-if="daily.hasOvernight" title="Nuitée">NU</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold leading-4">
|
||||
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
|
||||
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyWorkshopMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyTotalMinutes) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">{{ row.weeklyBreakfastCount ?? 0 }}</div>
|
||||
<div class="font-semibold">{{ row.weeklyLunchCount ?? 0 }}</div>
|
||||
<div class="font-semibold">{{ row.weeklyDinnerCount ?? 0 }}</div>
|
||||
<div class="font-semibold">{{ row.weeklyOvernightCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const HOLIDAY_BG_COLOR = '#b3e5fc'
|
||||
|
||||
const getDailyCellStyle = (daily: {
|
||||
hasAbsence?: boolean
|
||||
absenceColor?: string | null
|
||||
holidayLabel?: string | null
|
||||
}) => {
|
||||
if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
|
||||
if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cellTitle = (daily: {
|
||||
hasAbsence?: boolean
|
||||
absenceLabel?: string | null
|
||||
holidayLabel?: string | null
|
||||
}) => {
|
||||
const parts: string[] = []
|
||||
if (daily.absenceLabel) parts.push(daily.absenceLabel)
|
||||
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
|
||||
return parts.join(' — ')
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
isWeekLoading: boolean
|
||||
isAdmin: boolean
|
||||
weekGridCols: string
|
||||
weeklySummary: WeeklyWorkHourSummary | null
|
||||
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
|
||||
formatMinutes: (minutes: number) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
|
||||
</script>
|
||||
@@ -1,207 +0,0 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden bg-white">
|
||||
<div
|
||||
class="grid grid-cols-3 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||
<p>Mois</p>
|
||||
<p>Montant €</p>
|
||||
<p>Commentaire</p>
|
||||
</div>
|
||||
<div v-if="bonuses.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
|
||||
Aucune prime.
|
||||
</div>
|
||||
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="item in bonuses"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-3 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||
@click="onOpenEditDrawer(item)"
|
||||
>
|
||||
<p>{{ formatMonth(item.month) }}</p>
|
||||
<p>{{ item.amount }} €</p>
|
||||
<p>{{ item.comment ?? '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mb-4 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="onOpenCreateDrawer"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification prime' : 'Nouvelle prime'">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="bonus-month">
|
||||
Mois <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="bonus-month"
|
||||
v-model="form.month"
|
||||
type="month"
|
||||
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="bonus-amount">
|
||||
Montant (€) <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="bonus-amount"
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="bonus-comment">
|
||||
Commentaire
|
||||
</label>
|
||||
<textarea
|
||||
id="bonus-comment"
|
||||
v-model="form.comment"
|
||||
rows="3"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
placeholder="Commentaire..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||
@click="onDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Bonus } from '~/services/dto/bonus'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
bonuses: Bonus[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'create', data: { month: string; amount: number; comment?: string }): void
|
||||
(event: 'update', id: number, data: { month: string; amount: number; comment?: string }): void
|
||||
(event: 'delete', id: number): void
|
||||
}>()
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingItem = ref<Bonus | null>(null)
|
||||
|
||||
const currentYearMonth = () => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
month: currentYearMonth(),
|
||||
amount: 0,
|
||||
comment: ''
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.month && form.amount > 0
|
||||
})
|
||||
|
||||
const monthLabels: Record<number, string> = {
|
||||
1: 'Janvier',
|
||||
2: 'Février',
|
||||
3: 'Mars',
|
||||
4: 'Avril',
|
||||
5: 'Mai',
|
||||
6: 'Juin',
|
||||
7: 'Juillet',
|
||||
8: 'Août',
|
||||
9: 'Septembre',
|
||||
10: 'Octobre',
|
||||
11: 'Novembre',
|
||||
12: 'Décembre'
|
||||
}
|
||||
|
||||
const formatMonth = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
const month = date.getMonth() + 1
|
||||
const year = date.getFullYear()
|
||||
return `${monthLabels[month]} ${year}`
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.month = currentYearMonth()
|
||||
form.amount = 0
|
||||
form.comment = ''
|
||||
}
|
||||
|
||||
const onOpenCreateDrawer = () => {
|
||||
isEditing.value = false
|
||||
editingItem.value = null
|
||||
resetForm()
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onOpenEditDrawer = (item: Bonus) => {
|
||||
isEditing.value = true
|
||||
editingItem.value = item
|
||||
form.month = item.month.substring(0, 7)
|
||||
form.amount = item.amount
|
||||
form.comment = item.comment ?? ''
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const data = {
|
||||
month: `${form.month}-01`,
|
||||
amount: form.amount,
|
||||
comment: form.comment || undefined
|
||||
}
|
||||
|
||||
if (isEditing.value && editingItem.value) {
|
||||
emit('update', editingItem.value.id, data)
|
||||
} else {
|
||||
emit('create', data)
|
||||
}
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
if (!editingItem.value) return
|
||||
const ok = window.confirm('Supprimer cette prime ?')
|
||||
if (!ok) return
|
||||
emit('delete', editingItem.value.id)
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,380 +0,0 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden bg-white">
|
||||
<div class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||
<p>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 class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="item in contractHistory"
|
||||
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
||||
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||
>
|
||||
<p>{{ item.interimAgencyName ? `${contractNatureLabel(item.contractNature)} (${item.interimAgencyName})` : 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"
|
||||
>
|
||||
Modifier
|
||||
</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="Modifier le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
||||
<div class="mb-4 flex border-b border-neutral-200">
|
||||
<button
|
||||
type="button"
|
||||
class="pb-2 px-4 border-b-2 font-semibold"
|
||||
:class="drawerTab === 'close'
|
||||
? 'border-primary-500 text-primary-500'
|
||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||
@click="drawerTab = 'close'"
|
||||
>
|
||||
Clôturer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pb-2 px-4 border-b-2 font-semibold"
|
||||
:class="drawerTab === 'suspend'
|
||||
? 'border-primary-500 text-primary-500'
|
||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||
@click="drawerTab = 'suspend'"
|
||||
>
|
||||
Suspendre
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="drawerTab === 'close'">
|
||||
<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>
|
||||
|
||||
<WorkDaysHoursInput
|
||||
v-if="contractForm.workDaysHours"
|
||||
:model-value="contractForm.workDaysHours"
|
||||
:contract-weekly-hours="contractForm.weeklyHours ?? null"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<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-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isContractSubmitting || !isContractEndDateValid"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="drawerTab === 'suspend'" class="space-y-6">
|
||||
<div
|
||||
v-for="(form, index) in suspensionForms"
|
||||
:key="form.id ?? `new-${index}`"
|
||||
class="space-y-4 rounded-lg border border-neutral-200 p-4"
|
||||
>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">
|
||||
Date de début <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.startDate"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">
|
||||
Date de fin
|
||||
</label>
|
||||
<input
|
||||
v-model="form.endDate"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">
|
||||
Commentaire
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.comment"
|
||||
rows="3"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white 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>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="!form.startDate || isSuspensionSubmitting"
|
||||
@click="onSubmitSuspension(index)"
|
||||
>
|
||||
{{ form.id ? 'Modifier' : 'Ajouter' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-md border-2 border-dashed border-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
|
||||
@click="onAddSuspensionForm"
|
||||
>
|
||||
+ Ajouter une suspension
|
||||
</button>
|
||||
</div>
|
||||
</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 v-if="createContractForm.contractNature === 'INTERIM'">
|
||||
<label class="text-md font-semibold text-neutral-700" for="create-interim-agency">
|
||||
Agence d'intérim
|
||||
</label>
|
||||
<select
|
||||
id="create-interim-agency"
|
||||
v-model="createContractForm.interimAgencyId"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
>
|
||||
<option value="">Aucune</option>
|
||||
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
|
||||
{{ agency.name }}
|
||||
</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="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
||||
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="create-contract-is-driver">
|
||||
<input
|
||||
id="create-contract-is-driver"
|
||||
v-model="createContractForm.isDriver"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Chauffeur
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<WorkDaysHoursInput
|
||||
v-if="requiresCreateWorkDaysHours"
|
||||
v-model="createContractForm.workDaysHours"
|
||||
:contract-weekly-hours="selectedCreateContract?.weeklyHours ?? null"
|
||||
/>
|
||||
|
||||
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Contract } from '~/services/dto/contract'
|
||||
import type { ContractHistoryItem } from '~/services/dto/employee'
|
||||
import type { InterimAgency } from '~/services/interim-agencies'
|
||||
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
|
||||
|
||||
type SuspensionForm = {
|
||||
id: number | null
|
||||
startDate: string
|
||||
endDate: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
type ContractForm = {
|
||||
contractId: number | ''
|
||||
contractName: string
|
||||
weeklyHours: number | null
|
||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||
startDate: string
|
||||
endDate: string
|
||||
paidLeaveSettled: boolean
|
||||
comment: string
|
||||
workDaysHours: Record<number, number> | null
|
||||
}
|
||||
|
||||
type CreateContractForm = {
|
||||
contractId: number | ''
|
||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||
startDate: string
|
||||
endDate: string
|
||||
isDriver: boolean
|
||||
workDaysHours: Record<number, number> | null
|
||||
interimAgencyId: number | ''
|
||||
}
|
||||
|
||||
const props = 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
|
||||
requiresCreateWorkDaysHours: boolean
|
||||
selectedCreateContract: Contract | null
|
||||
onOpenCloseContractDrawer: () => void
|
||||
onOpenCreateContractDrawer: () => void
|
||||
onUpdateContractDrawerOpen: (open: boolean) => void
|
||||
onUpdateCreateContractDrawerOpen: (open: boolean) => void
|
||||
onSubmitCloseContract: () => void
|
||||
onSubmitCreateContract: () => void
|
||||
suspensionForms: SuspensionForm[]
|
||||
isSuspensionSubmitting: boolean
|
||||
onSubmitSuspension: (index: number) => void
|
||||
onAddSuspensionForm: () => void
|
||||
currentContractPeriodId?: number | null
|
||||
interimAgencies: InterimAgency[]
|
||||
}>()
|
||||
|
||||
const drawerTab = ref<'close' | 'suspend'>('close')
|
||||
|
||||
watch(() => props.isContractDrawerOpen, (open) => {
|
||||
if (open) drawerTab.value = 'close'
|
||||
})
|
||||
</script>
|
||||
@@ -1,251 +0,0 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden bg-white">
|
||||
<div
|
||||
class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||
<p>Date de début</p>
|
||||
<p>Date de fin</p>
|
||||
<p>Justificatif</p>
|
||||
<p>Commentaire</p>
|
||||
</div>
|
||||
<div v-if="formations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
|
||||
Aucune formation.
|
||||
</div>
|
||||
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="item in formations"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||
@click="onOpenEditDrawer(item)"
|
||||
>
|
||||
<p>{{ formatDate(item.startDate) }}</p>
|
||||
<p>{{ formatDate(item.endDate) }}</p>
|
||||
<p class="min-w-0">
|
||||
<a
|
||||
v-if="item.justificatifPath"
|
||||
:href="getFormationJustificatifUrl(props.apiBase, item.id)"
|
||||
target="_blank"
|
||||
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
|
||||
<span class="truncate">{{ item.justificatifName ?? 'Télécharger' }}</span>
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</p>
|
||||
<p class="truncate">{{ item.comment ?? '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mb-4 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="onOpenCreateDrawer"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AppDrawer v-model="isDrawerOpen" title="Formation">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="formation-start-date">
|
||||
Date de début <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="formation-start-date"
|
||||
v-model="form.startDate"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="formation-end-date">
|
||||
Date de fin <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="formation-end-date"
|
||||
v-model="form.endDate"
|
||||
type="date"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
<p v-if="isDateRangeInvalid" class="mt-1 text-sm text-red-600">La date de fin doit être postérieure ou égale à la date de début.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="formation-justificatif">
|
||||
Justificatif
|
||||
</label>
|
||||
<div v-if="isEditing && editingItem?.justificatifName" class="mt-1 text-sm text-neutral-500">
|
||||
Fichier actuel : {{ editingItem.justificatifName }}
|
||||
</div>
|
||||
<input
|
||||
id="formation-justificatif"
|
||||
ref="justificatifInput"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
|
||||
@change="onJustificatifChange"
|
||||
/>
|
||||
<p v-if="justificatifError" class="mt-1 text-sm text-red-600">{{ justificatifError }}</p>
|
||||
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="formation-comment">
|
||||
Commentaire
|
||||
</label>
|
||||
<textarea
|
||||
id="formation-comment"
|
||||
v-model="form.comment"
|
||||
rows="3"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
placeholder="Commentaire..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||
@click="onDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {Formation} from '~/services/dto/formation'
|
||||
import {getFormationJustificatifUrl} from '~/services/formations'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
formations: Formation[]
|
||||
apiBase: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'create', data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
|
||||
(event: 'update', id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
|
||||
(event: 'delete', id: number): void
|
||||
}>()
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingItem = ref<Formation | null>(null)
|
||||
const selectedJustificatif = ref<File | undefined>(undefined)
|
||||
const justificatifInput = ref<HTMLInputElement | null>(null)
|
||||
const justificatifError = ref('')
|
||||
|
||||
const form = reactive({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
comment: ''
|
||||
})
|
||||
|
||||
const isDateRangeInvalid = computed(() => {
|
||||
if (!form.startDate || !form.endDate) return false
|
||||
return form.endDate < form.startDate
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return Boolean(form.startDate) && Boolean(form.endDate) && !isDateRangeInvalid.value && !justificatifError.value
|
||||
})
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
return date.toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.startDate = ''
|
||||
form.endDate = ''
|
||||
form.comment = ''
|
||||
selectedJustificatif.value = undefined
|
||||
justificatifError.value = ''
|
||||
if (justificatifInput.value) {
|
||||
justificatifInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenCreateDrawer = () => {
|
||||
isEditing.value = false
|
||||
editingItem.value = null
|
||||
resetForm()
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onOpenEditDrawer = (item: Formation) => {
|
||||
isEditing.value = true
|
||||
editingItem.value = item
|
||||
form.startDate = item.startDate
|
||||
form.endDate = item.endDate
|
||||
form.comment = item.comment ?? ''
|
||||
selectedJustificatif.value = undefined
|
||||
justificatifError.value = ''
|
||||
if (justificatifInput.value) {
|
||||
justificatifInput.value.value = ''
|
||||
}
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onJustificatifChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file && file.type !== 'application/pdf') {
|
||||
justificatifError.value = 'Seuls les fichiers PDF sont acceptés.'
|
||||
selectedJustificatif.value = undefined
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
justificatifError.value = ''
|
||||
selectedJustificatif.value = file ?? undefined
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const data = {
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate,
|
||||
comment: form.comment || undefined
|
||||
}
|
||||
|
||||
if (isEditing.value && editingItem.value) {
|
||||
emit('update', editingItem.value.id, data, selectedJustificatif.value)
|
||||
} else {
|
||||
emit('create', data, selectedJustificatif.value)
|
||||
}
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
if (!editingItem.value) return
|
||||
const ok = window.confirm('Supprimer cette formation ?')
|
||||
if (!ok) return
|
||||
emit('delete', editingItem.value.id)
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,443 +0,0 @@
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
|
||||
<p class="col-start-1 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
||||
formatCount(summary?.acquiredDays)
|
||||
}} Jours
|
||||
</p>
|
||||
<p class="col-start-2 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Pris :</strong>
|
||||
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
|
||||
</p>
|
||||
<p class="col-start-3 p-[10px] border-b border-r border-b-white border-r-primary-500 bg-primary-500 text-white"><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
||||
{{ formatCount(summary?.remainingDays) }} Jours
|
||||
</p>
|
||||
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
|
||||
{{ formatCount(summary?.accruingDays) }} Jours
|
||||
</p>
|
||||
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
|
||||
{{ formatCount(summary?.acquiredSaturdays) }} Jours
|
||||
</p>
|
||||
<p v-else class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Année N-1 acquis :</span>
|
||||
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
|
||||
</p>
|
||||
<p v-if="!isForfaitRule" class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
|
||||
{{ formatCount(summary?.takenSaturdays) }} Jours
|
||||
</p>
|
||||
<p v-if="!isForfaitRule" class="col-start-3 p-[10px] border-r border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||
{{ formatCount(summary?.remainingSaturdays) }} Jours
|
||||
</p>
|
||||
<p v-else class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
|
||||
{{ formatCount(summary?.previousYearTakenDays) }} Jours
|
||||
</p>
|
||||
<p v-if="isForfaitRule" class="col-start-3 p-[10px] border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
|
||||
</p>
|
||||
<div v-if="isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
|
||||
<div>
|
||||
<span class="uppercase font-semibold">Année n-1 payés : </span>
|
||||
<span> {{ formatCount(summary?.previousYearPaidDays) }} Jours</span>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center"
|
||||
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
:disabled="isHistoricalYear"
|
||||
@click="openPaidLeaveDrawer"
|
||||
>
|
||||
<Icon name="mdi:edit-box" size="24"/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
|
||||
<div>
|
||||
<span class="uppercase font-semibold">Fractionné acquis : </span>
|
||||
<span>{{ formatCount(summary?.fractionedDays) }} Jours</span>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center"
|
||||
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
:disabled="isHistoricalYear"
|
||||
@click="openFractionedDrawer"
|
||||
>
|
||||
<Icon name="mdi:edit-box" size="24"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||
<div class="grid grid-cols-4 gap-10">
|
||||
<div v-for="month in months" :key="month.label"
|
||||
class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
|
||||
<div 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 class="px-2 py-2 text-center border-t border-primary-500">Jours de présence :
|
||||
{{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<label for="leave-year-select" class="text-md font-semibold text-primary-500 uppercase">
|
||||
{{ isForfaitRule ? 'Année :' : 'Exercice :' }}
|
||||
</label>
|
||||
<select
|
||||
id="leave-year-select"
|
||||
:value="selectedYear ?? ''"
|
||||
:disabled="!availableYears.length"
|
||||
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
|
||||
@change="handleYearChange"
|
||||
>
|
||||
<option v-for="option in availableYears" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</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>
|
||||
<AppDrawer v-model="isPaidLeaveDrawerOpen" title="Congés N-1 payés">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmitPaidLeave">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="paid-leave-days">
|
||||
Nombre de jours <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="paid-leave-days"
|
||||
v-model="paidLeaveForm.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="isPaidLeaveDrawerOpen = 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[]
|
||||
}
|
||||
|
||||
type LeaveYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
absences: Absence[]
|
||||
summary: EmployeeLeaveSummary | null
|
||||
publicHolidays: Record<string, string>
|
||||
selectedYear: number | null
|
||||
availableYears: LeaveYearOption[]
|
||||
currentYear: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update-fractioned-days', days: number): void
|
||||
(event: 'update-paid-leave-days', days: number): void
|
||||
(event: 'update-selected-year', year: number): void
|
||||
}>()
|
||||
|
||||
const isHistoricalYear = computed(() =>
|
||||
props.selectedYear !== null
|
||||
&& props.currentYear !== null
|
||||
&& props.selectedYear !== props.currentYear
|
||||
)
|
||||
|
||||
const handleYearChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const value = Number(target.value)
|
||||
if (Number.isNaN(value)) return
|
||||
emit('update-selected-year', value)
|
||||
}
|
||||
|
||||
const isFractionedDrawerOpen = ref(false)
|
||||
const fractionedForm = reactive({days: 0})
|
||||
|
||||
const isPaidLeaveDrawerOpen = ref(false)
|
||||
const paidLeaveForm = 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 openPaidLeaveDrawer = () => {
|
||||
paidLeaveForm.days = props.summary?.previousYearPaidDays ?? 0
|
||||
isPaidLeaveDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const handleSubmitPaidLeave = () => {
|
||||
const value = Number(paidLeaveForm.days)
|
||||
if (Number.isNaN(value) || value < 0) return
|
||||
emit('update-paid-leave-days', value)
|
||||
isPaidLeaveDrawerOpen.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 currentYearTakenDays = computed(() => {
|
||||
if (!props.summary) return null
|
||||
return props.summary.takenDays - (props.summary.previousYearTakenDays ?? 0)
|
||||
})
|
||||
|
||||
const displayedYear = computed(() => {
|
||||
if (props.selectedYear) return props.selectedYear
|
||||
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)
|
||||
}
|
||||
|
||||
const monthKey = `${monthYear}-${String(monthIndex + 1).padStart(2, '0')}`
|
||||
|
||||
return {
|
||||
label,
|
||||
cells,
|
||||
monthKey
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -1,346 +0,0 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden bg-white">
|
||||
<div
|
||||
class="grid grid-cols-6 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||
<p>Mois</p>
|
||||
<p>Nombre de Km</p>
|
||||
<p>Montant €</p>
|
||||
<p>Commentaire</p>
|
||||
<p>Justif. Km</p>
|
||||
<p>Justif. Montant</p>
|
||||
</div>
|
||||
<div v-if="allowances.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
|
||||
Aucun frais kilométrique.
|
||||
</div>
|
||||
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="item in allowances"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-6 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||
@click="onOpenEditDrawer(item)"
|
||||
>
|
||||
<p>{{ formatMonth(item.month) }}</p>
|
||||
<p>{{ item.kilometers }}</p>
|
||||
<p>{{ item.amount ? item.amount + ' €' : '-' }}</p>
|
||||
<p>{{ item.comment ?? '-' }}</p>
|
||||
<p class="min-w-0">
|
||||
<a
|
||||
v-if="item.receiptPath"
|
||||
:href="getKmReceiptUrl(props.apiBase, item.id)"
|
||||
target="_blank"
|
||||
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
|
||||
<span class="truncate">{{ item.receiptName ?? 'Télécharger' }}</span>
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</p>
|
||||
<p class="min-w-0">
|
||||
<a
|
||||
v-if="item.amountReceiptPath"
|
||||
:href="getAmountReceiptUrl(props.apiBase, item.id)"
|
||||
target="_blank"
|
||||
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
|
||||
<span class="truncate">{{ item.amountReceiptName ?? 'Télécharger' }}</span>
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mb-4 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="onOpenCreateDrawer"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<AppDrawer v-model="isDrawerOpen" title="Frais">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-month">
|
||||
Mois <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="mileage-month"
|
||||
v-model="form.month"
|
||||
type="month"
|
||||
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-kilometers">
|
||||
Nombre de Km
|
||||
</label>
|
||||
<input
|
||||
id="mileage-kilometers"
|
||||
v-model.number="form.kilometers"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-amount">
|
||||
Montant (€)
|
||||
</label>
|
||||
<input
|
||||
id="mileage-amount"
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-neutral-500">Au moins un des deux champs doit être rempli</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-km-receipt">
|
||||
Justificatif Km
|
||||
</label>
|
||||
<div v-if="isEditing && editingItem?.receiptName" class="mt-1 text-sm text-neutral-500">
|
||||
Fichier actuel : {{ editingItem.receiptName }}
|
||||
</div>
|
||||
<input
|
||||
id="mileage-km-receipt"
|
||||
ref="kmFileInput"
|
||||
type="file"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
|
||||
@change="onKmFileChange"
|
||||
/>
|
||||
<p v-if="kmFileError" class="mt-1 text-sm text-red-600">{{ kmFileError }}</p>
|
||||
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-amount-receipt">
|
||||
Justificatif Montant
|
||||
</label>
|
||||
<div v-if="isEditing && editingItem?.amountReceiptName" class="mt-1 text-sm text-neutral-500">
|
||||
Fichier actuel : {{ editingItem.amountReceiptName }}
|
||||
</div>
|
||||
<input
|
||||
id="mileage-amount-receipt"
|
||||
ref="amountFileInput"
|
||||
type="file"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
|
||||
@change="onAmountFileChange"
|
||||
/>
|
||||
<p v-if="amountFileError" class="mt-1 text-sm text-red-600">{{ amountFileError }}</p>
|
||||
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-comment">
|
||||
Commentaire
|
||||
</label>
|
||||
<textarea
|
||||
id="mileage-comment"
|
||||
v-model="form.comment"
|
||||
rows="3"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
placeholder="Commentaire..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||
@click="onDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {MileageAllowance} from '~/services/dto/mileage-allowance'
|
||||
import {getKmReceiptUrl, getAmountReceiptUrl} from '~/services/mileage-allowances'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
allowances: MileageAllowance[]
|
||||
apiBase: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'create', data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
|
||||
(event: 'update', id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
|
||||
(event: 'delete', id: number): void
|
||||
}>()
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingItem = ref<MileageAllowance | null>(null)
|
||||
const selectedKmFile = ref<File | undefined>(undefined)
|
||||
const selectedAmountFile = ref<File | undefined>(undefined)
|
||||
const kmFileInput = ref<HTMLInputElement | null>(null)
|
||||
const amountFileInput = ref<HTMLInputElement | null>(null)
|
||||
const kmFileError = ref('')
|
||||
const amountFileError = ref('')
|
||||
|
||||
const currentYearMonth = () => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
month: currentYearMonth(),
|
||||
kilometers: 0,
|
||||
amount: 0,
|
||||
comment: ''
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.month && (form.kilometers > 0 || form.amount > 0) && !kmFileError.value && !amountFileError.value
|
||||
})
|
||||
|
||||
const monthLabels: Record<number, string> = {
|
||||
1: 'Janvier',
|
||||
2: 'Février',
|
||||
3: 'Mars',
|
||||
4: 'Avril',
|
||||
5: 'Mai',
|
||||
6: 'Juin',
|
||||
7: 'Juillet',
|
||||
8: 'Août',
|
||||
9: 'Septembre',
|
||||
10: 'Octobre',
|
||||
11: 'Novembre',
|
||||
12: 'Décembre'
|
||||
}
|
||||
|
||||
const formatMonth = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
const month = date.getMonth() + 1
|
||||
const year = date.getFullYear()
|
||||
return `${monthLabels[month]} ${year}`
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.month = currentYearMonth()
|
||||
form.kilometers = 0
|
||||
form.amount = 0
|
||||
form.comment = ''
|
||||
selectedKmFile.value = undefined
|
||||
selectedAmountFile.value = undefined
|
||||
kmFileError.value = ''
|
||||
amountFileError.value = ''
|
||||
if (kmFileInput.value) {
|
||||
kmFileInput.value.value = ''
|
||||
}
|
||||
if (amountFileInput.value) {
|
||||
amountFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenCreateDrawer = () => {
|
||||
isEditing.value = false
|
||||
editingItem.value = null
|
||||
resetForm()
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onOpenEditDrawer = (item: MileageAllowance) => {
|
||||
isEditing.value = true
|
||||
editingItem.value = item
|
||||
// Extract YYYY-MM from YYYY-MM-DD
|
||||
form.month = item.month.substring(0, 7)
|
||||
form.kilometers = item.kilometers
|
||||
form.amount = item.amount
|
||||
form.comment = item.comment ?? ''
|
||||
selectedKmFile.value = undefined
|
||||
selectedAmountFile.value = undefined
|
||||
if (kmFileInput.value) {
|
||||
kmFileInput.value.value = ''
|
||||
}
|
||||
if (amountFileInput.value) {
|
||||
amountFileInput.value.value = ''
|
||||
}
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onKmFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file && file.type !== 'application/pdf') {
|
||||
kmFileError.value = 'Seuls les fichiers PDF sont acceptés.'
|
||||
selectedKmFile.value = undefined
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
kmFileError.value = ''
|
||||
selectedKmFile.value = file ?? undefined
|
||||
}
|
||||
|
||||
const onAmountFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file && file.type !== 'application/pdf') {
|
||||
amountFileError.value = 'Seuls les fichiers PDF sont acceptés.'
|
||||
selectedAmountFile.value = undefined
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
amountFileError.value = ''
|
||||
selectedAmountFile.value = file ?? undefined
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const data = {
|
||||
month: `${form.month}-01`,
|
||||
kilometers: form.kilometers,
|
||||
amount: form.amount,
|
||||
comment: form.comment || undefined
|
||||
}
|
||||
|
||||
if (isEditing.value && editingItem.value) {
|
||||
emit('update', editingItem.value.id, data, selectedKmFile.value, selectedAmountFile.value)
|
||||
} else {
|
||||
emit('create', data, selectedKmFile.value, selectedAmountFile.value)
|
||||
}
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
if (!editingItem.value) return
|
||||
const ok = window.confirm('Supprimer ce frais kilométrique ?')
|
||||
if (!ok) return
|
||||
emit('delete', editingItem.value.id)
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,187 +0,0 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden bg-white">
|
||||
<div
|
||||
class="grid grid-cols-2 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||
<p>Mois</p>
|
||||
<p>Observation</p>
|
||||
</div>
|
||||
<div v-if="observations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
|
||||
Aucune observation.
|
||||
</div>
|
||||
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="item in observations"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-2 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
|
||||
@click="onOpenEditDrawer(item)"
|
||||
>
|
||||
<p>{{ formatMonth(item.month) }}</p>
|
||||
<p class="truncate">{{ item.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center mb-4 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="onOpenCreateDrawer"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification observation' : 'Nouvelle observation'">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="observation-month">
|
||||
Mois <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="observation-month"
|
||||
v-model="form.month"
|
||||
type="month"
|
||||
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="observation-content">
|
||||
Observation <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="observation-content"
|
||||
v-model="form.content"
|
||||
rows="5"
|
||||
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="Observation..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||
@click="onDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Observation } from '~/services/dto/observation'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
observations: Observation[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'create', data: { month: string; content: string }): void
|
||||
(event: 'update', id: number, data: { month: string; content: string }): void
|
||||
(event: 'delete', id: number): void
|
||||
}>()
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingItem = ref<Observation | null>(null)
|
||||
|
||||
const currentYearMonth = () => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
month: currentYearMonth(),
|
||||
content: ''
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.month && form.content.trim().length > 0
|
||||
})
|
||||
|
||||
const monthLabels: Record<number, string> = {
|
||||
1: 'Janvier',
|
||||
2: 'Février',
|
||||
3: 'Mars',
|
||||
4: 'Avril',
|
||||
5: 'Mai',
|
||||
6: 'Juin',
|
||||
7: 'Juillet',
|
||||
8: 'Août',
|
||||
9: 'Septembre',
|
||||
10: 'Octobre',
|
||||
11: 'Novembre',
|
||||
12: 'Décembre'
|
||||
}
|
||||
|
||||
const formatMonth = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
const month = date.getMonth() + 1
|
||||
const year = date.getFullYear()
|
||||
return `${monthLabels[month]} ${year}`
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.month = currentYearMonth()
|
||||
form.content = ''
|
||||
}
|
||||
|
||||
const onOpenCreateDrawer = () => {
|
||||
isEditing.value = false
|
||||
editingItem.value = null
|
||||
resetForm()
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onOpenEditDrawer = (item: Observation) => {
|
||||
isEditing.value = true
|
||||
editingItem.value = item
|
||||
form.month = item.month.substring(0, 7)
|
||||
form.content = item.content
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const data = {
|
||||
month: `${form.month}-01`,
|
||||
content: form.content
|
||||
}
|
||||
|
||||
if (isEditing.value && editingItem.value) {
|
||||
emit('update', editingItem.value.id, data)
|
||||
} else {
|
||||
emit('create', data)
|
||||
}
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
if (!editingItem.value) return
|
||||
const ok = window.confirm('Supprimer cette observation ?')
|
||||
if (!ok) return
|
||||
emit('delete', editingItem.value.id)
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,610 +0,0 @@
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||
<!-- Header bar -->
|
||||
<div class="flex items-center justify-between rounded-t-md bg-tertiary-500 px-5 py-4 text-black border border-primary-500">
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||
:disabled="currentMonthIndex === 0"
|
||||
@click="currentMonthIndex--"
|
||||
>
|
||||
<Icon name="mdi:chevron-left" size="24"/>
|
||||
</button>
|
||||
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
|
||||
{{ currentMonthLabel }} {{ displayedMonthYear }}
|
||||
</span>
|
||||
<button
|
||||
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||
:disabled="currentMonthIndex === 11"
|
||||
@click="currentMonthIndex++"
|
||||
>
|
||||
<Icon name="mdi:chevron-right" size="24"/>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[16px]">
|
||||
<span class="font-bold">RTT À LA SEMAINE {{ lastCompleteWeek }} : </span>
|
||||
<span class="font-bold">{{ formatMinutes(summary?.availableMinutes ?? 0) }}</span>
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:disabled="isPayDisabled"
|
||||
@click="openPaymentDrawer"
|
||||
>
|
||||
+ Payer les RTT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<table class="w-full table-fixed border-collapse text-[18px]">
|
||||
<colgroup>
|
||||
<col />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
<col class="w-[10%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-5 py-[10px] text-left font-bold text-primary-500 border border-primary-500">Semaine</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Heure</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">25%</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 25%</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total</th>
|
||||
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Cumul</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Report N-1 row (RTT rollover carry, June only) -->
|
||||
<tr v-if="showCarryRow" class="bg-tertiary-500">
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus25Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Report mois précédent (cumulated balance from previous months, July+) -->
|
||||
<tr v-if="showMonthReportRow" class="bg-tertiary-500">
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Week rows (always 5) -->
|
||||
<tr
|
||||
v-for="(week, idx) in paddedWeeks"
|
||||
:key="week ? week.weekStart : `empty-${idx}`"
|
||||
>
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">Semaine {{ week.weekNumber }}</span>
|
||||
<span v-else> </span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||
<span v-if="week">{{ formatMinutes(week.overtimeMinutes) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base25Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus25Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||
<span v-if="week">{{ formatMinutes(week.base25Minutes + week.bonus25Minutes) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base50Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus50Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.cumulativeBalanceMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(week.cumulativeBalanceMinutes) }}</span></span>
|
||||
<span v-else> </span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Total row -->
|
||||
<tr>
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500 border-t-2">Total</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.overtime) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total) }}</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-t-2">-</td>
|
||||
</tr>
|
||||
|
||||
<!-- Payé row -->
|
||||
<tr>
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase25Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus25Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes) : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
|
||||
</tr>
|
||||
|
||||
<!-- Reste row -->
|
||||
<tr>
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<label for="rtt-year-select" class="text-md font-semibold text-primary-500 uppercase">
|
||||
Exercice :
|
||||
</label>
|
||||
<select
|
||||
id="rtt-year-select"
|
||||
:value="selectedYear ?? ''"
|
||||
:disabled="!availableYears.length"
|
||||
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
|
||||
@change="handleYearChange"
|
||||
>
|
||||
<option v-for="option in availableYears" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Drawer -->
|
||||
<AppDrawer v-model="isPaymentDrawerOpen" title="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"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.base25Hours"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.bonus25Hours"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.base50Hours"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.bonus50Hours"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div 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, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
||||
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
type RttYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
summary: EmployeeRttSummary | null
|
||||
selectedYear: number | null
|
||||
availableYears: RttYearOption[]
|
||||
currentYear: number | null
|
||||
selectedPhase: ContractPhase | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
|
||||
(event: 'update-selected-year', year: number): void
|
||||
}>()
|
||||
|
||||
const isHistoricalYear = computed(() =>
|
||||
props.selectedYear !== null
|
||||
&& props.currentYear !== null
|
||||
&& props.selectedYear !== props.currentYear
|
||||
)
|
||||
|
||||
const isLastExerciseOfPhase = computed(() => {
|
||||
if (!props.selectedPhase || props.selectedPhase.isCurrent) return false
|
||||
if (!props.selectedPhase.endDate) return false
|
||||
const endDate = new Date(`${props.selectedPhase.endDate}T00:00:00`)
|
||||
const endYear = endDate.getMonth() >= 5
|
||||
? endDate.getFullYear() + 1
|
||||
: endDate.getFullYear()
|
||||
return props.selectedYear === endYear
|
||||
})
|
||||
|
||||
const isPayDisabled = computed(() =>
|
||||
isHistoricalYear.value && !isLastExerciseOfPhase.value
|
||||
)
|
||||
|
||||
const handleYearChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const value = Number(target.value)
|
||||
if (Number.isNaN(value)) return
|
||||
emit('update-selected-year', value)
|
||||
}
|
||||
|
||||
// --- Last complete week number ---
|
||||
|
||||
const lastCompleteWeek = computed(() => {
|
||||
const now = new Date()
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||
const dayOfYear = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000) + 1
|
||||
const dayOfWeek = now.getDay() || 7 // Monday = 1, Sunday = 7
|
||||
const currentWeek = Math.ceil((dayOfYear - dayOfWeek + 10) / 7)
|
||||
return currentWeek - 1
|
||||
})
|
||||
|
||||
// --- Month navigation ---
|
||||
|
||||
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
|
||||
|
||||
const monthLabels: Record<number, string> = {
|
||||
1: 'JANVIER',
|
||||
2: 'FEVRIER',
|
||||
3: 'MARS',
|
||||
4: 'AVRIL',
|
||||
5: 'MAI',
|
||||
6: 'JUIN',
|
||||
7: 'JUILLET',
|
||||
8: 'AOUT',
|
||||
9: 'SEPTEMBRE',
|
||||
10: 'OCTOBRE',
|
||||
11: 'NOVEMBRE',
|
||||
12: 'DECEMBRE',
|
||||
}
|
||||
|
||||
const orderedMonthOptions = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
// Initialize to current month's position in the exercise
|
||||
const today = new Date()
|
||||
const todayMonth = today.getMonth() + 1
|
||||
const initialIndex = orderedMonths.indexOf(todayMonth as (typeof orderedMonths)[number])
|
||||
const currentMonthIndex = ref(initialIndex >= 0 ? initialIndex : 0)
|
||||
|
||||
const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
|
||||
|
||||
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
|
||||
|
||||
const displayedMonthYear = computed(() => {
|
||||
if (!props.summary) return ''
|
||||
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||
})
|
||||
|
||||
// --- Weeks for current month ---
|
||||
|
||||
const weeksForCurrentMonth = computed((): EmployeeRttWeekSummary[] => {
|
||||
if (!props.summary) return []
|
||||
return props.summary.weeks.filter((w) => w.month === currentMonth.value)
|
||||
})
|
||||
|
||||
const paddedWeeks = computed((): (EmployeeRttWeekSummary | null)[] => {
|
||||
const weeks = weeksForCurrentMonth.value
|
||||
const padded: (EmployeeRttWeekSummary | null)[] = [...weeks]
|
||||
while (padded.length < 5) {
|
||||
padded.push(null)
|
||||
}
|
||||
return padded
|
||||
})
|
||||
|
||||
// --- Carry row (RTT rollover from previous year, June only) ---
|
||||
|
||||
const carryMonth = computed(() => {
|
||||
if (!props.summary) return 6
|
||||
const cm = props.summary.carryMonth
|
||||
return cm >= 12 ? 1 : cm + 1
|
||||
})
|
||||
|
||||
const showCarryRow = computed(() => {
|
||||
if (currentMonth.value !== carryMonth.value) return false
|
||||
if ((props.summary?.carryFromPreviousYearMinutes ?? 0) === 0) return false
|
||||
|
||||
// On the first exercise, hide carry if carry month is before rttStartDate
|
||||
if (props.summary?.rttStartDate) {
|
||||
const startDate = new Date(props.summary.rttStartDate)
|
||||
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
|
||||
const startMonthDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1)
|
||||
if (viewDate < startMonthDate) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// --- Month report row (cumulated balance from previous months) ---
|
||||
|
||||
// Months of the exercise in order, starting from the carry month
|
||||
const exerciseMonths = computed((): number[] => {
|
||||
const start = carryMonth.value
|
||||
const startIdx = orderedMonths.indexOf(start as (typeof orderedMonths)[number])
|
||||
if (startIdx === -1) return [...orderedMonths]
|
||||
return [...orderedMonths.slice(startIdx), ...orderedMonths.slice(0, startIdx)]
|
||||
})
|
||||
|
||||
const monthReport = computed(() => {
|
||||
if (!props.summary) return { base25: 0, bonus25: 0, total25: 0, base50: 0, bonus50: 0, total50: 0, total: 0 }
|
||||
|
||||
const cm = currentMonth.value
|
||||
const cmIdx = exerciseMonths.value.indexOf(cm)
|
||||
const previousMonths = exerciseMonths.value.slice(0, cmIdx)
|
||||
|
||||
// Start from carry (included in the cumulation)
|
||||
let base25 = props.summary.carryBase25Minutes
|
||||
let bonus25 = props.summary.carryBonus25Minutes
|
||||
let base50 = props.summary.carryBase50Minutes
|
||||
let bonus50 = props.summary.carryBonus50Minutes
|
||||
let total = props.summary.carryFromPreviousYearMinutes
|
||||
|
||||
// Add weeks from previous months
|
||||
for (const w of props.summary.weeks) {
|
||||
if (previousMonths.includes(w.month)) {
|
||||
base25 += w.base25Minutes
|
||||
bonus25 += w.bonus25Minutes
|
||||
base50 += w.base50Minutes
|
||||
bonus50 += w.bonus50Minutes
|
||||
total += w.totalMinutes
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract payments from previous months
|
||||
for (const p of props.summary.monthPayments) {
|
||||
if (previousMonths.includes(p.month)) {
|
||||
base25 -= p.paidBase25Minutes
|
||||
bonus25 -= p.paidBonus25Minutes
|
||||
base50 -= p.paidBase50Minutes
|
||||
bonus50 -= p.paidBonus50Minutes
|
||||
total -= (p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
|
||||
}
|
||||
}
|
||||
|
||||
return { base25, bonus25, total25: base25 + bonus25, base50, bonus50, total50: base50 + bonus50, total }
|
||||
})
|
||||
|
||||
const showMonthReportRow = computed(() => {
|
||||
// Not on the carry month — carry row handles that
|
||||
if (currentMonth.value === carryMonth.value) return false
|
||||
|
||||
// On the first exercise (containing rttStartDate), hide report for months before the start date
|
||||
if (props.summary?.rttStartDate) {
|
||||
const startDate = new Date(props.summary.rttStartDate)
|
||||
const startYear = startDate.getFullYear()
|
||||
const startMonth = startDate.getMonth() + 1
|
||||
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
|
||||
const startMonthDate = new Date(startYear, startMonth - 1, 1)
|
||||
if (viewDate < startMonthDate) return false
|
||||
}
|
||||
|
||||
const r = monthReport.value
|
||||
return r.total !== 0
|
||||
})
|
||||
|
||||
// --- Totals (current month weeks only) ---
|
||||
|
||||
const totals = computed(() => {
|
||||
const weeks = weeksForCurrentMonth.value
|
||||
const positive = weeks.filter((w) => w.totalMinutes >= 0)
|
||||
return {
|
||||
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
|
||||
base25: positive.reduce((s, w) => s + w.base25Minutes, 0),
|
||||
bonus25: positive.reduce((s, w) => s + w.bonus25Minutes, 0),
|
||||
total25: weeks.reduce((s, w) => s + w.base25Minutes + w.bonus25Minutes, 0),
|
||||
base50: positive.reduce((s, w) => s + w.base50Minutes, 0),
|
||||
bonus50: positive.reduce((s, w) => s + w.bonus50Minutes, 0),
|
||||
total50: weeks.reduce((s, w) => s + w.base50Minutes + w.bonus50Minutes, 0),
|
||||
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
|
||||
}
|
||||
})
|
||||
|
||||
const currentPayment = computed(() => {
|
||||
if (!props.summary) return null
|
||||
return props.summary.monthPayments.find((p) => p.month === currentMonth.value) ?? null
|
||||
})
|
||||
|
||||
const paidTotal = computed(() => {
|
||||
if (!currentPayment.value) return 0
|
||||
const p = currentPayment.value
|
||||
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
|
||||
})
|
||||
|
||||
const reste = computed(() => {
|
||||
const total25 = monthReport.value.total25 + totals.value.total25
|
||||
- (currentPayment.value?.paidBase25Minutes ?? 0) - (currentPayment.value?.paidBonus25Minutes ?? 0)
|
||||
const total50 = monthReport.value.total50 + totals.value.total50
|
||||
- (currentPayment.value?.paidBase50Minutes ?? 0) - (currentPayment.value?.paidBonus50Minutes ?? 0)
|
||||
|
||||
const base25 = Math.round(total25 / 1.25)
|
||||
const bonus25 = total25 - base25
|
||||
const base50 = Math.round(total50 / 1.5)
|
||||
const bonus50 = total50 - base50
|
||||
const total = monthReport.value.total + totals.value.total + paidTotal.value
|
||||
|
||||
return { base25, bonus25, total25, base50, bonus50, total50, total }
|
||||
})
|
||||
|
||||
// --- Format ---
|
||||
|
||||
const formatMinutes = (minutes: number): string => {
|
||||
if (minutes === 0) return '0 h'
|
||||
const sign = minutes < 0 ? '- ' : ''
|
||||
const abs = Math.abs(minutes)
|
||||
const hours = Math.floor(abs / 60)
|
||||
const rest = abs % 60
|
||||
if (rest === 0) return `${sign}${hours} h`
|
||||
return `${sign}${hours} h ${rest} m`
|
||||
}
|
||||
|
||||
const formatCentiemes = (minutes: number): string => {
|
||||
const value = minutes / 60
|
||||
return value.toFixed(2).replace('.', ',')
|
||||
}
|
||||
|
||||
// --- Payment drawer ---
|
||||
|
||||
const isPaymentDrawerOpen = ref(false)
|
||||
const paymentForm = reactive({
|
||||
month: 6,
|
||||
base25Hours: 0,
|
||||
bonus25Hours: 0,
|
||||
base50Hours: 0,
|
||||
bonus50Hours: 0,
|
||||
})
|
||||
|
||||
const prefillFromExistingPayment = (month: number) => {
|
||||
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
|
||||
if (existing) {
|
||||
paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100
|
||||
paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100
|
||||
paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100
|
||||
paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100
|
||||
} else {
|
||||
paymentForm.base25Hours = 0
|
||||
paymentForm.bonus25Hours = 0
|
||||
paymentForm.base50Hours = 0
|
||||
paymentForm.bonus50Hours = 0
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => paymentForm.month, (newMonth) => {
|
||||
prefillFromExistingPayment(newMonth)
|
||||
})
|
||||
|
||||
watch(() => paymentForm.base25Hours, (value) => {
|
||||
paymentForm.bonus25Hours = Math.round(value * 0.25 * 100) / 100
|
||||
})
|
||||
|
||||
watch(() => paymentForm.base50Hours, (value) => {
|
||||
paymentForm.bonus50Hours = Math.round(value * 0.50 * 100) / 100
|
||||
})
|
||||
|
||||
const openPaymentDrawer = () => {
|
||||
paymentForm.month = currentMonth.value
|
||||
prefillFromExistingPayment(currentMonth.value)
|
||||
isPaymentDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onSubmitPayment = () => {
|
||||
emit(
|
||||
'submit-rtt-payment',
|
||||
paymentForm.month,
|
||||
Math.round(paymentForm.base25Hours * 60),
|
||||
Math.round(paymentForm.bonus25Hours * 60),
|
||||
Math.round(paymentForm.base50Hours * 60),
|
||||
Math.round(paymentForm.bonus50Hours * 60),
|
||||
)
|
||||
isPaymentDrawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-md font-semibold text-neutral-700">
|
||||
Jours travaillés <span v-if="!disabled" class="text-red-600">*</span>
|
||||
</p>
|
||||
<p class="text-sm" :class="totalIsValid ? 'text-green-700' : 'text-red-600'">
|
||||
{{ formatTotal(totalMinutes) }} / {{ formatTotal(expectedMinutes) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="!disabled" class="text-xs text-neutral-500">Somme requise = {{ expectedMinutes / 60 }}h (total hebdo du contrat).</p>
|
||||
<div class="space-y-1">
|
||||
<div v-for="day in days" :key="day.iso" class="flex items-center gap-3">
|
||||
<label class="inline-flex items-center gap-2 min-w-[120px]">
|
||||
<input
|
||||
:checked="day.active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
:disabled="disabled"
|
||||
@change="onToggleDay(day.iso, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-md text-neutral-700">{{ day.label }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="day.time"
|
||||
type="time"
|
||||
step="60"
|
||||
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:bg-neutral-100 disabled:text-neutral-400"
|
||||
:disabled="disabled || !day.active"
|
||||
@input="onChangeTime(day.iso, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!totalIsValid" class="text-sm text-red-600">
|
||||
La somme des heures par jour doit égaler exactement {{ expectedMinutes / 60 }}h.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: Record<number, number> | null
|
||||
contractWeeklyHours: number | null
|
||||
disabled?: boolean
|
||||
}>(), { disabled: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<number, number>]
|
||||
}>()
|
||||
|
||||
const DAY_LABELS: Record<number, string> = { 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi' }
|
||||
|
||||
const expectedMinutes = computed(() => (props.contractWeeklyHours ?? 0) * 60)
|
||||
|
||||
const days = computed(() => {
|
||||
const raw = props.modelValue ?? {}
|
||||
return [1, 2, 3, 4, 5].map((iso) => {
|
||||
const active = Object.prototype.hasOwnProperty.call(raw, iso)
|
||||
const minutes = Number(raw[iso] ?? 0)
|
||||
return {
|
||||
iso,
|
||||
label: DAY_LABELS[iso],
|
||||
active,
|
||||
time: active ? minutesToTime(minutes) : '00:00',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const totalMinutes = computed(() => {
|
||||
const raw = props.modelValue ?? {}
|
||||
return Object.values(raw).reduce((sum, n) => sum + (Number(n) || 0), 0)
|
||||
})
|
||||
|
||||
const totalIsValid = computed(() => totalMinutes.value === expectedMinutes.value && expectedMinutes.value > 0)
|
||||
|
||||
function minutesToTime(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function timeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(':').map(Number)
|
||||
return (h || 0) * 60 + (m || 0)
|
||||
}
|
||||
|
||||
function onToggleDay(iso: number, active: boolean) {
|
||||
const next = { ...(props.modelValue ?? {}) }
|
||||
if (active) {
|
||||
next[iso] = next[iso] ?? 0
|
||||
} else {
|
||||
delete next[iso]
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function onChangeTime(iso: number, value: string) {
|
||||
const next = { ...(props.modelValue ?? {}) }
|
||||
const minutes = timeToMinutes(value)
|
||||
next[iso] = minutes
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function formatTotal(min: number): string {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
defineExpose({ totalIsValid, totalMinutes })
|
||||
</script>
|
||||
@@ -1,444 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<!-- Mobile card layout -->
|
||||
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
:key="'m-' + employee.id"
|
||||
class="rounded-md border border-primary-500 bg-white p-4"
|
||||
>
|
||||
<!-- Employee name + site -->
|
||||
<div class="mb-3">
|
||||
<p class="text-md font-bold text-primary-500 truncate">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-sm text-neutral-500 truncate">
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Absence / Holiday / Formation pills -->
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<p
|
||||
v-if="getRowAbsenceLabel(employee.id)"
|
||||
class="rounded-md px-2 py-1 text-xs text-white truncate"
|
||||
:style="getRowAbsenceStyle(employee.id)"
|
||||
>
|
||||
{{ getRowAbsenceLabel(employee.id) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-neutral-400"
|
||||
>
|
||||
Aucune absence
|
||||
</p>
|
||||
<p
|
||||
v-if="isHoliday"
|
||||
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
|
||||
style="background-color: #b3e5fc"
|
||||
>
|
||||
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="hasRowFormation(employee.id)"
|
||||
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
|
||||
</p>
|
||||
<button
|
||||
v-if="!hasRowFormation(employee.id)"
|
||||
type="button"
|
||||
class="self-start text-xs font-semibold underline"
|
||||
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||
@click="onAbsenceClick(employee.id)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time inputs (TIME tracking) -->
|
||||
<div v-if="isTimeTracking(employee)" class="space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début matin</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].morningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin matin</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].morningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début après-midi</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].afternoonFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin après-midi</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].afternoonTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début soir</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].eveningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin soir</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].eveningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
|
||||
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
|
||||
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
|
||||
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Presence tracking -->
|
||||
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
v-model="rows[employee.id].isPresentMorning"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
Matin
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
v-model="rows[employee.id].isPresentAfternoon"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
Après-midi
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Validation status (non-admin) -->
|
||||
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
|
||||
<span v-if="!isSiteManager" class="flex items-center gap-1">
|
||||
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Validation checkbox (admin) -->
|
||||
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
|
||||
<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)"
|
||||
/>
|
||||
<span class="text-neutral-700 font-semibold">Valider</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table layout -->
|
||||
<div class="overflow-y-auto min-h-0 hidden lg:block">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span class="pl-2">Statut</span>
|
||||
<span class="pl-4">Début matin</span>
|
||||
<span class="pr-2">Fin matin</span>
|
||||
<span class="pl-2">Début après-midi</span>
|
||||
<span class="pr-2">Fin après-midi</span>
|
||||
<span class="pl-2">Début soir</span>
|
||||
<span class="pr-2">Fin soir</span>
|
||||
<span class="pl-2">Jour</span>
|
||||
<span>Nuit</span>
|
||||
<span>Total</span>
|
||||
<span v-if="isAdmin" class="flex justify-between items-center">
|
||||
<span>Valider</span>
|
||||
<input
|
||||
ref="bulkValidationInput"
|
||||
:checked="isBulkValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onBulkValidationChange"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
title="Validation site"
|
||||
>
|
||||
<Icon name="mdi:check"/>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
|
||||
Modifié le {{ getRowUpdatedAt(employee.id) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<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>
|
||||
<p
|
||||
v-if="isHoliday"
|
||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
|
||||
style="background-color: #b3e5fc"
|
||||
:title="holidayLabel || 'Férié'"
|
||||
>
|
||||
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="hasRowFormation(employee.id)"
|
||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
|
||||
:title="getRowFormationLabel(employee.id)"
|
||||
>
|
||||
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!hasRowFormation(employee.id)"
|
||||
type="button"
|
||||
class="self-start text-left text-xs font-semibold underline"
|
||||
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="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">
|
||||
<div v-if="isTimeTracking(employee)">{{
|
||||
formatMinutes(getRowMetrics(employee.id).dayMinutes)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold">
|
||||
<div v-if="isTimeTracking(employee)">{{
|
||||
formatMinutes(getRowMetrics(employee.id).nightMinutes)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-semibold">
|
||||
<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-if="!isSiteManager" class="text-right p-5">
|
||||
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
<div v-if="!isAdmin">
|
||||
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {Employee} from '~/services/dto/employee'
|
||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||
import type {HourRow} from './types'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
|
||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
employees: Employee[]
|
||||
isAdmin: boolean
|
||||
isSiteManager: boolean
|
||||
dayGridCols: string
|
||||
isHoliday: boolean
|
||||
holidayLabel: string
|
||||
contractLabel: (employee: Employee) => string
|
||||
isTimeTracking: (employee: Employee) => boolean
|
||||
isPresenceTracking: (employee: Employee) => boolean
|
||||
isRowLocked: (employeeId: number) => boolean
|
||||
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
||||
isEveningLockedByAbsence: (employeeId: number) => boolean
|
||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||
isValidationPending: (employeeId: number) => boolean
|
||||
isSiteValidationPending: (employeeId: number) => boolean
|
||||
canToggleValidation: (employeeId: number) => boolean
|
||||
canToggleSiteValidation: (employeeId: number) => boolean
|
||||
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
|
||||
isBulkValidationChecked: boolean
|
||||
isBulkValidationIndeterminate: boolean
|
||||
isBulkSiteValidationChecked: boolean
|
||||
isBulkSiteValidationIndeterminate: boolean
|
||||
canBulkToggleSiteValidation: boolean
|
||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
hasRowFormation: (employeeId: number) => boolean
|
||||
getRowFormationLabel: (employeeId: number) => string
|
||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
getPresenceDayValue: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
formatMinutes: (minutes: number) => string
|
||||
}>()
|
||||
|
||||
const onBulkValidationChange = (event: Event) => {
|
||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onBulkSiteValidationChange = (event: Event) => {
|
||||
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||
props.onToggleSiteValidation(employeeId, checked)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isBulkValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkValidationInput.value) return
|
||||
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.isBulkSiteValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkSiteValidationInput.value) return
|
||||
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{immediate: true}
|
||||
)
|
||||
</script>
|
||||
@@ -1,255 +0,0 @@
|
||||
<template>
|
||||
<div class="py-4 flex flex-col gap-3 lg:py-6">
|
||||
<!-- Desktop: filters row -->
|
||||
<div class="hidden lg:flex lg:items-center lg:gap-4">
|
||||
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
groupClass="w-80"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="w-80">
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: search + filter button -->
|
||||
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
|
||||
<div class="flex-1 min-w-0">
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
|
||||
@click="filtersDrawerOpen = true"
|
||||
>
|
||||
<Icon name="mdi:filter-variant" size="22"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile filters drawer -->
|
||||
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
|
||||
<div class="space-y-6">
|
||||
<div v-if="sites.length > 0 && isAdmin">
|
||||
<label class="text-md font-semibold text-neutral-700">Sites</label>
|
||||
<div class="mt-2">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
groupClass="w-80"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isAdmin">
|
||||
<label class="text-md font-semibold text-neutral-700">Vue</label>
|
||||
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
|
||||
:class="viewModeButtonClass('day')"
|
||||
@click="viewMode = 'day'; filtersDrawerOpen = false"
|
||||
>
|
||||
<Icon name="mdi:calendar-clock" />
|
||||
Jour
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
|
||||
:class="viewModeButtonClass('week')"
|
||||
@click="viewMode = 'week'; filtersDrawerOpen = false"
|
||||
>
|
||||
<Icon name="mdi:calendar-week" />
|
||||
Semaine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
<!-- Date navigation -->
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
|
||||
<div
|
||||
v-if="viewMode === 'day'"
|
||||
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="shortcutButtonClass('yesterday')"
|
||||
@click="emit('set-yesterday')"
|
||||
>
|
||||
Hier
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 border-x border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="shortcutButtonClass('today')"
|
||||
@click="emit('set-today')"
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="shortcutButtonClass('tomorrow')"
|
||||
@click="emit('set-tomorrow')"
|
||||
>
|
||||
Demain
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="weekShortcutButtonClass('previousWeek')"
|
||||
@click="emit('set-previous-week')"
|
||||
>
|
||||
{{ getWeekShortcutLabel('previousWeek') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 border-x border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="weekShortcutButtonClass('thisWeek')"
|
||||
@click="emit('set-this-week')"
|
||||
>
|
||||
{{ getWeekShortcutLabel('thisWeek') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="weekShortcutButtonClass('nextWeek')"
|
||||
@click="emit('set-next-week')"
|
||||
>
|
||||
{{ getWeekShortcutLabel('nextWeek') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PeriodStepperPicker
|
||||
width-class="w-full lg:w-[320px]"
|
||||
:label="formattedSelectedDate"
|
||||
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
||||
:picker-value="pickerValue"
|
||||
prev-aria-label="Période précédente"
|
||||
next-aria-label="Période suivante"
|
||||
@prev="emit('shift-date', -1)"
|
||||
@next="emit('shift-date', 1)"
|
||||
@pick="onPickerValue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: view mode toggle -->
|
||||
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="viewModeButtonClass('day')"
|
||||
@click="viewMode = 'day'"
|
||||
>
|
||||
<Icon name="mdi:calendar-clock" />
|
||||
Jour
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
:class="viewModeButtonClass('week')"
|
||||
@click="viewMode = 'week'"
|
||||
>
|
||||
<Icon name="mdi:calendar-week" />
|
||||
Semaine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||
class="hidden lg:flex flex-wrap items-center gap-6"
|
||||
>
|
||||
<p class="font-bold">Légende :</p>
|
||||
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
||||
<p>{{ type.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/services/dto/site'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||
|
||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
|
||||
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
|
||||
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
isAdmin: boolean
|
||||
sites: Site[]
|
||||
absenceTypes: AbsenceType[]
|
||||
formattedSelectedDate: string
|
||||
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
||||
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||
}>()
|
||||
|
||||
const siteOptions = computed(() => props.sites.map((site) => ({ label: site.name, value: site.id })))
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'set-yesterday'): void
|
||||
(e: 'set-today'): void
|
||||
(e: 'set-tomorrow'): void
|
||||
(e: 'set-previous-week'): void
|
||||
(e: 'set-this-week'): void
|
||||
(e: 'set-next-week'): void
|
||||
(e: 'shift-date', value: number): void
|
||||
}>()
|
||||
|
||||
const filtersDrawerOpen = ref(false)
|
||||
|
||||
const pickerValue = computed(() => {
|
||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
||||
return selectedDate.value
|
||||
})
|
||||
|
||||
const viewModeButtonClass = (mode: 'day' | 'week') => {
|
||||
if (viewMode.value === mode) {
|
||||
return 'bg-primary-500 text-white'
|
||||
}
|
||||
|
||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||
}
|
||||
|
||||
const onPickerValue = (value: string) => {
|
||||
if (!value) return
|
||||
|
||||
if (viewMode.value === 'week') {
|
||||
const ymd = weekInputValueToYmd(value)
|
||||
if (!ymd) return
|
||||
selectedDate.value = ymd
|
||||
return
|
||||
}
|
||||
|
||||
selectedDate.value = value
|
||||
}
|
||||
</script>
|
||||
@@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
|
||||
<div
|
||||
v-for="row in weeklySummary?.rows ?? []"
|
||||
:key="'m-' + row.employeeId"
|
||||
class="rounded-md border border-primary-500 bg-white p-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<p class="text-md font-bold text-primary-500 truncate">
|
||||
{{ row.firstName }} {{ row.lastName }}
|
||||
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-500 truncate">
|
||||
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily breakdown -->
|
||||
<div class="mb-3 space-y-1">
|
||||
<div
|
||||
v-for="(daily, i) in row.daily"
|
||||
:key="daily.date"
|
||||
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
|
||||
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
|
||||
:style="getDailyCellStyle(daily)"
|
||||
:title="cellTitle(daily)"
|
||||
>
|
||||
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
|
||||
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
|
||||
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly totals -->
|
||||
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">Total sem.</span>
|
||||
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-neutral-500">H. supp.</span>
|
||||
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||
<span class="text-neutral-500">+25%</span>
|
||||
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||
<span class="text-neutral-500">+50%</span>
|
||||
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||
<span class="text-neutral-500">Récup.</span>
|
||||
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
|
||||
</div>
|
||||
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
|
||||
<span class="text-neutral-500">Panier nuit</span>
|
||||
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: weekGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
|
||||
<span>Jour/Nuit <br>sem.</span>
|
||||
<span>Total <br>sem.</span>
|
||||
<span>Total <br>h. supp.</span>
|
||||
<span>+25%</span>
|
||||
<span>+50%</span>
|
||||
<span>Total <br>récup.</span>
|
||||
<span>Panier <br>nuit</span>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="row in weeklySummary?.rows ?? []"
|
||||
:key="row.employeeId"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||
:style="{ gridTemplateColumns: weekGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ row.firstName }} {{ row.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
||||
</p>
|
||||
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span></span>
|
||||
<button v-if="isAdmin" type="button" class="flex items-center text-white p-1" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
|
||||
<Icon name="mdi:comment-text-outline" size="12"/>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="daily in row.daily"
|
||||
:key="daily.date"
|
||||
class="text-left leading-4 rounded-md px-2 py-1"
|
||||
:class="daily.hasAbsence ? 'text-white' : ''"
|
||||
:style="getDailyCellStyle(daily)"
|
||||
:title="cellTitle(daily)"
|
||||
>
|
||||
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
|
||||
<template v-else>
|
||||
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold leading-4">
|
||||
<template v-if="row.trackingMode === 'PRESENCE'">-</template>
|
||||
<template v-else>
|
||||
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
|
||||
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ (row.weeklyNightBasketCount ?? 0) > 0 ? row.weeklyNightBasketCount : '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const isInterimContract = (contractType?: ContractType | null) => {
|
||||
return contractType === CONTRACT_TYPES.INTERIM
|
||||
}
|
||||
|
||||
const HOLIDAY_BG_COLOR = '#b3e5fc'
|
||||
|
||||
const getDailyCellStyle = (daily: {
|
||||
hasAbsence?: boolean
|
||||
absenceColor?: string | null
|
||||
holidayLabel?: string | null
|
||||
}) => {
|
||||
if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
|
||||
if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cellTitle = (daily: {
|
||||
hasAbsence?: boolean
|
||||
absenceLabel?: string | null
|
||||
holidayLabel?: string | null
|
||||
}) => {
|
||||
const parts: string[] = []
|
||||
if (daily.absenceLabel) parts.push(daily.absenceLabel)
|
||||
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
|
||||
return parts.join(' — ')
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
isWeekLoading: boolean
|
||||
isAdmin: boolean
|
||||
weekGridCols: string
|
||||
weeklySummary: WeeklyWorkHourSummary | null
|
||||
weekDayHeaders: Array<{ date: string; label: string }>
|
||||
formatMinutes: (minutes: number) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
|
||||
</script>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="drawerOpen" title="Commentaire">
|
||||
<form class="space-y-4" @submit.prevent="onSave">
|
||||
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
|
||||
<div class="text-md font-semibold text-neutral-700">{{ formatWeekRange }}</div>
|
||||
<MalioInputTextArea
|
||||
v-model="content"
|
||||
label="Commentaire"
|
||||
:size="8"
|
||||
:max-length="5000"
|
||||
:show-counter="true"
|
||||
resize="vertical"
|
||||
/>
|
||||
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-between gap-3">
|
||||
<MalioButton
|
||||
v-if="commentId"
|
||||
label="Supprimer"
|
||||
variant="danger"
|
||||
:disabled="isSubmitting"
|
||||
@click="onDelete"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="ml-auto"
|
||||
:disabled="isSubmitting || !canSubmit"
|
||||
@click="onSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { createWeekComment, deleteWeekComment, updateWeekComment } from '~/services/employee-week-comments'
|
||||
import { getIsoWeekNumber, parseYmd } from '~/utils/date'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
employeeId: number | null
|
||||
weekStart: string
|
||||
weekEnd: string
|
||||
initialContent: string
|
||||
commentId: number | null
|
||||
employeeLabel?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
|
||||
|
||||
const drawerOpen = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })
|
||||
const content = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
watch(() => [props.modelValue, props.initialContent] as const, ([open, init]) => { if (open) content.value = init ?? '' }, { immediate: true })
|
||||
|
||||
const formatWeekRange = computed(() => {
|
||||
const fmt = (ymd: string) => { const p = ymd.split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : ymd }
|
||||
const start = parseYmd(props.weekStart)
|
||||
const weekLabel = start ? `S${getIsoWeekNumber(start)}` : ''
|
||||
return weekLabel ? `${weekLabel} du ${fmt(props.weekStart)} au ${fmt(props.weekEnd)}` : `${fmt(props.weekStart)} → ${fmt(props.weekEnd)}`
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => content.value.trim().length > 0 || props.commentId !== null)
|
||||
|
||||
const onSave = async () => {
|
||||
if (!props.employeeId || isSubmitting.value) return
|
||||
const trimmed = content.value.trim()
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (trimmed === '' && props.commentId) await deleteWeekComment(props.commentId)
|
||||
else if (trimmed !== '' && props.commentId) await updateWeekComment(props.commentId, trimmed)
|
||||
else if (trimmed !== '') await createWeekComment({ employeeId: props.employeeId, weekStartDate: props.weekStart, content: trimmed })
|
||||
emit('saved'); drawerOpen.value = false
|
||||
} finally { isSubmitting.value = false }
|
||||
}
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!props.commentId || isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
try { await deleteWeekComment(props.commentId); emit('saved'); drawerOpen.value = false } finally { isSubmitting.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +0,0 @@
|
||||
export type HourRow = {
|
||||
workHourId: number | null
|
||||
morningFrom: string
|
||||
morningTo: string
|
||||
afternoonFrom: string
|
||||
afternoonTo: string
|
||||
eveningFrom: string
|
||||
eveningTo: string
|
||||
isPresentMorning: boolean
|
||||
isPresentAfternoon: boolean
|
||||
isSiteValid: boolean
|
||||
isValid: boolean
|
||||
updatedAt: string | null
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
<template>
|
||||
<div ref="root" class="relative w-full">
|
||||
<div
|
||||
ref="trigger"
|
||||
class="w-full flex items-center rounded-md border border-neutral-300 px-2 text-sm text-neutral-900 focus-within:border-primary-500"
|
||||
:class="props.disabled ? 'cursor-not-allowed border-neutral-300 bg-neutral-200 text-neutral-500' : 'bg-white'"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:placeholder="placeholder"
|
||||
:disabled="props.disabled"
|
||||
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
||||
@focus="openMenu"
|
||||
@keydown.down.prevent="openMenuAndFocusFirst"
|
||||
@keydown.enter.prevent="commitInput"
|
||||
@keydown.esc.prevent="closeMenu"
|
||||
@input="onInput($event)"
|
||||
@blur="onInputBlur"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
class="hidden lg:inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
||||
:disabled="props.disabled"
|
||||
@mousedown.prevent
|
||||
@click="toggleOpen"
|
||||
>
|
||||
<Icon name="mdi:chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menu"
|
||||
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
||||
:style="menuStyle"
|
||||
>
|
||||
<button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
|
||||
{{ placeholder }}
|
||||
</button>
|
||||
<button
|
||||
v-for="slot in filteredTimeSlots"
|
||||
:key="slot"
|
||||
type="button"
|
||||
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
||||
@click="selectValue(slot)"
|
||||
>
|
||||
{{ slot }}
|
||||
</button>
|
||||
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
|
||||
Aucun résultat
|
||||
</p>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
placeholder: '--',
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const trigger = ref<HTMLElement | null>(null)
|
||||
const menu = ref<HTMLElement | null>(null)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const inputValue = ref('')
|
||||
const menuStyle = ref<Record<string, string>>({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
maxHeight: '224px'
|
||||
})
|
||||
|
||||
const timeSlots = computed(() => {
|
||||
const slots: string[] = []
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 15) {
|
||||
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`)
|
||||
}
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
const filteredTimeSlots = computed(() => {
|
||||
const query = inputValue.value.trim()
|
||||
if (!query) return timeSlots.value
|
||||
return timeSlots.value.filter((slot) => slot.includes(query))
|
||||
})
|
||||
|
||||
const applyTimeMask = (value: string): string => {
|
||||
const digits = value.replace(/\D/g, '').slice(0, 4)
|
||||
if (digits.length <= 2) return digits
|
||||
return `${digits.slice(0, 2)}:${digits.slice(2)}`
|
||||
}
|
||||
|
||||
const normalizeTypedTime = (value: string): string | null => {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') return ''
|
||||
|
||||
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
|
||||
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
|
||||
if (!match) return null
|
||||
const hours = Number(match[1])
|
||||
const minutes = Number(match[2])
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const updateMenuPosition = () => {
|
||||
const triggerEl = trigger.value
|
||||
if (!triggerEl) return
|
||||
|
||||
const rect = triggerEl.getBoundingClientRect()
|
||||
const menuHeight = 224
|
||||
const belowTop = rect.bottom + 4
|
||||
const aboveTop = Math.max(8, rect.top - menuHeight - 4)
|
||||
const canOpenBelow = belowTop + menuHeight <= window.innerHeight - 8
|
||||
const top = canOpenBelow ? belowTop : aboveTop
|
||||
|
||||
menuStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
maxHeight: `${menuHeight}px`
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (props.disabled) return
|
||||
const next = !isOpen.value
|
||||
isOpen.value = next
|
||||
if (next) {
|
||||
nextTick(updateMenuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = () => window.innerWidth < 1024
|
||||
|
||||
const openMenu = () => {
|
||||
if (props.disabled) return
|
||||
if (isMobile()) return
|
||||
if (!isOpen.value) {
|
||||
isOpen.value = true
|
||||
nextTick(updateMenuPosition)
|
||||
}
|
||||
}
|
||||
|
||||
const openMenuAndFocusFirst = () => {
|
||||
openMenu()
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const snapToNearest15 = (time: string): string => {
|
||||
const [h, m] = time.split(':').map(Number)
|
||||
const snapped = Math.round(m / 15) * 15
|
||||
if (snapped === 60) {
|
||||
const newH = h + 1
|
||||
if (newH > 23) return '23:45'
|
||||
return `${String(newH).padStart(2, '0')}:00`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${String(snapped).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const commitInput = () => {
|
||||
let value = inputValue.value
|
||||
if (isMobile()) {
|
||||
value = clampTime(value)
|
||||
const normalized = normalizeTypedTime(value)
|
||||
if (normalized !== null && normalized !== '') {
|
||||
value = snapToNearest15(normalized)
|
||||
}
|
||||
inputValue.value = value
|
||||
}
|
||||
const normalized = normalizeTypedTime(value)
|
||||
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
|
||||
emit('update:modelValue', '')
|
||||
inputValue.value = ''
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', normalized)
|
||||
inputValue.value = normalized
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const masked = applyTimeMask(target.value)
|
||||
if (masked !== inputValue.value) {
|
||||
inputValue.value = masked
|
||||
}
|
||||
if (!isMobile()) {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const clampTime = (value: string): string => {
|
||||
const normalized = normalizeTypedTime(value)
|
||||
if (normalized === null || normalized === '') return value
|
||||
const [h, m] = normalized.split(':').map(Number)
|
||||
if (h > 23 || (h === 23 && m > 45)) return '23:45'
|
||||
return normalized
|
||||
}
|
||||
|
||||
const onInputBlur = () => {
|
||||
// Laisse le temps au click menu de passer avant fermeture.
|
||||
setTimeout(() => {
|
||||
if (menu.value?.contains(document.activeElement)) return
|
||||
if (isMobile()) {
|
||||
inputValue.value = clampTime(inputValue.value)
|
||||
}
|
||||
commitInput()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const selectValue = (value: string) => {
|
||||
if (props.disabled) return
|
||||
emit('update:modelValue', value)
|
||||
inputValue.value = value
|
||||
isOpen.value = false
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
}
|
||||
|
||||
const onDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as Node | null
|
||||
if (!target) return
|
||||
if (root.value?.contains(target) || menu.value?.contains(target)) return
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const onWindowChange = () => {
|
||||
if (!isOpen.value) return
|
||||
updateMenuPosition()
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
window.addEventListener('resize', onWindowChange)
|
||||
window.addEventListener('scroll', onWindowChange, true)
|
||||
nextTick(updateMenuPosition)
|
||||
} else {
|
||||
window.removeEventListener('resize', onWindowChange)
|
||||
window.removeEventListener('scroll', onWindowChange, true)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.disabled, (disabled) => {
|
||||
if (disabled) {
|
||||
isOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
inputValue.value = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
window.removeEventListener('resize', onWindowChange)
|
||||
window.removeEventListener('scroll', onWindowChange, true)
|
||||
})
|
||||
</script>
|
||||
@@ -4,14 +4,9 @@ import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
export type BlobResponse = {
|
||||
data: Blob
|
||||
headers: Headers
|
||||
}
|
||||
|
||||
export type ApiClient = {
|
||||
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<BlobResponse>
|
||||
getBlob(url: string, query?: AnyObject, options?: ApiFetchOptions<'blob'>): Promise<Blob>
|
||||
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||
@@ -21,7 +16,6 @@ export type ApiClient = {
|
||||
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||
FetchOptions<ResponseType> & {
|
||||
toast?: boolean
|
||||
toastOn401?: boolean
|
||||
toastTitle?: string
|
||||
toastErrorMessage?: string
|
||||
toastSuccessMessage?: string
|
||||
@@ -103,31 +97,9 @@ export const useApi = (): ApiClient => {
|
||||
}
|
||||
},
|
||||
async onResponseError({ response, error, options }) {
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (response?.status === 401) {
|
||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||
const isLoginCheck = requestUrl.includes('/login_check')
|
||||
const isLogout = requestUrl.includes('/logout')
|
||||
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
|
||||
|
||||
if (shouldToast401) {
|
||||
const errorKey = apiOptions?.toastErrorKey
|
||||
const errorMessage =
|
||||
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
|
||||
const extractedMessage = extractErrorMessage(error, response?._data)
|
||||
const message =
|
||||
apiOptions?.toastErrorMessage ||
|
||||
errorMessage ||
|
||||
extractedMessage ||
|
||||
'Une erreur est survenue.'
|
||||
|
||||
toast.error({
|
||||
title: apiOptions?.toastTitle ?? 'Erreur',
|
||||
message
|
||||
})
|
||||
}
|
||||
|
||||
if (!isLoginCheck && !isLogout) {
|
||||
if (!requestUrl.includes('/login_check') && !requestUrl.includes('/logout')) {
|
||||
if (!isHandlingUnauthorized) {
|
||||
isHandlingUnauthorized = true
|
||||
auth.clearSession()
|
||||
@@ -138,10 +110,10 @@ export const useApi = (): ApiClient => {
|
||||
isHandlingUnauthorized = false
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const apiOptions = options as ApiFetchOptions<'json'>
|
||||
if (apiOptions?.toast === false) {
|
||||
return
|
||||
}
|
||||
@@ -193,9 +165,7 @@ export const useApi = (): ApiClient => {
|
||||
return request<T>('GET', url, { ...options, query })
|
||||
},
|
||||
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
|
||||
return client
|
||||
.raw(url, { ...options, method: 'GET', query, responseType: 'blob' })
|
||||
.then((res) => ({ data: res._data as Blob, headers: res.headers }))
|
||||
return client<Blob>(url, { ...options, method: 'GET', query, responseType: 'blob' })
|
||||
},
|
||||
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||
return request<T>('POST', url, { ...options, body })
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export const useAppVersion = () => {
|
||||
const api = useApi()
|
||||
const version = useState<string | null>('app-version', () => null)
|
||||
|
||||
const load = async () => {
|
||||
if (version.value) {
|
||||
return version.value
|
||||
}
|
||||
const response = await api.get<{ version: string }>('version', {}, {
|
||||
toast: false
|
||||
})
|
||||
version.value = response.version
|
||||
return version.value
|
||||
}
|
||||
|
||||
return { version, load }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { documentationSections } from '~/data/documentation-content'
|
||||
import type { DocAccessLevel, DocSection } from '~/types/documentation'
|
||||
|
||||
const LEVEL_HIERARCHY: Record<DocAccessLevel, number> = {
|
||||
employee: 0,
|
||||
site_manager: 1,
|
||||
admin: 2,
|
||||
}
|
||||
|
||||
function getUserLevel(roles: string[]): number {
|
||||
if (roles.includes('ROLE_ADMIN') || roles.includes('ROLE_SUPER_ADMIN')) return 2
|
||||
if (roles.includes('ROLE_USER')) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export function useDocumentation() {
|
||||
const auth = useAuthStore()
|
||||
const userLevel = computed(() => getUserLevel(auth.user?.roles ?? []))
|
||||
|
||||
const visibleSections = computed<DocSection[]>(() => {
|
||||
return documentationSections
|
||||
.filter(s => LEVEL_HIERARCHY[s.requiredLevel] <= userLevel.value)
|
||||
.map(s => ({
|
||||
...s,
|
||||
articles: s.articles.filter(a => LEVEL_HIERARCHY[a.requiredLevel] <= userLevel.value),
|
||||
}))
|
||||
.filter(s => s.articles.length > 0)
|
||||
})
|
||||
|
||||
const activeArticleId = ref<string | null>(null)
|
||||
|
||||
const scrollToArticle = (articleId: string) => {
|
||||
activeArticleId.value = articleId
|
||||
const el = document.getElementById(`doc-${articleId}`)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
return { visibleSections, activeArticleId, scrollToArticle }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Bonus } from '~/services/dto/bonus'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import {
|
||||
listBonuses,
|
||||
createBonus,
|
||||
updateBonus,
|
||||
deleteBonus
|
||||
} from '~/services/bonuses'
|
||||
|
||||
export const useEmployeeBonus = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const bonuses = ref<Bonus[]>([])
|
||||
const isBonusLoading = ref(false)
|
||||
const bonusDataLoaded = ref(false)
|
||||
|
||||
const loadBonusData = async () => {
|
||||
if (!employee.value || isBonusLoading.value) return
|
||||
isBonusLoading.value = true
|
||||
try {
|
||||
bonuses.value = await listBonuses(employee.value.id)
|
||||
bonusDataLoaded.value = true
|
||||
} finally {
|
||||
isBonusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
bonusDataLoaded.value = false
|
||||
}
|
||||
|
||||
const submitCreateBonus = async (data: { month: string; amount: number; comment?: string }) => {
|
||||
if (!employee.value) return
|
||||
await createBonus({
|
||||
employeeId: employee.value.id,
|
||||
month: data.month,
|
||||
amount: data.amount,
|
||||
comment: data.comment
|
||||
})
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitUpdateBonus = async (id: number, data: { month: string; amount: number; comment?: string }) => {
|
||||
await updateBonus(id, data)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitDeleteBonus = async (id: number) => {
|
||||
await deleteBonus(id)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
return {
|
||||
bonuses,
|
||||
isBonusLoading,
|
||||
bonusDataLoaded,
|
||||
loadBonusData,
|
||||
resetLoaded,
|
||||
submitCreateBonus,
|
||||
submitUpdateBonus,
|
||||
submitDeleteBonus
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Contract } from '~/services/dto/contract'
|
||||
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
||||
import { listContracts } from '~/services/contracts'
|
||||
import { updateEmployee } from '~/services/employees'
|
||||
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
|
||||
import { listInterimAgencies, type InterimAgency } from '~/services/interim-agencies'
|
||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
|
||||
|
||||
type SuspensionForm = {
|
||||
id: number | null
|
||||
startDate: string
|
||||
endDate: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const toast = useToast()
|
||||
const contracts = ref<Contract[]>([])
|
||||
const interimAgencies = ref<InterimAgency[]>([])
|
||||
const isContractDrawerOpen = ref(false)
|
||||
const isContractSubmitting = ref(false)
|
||||
const isCreateContractDrawerOpen = ref(false)
|
||||
const isCreateContractSubmitting = ref(false)
|
||||
const suspensionForms = ref<SuspensionForm[]>([])
|
||||
const isSuspensionSubmitting = ref(false)
|
||||
|
||||
const contractForm = reactive({
|
||||
contractId: '' as number | '',
|
||||
contractName: '',
|
||||
weeklyHours: null as number | null,
|
||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
paidLeaveSettled: false,
|
||||
comment: '',
|
||||
workDaysHours: null as Record<number, number> | null
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
endDate: false
|
||||
})
|
||||
|
||||
const createContractForm = reactive({
|
||||
contractId: '' as number | '',
|
||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
isDriver: false,
|
||||
workDaysHours: null as Record<number, number> | null,
|
||||
interimAgencyId: '' as number | ''
|
||||
})
|
||||
|
||||
const createValidationTouched = reactive({
|
||||
contractId: false,
|
||||
contractNature: false,
|
||||
startDate: false,
|
||||
endDate: false
|
||||
})
|
||||
|
||||
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
|
||||
|
||||
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
|
||||
|
||||
const contractHistoryLabel = (item: ContractHistoryItem) => {
|
||||
const base = item.weeklyHours !== null && item.weeklyHours !== undefined
|
||||
? `${item.weeklyHours} heures`
|
||||
: (item.contractName ?? '-')
|
||||
const scheduleSummary = formatWorkDaysHoursSummary(item.workDaysHours)
|
||||
return scheduleSummary ? `${base} (${scheduleSummary})` : base
|
||||
}
|
||||
|
||||
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 lastEndedContractPeriod = computed(() => {
|
||||
if (currentActiveContractPeriod.value) return null
|
||||
const today = getTodayYmd()
|
||||
const history = employee.value?.contractHistory ?? []
|
||||
const ended = history.filter((item) => item.endDate && item.endDate < today)
|
||||
if (ended.length === 0) return null
|
||||
return ended.reduce((latest, item) => (item.endDate! > latest.endDate! ? item : latest))
|
||||
})
|
||||
|
||||
const editableContractPeriod = computed(() => currentActiveContractPeriod.value ?? lastEndedContractPeriod.value)
|
||||
|
||||
const currentActiveContractPeriodId = computed<number | null>(() => {
|
||||
const period = currentActiveContractPeriod.value
|
||||
return period?.periodId ?? null
|
||||
})
|
||||
|
||||
const canCloseCurrentContract = computed(() => {
|
||||
const active = currentActiveContractPeriod.value
|
||||
if (active) {
|
||||
if (!active.endDate) return true
|
||||
return active.endDate > getTodayYmd()
|
||||
}
|
||||
return !!lastEndedContractPeriod.value
|
||||
})
|
||||
|
||||
const canCreateContract = computed(() => {
|
||||
const active = editableContractPeriod.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 selectedCreateContract = computed<Contract | null>(() =>
|
||||
contracts.value.find((c) => c.id === Number(createContractForm.contractId)) ?? null
|
||||
)
|
||||
const requiresCreateWorkDaysHours = computed(() =>
|
||||
requiresWorkDaysHours(selectedCreateContract.value, createContractForm.contractNature)
|
||||
)
|
||||
const createScheduleTotalMinutes = computed(() => {
|
||||
const raw = createContractForm.workDaysHours ?? {}
|
||||
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
})
|
||||
const isCreateScheduleValid = computed(() => {
|
||||
if (!requiresCreateWorkDaysHours.value) return true
|
||||
const expected = (selectedCreateContract.value?.weeklyHours ?? 0) * 60
|
||||
return expected > 0 && createScheduleTotalMinutes.value === expected
|
||||
})
|
||||
const isCreateContractFormValid = computed(() =>
|
||||
isCreateContractValid.value &&
|
||||
isCreateContractNatureValid.value &&
|
||||
isCreateContractStartDateValid.value &&
|
||||
isCreateContractEndDateValid.value &&
|
||||
isCreateScheduleValid.value
|
||||
)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
|
||||
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
||||
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
||||
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
||||
const closeContractWorkedHoursLabel = computed(() => {
|
||||
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
|
||||
return contractForm.contractName || '-'
|
||||
})
|
||||
|
||||
const resetContractValidation = () => {
|
||||
validationTouched.endDate = false
|
||||
}
|
||||
|
||||
const hydrateSuspensionForms = () => {
|
||||
const current = employee.value?.currentSuspensions ?? []
|
||||
suspensionForms.value = current.map(s => ({
|
||||
id: s.id,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate ?? '',
|
||||
comment: s.comment ?? ''
|
||||
}))
|
||||
}
|
||||
|
||||
const hydrateContractFormFromCurrent = () => {
|
||||
const current = employee.value
|
||||
const period = editableContractPeriod.value
|
||||
if (!current || !period) return
|
||||
|
||||
contractForm.contractId = period.contractId ?? current.contract?.id ?? ''
|
||||
contractForm.contractName = period.contractName ?? current.contract?.name ?? ''
|
||||
contractForm.weeklyHours = period.weeklyHours ?? current.contract?.weeklyHours ?? null
|
||||
contractForm.contractNature = period.contractNature
|
||||
contractForm.startDate = period.startDate
|
||||
contractForm.endDate = period.endDate ?? getTodayYmd()
|
||||
contractForm.paidLeaveSettled = false
|
||||
contractForm.comment = ''
|
||||
contractForm.workDaysHours = period.workDaysHours ?? null
|
||||
}
|
||||
|
||||
const openCloseContractDrawer = () => {
|
||||
if (!employee.value || !canCloseCurrentContract.value) return
|
||||
hydrateContractFormFromCurrent()
|
||||
resetContractValidation()
|
||||
hydrateSuspensionForms()
|
||||
isContractDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const setContractDrawerOpen = (open: boolean) => {
|
||||
isContractDrawerOpen.value = open
|
||||
}
|
||||
|
||||
const resetCreateValidation = () => {
|
||||
createValidationTouched.contractId = false
|
||||
createValidationTouched.contractNature = false
|
||||
createValidationTouched.startDate = false
|
||||
createValidationTouched.endDate = false
|
||||
}
|
||||
|
||||
const openCreateContractDrawer = () => {
|
||||
if (!employee.value || !canCreateContract.value) return
|
||||
createContractForm.contractId = ''
|
||||
createContractForm.contractNature = 'CDI'
|
||||
createContractForm.endDate = ''
|
||||
createContractForm.isDriver = false
|
||||
createContractForm.workDaysHours = null
|
||||
createContractForm.interimAgencyId = ''
|
||||
createContractForm.startDate = editableContractPeriod.value?.endDate
|
||||
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
|
||||
: getTodayYmd()
|
||||
resetCreateValidation()
|
||||
isCreateContractDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const setCreateContractDrawerOpen = (open: boolean) => {
|
||||
isCreateContractDrawerOpen.value = open
|
||||
}
|
||||
|
||||
const submitContractUpdate = async () => {
|
||||
const period = editableContractPeriod.value
|
||||
if (!employee.value || isContractSubmitting.value || !period) return
|
||||
|
||||
validationTouched.endDate = true
|
||||
if (!isContractEndDateValid.value) return
|
||||
|
||||
if (contractForm.endDate < period.startDate) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: `La date de fin doit être postérieure au ${formatDate(period.startDate)}.`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isContractSubmitting.value = true
|
||||
try {
|
||||
await updateEmployee(employee.value.id, {
|
||||
firstName: employee.value.firstName,
|
||||
lastName: employee.value.lastName,
|
||||
siteId: employee.value.site?.id ?? null,
|
||||
contractId: Number(contractForm.contractId),
|
||||
contractEndDate: contractForm.endDate || null,
|
||||
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
|
||||
contractComment: contractForm.comment || null
|
||||
})
|
||||
|
||||
isContractDrawerOpen.value = false
|
||||
await reloadEmployee()
|
||||
} finally {
|
||||
isContractSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitCreateContract = async () => {
|
||||
if (!employee.value || isCreateContractSubmitting.value) return
|
||||
|
||||
createValidationTouched.contractId = true
|
||||
createValidationTouched.contractNature = true
|
||||
createValidationTouched.startDate = true
|
||||
createValidationTouched.endDate = true
|
||||
if (!isCreateContractFormValid.value) return
|
||||
|
||||
if (editableContractPeriod.value?.endDate) {
|
||||
const minStartDate = shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate
|
||||
if (createContractForm.startDate < minStartDate) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isCreateContractSubmitting.value = true
|
||||
try {
|
||||
await updateEmployee(employee.value.id, {
|
||||
firstName: employee.value.firstName,
|
||||
lastName: employee.value.lastName,
|
||||
siteId: employee.value.site?.id ?? null,
|
||||
contractId: Number(createContractForm.contractId),
|
||||
contractNature: createContractForm.contractNature,
|
||||
contractStartDate: createContractForm.startDate,
|
||||
contractEndDate: createContractForm.endDate || null,
|
||||
isDriverInput: createContractForm.isDriver,
|
||||
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null,
|
||||
interimAgencyId: createContractForm.contractNature === 'INTERIM' && createContractForm.interimAgencyId !== '' ? Number(createContractForm.interimAgencyId) : null
|
||||
})
|
||||
isCreateContractDrawerOpen.value = false
|
||||
await reloadEmployee()
|
||||
} finally {
|
||||
isCreateContractSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitSuspension = async (index: number) => {
|
||||
const form = suspensionForms.value[index]
|
||||
if (!form || !form.startDate) return
|
||||
|
||||
const periodId = currentActiveContractPeriodId.value
|
||||
if (!periodId) return
|
||||
|
||||
isSuspensionSubmitting.value = true
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateSuspension(form.id, {
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
comment: form.comment || null
|
||||
})
|
||||
} else {
|
||||
await createSuspension({
|
||||
contractPeriodId: periodId,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
comment: form.comment || null
|
||||
})
|
||||
}
|
||||
await reloadEmployee()
|
||||
hydrateSuspensionForms()
|
||||
} finally {
|
||||
isSuspensionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addSuspensionForm = () => {
|
||||
suspensionForms.value.push({
|
||||
id: null,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
comment: ''
|
||||
})
|
||||
}
|
||||
|
||||
const loadContracts = async () => {
|
||||
contracts.value = await listContracts()
|
||||
}
|
||||
|
||||
const loadInterimAgencies = async () => {
|
||||
interimAgencies.value = await listInterimAgencies()
|
||||
}
|
||||
|
||||
watch(() => createContractForm.contractNature, (nature) => {
|
||||
if (nature !== 'INTERIM') {
|
||||
createContractForm.interimAgencyId = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(showsCreateContractEndDate, (shows) => {
|
||||
if (!shows) {
|
||||
createContractForm.endDate = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(requiresCreateWorkDaysHours, (required) => {
|
||||
if (!required) {
|
||||
createContractForm.workDaysHours = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
contracts,
|
||||
contractHistory,
|
||||
contractForm,
|
||||
createContractForm,
|
||||
isContractDrawerOpen,
|
||||
isContractSubmitting,
|
||||
isCreateContractDrawerOpen,
|
||||
isCreateContractSubmitting,
|
||||
canCloseCurrentContract,
|
||||
canCreateContract,
|
||||
readonlyFieldClass,
|
||||
closeContractWorkedHoursLabel,
|
||||
contractEndDateFieldClass,
|
||||
showContractEndDateError,
|
||||
isContractEndDateValid,
|
||||
createContractNatureFieldClass,
|
||||
createContractFieldClass,
|
||||
createContractStartDateFieldClass,
|
||||
showsCreateContractEndDate,
|
||||
requiresCreateContractEndDate,
|
||||
createContractEndDateFieldClass,
|
||||
isCreateContractFormValid,
|
||||
requiresCreateWorkDaysHours,
|
||||
selectedCreateContract,
|
||||
contractNatureLabel,
|
||||
contractHistoryLabel,
|
||||
formatDate,
|
||||
openCloseContractDrawer,
|
||||
openCreateContractDrawer,
|
||||
setContractDrawerOpen,
|
||||
setCreateContractDrawerOpen,
|
||||
submitContractUpdate,
|
||||
submitCreateContract,
|
||||
suspensionForms,
|
||||
isSuspensionSubmitting,
|
||||
submitSuspension,
|
||||
addSuspensionForm,
|
||||
currentActiveContractPeriodId,
|
||||
interimAgencies,
|
||||
loadContracts,
|
||||
loadInterimAgencies
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||
|
||||
const formatDateFr = (iso: string | null): string => {
|
||||
if (!iso) return ''
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
const formatContractTypeLabel = (phase: ContractPhase): string => {
|
||||
switch (phase.contractType) {
|
||||
case CONTRACT_TYPES.FORFAIT:
|
||||
return 'FORFAIT'
|
||||
case CONTRACT_TYPES.H35:
|
||||
return '35h'
|
||||
case CONTRACT_TYPES.H39:
|
||||
return '39h'
|
||||
case CONTRACT_TYPES.INTERIM:
|
||||
return 'Intérim'
|
||||
case CONTRACT_TYPES.CUSTOM:
|
||||
return `CUSTOM (${phase.weeklyHours ?? '?'}h)`
|
||||
default:
|
||||
return String(phase.contractType)
|
||||
}
|
||||
}
|
||||
|
||||
export const formatPhaseLabel = (phase: ContractPhase): string => {
|
||||
const base = formatContractTypeLabel(phase)
|
||||
const driver = phase.isDriver ? ' (driver)' : ''
|
||||
const dates = phase.endDate
|
||||
? `${formatDateFr(phase.startDate)} → ${formatDateFr(phase.endDate)}`
|
||||
: `depuis ${formatDateFr(phase.startDate)}`
|
||||
const suffix = phase.isCurrent ? ' (actuel)' : ''
|
||||
return `${base}${driver} — ${dates}${suffix}`
|
||||
}
|
||||
|
||||
export const useEmployeeContractPhase = (employee: Ref<Employee | null>) => {
|
||||
const selectedPhaseId = ref<number | null>(null)
|
||||
|
||||
const availablePhases = computed<ContractPhase[]>(() => employee.value?.contractPhases ?? [])
|
||||
|
||||
const currentPhase = computed<ContractPhase | null>(() => {
|
||||
return availablePhases.value.find((p) => p.isCurrent) ?? availablePhases.value[0] ?? null
|
||||
})
|
||||
|
||||
const selectedPhase = computed<ContractPhase | null>(() => {
|
||||
if (selectedPhaseId.value === null) return currentPhase.value
|
||||
return availablePhases.value.find((p) => p.id === selectedPhaseId.value) ?? currentPhase.value
|
||||
})
|
||||
|
||||
const isViewingPastPhase = computed<boolean>(() => {
|
||||
if (!selectedPhase.value || !currentPhase.value) return false
|
||||
return selectedPhase.value.id !== currentPhase.value.id
|
||||
})
|
||||
|
||||
const phaseOptions = computed(() =>
|
||||
availablePhases.value.map((p) => ({ value: p.id, label: formatPhaseLabel(p) }))
|
||||
)
|
||||
|
||||
const showPicker = computed(() => availablePhases.value.length > 1)
|
||||
|
||||
const setSelectedPhase = (phaseId: number) => {
|
||||
selectedPhaseId.value = phaseId
|
||||
}
|
||||
|
||||
const resetToCurrent = () => {
|
||||
selectedPhaseId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
selectedPhaseId,
|
||||
selectedPhase,
|
||||
currentPhase,
|
||||
availablePhases,
|
||||
phaseOptions,
|
||||
showPicker,
|
||||
isViewingPastPhase,
|
||||
setSelectedPhase,
|
||||
resetToCurrent,
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||
import { getEmployee } from '~/services/employees'
|
||||
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
||||
|
||||
export const useEmployeeDetailPage = () => {
|
||||
const route = useRoute()
|
||||
const employee = ref<Employee | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
||||
|
||||
const phase = useEmployeeContractPhase(employee)
|
||||
|
||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||
const showRttTab = computed(() => phase.selectedPhase.value?.contractType !== CONTRACT_TYPES.FORFAIT)
|
||||
const isForfait = computed(() => phase.selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT)
|
||||
const employeeContractWorkLabel = computed(() => {
|
||||
const contract = employee.value?.contract
|
||||
if (!contract) return '-'
|
||||
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours'
|
||||
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
|
||||
return contract.name || '-'
|
||||
})
|
||||
|
||||
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 {
|
||||
employee.value = await getEmployee(employeeId)
|
||||
phase.resetToCurrent()
|
||||
|
||||
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
||||
activeTab.value = 'contract'
|
||||
}
|
||||
if (!showRttTab.value && activeTab.value === 'rtt') {
|
||||
activeTab.value = 'contract'
|
||||
}
|
||||
|
||||
leave.resetLoaded()
|
||||
rtt.resetLoaded()
|
||||
mileage.resetLoaded()
|
||||
formation.resetLoaded()
|
||||
bonus.resetLoaded()
|
||||
observation.resetLoaded()
|
||||
|
||||
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||
await leave.loadLeaveData()
|
||||
} else if (activeTab.value === 'rtt' && showRttTab.value) {
|
||||
await rtt.loadRttData()
|
||||
} else if (activeTab.value === 'mileage') {
|
||||
await mileage.loadMileageData()
|
||||
} else if (activeTab.value === 'formation') {
|
||||
await formation.loadFormationData()
|
||||
} else if (activeTab.value === 'bonus') {
|
||||
await bonus.loadBonusData()
|
||||
} else if (activeTab.value === 'observation') {
|
||||
await observation.loadObservationData()
|
||||
} else if (isForfait.value && showLeaveTab.value) {
|
||||
// Eager load: needed for the "X jours restants" header label on forfait employees.
|
||||
await leave.loadLeaveData()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const contract = useEmployeeContract(employee, loadEmployee)
|
||||
const leave = useEmployeeLeave(employee, loadEmployee, phase.selectedPhase)
|
||||
const forfaitRemainingDaysLabel = computed(() => {
|
||||
if (!isForfait.value) return ''
|
||||
const presence = leave.leaveSummary.value?.presenceDaysToToday
|
||||
if (presence === undefined || presence === null) return ''
|
||||
const remaining = 218 - presence
|
||||
return ` (${remaining} restants)`
|
||||
})
|
||||
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||
const formation = useEmployeeFormation(employee, loadEmployee)
|
||||
const bonus = useEmployeeBonus(employee, loadEmployee)
|
||||
const observation = useEmployeeObservation(employee, loadEmployee)
|
||||
|
||||
watch(() => phase.selectedPhase.value?.id, (newId, oldId) => {
|
||||
if (newId === oldId || oldId === undefined) return
|
||||
// Bascule onglet si on entre dans une phase qui ne supporte plus le tab actuel
|
||||
if (!showRttTab.value && activeTab.value === 'rtt') {
|
||||
activeTab.value = 'leave'
|
||||
}
|
||||
// Recharger l'onglet courant
|
||||
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||
leave.loadLeaveData()
|
||||
} else if (activeTab.value === 'rtt' && showRttTab.value) {
|
||||
rtt.loadRttData()
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||
leave.loadLeaveData()
|
||||
} else if (tab === 'rtt' && !rtt.rttDataLoaded.value && showRttTab.value) {
|
||||
rtt.loadRttData()
|
||||
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
|
||||
mileage.loadMileageData()
|
||||
} else if (tab === 'formation' && !formation.formationDataLoaded.value) {
|
||||
formation.loadFormationData()
|
||||
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
|
||||
bonus.loadBonusData()
|
||||
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
|
||||
observation.loadObservationData()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
|
||||
await loadEmployee()
|
||||
})
|
||||
|
||||
return {
|
||||
employee,
|
||||
isLoading,
|
||||
activeTab,
|
||||
showLeaveTab,
|
||||
showRttTab,
|
||||
employeeContractWorkLabel,
|
||||
forfaitRemainingDaysLabel,
|
||||
...phase,
|
||||
...contract,
|
||||
...leave,
|
||||
...rtt,
|
||||
...mileage,
|
||||
...formation,
|
||||
...bonus,
|
||||
...observation
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Formation } from '~/services/dto/formation'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import {
|
||||
listFormations,
|
||||
createFormation,
|
||||
updateFormation,
|
||||
deleteFormation,
|
||||
uploadFormationJustificatif
|
||||
} from '~/services/formations'
|
||||
|
||||
export const useEmployeeFormation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const config = useRuntimeConfig()
|
||||
const apiBase = (config.public.apiBase as string) ?? '/api'
|
||||
|
||||
const formations = ref<Formation[]>([])
|
||||
const isFormationLoading = ref(false)
|
||||
const formationDataLoaded = ref(false)
|
||||
|
||||
const loadFormationData = async () => {
|
||||
if (!employee.value || isFormationLoading.value) return
|
||||
isFormationLoading.value = true
|
||||
try {
|
||||
formations.value = await listFormations(employee.value.id)
|
||||
formationDataLoaded.value = true
|
||||
} finally {
|
||||
isFormationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
formationDataLoaded.value = false
|
||||
}
|
||||
|
||||
const submitCreateFormation = async (data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
|
||||
if (!employee.value) return
|
||||
const result = await createFormation({
|
||||
employeeId: employee.value.id,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
comment: data.comment
|
||||
})
|
||||
if (result?.id && justificatifFile) {
|
||||
await uploadFormationJustificatif(apiBase, result.id, justificatifFile)
|
||||
}
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitUpdateFormation = async (id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
|
||||
await updateFormation(id, data)
|
||||
if (justificatifFile) {
|
||||
await uploadFormationJustificatif(apiBase, id, justificatifFile)
|
||||
}
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitDeleteFormation = async (id: number) => {
|
||||
await deleteFormation(id)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
return {
|
||||
formations,
|
||||
isFormationLoading,
|
||||
formationDataLoaded,
|
||||
formationApiBase: apiBase,
|
||||
loadFormationData,
|
||||
resetLoaded,
|
||||
submitCreateFormation,
|
||||
submitUpdateFormation,
|
||||
submitDeleteFormation
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Absence } from '~/services/dto/absence'
|
||||
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||
import { listAbsences } from '~/services/absences'
|
||||
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
|
||||
import { listPublicHolidays } from '~/services/public-holidays'
|
||||
|
||||
export type LeaveYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useEmployeeLeave = (
|
||||
employee: Ref<Employee | null>,
|
||||
reloadEmployee: () => Promise<void>,
|
||||
selectedPhase: Ref<ContractPhase | null>,
|
||||
) => {
|
||||
const employeeAbsences = ref<Absence[]>([])
|
||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
||||
const publicHolidays = ref<Record<string, string>>({})
|
||||
const isLeaveLoading = ref(false)
|
||||
const leaveDataLoaded = ref(false)
|
||||
const selectedLeaveYear = ref<number | null>(null)
|
||||
|
||||
const isForfaitOnPhase = computed(() =>
|
||||
selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT
|
||||
)
|
||||
|
||||
const computeLeaveYearForDate = (date: Date): number => {
|
||||
if (isForfaitOnPhase.value) return date.getFullYear()
|
||||
return date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||
}
|
||||
|
||||
const currentLeaveYear = computed<number | null>(() => {
|
||||
if (!employee.value) return null
|
||||
return computeLeaveYearForDate(new Date())
|
||||
})
|
||||
|
||||
const formatLeaveYearLabel = (year: number, isForfait: boolean): string => {
|
||||
if (isForfait) return String(year)
|
||||
return `Juin ${year - 1} → Mai ${year}`
|
||||
}
|
||||
|
||||
const availableLeaveYears = computed<LeaveYearOption[]>(() => {
|
||||
if (!employee.value || !selectedPhase.value || currentLeaveYear.value === null) return []
|
||||
const isForfait = isForfaitOnPhase.value
|
||||
const phase = selectedPhase.value
|
||||
|
||||
// Plage = exercices intersectant la phase.
|
||||
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
|
||||
const phaseEndYear = phase.endDate
|
||||
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
|
||||
: currentLeaveYear.value
|
||||
|
||||
// Hard floor : data-start-date (env RTT_START_DATE) — le logiciel n'a pas
|
||||
// d'historique avant cette date, inutile de proposer des années antérieures.
|
||||
let dataFloor: number | null = null
|
||||
const dataStart = leaveSummary.value?.dataStartDate
|
||||
if (dataStart) {
|
||||
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(dataStartDate.getTime())) {
|
||||
dataFloor = computeLeaveYearForDate(dataStartDate)
|
||||
}
|
||||
}
|
||||
|
||||
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
|
||||
const maxYear = phaseEndYear
|
||||
|
||||
const years: LeaveYearOption[] = []
|
||||
for (let y = maxYear; y >= minYear; y -= 1) {
|
||||
years.push({ value: y, label: formatLeaveYearLabel(y, isForfait) })
|
||||
}
|
||||
return years
|
||||
})
|
||||
|
||||
const initSelectedLeaveYear = () => {
|
||||
if (selectedLeaveYear.value !== null) return
|
||||
if (currentLeaveYear.value !== null) {
|
||||
selectedLeaveYear.value = currentLeaveYear.value
|
||||
}
|
||||
}
|
||||
|
||||
const loadLeaveData = async () => {
|
||||
if (!employee.value || isLeaveLoading.value) return
|
||||
initSelectedLeaveYear()
|
||||
if (selectedLeaveYear.value === null) return
|
||||
isLeaveLoading.value = true
|
||||
try {
|
||||
const isForfait = isForfaitOnPhase.value
|
||||
const leaveYear = selectedLeaveYear.value
|
||||
let from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
||||
let to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
||||
const phase = selectedPhase.value
|
||||
if (phase?.startDate && phase.startDate > from) from = phase.startDate
|
||||
if (phase?.endDate && phase.endDate < to) to = phase.endDate
|
||||
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
||||
|
||||
const [absences, summary, ...holidayResults] = await Promise.all([
|
||||
listAbsences({ from, to, employeeId: employee.value.id }),
|
||||
getEmployeeLeaveSummary(employee.value.id, leaveYear, selectedPhase.value?.id),
|
||||
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
||||
])
|
||||
employeeAbsences.value = absences
|
||||
leaveSummary.value = summary
|
||||
publicHolidays.value = Object.assign({}, ...holidayResults)
|
||||
leaveDataLoaded.value = true
|
||||
} finally {
|
||||
isLeaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedLeaveYear = async (year: number) => {
|
||||
if (selectedLeaveYear.value === year) return
|
||||
selectedLeaveYear.value = year
|
||||
leaveDataLoaded.value = false
|
||||
await loadLeaveData()
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
leaveDataLoaded.value = false
|
||||
selectedLeaveYear.value = null
|
||||
}
|
||||
|
||||
watch(() => selectedPhase.value?.id, () => {
|
||||
// Reset l'année car la plage a peut-être changé.
|
||||
selectedLeaveYear.value = null
|
||||
leaveDataLoaded.value = false
|
||||
// Le rechargement effectif est piloté par useEmployeeDetailPage.
|
||||
})
|
||||
|
||||
const submitFractionedDays = async (days: number) => {
|
||||
if (!employee.value) return
|
||||
const year = leaveSummary.value?.year ?? undefined
|
||||
await updateFractionedDays(employee.value.id, days, year)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitPaidLeaveDays = async (days: number) => {
|
||||
if (!employee.value) return
|
||||
const year = leaveSummary.value?.year ?? undefined
|
||||
await updatePaidLeaveDays(employee.value.id, days, year)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
return {
|
||||
employeeAbsences,
|
||||
leaveSummary,
|
||||
publicHolidays,
|
||||
isLeaveLoading,
|
||||
leaveDataLoaded,
|
||||
selectedLeaveYear,
|
||||
currentLeaveYear,
|
||||
availableLeaveYears,
|
||||
setSelectedLeaveYear,
|
||||
loadLeaveData,
|
||||
resetLoaded,
|
||||
submitFractionedDays,
|
||||
submitPaidLeaveDays
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { MileageAllowance } from '~/services/dto/mileage-allowance'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import {
|
||||
listMileageAllowances,
|
||||
createMileageAllowance,
|
||||
updateMileageAllowance,
|
||||
deleteMileageAllowance,
|
||||
uploadKmReceipt,
|
||||
uploadAmountReceipt
|
||||
} from '~/services/mileage-allowances'
|
||||
|
||||
export const useEmployeeMileage = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const config = useRuntimeConfig()
|
||||
const apiBase = (config.public.apiBase as string) ?? '/api'
|
||||
|
||||
const mileageAllowances = ref<MileageAllowance[]>([])
|
||||
const isMileageLoading = ref(false)
|
||||
const mileageDataLoaded = ref(false)
|
||||
|
||||
const loadMileageData = async () => {
|
||||
if (!employee.value || isMileageLoading.value) return
|
||||
isMileageLoading.value = true
|
||||
try {
|
||||
mileageAllowances.value = await listMileageAllowances(employee.value.id)
|
||||
mileageDataLoaded.value = true
|
||||
} finally {
|
||||
isMileageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
mileageDataLoaded.value = false
|
||||
}
|
||||
|
||||
const submitCreateMileage = async (data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
|
||||
if (!employee.value) return
|
||||
const result = await createMileageAllowance({
|
||||
employeeId: employee.value.id,
|
||||
month: data.month,
|
||||
kilometers: data.kilometers,
|
||||
amount: data.amount,
|
||||
comment: data.comment
|
||||
})
|
||||
if (result?.id) {
|
||||
if (kmFile) {
|
||||
await uploadKmReceipt(apiBase, result.id, kmFile)
|
||||
}
|
||||
if (amountFile) {
|
||||
await uploadAmountReceipt(apiBase, result.id, amountFile)
|
||||
}
|
||||
}
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitUpdateMileage = async (id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
|
||||
await updateMileageAllowance(id, data)
|
||||
if (kmFile) {
|
||||
await uploadKmReceipt(apiBase, id, kmFile)
|
||||
}
|
||||
if (amountFile) {
|
||||
await uploadAmountReceipt(apiBase, id, amountFile)
|
||||
}
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitDeleteMileage = async (id: number) => {
|
||||
await deleteMileageAllowance(id)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
return {
|
||||
mileageAllowances,
|
||||
isMileageLoading,
|
||||
mileageDataLoaded,
|
||||
mileageApiBase: apiBase,
|
||||
loadMileageData,
|
||||
resetLoaded,
|
||||
submitCreateMileage,
|
||||
submitUpdateMileage,
|
||||
submitDeleteMileage
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Observation } from '~/services/dto/observation'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import {
|
||||
listObservations,
|
||||
createObservation,
|
||||
updateObservation,
|
||||
deleteObservation
|
||||
} from '~/services/observations'
|
||||
|
||||
export const useEmployeeObservation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const observations = ref<Observation[]>([])
|
||||
const isObservationLoading = ref(false)
|
||||
const observationDataLoaded = ref(false)
|
||||
|
||||
const loadObservationData = async () => {
|
||||
if (!employee.value || isObservationLoading.value) return
|
||||
isObservationLoading.value = true
|
||||
try {
|
||||
observations.value = await listObservations(employee.value.id)
|
||||
observationDataLoaded.value = true
|
||||
} finally {
|
||||
isObservationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
observationDataLoaded.value = false
|
||||
}
|
||||
|
||||
const submitCreateObservation = async (data: { month: string; content: string }) => {
|
||||
if (!employee.value) return
|
||||
await createObservation({
|
||||
employeeId: employee.value.id,
|
||||
month: data.month,
|
||||
content: data.content
|
||||
})
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitUpdateObservation = async (id: number, data: { month: string; content: string }) => {
|
||||
await updateObservation(id, data)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitDeleteObservation = async (id: number) => {
|
||||
await deleteObservation(id)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
return {
|
||||
observations,
|
||||
isObservationLoading,
|
||||
observationDataLoaded,
|
||||
loadObservationData,
|
||||
resetLoaded,
|
||||
submitCreateObservation,
|
||||
submitUpdateObservation,
|
||||
submitDeleteObservation
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
||||
|
||||
export type RttYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useEmployeeRtt = (
|
||||
employee: Ref<Employee | null>,
|
||||
reloadEmployee: () => Promise<void>,
|
||||
selectedPhase: Ref<ContractPhase | null>,
|
||||
) => {
|
||||
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
||||
const isRttLoading = ref(false)
|
||||
const rttDataLoaded = ref(false)
|
||||
const selectedRttYear = ref<number | null>(null)
|
||||
|
||||
// Exercice RTT : Juin (Y-1) → Mai (Y). Toujours, peu importe le type de contrat
|
||||
// (l'onglet RTT est masqué pour les FORFAIT côté page).
|
||||
const computeRttYearForDate = (date: Date): number =>
|
||||
date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||
|
||||
const currentRttYear = computed<number | null>(() => {
|
||||
if (!employee.value) return null
|
||||
return computeRttYearForDate(new Date())
|
||||
})
|
||||
|
||||
const availableRttYears = computed<RttYearOption[]>(() => {
|
||||
if (!employee.value || !selectedPhase.value || currentRttYear.value === null) return []
|
||||
const phase = selectedPhase.value
|
||||
|
||||
// Plage = exercices intersectant la phase.
|
||||
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
|
||||
const phaseEndYear = phase.endDate
|
||||
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
|
||||
: currentRttYear.value
|
||||
|
||||
// Hard floor : rttStartDate (env RTT_START_DATE) — pas d'historique avant.
|
||||
let dataFloor: number | null = null
|
||||
const dataStart = rttSummary.value?.rttStartDate
|
||||
if (dataStart) {
|
||||
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(dataStartDate.getTime())) {
|
||||
dataFloor = computeRttYearForDate(dataStartDate)
|
||||
}
|
||||
}
|
||||
|
||||
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
|
||||
const maxYear = phaseEndYear
|
||||
|
||||
const years: RttYearOption[] = []
|
||||
for (let y = maxYear; y >= minYear; y -= 1) {
|
||||
years.push({ value: y, label: `Juin ${y - 1} → Mai ${y}` })
|
||||
}
|
||||
return years
|
||||
})
|
||||
|
||||
const initSelectedRttYear = () => {
|
||||
if (selectedRttYear.value !== null) return
|
||||
if (currentRttYear.value !== null) {
|
||||
selectedRttYear.value = currentRttYear.value
|
||||
}
|
||||
}
|
||||
|
||||
const loadRttData = async () => {
|
||||
if (!employee.value || isRttLoading.value) return
|
||||
initSelectedRttYear()
|
||||
if (selectedRttYear.value === null) return
|
||||
isRttLoading.value = true
|
||||
try {
|
||||
rttSummary.value = await getEmployeeRttSummary(
|
||||
employee.value.id,
|
||||
selectedRttYear.value,
|
||||
selectedPhase.value?.id,
|
||||
)
|
||||
rttDataLoaded.value = true
|
||||
} finally {
|
||||
isRttLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedRttYear = async (year: number) => {
|
||||
if (selectedRttYear.value === year) return
|
||||
selectedRttYear.value = year
|
||||
rttDataLoaded.value = false
|
||||
await loadRttData()
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
rttDataLoaded.value = false
|
||||
selectedRttYear.value = null
|
||||
}
|
||||
|
||||
watch(() => selectedPhase.value?.id, () => {
|
||||
// Reset l'année car la plage a peut-être changé.
|
||||
selectedRttYear.value = null
|
||||
rttDataLoaded.value = false
|
||||
// Le rechargement effectif est piloté par useEmployeeDetailPage.
|
||||
})
|
||||
|
||||
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
|
||||
if (!employee.value) return
|
||||
const year = rttSummary.value?.year ?? undefined
|
||||
await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
return {
|
||||
rttSummary,
|
||||
isRttLoading,
|
||||
rttDataLoaded,
|
||||
selectedRttYear,
|
||||
currentRttYear,
|
||||
availableRttYears,
|
||||
setSelectedRttYear,
|
||||
loadRttData,
|
||||
resetLoaded,
|
||||
submitRttPayment
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
import {useApi} from '~/composables/useApi'
|
||||
|
||||
export const usePdfPrinter = () => {
|
||||
const api = useApi()
|
||||
|
||||
const printPdf = async (url: string): Promise<void> => {
|
||||
const res = await api.getBlob(url);
|
||||
const disposition = res.headers.get('content-disposition') || '';
|
||||
const match = disposition.match(/filename="(.+?)"/i);
|
||||
const filename = match?.[1] ?? 'document.pdf';
|
||||
|
||||
const pdfBlob = res.data.type === 'application/pdf'
|
||||
? res.data
|
||||
: new Blob([res.data], { type: 'application/pdf' });
|
||||
|
||||
const blobUrl = URL.createObjectURL(pdfBlob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// L'ouverture dans un nouvel onglet déclenche un 2e PDF sans le nom personnalisé.
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
}
|
||||
|
||||
return {
|
||||
printPdf
|
||||
}
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
import type { DocSection } from '~/types/documentation'
|
||||
|
||||
export const documentationSections: DocSection[] = [
|
||||
// ============================================================
|
||||
// EMPLOYEE LEVEL
|
||||
// ============================================================
|
||||
{
|
||||
id: 'connexion',
|
||||
title: 'Connexion et navigation',
|
||||
requiredLevel: 'employee',
|
||||
icon: 'mdi:login',
|
||||
articles: [
|
||||
{
|
||||
id: 'login',
|
||||
title: 'Se connecter',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Pour accéder à l\'application, rendez-vous sur la page de connexion et saisissez vos identifiants.' },
|
||||
{ type: 'list', content: 'Saisissez votre nom d\'utilisateur\nSaisissez votre mot de passe\nCliquez sur le bouton "Connexion"' },
|
||||
{ type: 'note', content: 'Si vous ne parvenez pas à vous connecter, contactez votre administrateur RH. Votre compte a peut-être été verrouillé.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'navigation',
|
||||
title: 'Naviguer dans la vue jour',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
|
||||
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
|
||||
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
|
||||
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'perimetre',
|
||||
title: 'Périmètre d\'accès',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Votre accès dépend du rôle qui vous a été attribué par l\'administrateur.' },
|
||||
{ type: 'list', content: 'Employé : accès à la saisie de ses propres heures uniquement\nChef de site : accès aux heures des employés de ses sites autorisés + validation\nAdministrateur : accès complet à toutes les fonctionnalités' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'saisie-heures',
|
||||
title: 'Saisie des heures',
|
||||
requiredLevel: 'employee',
|
||||
icon: 'mdi:clock-time-four-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'saisie-time',
|
||||
title: 'Mode horaire (TIME)',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'En mode horaire, vous saisissez vos heures via des créneaux matin, après-midi et soir.' },
|
||||
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
|
||||
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
|
||||
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:00–21:00), heures de nuit (00:00–06:00 et 21:00–24:00) et total.' },
|
||||
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) et la création d\'absences sont autorisées.' },
|
||||
{ type: 'note', content: 'Crédit automatique sur jour férié Lun-Ven : pour tout contrat hors Forfait et s\'il n\'y a pas d\'absence déclarée, un jour férié compte au minimum les heures contractuelles attendues (35h → 7h, 39h → 8h Lun-Jeu / 7h Ven). Si vous saisissez des heures supérieures à cette référence, ce sont vos heures qui sont comptées ; sinon c\'est la référence. Les conducteurs reçoivent ce crédit dans leur bucket "Heures jour". **Si une absence est posée sur le férié**, c\'est le paramétrage du type d\'absence (compte les heures oui/non) qui pilote les heures comptées, le crédit virtuel férié ne s\'applique plus.' },
|
||||
{ type: 'note', content: 'Contrats non-standards (4h, 25h, 28h, etc.) : un planning par jour travaillé doit être saisi à la création/modification du contrat (bloc « Jours travaillés » avec case à cocher + horaire par jour). Le crédit férié et le crédit d\'absence ne s\'appliquent que sur les jours programmés, avec les heures programmées. Ex. un 4h Lundi 2h + Jeudi 2h : férié le lundi → +2h, férié le mardi → 0h.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'saisie-presence',
|
||||
title: 'Mode présence (PRESENCE)',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'En mode présence (contrats forfait), vous indiquez simplement si vous étiez présent le matin et/ou l\'après-midi.' },
|
||||
{ type: 'list', content: 'Cochez "Présent matin" pour indiquer une demi-journée de travail le matin\nCochez "Présent après-midi" pour indiquer une demi-journée l\'après-midi\nChaque demi-journée cochée compte pour 0.5 jour de présence' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comprendre-calculs',
|
||||
title: 'Comprendre les calculs affichés',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les colonnes de calcul sont mises à jour automatiquement en fonction de votre saisie.' },
|
||||
{ type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24:00\nTotal : somme des heures de jour et de nuit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'commentaire-semaine',
|
||||
title: 'Commentaires de semaine (admin)',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Sur la vue semaine, un picto bulle permet d\'attacher un commentaire libre sur la semaine d\'un employé.' },
|
||||
{ type: 'list', content: 'Bulle bleue : pas de commentaire\nBulle rouge : un commentaire existe\nClic : ouvre le drawer avec textarea' },
|
||||
{ type: 'note', content: 'Les commentaires n\'affectent aucun calcul. Pour supprimer, videz la textarea puis Enregistrer, ou bouton Supprimer.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'saisie-conducteurs',
|
||||
title: 'Saisie conducteurs',
|
||||
requiredLevel: 'employee',
|
||||
icon: 'mdi:truck-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'conducteur-heures',
|
||||
title: 'Saisie des heures conducteur',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les conducteurs disposent d\'un écran dédié accessible via le menu "Heures Conducteurs". Ils n\'apparaissent pas sur l\'écran classique des heures.' },
|
||||
{ type: 'list', content: 'Heures de jour : durée au format HH:MM\nHeures de nuit : durée au format HH:MM\nHeures atelier : durée au format HH:MM\nTotal : calculé automatiquement (jour + nuit + atelier)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'conducteur-indemnites',
|
||||
title: 'Indemnités conducteur',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'En plus des heures, vous pouvez cocher les indemnités correspondant à votre journée.' },
|
||||
{ type: 'list', content: 'Petit déjeuner\nDéjeuner\nDîner\nNuitée' },
|
||||
{ type: 'paragraph', content: 'La même logique de validation s\'applique que pour les heures classiques.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'absences-validations',
|
||||
title: 'Absences et validations',
|
||||
requiredLevel: 'employee',
|
||||
icon: 'mdi:information-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'comprendre-absences',
|
||||
title: 'Comprendre les absences affichées',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Quand une absence est posée sur votre journée, elle apparaît dans la colonne dédiée avec un fond coloré selon le type d\'absence.' },
|
||||
{ type: 'list', content: 'Absence du matin (AM) : verrouille le créneau matin\nAbsence de l\'après-midi (PM) : verrouille les créneaux après-midi et soir\nAbsence journée complète : verrouille tous les créneaux' },
|
||||
{ type: 'note', content: 'Vous ne pouvez pas modifier les créneaux horaires verrouillés par une absence. Seul un administrateur peut retirer ou modifier l\'absence.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'comprendre-validations',
|
||||
title: 'Comprendre les validations',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Vos heures passent par un processus de double validation avant d\'être définitivement enregistrées.' },
|
||||
{ type: 'list', content: 'Validation chef de site : votre chef de site vérifie et valide vos heures. La ligne est alors verrouillée pour vous.\nValidation RH : l\'administrateur RH valide définitivement. La ligne est complètement verrouillée.' },
|
||||
{ type: 'paragraph', content: 'Une fois validée, vous ne pouvez plus modifier la ligne. Si une correction est nécessaire, contactez votre chef de site ou l\'administrateur RH.' },
|
||||
{ type: 'note', content: 'Toute vraie modification effectuée par un administrateur remet automatiquement les deux validations à zéro.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// SITE MANAGER LEVEL
|
||||
// ============================================================
|
||||
{
|
||||
id: 'validation-site',
|
||||
title: 'Validation de site',
|
||||
requiredLevel: 'site_manager',
|
||||
icon: 'mdi:check-decagram-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'role-chef-site',
|
||||
title: 'Rôle du chef de site',
|
||||
requiredLevel: 'site_manager',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'En tant que chef de site, vous êtes responsable de la vérification et de la validation des heures saisies par les employés de votre site.' },
|
||||
{ type: 'paragraph', content: 'Le workflow de validation suit un circuit en 3 étapes : l\'employé saisit ses heures → le chef de site valide → l\'admin RH valide définitivement.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'validation-individuelle',
|
||||
title: 'Validation individuelle',
|
||||
requiredLevel: 'site_manager',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Pour valider une ligne d\'heures individuellement :' },
|
||||
{ type: 'list', content: 'Cochez la case de validation site sur la ligne de l\'employé\nLa ligne est immédiatement verrouillée pour l\'employé\nL\'administrateur RH peut toujours corriger une ligne que vous avez validée' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'validation-masse',
|
||||
title: 'Validation en masse',
|
||||
requiredLevel: 'site_manager',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Pour gagner du temps, vous pouvez valider toutes les lignes en une seule action.' },
|
||||
{ type: 'list', content: 'Cliquez sur le bouton de validation en masse\nToutes les lignes de la date affichée sont validées d\'un coup\nUtile quand toutes les saisies sont correctes' },
|
||||
{ type: 'note', content: 'Quand toutes les lignes de votre site sont validées pour une date donnée, les administrateurs RH reçoivent automatiquement une notification.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'difference-validations',
|
||||
title: 'Validation site vs validation RH',
|
||||
requiredLevel: 'site_manager',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Il est important de comprendre la différence entre les deux niveaux de validation.' },
|
||||
{ type: 'list', content: 'Validation site : verrouille la ligne pour les employés, mais l\'admin RH peut encore modifier\nValidation RH : verrouillage complet, seul l\'admin peut retirer cette validation\nLe chef de site ne voit pas et ne peut pas agir sur la validation RH' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// ADMIN LEVEL
|
||||
// ============================================================
|
||||
{
|
||||
id: 'administration',
|
||||
title: 'Administration',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:cog-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'gestion-sites',
|
||||
title: 'Gestion des sites',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les sites organisent les employés et les accès dans l\'application. Chaque site possède un nom et une couleur utilisée dans toute l\'interface.' },
|
||||
{ type: 'list', content: 'Créer, modifier ou supprimer un site depuis le menu "Sites"\nL\'ordre d\'affichage est modifiable par glisser-déposer\nLa couleur du site est utilisée pour identifier visuellement les employés' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gestion-types-absence',
|
||||
title: 'Gestion des types de statut',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les types de statut définissent les catégories disponibles lors de la pose d\'une absence.' },
|
||||
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
|
||||
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gestion-utilisateurs',
|
||||
title: 'Gestion des utilisateurs',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Chaque personne qui se connecte à l\'application a un compte utilisateur distinct de sa fiche employé.' },
|
||||
{ type: 'list', content: 'Nom d\'utilisateur : unique, sert de login\nMot de passe : défini à la création, modifiable\nRôle : Admin (accès complet), User (chef de site), Self (employé)\nSites autorisés : pour les chefs de site, définit leur périmètre\nAssociation employé : lie le compte à une fiche employé\nVerrouillage : un compte verrouillé ne peut plus se connecter' },
|
||||
{ type: 'note', content: 'Il n\'est pas possible de supprimer un utilisateur (sécurité). Pour bloquer l\'accès, utilisez le verrouillage de compte.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'taches-automatiques',
|
||||
title: 'Tâches automatiques (crons)',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
|
||||
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
|
||||
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'employes-contrats',
|
||||
title: 'Employés et contrats',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:account-group-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'liste-employes',
|
||||
title: 'Liste et recherche d\'employés',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'La page Employés affiche tous les employés sous forme de cartes.' },
|
||||
{ type: 'list', content: 'Recherche par nom\nFiltrage par site (multi-sélection)\nFiltrage par statut de contrat : "Avec contrat" (défaut), "Sans contrat", "Tous"\n"Avec contrat" = employés ayant une période de contrat active à la date du jour' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'creation-employe',
|
||||
title: 'Créer un employé',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
|
||||
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nAgence d\'intérim (visible uniquement pour INTERIM, optionnel)\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'types-contrat',
|
||||
title: 'Types de contrat',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Le type de contrat détermine le mode de suivi et les règles de calcul appliquées.' },
|
||||
{ type: 'list', content: 'FORFAIT : suivi en jours (mode PRESENCE), base 218 jours/an\n35 HEURES : suivi horaire (mode TIME), 35h/semaine\n39 HEURES : suivi horaire (mode TIME), 39h/semaine\nCUSTOM : heures personnalisées (ex: 4h, 20h), 1h sup = 1h récup sans bonus\nINTERIM : travail temporaire, pas de récupération ni de congés gérés' },
|
||||
{ type: 'note', content: 'Le mode de suivi (TIME ou PRESENCE) est lié au type de contrat et ne peut pas être modifié indépendamment.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'suivi-contrat',
|
||||
title: 'Suivi contrat et historique',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'onglet "Suivi contrat" sur la fiche employé affiche l\'historique complet des périodes de contrat.' },
|
||||
{ type: 'list', content: 'Chaque ligne : nature (CDI/CDD/INTERIM), type de contrat, date début, date fin ou "En cours"\nAjouter un contrat : disponible uniquement si le contrat en cours est clôturé\nClôturer un contrat : définir la date de fin + option "Solde de tout compte"\nSuspension : ajouter une période de suspension avec dates et commentaire' },
|
||||
{ type: 'note', content: 'La case "Soldé dans le solde de tout compte" remet le report des congés à 0 pour l\'exercice suivant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'statut-conducteur',
|
||||
title: 'Statut conducteur',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Le statut conducteur est un flag activé sur une période de contrat. Un employé peut changer de statut conducteur selon la période.' },
|
||||
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contract-phase-view',
|
||||
title: 'Vue contrat — sélecteur de phase',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Quand un employé change de type de contrat (ex. 39h → FORFAIT) ou enchaîne plusieurs CDD avec solde de tout compte, ses anciennes phases de contrat restent consultables via le sélecteur "Vue contrat" en haut de la fiche.' },
|
||||
{ type: 'paragraph', content: 'Choisir une phase passée fait basculer les onglets Congés et RTT sur les règles de cette phase. L\'onglet RTT réapparaît si la phase n\'est pas un FORFAIT. Un bandeau jaune indique que vous êtes en mode historique.' },
|
||||
{ type: 'paragraph', content: 'Sur une phase passée, vous pouvez :' },
|
||||
{ type: 'list', content: 'Solder les RTT restants — bouton "+ Payer les RTT" actif uniquement sur le dernier exercice de la phase (celui contenant la date de fin)\nSolder les CP restants via le champ "Solde de tout compte" sur la période de contrat correspondante (onglet Contrat)' },
|
||||
{ type: 'note', content: 'L\'édition d\'absences et des stocks de report (jours fractionnés, Année N-1) est désactivée en mode phase passée.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'double-validation',
|
||||
title: 'Saisie et double validation',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:shield-check-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'validation-rh',
|
||||
title: 'Validation RH',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'La validation RH est le niveau de validation le plus élevé, réservé aux administrateurs.' },
|
||||
{ type: 'list', content: 'Verrouille complètement la ligne (heures et absences)\nSeul un administrateur peut retirer cette validation\nPeut être appliquée individuellement ou en masse' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'regles-reinitialisation',
|
||||
title: 'Règles de réinitialisation',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' },
|
||||
{ type: 'list', content: 'Toute vraie modification d\'une ligne remet les deux validations (site et RH) à faux\nUn enregistrement sans changement réel préserve les validations existantes\nLa date de modification est mise à jour uniquement quand un employé modifie ses propres heures' },
|
||||
{ type: 'note', content: 'La date de modification est visible uniquement par les administrateurs, sous le nom de l\'employé dans la vue jour.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vue-semaine-hs',
|
||||
title: 'Vue semaine et heures supplémentaires',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:calendar-week',
|
||||
articles: [
|
||||
{
|
||||
id: 'vue-semaine',
|
||||
title: 'Vue semaine',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'La vue semaine est réservée aux administrateurs. Elle affiche une synthèse hebdomadaire par employé avec les heures supplémentaires calculées.' },
|
||||
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération\nLes jours fériés sont signalés sur la cellule du jour : fond bleu clair quand pas d\'absence, nom du férié au survol' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'calcul-hs',
|
||||
title: 'Calcul des heures supplémentaires',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
|
||||
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus\nINTERIM : aucune récupération, aucun bonus' },
|
||||
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d\'abord des heures à 50%, puis des heures à 25%.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vue-semaine-conducteurs',
|
||||
title: 'Vue semaine conducteurs',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'La vue semaine conducteurs affiche des colonnes spécifiques.' },
|
||||
{ type: 'list', content: 'Totaux jour / nuit / atelier par jour et par semaine\nPanier de nuit (PN) : affiché quand heures nuit > heures jour OU nuit ≥ 4h\nCompteurs hebdomadaires : petit déjeuner, déjeuner, dîner, nuitée\nRTT calculé sur jour + nuit + atelier (au lieu des créneaux classiques)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'absences-calendrier',
|
||||
title: 'Absences et calendrier',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:calendar-blank',
|
||||
articles: [
|
||||
{
|
||||
id: 'poser-absence',
|
||||
title: 'Poser une absence',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
|
||||
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
|
||||
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'effet-absences-heures',
|
||||
title: 'Effet sur les heures',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'impact d\'une absence sur les heures dépend du type d\'absence et du mode de suivi.' },
|
||||
{ type: 'list', content: 'Standard : efface les créneaux horaires correspondants\nSi "Compté comme travaillé" en mode TIME : crédite des minutes selon le contrat actif\nSi "Compté comme travaillé" en mode PRESENCE : aucun crédit (seules les cases cochées comptent)' },
|
||||
{ type: 'note', content: 'Les absences comptées comme travaillées impactent le calcul des heures supplémentaires et du RTT.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'calendrier-mensuel',
|
||||
title: 'Calendrier mensuel',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
|
||||
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
|
||||
{ type: 'note', content: 'Seuls les employés ayant au moins un jour de contrat sur le mois affiché apparaissent. Un employé dont le contrat s\'est terminé avant le 1er du mois (ou qui commence après la fin du mois) est masqué.' },
|
||||
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'conges-payes',
|
||||
title: 'Congés payés',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:umbrella-beach-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'regles-cdi-cdd',
|
||||
title: 'Règles CDI/CDD non-forfait',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Pour les contrats CDI et CDD (hors forfait), l\'exercice de congés va du 1er juin (N-1) au 31 mai (N).' },
|
||||
{ type: 'list', content: 'Acquisition annuelle : 25 jours + 5 samedis\nAcquisition mensuelle : 2,08 jours + 0,42 samedi par mois\nProratisation en cas de début/fin ou suspension en cours de mois\nContrat 4h : 10 jours annuels, 0 samedi, 0,83 jour/mois' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'regles-forfait',
|
||||
title: 'Règles FORFAIT',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Pour les contrats forfait, l\'exercice suit l\'année civile (1er janvier au 31 décembre).' },
|
||||
{ type: 'list', content: 'Calcul : jours ouvrés de l\'année − 218 + bonus weekend/férié\nBonus : 1 jour par jour travaillé un weekend ou jour férié (0.5 si demi-journée)\nPas de samedis\nPas de jours en cours d\'acquisition' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'maladie-longue',
|
||||
title: 'Arrêt maladie long',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'En cas d\'arrêt maladie de plus d\'un mois, les règles d\'acquisition sont modifiées.' },
|
||||
{ type: 'list', content: 'Premier mois de maladie : acquisition normale\nAprès le premier mois : acquisition réduite (facteur 0,80)\nDétection automatique à partir des absences MALADIE consécutives (tolérance de gap ≤ 3 jours)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'report-conges',
|
||||
title: 'Report annuel et rollover',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Le reliquat de congés de l\'exercice précédent est automatiquement reporté dans les acquis du nouvel exercice.' },
|
||||
{ type: 'list', content: 'Report automatique le 1er juin (CDI/CDD non-forfait) ou 1er janvier (forfait)\nSi "Solde de tout compte" coché sur le contrat clôturé : report remis à 0\nJours fractionnés : saisie manuelle par la RH, ajoutés aux acquis' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'consommation-conges',
|
||||
title: 'Règle de consommation',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Les absences s\'imputent selon un ordre précis.' },
|
||||
{ type: 'list', content: 'D\'abord sur les acquis (report N-1)\nPuis sur les jours en cours d\'acquisition\nEn cours d\'acquisition peut devenir négatif temporairement (se reconstitue avec les acquisitions suivantes)' },
|
||||
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'onglet-conges-fiche-employe',
|
||||
title: 'Onglet Congés (fiche employé)',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
|
||||
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
|
||||
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
|
||||
{ type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ecran-recap-conges',
|
||||
title: 'Écran Récap. congés',
|
||||
requiredLevel: 'employee',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
|
||||
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
|
||||
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
|
||||
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rtt',
|
||||
title: 'RTT (Récupération de Temps de Travail)',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:timer-sand',
|
||||
articles: [
|
||||
{
|
||||
id: 'rtt-principe',
|
||||
title: 'Principe et exercice RTT',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Le RTT correspond aux heures supplémentaires accumulées, converties en temps de récupération. L\'exercice RTT va du 1er juin (N-1) au 31 mai (N).' },
|
||||
{ type: 'paragraph', content: 'L\'onglet RTT sur la fiche employé affiche le détail hebdomadaire regroupé par mois, avec un compteur global en heures (1 jour = 7h = 420 minutes).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rtt-compteurs',
|
||||
title: 'Compteurs RTT',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis − payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
|
||||
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
|
||||
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rtt-paiement',
|
||||
title: 'Paiement RTT',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' },
|
||||
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rtt-selecteur-exercice',
|
||||
title: 'Consulter un exercice passé',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
|
||||
{ type: 'note', content: 'Sur un exercice passé, le bouton « + Payer les RTT » est désactivé. Aucun paiement rétroactif n\'est autorisé pour préserver la cohérence du report N-1.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rtt-semaines-mois',
|
||||
title: 'Attribution des semaines aux mois',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Chaque semaine ISO est attribuée à un seul mois dans le tableau RTT.' },
|
||||
{ type: 'list', content: 'Une semaine est attribuée au mois qui contient son samedi\nSi le samedi tombe en début de mois suivant, la semaine est dans ce mois suivant' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'frais-primes-observations',
|
||||
title: 'Frais, primes et observations',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:account-cash-outline',
|
||||
articles: [
|
||||
{
|
||||
id: 'frais',
|
||||
title: 'Onglet Frais',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'onglet Frais sur la fiche employé permet de saisir les frais kilométriques et les montants associés.' },
|
||||
{ type: 'list', content: 'Mois : obligatoire\nKilomètres : nombre de km (optionnel)\nMontant : en euros (optionnel)\nCommentaire : optionnel\nDeux justificatifs PDF distincts : un pour les km, un pour le montant' },
|
||||
{ type: 'note', content: 'Au moins un des deux champs (kilomètres ou montant) doit être supérieur à 0. Un seul enregistrement par mois par employé.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'formation',
|
||||
title: 'Onglet Formation',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'onglet Formation sur la fiche employé permet de tracer les formations suivies par le salarié.' },
|
||||
{ type: 'list', content: 'Date de début : obligatoire\nDate de fin : obligatoire (doit être postérieure ou égale à la date de début)\nJustificatif PDF : optionnel\nCommentaire : optionnel' },
|
||||
{ type: 'note', content: 'Les formations sont triées par date de début décroissante. Cliquer sur une ligne permet de la modifier ou la supprimer.' },
|
||||
{ type: 'paragraph', content: 'Les formations sont également affichées en consultation sur l\'écran des heures (pastille indigo "Formation" dans la colonne Absence, sans bouton Modifier) et dans le calendrier (cellule "F" indigo ou icône école si couplée à une absence, cellule non cliquable). La modification et la suppression d\'une formation se font exclusivement depuis cet onglet.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'primes',
|
||||
title: 'Onglet Prime',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'list', content: 'Mois : obligatoire\nMontant en euros : obligatoire\nCommentaire : optionnel\nUne seule prime par mois par employé' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'observations',
|
||||
title: 'Onglet Observation',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'list', content: 'Mois : obligatoire\nTexte d\'observation : obligatoire\nUne seule observation par mois par employé\nNote libre pour le suivi RH' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'exports',
|
||||
title: 'Exports et impressions',
|
||||
requiredLevel: 'admin',
|
||||
icon: 'mdi:file-pdf-box',
|
||||
articles: [
|
||||
{
|
||||
id: 'export-recap-conges',
|
||||
title: 'Export récap. congés',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Génère un PDF A4 portrait récapitulant les congés de tous les employés actifs.' },
|
||||
{ type: 'list', content: 'Accessible depuis la page Employés (bouton "Export récap. congés")\nGénère un PDF à la date du jour\nDonnées groupées par site\nColonnes : nom, contrat, CP N-1 restant, samedi restant, CP N, RTT' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'export-recap-salaire',
|
||||
title: 'Récapitulatif salaire',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
|
||||
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'impression-absences',
|
||||
title: 'Impression absences',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Génère un PDF A3 paysage du calendrier d\'absences avec des filtres.' },
|
||||
{ type: 'list', content: 'Filtres : période (du/au), sites, nature de contrat, type de contrat\nTous les filtres sont cochés par défaut\nCalendrier coloré par type d\'absence' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'export-heures-annuelles',
|
||||
title: 'Export heures annuelles',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' },
|
||||
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user