Compare commits
2 Commits
v0.1.26
...
3f98fadfa4
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f98fadfa4 | |||
| 16215bce45 |
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npx vue-tsc:*)",
|
|
||||||
"Bash(npx nuxi:*)",
|
|
||||||
"Bash(php:*)",
|
|
||||||
"Bash(docker compose:*)",
|
|
||||||
"Bash(make test:*)",
|
|
||||||
"Bash(grep:*)",
|
|
||||||
"Bash(docker exec:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
.env
2
.env
@@ -46,6 +46,4 @@ JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
|||||||
JWT_PASSPHRASE=9efb9a2ec48439c723621d0c6393d04da5516c8fa00ecdba1660717b4f996867
|
JWT_PASSPHRASE=9efb9a2ec48439c723621d0c6393d04da5516c8fa00ecdba1660717b4f996867
|
||||||
JWT_COOKIE_SECURE=0
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_COOKIE_SAMESITE=lax
|
JWT_COOKIE_SAMESITE=lax
|
||||||
JWT_TOKEN_TTL=86400
|
|
||||||
JWT_COOKIE_TTL=86400
|
|
||||||
###< lexik/jwt-authentication-bundle ###
|
###< 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,65 +0,0 @@
|
|||||||
name: Build Release Artefact
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: "8.4"
|
|
||||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "lts/*"
|
|
||||||
|
|
||||||
- name: Install backend deps (prod)
|
|
||||||
env:
|
|
||||||
APP_ENV: prod
|
|
||||||
APP_DEBUG: "0"
|
|
||||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
|
||||||
|
|
||||||
- name: Build frontend (static)
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm ci
|
|
||||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
|
||||||
test -f .output/public/index.html
|
|
||||||
|
|
||||||
- name: Build artefact
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release
|
|
||||||
tar -czf "release/sirh-${GITHUB_REF_NAME}.tar.gz" \
|
|
||||||
bin \
|
|
||||||
config \
|
|
||||||
migrations \
|
|
||||||
public \
|
|
||||||
src \
|
|
||||||
templates \
|
|
||||||
vendor \
|
|
||||||
composer.json \
|
|
||||||
composer.lock \
|
|
||||||
symfony.lock \
|
|
||||||
frontend/.output
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: release/sirh-${{ github.ref_name }}.tar.gz
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
18
.idea/SIRH.iml
generated
18
.idea/SIRH.iml
generated
@@ -136,24 +136,6 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/willdurand/negotiation" />
|
<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" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
|
<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" />
|
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<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>
|
||||||
10
.idea/data_source_mapping.xml
generated
10
.idea/data_source_mapping.xml
generated
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DataSourcePerFileMappings">
|
<component name="DataSourcePerFileMappings">
|
||||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console_3.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
|
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
6
.idea/db-forest-config.xml
generated
6
.idea/db-forest-config.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="MaterialThemeProjectNewConfig">
|
<component name="MaterialThemeProjectNewConfig">
|
||||||
<option name="metadata">
|
<option name="metadata">
|
||||||
<MTProjectMetadataState>
|
<MTProjectMetadataState>
|
||||||
<option name="migrated" value="true" />
|
<option name="userId" value="-7cf7a629:19c1e9ce3f8:-7e79" />
|
||||||
<option name="pristineConfig" value="false" />
|
</MTProjectMetadataState>
|
||||||
<option name="userId" value="-3bc0fa3e:19bc6e06872:-7ff9" />
|
</option>
|
||||||
</MTProjectMetadataState>
|
</component>
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
</project>
|
||||||
331
.idea/php.xml
generated
331
.idea/php.xml
generated
@@ -1,172 +1,161 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="MessDetectorOptionsConfiguration">
|
<component name="MessDetectorOptionsConfiguration">
|
||||||
<option name="transferred" value="true" />
|
<option name="transferred" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PHPCSFixerOptionsConfiguration">
|
<component name="PHPCSFixerOptionsConfiguration">
|
||||||
<option name="transferred" value="true" />
|
<option name="transferred" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PHPCodeSnifferOptionsConfiguration">
|
<component name="PHPCodeSnifferOptionsConfiguration">
|
||||||
<option name="highlightLevel" value="WARNING" />
|
<option name="highlightLevel" value="WARNING" />
|
||||||
<option name="transferred" value="true" />
|
<option name="transferred" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpIncludePathManager">
|
<component name="PhpIncludePathManager">
|
||||||
<include_path>
|
<include_path>
|
||||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
<path value="$PROJECT_DIR$/vendor/psr/log" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
<path value="$PROJECT_DIR$/vendor/psr/container" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||||
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||||
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
|
||||||
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||||
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
<path value="$PROJECT_DIR$/vendor/react/cache" />
|
||||||
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
<path value="$PROJECT_DIR$/vendor/react/stream" />
|
||||||
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
<path value="$PROJECT_DIR$/vendor/react/socket" />
|
||||||
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
|
||||||
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
<path value="$PROJECT_DIR$/vendor/react/child-process" />
|
||||||
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
<path value="$PROJECT_DIR$/vendor/react/dns" />
|
||||||
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
<path value="$PROJECT_DIR$/vendor/react/promise" />
|
||||||
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
|
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
|
||||||
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
<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-text-template" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
|
||||||
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
|
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
|
||||||
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
|
||||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
||||||
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
|
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
|
<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/doctrine-orm" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
|
||||||
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
|
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
|
||||||
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
<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-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
|
</include_path>
|
||||||
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
|
</component>
|
||||||
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
|
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||||
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
|
<component name="PhpStanOptionsConfiguration">
|
||||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
<option name="transferred" value="true" />
|
||||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
</component>
|
||||||
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
|
<component name="PhpUnit">
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
|
<phpunit_settings>
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
|
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
|
</phpunit_settings>
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
|
</component>
|
||||||
</include_path>
|
<component name="PsalmOptionsConfiguration">
|
||||||
</component>
|
<option name="transferred" value="true" />
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
</component>
|
||||||
<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>
|
</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`
|
|
||||||
17
README.md
17
README.md
@@ -1,19 +1,2 @@
|
|||||||
# SIRH
|
# SIRH
|
||||||
Application de gestion des absences employée
|
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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"doctrine/doctrine-bundle": "^3.2",
|
"doctrine/doctrine-bundle": "^3.2",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
"dompdf/dompdf": "^3.1",
|
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/http-client": "8.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
@@ -87,7 +85,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.93",
|
"friendsofphp/php-cs-fixer": "^3.93",
|
||||||
"phpunit/phpunit": "^12.5"
|
"phpunit/phpunit": "^12.5"
|
||||||
}
|
}
|
||||||
|
|||||||
865
composer.lock
generated
865
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "b540b6cb25ef55c5eebccb57c76da584",
|
"content-hash": "567d0702493304b192a19126c0692e72",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2360,161 +2360,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-10-26T09:35:14+00:00"
|
"time": "2025-10-26T09:35:14+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "dompdf/dompdf",
|
|
||||||
"version": "v3.1.4",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/dompdf/dompdf.git",
|
|
||||||
"reference": "db712c90c5b9868df3600e64e68da62e78a34623"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
|
|
||||||
"reference": "db712c90c5b9868df3600e64e68da62e78a34623",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"dompdf/php-font-lib": "^1.0.0",
|
|
||||||
"dompdf/php-svg-lib": "^1.0.0",
|
|
||||||
"ext-dom": "*",
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"masterminds/html5": "^2.0",
|
|
||||||
"php": "^7.1 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"ext-gd": "*",
|
|
||||||
"ext-json": "*",
|
|
||||||
"ext-zip": "*",
|
|
||||||
"mockery/mockery": "^1.3",
|
|
||||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
|
||||||
"squizlabs/php_codesniffer": "^3.5",
|
|
||||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-gd": "Needed to process images",
|
|
||||||
"ext-gmagick": "Improves image processing performance",
|
|
||||||
"ext-imagick": "Improves image processing performance",
|
|
||||||
"ext-zlib": "Needed for pdf stream compression"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Dompdf\\": "src/"
|
|
||||||
},
|
|
||||||
"classmap": [
|
|
||||||
"lib/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"LGPL-2.1"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "The Dompdf Community",
|
|
||||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
|
||||||
"homepage": "https://github.com/dompdf/dompdf",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
|
||||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
|
|
||||||
},
|
|
||||||
"time": "2025-10-29T12:43:30+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dompdf/php-font-lib",
|
|
||||||
"version": "1.0.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
|
||||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
|
||||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"php": "^7.1 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"FontLib\\": "src/FontLib"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"LGPL-2.1-or-later"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "The FontLib Community",
|
|
||||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
|
||||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
|
||||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
|
||||||
},
|
|
||||||
"time": "2026-01-20T14:10:26+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dompdf/php-svg-lib",
|
|
||||||
"version": "1.0.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
|
||||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
|
||||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"php": "^7.1 || ^8.0",
|
|
||||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Svg\\": "src/Svg"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"LGPL-3.0-or-later"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "The SvgLib Community",
|
|
||||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A library to read, parse and export to PDF SVG files.",
|
|
||||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
|
||||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
|
||||||
},
|
|
||||||
"time": "2026-01-02T16:01:13+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "lcobucci/jwt",
|
"name": "lcobucci/jwt",
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
@@ -2704,176 +2549,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-20T17:47:00+00:00"
|
"time": "2025-12-20T17:47:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "masterminds/html5",
|
|
||||||
"version": "2.10.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Masterminds/html5-php.git",
|
|
||||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
|
|
||||||
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-dom": "*",
|
|
||||||
"php": ">=5.3.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "2.7-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Masterminds\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Matt Butcher",
|
|
||||||
"email": "technosophos@gmail.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Matt Farina",
|
|
||||||
"email": "matt@mattfarina.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Asmir Mustafic",
|
|
||||||
"email": "goetas@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "An HTML5 parser and serializer.",
|
|
||||||
"homepage": "http://masterminds.github.io/html5-php",
|
|
||||||
"keywords": [
|
|
||||||
"HTML5",
|
|
||||||
"dom",
|
|
||||||
"html",
|
|
||||||
"parser",
|
|
||||||
"querypath",
|
|
||||||
"serializer",
|
|
||||||
"xml"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
|
||||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
|
|
||||||
},
|
|
||||||
"time": "2025-07-25T09:04:22+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "monolog/monolog",
|
|
||||||
"version": "3.10.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Seldaek/monolog.git",
|
|
||||||
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
|
||||||
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.1",
|
|
||||||
"psr/log": "^2.0 || ^3.0"
|
|
||||||
},
|
|
||||||
"provide": {
|
|
||||||
"psr/log-implementation": "3.0.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"aws/aws-sdk-php": "^3.0",
|
|
||||||
"doctrine/couchdb": "~1.0@dev",
|
|
||||||
"elasticsearch/elasticsearch": "^7 || ^8",
|
|
||||||
"ext-json": "*",
|
|
||||||
"graylog2/gelf-php": "^1.4.2 || ^2.0",
|
|
||||||
"guzzlehttp/guzzle": "^7.4.5",
|
|
||||||
"guzzlehttp/psr7": "^2.2",
|
|
||||||
"mongodb/mongodb": "^1.8 || ^2.0",
|
|
||||||
"php-amqplib/php-amqplib": "~2.4 || ^3",
|
|
||||||
"php-console/php-console": "^3.1.8",
|
|
||||||
"phpstan/phpstan": "^2",
|
|
||||||
"phpstan/phpstan-deprecation-rules": "^2",
|
|
||||||
"phpstan/phpstan-strict-rules": "^2",
|
|
||||||
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
|
||||||
"predis/predis": "^1.1 || ^2",
|
|
||||||
"rollbar/rollbar": "^4.0",
|
|
||||||
"ruflin/elastica": "^7 || ^8",
|
|
||||||
"symfony/mailer": "^5.4 || ^6",
|
|
||||||
"symfony/mime": "^5.4 || ^6"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
|
|
||||||
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
|
|
||||||
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
|
|
||||||
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
|
|
||||||
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
|
|
||||||
"ext-mbstring": "Allow to work properly with unicode symbols",
|
|
||||||
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
|
|
||||||
"ext-openssl": "Required to send log messages using SSL",
|
|
||||||
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
|
|
||||||
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
|
||||||
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
|
||||||
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
|
||||||
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
|
||||||
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "3.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Monolog\\": "src/Monolog"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jordi Boggiano",
|
|
||||||
"email": "j.boggiano@seld.be",
|
|
||||||
"homepage": "https://seld.be"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
|
|
||||||
"homepage": "https://github.com/Seldaek/monolog",
|
|
||||||
"keywords": [
|
|
||||||
"log",
|
|
||||||
"logging",
|
|
||||||
"psr-3"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/Seldaek/monolog/issues",
|
|
||||||
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/Seldaek",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "nelmio/cors-bundle",
|
"name": "nelmio/cors-bundle",
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
@@ -3467,80 +3142,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "sabberworm/php-css-parser",
|
|
||||||
"version": "v9.1.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
|
||||||
"reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb",
|
|
||||||
"reference": "1b363fdbdc6dd0ca0f4bf98d3a4d7f388133f1fb",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-iconv": "*",
|
|
||||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
|
||||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.3"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
|
||||||
"phpstan/extension-installer": "1.4.3",
|
|
||||||
"phpstan/phpstan": "1.12.28 || 2.1.25",
|
|
||||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.7",
|
|
||||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.6",
|
|
||||||
"phpunit/phpunit": "8.5.46",
|
|
||||||
"rawr/phpunit-data-provider": "3.3.1",
|
|
||||||
"rector/rector": "1.2.10 || 2.1.7",
|
|
||||||
"rector/type-perfect": "1.0.0 || 2.1.0"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "9.2.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Sabberworm\\CSS\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Raphael Schweikert"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Oliver Klee",
|
|
||||||
"email": "github@oliverklee.de"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jake Hotson",
|
|
||||||
"email": "jake.github@qzdesign.co.uk"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Parser for CSS Files written in PHP",
|
|
||||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
|
||||||
"keywords": [
|
|
||||||
"css",
|
|
||||||
"parser",
|
|
||||||
"stylesheet"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
|
||||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.1.0"
|
|
||||||
},
|
|
||||||
"time": "2025-09-14T07:37:21+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/asset",
|
"name": "symfony/asset",
|
||||||
"version": "v8.0.4",
|
"version": "v8.0.4",
|
||||||
@@ -5374,162 +4975,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-28T10:46:31+00:00"
|
"time": "2026-01-28T10:46:31+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/monolog-bridge",
|
|
||||||
"version": "v8.0.4",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/monolog-bridge.git",
|
|
||||||
"reference": "7c3da570ec252d5ca1212945ddbbf1dac4a0d779"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/7c3da570ec252d5ca1212945ddbbf1dac4a0d779",
|
|
||||||
"reference": "7c3da570ec252d5ca1212945ddbbf1dac4a0d779",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"monolog/monolog": "^3",
|
|
||||||
"php": ">=8.4",
|
|
||||||
"symfony/http-kernel": "^7.4|^8.0",
|
|
||||||
"symfony/service-contracts": "^2.5|^3"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"symfony/console": "^7.4|^8.0",
|
|
||||||
"symfony/http-client": "^7.4|^8.0",
|
|
||||||
"symfony/mailer": "^7.4|^8.0",
|
|
||||||
"symfony/messenger": "^7.4|^8.0",
|
|
||||||
"symfony/mime": "^7.4|^8.0",
|
|
||||||
"symfony/security-core": "^7.4|^8.0",
|
|
||||||
"symfony/var-dumper": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"type": "symfony-bridge",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Bridge\\Monolog\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Provides integration for Monolog with various Symfony components",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.4"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-01-07T12:23:22+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "symfony/monolog-bundle",
|
|
||||||
"version": "v4.0.1",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/monolog-bundle.git",
|
|
||||||
"reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/3b4ee2717ee56c5e1edb516140a175eb2a72bc66",
|
|
||||||
"reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"composer-runtime-api": "^2.0",
|
|
||||||
"monolog/monolog": "^3.5",
|
|
||||||
"php": ">=8.2",
|
|
||||||
"symfony/config": "^7.3 || ^8.0",
|
|
||||||
"symfony/dependency-injection": "^7.3 || ^8.0",
|
|
||||||
"symfony/http-kernel": "^7.3 || ^8.0",
|
|
||||||
"symfony/monolog-bridge": "^7.3 || ^8.0",
|
|
||||||
"symfony/polyfill-php84": "^1.30"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^11.5.41 || ^12.3",
|
|
||||||
"symfony/console": "^7.3 || ^8.0",
|
|
||||||
"symfony/yaml": "^7.3 || ^8.0"
|
|
||||||
},
|
|
||||||
"type": "symfony-bundle",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Bundle\\MonologBundle\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Symfony MonologBundle",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"log",
|
|
||||||
"logging"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/symfony/monolog-bundle/issues",
|
|
||||||
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.1"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-12-08T08:00:13+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/password-hasher",
|
"name": "symfony/password-hasher",
|
||||||
"version": "v8.0.4",
|
"version": "v8.0.4",
|
||||||
@@ -7880,145 +7325,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-04T18:17:06+00:00"
|
"time": "2025-12-04T18:17:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "thecodingmachine/safe",
|
|
||||||
"version": "v3.3.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/thecodingmachine/safe.git",
|
|
||||||
"reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236",
|
|
||||||
"reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^8.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
|
||||||
"phpstan/phpstan": "^2",
|
|
||||||
"phpunit/phpunit": "^10",
|
|
||||||
"squizlabs/php_codesniffer": "^3.2"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"lib/special_cases.php",
|
|
||||||
"generated/apache.php",
|
|
||||||
"generated/apcu.php",
|
|
||||||
"generated/array.php",
|
|
||||||
"generated/bzip2.php",
|
|
||||||
"generated/calendar.php",
|
|
||||||
"generated/classobj.php",
|
|
||||||
"generated/com.php",
|
|
||||||
"generated/cubrid.php",
|
|
||||||
"generated/curl.php",
|
|
||||||
"generated/datetime.php",
|
|
||||||
"generated/dir.php",
|
|
||||||
"generated/eio.php",
|
|
||||||
"generated/errorfunc.php",
|
|
||||||
"generated/exec.php",
|
|
||||||
"generated/fileinfo.php",
|
|
||||||
"generated/filesystem.php",
|
|
||||||
"generated/filter.php",
|
|
||||||
"generated/fpm.php",
|
|
||||||
"generated/ftp.php",
|
|
||||||
"generated/funchand.php",
|
|
||||||
"generated/gettext.php",
|
|
||||||
"generated/gmp.php",
|
|
||||||
"generated/gnupg.php",
|
|
||||||
"generated/hash.php",
|
|
||||||
"generated/ibase.php",
|
|
||||||
"generated/ibmDb2.php",
|
|
||||||
"generated/iconv.php",
|
|
||||||
"generated/image.php",
|
|
||||||
"generated/imap.php",
|
|
||||||
"generated/info.php",
|
|
||||||
"generated/inotify.php",
|
|
||||||
"generated/json.php",
|
|
||||||
"generated/ldap.php",
|
|
||||||
"generated/libxml.php",
|
|
||||||
"generated/lzf.php",
|
|
||||||
"generated/mailparse.php",
|
|
||||||
"generated/mbstring.php",
|
|
||||||
"generated/misc.php",
|
|
||||||
"generated/mysql.php",
|
|
||||||
"generated/mysqli.php",
|
|
||||||
"generated/network.php",
|
|
||||||
"generated/oci8.php",
|
|
||||||
"generated/opcache.php",
|
|
||||||
"generated/openssl.php",
|
|
||||||
"generated/outcontrol.php",
|
|
||||||
"generated/pcntl.php",
|
|
||||||
"generated/pcre.php",
|
|
||||||
"generated/pgsql.php",
|
|
||||||
"generated/posix.php",
|
|
||||||
"generated/ps.php",
|
|
||||||
"generated/pspell.php",
|
|
||||||
"generated/readline.php",
|
|
||||||
"generated/rnp.php",
|
|
||||||
"generated/rpminfo.php",
|
|
||||||
"generated/rrd.php",
|
|
||||||
"generated/sem.php",
|
|
||||||
"generated/session.php",
|
|
||||||
"generated/shmop.php",
|
|
||||||
"generated/sockets.php",
|
|
||||||
"generated/sodium.php",
|
|
||||||
"generated/solr.php",
|
|
||||||
"generated/spl.php",
|
|
||||||
"generated/sqlsrv.php",
|
|
||||||
"generated/ssdeep.php",
|
|
||||||
"generated/ssh2.php",
|
|
||||||
"generated/stream.php",
|
|
||||||
"generated/strings.php",
|
|
||||||
"generated/swoole.php",
|
|
||||||
"generated/uodbc.php",
|
|
||||||
"generated/uopz.php",
|
|
||||||
"generated/url.php",
|
|
||||||
"generated/var.php",
|
|
||||||
"generated/xdiff.php",
|
|
||||||
"generated/xml.php",
|
|
||||||
"generated/xmlrpc.php",
|
|
||||||
"generated/yaml.php",
|
|
||||||
"generated/yaz.php",
|
|
||||||
"generated/zip.php",
|
|
||||||
"generated/zlib.php"
|
|
||||||
],
|
|
||||||
"classmap": [
|
|
||||||
"lib/DateTime.php",
|
|
||||||
"lib/DateTimeImmutable.php",
|
|
||||||
"lib/Exceptions/",
|
|
||||||
"generated/Exceptions/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
|
||||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.3.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/OskarStark",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/shish",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/staabm",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-05-14T06:15:44+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "twig/twig",
|
"name": "twig/twig",
|
||||||
"version": "v3.23.0",
|
"version": "v3.23.0",
|
||||||
@@ -8504,175 +7810,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-05-06T16:37:16+00:00"
|
"time": "2024-05-06T16:37:16+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "doctrine/data-fixtures",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/doctrine/data-fixtures.git",
|
|
||||||
"reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97",
|
|
||||||
"reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"doctrine/persistence": "^3.1 || ^4.0",
|
|
||||||
"php": "^8.1",
|
|
||||||
"psr/log": "^1.1 || ^2 || ^3"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"doctrine/dbal": "<3.5 || >=5",
|
|
||||||
"doctrine/orm": "<2.14 || >=4",
|
|
||||||
"doctrine/phpcr-odm": "<1.3.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"doctrine/coding-standard": "^14",
|
|
||||||
"doctrine/dbal": "^3.5 || ^4",
|
|
||||||
"doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
|
|
||||||
"doctrine/orm": "^2.14 || ^3",
|
|
||||||
"ext-sqlite3": "*",
|
|
||||||
"fig/log-test": "^1",
|
|
||||||
"phpstan/phpstan": "2.1.31",
|
|
||||||
"phpunit/phpunit": "10.5.45 || 12.4.0",
|
|
||||||
"symfony/cache": "^6.4 || ^7",
|
|
||||||
"symfony/var-exporter": "^6.4 || ^7"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)",
|
|
||||||
"doctrine/mongodb-odm": "For loading MongoDB ODM fixtures",
|
|
||||||
"doctrine/orm": "For loading ORM fixtures",
|
|
||||||
"doctrine/phpcr-odm": "For loading PHPCR ODM fixtures"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Doctrine\\Common\\DataFixtures\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jonathan Wage",
|
|
||||||
"email": "jonwage@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Data Fixtures for all Doctrine Object Managers",
|
|
||||||
"homepage": "https://www.doctrine-project.org",
|
|
||||||
"keywords": [
|
|
||||||
"database"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/doctrine/data-fixtures/issues",
|
|
||||||
"source": "https://github.com/doctrine/data-fixtures/tree/2.2.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://www.patreon.com/phpdoctrine",
|
|
||||||
"type": "patreon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-10-17T20:06:20+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "doctrine/doctrine-fixtures-bundle",
|
|
||||||
"version": "4.3.1",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
|
|
||||||
"reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d",
|
|
||||||
"reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"doctrine/data-fixtures": "^2.2",
|
|
||||||
"doctrine/doctrine-bundle": "^2.2 || ^3.0",
|
|
||||||
"doctrine/orm": "^2.14.0 || ^3.0",
|
|
||||||
"doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
|
|
||||||
"php": "^8.1",
|
|
||||||
"psr/log": "^2 || ^3",
|
|
||||||
"symfony/config": "^6.4 || ^7.0 || ^8.0",
|
|
||||||
"symfony/console": "^6.4 || ^7.0 || ^8.0",
|
|
||||||
"symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
|
|
||||||
"symfony/deprecation-contracts": "^2.1 || ^3",
|
|
||||||
"symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0",
|
|
||||||
"symfony/http-kernel": "^6.4 || ^7.0 || ^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"doctrine/dbal": "< 3"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"doctrine/coding-standard": "14.0.0",
|
|
||||||
"phpstan/phpstan": "2.1.11",
|
|
||||||
"phpunit/phpunit": "^10.5.38 || 11.4.14"
|
|
||||||
},
|
|
||||||
"type": "symfony-bundle",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Doctrine\\Bundle\\FixturesBundle\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Doctrine Project",
|
|
||||||
"homepage": "https://www.doctrine-project.org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Symfony DoctrineFixturesBundle",
|
|
||||||
"homepage": "https://www.doctrine-project.org",
|
|
||||||
"keywords": [
|
|
||||||
"Fixture",
|
|
||||||
"persistence"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
|
|
||||||
"source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://www.patreon.com/phpdoctrine",
|
|
||||||
"type": "patreon"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-12-03T16:05:42+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "evenement/evenement",
|
"name": "evenement/evenement",
|
||||||
"version": "v3.0.2",
|
"version": "v3.0.2",
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
|
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
|
||||||
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||||
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
|
||||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
|
|
||||||
@@ -22,6 +20,4 @@ return [
|
|||||||
NelmioCorsBundle::class => ['all' => true],
|
NelmioCorsBundle::class => ['all' => true],
|
||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
LexikJWTAuthenticationBundle::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)%'
|
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
||||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||||
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
|
||||||
remove_token_from_body_when_cookies_used: true
|
remove_token_from_body_when_cookies_used: true
|
||||||
token_extractors:
|
token_extractors:
|
||||||
authorization_header:
|
authorization_header:
|
||||||
@@ -14,7 +13,7 @@ lexik_jwt_authentication:
|
|||||||
enabled: false
|
enabled: false
|
||||||
set_cookies:
|
set_cookies:
|
||||||
BEARER:
|
BEARER:
|
||||||
lifetime: '%env(int:JWT_COOKIE_TTL)%'
|
lifetime: 86400
|
||||||
samesite: lax
|
samesite: lax
|
||||||
path: /
|
path: /
|
||||||
secure: '%env(bool:JWT_COOKIE_SECURE)%'
|
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_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
allow_headers: ['Content-Type', 'Authorization']
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
allow_credentials: true
|
allow_credentials: true
|
||||||
expose_headers: ['Link', 'Content-Disposition']
|
expose_headers: ['Link']
|
||||||
max_age: 3600
|
max_age: 3600
|
||||||
paths:
|
paths:
|
||||||
'^/': null
|
'^/': null
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ security:
|
|||||||
access_control:
|
access_control:
|
||||||
- { path: ^/login_check, roles: PUBLIC_ACCESS }
|
- { path: ^/login_check, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/docs, 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 }
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
when@test:
|
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"
|
* 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{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1763,7 +1620,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* monolog?: MonologConfig,
|
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1776,7 +1632,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* monolog?: MonologConfig,
|
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1790,7 +1645,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* monolog?: MonologConfig,
|
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1804,7 +1658,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* monolog?: MonologConfig,
|
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
|
|
||||||
imports:
|
|
||||||
- { resource: version.yaml }
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
_defaults:
|
_defaults:
|
||||||
@@ -22,15 +19,5 @@ services:
|
|||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
|
||||||
App\Service\PublicHolidayService:
|
|
||||||
arguments:
|
|
||||||
$holidayUrl: '%env(HOLIDAY_URL)%'
|
|
||||||
|
|
||||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
|
||||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
|
||||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
|
||||||
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
|
|
||||||
App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'
|
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
parameters:
|
|
||||||
app.version: '0.1.26'
|
|
||||||
@@ -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,258 +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)
|
|
||||||
|
|
||||||
- Saisie par salarié et par date:
|
|
||||||
- matin / après-midi / soir
|
|
||||||
- pour `PRESENCE`: demi-journées matin/après-midi
|
|
||||||
- Sélecteur de temps:
|
|
||||||
- créneaux de 15 minutes uniquement (00:00, 00:15, ..., 23:45)
|
|
||||||
- saisie libre possible mais valeur vidée au blur si hors options
|
|
||||||
- Calculs affichés:
|
|
||||||
- `Jour`, `Nuit`, `Total`
|
|
||||||
- Heures de nuit:
|
|
||||||
- fenêtres `00:00-06:00` et `21:00-24:00`
|
|
||||||
- Date de modification (`updatedAt`):
|
|
||||||
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
|
|
||||||
- non mise à jour lors de modifications admin ou chef de site
|
|
||||||
- affichée sous le nom de l'employé (visible admin uniquement)
|
|
||||||
|
|
||||||
## 4) Absences
|
|
||||||
|
|
||||||
- Les absences sont stockées par jour (découpage lors de l'écriture)
|
|
||||||
- Une absence peut être:
|
|
||||||
- journée complète
|
|
||||||
- demi-journée `AM` ou `PM`
|
|
||||||
- Colonne absence (vue jour):
|
|
||||||
- affiche le libellé
|
|
||||||
- fond coloré selon le type d'absence
|
|
||||||
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
|
||||||
- demi-journée: dégradé diagonal
|
|
||||||
- journée complète: fond plein
|
|
||||||
|
|
||||||
### Effet absence sur les heures
|
|
||||||
|
|
||||||
- Absence `AM`:
|
|
||||||
- efface les heures du matin
|
|
||||||
- Absence `PM`:
|
|
||||||
- efface les heures d'après-midi et du soir
|
|
||||||
- Absence journée:
|
|
||||||
- efface toutes les plages horaires
|
|
||||||
|
|
||||||
### Absences "comptées comme travaillées"
|
|
||||||
|
|
||||||
- Si `countAsWorkedHours = true`:
|
|
||||||
- `TIME`: crédit de minutes selon contrat actif du jour
|
|
||||||
- `PRESENCE` (forfait): aucun crédit de présence (seules les checkboxes cochées comptent)
|
|
||||||
|
|
||||||
## 5) Validations des lignes d'heures
|
|
||||||
|
|
||||||
- Validation RH (`isValid`)
|
|
||||||
- action admin
|
|
||||||
- Validation site (`isSiteValid`)
|
|
||||||
- action chef de site
|
|
||||||
|
|
||||||
### Verrouillage
|
|
||||||
|
|
||||||
- Ligne validée RH:
|
|
||||||
- verrouillée pour modifications heures/absences
|
|
||||||
- Ligne validée site:
|
|
||||||
- verrouillée pour non-admin
|
|
||||||
- admin peut corriger
|
|
||||||
- Toute vraie modification d'une ligne:
|
|
||||||
- remet `isSiteValid = false`
|
|
||||||
- remet `isValid = false`
|
|
||||||
- Si aucun changement réel à l'enregistrement:
|
|
||||||
- les validations existantes ne sont pas altérées
|
|
||||||
|
|
||||||
## 6) Heures supplémentaires (vue semaine)
|
|
||||||
|
|
||||||
- Base de calcul:
|
|
||||||
- dépend du contrat actif par jour
|
|
||||||
- Tranche 25%:
|
|
||||||
- contrats <= 35h: de 35h à 43h
|
|
||||||
- contrats >= 39h: de 39h à 43h
|
|
||||||
- Tranche 50%:
|
|
||||||
- au-delà de 43h
|
|
||||||
- Nature `INTERIM`:
|
|
||||||
- pas de bonus 25%
|
|
||||||
- pas de bonus 50%
|
|
||||||
- pas de total récup
|
|
||||||
|
|
||||||
## 7) Fériés
|
|
||||||
|
|
||||||
- Les jours fériés sont identifiés et affichés
|
|
||||||
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
|
|
||||||
- Règle courante:
|
|
||||||
- absences bloquées sur jour férié
|
|
||||||
- saisie d'heures autorisée
|
|
||||||
|
|
||||||
## 8) Impression absences (PDF)
|
|
||||||
|
|
||||||
Filtres disponibles:
|
|
||||||
- période `from` / `to`
|
|
||||||
- sites
|
|
||||||
- nature de contrat (`CDI`, `CDD`, `INTERIM`)
|
|
||||||
- temps de travail (contrats de type Forfait, 35h, 39h, etc.)
|
|
||||||
|
|
||||||
Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|
||||||
|
|
||||||
## 9) Employés
|
|
||||||
|
|
||||||
- Création employé:
|
|
||||||
- prénom, nom, site
|
|
||||||
- type de contrat (nature RH)
|
|
||||||
- temps de travail
|
|
||||||
- dates début/fin (selon règles nature)
|
|
||||||
- Modification employé:
|
|
||||||
- uniquement prénom, nom, site
|
|
||||||
- pas de modification de contrat depuis ce drawer
|
|
||||||
- Détail employé:
|
|
||||||
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
|
|
||||||
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
|
|
||||||
- action `Clôturer`:
|
|
||||||
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
|
|
||||||
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
|
|
||||||
- champs saisissables:
|
|
||||||
- `contractEndDate` (prérempli à aujourd'hui)
|
|
||||||
- `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
|
|
||||||
- backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
|
|
||||||
- action `Ajouter`:
|
|
||||||
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
|
|
||||||
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
|
|
||||||
- onglet `Congé`:
|
|
||||||
- endpoint de synthèse: `GET /api/employees/{id}/leave-summary?year=YYYY`
|
|
||||||
- phase 1 métier (`CDI`/`CDD` non forfait + `FORFAIT`):
|
|
||||||
- exercice CP:
|
|
||||||
- `CDI`/`CDD` non forfait: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
|
|
||||||
- `FORFAIT`: du `1er janvier (YYYY)` au `31 décembre (YYYY)` (paramètre `year` = année civile)
|
|
||||||
- contrats `39h` / `35h` / `25h` (et plus largement CDI/CDD non forfait hors `4h`):
|
|
||||||
- acquis annuel CP: `25`
|
|
||||||
- acquis annuel samedi: `5`
|
|
||||||
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
|
|
||||||
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
|
|
||||||
- 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
|
|
||||||
- contrat `FORFAIT`:
|
|
||||||
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
|
||||||
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
|
||||||
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
|
|
||||||
- pas de samedi (`0`)
|
|
||||||
- pas de jours en cours d'acquisition (`0`)
|
|
||||||
- fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
|
|
||||||
- pour `CDI`/`CDD` non forfait:
|
|
||||||
- pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées
|
|
||||||
- samedi pris: absences `C` posées le samedi (demi-journée incluse)
|
|
||||||
- restants = acquis - pris (borné à 0)
|
|
||||||
- pour `FORFAIT`:
|
|
||||||
- pris: basé sur toutes les absences (demi-journées incluses)
|
|
||||||
- restants = acquis - pris (borné à 0)
|
|
||||||
- report annuel:
|
|
||||||
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
|
|
||||||
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
|
|
||||||
- pour `FORFAIT`: report uniquement sur les jours
|
|
||||||
- si un solde d'ouverture existe en base (`employee_leave_balances`) pour l'exercice courant, ce solde devient la source prioritaire du report
|
|
||||||
- si une clôture de contrat est marquée `contractPaidLeaveSettled=true` sur l'exercice précédent, le report vers l'exercice suivant est remis à `0`
|
|
||||||
- si une clôture `contractPaidLeaveSettled=true` existe dans l'exercice courant, le calcul est réinitialisé à partir du lendemain de cette clôture (pas de continuité intra-exercice)
|
|
||||||
- lecture des compteurs:
|
|
||||||
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
|
|
||||||
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
|
|
||||||
- `en cours d'acquisition` est arrêté au dernier jour du mois précédent
|
|
||||||
- règle de consommation:
|
|
||||||
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
|
|
||||||
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
|
|
||||||
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
|
|
||||||
- date d'arret de calcul:
|
|
||||||
- `reste à prendre` est calculé en prévisionnel jusqu'à la fin de l'exercice
|
|
||||||
- les absences futures déjà posées sur l'exercice sont déduites du `reste à prendre`
|
|
||||||
- `en cours d'acquisition` reste calculé jusqu'au dernier jour du mois précédent
|
|
||||||
- exemple: au `11/03/2026`, l'exercice `2026` déduit les absences posées jusqu'au `31/05/2026`, mais l'acquisition reste arrêtée au `28/02/2026`
|
|
||||||
- hors périmètre phase 1: `INTERIM` (retour non supporté)
|
|
||||||
- onglet `RTT`:
|
|
||||||
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
|
|
||||||
- exercice RTT: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
|
|
||||||
- affichage:
|
|
||||||
- détail hebdomadaire (semaine ISO) regroupé par mois
|
|
||||||
- total mensuel des minutes de récupération
|
|
||||||
- compteur global exercice = `report N-1 + acquis N`
|
|
||||||
- attribution mensuelle des semaines:
|
|
||||||
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine
|
|
||||||
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
|
|
||||||
- logique de calcul:
|
|
||||||
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
|
|
||||||
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
|
|
||||||
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
|
|
||||||
- compteur global:
|
|
||||||
- affiché en **jours** (1 jour = 7h = 420 minutes)
|
|
||||||
- report:
|
|
||||||
- le report N-1 correspond à la somme des minutes de récupération calculées sur l'exercice précédent
|
|
||||||
- si une ligne existe dans `employee_rtt_balances` pour `(employee, year)`, le champ `opening_minutes` est utilisé en priorité
|
|
||||||
- sinon, le calcul dynamique sur l'exercice N-1 est effectué
|
|
||||||
- rollover automatique:
|
|
||||||
- commande: `php bin/console app:rtt:rollover`
|
|
||||||
- s'exécute le `1er juin` (même cron que le rollover congés)
|
|
||||||
- calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice
|
|
||||||
- idempotent (ne recrée pas si la ligne existe)
|
|
||||||
- paiement RTT:
|
|
||||||
- saisie RH via `PATCH /employees/{id}/rtt-payments` (body: `month`, `minutes`, `rate`)
|
|
||||||
- stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
|
|
||||||
- `rate`: taux de majoration, valeurs `25` ou `50`
|
|
||||||
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
|
|
||||||
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
|
|
||||||
- affichage:
|
|
||||||
- le compteur global RTT est affiché en **heures** (format `Xh00`)
|
|
||||||
|
|
||||||
## 10) Notifications
|
|
||||||
|
|
||||||
- Icône cloche en topbar:
|
|
||||||
- badge = nombre de notifications non lues
|
|
||||||
- ouverture panneau = liste des non lues
|
|
||||||
- fermeture panneau = marquage "lu" en masse
|
|
||||||
|
|
||||||
### Règle métier de déclenchement
|
|
||||||
|
|
||||||
- Les notifications de validation site ne sont pas envoyées ligne par ligne.
|
|
||||||
- Une notification est créée uniquement quand un chef de site termine la validation complète:
|
|
||||||
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
|
||||||
- destinataires: utilisateurs `ROLE_ADMIN`
|
|
||||||
@@ -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,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 est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
|
|
||||||
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
|
|
||||||
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
|
|
||||||
|
|
||||||
## 5) Table cible
|
|
||||||
|
|
||||||
Table `employee_rtt_balances` (une ligne par employe et exercice):
|
|
||||||
- `employee_id`
|
|
||||||
- `year`
|
|
||||||
- `opening_minutes`
|
|
||||||
- `is_locked`
|
|
||||||
- `created_at`, `updated_at`
|
|
||||||
|
|
||||||
Contrainte unique:
|
|
||||||
- `(employee_id, year)`
|
|
||||||
|
|
||||||
Etat implementation:
|
|
||||||
- la table est creee
|
|
||||||
- le calcul de synthese RTT lit en priorite `opening_minutes` de cette table quand une ligne existe pour `(employee, year)`
|
|
||||||
- si aucune ligne n'existe, le calcul dynamique sur l'exercice N-1 est effectue
|
|
||||||
|
|
||||||
### Definition des colonnes
|
|
||||||
|
|
||||||
- `employee_id`:
|
|
||||||
- identifiant employe (FK vers `employees`)
|
|
||||||
- une ligne de solde par employe / exercice
|
|
||||||
- `year`:
|
|
||||||
- annee d'exercice (annee de fin)
|
|
||||||
- `2026` = 01/06/2025 -> 31/05/2026
|
|
||||||
- `opening_minutes`:
|
|
||||||
- report N-1 en minutes (solde d'ouverture)
|
|
||||||
- correspond a la somme des minutes de recuperation de l'exercice precedent
|
|
||||||
- `is_locked`:
|
|
||||||
- `false` sur exercice ouvert (recalcul possible)
|
|
||||||
- `true` apres validation RH (exercice fige)
|
|
||||||
- `created_at`, `updated_at`:
|
|
||||||
- trace technique creation / mise a jour
|
|
||||||
|
|
||||||
## 6) Rollover automatique
|
|
||||||
|
|
||||||
Commande quotidienne (cron) idempotente.
|
|
||||||
|
|
||||||
- commande Symfony: `php bin/console app:rtt:rollover`
|
|
||||||
- comportement date metier:
|
|
||||||
- le `01/06`: calcule et persiste le report pour chaque employe eligible
|
|
||||||
- les autres jours: sortie sans action
|
|
||||||
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
|
|
||||||
|
|
||||||
Date d'effet:
|
|
||||||
- au `1er juin` (meme date que le rollover conges non forfait)
|
|
||||||
|
|
||||||
Traitement par employe:
|
|
||||||
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
|
|
||||||
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
|
|
||||||
3. calculer la somme des minutes de recuperation de l'exercice N-1
|
|
||||||
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
|
|
||||||
|
|
||||||
## 7) Donnees a fournir au go-live
|
|
||||||
|
|
||||||
La RH doit fournir les soldes RTT a reporter.
|
|
||||||
|
|
||||||
Colonnes minimales:
|
|
||||||
- `employee_id` (id interne)
|
|
||||||
- `year`
|
|
||||||
- `opening_minutes` (total en minutes)
|
|
||||||
|
|
||||||
Format recommande:
|
|
||||||
- CSV UTF-8
|
|
||||||
- separateur `;`
|
|
||||||
|
|
||||||
Exemple:
|
|
||||||
```csv
|
|
||||||
employee_id;year;opening_minutes
|
|
||||||
42;2026;1260
|
|
||||||
17;2026;840
|
|
||||||
```
|
|
||||||
|
|
||||||
Equivalent en insertion SQL directe:
|
|
||||||
```sql
|
|
||||||
INSERT INTO employee_rtt_balances (employee_id, year, opening_minutes, is_locked, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(42, 2026, 1260, false, NOW(), NOW()),
|
|
||||||
(17, 2026, 840, false, NOW(), NOW());
|
|
||||||
```
|
|
||||||
|
|
||||||
Conversion rapide: `1260 minutes = 21h00 = 3.00 jours` (1 jour = 420 min = 7h)
|
|
||||||
|
|
||||||
## 8) Checklist mise en prod
|
|
||||||
|
|
||||||
1. Executer la migration (`employee_rtt_balances`)
|
|
||||||
2. Importer les soldes d'ouverture N-1 (CSV ou SQL)
|
|
||||||
3. Verifier 3 cas metier:
|
|
||||||
- CDI 39h avec heures supp sur l'exercice precedent
|
|
||||||
- CDI 35h sans heures supp (report = 0)
|
|
||||||
- INTERIM (doit etre ignore, pas de ligne creee)
|
|
||||||
4. Activer le cron de rollover
|
|
||||||
5. Geler (`is_locked`) les exercices historicises valides
|
|
||||||
|
|
||||||
Exemple cron (tous les jours a 02:15, juste apres le rollover conges):
|
|
||||||
Dev
|
|
||||||
```cron
|
|
||||||
15 2 * * * cd /var/www/html && php bin/console app:rtt:rollover --no-interaction 2>&1
|
|
||||||
```
|
|
||||||
Prod
|
|
||||||
```cron
|
|
||||||
10 2 * * * cd /var/www/sirh && php bin/console app:rtt:rollover --no-interaction 2>&1
|
|
||||||
```
|
|
||||||
Explication de la ligne cron:
|
|
||||||
- `15 2 * * *`: tous les jours a 02:15
|
|
||||||
- `php bin/console app:rtt:rollover --no-interaction`: execute le rollover sans confirmation
|
|
||||||
- hors `01/06`, la commande sort en no-op (normal)
|
|
||||||
- `>> var/log/rtt-rollover.log 2>&1`: log sortie standard et erreurs
|
|
||||||
|
|
||||||
Execution manuelle forcee:
|
|
||||||
```bash
|
|
||||||
php bin/console app:rtt:rollover --force --no-interaction
|
|
||||||
```
|
|
||||||
|
|
||||||
Exemple de verification rapide:
|
|
||||||
```bash
|
|
||||||
tail -n 50 /var/www/html/var/log/rtt-rollover.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9) Points de vigilance
|
|
||||||
|
|
||||||
- Ne jamais modifier `opening_minutes` apres validation RH sans procedure explicite
|
|
||||||
- Garder une trace de toute correction manuelle (auteur, date, motif)
|
|
||||||
- Le calcul dynamique N-1 (fallback) parcourt toutes les heures de l'exercice precedent: preferer l'import explicite pour les exercices historiques
|
|
||||||
- La commande de rollover est idempotente: si une ligne existe deja, l'employe est ignore (pas d'ecrasement)
|
|
||||||
@@ -3,11 +3,3 @@
|
|||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { load } = useAppVersion()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
load()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
|
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="employee">
|
|
||||||
Employé <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="employee"
|
|
||||||
v-model="absenceForm.employeeId"
|
|
||||||
:class="employeeFieldClass"
|
|
||||||
:disabled="props.lockEmployee"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Choisir un employé</option>
|
|
||||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
|
||||||
{{ employee.firstName }} {{ employee.lastName }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
|
|
||||||
L'employé est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="type">
|
|
||||||
Type d'absence <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="type"
|
|
||||||
v-model="absenceForm.typeId"
|
|
||||||
:class="typeFieldClass"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Choisir un type</option>
|
|
||||||
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
|
|
||||||
{{ type.label }} ({{ type.code }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le type d'absence est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
|
||||||
:disabled="props.lockDates"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
v-model="absenceForm.startHalf"
|
|
||||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
|
||||||
>
|
|
||||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
|
||||||
{{ half.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</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="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
|
||||||
:disabled="props.lockDates"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
v-model="absenceForm.endHalf"
|
|
||||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
|
||||||
>
|
|
||||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
|
||||||
{{ half.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</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 class="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
v-if="editingAbsence"
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
|
|
||||||
@click="handleDelete"
|
|
||||||
>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="handleCancel"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:class="submitButtonClass"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</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 baseSelectClass =
|
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
const employeeFieldClass = computed(() => {
|
|
||||||
if (showEmployeeError.value) {
|
|
||||||
return `${baseSelectClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseSelectClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const typeFieldClass = computed(() => {
|
|
||||||
if (showTypeError.value) {
|
|
||||||
return `${baseSelectClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseSelectClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
|
|
||||||
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,249 +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-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="handleCancel"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:class="submitButtonClass"
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<template>
|
|
||||||
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
|
||||||
<div class="flex h-full items-center justify-end">
|
|
||||||
<div class="flex gap-6 text-xl text-white">
|
|
||||||
<div v-if="isAdmin" ref="bellRoot" class="relative">
|
|
||||||
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
|
|
||||||
<Icon name="mdi:bell-plus" size="36"/>
|
|
||||||
<span
|
|
||||||
v-if="unreadCount > 0"
|
|
||||||
class="absolute -right-1 -top-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
|
||||||
>
|
|
||||||
{{ unreadCount }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isNotificationsOpen"
|
|
||||||
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
|
|
||||||
:style="{ top: `${navbarBottom + 20}px` }"
|
|
||||||
>
|
|
||||||
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
|
|
||||||
Notifications
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-6 px-3 pb-2 border-b border-black">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border-b-2 cursor-pointer text-[18px]"
|
|
||||||
:class="activeNotifTab === 'unread' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
|
|
||||||
@click="switchNotifTab('unread')"
|
|
||||||
>
|
|
||||||
Non lues
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border-b-2 cursor-pointer text-[18px]"
|
|
||||||
:class="activeNotifTab === 'history' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
|
|
||||||
@click="switchNotifTab('history')"
|
|
||||||
>
|
|
||||||
Historique
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="isLoadingNotifications" class="px-3 py-3 text-sm text-neutral-500">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
<div v-else-if="displayedNotifications.length === 0" class="px-3 py-3 text-sm text-neutral-500">
|
|
||||||
Aucune notification.
|
|
||||||
</div>
|
|
||||||
<div v-else class="max-h-80 overflow-auto">
|
|
||||||
<NuxtLink
|
|
||||||
:to="notification.target"
|
|
||||||
v-for="notification in displayedNotifications"
|
|
||||||
:key="notification.id"
|
|
||||||
class="flex gap-5 items-center border-b border-black px-3 py-4 last:border-b-0 relative hover:bg-tertiary-500"
|
|
||||||
:class="notification.isRead ? '' : 'bg-tertiary-500'"
|
|
||||||
>
|
|
||||||
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
|
||||||
<div class="flex flex-col min-w-0 text-[16px]">
|
|
||||||
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
|
||||||
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
|
||||||
</div>
|
|
||||||
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ref="userMenuRoot" class="relative flex gap-4">
|
|
||||||
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
|
|
||||||
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
|
|
||||||
<p class="self-center">{{ user?.username }}</p>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="isUserMenuOpen"
|
|
||||||
class="fixed right-[20px] z-30 w-60 rounded-md border border-neutral-200 bg-white text-[16px] text-black font-semibold shadow-lg"
|
|
||||||
:style="{ top: `${navbarBottom + 20}px` }"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 border-b border-black"
|
|
||||||
>
|
|
||||||
Mon profil
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 flex justify-between items-center"
|
|
||||||
@click="handleLogout"
|
|
||||||
>
|
|
||||||
<p>Déconnexion</p>
|
|
||||||
<Icon name="mdi:logout-variant" size="20"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type {User} from '~/services/dto/user'
|
|
||||||
import type {NotificationItem} from '~/services/dto/notification'
|
|
||||||
import {listUnreadNotifications, listTodayNotifications, listHistoryNotifications, markAllNotificationsRead} from '~/services/notifications'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
user?: User
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const formatTimeAgo = (dateString: string): string => {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now.getTime() - date.getTime()
|
|
||||||
const diffMinutes = Math.floor(diffMs / 60000)
|
|
||||||
if (diffMinutes < 1) return "À l'instant"
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`
|
|
||||||
const diffHours = Math.floor(diffMinutes / 60)
|
|
||||||
if (diffHours < 24) return `${diffHours} heure${diffHours > 1 ? 's' : ''}`
|
|
||||||
const diffDays = Math.floor(diffHours / 24)
|
|
||||||
return `${diffDays} jour${diffDays > 1 ? 's' : ''}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const headerRef = ref<HTMLElement | null>(null)
|
|
||||||
const bellRoot = ref<HTMLElement | null>(null)
|
|
||||||
const userMenuRoot = ref<HTMLElement | null>(null)
|
|
||||||
const isUserMenuOpen = ref(false)
|
|
||||||
const navbarBottom = ref(0)
|
|
||||||
|
|
||||||
const updateNavbarBottom = () => {
|
|
||||||
if (headerRef.value) {
|
|
||||||
navbarBottom.value = headerRef.value.getBoundingClientRect().bottom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const todayNotifications = ref<NotificationItem[]>([])
|
|
||||||
const historyNotifications = ref<NotificationItem[]>([])
|
|
||||||
const isNotificationsOpen = ref(false)
|
|
||||||
const isLoadingNotifications = ref(false)
|
|
||||||
const activeNotifTab = ref<'unread' | 'history'>('unread')
|
|
||||||
const unreadCount = computed(() => todayNotifications.value.length)
|
|
||||||
const displayedNotifications = computed(() => activeNotifTab.value === 'unread' ? todayNotifications.value : historyNotifications.value)
|
|
||||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
|
||||||
|
|
||||||
const toggleUserMenu = () => {
|
|
||||||
updateNavbarBottom()
|
|
||||||
isUserMenuOpen.value = !isUserMenuOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await auth.logout()
|
|
||||||
await navigateTo('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadTodayNotifications = async () => {
|
|
||||||
todayNotifications.value = await listTodayNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadHistoryNotifications = async () => {
|
|
||||||
historyNotifications.value = await listHistoryNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadNotifications = async () => {
|
|
||||||
isLoadingNotifications.value = true
|
|
||||||
try {
|
|
||||||
await loadTodayNotifications()
|
|
||||||
} finally {
|
|
||||||
isLoadingNotifications.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchNotifTab = async (tab: 'unread' | 'history') => {
|
|
||||||
activeNotifTab.value = tab
|
|
||||||
isLoadingNotifications.value = true
|
|
||||||
try {
|
|
||||||
if (tab === 'history') {
|
|
||||||
await loadHistoryNotifications()
|
|
||||||
} else {
|
|
||||||
await loadTodayNotifications()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoadingNotifications.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeNotifications = async () => {
|
|
||||||
if (!isNotificationsOpen.value) return
|
|
||||||
isNotificationsOpen.value = false
|
|
||||||
if (todayNotifications.value.length > 0) {
|
|
||||||
await markAllNotificationsRead()
|
|
||||||
todayNotifications.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleNotifications = async () => {
|
|
||||||
if (isNotificationsOpen.value) {
|
|
||||||
await closeNotifications()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateNavbarBottom()
|
|
||||||
activeNotifTab.value = 'unread'
|
|
||||||
isNotificationsOpen.value = true
|
|
||||||
await loadNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClickOutside = async (event: MouseEvent) => {
|
|
||||||
const target = event.target as Node | null
|
|
||||||
if (!target) return
|
|
||||||
if (bellRoot.value && !bellRoot.value.contains(target)) {
|
|
||||||
await closeNotifications()
|
|
||||||
}
|
|
||||||
if (userMenuRoot.value && !userMenuRoot.value.contains(target)) {
|
|
||||||
isUserMenuOpen.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
updateNavbarBottom()
|
|
||||||
if (isAdmin.value) {
|
|
||||||
await loadNotifications()
|
|
||||||
}
|
|
||||||
document.addEventListener('click', handleClickOutside)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.fullPath,
|
|
||||||
async () => {
|
|
||||||
if (!isAdmin.value) return
|
|
||||||
await loadNotifications()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', handleClickOutside)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,192 +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="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
|
||||||
:style="getCellStyle(employee.id, day.date)"
|
|
||||||
:disabled="isHolidayDate(day.date)"
|
|
||||||
@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>
|
|
||||||
</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"
|
|
||||||
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
|
||||||
:style="getCellStyle(employee.id, day.date)"
|
|
||||||
:disabled="isHolidayDate(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 } | 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,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative w-full max-w-[340px]">
|
|
||||||
<input
|
|
||||||
id="employee-search"
|
|
||||||
v-model="model"
|
|
||||||
type="text"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
name="mdi:magnify"
|
|
||||||
size="18"
|
|
||||||
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const model = defineModel<string>({required: true})
|
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
|
||||||
placeholder?: string
|
|
||||||
}>(), {
|
|
||||||
placeholder: "Recherche d'un employé"
|
|
||||||
})
|
|
||||||
</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,69 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="root" class="relative inline-block w-fit max-w-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex w-[320px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
|
|
||||||
@click="isOpen = !isOpen"
|
|
||||||
>
|
|
||||||
<span>Sites</span>
|
|
||||||
<span class="inline-flex items-center gap-2">
|
|
||||||
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
|
|
||||||
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isOpen"
|
|
||||||
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label
|
|
||||||
v-for="site in sites"
|
|
||||||
:key="site.id"
|
|
||||||
:for="`site-${site.id}`"
|
|
||||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:id="`site-${site.id}`"
|
|
||||||
v-model="selectedSiteIds"
|
|
||||||
:value="site.id"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<span class="text-md text-neutral-800">{{ site.name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import type { Site } from '~/services/dto/site'
|
|
||||||
|
|
||||||
const selectedSiteIds = defineModel<number[]>({ required: true })
|
|
||||||
const isOpen = ref(false)
|
|
||||||
const root = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
sites: Site[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const selectedCount = computed(() => selectedSiteIds.value.length)
|
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
const target = event.target as Node | null
|
|
||||||
if (!root.value || !target) return
|
|
||||||
if (!root.value.contains(target)) {
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', handleClickOutside)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener('click', handleClickOutside)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="mt-8">
|
|
||||||
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
|
||||||
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
|
|
||||||
<p>Contrat</p>
|
|
||||||
<p>Heures</p>
|
|
||||||
<p>Date de début</p>
|
|
||||||
<p>Date de fin</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
|
|
||||||
Aucun historique de contrat.
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div
|
|
||||||
v-for="item in contractHistory"
|
|
||||||
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
|
||||||
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
|
|
||||||
>
|
|
||||||
<p>{{ contractNatureLabel(item.contractNature) }}</p>
|
|
||||||
<p>{{ contractHistoryLabel(item) }}</p>
|
|
||||||
<p>{{ formatDate(item.startDate) }}</p>
|
|
||||||
<p>{{ formatDate(item.endDate) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flex justify-center gap-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-[200px] rounded-md bg-blue-500 py-2 text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isContractSubmitting || !canCloseCurrentContract"
|
|
||||||
@click="onOpenCloseContractDrawer"
|
|
||||||
>
|
|
||||||
Clôturer
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isCreateContractSubmitting || contracts.length === 0 || !canCreateContract"
|
|
||||||
@click="onOpenCreateContractDrawer"
|
|
||||||
>
|
|
||||||
+ Ajouter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
|
||||||
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
|
||||||
Type de contrat
|
|
||||||
</label>
|
|
||||||
<input id="contract-nature" :value="contractNatureLabel(contractForm.contractNature)" type="text" :class="readonlyFieldClass" readonly />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract">
|
|
||||||
Temps de travail
|
|
||||||
</label>
|
|
||||||
<input id="contract" :value="closeContractWorkedHoursLabel" type="text" :class="readonlyFieldClass" readonly />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
|
||||||
Début contrat
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contract-start-date"
|
|
||||||
:value="contractForm.startDate"
|
|
||||||
type="date"
|
|
||||||
:class="readonlyFieldClass"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
|
||||||
Fin contrat <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contract-end-date"
|
|
||||||
v-model="contractForm.endDate"
|
|
||||||
type="date"
|
|
||||||
:class="contractEndDateFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
|
|
||||||
Commentaire
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="contract-comment"
|
|
||||||
v-model="contractForm.comment"
|
|
||||||
rows="3"
|
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
placeholder="Motif de la clôture..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
|
||||||
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="contract-paid-leave-settled">
|
|
||||||
<input
|
|
||||||
id="contract-paid-leave-settled"
|
|
||||||
v-model="contractForm.paidLeaveSettled"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
Soldé dans le solde de tout compte
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
:disabled="isContractSubmitting"
|
|
||||||
@click="onUpdateContractDrawerOpen(false)"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isContractSubmitting || !isContractEndDateValid"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
|
|
||||||
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
|
|
||||||
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
|
|
||||||
Type de contrat <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select id="create-contract-nature" v-model="createContractForm.contractNature" :class="createContractNatureFieldClass">
|
|
||||||
<option value="CDI">CDI</option>
|
|
||||||
<option value="CDD">CDD</option>
|
|
||||||
<option value="INTERIM">Intérim</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
|
|
||||||
Temps de travail <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select id="create-contract-id" v-model="createContractForm.contractId" :class="createContractFieldClass">
|
|
||||||
<option value="">Sélectionner un contrat</option>
|
|
||||||
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
|
||||||
{{ contract.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-start-date">
|
|
||||||
Début contrat <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showsCreateContractEndDate">
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-end-date">
|
|
||||||
Fin contrat <span v-if="requiresCreateContractEndDate" class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
:disabled="isCreateContractSubmitting"
|
|
||||||
@click="onUpdateCreateContractDrawerOpen(false)"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Contract } from '~/services/dto/contract'
|
|
||||||
import type { ContractHistoryItem } from '~/services/dto/employee'
|
|
||||||
|
|
||||||
type ContractForm = {
|
|
||||||
contractId: number | ''
|
|
||||||
contractName: string
|
|
||||||
weeklyHours: number | null
|
|
||||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
|
||||||
startDate: string
|
|
||||||
endDate: string
|
|
||||||
paidLeaveSettled: boolean
|
|
||||||
comment: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateContractForm = {
|
|
||||||
contractId: number | ''
|
|
||||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
|
||||||
startDate: string
|
|
||||||
endDate: string
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
contractHistory: ContractHistoryItem[]
|
|
||||||
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
|
|
||||||
contractHistoryLabel: (item: ContractHistoryItem) => string
|
|
||||||
formatDate: (value?: string | null) => string
|
|
||||||
isContractSubmitting: boolean
|
|
||||||
canCloseCurrentContract: boolean
|
|
||||||
isCreateContractSubmitting: boolean
|
|
||||||
contracts: Contract[]
|
|
||||||
canCreateContract: boolean
|
|
||||||
isContractDrawerOpen: boolean
|
|
||||||
contractForm: ContractForm
|
|
||||||
readonlyFieldClass: string
|
|
||||||
closeContractWorkedHoursLabel: string
|
|
||||||
contractEndDateFieldClass: string
|
|
||||||
showContractEndDateError: boolean
|
|
||||||
isContractEndDateValid: boolean
|
|
||||||
isCreateContractDrawerOpen: boolean
|
|
||||||
createContractForm: CreateContractForm
|
|
||||||
createContractNatureFieldClass: string
|
|
||||||
createContractFieldClass: string
|
|
||||||
createContractStartDateFieldClass: string
|
|
||||||
showsCreateContractEndDate: boolean
|
|
||||||
requiresCreateContractEndDate: boolean
|
|
||||||
createContractEndDateFieldClass: string
|
|
||||||
isCreateContractFormValid: boolean
|
|
||||||
onOpenCloseContractDrawer: () => void
|
|
||||||
onOpenCreateContractDrawer: () => void
|
|
||||||
onUpdateContractDrawerOpen: (open: boolean) => void
|
|
||||||
onUpdateCreateContractDrawerOpen: (open: boolean) => void
|
|
||||||
onSubmitCloseContract: () => void
|
|
||||||
onSubmitCreateContract: () => void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -1,309 +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-primary-500 text-white text-[20]">
|
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
|
||||||
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
|
||||||
formatCount(summary?.acquiredDays)
|
|
||||||
}} Jours</p>
|
|
||||||
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
|
||||||
{{ formatCount(summary?.remainingDays) }} Jours</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
|
||||||
<p><span class="uppercase font-semibold">Samedi acquis :</span>
|
|
||||||
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
|
|
||||||
<p><span class="uppercase font-semibold">Reste à prendre :</span>
|
|
||||||
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
|
||||||
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
|
|
||||||
<button
|
|
||||||
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
|
|
||||||
@click="openFractionedDrawer"
|
|
||||||
>
|
|
||||||
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col jutify-center gap-2 items-center py-3">
|
|
||||||
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
|
|
||||||
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
|
||||||
<div class="grid grid-cols-4 gap-10">
|
|
||||||
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
|
|
||||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
|
|
||||||
{{ month.label }}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
|
|
||||||
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
|
|
||||||
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
|
|
||||||
<div v-if="!day" class="h-6"/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-6 w-6"
|
|
||||||
:class="getDayClass(day)"
|
|
||||||
:style="getDayStyle(day)"
|
|
||||||
:title="getDayTitle(day)"
|
|
||||||
>
|
|
||||||
{{ getDayText(day) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="fractioned-days">
|
|
||||||
Nombre de jours <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="fractioned-days"
|
|
||||||
v-model="fractionedForm.days"
|
|
||||||
type="number"
|
|
||||||
step="0.5"
|
|
||||||
min="0"
|
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="isFractionedDrawerOpen = false"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type {Absence} from '~/services/dto/absence'
|
|
||||||
import type {EmployeeLeaveSummary} from '~/services/dto/employee-leave-summary'
|
|
||||||
import {normalizeDate, toYmd} from '~/utils/date'
|
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
type DayLeaveState = {
|
|
||||||
am: boolean
|
|
||||||
pm: boolean
|
|
||||||
labels: string[]
|
|
||||||
colors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
absences: Absence[]
|
|
||||||
summary: EmployeeLeaveSummary | null
|
|
||||||
publicHolidays: Record<string, string>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'update-fractioned-days', days: number): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isFractionedDrawerOpen = ref(false)
|
|
||||||
const fractionedForm = reactive({ days: 0 })
|
|
||||||
|
|
||||||
const openFractionedDrawer = () => {
|
|
||||||
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
|
||||||
isFractionedDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmitFractioned = () => {
|
|
||||||
const value = Number(fractionedForm.days)
|
|
||||||
if (Number.isNaN(value) || value < 0) return
|
|
||||||
emit('update-fractioned-days', value)
|
|
||||||
isFractionedDrawerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthLabels = [
|
|
||||||
'Janvier',
|
|
||||||
'Fevrier',
|
|
||||||
'Mars',
|
|
||||||
'Avril',
|
|
||||||
'Mai',
|
|
||||||
'Juin',
|
|
||||||
'Juillet',
|
|
||||||
'Aout',
|
|
||||||
'Septembre',
|
|
||||||
'Octobre',
|
|
||||||
'Novembre',
|
|
||||||
'Decembre'
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
|
|
||||||
|
|
||||||
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
|
||||||
|
|
||||||
const displayedYear = computed(() => {
|
|
||||||
if (props.summary?.year) return props.summary.year
|
|
||||||
const today = new Date()
|
|
||||||
const year = today.getFullYear()
|
|
||||||
const month = today.getMonth() + 1
|
|
||||||
return month >= 6 ? year + 1 : year
|
|
||||||
})
|
|
||||||
|
|
||||||
const orderedMonthIndexes = computed(() => {
|
|
||||||
if (isForfaitRule.value) return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
|
|
||||||
return [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4]
|
|
||||||
})
|
|
||||||
|
|
||||||
const buildDateFromYmd = (value: string) => new Date(`${value}T00:00:00`)
|
|
||||||
|
|
||||||
const dayLeaveMap = computed(() => {
|
|
||||||
const map = new Map<string, DayLeaveState>()
|
|
||||||
|
|
||||||
for (const absence of props.absences) {
|
|
||||||
const startYmd = normalizeDate(absence.startDate)
|
|
||||||
const endYmd = normalizeDate(absence.endDate)
|
|
||||||
const start = buildDateFromYmd(startYmd)
|
|
||||||
const end = buildDateFromYmd(endYmd)
|
|
||||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue
|
|
||||||
|
|
||||||
for (const cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
|
|
||||||
const ymd = toYmd(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())
|
|
||||||
const existing = map.get(ymd) ?? {
|
|
||||||
am: false,
|
|
||||||
pm: false,
|
|
||||||
labels: [] as string[],
|
|
||||||
colors: [] as string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStart = ymd === startYmd
|
|
||||||
const isEnd = ymd === endYmd
|
|
||||||
const isSingleDay = startYmd === endYmd
|
|
||||||
|
|
||||||
let am = false
|
|
||||||
let pm = false
|
|
||||||
|
|
||||||
if (isSingleDay) {
|
|
||||||
am = absence.startHalf === 'AM'
|
|
||||||
pm = absence.endHalf === 'PM'
|
|
||||||
} else if (isStart) {
|
|
||||||
am = absence.startHalf === 'AM'
|
|
||||||
pm = true
|
|
||||||
} else if (isEnd) {
|
|
||||||
am = true
|
|
||||||
pm = absence.endHalf === 'PM'
|
|
||||||
} else {
|
|
||||||
am = true
|
|
||||||
pm = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence'
|
|
||||||
const typeColor = absence.type?.color ?? '#222783'
|
|
||||||
const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '')
|
|
||||||
const hoverLabel = `${typeLabel}${halfSuffix}`
|
|
||||||
|
|
||||||
const colors = existing.colors.includes(typeColor)
|
|
||||||
? existing.colors
|
|
||||||
: [...existing.colors, typeColor]
|
|
||||||
|
|
||||||
map.set(ymd, {
|
|
||||||
am: existing.am || am,
|
|
||||||
pm: existing.pm || pm,
|
|
||||||
labels: existing.labels.includes(hoverLabel)
|
|
||||||
? existing.labels
|
|
||||||
: [...existing.labels, hoverLabel],
|
|
||||||
colors
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const months = computed(() => {
|
|
||||||
return orderedMonthIndexes.value.map((monthIndex) => {
|
|
||||||
const label = monthLabels[monthIndex]
|
|
||||||
const monthYear = isForfaitRule.value
|
|
||||||
? displayedYear.value
|
|
||||||
: (monthIndex >= 5 ? displayedYear.value - 1 : displayedYear.value)
|
|
||||||
|
|
||||||
const first = new Date(monthYear, monthIndex, 1)
|
|
||||||
const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate()
|
|
||||||
const mondayBasedFirstDay = (first.getDay() + 6) % 7
|
|
||||||
|
|
||||||
const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null; isHoliday: boolean } | null> = []
|
|
||||||
|
|
||||||
for (let i = 0; i < mondayBasedFirstDay; i += 1) {
|
|
||||||
cells.push(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
|
||||||
const ymd = toYmd(monthYear, monthIndex, day)
|
|
||||||
cells.push({
|
|
||||||
ymd,
|
|
||||||
label: String(day),
|
|
||||||
leave: dayLeaveMap.value.get(ymd) ?? null,
|
|
||||||
isHoliday: ymd in props.publicHolidays
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
while (cells.length % 7 !== 0) {
|
|
||||||
cells.push(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
cells
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const getDayClass = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
|
|
||||||
if (day.leave) {
|
|
||||||
return 'rounded font-semibold text-white'
|
|
||||||
}
|
|
||||||
if (day.isHoliday) return 'text-primary-500 rounded font-semibold'
|
|
||||||
return 'text-primary-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
|
|
||||||
if (day.leave) {
|
|
||||||
const color = day.leave.colors[0] ?? '#222783'
|
|
||||||
if (day.leave.am && day.leave.pm) {
|
|
||||||
return { backgroundColor: color }
|
|
||||||
}
|
|
||||||
const colorFaded = `${color}60`
|
|
||||||
const backgroundImage = day.leave.am
|
|
||||||
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
|
|
||||||
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
|
|
||||||
return { backgroundImage, backgroundColor: 'transparent' }
|
|
||||||
}
|
|
||||||
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDayText = (day: { label: string; leave: DayLeaveState | null }) => {
|
|
||||||
return day.label
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDayTitle = (day: { leave: DayLeaveState | null; isHoliday: boolean; ymd: string }) => {
|
|
||||||
if (day.leave && day.leave.labels.length > 0) return day.leave.labels.join(' / ')
|
|
||||||
if (day.isHoliday) return props.publicHolidays[day.ymd] ?? 'Jour férié'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatCount = (value: number | null | undefined) => {
|
|
||||||
if (value === null || value === undefined) return '-'
|
|
||||||
const rounded = Math.round(value * 100) / 100
|
|
||||||
if (Number.isInteger(rounded)) return String(rounded)
|
|
||||||
return rounded.toFixed(2).replace('.', ',')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
|
||||||
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5">
|
|
||||||
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
|
||||||
<button class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px] text-md" @click="openNewPayment">
|
|
||||||
+ Payer les RTT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
|
||||||
<div class="grid grid-cols-4 gap-10 pb-4">
|
|
||||||
<div
|
|
||||||
v-for="month in months"
|
|
||||||
:key="month.month"
|
|
||||||
class="rounded-md bg-tertiary-500 text-primary-500"
|
|
||||||
>
|
|
||||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
|
|
||||||
{{ month.label }}
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
|
|
||||||
<template v-for="week in month.weeks" :key="week.key">
|
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
|
|
||||||
<span v-if="week.isEmpty"> </span>
|
|
||||||
<span v-else>Semaine {{ week.weekNumber }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500">
|
|
||||||
<span v-if="week.isEmpty"> </span>
|
|
||||||
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
|
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
|
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
|
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
|
||||||
@click="openEditPayment(month.month, '25')"
|
|
||||||
title="Modifier les heures payées"
|
|
||||||
>
|
|
||||||
<p>{{ formatMinutes(getMonthPaid25(month.month)) }}</p>
|
|
||||||
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
|
|
||||||
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
|
|
||||||
<div class="py-[6px] px-3 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
|
|
||||||
@click="openEditPayment(month.month, '50')"
|
|
||||||
title="Modifier les heures payées"
|
|
||||||
>
|
|
||||||
<p>{{ formatMinutes(getMonthPaid50(month.month)) }}</p>
|
|
||||||
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
|
|
||||||
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AppDrawer v-model="isPaymentDrawerOpen" :title="isEditMode ? 'Modifier le paiement RTT' : 'Payer des RTT'">
|
|
||||||
<form @submit.prevent="onSubmitPayment">
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
|
||||||
<select v-model.number="paymentForm.month" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-neutral-700">Nombre d'heures</label>
|
|
||||||
<input v-model.number="paymentForm.hours" type="number" step="0.5" min="0" class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-6">
|
|
||||||
<label class="block text-sm font-medium text-neutral-700">Taux</label>
|
|
||||||
<select v-model="paymentForm.rate" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<option value="25">25%</option>
|
|
||||||
<option value="50">50%</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button type="button" class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" @click="isPaymentDrawerOpen = false">Annuler</button>
|
|
||||||
<button type="submit" class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600">Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
summary: EmployeeRttSummary | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'submit-rtt-payment', month: number, minutes: number, rate: '25' | '50'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isPaymentDrawerOpen = ref(false)
|
|
||||||
const isEditMode = ref(false)
|
|
||||||
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
|
|
||||||
|
|
||||||
const monthLabels = [
|
|
||||||
'Janvier',
|
|
||||||
'Fevrier',
|
|
||||||
'Mars',
|
|
||||||
'Avril',
|
|
||||||
'Mai',
|
|
||||||
'Juin',
|
|
||||||
'Juillet',
|
|
||||||
'Aout',
|
|
||||||
'Septembre',
|
|
||||||
'Octobre',
|
|
||||||
'Novembre',
|
|
||||||
'Decembre'
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const orderedMonthOptions = [
|
|
||||||
{ value: 6, label: 'Juin' },
|
|
||||||
{ value: 7, label: 'Juillet' },
|
|
||||||
{ value: 8, label: 'Aout' },
|
|
||||||
{ value: 9, label: 'Septembre' },
|
|
||||||
{ value: 10, label: 'Octobre' },
|
|
||||||
{ value: 11, label: 'Novembre' },
|
|
||||||
{ value: 12, label: 'Decembre' },
|
|
||||||
{ value: 1, label: 'Janvier' },
|
|
||||||
{ value: 2, label: 'Fevrier' },
|
|
||||||
{ value: 3, label: 'Mars' },
|
|
||||||
{ value: 4, label: 'Avril' },
|
|
||||||
{ value: 5, label: 'Mai' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const paymentsByMonth = computed(() => {
|
|
||||||
const map = new Map<number, { paid25: number; paid50: number }>()
|
|
||||||
for (const mp of props.summary?.monthPayments ?? []) {
|
|
||||||
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
|
|
||||||
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
|
||||||
|
|
||||||
const months = computed(() => {
|
|
||||||
type DisplayWeek = {
|
|
||||||
key: string
|
|
||||||
weekNumber: number
|
|
||||||
recoveryMinutes: number
|
|
||||||
isEmpty?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const byMonth = new Map<number, { month: number; label: string; weeks: DisplayWeek[]; totalMinutes: number }>()
|
|
||||||
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5]
|
|
||||||
for (const month of orderedMonths) {
|
|
||||||
byMonth.set(month, {
|
|
||||||
month,
|
|
||||||
label: monthLabels[month - 1],
|
|
||||||
weeks: [],
|
|
||||||
totalMinutes: 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const week of props.summary?.weeks ?? []) {
|
|
||||||
const month = byMonth.get(week.month)
|
|
||||||
if (!month) continue
|
|
||||||
|
|
||||||
month.weeks.push({
|
|
||||||
key: week.weekStart,
|
|
||||||
weekNumber: week.weekNumber,
|
|
||||||
recoveryMinutes: week.recoveryMinutes
|
|
||||||
})
|
|
||||||
month.totalMinutes += week.recoveryMinutes
|
|
||||||
}
|
|
||||||
|
|
||||||
return orderedMonths
|
|
||||||
.map((monthNumber) => byMonth.get(monthNumber)!)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((month) => {
|
|
||||||
const minRows = 5
|
|
||||||
const missing = Math.max(0, minRows - month.weeks.length)
|
|
||||||
for (let i = 0; i < missing; i += 1) {
|
|
||||||
month.weeks.push({
|
|
||||||
key: `empty-${month.month}-${i}`,
|
|
||||||
weekNumber: 0,
|
|
||||||
recoveryMinutes: 0,
|
|
||||||
isEmpty: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return month
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatMinutes = (minutes: number) => {
|
|
||||||
const abs = Math.abs(minutes)
|
|
||||||
const hours = Math.floor(abs / 60)
|
|
||||||
const rest = abs % 60
|
|
||||||
const sign = minutes < 0 ? '-' : ''
|
|
||||||
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const openNewPayment = () => {
|
|
||||||
isEditMode.value = false
|
|
||||||
paymentForm.month = 6
|
|
||||||
paymentForm.hours = 0
|
|
||||||
paymentForm.rate = '25'
|
|
||||||
isPaymentDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditPayment = (month: number, rate: '25' | '50') => {
|
|
||||||
isEditMode.value = true
|
|
||||||
paymentForm.month = month
|
|
||||||
paymentForm.rate = rate
|
|
||||||
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
|
|
||||||
paymentForm.hours = currentMinutes / 60
|
|
||||||
isPaymentDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmitPayment = () => {
|
|
||||||
const minutes = Math.round(paymentForm.hours * 60)
|
|
||||||
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
|
|
||||||
isPaymentDrawerOpen.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
|
|
||||||
<div class="overflow-y-auto min-h-0">
|
|
||||||
<div
|
|
||||||
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
|
||||||
:style="{ gridTemplateColumns: dayGridCols }"
|
|
||||||
>
|
|
||||||
<span>Nom</span>
|
|
||||||
<span class="pl-2">Absence</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" class="inline-flex items-center gap-2">
|
|
||||||
<span>Site</span>
|
|
||||||
<input
|
|
||||||
ref="bulkSiteValidationInput"
|
|
||||||
:checked="isBulkSiteValidationChecked"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
|
||||||
:disabled="!canBulkToggleSiteValidation"
|
|
||||||
@change="onBulkSiteValidationChange"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
|
||||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="employee in employees"
|
|
||||||
:key="employee.id"
|
|
||||||
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
|
|
||||||
:style="{ gridTemplateColumns: dayGridCols }"
|
|
||||||
>
|
|
||||||
<div class="text-neutral-900 min-w-0">
|
|
||||||
<p class="font-semibold truncate">
|
|
||||||
{{ employee.firstName }} {{ employee.lastName }}
|
|
||||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
|
||||||
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
|
|
||||||
<span
|
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
|
||||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
|
||||||
title="Validation site"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:check"/>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
|
|
||||||
Modifié le {{ getRowUpdatedAt(employee.id) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
|
||||||
<p
|
|
||||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
|
||||||
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
|
||||||
:title="getRowAbsenceLabel(employee.id) || ''"
|
|
||||||
:style="getRowAbsenceStyle(employee.id)"
|
|
||||||
>
|
|
||||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="self-start text-left text-xs font-semibold underline"
|
|
||||||
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
|
||||||
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
|
||||||
@click="onAbsenceClick(employee.id)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pl-4">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].morningFrom"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-else-if="isPresenceTracking(employee)"
|
|
||||||
v-model="rows[employee.id].isPresentMorning"
|
|
||||||
type="checkbox"
|
|
||||||
class="cursor-pointer h-4 w-4"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pr-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].morningTo"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].afternoonFrom"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-else-if="isPresenceTracking(employee)"
|
|
||||||
v-model="rows[employee.id].isPresentAfternoon"
|
|
||||||
type="checkbox"
|
|
||||||
class="cursor-pointer h-4 w-4"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pr-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].afternoonTo"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].eveningFrom"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pr-2">
|
|
||||||
<TimeSelect
|
|
||||||
v-if="isTimeTracking(employee)"
|
|
||||||
v-model="rows[employee.id].eveningTo"
|
|
||||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
|
||||||
<div v-if="isTimeTracking(employee)">{{
|
|
||||||
formatMinutes(getRowMetrics(employee.id).dayMinutes)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm font-semibold text-neutral-700">
|
|
||||||
<div v-if="isTimeTracking(employee)">{{
|
|
||||||
formatMinutes(getRowMetrics(employee.id).nightMinutes)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm font-semibold text-neutral-700">
|
|
||||||
<div v-if="isTimeTracking(employee)">{{
|
|
||||||
formatMinutes(getRowMetrics(employee.id).totalMinutes)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isAdmin" class="text-right">
|
|
||||||
<input
|
|
||||||
:checked="rows[employee.id]?.isValid ?? false"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 cursor-pointer"
|
|
||||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-right p-5">
|
|
||||||
<input
|
|
||||||
v-if="isSiteManager"
|
|
||||||
:checked="rows[employee.id]?.isSiteValid ?? false"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 cursor-pointer"
|
|
||||||
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
|
|
||||||
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
|
||||||
<span v-else class="text-xs text-neutral-500">-</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="!isAdmin">
|
|
||||||
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
|
||||||
<span v-else class="text-xs text-neutral-500">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type {Employee} from '~/services/dto/employee'
|
|
||||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
|
||||||
import type {HourRow} from './types'
|
|
||||||
|
|
||||||
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
|
|
||||||
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 }
|
|
||||||
getRowAbsenceLabel: (employeeId: number) => string
|
|
||||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
|
||||||
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,178 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="py-6 flex flex-col gap-3">
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
|
||||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center gap-4">
|
|
||||||
<div class="flex gap-4 flex-wrap">
|
|
||||||
<div
|
|
||||||
v-if="viewMode === 'day'"
|
|
||||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
|
||||||
>
|
|
||||||
<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-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
|
||||||
>
|
|
||||||
<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-[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>
|
|
||||||
|
|
||||||
<div v-if="isAdmin" class="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="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 EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
|
||||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.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 })
|
|
||||||
|
|
||||||
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 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 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,98 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
|
|
||||||
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
|
||||||
<div v-else class="overflow-y-auto min-h-0">
|
|
||||||
<div
|
|
||||||
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
|
||||||
: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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="row in weeklySummary?.rows ?? []"
|
|
||||||
:key="row.employeeId"
|
|
||||||
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0 hover:bg-tertiary-500"
|
|
||||||
:style="{ gridTemplateColumns: weekGridCols }"
|
|
||||||
>
|
|
||||||
<div class="text-neutral-900 min-w-0">
|
|
||||||
<p class="font-semibold truncate">
|
|
||||||
{{ row.firstName }} {{ row.lastName }}
|
|
||||||
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="daily in row.daily"
|
|
||||||
:key="daily.date"
|
|
||||||
class="text-left leading-4 rounded-md px-2 py-1"
|
|
||||||
:class="daily.hasAbsence ? 'text-white' : ''"
|
|
||||||
:style="getDailyCellStyle(daily)"
|
|
||||||
:title="daily.absenceLabel ?? ''"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
|
||||||
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
|
|
||||||
|
|
||||||
const isInterimContract = (contractType?: ContractType | null) => {
|
|
||||||
return contractType === CONTRACT_TYPES.INTERIM
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDailyCellStyle = (daily: {
|
|
||||||
hasAbsence?: boolean
|
|
||||||
absenceColor?: string | null
|
|
||||||
}) => {
|
|
||||||
if (!daily.hasAbsence) return undefined
|
|
||||||
return { backgroundColor: daily.absenceColor || '#dc2626' }
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
isWeekLoading: boolean
|
|
||||||
weekGridCols: string
|
|
||||||
weeklySummary: WeeklyWorkHourSummary | null
|
|
||||||
weekDayHeaders: Array<{ date: string; label: string }>
|
|
||||||
formatMinutes: (minutes: number) => string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@@ -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,253 +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="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 openMenu = () => {
|
|
||||||
if (props.disabled) return
|
|
||||||
if (!isOpen.value) {
|
|
||||||
isOpen.value = true
|
|
||||||
nextTick(updateMenuPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMenuAndFocusFirst = () => {
|
|
||||||
openMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeMenu = () => {
|
|
||||||
isOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitInput = () => {
|
|
||||||
const normalized = normalizeTypedTime(inputValue.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
|
|
||||||
}
|
|
||||||
openMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onInputBlur = () => {
|
|
||||||
// Laisse le temps au click menu de passer avant fermeture.
|
|
||||||
setTimeout(() => {
|
|
||||||
if (menu.value?.contains(document.activeElement)) return
|
|
||||||
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 AnyObject = Record<string, unknown>
|
||||||
|
|
||||||
export type BlobResponse = {
|
|
||||||
data: Blob
|
|
||||||
headers: Headers
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApiClient = {
|
export type ApiClient = {
|
||||||
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
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>
|
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
|
||||||
put<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>
|
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'> =
|
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
|
||||||
FetchOptions<ResponseType> & {
|
FetchOptions<ResponseType> & {
|
||||||
toast?: boolean
|
toast?: boolean
|
||||||
toastOn401?: boolean
|
|
||||||
toastTitle?: string
|
toastTitle?: string
|
||||||
toastErrorMessage?: string
|
toastErrorMessage?: string
|
||||||
toastSuccessMessage?: string
|
toastSuccessMessage?: string
|
||||||
@@ -103,31 +97,9 @@ export const useApi = (): ApiClient => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onResponseError({ response, error, options }) {
|
async onResponseError({ response, error, options }) {
|
||||||
const apiOptions = options as ApiFetchOptions<'json'>
|
|
||||||
if (response?.status === 401) {
|
if (response?.status === 401) {
|
||||||
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
const requestUrl = typeof options?.url === 'string' ? options.url : ''
|
||||||
const isLoginCheck = requestUrl.includes('/login_check')
|
if (!requestUrl.includes('/login_check') && !requestUrl.includes('/logout')) {
|
||||||
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 (!isHandlingUnauthorized) {
|
if (!isHandlingUnauthorized) {
|
||||||
isHandlingUnauthorized = true
|
isHandlingUnauthorized = true
|
||||||
auth.clearSession()
|
auth.clearSession()
|
||||||
@@ -138,10 +110,10 @@ export const useApi = (): ApiClient => {
|
|||||||
isHandlingUnauthorized = false
|
isHandlingUnauthorized = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const apiOptions = options as ApiFetchOptions<'json'>
|
||||||
if (apiOptions?.toast === false) {
|
if (apiOptions?.toast === false) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -193,9 +165,7 @@ export const useApi = (): ApiClient => {
|
|||||||
return request<T>('GET', url, { ...options, query })
|
return request<T>('GET', url, { ...options, query })
|
||||||
},
|
},
|
||||||
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
|
getBlob(url: string, query: AnyObject = {}, options: ApiFetchOptions<'blob'> = {}) {
|
||||||
return client
|
return client<Blob>(url, { ...options, method: 'GET', query, responseType: 'blob' })
|
||||||
.raw(url, { ...options, method: 'GET', query, responseType: 'blob' })
|
|
||||||
.then((res) => ({ data: res._data as Blob, headers: res.headers }))
|
|
||||||
},
|
},
|
||||||
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
|
||||||
return request<T>('POST', url, { ...options, body })
|
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,373 +0,0 @@
|
|||||||
import type { Contract } from '~/services/dto/contract'
|
|
||||||
import type { Absence } from '~/services/dto/absence'
|
|
||||||
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
|
||||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
|
||||||
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
|
||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
|
||||||
import { listAbsences } from '~/services/absences'
|
|
||||||
import { listContracts } from '~/services/contracts'
|
|
||||||
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
|
|
||||||
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
|
||||||
import { getEmployee, updateEmployee } from '~/services/employees'
|
|
||||||
import { listPublicHolidays } from '~/services/public-holidays'
|
|
||||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
|
||||||
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
|
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
|
||||||
const route = useRoute()
|
|
||||||
const toast = useToast()
|
|
||||||
const employee = ref<Employee | null>(null)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
|
|
||||||
const contracts = ref<Contract[]>([])
|
|
||||||
const employeeAbsences = ref<Absence[]>([])
|
|
||||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
|
||||||
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
|
||||||
const publicHolidays = ref<Record<string, string>>({})
|
|
||||||
const isContractDrawerOpen = ref(false)
|
|
||||||
const isContractSubmitting = ref(false)
|
|
||||||
const isCreateContractDrawerOpen = ref(false)
|
|
||||||
const isCreateContractSubmitting = ref(false)
|
|
||||||
|
|
||||||
const contractForm = reactive({
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractName: '',
|
|
||||||
weeklyHours: null as number | null,
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
startDate: '',
|
|
||||||
endDate: '',
|
|
||||||
paidLeaveSettled: false,
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
endDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const createContractForm = reactive({
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
startDate: '',
|
|
||||||
endDate: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const createValidationTouched = reactive({
|
|
||||||
contractId: false,
|
|
||||||
contractNature: false,
|
|
||||||
startDate: false,
|
|
||||||
endDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
|
|
||||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
|
||||||
const employeeContractWorkLabel = computed(() => {
|
|
||||||
const contract = employee.value?.contract
|
|
||||||
if (!contract) return '-'
|
|
||||||
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
|
|
||||||
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
|
|
||||||
return contract.name || '-'
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
|
|
||||||
|
|
||||||
const contractHistoryLabel = (item: ContractHistoryItem) => {
|
|
||||||
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
|
|
||||||
return `${item.weeklyHours} heures`
|
|
||||||
}
|
|
||||||
return item.contractName ?? '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentActiveContractPeriod = computed(() => {
|
|
||||||
const today = getTodayYmd()
|
|
||||||
const history = employee.value?.contractHistory ?? []
|
|
||||||
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCloseCurrentContract = computed(() => {
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!active) return false
|
|
||||||
if (!active.endDate) return true
|
|
||||||
return active.endDate > getTodayYmd()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCreateContract = computed(() => {
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!active) return true
|
|
||||||
return !!active.endDate
|
|
||||||
})
|
|
||||||
|
|
||||||
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
|
|
||||||
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
|
|
||||||
|
|
||||||
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
|
|
||||||
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
|
|
||||||
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
|
|
||||||
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
|
|
||||||
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
|
|
||||||
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
|
|
||||||
const isCreateContractFormValid = computed(() =>
|
|
||||||
isCreateContractValid.value &&
|
|
||||||
isCreateContractNatureValid.value &&
|
|
||||||
isCreateContractStartDateValid.value &&
|
|
||||||
isCreateContractEndDateValid.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const baseInputClass =
|
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
|
||||||
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
|
|
||||||
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
|
||||||
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
|
||||||
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const closeContractWorkedHoursLabel = computed(() => {
|
|
||||||
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
|
|
||||||
return contractForm.contractName || '-'
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetContractValidation = () => {
|
|
||||||
validationTouched.endDate = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateContractFormFromCurrent = () => {
|
|
||||||
const current = employee.value
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!current || !active) return
|
|
||||||
|
|
||||||
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
|
|
||||||
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
|
|
||||||
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
|
|
||||||
contractForm.contractNature = active.contractNature
|
|
||||||
contractForm.startDate = active.startDate
|
|
||||||
contractForm.endDate = getTodayYmd()
|
|
||||||
contractForm.paidLeaveSettled = false
|
|
||||||
contractForm.comment = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCloseContractDrawer = () => {
|
|
||||||
if (!employee.value || !canCloseCurrentContract.value) return
|
|
||||||
hydrateContractFormFromCurrent()
|
|
||||||
resetContractValidation()
|
|
||||||
isContractDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const setContractDrawerOpen = (open: boolean) => {
|
|
||||||
isContractDrawerOpen.value = open
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetCreateValidation = () => {
|
|
||||||
createValidationTouched.contractId = false
|
|
||||||
createValidationTouched.contractNature = false
|
|
||||||
createValidationTouched.startDate = false
|
|
||||||
createValidationTouched.endDate = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreateContractDrawer = () => {
|
|
||||||
if (!employee.value || !canCreateContract.value) return
|
|
||||||
createContractForm.contractId = ''
|
|
||||||
createContractForm.contractNature = 'CDI'
|
|
||||||
createContractForm.endDate = ''
|
|
||||||
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
|
||||||
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
|
||||||
: getTodayYmd()
|
|
||||||
resetCreateValidation()
|
|
||||||
isCreateContractDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const setCreateContractDrawerOpen = (open: boolean) => {
|
|
||||||
isCreateContractDrawerOpen.value = open
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadEmployee = async () => {
|
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
|
||||||
const employeeId = Number(idParam)
|
|
||||||
if (!Number.isInteger(employeeId) || employeeId <= 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const loadedEmployee = await getEmployee(employeeId)
|
|
||||||
employee.value = loadedEmployee
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const isForfait = loadedEmployee.contract?.type === CONTRACT_TYPES.FORFAIT
|
|
||||||
const leaveYear = isForfait
|
|
||||||
? now.getFullYear()
|
|
||||||
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
|
|
||||||
const rttYear = now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()
|
|
||||||
const from = isForfait
|
|
||||||
? `${leaveYear}-01-01`
|
|
||||||
: `${leaveYear - 1}-06-01`
|
|
||||||
const to = isForfait
|
|
||||||
? `${leaveYear}-12-31`
|
|
||||||
: `${leaveYear}-05-31`
|
|
||||||
const holidayYears = isForfait
|
|
||||||
? [leaveYear]
|
|
||||||
: [leaveYear - 1, leaveYear]
|
|
||||||
const [absences, summary, rtt, ...holidayResults] = await Promise.all([
|
|
||||||
listAbsences({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
employeeId: loadedEmployee.id
|
|
||||||
}),
|
|
||||||
showLeaveTab.value
|
|
||||||
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
getEmployeeRttSummary(loadedEmployee.id, rttYear),
|
|
||||||
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
|
||||||
])
|
|
||||||
employeeAbsences.value = absences
|
|
||||||
leaveSummary.value = summary
|
|
||||||
rttSummary.value = rtt
|
|
||||||
publicHolidays.value = Object.assign({}, ...holidayResults)
|
|
||||||
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
|
||||||
activeTab.value = 'contract'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitContractUpdate = async () => {
|
|
||||||
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
|
|
||||||
|
|
||||||
validationTouched.endDate = true
|
|
||||||
if (!isContractEndDateValid.value) return
|
|
||||||
|
|
||||||
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
|
|
||||||
toast.error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isContractSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await updateEmployee(employee.value.id, {
|
|
||||||
firstName: employee.value.firstName,
|
|
||||||
lastName: employee.value.lastName,
|
|
||||||
siteId: employee.value.site?.id ?? null,
|
|
||||||
contractId: Number(contractForm.contractId),
|
|
||||||
contractEndDate: contractForm.endDate || null,
|
|
||||||
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
|
|
||||||
contractComment: contractForm.comment || null
|
|
||||||
})
|
|
||||||
|
|
||||||
isContractDrawerOpen.value = false
|
|
||||||
await loadEmployee()
|
|
||||||
} finally {
|
|
||||||
isContractSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitCreateContract = async () => {
|
|
||||||
if (!employee.value || isCreateContractSubmitting.value) return
|
|
||||||
|
|
||||||
createValidationTouched.contractId = true
|
|
||||||
createValidationTouched.contractNature = true
|
|
||||||
createValidationTouched.startDate = true
|
|
||||||
createValidationTouched.endDate = true
|
|
||||||
if (!isCreateContractFormValid.value) return
|
|
||||||
|
|
||||||
if (currentActiveContractPeriod.value?.endDate) {
|
|
||||||
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
|
|
||||||
if (createContractForm.startDate < minStartDate) {
|
|
||||||
toast.error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreateContractSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await updateEmployee(employee.value.id, {
|
|
||||||
firstName: employee.value.firstName,
|
|
||||||
lastName: employee.value.lastName,
|
|
||||||
siteId: employee.value.site?.id ?? null,
|
|
||||||
contractId: Number(createContractForm.contractId),
|
|
||||||
contractNature: createContractForm.contractNature,
|
|
||||||
contractStartDate: createContractForm.startDate,
|
|
||||||
contractEndDate: createContractForm.endDate || null
|
|
||||||
})
|
|
||||||
isCreateContractDrawerOpen.value = false
|
|
||||||
await loadEmployee()
|
|
||||||
} finally {
|
|
||||||
isCreateContractSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitFractionedDays = async (days: number) => {
|
|
||||||
if (!employee.value) return
|
|
||||||
const year = leaveSummary.value?.year ?? undefined
|
|
||||||
await updateFractionedDays(employee.value.id, days, year)
|
|
||||||
await loadEmployee()
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => {
|
|
||||||
if (!employee.value) return
|
|
||||||
const year = rttSummary.value?.year ?? undefined
|
|
||||||
await createRttPayment(employee.value.id, month, minutes, rate, year)
|
|
||||||
await loadEmployee()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(showsCreateContractEndDate, (shows) => {
|
|
||||||
if (!shows) {
|
|
||||||
createContractForm.endDate = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
contracts.value = await listContracts()
|
|
||||||
await loadEmployee()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
employee,
|
|
||||||
isLoading,
|
|
||||||
activeTab,
|
|
||||||
contracts,
|
|
||||||
employeeAbsences,
|
|
||||||
leaveSummary,
|
|
||||||
rttSummary,
|
|
||||||
publicHolidays,
|
|
||||||
showLeaveTab,
|
|
||||||
contractHistory,
|
|
||||||
employeeContractWorkLabel,
|
|
||||||
contractForm,
|
|
||||||
createContractForm,
|
|
||||||
isContractDrawerOpen,
|
|
||||||
isContractSubmitting,
|
|
||||||
isCreateContractDrawerOpen,
|
|
||||||
isCreateContractSubmitting,
|
|
||||||
canCloseCurrentContract,
|
|
||||||
canCreateContract,
|
|
||||||
readonlyFieldClass,
|
|
||||||
closeContractWorkedHoursLabel,
|
|
||||||
contractEndDateFieldClass,
|
|
||||||
showContractEndDateError,
|
|
||||||
isContractEndDateValid,
|
|
||||||
createContractNatureFieldClass,
|
|
||||||
createContractFieldClass,
|
|
||||||
createContractStartDateFieldClass,
|
|
||||||
showsCreateContractEndDate,
|
|
||||||
requiresCreateContractEndDate,
|
|
||||||
createContractEndDateFieldClass,
|
|
||||||
isCreateContractFormValid,
|
|
||||||
contractNatureLabel,
|
|
||||||
contractHistoryLabel,
|
|
||||||
formatDate,
|
|
||||||
openCloseContractDrawer,
|
|
||||||
openCreateContractDrawer,
|
|
||||||
setContractDrawerOpen,
|
|
||||||
setCreateContractDrawerOpen,
|
|
||||||
submitContractUpdate,
|
|
||||||
submitCreateContract,
|
|
||||||
submitFractionedDays,
|
|
||||||
submitRttPayment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,11 +31,6 @@
|
|||||||
"create": "Impossible de créer l'absence.",
|
"create": "Impossible de créer l'absence.",
|
||||||
"update": "Impossible de mettre à jour l'absence.",
|
"update": "Impossible de mettre à jour l'absence.",
|
||||||
"delete": "Impossible de supprimer l'absence."
|
"delete": "Impossible de supprimer l'absence."
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"create": "Impossible de créer l'utilisateur.",
|
|
||||||
"update": "Impossible de mettre à jour l'utilisateur.",
|
|
||||||
"delete": "Impossible de supprimer l'utilisateur."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
@@ -62,11 +57,6 @@
|
|||||||
"create": "Absence créée.",
|
"create": "Absence créée.",
|
||||||
"update": "Absence mise à jour.",
|
"update": "Absence mise à jour.",
|
||||||
"delete": "Absence supprimée."
|
"delete": "Absence supprimée."
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"create": "Utilisateur créé.",
|
|
||||||
"update": "Utilisateur mis à jour.",
|
|
||||||
"delete": "Utilisateur supprimé."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
|
<div class="min-h-screen bg-tertiary-500 from-tertiary-50 via-white to-neutral-100 text-neutral-900">
|
||||||
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { version } = useAppVersion()
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,96 +1,71 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden">
|
<div class="min-h-screen">
|
||||||
<div class="flex h-full">
|
<div class="flex min-h-screen">
|
||||||
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500">
|
||||||
<div class="h-[75px]">
|
<div>
|
||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 px-4 pb-6">
|
||||||
<template v-if="isAdmin">
|
|
||||||
<NuxtLink
|
|
||||||
to="/"
|
|
||||||
class="hidden flex items-center gap-3 px-4 pb-3 pt-6 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
|
||||||
active-class="bg-tertiary-500 text-primary-500 font-bold"
|
|
||||||
>
|
|
||||||
Tableau de bord
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/calendar"
|
|
||||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
|
||||||
:class="route.path.startsWith('/calendar')
|
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
|
||||||
: ''"
|
|
||||||
>
|
|
||||||
Calendrier
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/hours"
|
to="/"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600 border-t border-secondary-500"
|
||||||
:class="route.path.startsWith('/hours')
|
active-class="bg-primary-50 text-primary-600"
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
|
||||||
: ''"
|
|
||||||
>
|
>
|
||||||
Heures
|
Tableau de bord
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/calendar"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
||||||
|
active-class="bg-primary-50 text-primary-600"
|
||||||
|
>
|
||||||
|
Calendrier
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/employees"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
||||||
|
active-class="bg-primary-50 text-primary-600"
|
||||||
|
>
|
||||||
|
Employés
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/sites"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
||||||
|
active-class="bg-primary-50 text-primary-600"
|
||||||
|
>
|
||||||
|
Sites
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/absence-types"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
||||||
|
active-class="bg-primary-50 text-primary-600"
|
||||||
|
>
|
||||||
|
Types d'absence
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-if="isAdmin">
|
|
||||||
<NuxtLink
|
|
||||||
to="/employees"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
|
||||||
:class="route.path.startsWith('/employees')
|
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
|
||||||
: ''"
|
|
||||||
>
|
|
||||||
Employés
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/sites"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
|
||||||
:class="route.path.startsWith('/sites')
|
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
|
||||||
: ''"
|
|
||||||
>
|
|
||||||
Sites
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/absence-types"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
|
||||||
:class="route.path.startsWith('/absence-types')
|
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
|
||||||
: ''"
|
|
||||||
>
|
|
||||||
Types d'absence
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/users"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
|
||||||
:class="route.path.startsWith('/users')
|
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
|
||||||
: ''"
|
|
||||||
>
|
|
||||||
Utilisateurs
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 items-center p-4">
|
<div class="p-4">
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-lg px-4 py-2 text-md font-semibold text-white bg-primary-500"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="h-full flex-1 overflow-hidden flex flex-col">
|
<main class="flex-1 px-8 py-8">
|
||||||
<AppTopNav :user="auth.user" />
|
<slot/>
|
||||||
<main class="flex-1 overflow-y-auto px-8 py-12">
|
</main>
|
||||||
<slot/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const {version} = useAppVersion()
|
|
||||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
const handleLogout = async () => {
|
||||||
const route = useRoute()
|
await auth.logout()
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
export default defineNuxtRouteMiddleware(async () => {
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
if (!auth.checked) {
|
|
||||||
await auth.ensureSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdmin = auth.user?.roles?.includes('ROLE_ADMIN')
|
|
||||||
if (!isAdmin) {
|
|
||||||
return navigateTo('/')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -3,16 +3,13 @@ export default defineNuxtConfig({
|
|||||||
devtools: {enabled: false},
|
devtools: {enabled: false},
|
||||||
ssr: false,
|
ssr: false,
|
||||||
app: {
|
app: {
|
||||||
baseURL: process.env.NODE_ENV === 'production'
|
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
|
||||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
|
||||||
: '/'
|
|
||||||
},
|
},
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
'nuxt-toast',
|
'nuxt-toast',
|
||||||
'@nuxtjs/i18n',
|
'@nuxtjs/i18n'
|
||||||
'@nuxt/icon'
|
|
||||||
],
|
],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
@@ -22,7 +19,7 @@ export default defineNuxtConfig({
|
|||||||
devServer: {port: 3001},
|
devServer: {port: 3001},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
timeout: 2000,
|
timeout: 10000,
|
||||||
closeOnClick: true,
|
closeOnClick: true,
|
||||||
progressBar: false
|
progressBar: false
|
||||||
}
|
}
|
||||||
|
|||||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@@ -7,7 +7,6 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"nuxt": "^4.3.0",
|
"nuxt": "^4.3.0",
|
||||||
@@ -33,19 +32,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@antfu/install-pkg": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"package-manager-detector": "^1.3.0",
|
|
||||||
"tinyexec": "^1.0.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/antfu"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1234,47 +1220,6 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@iconify/collections": {
|
|
||||||
"version": "1.0.651",
|
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.651.tgz",
|
|
||||||
"integrity": "sha512-ALGlYxNVOIylxNHjFaylqPTzgNaMHeoFA8ao/piPHjYGD526xEp847F7KePy9jvOLChy2bzQVwAV9Em3HiicjQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@iconify/types": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@iconify/types": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@iconify/utils": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@antfu/install-pkg": "^1.1.0",
|
|
||||||
"@iconify/types": "^2.0.0",
|
|
||||||
"mlly": "^1.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@iconify/vue": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@iconify/types": "^2.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/cyberalien"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vue": ">=3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@intlify/bundle-utils": {
|
"node_modules/@intlify/bundle-utils": {
|
||||||
"version": "11.0.3",
|
"version": "11.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
||||||
@@ -2427,28 +2372,6 @@
|
|||||||
"devtools-wizard": "cli.mjs"
|
"devtools-wizard": "cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/icon": {
|
|
||||||
"version": "2.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
|
|
||||||
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@iconify/collections": "^1.0.641",
|
|
||||||
"@iconify/types": "^2.0.0",
|
|
||||||
"@iconify/utils": "^3.1.0",
|
|
||||||
"@iconify/vue": "^5.0.0",
|
|
||||||
"@nuxt/devtools-kit": "^3.1.1",
|
|
||||||
"@nuxt/kit": "^4.2.2",
|
|
||||||
"consola": "^3.4.2",
|
|
||||||
"local-pkg": "^1.1.2",
|
|
||||||
"mlly": "^1.8.0",
|
|
||||||
"ohash": "^2.0.11",
|
|
||||||
"pathe": "^2.0.3",
|
|
||||||
"picomatch": "^4.0.3",
|
|
||||||
"std-env": "^3.10.0",
|
|
||||||
"tinyglobby": "^0.2.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/kit": {
|
"node_modules/@nuxt/kit": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"nuxt": "^4.3.0",
|
"nuxt": "^4.3.0",
|
||||||
|
|||||||
@@ -19,11 +19,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||||
<div class="grid grid-cols-[120px_160px_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
<div class="grid grid-cols-[120px_120px_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||||
<span class="text-left">Code</span>
|
<span class="text-left">Code</span>
|
||||||
<span class="text-left">Libellé</span>
|
<span class="text-left">Libellé</span>
|
||||||
<span class="text-left">Couleur</span>
|
<span class="text-left">Couleur</span>
|
||||||
<span class="text-left">Compte en heures</span>
|
|
||||||
<span class="text-right">Actions</span>
|
<span class="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
@@ -33,7 +32,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="type in absenceTypes"
|
v-for="type in absenceTypes"
|
||||||
:key="type.id"
|
:key="type.id"
|
||||||
class="grid grid-cols-[120px_160px_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
class="grid grid-cols-[120px_120px_1fr_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-left">{{ type.code }}</span>
|
<span class="font-semibold text-left">{{ type.code }}</span>
|
||||||
<span class="text-left">{{ type.label }}</span>
|
<span class="text-left">{{ type.label }}</span>
|
||||||
@@ -44,14 +43,6 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left">
|
|
||||||
<span
|
|
||||||
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
|
|
||||||
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
|
|
||||||
>
|
|
||||||
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -75,75 +66,35 @@
|
|||||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="code">
|
<label class="text-md font-semibold text-neutral-700" for="code">Code</label>
|
||||||
Code <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="code"
|
id="code"
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
:class="codeFieldClass"
|
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-primary-200"
|
||||||
/>
|
/>
|
||||||
<p v-if="showCodeError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le code est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="label">
|
<label class="text-md font-semibold text-neutral-700" for="label">Libellé</label>
|
||||||
Libellé <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="label"
|
id="label"
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
type="text"
|
type="text"
|
||||||
:class="labelFieldClass"
|
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-primary-200"
|
||||||
/>
|
/>
|
||||||
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le libellé est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700">
|
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
|
||||||
Compté comme travaillé
|
|
||||||
</label>
|
|
||||||
<div class="mt-2 flex items-center gap-6">
|
|
||||||
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
|
||||||
<input
|
|
||||||
v-model="form.countAsWorkedHours"
|
|
||||||
type="radio"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:value="true"
|
|
||||||
/>
|
|
||||||
Oui
|
|
||||||
</label>
|
|
||||||
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
|
||||||
<input
|
|
||||||
v-model="form.countAsWorkedHours"
|
|
||||||
type="radio"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:value="false"
|
|
||||||
/>
|
|
||||||
Non
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="color">
|
|
||||||
Couleur <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="mt-2 flex items-center gap-3">
|
<div class="mt-2 flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
id="color"
|
id="color"
|
||||||
v-model="form.color"
|
v-model="form.color"
|
||||||
type="color"
|
type="color"
|
||||||
:class="colorFieldClass"
|
class="h-10 w-16 cursor-pointer rounded-md border border-neutral-300 bg-white p-1"
|
||||||
/>
|
/>
|
||||||
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
|
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="showColorError" class="mt-1 text-sm text-red-600">
|
|
||||||
La couleur est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@@ -156,7 +107,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
:class="submitButtonClass"
|
:disabled="isSubmitting"
|
||||||
>
|
>
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -170,10 +121,6 @@
|
|||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Types d\'absences'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
@@ -188,54 +135,7 @@ const drawerTitle = computed(() =>
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: '',
|
code: '',
|
||||||
label: '',
|
label: '',
|
||||||
color: '#222783',
|
color: ''
|
||||||
countAsWorkedHours: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
code: false,
|
|
||||||
label: false,
|
|
||||||
color: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isCodeValid = computed(() => form.code.trim() !== '')
|
|
||||||
const isLabelValid = computed(() => form.label.trim() !== '')
|
|
||||||
const isColorValid = computed(() => form.color.trim() !== '')
|
|
||||||
const isFormValid = computed(
|
|
||||||
() => isCodeValid.value && isLabelValid.value && isColorValid.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const showCodeError = computed(() => validationTouched.code && !isCodeValid.value)
|
|
||||||
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
|
|
||||||
const showColorError = computed(() => validationTouched.color && !isColorValid.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 codeFieldClass = computed(() => {
|
|
||||||
if (showCodeError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const labelFieldClass = computed(() => {
|
|
||||||
if (showLabelError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const colorFieldClass = computed(() => {
|
|
||||||
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
|
|
||||||
if (showColorError.value) {
|
|
||||||
return `${baseColorClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseColorClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
|
||||||
if (isSubmitting.value || !isFormValid.value) {
|
|
||||||
return 'opacity-50 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadAbsenceTypes = async () => {
|
const loadAbsenceTypes = async () => {
|
||||||
@@ -252,8 +152,7 @@ onMounted(loadAbsenceTypes)
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.code = ''
|
form.code = ''
|
||||||
form.label = ''
|
form.label = ''
|
||||||
form.color = '#222783'
|
form.color = ''
|
||||||
form.countAsWorkedHours = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
@@ -267,7 +166,6 @@ const openEdit = (type: AbsenceType) => {
|
|||||||
form.code = type.code
|
form.code = type.code
|
||||||
form.label = type.label
|
form.label = type.label
|
||||||
form.color = type.color
|
form.color = type.color
|
||||||
form.countAsWorkedHours = type.countAsWorkedHours
|
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,10 +177,6 @@ const closeDrawer = () => {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
validationTouched.code = true
|
|
||||||
validationTouched.label = true
|
|
||||||
validationTouched.color = true
|
|
||||||
if (!isFormValid.value) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -290,15 +184,13 @@ const handleSubmit = async () => {
|
|||||||
await updateAbsenceType(editingType.value.id, {
|
await updateAbsenceType(editingType.value.id, {
|
||||||
code: form.code,
|
code: form.code,
|
||||||
label: form.label,
|
label: form.label,
|
||||||
color: form.color,
|
color: form.color
|
||||||
countAsWorkedHours: form.countAsWorkedHours
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createAbsenceType({
|
await createAbsenceType({
|
||||||
code: form.code,
|
code: form.code,
|
||||||
label: form.label,
|
label: form.label,
|
||||||
color: form.color,
|
color: form.color
|
||||||
countAsWorkedHours: form.countAsWorkedHours
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,14 +201,6 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(isDrawerOpen, (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
validationTouched.code = false
|
|
||||||
validationTouched.label = false
|
|
||||||
validationTouched.color = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const confirmDelete = async (type: AbsenceType) => {
|
const confirmDelete = async (type: AbsenceType) => {
|
||||||
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
|
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|||||||
@@ -1,92 +1,179 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col overflow-hidden">
|
<div>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4 pb-10">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
||||||
</div>
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex flex-col gap-3 py-6">
|
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-4">
|
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
|
||||||
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
|
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
||||||
|
<input
|
||||||
|
:id="`site-${site.id}`"
|
||||||
|
v-model="selectedSiteIds"
|
||||||
|
:value="site.id"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4">
|
<select
|
||||||
<button
|
v-model="selectedMonth"
|
||||||
type="button"
|
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
>
|
||||||
@click="openCreateFromToday"
|
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||||
|
{{ month.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="selectedYear"
|
||||||
|
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
<option v-for="year in years" :key="year" :value="year">
|
||||||
|
{{ year }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="openCreateFromToday"
|
||||||
|
>
|
||||||
|
Ajouter une absence
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||||
|
<div class="min-w-[900px]">
|
||||||
|
<div class="grid" :style="gridStyle">
|
||||||
|
<div
|
||||||
|
class="sticky left-0 z-20 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="border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
|
||||||
>
|
>
|
||||||
Ajouter une absence
|
<div>{{ day.label }}</div>
|
||||||
</button>
|
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
<template v-for="employee in visibleEmployees" :key="employee.id">
|
||||||
@click="openPrint"
|
<div
|
||||||
>
|
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
|
||||||
Imprimer
|
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
|
||||||
</button>
|
>
|
||||||
|
{{ formatEmployeeName(employee) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="day in daysInMonth"
|
||||||
|
:key="employee.id + '-' + day.date"
|
||||||
|
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
|
||||||
|
:style="getCellStyle(employee.id, day.date)"
|
||||||
|
@click="openCreate(employee, day.date)"
|
||||||
|
>
|
||||||
|
<span v-if="getCellCode(employee.id, day.date)">
|
||||||
|
{{ getCellCode(employee.id, day.date) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="w-80">
|
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
<AppDrawer v-model="isDrawerOpen" title="Nouvelle absence">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="employee">Employé</label>
|
||||||
|
<select
|
||||||
|
id="employee"
|
||||||
|
v-model="form.employeeId"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choisir un employé</option>
|
||||||
|
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||||
|
{{ employee.firstName }} {{ employee.lastName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="type">Type d'absence</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
v-model="form.typeId"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choisir un type</option>
|
||||||
|
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
|
||||||
|
{{ type.label }} ({{ type.code }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="start-date">Date de début</label>
|
||||||
|
<input
|
||||||
|
id="start-date"
|
||||||
|
v-model="form.startDate"
|
||||||
|
type="date"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PeriodStepperPicker
|
<div>
|
||||||
width-class="w-[260px]"
|
<label class="text-md font-semibold text-neutral-700" for="end-date">Date de fin</label>
|
||||||
:label="selectedMonthLabel"
|
<input
|
||||||
picker-type="month"
|
id="end-date"
|
||||||
:picker-value="monthPickerValue"
|
v-model="form.endDate"
|
||||||
prev-aria-label="Mois précédent"
|
type="date"
|
||||||
next-aria-label="Mois suivant"
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||||
@prev="shiftMonth(-1)"
|
/>
|
||||||
@next="shiftMonth(1)"
|
</div>
|
||||||
@pick="onMonthPickerValue"
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
|
||||||
|
<textarea
|
||||||
|
id="comment"
|
||||||
|
v-model="form.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>
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-6 py-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<p class="font-bold">Légende :</p>
|
<button
|
||||||
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
v-if="editingAbsence"
|
||||||
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
type="button"
|
||||||
<p>{{ type.label }}</p>
|
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||||
|
@click="closeDrawer"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</AppDrawer>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0">
|
|
||||||
<CalendarGrid
|
|
||||||
:days-in-month="daysInMonth"
|
|
||||||
:visible-employees="visibleEmployees"
|
|
||||||
:grid-style="gridStyle"
|
|
||||||
:get-cell-style="getCellStyle"
|
|
||||||
:get-cell-info="getCellInfo"
|
|
||||||
:format-employee-name="formatEmployeeName"
|
|
||||||
:is-holiday-date="isHolidayDate"
|
|
||||||
@cell-click="openCreate"
|
|
||||||
@reorder="handleReorder"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AbsenceFormDrawer
|
|
||||||
v-model="isDrawerOpen"
|
|
||||||
:employees="employees"
|
|
||||||
:absence-types="absenceTypes"
|
|
||||||
:form="form"
|
|
||||||
:editing-absence="editingAbsence"
|
|
||||||
:is-submitting="isSubmitting"
|
|
||||||
@submit="handleSubmit"
|
|
||||||
@delete="handleDelete"
|
|
||||||
@cancel="closeDrawer"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AbsencePrintDrawer
|
|
||||||
v-model="isPrintOpen"
|
|
||||||
:sites="sites"
|
|
||||||
:contract-natures="contractNatureOptions"
|
|
||||||
:work-contracts="workContractOptions"
|
|
||||||
:print-form="printForm"
|
|
||||||
@submit="handlePrint"
|
|
||||||
@cancel="closePrint"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -94,43 +181,22 @@
|
|||||||
import type {Employee} from '~/services/dto/employee'
|
import type {Employee} from '~/services/dto/employee'
|
||||||
import type {AbsenceType} from '~/services/dto/absence-type'
|
import type {AbsenceType} from '~/services/dto/absence-type'
|
||||||
import type {Absence} from '~/services/dto/absence'
|
import type {Absence} from '~/services/dto/absence'
|
||||||
import type {HalfDay} from '~/services/dto/half-day'
|
import {listEmployees} from '~/services/employees'
|
||||||
import {HALF_DAYS} from '~/services/dto/half-day'
|
|
||||||
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
|
|
||||||
import {listAbsenceTypes} from '~/services/absence-types'
|
import {listAbsenceTypes} from '~/services/absence-types'
|
||||||
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
||||||
import {listPublicHolidays} from '~/services/public-holidays'
|
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
|
||||||
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
|
|
||||||
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
|
|
||||||
import CalendarGrid from '~/components/CalendarGrid.vue'
|
|
||||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
|
||||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
|
||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
|
||||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Calendrier'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Données principales affichées dans la grille.
|
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = computed(() => {
|
const sites = computed(() => {
|
||||||
const siteMap = new Map<number, { id: number; name: string; color: string }>()
|
const map = new Map<number, { id: number; name: string; color: string }>()
|
||||||
for (const employee of employees.value) {
|
for (const employee of employees.value) {
|
||||||
if (employee.site) {
|
if (employee.site) {
|
||||||
siteMap.set(employee.site.id, employee.site)
|
map.set(employee.site.id, employee.site)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(siteMap.values()).sort((siteA, siteB) => {
|
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||||
const orderA = siteA.displayOrder ?? 0
|
|
||||||
const orderB = siteB.displayOrder ?? 0
|
|
||||||
if (orderA !== orderB) return orderA - orderB
|
|
||||||
return siteA.name.localeCompare(siteB.name, 'fr')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filtres de sites (par défaut: tous sélectionnés à l'init).
|
|
||||||
const selectedSiteIds = ref<number[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
|
|
||||||
@@ -138,40 +204,35 @@ watch(sites, (next) => {
|
|||||||
if (sitesInitialized.value || next.length === 0) return
|
if (sitesInitialized.value || next.length === 0) return
|
||||||
selectedSiteIds.value = next.map((site) => site.id)
|
selectedSiteIds.value = next.map((site) => site.id)
|
||||||
sitesInitialized.value = true
|
sitesInitialized.value = true
|
||||||
}, {immediate: true})
|
}, { immediate: true })
|
||||||
|
|
||||||
// Tri stable: site -> nom -> prénom.
|
|
||||||
const sortedEmployees = computed(() => {
|
const sortedEmployees = computed(() => {
|
||||||
return sortEmployeesBySiteAndOrder(employees.value)
|
return [...employees.value].sort((a, b) => {
|
||||||
|
const siteA = a.site?.name ?? ''
|
||||||
|
const siteB = b.site?.name ?? ''
|
||||||
|
if (siteA !== siteB) return siteA.localeCompare(siteB, 'fr')
|
||||||
|
const lastA = a.lastName ?? ''
|
||||||
|
const lastB = b.lastName ?? ''
|
||||||
|
if (lastA !== lastB) return lastA.localeCompare(lastB, 'fr')
|
||||||
|
const firstA = a.firstName ?? ''
|
||||||
|
const firstB = b.firstName ?? ''
|
||||||
|
return firstA.localeCompare(firstB, 'fr')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Employés visibles selon le filtre de sites.
|
|
||||||
const employeeFilter = ref('')
|
|
||||||
|
|
||||||
const visibleEmployees = computed(() => {
|
const visibleEmployees = computed(() => {
|
||||||
if (selectedSiteIds.value.length === 0) return []
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
|
||||||
return sortedEmployees.value.filter((employee) => {
|
return sortedEmployees.value.filter((employee) => {
|
||||||
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
|
return employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
|
||||||
if (!siteOk) return false
|
|
||||||
if (!filter) return true
|
|
||||||
const first = employee.firstName?.toLowerCase() ?? ''
|
|
||||||
const last = employee.lastName?.toLowerCase() ?? ''
|
|
||||||
return first.includes(filter) || last.includes(filter)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Données de référence et absences du mois affiché.
|
|
||||||
const absenceTypes = ref<AbsenceType[]>([])
|
const absenceTypes = ref<AbsenceType[]>([])
|
||||||
const absences = ref<Absence[]>([])
|
const absences = ref<Absence[]>([])
|
||||||
const publicHolidays = ref<Record<string, string>>({})
|
|
||||||
|
|
||||||
// États UI.
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const editingAbsence = ref<Absence | null>(null)
|
const editingAbsence = ref<Absence | null>(null)
|
||||||
const isPrintOpen = ref(false)
|
|
||||||
|
|
||||||
// Sélecteurs de période.
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const selectedMonth = ref(now.getMonth())
|
const selectedMonth = ref(now.getMonth())
|
||||||
const selectedYear = ref(now.getFullYear())
|
const selectedYear = ref(now.getFullYear())
|
||||||
@@ -191,177 +252,38 @@ const months = [
|
|||||||
{value: 11, label: 'Décembre'}
|
{value: 11, label: 'Décembre'}
|
||||||
]
|
]
|
||||||
|
|
||||||
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
|
const years = Array.from({length: 5}, (_, i) => now.getFullYear() - 2 + i)
|
||||||
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
|
|
||||||
|
|
||||||
// Infos de calendrier calculées.
|
|
||||||
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
||||||
const monthStartDate = computed(() => new Date(selectedYear.value, selectedMonth.value, 1))
|
|
||||||
const monthEndDate = computed(() => new Date(selectedYear.value, selectedMonth.value + 1, 0))
|
|
||||||
|
|
||||||
// Largeur fixe de la colonne employés + une colonne par jour.
|
|
||||||
const gridStyle = computed(() => ({
|
const gridStyle = computed(() => ({
|
||||||
gridTemplateColumns: `160px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Formulaire d'absence (AM/PM par défaut = journée complète).
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
employeeId: '' as number | '',
|
employeeId: '' as number | '',
|
||||||
typeId: '' as number | '',
|
typeId: '' as number | '',
|
||||||
startDate: '',
|
startDate: '',
|
||||||
startHalf: 'AM' as HalfDay,
|
|
||||||
endDate: '',
|
endDate: '',
|
||||||
endHalf: 'PM' as HalfDay,
|
|
||||||
comment: ''
|
comment: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Formulaire d'impression (intervalle + sites).
|
|
||||||
const printForm = reactive({
|
|
||||||
from: '',
|
|
||||||
to: '',
|
|
||||||
siteIds: [] as number[],
|
|
||||||
contractNatures: [] as Array<'CDI' | 'CDD' | 'INTERIM'>,
|
|
||||||
workContractIds: [] as number[]
|
|
||||||
})
|
|
||||||
|
|
||||||
const contractNatureOptions = [
|
|
||||||
{ value: 'CDI' as const, label: 'CDI' },
|
|
||||||
{ value: 'CDD' as const, label: 'CDD' },
|
|
||||||
{ value: 'INTERIM' as const, label: 'Intérim' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const workContractOptions = computed(() => {
|
|
||||||
const byId = new Map<number, { id: number; name: string }>()
|
|
||||||
for (const employee of employees.value) {
|
|
||||||
const contract = employee.contract
|
|
||||||
if (!contract?.id) continue
|
|
||||||
byId.set(contract.id, { id: contract.id, name: contract.name })
|
|
||||||
}
|
|
||||||
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remet le formulaire à zéro.
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.employeeId = ''
|
form.employeeId = ''
|
||||||
form.typeId = ''
|
form.typeId = ''
|
||||||
form.startDate = ''
|
form.startDate = ''
|
||||||
form.startHalf = 'AM'
|
|
||||||
form.endDate = ''
|
form.endDate = ''
|
||||||
form.endHalf = 'PM'
|
|
||||||
form.comment = ''
|
form.comment = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ferme le drawer et nettoie l'état.
|
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
editingAbsence.value = null
|
editingAbsence.value = null
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ouvre l'impression avec la période du mois courant.
|
|
||||||
const openPrint = () => {
|
|
||||||
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
|
|
||||||
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
|
|
||||||
printForm.from = monthStart
|
|
||||||
printForm.to = monthEnd
|
|
||||||
printForm.siteIds = [...selectedSiteIds.value]
|
|
||||||
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
|
|
||||||
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
|
|
||||||
isPrintOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closePrint = () => {
|
|
||||||
isPrintOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
|
|
||||||
const getHalfForDate = (
|
|
||||||
startDate: string,
|
|
||||||
endDate: string,
|
|
||||||
startHalf: HalfDay,
|
|
||||||
endHalf: HalfDay,
|
|
||||||
date: string
|
|
||||||
) => {
|
|
||||||
if (startDate === endDate) {
|
|
||||||
return startHalf === endHalf ? startHalf : null
|
|
||||||
}
|
|
||||||
if (date === startDate && startHalf === 'PM') return 'PM'
|
|
||||||
if (date === endDate && endHalf === 'AM') return 'AM'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renvoie les segments occupés pour une date donnée (AM/PM).
|
|
||||||
const getSegmentsForDate = (
|
|
||||||
startDate: string,
|
|
||||||
endDate: string,
|
|
||||||
startHalf: HalfDay,
|
|
||||||
endHalf: HalfDay,
|
|
||||||
date: string
|
|
||||||
) => {
|
|
||||||
const half = getHalfForDate(startDate, endDate, startHalf, endHalf, date)
|
|
||||||
if (!half) return HALF_DAYS.map((item) => item.value) as HalfDay[]
|
|
||||||
return [half] as HalfDay[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajoute des mois tout en gardant un jour valide.
|
|
||||||
const addMonths = (date: Date, months: number) => {
|
|
||||||
const next = new Date(date.getFullYear(), date.getMonth() + months, date.getDate())
|
|
||||||
if (next.getMonth() !== (date.getMonth() + months) % 12) {
|
|
||||||
next.setDate(0)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const shiftMonth = (delta: number) => {
|
|
||||||
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
|
|
||||||
selectedYear.value = next.getFullYear()
|
|
||||||
selectedMonth.value = next.getMonth()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMonthPickerValue = (value: string) => {
|
|
||||||
if (!value) return
|
|
||||||
const [yearStr, monthStr] = value.split('-')
|
|
||||||
const year = Number(yearStr)
|
|
||||||
const month = Number(monthStr)
|
|
||||||
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
|
|
||||||
selectedYear.value = year
|
|
||||||
selectedMonth.value = month - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limite l'intervalle d'impression à 2 mois max.
|
|
||||||
const enforcePrintRange = () => {
|
|
||||||
if (!printForm.from) return
|
|
||||||
const start = parseYmd(printForm.from)
|
|
||||||
if (!start) return
|
|
||||||
const maxEnd = addMonths(start, 2)
|
|
||||||
maxEnd.setDate(maxEnd.getDate() - 1)
|
|
||||||
const maxEndYmd = toYmd(maxEnd.getFullYear(), maxEnd.getMonth(), maxEnd.getDate())
|
|
||||||
|
|
||||||
if (!printForm.to) {
|
|
||||||
printForm.to = maxEndYmd
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = parseYmd(printForm.to)
|
|
||||||
if (!end) {
|
|
||||||
printForm.to = maxEndYmd
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end < start) {
|
|
||||||
printForm.to = printForm.from
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end > maxEnd) {
|
|
||||||
printForm.to = maxEndYmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => printForm.from, enforcePrintRange)
|
|
||||||
watch(() => printForm.to, enforcePrintRange)
|
|
||||||
|
|
||||||
// Chargements API.
|
|
||||||
const loadEmployees = async () => {
|
const loadEmployees = async () => {
|
||||||
employees.value = await listEmployees()
|
employees.value = await listEmployees()
|
||||||
}
|
}
|
||||||
@@ -370,132 +292,50 @@ const loadAbsenceTypes = async () => {
|
|||||||
absenceTypes.value = await listAbsenceTypes()
|
absenceTypes.value = await listAbsenceTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPublicHolidays = async () => {
|
|
||||||
publicHolidays.value = await listPublicHolidays('metropole', selectedYear.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadAbsences = async () => {
|
const loadAbsences = async () => {
|
||||||
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
|
absences.value = await listAbsences()
|
||||||
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
|
|
||||||
absences.value = await listAbsences({
|
|
||||||
from: monthStart,
|
|
||||||
to: monthEnd,
|
|
||||||
siteIds: selectedSiteIds.value
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
|
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadAbsences()])
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
|
watch([selectedMonth, selectedYear], async () => {
|
||||||
await loadAbsences()
|
await loadAbsences()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedYear, async () => {
|
|
||||||
await loadPublicHolidays()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Indexation des absences par cellule pour eviter un find() a chaque case.
|
|
||||||
const cellAbsenceMap = computed(() => {
|
|
||||||
const map = new Map<string, { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string }>()
|
|
||||||
const monthStart = monthStartDate.value
|
|
||||||
const monthEnd = monthEndDate.value
|
|
||||||
|
|
||||||
for (const absence of absences.value) {
|
|
||||||
const employeeId = absence.employee?.id
|
|
||||||
if (!employeeId) continue
|
|
||||||
const startDate = normalizeDate(absence.startDate)
|
|
||||||
const endDate = normalizeDate(absence.endDate)
|
|
||||||
const start = parseYmd(startDate)
|
|
||||||
const end = parseYmd(endDate)
|
|
||||||
if (!start || !end) continue
|
|
||||||
|
|
||||||
const rangeStart = start < monthStart ? monthStart : start
|
|
||||||
const rangeEnd = end > monthEnd ? monthEnd : end
|
|
||||||
if (rangeEnd < rangeStart) continue
|
|
||||||
|
|
||||||
for (
|
|
||||||
let currentDate = new Date(rangeStart.getTime());
|
|
||||||
currentDate <= rangeEnd;
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1)
|
|
||||||
) {
|
|
||||||
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
|
|
||||||
const key = `${employeeId}-${dateKey}`
|
|
||||||
const halfLabel = getHalfForDate(
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
absence.startHalf ?? 'AM',
|
|
||||||
absence.endHalf ?? 'PM',
|
|
||||||
dateKey
|
|
||||||
) ?? undefined
|
|
||||||
map.set(key, {
|
|
||||||
id: absence.id,
|
|
||||||
code: absence.type?.code ?? '',
|
|
||||||
color: absence.type?.color ?? '#222783',
|
|
||||||
halfLabel
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
// Jours fériés (interdit pour la création).
|
|
||||||
const isHolidayDate = (date: string) => {
|
|
||||||
return Boolean(publicHolidays.value[date])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renvoie l'absence effective pour une cellule (ou un "Férié").
|
|
||||||
const getCellAbsence = (employeeId: number, date: string) => {
|
const getCellAbsence = (employeeId: number, date: string) => {
|
||||||
if (isHolidayDate(date)) {
|
const match = absences.value.find((absence) => {
|
||||||
return {
|
const employee = absence.employee?.id
|
||||||
id: 0,
|
const start = normalizeDate(absence.startDate)
|
||||||
code: 'Férié',
|
const end = normalizeDate(absence.endDate)
|
||||||
color: '#b3e5fc',
|
return Number(employee) === employeeId && date >= start && date <= end
|
||||||
textColor: '#0f172a'
|
})
|
||||||
}
|
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.id,
|
||||||
|
code: match.type?.code ?? '',
|
||||||
|
color: match.type?.color ?? '#222783'
|
||||||
}
|
}
|
||||||
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
|
|
||||||
if (absence) return absence
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Style de cellule (plein ou demi-journée).
|
|
||||||
const getCellStyle = (employeeId: number, date: string) => {
|
const getCellStyle = (employeeId: number, date: string) => {
|
||||||
const absence = getCellAbsence(employeeId, date)
|
const absence = getCellAbsence(employeeId, date)
|
||||||
if (!absence) return undefined
|
if (!absence) return undefined
|
||||||
|
|
||||||
if (absence.halfLabel) {
|
|
||||||
const color = absence.color
|
|
||||||
const textColor = absence.textColor ?? '#FFF'
|
|
||||||
const backgroundImage = absence.halfLabel === 'AM'
|
|
||||||
? `linear-gradient(180deg, ${color} 0 50%, transparent 50% 100%)`
|
|
||||||
: `linear-gradient(180deg, transparent 0 50%, ${color} 50% 100%)`
|
|
||||||
return {
|
|
||||||
backgroundImage,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
color: textColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: absence.color,
|
backgroundColor: absence.color,
|
||||||
color: absence.textColor ?? '#fff'
|
color: '#fff'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCellInfo = (employeeId: number, date: string) => {
|
const getCellCode = (employeeId: number, date: string) => {
|
||||||
return getCellAbsence(employeeId, date)
|
return getCellAbsence(employeeId, date)?.code ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ouverture du drawer depuis une cellule.
|
|
||||||
const openCreate = (employee: Employee, date: string) => {
|
const openCreate = (employee: Employee, date: string) => {
|
||||||
if (isHolidayDate(date)) {
|
|
||||||
window.alert("Impossible de creer une absence un jour ferie.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = absences.value.find((absence) => {
|
const existing = absences.value.find((absence) => {
|
||||||
const start = normalizeDate(absence.startDate)
|
const start = normalizeDate(absence.startDate)
|
||||||
const end = normalizeDate(absence.endDate)
|
const end = normalizeDate(absence.endDate)
|
||||||
@@ -508,16 +348,12 @@ const openCreate = (employee: Employee, date: string) => {
|
|||||||
form.typeId = existing.type.id
|
form.typeId = existing.type.id
|
||||||
form.startDate = normalizeDate(existing.startDate)
|
form.startDate = normalizeDate(existing.startDate)
|
||||||
form.endDate = normalizeDate(existing.endDate)
|
form.endDate = normalizeDate(existing.endDate)
|
||||||
form.startHalf = existing.startHalf ?? 'AM'
|
|
||||||
form.endHalf = existing.endHalf ?? 'PM'
|
|
||||||
form.comment = existing.comment ?? ''
|
form.comment = existing.comment ?? ''
|
||||||
} else {
|
} else {
|
||||||
editingAbsence.value = null
|
editingAbsence.value = null
|
||||||
form.employeeId = employee.id
|
form.employeeId = employee.id
|
||||||
form.startDate = date
|
form.startDate = date
|
||||||
form.endDate = date
|
form.endDate = date
|
||||||
form.startHalf = 'AM'
|
|
||||||
form.endHalf = 'PM'
|
|
||||||
form.typeId = ''
|
form.typeId = ''
|
||||||
form.comment = ''
|
form.comment = ''
|
||||||
}
|
}
|
||||||
@@ -525,44 +361,18 @@ const openCreate = (employee: Employee, date: string) => {
|
|||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ouverture du drawer depuis le bouton "Ajouter".
|
|
||||||
const openCreateFromToday = () => {
|
const openCreateFromToday = () => {
|
||||||
editingAbsence.value = null
|
editingAbsence.value = null
|
||||||
form.employeeId = ''
|
form.employeeId = ''
|
||||||
form.typeId = ''
|
form.typeId = ''
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
|
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
if (isHolidayDate(today)) {
|
|
||||||
window.alert("Impossible de creer une absence un jour ferie.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form.startDate = today
|
form.startDate = today
|
||||||
form.endDate = today
|
form.endDate = today
|
||||||
form.startHalf = 'AM'
|
|
||||||
form.endHalf = 'PM'
|
|
||||||
form.comment = ''
|
form.comment = ''
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifie la présence d'un férié dans l'intervalle.
|
|
||||||
const hasHolidayInRange = (startDate: string, endDate: string) => {
|
|
||||||
const start = parseYmd(startDate)
|
|
||||||
const end = parseYmd(endDate)
|
|
||||||
if (!start || !end) return false
|
|
||||||
for (
|
|
||||||
let currentDate = new Date(start.getTime());
|
|
||||||
currentDate <= end;
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1)
|
|
||||||
) {
|
|
||||||
const key = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
|
|
||||||
if (isHolidayDate(key)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Soumission du formulaire: validations + chevauchement + save.
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
@@ -570,68 +380,16 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
const start = normalizeDate(form.startDate)
|
const start = normalizeDate(form.startDate)
|
||||||
const end = normalizeDate(form.endDate)
|
const end = normalizeDate(form.endDate)
|
||||||
if (start > end) {
|
|
||||||
window.alert("La date de fin ne peut pas etre avant la date de debut.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (start === end && form.startHalf === 'PM' && form.endHalf === 'AM') {
|
|
||||||
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (hasHolidayInRange(start, end)) {
|
|
||||||
window.alert("Impossible de creer une absence sur un jour ferie.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const overlaps = absences.value.filter((absence) => {
|
const overlaps = absences.value.filter((absence) => {
|
||||||
if (absence.employee?.id !== Number(form.employeeId)) return false
|
if (absence.employee?.id !== Number(form.employeeId)) return false
|
||||||
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
||||||
const aStart = normalizeDate(absence.startDate)
|
const aStart = normalizeDate(absence.startDate)
|
||||||
const aEnd = normalizeDate(absence.endDate)
|
const aEnd = normalizeDate(absence.endDate)
|
||||||
if (start > aEnd || end < aStart) return false
|
return start <= aEnd && end >= aStart
|
||||||
|
|
||||||
const overlapStart = start > aStart ? start : aStart
|
|
||||||
const overlapEnd = end < aEnd ? end : aEnd
|
|
||||||
const overlapStartDate = parseYmd(overlapStart)
|
|
||||||
const overlapEndDate = parseYmd(overlapEnd)
|
|
||||||
if (!overlapStartDate || !overlapEndDate) return false
|
|
||||||
|
|
||||||
for (
|
|
||||||
let currentDate = new Date(overlapStartDate.getTime());
|
|
||||||
currentDate <= overlapEndDate;
|
|
||||||
currentDate.setDate(currentDate.getDate() + 1)
|
|
||||||
) {
|
|
||||||
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
|
|
||||||
const existingSegments = getSegmentsForDate(
|
|
||||||
aStart,
|
|
||||||
aEnd,
|
|
||||||
absence.startHalf ?? 'AM',
|
|
||||||
absence.endHalf ?? 'PM',
|
|
||||||
dateKey
|
|
||||||
)
|
|
||||||
const newSegments = getSegmentsForDate(
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
form.startHalf,
|
|
||||||
form.endHalf,
|
|
||||||
dateKey
|
|
||||||
)
|
|
||||||
if (existingSegments.some((segment) => newSegments.includes(segment))) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (overlaps.length > 0) {
|
for (const overlap of overlaps) {
|
||||||
// Securise le chevauchement: on demande confirmation avant suppression.
|
await deleteAbsence(overlap.id)
|
||||||
const confirmReplace = window.confirm(
|
|
||||||
"Cette absence chevauche une autre. Voulez-vous la remplacer ?"
|
|
||||||
)
|
|
||||||
if (!confirmReplace) return
|
|
||||||
for (const overlap of overlaps) {
|
|
||||||
await deleteAbsence(overlap.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingAbsence.value) {
|
if (editingAbsence.value) {
|
||||||
@@ -640,9 +398,7 @@ const handleSubmit = async () => {
|
|||||||
employeeId: Number(form.employeeId),
|
employeeId: Number(form.employeeId),
|
||||||
typeId: Number(form.typeId),
|
typeId: Number(form.typeId),
|
||||||
startDate: form.startDate,
|
startDate: form.startDate,
|
||||||
startHalf: form.startHalf,
|
|
||||||
endDate: form.endDate,
|
endDate: form.endDate,
|
||||||
endHalf: form.endHalf,
|
|
||||||
comment: form.comment
|
comment: form.comment
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -650,9 +406,7 @@ const handleSubmit = async () => {
|
|||||||
employeeId: Number(form.employeeId),
|
employeeId: Number(form.employeeId),
|
||||||
typeId: Number(form.typeId),
|
typeId: Number(form.typeId),
|
||||||
startDate: form.startDate,
|
startDate: form.startDate,
|
||||||
startHalf: form.startHalf,
|
|
||||||
endDate: form.endDate,
|
endDate: form.endDate,
|
||||||
endHalf: form.endHalf,
|
|
||||||
comment: form.comment
|
comment: form.comment
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -664,72 +418,20 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppression de l'absence en cours d'édition.
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!editingAbsence.value) return
|
if (!editingAbsence.value) return
|
||||||
|
|
||||||
const confirmDelete = window.confirm('Supprimer cette absence ?')
|
const ok = window.confirm('Supprimer cette absence ?')
|
||||||
if (!confirmDelete) return
|
if (!ok) return
|
||||||
|
|
||||||
await deleteAbsence(editingAbsence.value.id)
|
await deleteAbsence(editingAbsence.value.id)
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
await loadAbsences()
|
await loadAbsences()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Affiche "Prénom N.".
|
|
||||||
const formatEmployeeName = (employee: Employee) => {
|
const formatEmployeeName = (employee: Employee) => {
|
||||||
const initial = employee.lastName ? `${employee.lastName[0].toUpperCase()}.` : ''
|
const initial = employee.lastName ? `${employee.lastName[0].toUpperCase()}.` : ''
|
||||||
return `${employee.firstName} ${initial}`.trim()
|
return `${employee.firstName} ${initial}`.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Impression PDF de l'intervalle sélectionné.
|
|
||||||
const {printPdf} = usePdfPrinter()
|
|
||||||
const handlePrint = async () => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('from', printForm.from)
|
|
||||||
params.set('to', printForm.to)
|
|
||||||
if (printForm.siteIds.length > 0) {
|
|
||||||
params.set('sites', printForm.siteIds.join(','))
|
|
||||||
}
|
|
||||||
if (printForm.contractNatures.length > 0) {
|
|
||||||
params.set('contractNatures', printForm.contractNatures.join(','))
|
|
||||||
}
|
|
||||||
if (printForm.workContractIds.length > 0) {
|
|
||||||
params.set('workContracts', printForm.workContractIds.join(','))
|
|
||||||
}
|
|
||||||
await printPdf(`/absences/print?${params.toString()}`)
|
|
||||||
isPrintOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReorder = async (payload: { dragId: number; dropId: number }) => {
|
|
||||||
const dragEmployee = employees.value.find((employee) => employee.id === payload.dragId)
|
|
||||||
const dropEmployee = employees.value.find((employee) => employee.id === payload.dropId)
|
|
||||||
if (!dragEmployee || !dropEmployee) return
|
|
||||||
const dragSiteId = dragEmployee.site?.id
|
|
||||||
const dropSiteId = dropEmployee.site?.id
|
|
||||||
if (!dragSiteId || !dropSiteId || dragSiteId !== dropSiteId) return
|
|
||||||
|
|
||||||
const siteEmployees = [...employees.value]
|
|
||||||
.filter((employee) => employee.site?.id === dragSiteId)
|
|
||||||
.sort(compareEmployeesInSite)
|
|
||||||
|
|
||||||
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.id)
|
|
||||||
const toIndex = siteEmployees.findIndex((employee) => employee.id === dropEmployee.id)
|
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
|
|
||||||
|
|
||||||
const [moved] = siteEmployees.splice(fromIndex, 1)
|
|
||||||
siteEmployees.splice(toIndex, 0, moved)
|
|
||||||
|
|
||||||
const updates: Array<{ id: number; displayOrder: number }> = []
|
|
||||||
siteEmployees.forEach((employee, index) => {
|
|
||||||
const nextOrder = index + 1
|
|
||||||
if ((employee.displayOrder ?? 0) !== nextOrder) {
|
|
||||||
updates.push({id: employee.id, displayOrder: nextOrder})
|
|
||||||
}
|
|
||||||
employee.displayOrder = nextOrder
|
|
||||||
})
|
|
||||||
|
|
||||||
if (updates.length === 0) return
|
|
||||||
await Promise.all(updates.map((update) => updateEmployeeOrder(update.id, update.displayOrder)))
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
199
frontend/pages/employees.vue
Normal file
199
frontend/pages/employees.vue
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between pb-12">
|
||||||
|
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
@click="isDrawerOpen = true"
|
||||||
|
>
|
||||||
|
Ajouter un employé
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isLoading && employees.length === 0"
|
||||||
|
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||||
|
>
|
||||||
|
Aucun employé pour le moment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||||
|
<div class="grid grid-cols-[120px_1fr_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||||
|
<span class="text-left">Prénom</span>
|
||||||
|
<span class="text-left">Nom</span>
|
||||||
|
<span class="text-left">Site</span>
|
||||||
|
<span class="text-right">Actions</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="employee in employees"
|
||||||
|
:key="employee.id"
|
||||||
|
class="grid grid-cols-[120px_1fr_1fr_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span>{{ employee.firstName }}</span>
|
||||||
|
<span>{{ employee.lastName }}</span>
|
||||||
|
<span>{{ employee.site?.name ?? '-' }}</span>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
||||||
|
@click="openEdit(employee)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
|
||||||
|
@click="confirmDelete(employee)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="first-name">Prénom</label>
|
||||||
|
<input
|
||||||
|
id="first-name"
|
||||||
|
v-model="form.firstName"
|
||||||
|
type="text"
|
||||||
|
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-primary-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="last-name">Nom</label>
|
||||||
|
<input
|
||||||
|
id="last-name"
|
||||||
|
v-model="form.lastName"
|
||||||
|
type="text"
|
||||||
|
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-primary-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="site">Site</label>
|
||||||
|
<select
|
||||||
|
id="site"
|
||||||
|
v-model="form.siteId"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||||
|
>
|
||||||
|
<option value="">Aucun site</option>
|
||||||
|
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||||
|
{{ site.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</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="isDrawerOpen = false"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import type { Site } from '~/services/dto/site'
|
||||||
|
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
||||||
|
import { listSites } from '~/services/sites'
|
||||||
|
|
||||||
|
const isDrawerOpen = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const editingEmployee = ref<Employee | null>(null)
|
||||||
|
const drawerTitle = computed(() =>
|
||||||
|
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
||||||
|
)
|
||||||
|
|
||||||
|
const employees = ref<Employee[]>([])
|
||||||
|
const sites = ref<Site[]>([])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
siteId: '' as number | ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadEmployees = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
employees.value = await listEmployees()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
sites.value = await listSites()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadEmployees(), loadSites()])
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingEmployee.value) {
|
||||||
|
await updateEmployee(editingEmployee.value.id, {
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
siteId: form.siteId === '' ? null : Number(form.siteId)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createEmployee({
|
||||||
|
firstName: form.firstName,
|
||||||
|
lastName: form.lastName,
|
||||||
|
siteId: form.siteId === '' ? null : Number(form.siteId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
form.firstName = ''
|
||||||
|
form.lastName = ''
|
||||||
|
form.siteId = ''
|
||||||
|
editingEmployee.value = null
|
||||||
|
isDrawerOpen.value = false
|
||||||
|
await loadEmployees()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (employee: Employee) => {
|
||||||
|
editingEmployee.value = employee
|
||||||
|
form.firstName = employee.firstName
|
||||||
|
form.lastName = employee.lastName
|
||||||
|
form.siteId = employee.site?.id ?? ''
|
||||||
|
isDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (employee: Employee) => {
|
||||||
|
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
await deleteEmployee(employee.id)
|
||||||
|
await loadEmployees()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full overflow-hidden flex flex-col">
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="!employee"
|
|
||||||
class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
|
||||||
Employé introuvable.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
|
||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-12 border-b border-primary-500">
|
|
||||||
<div class="flex justify-center gap-16 text-2xl font-bold">
|
|
||||||
<button
|
|
||||||
class="pb-2 border-b-2 flex items-center gap-3"
|
|
||||||
:class="activeTab === 'contract'
|
|
||||||
? 'border-primary-500 text-primary-500'
|
|
||||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
|
||||||
@click="activeTab = 'contract'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:file-check-outline" size="24" class="align-self"/>
|
|
||||||
Suivi contrat
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="showLeaveTab"
|
|
||||||
class="pb-2 border-b-2 flex items-center gap-3"
|
|
||||||
:class="activeTab === 'leave'
|
|
||||||
? 'border-primary-500 text-primary-500'
|
|
||||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
|
||||||
@click="activeTab = 'leave'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:event-blank-outline" size="24" class="align-self"/>
|
|
||||||
Congé
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="pb-2 border-b-2 flex items-center gap-3"
|
|
||||||
:class="activeTab === 'rtt'
|
|
||||||
? 'border-primary-500 text-primary-500'
|
|
||||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
|
||||||
@click="activeTab = 'rtt'"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:schedule" size="24" class="align-self"/>
|
|
||||||
RTT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-h-0 flex-1">
|
|
||||||
<EmployeesContractTab
|
|
||||||
v-if="activeTab === 'contract'"
|
|
||||||
class="h-full overflow-y-auto pr-1"
|
|
||||||
:contract-history="contractHistory"
|
|
||||||
:contract-nature-label="contractNatureLabel"
|
|
||||||
:contract-history-label="contractHistoryLabel"
|
|
||||||
:format-date="formatDate"
|
|
||||||
:is-contract-submitting="isContractSubmitting"
|
|
||||||
:can-close-current-contract="canCloseCurrentContract"
|
|
||||||
:is-create-contract-submitting="isCreateContractSubmitting"
|
|
||||||
:contracts="contracts"
|
|
||||||
:can-create-contract="canCreateContract"
|
|
||||||
:is-contract-drawer-open="isContractDrawerOpen"
|
|
||||||
:contract-form="contractForm"
|
|
||||||
:readonly-field-class="readonlyFieldClass"
|
|
||||||
:close-contract-worked-hours-label="closeContractWorkedHoursLabel"
|
|
||||||
:contract-end-date-field-class="contractEndDateFieldClass"
|
|
||||||
:show-contract-end-date-error="showContractEndDateError"
|
|
||||||
:is-contract-end-date-valid="isContractEndDateValid"
|
|
||||||
:is-create-contract-drawer-open="isCreateContractDrawerOpen"
|
|
||||||
:create-contract-form="createContractForm"
|
|
||||||
:create-contract-nature-field-class="createContractNatureFieldClass"
|
|
||||||
:create-contract-field-class="createContractFieldClass"
|
|
||||||
:create-contract-start-date-field-class="createContractStartDateFieldClass"
|
|
||||||
:shows-create-contract-end-date="showsCreateContractEndDate"
|
|
||||||
:requires-create-contract-end-date="requiresCreateContractEndDate"
|
|
||||||
:create-contract-end-date-field-class="createContractEndDateFieldClass"
|
|
||||||
:is-create-contract-form-valid="isCreateContractFormValid"
|
|
||||||
:on-open-close-contract-drawer="openCloseContractDrawer"
|
|
||||||
:on-open-create-contract-drawer="openCreateContractDrawer"
|
|
||||||
:on-update-contract-drawer-open="setContractDrawerOpen"
|
|
||||||
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
|
|
||||||
:on-submit-close-contract="submitContractUpdate"
|
|
||||||
:on-submit-create-contract="submitCreateContract"
|
|
||||||
/>
|
|
||||||
<EmployeesLeaveTab
|
|
||||||
v-else-if="showLeaveTab && activeTab === 'leave'"
|
|
||||||
class="h-full"
|
|
||||||
:absences="employeeAbsences"
|
|
||||||
:summary="leaveSummary"
|
|
||||||
:public-holidays="publicHolidays"
|
|
||||||
@update-fractioned-days="submitFractionedDays"
|
|
||||||
/>
|
|
||||||
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const {
|
|
||||||
employee,
|
|
||||||
isLoading,
|
|
||||||
activeTab,
|
|
||||||
contracts,
|
|
||||||
employeeAbsences,
|
|
||||||
leaveSummary,
|
|
||||||
rttSummary,
|
|
||||||
publicHolidays,
|
|
||||||
showLeaveTab,
|
|
||||||
contractHistory,
|
|
||||||
employeeContractWorkLabel,
|
|
||||||
contractForm,
|
|
||||||
createContractForm,
|
|
||||||
isContractDrawerOpen,
|
|
||||||
isContractSubmitting,
|
|
||||||
isCreateContractDrawerOpen,
|
|
||||||
isCreateContractSubmitting,
|
|
||||||
canCloseCurrentContract,
|
|
||||||
canCreateContract,
|
|
||||||
readonlyFieldClass,
|
|
||||||
closeContractWorkedHoursLabel,
|
|
||||||
contractEndDateFieldClass,
|
|
||||||
showContractEndDateError,
|
|
||||||
isContractEndDateValid,
|
|
||||||
createContractNatureFieldClass,
|
|
||||||
createContractFieldClass,
|
|
||||||
createContractStartDateFieldClass,
|
|
||||||
showsCreateContractEndDate,
|
|
||||||
requiresCreateContractEndDate,
|
|
||||||
createContractEndDateFieldClass,
|
|
||||||
isCreateContractFormValid,
|
|
||||||
contractNatureLabel,
|
|
||||||
contractHistoryLabel,
|
|
||||||
formatDate,
|
|
||||||
openCloseContractDrawer,
|
|
||||||
openCreateContractDrawer,
|
|
||||||
setContractDrawerOpen,
|
|
||||||
setCreateContractDrawerOpen,
|
|
||||||
submitContractUpdate,
|
|
||||||
submitCreateContract,
|
|
||||||
submitFractionedDays,
|
|
||||||
submitRttPayment
|
|
||||||
} = useEmployeeDetailPage()
|
|
||||||
|
|
||||||
useHead(() => ({
|
|
||||||
title: employee.value
|
|
||||||
? `${employee.value.firstName} ${employee.value.lastName}`
|
|
||||||
: 'Détail employé'
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex-col">
|
|
||||||
<div class="shrink-0">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="openCreate"
|
|
||||||
>
|
|
||||||
+ Ajouter un employé
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-10 py-7">
|
|
||||||
<div class="w-80">
|
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
|
||||||
</div>
|
|
||||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!isLoading && filteredEmployees.length === 0"
|
|
||||||
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
|
||||||
>
|
|
||||||
Aucun employé pour le moment.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(260px,1fr))]">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="employee in filteredEmployees"
|
|
||||||
:key="employee.id"
|
|
||||||
:to="`/employees/${employee.id}`"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="group relative min-h-[328px] overflow-hidden rounded-lg bg-tertiary-500 p-4 transition-all duration-200 hover:shadow-md"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col items-center gap-7 transition-opacity duration-200 group-hover:opacity-0">
|
|
||||||
<div class="rounded-full bg-primary-500 h-[175px] w-[175px] flex justify-center items-center text-white font-bold text-5xl">{{ employee.initials}}</div>
|
|
||||||
<div class="text-center text-[20px]">
|
|
||||||
<p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p>
|
|
||||||
<p>Nom du poste occupé</p>
|
|
||||||
<p>Site ({{ employee.site?.name ?? '-' }})</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
|
||||||
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
|
|
||||||
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
|
|
||||||
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
|
||||||
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
|
||||||
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="first-name">
|
|
||||||
Prénom <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="first-name"
|
|
||||||
v-model="form.firstName"
|
|
||||||
type="text"
|
|
||||||
:class="firstNameFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le prénom est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="last-name">
|
|
||||||
Nom <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="last-name"
|
|
||||||
v-model="form.lastName"
|
|
||||||
type="text"
|
|
||||||
:class="lastNameFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le nom est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="site">
|
|
||||||
Site <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="site"
|
|
||||||
v-model="form.siteId"
|
|
||||||
:class="siteFieldClass"
|
|
||||||
>
|
|
||||||
<option value="">Aucun site</option>
|
|
||||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
|
||||||
{{ site.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le site est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<template v-if="!editingEmployee">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
|
||||||
Type de contrat <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="contract-nature"
|
|
||||||
v-model="form.contractNature"
|
|
||||||
:class="contractNatureFieldClass"
|
|
||||||
>
|
|
||||||
<option value="CDI">CDI</option>
|
|
||||||
<option value="CDD">CDD</option>
|
|
||||||
<option value="INTERIM">Intérim</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le type de contrat est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract">
|
|
||||||
Temps de travail <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="contract"
|
|
||||||
v-model="form.contractId"
|
|
||||||
:class="contractFieldClass"
|
|
||||||
>
|
|
||||||
<option value="">Sélectionner un contrat</option>
|
|
||||||
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
|
||||||
{{ contract.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le temps de travail est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
|
||||||
Début contrat <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contract-start-date"
|
|
||||||
v-model="form.contractStartDate"
|
|
||||||
type="date"
|
|
||||||
:class="contractStartDateFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
|
|
||||||
La date de début est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="showsContractEndDateComputed">
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
|
||||||
Fin contrat
|
|
||||||
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contract-end-date"
|
|
||||||
v-model="form.contractEndDate"
|
|
||||||
type="date"
|
|
||||||
:class="contractEndDateFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
|
|
||||||
La date de fin est obligatoire pour un CDD.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="isDrawerOpen = false"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:class="submitButtonClass"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type {Contract} from '~/services/dto/contract'
|
|
||||||
import type {Employee} from '~/services/dto/employee'
|
|
||||||
import type {Site} from '~/services/dto/site'
|
|
||||||
import {listContracts} from '~/services/contracts'
|
|
||||||
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
|
|
||||||
import {listSites} from '~/services/sites'
|
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
|
||||||
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Employés'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const sitesInitialized = ref(false)
|
|
||||||
const editingEmployee = ref<Employee | null>(null)
|
|
||||||
const drawerTitle = computed(() =>
|
|
||||||
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
|
|
||||||
)
|
|
||||||
|
|
||||||
const employees = ref<Employee[]>([])
|
|
||||||
const sites = ref<Site[]>([])
|
|
||||||
const contracts = ref<Contract[]>([])
|
|
||||||
const employeeFilter = ref('')
|
|
||||||
const selectedSiteIds = ref<number[]>([])
|
|
||||||
|
|
||||||
const filteredEmployees = computed<Employee[]>(() => {
|
|
||||||
if (selectedSiteIds.value.length === 0) return []
|
|
||||||
|
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
|
||||||
const bySite = employees.value.filter((employee) => {
|
|
||||||
const siteId = employee.site?.id
|
|
||||||
return !!siteId && selectedSiteIds.value.includes(siteId)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!filter) return bySite
|
|
||||||
|
|
||||||
return bySite.filter((employee) => {
|
|
||||||
const firstName = employee.firstName?.toLowerCase() ?? ''
|
|
||||||
const lastName = employee.lastName?.toLowerCase() ?? ''
|
|
||||||
return firstName.includes(filter) || lastName.includes(filter)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
siteId: '' as number | '',
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
contractStartDate: '',
|
|
||||||
contractEndDate: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
firstName: false,
|
|
||||||
lastName: false,
|
|
||||||
siteId: false,
|
|
||||||
contractId: false,
|
|
||||||
contractNature: false,
|
|
||||||
contractStartDate: false,
|
|
||||||
contractEndDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
|
||||||
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
|
||||||
const isSiteValid = computed(() => form.siteId !== '')
|
|
||||||
const isContractValid = computed(() => form.contractId !== '')
|
|
||||||
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
|
|
||||||
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
|
|
||||||
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
|
|
||||||
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
|
|
||||||
const isContractEndDateValid = computed(() => {
|
|
||||||
if (!requiresContractEndDateComputed.value) return true
|
|
||||||
return form.contractEndDate !== ''
|
|
||||||
})
|
|
||||||
const isFormValid = computed(
|
|
||||||
() =>
|
|
||||||
isFirstNameValid.value &&
|
|
||||||
isLastNameValid.value &&
|
|
||||||
isSiteValid.value &&
|
|
||||||
(editingEmployee.value
|
|
||||||
? true
|
|
||||||
: (isContractValid.value &&
|
|
||||||
isContractNatureValid.value &&
|
|
||||||
isContractStartDateValid.value &&
|
|
||||||
isContractEndDateValid.value))
|
|
||||||
)
|
|
||||||
|
|
||||||
const showFirstNameError = computed(
|
|
||||||
() => validationTouched.firstName && !isFirstNameValid.value
|
|
||||||
)
|
|
||||||
const showLastNameError = computed(
|
|
||||||
() => validationTouched.lastName && !isLastNameValid.value
|
|
||||||
)
|
|
||||||
const showSiteError = computed(
|
|
||||||
() => validationTouched.siteId && !isSiteValid.value
|
|
||||||
)
|
|
||||||
const showContractError = computed(
|
|
||||||
() => validationTouched.contractId && !isContractValid.value
|
|
||||||
)
|
|
||||||
const showContractNatureError = computed(
|
|
||||||
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
|
|
||||||
)
|
|
||||||
const showContractStartDateError = computed(
|
|
||||||
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
|
|
||||||
)
|
|
||||||
const showContractEndDateError = computed(
|
|
||||||
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const baseInputClass =
|
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
|
||||||
const firstNameFieldClass = computed(() => {
|
|
||||||
if (showFirstNameError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const lastNameFieldClass = computed(() => {
|
|
||||||
if (showLastNameError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const siteFieldClass = computed(() => {
|
|
||||||
const baseSelectClass =
|
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
if (showSiteError.value) {
|
|
||||||
return `${baseSelectClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseSelectClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractFieldClass = computed(() => {
|
|
||||||
const baseClass =
|
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
if (showContractError.value) {
|
|
||||||
return `${baseClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractNatureFieldClass = computed(() => {
|
|
||||||
const baseClass =
|
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
if (showContractNatureError.value) {
|
|
||||||
return `${baseClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractStartDateFieldClass = computed(() => {
|
|
||||||
if (showContractStartDateError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const contractEndDateFieldClass = computed(() => {
|
|
||||||
if (showContractEndDateError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
|
||||||
if (isSubmitting.value || !isFormValid.value) {
|
|
||||||
return 'opacity-50 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadEmployees = async () => {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
employees.value = await listEmployees()
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadSites = async () => {
|
|
||||||
sites.value = await listSites()
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadContracts = async () => {
|
|
||||||
contracts.value = await listContracts()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
|
||||||
if (form.contractStartDate === '') {
|
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(sites, (nextSites) => {
|
|
||||||
const currentSiteIds = nextSites.map((site) => site.id)
|
|
||||||
|
|
||||||
if (!sitesInitialized.value) {
|
|
||||||
if (currentSiteIds.length === 0) return
|
|
||||||
selectedSiteIds.value = currentSiteIds
|
|
||||||
sitesInitialized.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
|
|
||||||
}, {immediate: true})
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (isSubmitting.value) return
|
|
||||||
validationTouched.firstName = true
|
|
||||||
validationTouched.lastName = true
|
|
||||||
validationTouched.siteId = true
|
|
||||||
if (!editingEmployee.value) {
|
|
||||||
validationTouched.contractId = true
|
|
||||||
validationTouched.contractNature = true
|
|
||||||
validationTouched.contractStartDate = true
|
|
||||||
validationTouched.contractEndDate = true
|
|
||||||
}
|
|
||||||
if (!isFormValid.value) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
if (editingEmployee.value) {
|
|
||||||
await updateEmployee(editingEmployee.value.id, {
|
|
||||||
firstName: form.firstName,
|
|
||||||
lastName: form.lastName,
|
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId),
|
|
||||||
contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await createEmployee({
|
|
||||||
firstName: form.firstName,
|
|
||||||
lastName: form.lastName,
|
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId),
|
|
||||||
contractId: Number(form.contractId),
|
|
||||||
contractNature: form.contractNature,
|
|
||||||
contractStartDate: form.contractStartDate,
|
|
||||||
contractEndDate: form.contractEndDate || null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
form.firstName = ''
|
|
||||||
form.lastName = ''
|
|
||||||
form.siteId = ''
|
|
||||||
form.contractId = ''
|
|
||||||
form.contractNature = 'CDI'
|
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
|
||||||
form.contractEndDate = ''
|
|
||||||
editingEmployee.value = null
|
|
||||||
isDrawerOpen.value = false
|
|
||||||
await loadEmployees()
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(isDrawerOpen, (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
validationTouched.firstName = false
|
|
||||||
validationTouched.lastName = false
|
|
||||||
validationTouched.siteId = false
|
|
||||||
validationTouched.contractId = false
|
|
||||||
validationTouched.contractNature = false
|
|
||||||
validationTouched.contractStartDate = false
|
|
||||||
validationTouched.contractEndDate = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(showsContractEndDateComputed, (shows) => {
|
|
||||||
if (!shows) {
|
|
||||||
form.contractEndDate = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const openEdit = (employee: Employee) => {
|
|
||||||
editingEmployee.value = employee
|
|
||||||
form.firstName = employee.firstName
|
|
||||||
form.lastName = employee.lastName
|
|
||||||
form.siteId = employee.site?.id ?? ''
|
|
||||||
isDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
editingEmployee.value = null
|
|
||||||
form.firstName = ''
|
|
||||||
form.lastName = ''
|
|
||||||
form.siteId = ''
|
|
||||||
form.contractId = ''
|
|
||||||
form.contractNature = 'CDI'
|
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
|
||||||
form.contractEndDate = ''
|
|
||||||
isDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = async (employee: Employee) => {
|
|
||||||
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
|
|
||||||
if (!ok) return
|
|
||||||
|
|
||||||
await deleteEmployee(employee.id)
|
|
||||||
await loadEmployees()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-full overflow-hidden flex flex-col">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HoursToolbar
|
|
||||||
v-model:selected-date="selectedDate"
|
|
||||||
v-model:view-mode="viewMode"
|
|
||||||
v-model:selected-site-ids="selectedSiteIds"
|
|
||||||
v-model:employee-filter="employeeFilter"
|
|
||||||
:is-admin="isAdmin"
|
|
||||||
:sites="sites"
|
|
||||||
:absence-types="absenceTypes"
|
|
||||||
:formatted-selected-date="formattedSelectedDate"
|
|
||||||
:shortcut-button-class="shortcutButtonClass"
|
|
||||||
:week-shortcut-button-class="weekShortcutButtonClass"
|
|
||||||
:get-week-shortcut-label="getWeekShortcutLabel"
|
|
||||||
@set-yesterday="setYesterday"
|
|
||||||
@set-today="setToday"
|
|
||||||
@set-tomorrow="setTomorrow"
|
|
||||||
@set-previous-week="setPreviousWeek"
|
|
||||||
@set-this-week="setThisWeek"
|
|
||||||
@set-next-week="setNextWeek"
|
|
||||||
@shift-date="shiftDate"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="employees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
|
||||||
Aucun employé accessible.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex min-h-0 flex-col gap-4">
|
|
||||||
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
|
|
||||||
<HoursDayView
|
|
||||||
v-if="viewMode === 'day'"
|
|
||||||
v-model:rows="rows"
|
|
||||||
:employees="visibleEmployees"
|
|
||||||
:is-admin="isAdmin"
|
|
||||||
:is-site-manager="isSiteManager"
|
|
||||||
:day-grid-cols="dayGridCols"
|
|
||||||
:is-holiday="isSelectedDateHoliday"
|
|
||||||
:contract-label="contractLabel"
|
|
||||||
:is-time-tracking="isTimeTracking"
|
|
||||||
:is-presence-tracking="isPresenceTracking"
|
|
||||||
:is-row-locked="isRowLocked"
|
|
||||||
:is-half-locked-by-absence="isHalfLockedByAbsence"
|
|
||||||
:is-evening-locked-by-absence="isEveningLockedByAbsence"
|
|
||||||
:has-contract-at-selected-date="hasContractAtSelectedDate"
|
|
||||||
:is-validation-pending="isValidationPending"
|
|
||||||
:is-site-validation-pending="isSiteValidationPending"
|
|
||||||
:can-toggle-validation="canToggleValidation"
|
|
||||||
:can-toggle-site-validation="canToggleSiteValidation"
|
|
||||||
:can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
|
|
||||||
:is-bulk-validation-checked="isBulkValidationChecked"
|
|
||||||
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
|
||||||
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
|
|
||||||
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
|
|
||||||
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
|
|
||||||
:on-toggle-validation="toggleValidation"
|
|
||||||
:on-toggle-site-validation="toggleSiteValidation"
|
|
||||||
:on-toggle-validation-bulk="toggleValidationBulk"
|
|
||||||
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
|
|
||||||
:get-row-metrics="getRowMetrics"
|
|
||||||
:get-row-absence-label="getRowAbsenceLabel"
|
|
||||||
:get-row-absence-style="getRowAbsenceStyle"
|
|
||||||
:get-row-updated-at="getRowUpdatedAt"
|
|
||||||
:get-presence-day-value="getPresenceDayValue"
|
|
||||||
:on-absence-click="openAbsenceDrawer"
|
|
||||||
:format-minutes="formatMinutes"
|
|
||||||
class="max-h-[calc(100vh-300px)]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HoursWeekView
|
|
||||||
v-else-if="isAdmin && viewMode === 'week'"
|
|
||||||
:is-week-loading="isWeekLoading"
|
|
||||||
:week-grid-cols="weekGridCols"
|
|
||||||
:weekly-summary="filteredWeeklySummary"
|
|
||||||
:week-day-headers="weekDayHeaders"
|
|
||||||
:format-minutes="formatMinutes"
|
|
||||||
class="max-h-[calc(100vh-300px)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:class="saveButtonClass"
|
|
||||||
:disabled="isSubmitting || visibleEmployees.length === 0"
|
|
||||||
@click="handleSave"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AbsenceFormDrawer
|
|
||||||
v-model="isAbsenceDrawerOpen"
|
|
||||||
:employees="employees"
|
|
||||||
:absence-types="absenceTypes"
|
|
||||||
:form="absenceForm"
|
|
||||||
:editing-absence="editingAbsence"
|
|
||||||
:is-submitting="isAbsenceSubmitting"
|
|
||||||
:lock-employee="true"
|
|
||||||
:lock-dates="true"
|
|
||||||
:show-comment="false"
|
|
||||||
@submit="submitAbsence"
|
|
||||||
@delete="deleteAbsenceFromDrawer"
|
|
||||||
@cancel="closeAbsenceDrawer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const {
|
|
||||||
isAdmin,
|
|
||||||
isSiteManager,
|
|
||||||
viewMode,
|
|
||||||
selectedDate,
|
|
||||||
employeeFilter,
|
|
||||||
sites,
|
|
||||||
selectedSiteIds,
|
|
||||||
employees,
|
|
||||||
visibleEmployees,
|
|
||||||
rows,
|
|
||||||
absenceTypes,
|
|
||||||
absenceForm,
|
|
||||||
isAbsenceDrawerOpen,
|
|
||||||
isAbsenceSubmitting,
|
|
||||||
editingAbsence,
|
|
||||||
filteredWeeklySummary,
|
|
||||||
isLoading,
|
|
||||||
isWeekLoading,
|
|
||||||
isSubmitting,
|
|
||||||
dayGridCols,
|
|
||||||
isSelectedDateHoliday,
|
|
||||||
weekGridCols,
|
|
||||||
saveButtonClass,
|
|
||||||
formattedSelectedDate,
|
|
||||||
weekDayHeaders,
|
|
||||||
shortcutButtonClass,
|
|
||||||
weekShortcutButtonClass,
|
|
||||||
getWeekShortcutLabel,
|
|
||||||
setToday,
|
|
||||||
setYesterday,
|
|
||||||
setTomorrow,
|
|
||||||
setThisWeek,
|
|
||||||
setPreviousWeek,
|
|
||||||
setNextWeek,
|
|
||||||
shiftDate,
|
|
||||||
contractLabel,
|
|
||||||
isTimeTracking,
|
|
||||||
isPresenceTracking,
|
|
||||||
isRowLocked,
|
|
||||||
isHalfLockedByAbsence,
|
|
||||||
isEveningLockedByAbsence,
|
|
||||||
hasContractAtSelectedDate,
|
|
||||||
isValidationPending,
|
|
||||||
isSiteValidationPending,
|
|
||||||
canToggleValidation,
|
|
||||||
canToggleSiteValidation,
|
|
||||||
canCreateSiteValidationRowFromAbsence,
|
|
||||||
isBulkValidationChecked,
|
|
||||||
isBulkValidationIndeterminate,
|
|
||||||
isBulkSiteValidationChecked,
|
|
||||||
isBulkSiteValidationIndeterminate,
|
|
||||||
canBulkToggleSiteValidation,
|
|
||||||
toggleValidation,
|
|
||||||
toggleSiteValidation,
|
|
||||||
toggleValidationBulk,
|
|
||||||
toggleSiteValidationBulk,
|
|
||||||
getRowMetrics,
|
|
||||||
getRowAbsenceLabel,
|
|
||||||
getRowAbsenceStyle,
|
|
||||||
getRowUpdatedAt,
|
|
||||||
getPresenceDayValue,
|
|
||||||
openAbsenceDrawer,
|
|
||||||
submitAbsence,
|
|
||||||
deleteAbsenceFromDrawer,
|
|
||||||
closeAbsenceDrawer,
|
|
||||||
formatMinutes,
|
|
||||||
handleSave
|
|
||||||
} = useHoursPage()
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Heures'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -3,7 +3,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({
|
|
||||||
title: 'Tableau de bord'
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,76 +1,72 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mx-auto w-full max-w-lg">
|
<div class="mx-auto w-full max-w-lg">
|
||||||
<span
|
<span
|
||||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||||
>
|
>
|
||||||
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
|
LOGO
|
||||||
</span>
|
</span>
|
||||||
<form
|
<form
|
||||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-semibold text-neutral-700" for="username">
|
<label class="text-sm font-semibold text-neutral-700" for="username">
|
||||||
Nom d'utilisateur
|
Nom d'utilisateur
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
v-model="username"
|
v-model="username"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
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"
|
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-primary-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||||
Mot de passe
|
Mot de passe
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
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"
|
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-primary-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
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"
|
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
>
|
||||||
Se connecter
|
Se connecter
|
||||||
</button>
|
</button>
|
||||||
<p class="font-bold">v{{ version }}</p>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({layout: 'auth'})
|
definePageMeta({ layout: 'auth' })
|
||||||
useHead({
|
|
||||||
title: 'Connexion'
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const { version } = useAppVersion()
|
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
console.log(useRuntimeConfig().public.apiBase)
|
||||||
|
await auth.login(username.value, password.value)
|
||||||
|
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -32,15 +32,8 @@
|
|||||||
v-for="site in sites"
|
v-for="site in sites"
|
||||||
:key="site.id"
|
:key="site.id"
|
||||||
class="grid grid-cols-[1fr_140px_160px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
class="grid grid-cols-[1fr_140px_160px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
draggable="true"
|
|
||||||
@dragstart="handleDragStart($event, site)"
|
|
||||||
@dragover="handleDragOver"
|
|
||||||
@drop="handleDrop($event, site)"
|
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-2 text-left cursor-pointer">
|
<span class="text-left">{{ site.name }}</span>
|
||||||
<span class="select-none text-xs">::</span>
|
|
||||||
<span>{{ site.name }}</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-2 justify-start">
|
<div class="flex items-center gap-2 justify-start">
|
||||||
<span
|
<span
|
||||||
class="inline-block h-3 w-3 rounded-full"
|
class="inline-block h-3 w-3 rounded-full"
|
||||||
@@ -71,23 +64,16 @@
|
|||||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="name">
|
<label class="text-md font-semibold text-neutral-700" for="name">Nom</label>
|
||||||
Nom <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
type="text"
|
type="text"
|
||||||
:class="nameFieldClass"
|
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-primary-200"
|
||||||
/>
|
/>
|
||||||
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le nom du site est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="color">
|
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
|
||||||
Couleur <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<div class="mt-2 flex items-center gap-3">
|
<div class="mt-2 flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
id="color"
|
id="color"
|
||||||
@@ -109,7 +95,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
:class="submitButtonClass"
|
:disabled="isSubmitting"
|
||||||
>
|
>
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -121,16 +107,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
import { createSite, deleteSite, listSites, updateSite } from '~/services/sites'
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Sites'
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isReordering = ref(false)
|
|
||||||
|
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const editingSite = ref<Site | null>(null)
|
const editingSite = ref<Site | null>(null)
|
||||||
@@ -144,31 +125,6 @@ const form = reactive({
|
|||||||
color: '#222783'
|
color: '#222783'
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
name: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isNameValid = computed(() => form.name.trim() !== '')
|
|
||||||
const isFormValid = computed(() => isNameValid.value)
|
|
||||||
|
|
||||||
const showNameError = computed(() => validationTouched.name && !isNameValid.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 nameFieldClass = computed(() => {
|
|
||||||
if (showNameError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
|
||||||
if (isSubmitting.value || !isFormValid.value) {
|
|
||||||
return 'opacity-50 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadSites = async () => {
|
const loadSites = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -206,8 +162,6 @@ const closeDrawer = () => {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
validationTouched.name = true
|
|
||||||
if (!isFormValid.value) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -219,8 +173,7 @@ const handleSubmit = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await createSite({
|
await createSite({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
color: form.color,
|
color: form.color
|
||||||
displayOrder: sites.value.length + 1
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,12 +184,6 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(isDrawerOpen, (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
validationTouched.name = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const confirmDelete = async (site: Site) => {
|
const confirmDelete = async (site: Site) => {
|
||||||
const ok = window.confirm(`Supprimer le site ${site.name} ?`)
|
const ok = window.confirm(`Supprimer le site ${site.name} ?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
@@ -244,52 +191,4 @@ const confirmDelete = async (site: Site) => {
|
|||||||
await deleteSite(site.id)
|
await deleteSite(site.id)
|
||||||
await loadSites()
|
await loadSites()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (event: DragEvent, site: Site) => {
|
|
||||||
if (isReordering.value || !event.dataTransfer) return
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
|
||||||
event.dataTransfer.setData('text/plain', String(site.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = async (event: DragEvent, site: Site) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (isReordering.value) return
|
|
||||||
|
|
||||||
const dragId = Number(event.dataTransfer?.getData('text/plain'))
|
|
||||||
if (!dragId || dragId === site.id) return
|
|
||||||
|
|
||||||
const fromIndex = sites.value.findIndex((item) => item.id === dragId)
|
|
||||||
const toIndex = sites.value.findIndex((item) => item.id === site.id)
|
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
|
|
||||||
|
|
||||||
const reordered = [...sites.value]
|
|
||||||
const [moved] = reordered.splice(fromIndex, 1)
|
|
||||||
reordered.splice(toIndex, 0, moved)
|
|
||||||
|
|
||||||
const updates: Array<{ id: number; displayOrder: number }> = []
|
|
||||||
reordered.forEach((item, index) => {
|
|
||||||
const nextOrder = index + 1
|
|
||||||
if ((item.displayOrder ?? 0) !== nextOrder) {
|
|
||||||
updates.push({ id: item.id, displayOrder: nextOrder })
|
|
||||||
}
|
|
||||||
item.displayOrder = nextOrder
|
|
||||||
})
|
|
||||||
|
|
||||||
sites.value = reordered
|
|
||||||
if (updates.length === 0) return
|
|
||||||
|
|
||||||
isReordering.value = true
|
|
||||||
try {
|
|
||||||
await Promise.all(updates.map((update) => updateSiteOrder(update.id, update.displayOrder)))
|
|
||||||
} catch {
|
|
||||||
window.alert("Impossible de reordonner les sites.")
|
|
||||||
await loadSites()
|
|
||||||
} finally {
|
|
||||||
isReordering.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,467 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between pb-12">
|
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="openCreate"
|
|
||||||
>
|
|
||||||
Ajouter un utilisateur
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!isLoading && users.length === 0"
|
|
||||||
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
|
||||||
>
|
|
||||||
Aucun utilisateur pour le moment.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
|
||||||
<div class="grid grid-cols-[1fr_1fr_140px_1fr_140px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
|
||||||
<span class="text-left">Utilisateur</span>
|
|
||||||
<span class="text-left">Employé</span>
|
|
||||||
<span class="text-left">Accès</span>
|
|
||||||
<span class="text-left">Sites</span>
|
|
||||||
<span class="text-right">Actions</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
class="grid grid-cols-[1fr_1fr_140px_1fr_140px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span class="text-left">{{ user.username }}</span>
|
|
||||||
<span class="text-left">
|
|
||||||
{{ user.employee ? `${user.employee.firstName} ${user.employee.lastName}` : '-' }}
|
|
||||||
</span>
|
|
||||||
<span class="text-left text-sm text-neutral-600">
|
|
||||||
{{ getAccessLabel(user) }}
|
|
||||||
</span>
|
|
||||||
<span class="text-left text-sm text-neutral-600">
|
|
||||||
{{ getSiteLabels(user) }}
|
|
||||||
</span>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="openEdit(user)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppDrawer
|
|
||||||
v-model="isDrawerOpen"
|
|
||||||
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
|
|
||||||
>
|
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="username">
|
|
||||||
Nom d'utilisateur <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
v-model="form.username"
|
|
||||||
type="text"
|
|
||||||
:class="usernameFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le nom d'utilisateur est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="password">
|
|
||||||
Mot de passe
|
|
||||||
<span v-if="!editingUser" class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
:class="passwordFieldClass"
|
|
||||||
/>
|
|
||||||
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
|
|
||||||
Laisse vide pour ne pas changer le mot de passe.
|
|
||||||
</p>
|
|
||||||
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
|
|
||||||
Le mot de passe est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="text-md font-semibold text-neutral-700">Accès</p>
|
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
|
||||||
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
|
|
||||||
@click="selectAccessMode('admin')"
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
|
||||||
:class="form.accessMode === 'self' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
|
|
||||||
@click="selectAccessMode('self')"
|
|
||||||
>
|
|
||||||
Accès personnel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
|
||||||
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
|
|
||||||
@click="selectAccessMode('sites')"
|
|
||||||
>
|
|
||||||
Sites
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-neutral-500">
|
|
||||||
{{
|
|
||||||
form.accessMode === 'admin'
|
|
||||||
? 'Donne accès à tout.'
|
|
||||||
: form.accessMode === 'self'
|
|
||||||
? "Donne accès uniquement à ses propres données."
|
|
||||||
: 'Donne accès aux employés des sites sélectionnés.'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="form.accessMode === 'self'">
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="employee">
|
|
||||||
Employé lié
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="employee"
|
|
||||||
v-model="form.employeeId"
|
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
|
||||||
>
|
|
||||||
<option value="">Aucun</option>
|
|
||||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
|
||||||
{{ employee.firstName }} {{ employee.lastName }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
|
|
||||||
Sélectionne un employé.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="form.accessMode === 'sites'">
|
|
||||||
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
|
|
||||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
|
||||||
<label
|
|
||||||
v-for="site in sites"
|
|
||||||
:key="site.id"
|
|
||||||
class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="cursor-pointer"
|
|
||||||
:checked="form.siteIds.includes(site.id)"
|
|
||||||
@change="toggleSite(site.id)"
|
|
||||||
/>
|
|
||||||
<span>{{ site.name }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p v-if="showSitesError" class="mt-1 text-sm text-red-600">
|
|
||||||
Sélectionne au moins un site.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
||||||
@click="closeDrawer"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:class="submitButtonClass"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Employee } from '~/services/dto/employee'
|
|
||||||
import type { Site } from '~/services/dto/site'
|
|
||||||
import type { User } from '~/services/dto/user'
|
|
||||||
import type { UserSiteRole } from '~/services/user-site-roles'
|
|
||||||
import { listEmployees } from '~/services/employees'
|
|
||||||
import { listSites } from '~/services/sites'
|
|
||||||
import { createUser, listUsers, updateUser } from '~/services/users'
|
|
||||||
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
|
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
|
||||||
useHead({
|
|
||||||
title: 'Utilisateurs'
|
|
||||||
})
|
|
||||||
|
|
||||||
const users = ref<User[]>([])
|
|
||||||
const employees = ref<Employee[]>([])
|
|
||||||
const sites = ref<Site[]>([])
|
|
||||||
const userSiteRoles = ref<UserSiteRole[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const isDrawerOpen = ref(false)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
const editingUser = ref<User | null>(null)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
|
||||||
employeeId: '' as number | '',
|
|
||||||
siteIds: [] as number[]
|
|
||||||
})
|
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
username: false,
|
|
||||||
password: false,
|
|
||||||
sites: false,
|
|
||||||
selfEmployee: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isUsernameValid = computed(() => form.username.trim() !== '')
|
|
||||||
const isPasswordValid = computed(() =>
|
|
||||||
editingUser.value ? true : form.password.trim() !== ''
|
|
||||||
)
|
|
||||||
const isFormValid = computed(() => isUsernameValid.value && isPasswordValid.value)
|
|
||||||
const isSitesValid = computed(() => form.siteIds.length > 0)
|
|
||||||
const isSelfEmployeeValid = computed(() => form.employeeId !== '')
|
|
||||||
|
|
||||||
const showUsernameError = computed(
|
|
||||||
() => validationTouched.username && !isUsernameValid.value
|
|
||||||
)
|
|
||||||
const showPasswordError = computed(
|
|
||||||
() => validationTouched.password && !isPasswordValid.value
|
|
||||||
)
|
|
||||||
const showSitesError = computed(
|
|
||||||
() => validationTouched.sites && form.accessMode === 'sites' && !isSitesValid.value
|
|
||||||
)
|
|
||||||
const showSelfEmployeeError = computed(
|
|
||||||
() =>
|
|
||||||
validationTouched.selfEmployee &&
|
|
||||||
form.accessMode === 'self' &&
|
|
||||||
!isSelfEmployeeValid.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const userAccessById = computed(() => {
|
|
||||||
const rolesByUser = new Map<number, UserSiteRole[]>()
|
|
||||||
for (const role of userSiteRoles.value) {
|
|
||||||
const userId = role.user?.id
|
|
||||||
if (!userId) continue
|
|
||||||
const list = rolesByUser.get(userId) ?? []
|
|
||||||
list.push(role)
|
|
||||||
rolesByUser.set(userId, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rolesByUser
|
|
||||||
})
|
|
||||||
|
|
||||||
const getAccessLabel = (user: User) => {
|
|
||||||
if (user.roles.includes('ROLE_ADMIN')) return 'Admin'
|
|
||||||
if (user.roles.includes('ROLE_SELF')) return 'Self'
|
|
||||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
|
||||||
return siteRoles.length > 0 ? 'Sites' : 'Aucun'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSiteLabels = (user: User) => {
|
|
||||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
|
||||||
if (siteRoles.length === 0) return '-'
|
|
||||||
const names = siteRoles
|
|
||||||
.map((role) => role.site?.name)
|
|
||||||
.filter((name): name is string => Boolean(name))
|
|
||||||
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
|
|
||||||
}
|
|
||||||
|
|
||||||
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 usernameFieldClass = computed(() => {
|
|
||||||
if (showUsernameError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const passwordFieldClass = computed(() => {
|
|
||||||
if (showPasswordError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
|
||||||
if (isSubmitting.value || !isFormValid.value) {
|
|
||||||
return 'opacity-50 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const [usersData, employeesData, sitesData, userSiteRolesData] = await Promise.all([
|
|
||||||
listUsers(),
|
|
||||||
listEmployees(),
|
|
||||||
listSites(),
|
|
||||||
listUserSiteRoles()
|
|
||||||
])
|
|
||||||
users.value = usersData
|
|
||||||
employees.value = employeesData
|
|
||||||
sites.value = sitesData
|
|
||||||
userSiteRoles.value = userSiteRolesData
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadData)
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
form.username = ''
|
|
||||||
form.password = ''
|
|
||||||
form.employeeId = ''
|
|
||||||
form.accessMode = 'admin'
|
|
||||||
form.siteIds = []
|
|
||||||
editingUser.value = null
|
|
||||||
validationTouched.username = false
|
|
||||||
validationTouched.password = false
|
|
||||||
validationTouched.sites = false
|
|
||||||
validationTouched.selfEmployee = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
resetForm()
|
|
||||||
isDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEdit = (user: User) => {
|
|
||||||
resetForm()
|
|
||||||
editingUser.value = user
|
|
||||||
form.username = user.username
|
|
||||||
form.password = ''
|
|
||||||
|
|
||||||
if (user.roles.includes('ROLE_ADMIN')) {
|
|
||||||
selectAccessMode('admin')
|
|
||||||
} else if (user.roles.includes('ROLE_SELF')) {
|
|
||||||
selectAccessMode('self')
|
|
||||||
} else {
|
|
||||||
selectAccessMode('sites')
|
|
||||||
}
|
|
||||||
|
|
||||||
form.employeeId = user.employee?.id ?? ''
|
|
||||||
|
|
||||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
|
||||||
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
|
|
||||||
isDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDrawer = () => {
|
|
||||||
isDrawerOpen.value = false
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAccessMode = (mode: 'admin' | 'self' | 'sites') => {
|
|
||||||
form.accessMode = mode
|
|
||||||
if (mode !== 'sites') {
|
|
||||||
form.siteIds = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSite = (siteId: number) => {
|
|
||||||
if (form.siteIds.includes(siteId)) {
|
|
||||||
form.siteIds = form.siteIds.filter((existing) => existing !== siteId)
|
|
||||||
} else {
|
|
||||||
form.siteIds = [...form.siteIds, siteId]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (isSubmitting.value) return
|
|
||||||
validationTouched.username = true
|
|
||||||
validationTouched.password = true
|
|
||||||
validationTouched.sites = true
|
|
||||||
validationTouched.selfEmployee = true
|
|
||||||
if (!isFormValid.value) return
|
|
||||||
if (form.accessMode === 'sites' && !isSitesValid.value) return
|
|
||||||
if (form.accessMode === 'self' && !isSelfEmployeeValid.value) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const roles =
|
|
||||||
form.accessMode === 'admin'
|
|
||||||
? ['ROLE_ADMIN']
|
|
||||||
: form.accessMode === 'self'
|
|
||||||
? ['ROLE_SELF']
|
|
||||||
: []
|
|
||||||
|
|
||||||
const employeeId =
|
|
||||||
form.accessMode === 'self' ? (form.employeeId === '' ? null : Number(form.employeeId)) : null
|
|
||||||
|
|
||||||
if (editingUser.value) {
|
|
||||||
await updateUser(editingUser.value.id, {
|
|
||||||
username: form.username,
|
|
||||||
plainPassword: form.password.trim() ? form.password : undefined,
|
|
||||||
roles,
|
|
||||||
employeeId
|
|
||||||
})
|
|
||||||
|
|
||||||
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
|
||||||
if (existingSiteRoles.length > 0) {
|
|
||||||
await Promise.all(existingSiteRoles.map((role) => deleteUserSiteRole(role.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
|
||||||
await Promise.all(
|
|
||||||
form.siteIds.map((siteId) =>
|
|
||||||
createUserSiteRole({
|
|
||||||
userId: editingUser.value!.id,
|
|
||||||
siteId,
|
|
||||||
role: 'SITE_ACCESS'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const created = await createUser({
|
|
||||||
username: form.username,
|
|
||||||
plainPassword: form.password,
|
|
||||||
roles,
|
|
||||||
employeeId
|
|
||||||
})
|
|
||||||
|
|
||||||
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
|
||||||
await Promise.all(
|
|
||||||
form.siteIds.map((siteId) =>
|
|
||||||
createUserSiteRole({
|
|
||||||
userId: created.id,
|
|
||||||
siteId,
|
|
||||||
role: 'SITE_ACCESS'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDrawer()
|
|
||||||
await loadData()
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -12,7 +12,7 @@ export const listAbsenceTypes = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createAbsenceType = async (
|
export const createAbsenceType = async (
|
||||||
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
|
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<AbsenceType>('/absence_types', payload, {
|
return api.post<AbsenceType>('/absence_types', payload, {
|
||||||
@@ -23,7 +23,7 @@ export const createAbsenceType = async (
|
|||||||
|
|
||||||
export const updateAbsenceType = async (
|
export const updateAbsenceType = async (
|
||||||
id: number,
|
id: number,
|
||||||
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
|
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {
|
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {
|
||||||
|
|||||||
@@ -1,32 +1,11 @@
|
|||||||
import type { Absence } from './dto/absence'
|
import type { Absence } from './dto/absence'
|
||||||
import type { HalfDay } from './dto/half-day'
|
|
||||||
import { extractItems } from '~/utils/api'
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
type ListAbsencesFilters = {
|
export const listAbsences = async () => {
|
||||||
from?: string
|
|
||||||
to?: string
|
|
||||||
siteIds?: number[]
|
|
||||||
employeeId?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const query: Record<string, string | string[]> = {}
|
|
||||||
if (filters.from) {
|
|
||||||
query['endDate[after]'] = filters.from
|
|
||||||
}
|
|
||||||
if (filters.to) {
|
|
||||||
query['startDate[before]'] = filters.to
|
|
||||||
}
|
|
||||||
if (filters.siteIds && filters.siteIds.length > 0) {
|
|
||||||
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
|
|
||||||
}
|
|
||||||
if (filters.employeeId) {
|
|
||||||
query.employee = `/api/employees/${filters.employeeId}`
|
|
||||||
}
|
|
||||||
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
|
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
|
||||||
'/absences',
|
'/absences',
|
||||||
query,
|
{},
|
||||||
{ toast: false }
|
{ toast: false }
|
||||||
)
|
)
|
||||||
return extractItems<Absence>(data)
|
return extractItems<Absence>(data)
|
||||||
@@ -36,9 +15,7 @@ export const createAbsence = async (payload: {
|
|||||||
employeeId: number
|
employeeId: number
|
||||||
typeId: number
|
typeId: number
|
||||||
startDate: string
|
startDate: string
|
||||||
startHalf: HalfDay
|
|
||||||
endDate: string
|
endDate: string
|
||||||
endHalf: HalfDay
|
|
||||||
comment?: string
|
comment?: string
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -46,9 +23,7 @@ export const createAbsence = async (payload: {
|
|||||||
employee: `/api/employees/${payload.employeeId}`,
|
employee: `/api/employees/${payload.employeeId}`,
|
||||||
type: `/api/absence_types/${payload.typeId}`,
|
type: `/api/absence_types/${payload.typeId}`,
|
||||||
startDate: payload.startDate,
|
startDate: payload.startDate,
|
||||||
startHalf: payload.startHalf,
|
|
||||||
endDate: payload.endDate,
|
endDate: payload.endDate,
|
||||||
endHalf: payload.endHalf,
|
|
||||||
comment: payload.comment
|
comment: payload.comment
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.absence.create',
|
toastSuccessKey: 'success.absence.create',
|
||||||
@@ -61,9 +36,7 @@ export const updateAbsence = async (payload: {
|
|||||||
employeeId: number
|
employeeId: number
|
||||||
typeId: number
|
typeId: number
|
||||||
startDate: string
|
startDate: string
|
||||||
startHalf: HalfDay
|
|
||||||
endDate: string
|
endDate: string
|
||||||
endHalf: HalfDay
|
|
||||||
comment?: string
|
comment?: string
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -71,9 +44,7 @@ export const updateAbsence = async (payload: {
|
|||||||
employee: `/api/employees/${payload.employeeId}`,
|
employee: `/api/employees/${payload.employeeId}`,
|
||||||
type: `/api/absence_types/${payload.typeId}`,
|
type: `/api/absence_types/${payload.typeId}`,
|
||||||
startDate: payload.startDate,
|
startDate: payload.startDate,
|
||||||
startHalf: payload.startHalf,
|
|
||||||
endDate: payload.endDate,
|
endDate: payload.endDate,
|
||||||
endHalf: payload.endHalf,
|
|
||||||
comment: payload.comment
|
comment: payload.comment
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.absence.update',
|
toastSuccessKey: 'success.absence.update',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const getCurrentUser = () => {
|
|||||||
export const login = (username: string, password: string) => {
|
export const login = (username: string, password: string) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post('/login_check', { username, password }, {
|
return api.post('/login_check', { username, password }, {
|
||||||
toastOn401: true,
|
|
||||||
toastErrorKey: 'errors.auth.login'
|
toastErrorKey: 'errors.auth.login'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { Contract } from './dto/contract'
|
|
||||||
import { extractItems } from '~/utils/api'
|
|
||||||
|
|
||||||
export const listContracts = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<Contract[] | { 'hydra:member'?: Contract[] }>(
|
|
||||||
'/contracts',
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
return extractItems<Contract>(data)
|
|
||||||
}
|
|
||||||
@@ -3,5 +3,4 @@ export type AbsenceType = {
|
|||||||
code: string
|
code: string
|
||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
countAsWorkedHours: boolean
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { Employee } from './employee'
|
import type { Employee } from './employee'
|
||||||
import type { AbsenceType } from './absence-type'
|
import type { AbsenceType } from './absence-type'
|
||||||
import type { HalfDay } from './half-day'
|
|
||||||
|
|
||||||
export type Absence = {
|
export type Absence = {
|
||||||
id: number
|
id: number
|
||||||
startDate: string
|
startDate: string
|
||||||
startHalf: HalfDay
|
|
||||||
endDate: string
|
endDate: string
|
||||||
endHalf: HalfDay
|
|
||||||
comment?: string | null
|
comment?: string | null
|
||||||
employee: Employee
|
employee: Employee
|
||||||
type: AbsenceType
|
type: AbsenceType
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
export const TRACKING_MODES = {
|
|
||||||
TIME: 'TIME',
|
|
||||||
PRESENCE: 'PRESENCE'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type TrackingMode = (typeof TRACKING_MODES)[keyof typeof TRACKING_MODES]
|
|
||||||
|
|
||||||
export const CONTRACT_TYPES = {
|
|
||||||
FORFAIT: 'FORFAIT',
|
|
||||||
H35: '35H',
|
|
||||||
H39: '39H',
|
|
||||||
INTERIM: 'INTERIM',
|
|
||||||
CUSTOM: 'CUSTOM'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type ContractType = (typeof CONTRACT_TYPES)[keyof typeof CONTRACT_TYPES]
|
|
||||||
|
|
||||||
export type Contract = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
trackingMode: TrackingMode
|
|
||||||
type: ContractType
|
|
||||||
weeklyHours?: number | null
|
|
||||||
isActive?: boolean
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export type EmployeeLeaveSummary = {
|
|
||||||
year: number
|
|
||||||
isSupported: boolean
|
|
||||||
ruleCode: string
|
|
||||||
acquiredDays: number
|
|
||||||
remainingDays: number
|
|
||||||
takenDays: number
|
|
||||||
acquiredSaturdays: number
|
|
||||||
remainingSaturdays: number
|
|
||||||
takenSaturdays: number
|
|
||||||
fractionedDays: number
|
|
||||||
accruingDays: number
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
export type EmployeeRttWeekSummary = {
|
|
||||||
month: number
|
|
||||||
weekNumber: number
|
|
||||||
weekStart: string
|
|
||||||
weekEnd: string
|
|
||||||
recoveryMinutes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RttMonthPayment = {
|
|
||||||
month: number
|
|
||||||
paidMinutes25: number
|
|
||||||
paidMinutes50: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EmployeeRttSummary = {
|
|
||||||
year: number
|
|
||||||
carryFromPreviousYearMinutes: number
|
|
||||||
currentYearRecoveryMinutes: number
|
|
||||||
totalPaidMinutes: number
|
|
||||||
availableMinutes: number
|
|
||||||
weeks: EmployeeRttWeekSummary[]
|
|
||||||
monthPayments: RttMonthPayment[]
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,25 +1,8 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
import type { Contract } from './contract'
|
|
||||||
|
|
||||||
export type ContractHistoryItem = {
|
|
||||||
contractId?: number | null
|
|
||||||
contractName?: string | null
|
|
||||||
weeklyHours?: number | null
|
|
||||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
|
||||||
startDate: string
|
|
||||||
endDate?: string | null
|
|
||||||
comment?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
id: number
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
site: Site
|
site?: Site | null
|
||||||
contract?: Contract | null
|
|
||||||
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
|
||||||
currentContractStartDate?: string | null
|
|
||||||
currentContractEndDate?: string | null
|
|
||||||
contractHistory?: ContractHistoryItem[]
|
|
||||||
displayOrder?: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export type HalfDay = 'AM' | 'PM'
|
|
||||||
|
|
||||||
export const HALF_DAYS: { value: HalfDay; label: string }[] = [
|
|
||||||
{ value: 'AM', label: 'Matin' },
|
|
||||||
{ value: 'PM', label: 'Après-midi' }
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type NotificationItem = {
|
|
||||||
id: number
|
|
||||||
actorName: string
|
|
||||||
message: string
|
|
||||||
category: string
|
|
||||||
target: string
|
|
||||||
isRead: boolean
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
@@ -2,5 +2,4 @@ export type Site = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
color: string
|
color: string
|
||||||
displayOrder?: number
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export type UserData = {
|
export type UserData = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
roles: string[]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { Employee } from './employee'
|
|
||||||
|
|
||||||
export type User = {
|
|
||||||
id: number
|
|
||||||
username: string
|
|
||||||
roles: string[]
|
|
||||||
employee?: Employee | null
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import type { Employee } from './employee'
|
|
||||||
import type { ContractType, TrackingMode } from './contract'
|
|
||||||
|
|
||||||
export type WorkHour = {
|
|
||||||
id: number
|
|
||||||
employee: Employee
|
|
||||||
workDate: string
|
|
||||||
morningFrom?: string | null
|
|
||||||
morningTo?: string | null
|
|
||||||
afternoonFrom?: string | null
|
|
||||||
afternoonTo?: string | null
|
|
||||||
eveningFrom?: string | null
|
|
||||||
eveningTo?: string | null
|
|
||||||
isPresentMorning?: boolean
|
|
||||||
isPresentAfternoon?: boolean
|
|
||||||
isSiteValid?: boolean
|
|
||||||
isValid?: boolean
|
|
||||||
updatedAt?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkHourEntryPayload = {
|
|
||||||
employeeId: number
|
|
||||||
morningFrom?: string | null
|
|
||||||
morningTo?: string | null
|
|
||||||
afternoonFrom?: string | null
|
|
||||||
afternoonTo?: string | null
|
|
||||||
eveningFrom?: string | null
|
|
||||||
eveningTo?: string | null
|
|
||||||
isPresentMorning?: boolean
|
|
||||||
isPresentAfternoon?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WeeklyWorkHourDailySummary = {
|
|
||||||
date: string
|
|
||||||
dayMinutes: number
|
|
||||||
nightMinutes: number
|
|
||||||
totalMinutes: number
|
|
||||||
present?: number | null
|
|
||||||
hasAbsence?: boolean
|
|
||||||
absenceLabel?: string | null
|
|
||||||
absenceColor?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WeeklyWorkHourRowSummary = {
|
|
||||||
employeeId: number
|
|
||||||
firstName: string
|
|
||||||
lastName: string
|
|
||||||
siteName?: string | null
|
|
||||||
contractName?: string | null
|
|
||||||
contractType?: ContractType | null
|
|
||||||
trackingMode?: TrackingMode | null
|
|
||||||
daily: WeeklyWorkHourDailySummary[]
|
|
||||||
weeklyDayMinutes: number
|
|
||||||
weeklyNightMinutes: number
|
|
||||||
weeklyTotalMinutes: number
|
|
||||||
weeklyPresenceCount?: number
|
|
||||||
weeklyOvertimeTotalMinutes?: number
|
|
||||||
weeklyOvertime25Minutes?: number
|
|
||||||
weeklyOvertime50Minutes?: number
|
|
||||||
weeklyRecoveryMinutes?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WeeklyWorkHourSummary = {
|
|
||||||
weekStart: string
|
|
||||||
weekEnd: string
|
|
||||||
days: string[]
|
|
||||||
rows: WeeklyWorkHourRowSummary[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkHourDayContextRow = {
|
|
||||||
employeeId: number
|
|
||||||
hasContractAtDate: boolean
|
|
||||||
absenceLabel?: string | null
|
|
||||||
absenceColor?: string | null
|
|
||||||
absenceHalf?: 'AM' | 'PM' | null
|
|
||||||
absentMorning: boolean
|
|
||||||
absentAfternoon: boolean
|
|
||||||
creditedMinutes: number
|
|
||||||
creditedPresenceUnits: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkHourDayContext = {
|
|
||||||
workDate: string
|
|
||||||
rows: WorkHourDayContextRow[]
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
|
|
||||||
|
|
||||||
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
const query: Record<string, string> = {}
|
|
||||||
if (year) query.year = String(year)
|
|
||||||
|
|
||||||
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateFractionedDays = async (employeeId: number, fractionedDays: number, year?: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
const body: Record<string, unknown> = { fractionedDays }
|
|
||||||
if (year) body.year = year
|
|
||||||
|
|
||||||
return api.patch(`/employees/${employeeId}/fractioned-days`, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
|
|
||||||
|
|
||||||
export const getEmployeeRttSummary = async (employeeId: number, year?: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
const query = year ? { year } : {}
|
|
||||||
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createRttPayment = async (employeeId: number, month: number, minutes: number, rate: '25' | '50', year?: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
const body: Record<string, unknown> = { month, minutes, rate }
|
|
||||||
if (year) body.year = year
|
|
||||||
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -11,39 +11,16 @@ export const listEmployees = async () => {
|
|||||||
return extractItems<Employee>(data)
|
return extractItems<Employee>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listScopedEmployees = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<Employee[] | { 'hydra:member'?: Employee[] }>(
|
|
||||||
'/employees/scoped',
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
return extractItems<Employee>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getEmployee = async (id: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.get<Employee>(`/employees/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createEmployee = async (payload: {
|
export const createEmployee = async (payload: {
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
contractId: number
|
|
||||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
|
||||||
contractStartDate?: string
|
|
||||||
contractEndDate?: string | null
|
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Employee>('/employees', {
|
return api.post<Employee>('/employees', {
|
||||||
firstName: payload.firstName,
|
firstName: payload.firstName,
|
||||||
lastName: payload.lastName,
|
lastName: payload.lastName,
|
||||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
|
||||||
contract: `/api/contracts/${payload.contractId}`,
|
|
||||||
contractNature: payload.contractNature,
|
|
||||||
contractStartDate: payload.contractStartDate,
|
|
||||||
contractEndDate: payload.contractEndDate ?? null
|
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.create',
|
toastSuccessKey: 'success.employee.create',
|
||||||
toastErrorKey: 'errors.employee.create'
|
toastErrorKey: 'errors.employee.create'
|
||||||
@@ -56,57 +33,16 @@ export const updateEmployee = async (
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
contractId?: number
|
|
||||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
|
||||||
contractStartDate?: string
|
|
||||||
contractEndDate?: string | null
|
|
||||||
contractPaidLeaveSettled?: boolean
|
|
||||||
contractComment?: string | null
|
|
||||||
displayOrder?: number
|
|
||||||
}
|
}
|
||||||
) => {
|
|
||||||
const api = useApi()
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
firstName: payload.firstName,
|
|
||||||
lastName: payload.lastName,
|
|
||||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
|
||||||
displayOrder: payload.displayOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.contractId !== undefined) {
|
|
||||||
body.contract = `/api/contracts/${payload.contractId}`
|
|
||||||
}
|
|
||||||
if (payload.contractNature !== undefined) {
|
|
||||||
body.contractNature = payload.contractNature
|
|
||||||
}
|
|
||||||
if (payload.contractStartDate !== undefined) {
|
|
||||||
body.contractStartDate = payload.contractStartDate
|
|
||||||
}
|
|
||||||
if (payload.contractEndDate !== undefined) {
|
|
||||||
body.contractEndDate = payload.contractEndDate ?? null
|
|
||||||
}
|
|
||||||
if (payload.contractPaidLeaveSettled !== undefined) {
|
|
||||||
body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled
|
|
||||||
}
|
|
||||||
if (payload.contractComment !== undefined) {
|
|
||||||
body.contractComment = payload.contractComment ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return api.patch<Employee>(`/employees/${id}`, body, {
|
|
||||||
toastSuccessKey: 'success.employee.update',
|
|
||||||
toastErrorKey: 'errors.employee.update'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateEmployeeOrder = async (
|
|
||||||
id: number,
|
|
||||||
displayOrder: number
|
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<Employee>(`/employees/${id}`, {
|
return api.patch<Employee>(`/employees/${id}`, {
|
||||||
displayOrder
|
firstName: payload.firstName,
|
||||||
|
lastName: payload.lastName,
|
||||||
|
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
|
||||||
}, {
|
}, {
|
||||||
toast: false
|
toastSuccessKey: 'success.employee.update',
|
||||||
|
toastErrorKey: 'errors.employee.update'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { NotificationItem } from './dto/notification'
|
|
||||||
import { extractItems } from '~/utils/api'
|
|
||||||
|
|
||||||
export const listUnreadNotifications = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
|
|
||||||
'/notifications/unread',
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
return extractItems<NotificationItem>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listTodayNotifications = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
|
|
||||||
'/notifications/today',
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
return extractItems<NotificationItem>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listHistoryNotifications = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
|
|
||||||
'/notifications/history',
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
return extractItems<NotificationItem>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const markAllNotificationsRead = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.post('/notifications/mark-all-read', {}, { toast: false })
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export type PublicHolidaysResponse =
|
|
||||||
| { days?: Record<string, string> }
|
|
||||||
| Record<string, string>
|
|
||||||
|
|
||||||
export const listPublicHolidays = async (zone: string, year: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<PublicHolidaysResponse>(
|
|
||||||
`/public-holidays/${zone}/${year}`,
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (data && typeof data === 'object' && 'days' in data) {
|
|
||||||
return (data.days ?? {}) as Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data ?? {}) as Record<string, string>
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,10 @@ export const listSites = async () => {
|
|||||||
{},
|
{},
|
||||||
{ toast: false }
|
{ toast: false }
|
||||||
)
|
)
|
||||||
return extractItems<Site>(data).sort((siteA, siteB) => {
|
return extractItems<Site>(data)
|
||||||
const orderA = siteA.displayOrder ?? 0
|
|
||||||
const orderB = siteB.displayOrder ?? 0
|
|
||||||
if (orderA !== orderB) return orderA - orderB
|
|
||||||
return siteA.name.localeCompare(siteB.name, 'fr')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createSite = async (payload: Pick<Site, 'name' | 'color'> & { displayOrder?: number }) => {
|
export const createSite = async (payload: Pick<Site, 'name' | 'color'>) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Site>('/sites', payload, {
|
return api.post<Site>('/sites', payload, {
|
||||||
toastSuccessKey: 'success.site.create',
|
toastSuccessKey: 'success.site.create',
|
||||||
@@ -24,10 +19,7 @@ export const createSite = async (payload: Pick<Site, 'name' | 'color'> & { displ
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateSite = async (
|
export const updateSite = async (id: number, payload: Pick<Site, 'name' | 'color'>) => {
|
||||||
id: number,
|
|
||||||
payload: Pick<Site, 'name' | 'color'> & { displayOrder?: number }
|
|
||||||
) => {
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<Site>(`/sites/${id}`, payload, {
|
return api.patch<Site>(`/sites/${id}`, payload, {
|
||||||
toastSuccessKey: 'success.site.update',
|
toastSuccessKey: 'success.site.update',
|
||||||
@@ -35,15 +27,6 @@ export const updateSite = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateSiteOrder = async (id: number, displayOrder: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.patch<Site>(`/sites/${id}`, {
|
|
||||||
displayOrder
|
|
||||||
}, {
|
|
||||||
toast: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteSite = async (id: number) => {
|
export const deleteSite = async (id: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.delete(`/sites/${id}`, {}, {
|
return api.delete(`/sites/${id}`, {}, {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { extractItems } from '~/utils/api'
|
|
||||||
|
|
||||||
export const createUserSiteRole = async (payload: {
|
|
||||||
userId: number
|
|
||||||
siteId: number
|
|
||||||
role: string
|
|
||||||
}) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.post('/user_site_roles', {
|
|
||||||
user: `/api/users/${payload.userId}`,
|
|
||||||
site: `/api/sites/${payload.siteId}`,
|
|
||||||
role: payload.role
|
|
||||||
}, {
|
|
||||||
toast: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UserSiteRole = {
|
|
||||||
id: number
|
|
||||||
user: { id: number }
|
|
||||||
site: { id: number; name?: string }
|
|
||||||
role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const listUserSiteRoles = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<UserSiteRole[] | { 'hydra:member'?: UserSiteRole[] }>(
|
|
||||||
'/user_site_roles',
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
return extractItems<UserSiteRole>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteUserSiteRole = async (id: number) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.delete(`/user_site_roles/${id}`, {}, { toast: false })
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { User } from './dto/user'
|
|
||||||
import { extractItems } from '~/utils/api'
|
|
||||||
|
|
||||||
export const listUsers = async () => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<User[] | { 'hydra:member'?: User[] }>(
|
|
||||||
'/users',
|
|
||||||
{},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
return extractItems<User>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createUser = async (payload: {
|
|
||||||
username: string
|
|
||||||
plainPassword: string
|
|
||||||
roles: string[]
|
|
||||||
employeeId?: number | null
|
|
||||||
}) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.post<User>(
|
|
||||||
'/users',
|
|
||||||
{
|
|
||||||
username: payload.username,
|
|
||||||
plainPassword: payload.plainPassword,
|
|
||||||
roles: payload.roles,
|
|
||||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
toastSuccessKey: 'success.user.create',
|
|
||||||
toastErrorKey: 'errors.user.create'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateUser = async (id: number, payload: {
|
|
||||||
username: string
|
|
||||||
plainPassword?: string
|
|
||||||
roles: string[]
|
|
||||||
employeeId?: number | null
|
|
||||||
}) => {
|
|
||||||
const api = useApi()
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
username: payload.username,
|
|
||||||
roles: payload.roles,
|
|
||||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.plainPassword) {
|
|
||||||
body.plainPassword = payload.plainPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
return api.patch<User>(`/users/${id}`, body, {
|
|
||||||
toastSuccessKey: 'success.user.update',
|
|
||||||
toastErrorKey: 'errors.user.update'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import type {
|
|
||||||
WorkHourDayContext,
|
|
||||||
WorkHour,
|
|
||||||
WorkHourEntryPayload,
|
|
||||||
WeeklyWorkHourSummary
|
|
||||||
} from './dto/work-hour'
|
|
||||||
import { extractItems } from '~/utils/api'
|
|
||||||
|
|
||||||
export const listWorkHoursByDate = async (workDate: string) => {
|
|
||||||
const api = useApi()
|
|
||||||
const data = await api.get<WorkHour[] | { 'hydra:member'?: WorkHour[] }>(
|
|
||||||
'/work_hours',
|
|
||||||
{
|
|
||||||
'workDate[after]': workDate,
|
|
||||||
'workDate[before]': workDate
|
|
||||||
},
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
return extractItems<WorkHour>(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bulkUpsertWorkHours = async (payload: {
|
|
||||||
workDate: string
|
|
||||||
entries: WorkHourEntryPayload[]
|
|
||||||
}, options?: { toast?: boolean }) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.post<{
|
|
||||||
processed: number
|
|
||||||
created: number
|
|
||||||
updated: number
|
|
||||||
deleted: number
|
|
||||||
}>(
|
|
||||||
'/work-hours/bulk-upsert',
|
|
||||||
payload,
|
|
||||||
{
|
|
||||||
toast: options?.toast ?? true,
|
|
||||||
toastSuccessMessage: 'Horaires enregistrés.',
|
|
||||||
toastErrorMessage: "Impossible d'enregistrer les horaires."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateWorkHourValidation = async (
|
|
||||||
id: number,
|
|
||||||
isValid: boolean,
|
|
||||||
options?: { toast?: boolean }
|
|
||||||
) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.patch<WorkHour>(
|
|
||||||
`/work_hours/${id}`,
|
|
||||||
{ isValid },
|
|
||||||
{
|
|
||||||
toast: options?.toast ?? true,
|
|
||||||
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
|
|
||||||
toastErrorMessage: 'Impossible de mettre à jour la validation.'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bulkUpdateWorkHourValidation = async (payload: {
|
|
||||||
workDate: string
|
|
||||||
isValid: boolean
|
|
||||||
employeeIds: number[]
|
|
||||||
}, options?: { toast?: boolean }) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.post<{
|
|
||||||
requested: number
|
|
||||||
updated: number
|
|
||||||
skipped: number
|
|
||||||
updatedEmployeeIds: number[]
|
|
||||||
skippedEmployeeIds: number[]
|
|
||||||
}>(
|
|
||||||
'/work-hours/bulk-validation',
|
|
||||||
payload,
|
|
||||||
{
|
|
||||||
toast: options?.toast ?? true,
|
|
||||||
toastSuccessMessage: payload.isValid ? 'Validations enregistrées.' : 'Validations retirées.',
|
|
||||||
toastErrorMessage: "Impossible de mettre à jour les validations."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateWorkHourSiteValidation = async (
|
|
||||||
id: number,
|
|
||||||
isSiteValid: boolean,
|
|
||||||
options?: { toast?: boolean }
|
|
||||||
) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.patch<WorkHour>(
|
|
||||||
`/work_hours/${id}/site-validation`,
|
|
||||||
{ isSiteValid },
|
|
||||||
{
|
|
||||||
toast: options?.toast ?? true,
|
|
||||||
toastSuccessMessage: isSiteValid ? 'Validation site enregistrée.' : 'Validation site retirée.',
|
|
||||||
toastErrorMessage: "Impossible de mettre à jour la validation site."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const bulkUpdateWorkHourSiteValidation = async (payload: {
|
|
||||||
workDate: string
|
|
||||||
isSiteValid: boolean
|
|
||||||
employeeIds: number[]
|
|
||||||
}, options?: { toast?: boolean }) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.post<{
|
|
||||||
requested: number
|
|
||||||
updated: number
|
|
||||||
skipped: number
|
|
||||||
updatedEmployeeIds: number[]
|
|
||||||
skippedEmployeeIds: number[]
|
|
||||||
}>(
|
|
||||||
'/work-hours/site-bulk-validation',
|
|
||||||
payload,
|
|
||||||
{
|
|
||||||
toast: options?.toast ?? true,
|
|
||||||
toastSuccessMessage: payload.isSiteValid ? 'Validations site enregistrées.' : 'Validations site retirées.',
|
|
||||||
toastErrorMessage: "Impossible de mettre à jour les validations site."
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.get<WeeklyWorkHourSummary>(
|
|
||||||
'/work-hours/weekly-summary',
|
|
||||||
{ weekStart },
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getWorkHourDayContext = async (workDate: string) => {
|
|
||||||
const api = useApi()
|
|
||||||
return api.get<WorkHourDayContext>(
|
|
||||||
'/work-hours/day-context',
|
|
||||||
{ workDate },
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ export default <Partial<Config>>{
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['"Inter"', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif']
|
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
@@ -15,9 +15,6 @@ export default <Partial<Config>>{
|
|||||||
},
|
},
|
||||||
tertiary: {
|
tertiary: {
|
||||||
500: '#F3F4F8'
|
500: '#F3F4F8'
|
||||||
},
|
|
||||||
blue: {
|
|
||||||
500: '#056CF2'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
export const CONTRACT_NATURES = ['CDI', 'CDD', 'INTERIM'] as const
|
|
||||||
|
|
||||||
export type ContractNature = (typeof CONTRACT_NATURES)[number]
|
|
||||||
|
|
||||||
export const contractNatureLabel = (value?: ContractNature) => {
|
|
||||||
if (value === 'CDD') return 'CDD'
|
|
||||||
if (value === 'INTERIM') return 'Intérim'
|
|
||||||
return 'CDI'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const showsContractEndDate = (nature: ContractNature) => {
|
|
||||||
return nature === 'CDD' || nature === 'INTERIM'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const requiresContractEndDate = (nature: ContractNature) => {
|
|
||||||
return nature === 'CDD'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isContractNature = (value: string): value is ContractNature => {
|
|
||||||
return (CONTRACT_NATURES as readonly string[]).includes(value)
|
|
||||||
}
|
|
||||||
@@ -6,126 +6,6 @@ export const toYmd = (year: number, month: number, day: number) => {
|
|||||||
|
|
||||||
export const normalizeDate = (value: string) => value.slice(0, 10)
|
export const normalizeDate = (value: string) => value.slice(0, 10)
|
||||||
|
|
||||||
export const formatYmdToFr = (value: string) => {
|
|
||||||
const [year, month, day] = value.split('-')
|
|
||||||
if (!year || !month || !day) return value
|
|
||||||
return `${day}/${month}/${year}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatNullableYmdToFr = (value?: string | null, fallback = 'En cours') => {
|
|
||||||
if (!value) return fallback
|
|
||||||
return formatYmdToFr(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const parseYmd = (value: string) => {
|
|
||||||
const [year, month, day] = value.split('-').map(Number)
|
|
||||||
if (!year || !month || !day) return null
|
|
||||||
return new Date(year, month - 1, day)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatDateLongFr = (date: Date) => {
|
|
||||||
const label = new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
weekday: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric'
|
|
||||||
}).format(date)
|
|
||||||
|
|
||||||
return label.charAt(0).toUpperCase() + label.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatWeekDayHeaderFr = (dateYmd: string) => {
|
|
||||||
const parsed = parseYmd(dateYmd)
|
|
||||||
if (!parsed) return dateYmd
|
|
||||||
return new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
weekday: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit'
|
|
||||||
}).format(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getWeekStartDate = (date: Date) => {
|
|
||||||
const copy = new Date(date)
|
|
||||||
const day = copy.getDay()
|
|
||||||
const diff = day === 0 ? -6 : 1 - day
|
|
||||||
copy.setDate(copy.getDate() + diff)
|
|
||||||
copy.setHours(0, 0, 0, 0)
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getIsoWeekNumber = (date: Date) => {
|
|
||||||
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
|
||||||
const day = utc.getUTCDay() || 7
|
|
||||||
utc.setUTCDate(utc.getUTCDate() + 4 - day)
|
|
||||||
const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1))
|
|
||||||
return Math.ceil((((utc.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getIsoWeekYear = (date: Date) => {
|
|
||||||
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
|
||||||
const day = utc.getUTCDay() || 7
|
|
||||||
utc.setUTCDate(utc.getUTCDate() + 4 - day)
|
|
||||||
return utc.getUTCFullYear()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ymdToWeekInputValue = (dateYmd: string) => {
|
|
||||||
const parsed = parseYmd(dateYmd)
|
|
||||||
if (!parsed) return ''
|
|
||||||
const weekDate = getWeekStartDate(parsed)
|
|
||||||
const weekNumber = getIsoWeekNumber(weekDate)
|
|
||||||
const weekYear = getIsoWeekYear(weekDate)
|
|
||||||
return `${weekYear}-W${String(weekNumber).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const weekInputValueToYmd = (weekValue: string) => {
|
|
||||||
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue)
|
|
||||||
if (!match) return null
|
|
||||||
|
|
||||||
const year = Number(match[1])
|
|
||||||
const week = Number(match[2])
|
|
||||||
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) return null
|
|
||||||
|
|
||||||
const jan4 = new Date(year, 0, 4)
|
|
||||||
const week1Monday = getWeekStartDate(jan4)
|
|
||||||
const monday = new Date(week1Monday)
|
|
||||||
monday.setDate(week1Monday.getDate() + ((week - 1) * 7))
|
|
||||||
|
|
||||||
return toYmd(monday.getFullYear(), monday.getMonth(), monday.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getTodayYmd = () => {
|
|
||||||
const date = new Date()
|
|
||||||
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getOffsetFromTodayYmd = (offset: number) => {
|
|
||||||
const date = new Date()
|
|
||||||
date.setDate(date.getDate() + offset)
|
|
||||||
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const shiftYmd = (value: string, days: number) => {
|
|
||||||
const parsed = parseYmd(value)
|
|
||||||
if (!parsed) return null
|
|
||||||
parsed.setDate(parsed.getDate() + days)
|
|
||||||
return toYmd(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatWeekRangeFr = (date: Date) => {
|
|
||||||
const start = getWeekStartDate(date)
|
|
||||||
const end = new Date(start)
|
|
||||||
end.setDate(start.getDate() + 6)
|
|
||||||
const weekNumber = getIsoWeekNumber(start)
|
|
||||||
|
|
||||||
const formatter = new Intl.DateTimeFormat('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric'
|
|
||||||
})
|
|
||||||
|
|
||||||
return `S${weekNumber} du ${formatter.format(start)} au ${formatter.format(end)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getDaysInMonth = (year: number, month: number) => {
|
export const getDaysInMonth = (year: number, month: number) => {
|
||||||
const total = new Date(year, month + 1, 0).getDate()
|
const total = new Date(year, month + 1, 0).getDate()
|
||||||
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { Employee } from '~/services/dto/employee'
|
|
||||||
|
|
||||||
export const compareEmployeesInSite = (employeeA: Employee, employeeB: Employee) => {
|
|
||||||
const orderA = employeeA.displayOrder ?? 0
|
|
||||||
const orderB = employeeB.displayOrder ?? 0
|
|
||||||
if (orderA !== orderB) return orderA - orderB
|
|
||||||
|
|
||||||
const lastNameA = employeeA.lastName ?? ''
|
|
||||||
const lastNameB = employeeB.lastName ?? ''
|
|
||||||
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
|
|
||||||
|
|
||||||
const firstNameA = employeeA.firstName ?? ''
|
|
||||||
const firstNameB = employeeB.firstName ?? ''
|
|
||||||
return firstNameA.localeCompare(firstNameB, 'fr')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const compareEmployeesBySiteAndOrder = (employeeA: Employee, employeeB: Employee) => {
|
|
||||||
const siteOrderA = employeeA.site?.displayOrder ?? 0
|
|
||||||
const siteOrderB = employeeB.site?.displayOrder ?? 0
|
|
||||||
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
|
|
||||||
|
|
||||||
const siteNameA = employeeA.site?.name ?? ''
|
|
||||||
const siteNameB = employeeB.site?.name ?? ''
|
|
||||||
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
|
|
||||||
|
|
||||||
return compareEmployeesInSite(employeeA, employeeB)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sortEmployeesBySiteAndOrder = (employees: Employee[]) => {
|
|
||||||
return [...employees].sort(compareEmployeesBySiteAndOrder)
|
|
||||||
}
|
|
||||||
2
makefile
2
makefile
@@ -77,7 +77,7 @@ migration-migrate:
|
|||||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
|
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) doctrine:fixtures:load
|
||||||
|
|
||||||
# Attention, supprime votre bdd local
|
# Attention, supprime votre bdd local
|
||||||
db-reset:
|
db-reset:
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260210120000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add start/end half-day fields to absences';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql("ALTER TABLE absences ADD start_half VARCHAR(2) NOT NULL DEFAULT 'AM'");
|
|
||||||
$this->addSql("ALTER TABLE absences ADD end_half VARCHAR(2) NOT NULL DEFAULT 'PM'");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE absences DROP COLUMN start_half');
|
|
||||||
$this->addSql('ALTER TABLE absences DROP COLUMN end_half');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260210121500 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add display_order to employees';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE employees ADD display_order INT NOT NULL DEFAULT 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE employees DROP COLUMN display_order');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260210123000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Backfill employee display_order per site';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// Initialise display_order par site selon le tri nom/prénom/id.
|
|
||||||
$this->addSql('
|
|
||||||
UPDATE employees e
|
|
||||||
SET display_order = ranked.rn
|
|
||||||
FROM (
|
|
||||||
SELECT id,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY site_id
|
|
||||||
ORDER BY last_name ASC, first_name ASC, id ASC
|
|
||||||
) AS rn
|
|
||||||
FROM employees
|
|
||||||
) ranked
|
|
||||||
WHERE ranked.id = e.id
|
|
||||||
');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// Pas de rollback pertinent.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user