Compare commits

...

91 Commits

Author SHA1 Message Date
gitea-actions
fd154a59fb chore: bump version to v0.1.74
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 18s
2026-03-31 14:44:07 +00:00
967e3311e5 docs : update doc deployment-docker.md
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 16:43:54 +02:00
gitea-actions
04c5279946 chore: bump version to v0.1.73
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 15s
2026-03-31 12:46:03 +00:00
b25d40f3d8 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 14:45:55 +02:00
e654516b82 docs : fix JWT key generation and permissions in deployment doc 2026-03-31 14:32:12 +02:00
gitea-actions
b07146e78d chore: bump version to v0.1.72
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 19s
2026-03-31 10:13:39 +00:00
b1bf363fa1 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 12:13:31 +02:00
c13cab6b59 fix(deploy) : run console commands as www-data to prevent permission issues 2026-03-31 12:11:58 +02:00
gitea-actions
3752785ed1 chore: bump version to v0.1.71
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 17s
2026-03-31 09:50:51 +00:00
ab44b5439d fix(deploy) : add minimal .env file in Docker image for Symfony boot
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 10:38:59 +02:00
699d09e2f4 docs : add nginx install to deployment prerequisites 2026-03-31 10:35:32 +02:00
b62a19513d docs : improve Docker deployment documentation 2026-03-31 09:33:05 +02:00
gitea-actions
3d69346d24 chore: bump version to v0.1.70
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-03-31 07:11:36 +00:00
ea849a4fdd Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-03-31 09:11:28 +02:00
7b3dcc3c54 fix : déploiement docker 2026-03-31 09:11:00 +02:00
gitea-actions
c6ab8e3624 chore: bump version to v0.1.69
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
2026-03-31 06:57:47 +00:00
f3b65c0617 fix(deploy) : use REGISTRY_TOKEN for Docker registry authentication
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 08:57:27 +02:00
gitea-actions
15ce234737 chore: bump version to v0.1.68
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 6s
2026-03-31 06:38:47 +00:00
caffb74cbf [#INFRA-142] Revoir le système de déploiement (#11)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| 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é

Co-authored-by: gitea-actions <gitea-actions@local>
Reviewed-on: #11
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-31 06:38:38 +00:00
gitea-actions
54354c4435 chore: bump version to v0.1.67
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m33s
2026-03-30 13:06:35 +00:00
3dcdf0fb81 [#SIRH-18] Fix connexion conducteur (#10)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| 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é

Reviewed-on: #10
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 13:06:27 +00:00
gitea-actions
1a71ff6834 chore: bump version to v0.1.66
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m30s
2026-03-30 07:52:57 +00:00
057d6bf06f [#SIRH-17] Ajouter un système de log des actions utilisateurs (#9)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| 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é

Reviewed-on: #9
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 07:52:49 +00:00
gitea-actions
e74a264b37 chore: bump version to v0.1.65
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 2m24s
2026-03-25 09:49:32 +00:00
60bb3cf8c4 fix : verrouillage utilisateur + modification de contrat terminé
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-25 10:49:20 +01:00
gitea-actions
1a485e8780 chore: bump version to v0.1.64
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-25 09:19:21 +00:00
5c6d42c729 [#SIRH-14] Ajouter un onglet Observation sur la fiche employé (#8)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| 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é

Reviewed-on: #8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 09:19:16 +00:00
gitea-actions
3c434d20b2 chore: bump version to v0.1.63
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-03-25 07:51:33 +00:00
bbb020025a [#SIRH-12] Export des heures d'un employé sur une année (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| 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é

Reviewed-on: #7
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 07:51:26 +00:00
gitea-actions
640bb42d3a chore: bump version to v0.1.62
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m38s
2026-03-23 15:40:37 +00:00
50712ccb00 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-23 16:40:24 +01:00
265b19a9d0 fix : calcule des RTT pour les chauffeurs dans le récap des congés 2026-03-23 16:40:06 +01:00
gitea-actions
13743738fd chore: bump version to v0.1.61
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m45s
2026-03-20 13:42:48 +00:00
085fe0c150 fix : calcule des RTT pour les chauffeurs
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 14:42:36 +01:00
gitea-actions
a1110069b5 chore: bump version to v0.1.60
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-20 10:53:51 +00:00
4901c58ebf Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 11:53:34 +01:00
4de891579c feat : ajout des congés bonus pour les forfaits si ils travaillent un weekend ou férié 2026-03-20 11:53:24 +01:00
gitea-actions
a17d6a67cf chore: bump version to v0.1.59
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m59s
2026-03-20 10:26:29 +00:00
29db3b5025 fix : calcule des RTT sur les contrats 4h
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 11:26:17 +01:00
gitea-actions
6df9110187 chore: bump version to v0.1.58
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m39s
2026-03-20 07:13:49 +00:00
f0dfb30566 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-20 08:13:34 +01:00
049e64288e fix : calcule des RTT 2026-03-20 08:13:20 +01:00
gitea-actions
9577a70ea3 chore: bump version to v0.1.57
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m9s
2026-03-19 17:18:17 +00:00
e85f7b6f4c fix : calcule des RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-19 18:18:06 +01:00
gitea-actions
834b4cb695 chore: bump version to v0.1.56
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m36s
2026-03-19 16:10:25 +00:00
17f871e82d feat : modification écran RTT + modification écran frais
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-19 17:10:11 +01:00
gitea-actions
3ec1e1f10d chore: bump version to v0.1.55
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-18 14:40:57 +00:00
24b7512c8a Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-18 15:40:44 +01:00
f047e3ed4b feat : ajout d'une colonne montant dans les Frais employé 2026-03-18 15:40:31 +01:00
gitea-actions
1feedd0381 chore: bump version to v0.1.54
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m9s
2026-03-17 14:24:43 +00:00
f9cd5a0143 fix : RTT à la date du jour et affichage des congés restant
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-17 15:24:32 +01:00
gitea-actions
ede7decaa7 chore: bump version to v0.1.53
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m44s
2026-03-17 14:05:55 +00:00
2cfb05e5de feat : ajout de cache sur la récupération des jours fériés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 15:05:43 +01:00
gitea-actions
0a8399a950 chore: bump version to v0.1.52
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-17 12:52:06 +00:00
6a64cb4c58 feat : ajout de sécurité sur les endpoints
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 13:52:00 +01:00
gitea-actions
facded4c55 chore: bump version to v0.1.51
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-03-17 12:28:00 +00:00
9787231052 fix : correction calcule prorata congés avec un arrêt maladie long
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 13:27:51 +01:00
gitea-actions
8563ddb08c chore: bump version to v0.1.50
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-17 10:54:32 +00:00
353d4d9d2b fix : correction calcule prorata congés avec une suspension de contrat
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 11:54:23 +01:00
gitea-actions
8745e5e425 chore: bump version to v0.1.49
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-17 10:25:15 +00:00
4d8c850a77 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 11:25:05 +01:00
1974ace1f2 fix : correction validation des heures qui bloque la modification 2026-03-17 11:24:56 +01:00
gitea-actions
a99a12a759 chore: bump version to v0.1.48
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-17 10:17:34 +00:00
548b5d63a6 fix : correction calcule des RTT
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-17 11:17:24 +01:00
gitea-actions
ed9df4e178 chore: bump version to v0.1.47
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-17 09:34:55 +00:00
625b4af5ba fix : correction calcule des paniers de nuit
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 10:34:45 +01:00
gitea-actions
2ec3044cb3 chore: bump version to v0.1.46
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-17 08:09:03 +00:00
f024a6a8de fix : correction du calcule des RTT
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-17 09:08:54 +01:00
gitea-actions
a60294a8f7 chore: bump version to v0.1.45
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-16 17:23:19 +00:00
dd7f9ef8a0 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 18:23:05 +01:00
cfa7d25521 fix : correction du récap congés et RTT 2026-03-16 18:22:55 +01:00
gitea-actions
5faa0facca chore: bump version to v0.1.44
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m48s
2026-03-16 17:18:08 +00:00
04f90afc58 feat : ajout de la règle de décompte des RTT et correction du récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 18:17:58 +01:00
gitea-actions
e022cfac98 chore: bump version to v0.1.43
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-16 15:26:24 +00:00
e827128392 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-16 16:26:13 +01:00
86cdec50c6 feat : ajout de l'export récap congés et RTT 2026-03-16 16:26:06 +01:00
gitea-actions
443ed1e003 chore: bump version to v0.1.42
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m21s
2026-03-16 13:38:06 +00:00
cef364fcec fix : fix affichage employé sur les pages d'heures + ajout d'un filtre employé sur la liste + fix impression recap salaire
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 14:37:00 +01:00
gitea-actions
d4884bc489 chore: bump version to v0.1.41
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-16 11:25:51 +00:00
b93c4bf3e9 feat : ajout de l'export récap. salaire
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 12:25:41 +01:00
gitea-actions
f0ee489c26 chore: bump version to v0.1.40
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-16 08:13:46 +00:00
01f8058f56 fix : redirection après login + écran des heures chauffeurs
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 09:13:35 +01:00
gitea-actions
3d26d6b50f chore: bump version to v0.1.39
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m26s
2026-03-15 18:05:02 +00:00
339d650b41 feat : ajout de la gestion des heures chauffeurs
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-15 19:04:52 +01:00
gitea-actions
43957903b0 chore: bump version to v0.1.38
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-13 15:20:39 +00:00
d455bb77a3 feat : ajout des primes
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-13 16:20:30 +01:00
gitea-actions
8b20632ab8 chore: bump version to v0.1.37
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m18s
2026-03-13 15:06:06 +00:00
0cc2b2730a feat : ajout des frais kms + alignment du style de l'application
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 16:05:54 +01:00
gitea-actions
c35edb9a1c chore: bump version to v0.1.36
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-13 11:24:19 +00:00
4b04be1d1b Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 12:24:11 +01:00
b24dd8595d fix : calcule des jours de présence + SiteFilterSelector.vue 2026-03-13 12:23:55 +01:00
159 changed files with 10025 additions and 544 deletions

View File

@@ -21,7 +21,12 @@
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
"Bash(which python3:*)",
"Bash(sudo apt-get:*)",
"Bash(npx xlsx-cli:*)"
"Bash(npx xlsx-cli:*)",
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
"Bash(pip3 install:*)",
"Bash(find:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
.git
.gitea
.env.local
.env.test
docker/
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output
var/
LOG/
docs/
doc/
tests/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!frontend/package-lock.json

4
.env
View File

@@ -36,6 +36,10 @@ DEFAULT_URI=http://localhost
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> app ###
RTT_START_DATE=2026-02-23
###< app ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

View File

@@ -0,0 +1,30 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-f deploy/docker/Dockerfile.prod \
-t gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }} \
-t gitea.malio.fr/malio-dev/sirh:latest \
.
- name: Push Docker image
run: |
docker push gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }}
docker push gitea.malio.fr/malio-dev/sirh:latest

View File

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

2
.idea/SIRH.iml generated
View File

@@ -154,6 +154,8 @@
<excludeFolder url="file://$MODULE_DIR$/var" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-idn" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console_3.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
</component>
</project>

View File

@@ -1,5 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:9cad43df-2147-4989-b7a4-443067034884
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
3:0:f407a514-c6b4-4b26-9555-445a85892502
4:0:09e221b8-067a-488b-9c1d-4e155a333079
5:0:9d8c1ad3-2491-4642-964a-666003c14128
.</data>
</component>
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>

2
.idea/php.xml generated
View File

@@ -155,6 +155,8 @@
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />

90
CLAUDE.md Normal file
View File

@@ -0,0 +1,90 @@
# SIRH
## Mandatory Rules
- Any functional change MUST update `doc/` in the same intervention
- At the end of every feature addition or functional modification, update this CLAUDE.md to reflect new patterns, rules, or conventions introduced
## Commands
- `make start` — start Docker stack
- `make test` — run backend tests (PHPUnit)
- `make dev-nuxt` — dev frontend
- `cd frontend && npm run build` — build frontend
- `php bin/console cache:clear && php bin/console cache:warmup` — clear cache after deploy
## Stack
- Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
## Project Structure
- `src/` — Symfony domain, API resources, state providers/processors, services
- `frontend/` — Nuxt app (pages, components, composables, services)
- `migrations/` — Doctrine migrations (always include working `down()`)
- `doc/` — functional rules and business documentation
## Functional Rules
- Reference: `doc/functional-rules.md` (mandatory reading before any business logic change)
- Complementary: `doc/leave-rollover.md`, `doc/rtt-rollover.md`
## Domain Model
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
- Any real modification resets both `isSiteValid=false` and `isValid=false`
- No-op saves preserve existing validations
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
- INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
## Frontend Patterns
### Table styling (standard across all pages)
- Header: `grid border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`
- Body wrapper: `border-x border-b border-primary-500 rounded-b-md`
- Rows: `grid items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500`
- Page wrapper for scroll: `h-full flex flex-col overflow-hidden`, table container: `min-h-0 overflow-auto rounded-md bg-white`
### Drawer buttons (AppDrawer)
- Edit mode: `grid grid-cols-2 gap-3` → Supprimer (red, left) + Modifier (primary, right)
- Create mode: centered `+ Ajouter` button, w-[200px]
- Exception: Users drawer has NO delete button
- All "Ajouter" buttons across the app use "+" prefix
### API Platform (backend)
- Custom operations use Processor (write) / Provider (read)
- File uploads: `deserialize: false` on Post, access file via RequestStack
- Upload dir: `%kernel.project_dir%/var/uploads`
## Audit Logging
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
- Documentation: `doc/audit-logging.md`
## Backend Conventions
- Prefer explicit DTOs over associative arrays
- Business rules in backend (providers/processors/services), frontend is display/interaction only
- Keep backend PHP DTOs aligned with frontend TS DTOs (`frontend/services/dto/*`)
- Update unit tests when constructor/service signatures change
## Language
- UI is in French
- User communicates in French
- Code (variables, comments) in English

View File

@@ -1,4 +1,5 @@
# SIRH
Application de gestion des absences employée
## Importer un dump de prod en dev
@@ -17,3 +18,8 @@ Remplie la base avec le dump :
```shell
docker compose exec -T db psql -U root -d sirh < sirh.sql
```
## Mettre SUPER_ADMIN sur un user
```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
```

View File

@@ -24,6 +24,7 @@
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",

175
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "b540b6cb25ef55c5eebccb57c76da584",
"content-hash": "bdc04f5145303388bac52809ea3f4b05",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -5374,6 +5374,92 @@
],
"time": "2026-01-28T10:46:31+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b",
"reference": "5d26d1958aeeba2ace8cc64a3a93d4f5d8f8022b",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<5.2|>=7",
"phpdocumentor/type-resolver": "<1.5.1"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.7"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-06T13:17:40+00:00"
},
{
"name": "symfony/monolog-bridge",
"version": "v8.0.4",
@@ -5685,6 +5771,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",

View File

@@ -19,6 +19,7 @@ security:
pattern: ^/login_check
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
json_login:
check_path: /login_check
username_path: username
@@ -29,6 +30,7 @@ security:
pattern: ^/api
stateless: true
provider: app_user_provider
user_checker: App\Security\UserChecker
jwt: ~
logout:
path: /api/logout

View File

@@ -26,6 +26,14 @@ services:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
App\Service\Rtt\RttRecoveryComputationService:
arguments:
$rttStartDate: '%env(RTT_START_DATE)%'
App\State\EmployeeRttSummaryProvider:
arguments:
$rttStartDate: '%env(RTT_START_DATE)%'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.35'
app.version: '0.1.74'

View File

@@ -0,0 +1,25 @@
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=change-me
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
DATABASE_URL="postgresql://sirh_user:password@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=change-me
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
# App
DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"

View File

@@ -0,0 +1,80 @@
# --- Stage 1: Build backend ---
FROM php:8.4-cli AS backend-build
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
unzip curl git \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
COPY bin bin/
COPY config config/
COPY migrations migrations/
COPY public public/
COPY src src/
COPY templates templates/
# --- Stage 2: Build frontend ---
FROM node:lts-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
# Nginx: log to stdout/stderr
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# Remove default nginx site
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/sirh.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html
# Frontend from stage 2
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN mkdir -p /var/www/html/var \
&& chown -R www-data:www-data /var/www/html/var
WORKDIR /var/www/html
EXPOSE 80
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]

28
deploy/docker/deploy.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export SIRH_IMAGE_TAG="$TAG"
echo "==> Deploying sirh:${TAG}..."
echo "==> Pulling image..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"

View File

@@ -0,0 +1,13 @@
services:
app:
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
container_name: sirh-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

46
deploy/docker/nginx.conf Normal file
View File

@@ -0,0 +1,46 @@
server {
listen 80;
server_name _;
root /var/www/html/frontend/.output/public;
index index.html;
access_log /dev/stdout;
error_log /dev/stderr;
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass 127.0.0.1:9000;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass 127.0.0.1:9000;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT

View File

@@ -0,0 +1,12 @@
server {
listen 80;
server_name sirh.malio-dev.fr;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

57
doc/audit-logging.md Normal file
View File

@@ -0,0 +1,57 @@
# Journal des actions (Audit Log)
## Objectif
Tracer les actions utilisateurs pour diagnostiquer rapidement les problèmes de calcul signalés.
Quand un utilisateur signale une incohérence dans ses heures, RTT ou congés, le journal permet de voir
exactement ce qui a été modifié, par qui, et quand.
## Accès
- **Rôle requis** : `ROLE_SUPER_ADMIN` (rôle caché, non visible dans l'interface de gestion des utilisateurs)
- **Ajout du rôle** : directement en base de données
```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'xxx';
```
- **Page** : `/audit-logs` (lien "Journal" dans la sidebar, visible uniquement avec le rôle)
## Actions tracées
| Processor | Entité | Actions |
|---|---|---|
| `AbsenceWriteProcessor` | Absence | create, delete |
| `WorkHourBulkUpsertProcessor` | WorkHour | create, update, delete |
| `WorkHourSiteValidationProcessor` | WorkHour | site_validate |
| `WorkHourBulkValidationProcessor` | WorkHour | validate |
| `WorkHourBulkSiteValidationProcessor` | WorkHour | site_validate |
| `EmployeeWriteProcessor` | Employee | create, update (changement contrat) |
| `ContractSuspensionWriteProcessor` | ContractSuspension | create, update |
| `EmployeeRttPaymentProcessor` | EmployeeRttPayment | update |
| `EmployeeFractionedDaysProcessor` | EmployeeLeaveBalance | update |
## Données stockées
Chaque entrée contient :
- **employee** : l'employé concerné (FK, nullable)
- **username** : l'utilisateur qui a effectué l'action
- **action** : type d'action (create, update, delete, validate, site_validate)
- **entityType** : type d'entité (work_hour, absence, employee, etc.)
- **description** : description lisible en français
- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs
- **affectedDate** : date de travail ou début d'absence (pour filtrage par période)
- **createdAt** : horodatage de l'action
## Filtres disponibles
- Par employé
- Par plage de dates (date affectée)
- Par type d'entité
## Pagination
Les résultats sont paginés par 50 entrées. L'API retourne `{items, total, page, perPage}` et accepte un query param `page`.
## Convention
Tout nouveau processor traitant des entités impactant les calculs (heures, absences, contrats, RTT)
doit intégrer le service `AuditLogger` et logger les actions create/update/delete.

326
doc/deployment-docker.md Normal file
View File

@@ -0,0 +1,326 @@
# Deploiement Docker — SIRH
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer SIRH.
Creer la base de donnees pour SIRH :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE sirh_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne SIRH sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/sirh
sudo chown -R $(whoami):$(whoami) /var/www/sirh
cd /var/www/sirh
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO-DEV`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
container_name: sirh-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export SIRH_IMAGE_TAG="$TAG"
echo "==> Deploying sirh:${TAG}..."
echo "==> Pulling image..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/sirh_prod?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
# App
DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
```
### 6. Generer les cles JWT
```bash
mkdir -p config/jwt
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
```
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
```bash
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
```
### 7. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 8. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/sirh.conf` :
```nginx
server {
listen 80;
server_name sirh.malio-dev.fr;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Activer le site :
```bash
sudo ln -sf /etc/nginx/sites-available/sirh.conf /etc/nginx/sites-enabled/sirh.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 9. Deployer
```bash
./deploy.sh
```
### 10. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp sirh.sql user@serveur:/tmp/sirh.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio sirh_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio sirh_prod < /tmp/sirh.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/sirh
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/sirh.sql
```
### Structure finale du dossier
```
/var/www/sirh/
├── docker-compose.yml
├── deploy.sh
├── .env
├── config/jwt/
│ ├── private.pem
│ └── public.pem
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/sirh
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v0.1.61 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v0.1.60
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v0.1.60
```
---
## Voir les logs
```bash
cd /var/www/sirh
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (tar.gz)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/sirh-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/sirh/.env /var/www/sirh-docker/.env
cp -a /var/www/sirh/config/jwt /var/www/sirh-docker/config/jwt
cp -a /var/www/sirh/var/uploads /var/www/sirh-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/sirh-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/sirh-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
9. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/sirh-docker /var/www/sirh`

View File

@@ -40,6 +40,10 @@ Documents complementaires:
## 3) Heures (vue jour)
- Visibilité des employés:
- vue jour: un employé sans contrat à la date sélectionnée est masqué
- vue semaine: un employé sans contrat sur aucun jour de la semaine est masqué
- même règle pour les heures classiques et les heures conducteurs
- Saisie par salarié et par date:
- matin / après-midi / soir
- pour `PRESENCE`: demi-journées matin/après-midi
@@ -112,11 +116,48 @@ Documents complementaires:
- contrats >= 39h: de 39h à 43h
- Tranche 50%:
- au-delà de 43h
- Date de début RTT (`RTT_START_DATE` dans `.env`):
- les semaines dont la fin est antérieure à cette date sont ignorées dans le calcul de récupération
- permet d'éviter les déficits fictifs avant la mise en service du logiciel
- Semaine en déficit (heures travaillées < heures contrat):
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
- Nature `INTERIM`:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
## 6bis) Heures Conducteurs
- Écran dédié `/driver-hours` pour les employés dont le contrat est marqué `isDriver = true`
- Les conducteurs sont exclus de l'écran `/hours` classique
- Colonnes spécifiques (vue jour):
- Heure de jour (durée HH:MM via TimeSelect)
- Heure de nuit (durée HH:MM via TimeSelect)
- Heure atelier (durée HH:MM via TimeSelect)
- Total (somme jour + nuit + atelier, calculé)
- Petit déjeuner (checkbox)
- Déjeuner (checkbox)
- Dîner (checkbox)
- Nuitée (checkbox)
- Stockage backend:
- `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour`
- `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour`
- les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
- Absences `countAsWorkedHours=true`: les minutes créditées sont ajoutées aux heures de jour (vue jour et vue semaine), même logique que les employés classiques
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
- Vue semaine:
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
## 7) Fériés
- Les jours fériés sont identifiés et affichés
@@ -145,16 +186,22 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Modification employé:
- uniquement prénom, nom, site
- pas de modification de contrat depuis ce drawer
- Liste employés — filtre par statut de contrat:
- 3 options: "Avec contrat" (défaut), "Sans contrat", "Tous"
- "Avec contrat": employés ayant une période de contrat active à la date du jour
- "Sans contrat": employés sans période de contrat active
- "Tous": aucun filtrage sur le contrat
- Détail employé:
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
- action `Clôturer`:
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
- action `Modifier` (clôture/solde de tout compte):
- bouton actif s'il existe un contrat en cours non clôturé, ou si le dernier contrat est terminé (sans contrat actif après)
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
- champs saisissables:
- `contractEndDate` (prérempli à aujourd'hui)
- `contractEndDate` (prérempli à aujourd'hui si contrat en cours, à la date de fin existante si contrat terminé)
- `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
- backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
- cas du contrat déjà terminé: permet de modifier `paidLeaveSettled` et le commentaire sur le dernier contrat terminé (ex: solde de tout compte CDD)
- action `Ajouter`:
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
@@ -170,14 +217,22 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22 (standard mensuel)
- arrêt maladie long (absences continues de type `M` > 1 mois):
- premier mois de maladie (date début + 1 mois calendaire): acquisition normale (`2,50`/mois)
- après le premier mois: acquisition réduite à `2,00`/mois (facteur `0,80` appliqué aux deux taux jours et samedis)
- en cas de mois partiellement couvert par la période réduite, le prorata est calculé en jours calendaires (jours normaux × taux normal + jours réduits × taux réduit)
- la détection est automatique à partir des absences MALADIE consécutives en base (tolérance de gap ≤ 3 jours)
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
- contrat `4h`:
- acquis annuel CP: `10`
- acquis annuel samedi: `0`
- en cours d'acquisition: `0.83` jour/mois
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
- contrat `FORFAIT`:
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
- bonus weekend/férié: chaque jour travaillé un weekend ou jour férié donne 1 jour de congé supplémentaire (journée ≥ 5h = 1.0 jour, demi-journée > 0h et < 5h = 0.5 jour), sans plafond
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
- pas de samedi (`0`)
@@ -225,6 +280,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
- date limite de calcul: uniquement les semaines terminées (jusqu'au dernier dimanche), **ou** la semaine en cours si tous les jours existants sont validés RH (`isValid = true`). En cas de fin de contrat en milieu de semaine, seuls les jours jusqu'à la date de fin sont vérifiés.
- compteur global:
- affiché en **jours** (1 jour = 7h = 420 minutes)
- report:
@@ -242,10 +298,100 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- `rate`: taux de majoration, valeurs `25` ou `50`
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures paiements antérieurs), affichée à partir de juillet (masquée si nul)
- Reste = Report cumulé + Total du mois Payé du mois (balance courante en fin de mois)
- affichage:
- le compteur global RTT est affiché en **heures** (format `Xh00`)
## 10) Notifications
## 10) Export récap. congés & RTT (PDF)
- Accessible depuis la page Employés via le bouton "Export récap. congés" (réservé `ROLE_ADMIN`)
- Clic direct (pas de drawer), génère un PDF A4 portrait à la date du jour
- Endpoint: `GET /api/leave-recap/print`
- Seuls les employés avec contrat actif sont inclus
- Données groupées par site
### Colonnes du tableau
| Colonne | Logique |
|---------|---------|
| Nom | lastName + firstName |
| Contrat | Contract.name |
| CP N-1 restant | CDI/CDD: acquis N-1 pris sur N-1. Forfait: report N-1 restant |
| Samedi restant | CDI/CDD: samedis acquis N-1 pris. Forfait: `-` |
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
- Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage
- Endpoint: `GET /api/salary-recap/print?month=YYYY-MM`
- Données groupées par site, un en-tête par site
### Colonnes du tableau
| Colonne | Source | Logique |
|---------|--------|---------|
| Nom | Employee | firstName + lastName |
| Base | Contract.name | Via EmployeeContractResolver pour le mois |
| Jour de présence Cadre | WorkHour | Uniquement FORFAIT (PRESENCE). Somme isPresentMorning (0.5) + isPresentAfternoon (0.5) |
| Heures de nuit | WorkHour | Non-chauffeurs: calcul intervalles nuit (00:00-06:00, 21:00-24:00). Chauffeurs: somme nightHoursMinutes |
| Panier de nuit | WorkHour | Nombre de jours où (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit 4h entre 21h-6h) |
| Heures payés | EmployeeRttPayment | Somme base25Minutes + base50Minutes du mois, convertie en heures |
| Congés - Nombre | Absence code 'C' | Jours (demi-journées = 0.5) |
| Congés - Date | Absence code 'C' | Dates formatées dd/mm |
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle |
## 12) Frais
- Onglet "Frais" sur la fiche employé (icône `mdi:account-cash-outline`)
- Entité `MileageAllowance` (table `mileage_allowances`)
- Champs:
- `month` (mois, obligatoire)
- `kilometers` (nombre de km, optionnel)
- `amount` (montant en €, optionnel)
- `comment` (commentaire, optionnel)
- `receiptPath` / `receiptName` (justificatif Km, PDF)
- `amountReceiptPath` / `amountReceiptName` (justificatif Montant, PDF)
- Règle de validation:
- le mois est obligatoire
- au moins un des deux champs `kilometers` ou `amount` doit être > 0
- les deux peuvent être remplis simultanément
- Tableau: colonnes Mois, Nombre de Km, Montant €, Commentaire, Justif. Km, Justif. Montant
- Deux justificatifs distincts (upload PDF uniquement):
- Justificatif Km : upload via `/mileage_allowances/{id}/receipt`, téléchargement via GET même URL
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
## 13) Observations
- Onglet "Observation" sur la fiche employé (icône `mdi:note-text-outline`)
- Entité `Observation` (table `observations`)
- Champs:
- `month` (mois, obligatoire)
- `content` (texte d'observation, obligatoire)
- Contrainte: une seule observation par mois par employé (unique sur `employee_id + month`)
- Tableau: colonnes Mois | Observation
- Drawer avec champs mois (`type="month"`) et textarea "Observation"
- CRUD standard: création, modification, suppression avec confirmation
## 14) Verrouillage utilisateur
- Champ `isLocked` (boolean, default false) sur l'entité `User`
- Un admin peut verrouiller/déverrouiller un utilisateur depuis la page Utilisateurs (checkbox dans le drawer)
- Un utilisateur verrouillé ne peut plus se connecter (vérification via `UserChecker` sur les firewalls `login` et `api`)
- Colonne "Statut" dans le tableau utilisateurs avec label "Actif" (vert) ou "Verrouillé" (rouge)
## 15) Notifications
- Icône cloche en topbar:
- badge = nombre de notifications non lues
@@ -258,3 +404,31 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Une notification est créée uniquement quand un chef de site termine la validation complète:
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
- destinataires: utilisateurs `ROLE_ADMIN`
## 16) Export PDF des heures annuelles
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
- Génère un PDF avec le détail jour par jour des heures de l'employé
- Seuls les jours avec heures saisies ou absence sont affichés
### Colonnes selon le mode de suivi
- **TIME (non-chauffeur)**: Date | Absence | Début matin | Fin matin | Début après-midi | Fin après-midi | Début soir | Fin soir | Total
- **PRESENCE (forfait)**: Date | Absence | Présence matin | Présence après-midi | Total
- **Chauffeur**: Date | Absence | Heures jour | Heures nuit | Heures atelier | Total
### Changement de contrat en cours d'année
- Si l'employé change de mode de suivi (TIME/PRESENCE) ou de statut chauffeur en cours d'année, le PDF affiche des sections séparées avec les colonnes adaptées à chaque période
- Le nom du contrat est affiché en sous-titre de chaque section
### Calcul du total
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
### Nom du fichier
- Format: `{nom}_{prenom}_{annee}.pdf`

View File

@@ -93,28 +93,29 @@
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<div v-if="editingAbsence" class="grid grid-cols-2 gap-3 pt-2">
<button
v-if="editingAbsence"
type="button"
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="handleDelete"
>
Supprimer
</button>
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="handleCancel"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
+ Ajouter
</button>
</div>
</form>

View File

@@ -96,17 +96,10 @@
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="handleCancel"
>
Annuler
</button>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Imprimer

View File

@@ -5,19 +5,12 @@
</Transition>
<Transition name="drawer-panel">
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
<div class="flex items-center justify-between border-b border-neutral-200 bg-tertiary-500 px-6 py-4">
<h2 class="text-lg font-semibold text-neutral-900">
<div class="flex items-center justify-between px-[20px] pt-8 pb-8">
<h2 class="text-[32px] font-semibold text-primary-500">
{{ title }}
</h2>
<button
type="button"
class="rounded-md p-2 text-neutral-500 hover:bg-neutral-100"
@click="close"
>
</button>
</div>
<div class="overflow-y-auto p-6" style="max-height: calc(100% - 65px)">
<div class="overflow-y-auto px-[20px]" style="max-height: calc(100% - 65px)">
<slot />
</div>
</div>

View File

@@ -0,0 +1,67 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export heures annuelles">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
Année <span class="text-red-600">*</span>
</label>
<select
id="yearly-hours-year"
v-model="selectedYear"
:class="selectFieldClass"
>
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
employeeId: number
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', year: number): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const selectedYear = ref(currentYear)
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => {
emit('submit', selectedYear.value)
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
selectedYear.value = currentYear
}
}
)
</script>

View File

@@ -0,0 +1,87 @@
<template>
<AppDrawer v-model="drawerOpen" title="Récapitulatif Salaire">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="salary-recap-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="salary-recap-month"
v-model="selectedMonth"
type="month"
:class="monthFieldClass"
/>
<p v-if="showMonthError" class="mt-1 text-sm text-red-600">
Le mois est obligatoire.
</p>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', month: string): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const selectedMonth = ref(defaultMonth)
const validationTouched = ref(false)
const isMonthValid = computed(() => selectedMonth.value.trim() !== '')
const showMonthError = computed(() => validationTouched.value && !isMonthValid.value)
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const monthFieldClass = computed(() => {
if (showMonthError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (!isMonthValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const handleSubmit = () => {
validationTouched.value = true
if (!isMonthValid.value) return
emit('submit', selectedMonth.value)
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
validationTouched.value = false
}
}
)
</script>

View File

@@ -14,7 +14,7 @@
<div
v-if="isOpen"
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
class="z-50 absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
>
<div class="flex flex-col gap-2">
<label

View File

@@ -0,0 +1,241 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-4">Heure de jour</span>
<span class="pl-2">Heure de nuit</span>
<span class="pl-2">Heure atelier</span>
<span class="pl-2">Total</span>
<span>Petit déj.</span>
<span>Déjeuner</span>
<span>Dîner</span>
<span>Nuitée</span>
<span v-if="isAdmin" class="flex justify-between items-center">
<span>Valider</span>
<input
ref="bulkValidationInput"
:checked="isBulkValidationChecked"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="employee in employees"
:key="employee.id"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: dayGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
title="Validation site"
>
<Icon name="mdi:check"/>
</span>
</p>
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
Modifié le {{ getRowUpdatedAt(employee.id) }}
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<div class="pl-4">
<TimeSelect
v-model="rows[employee.id].dayHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="pl-2">
<TimeSelect
v-model="rows[employee.id].nightHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="pl-2">
<TimeSelect
v-model="rows[employee.id].workshopHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="pl-2 text-sm font-semibold">
{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasBreakfast"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasLunch"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasDinner"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div class="flex">
<input
v-model="rows[employee.id].hasOvernight"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/>
</div>
<div v-if="isAdmin" class="text-right">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { DriverHourRow } from '~/services/dto/work-hour'
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null)
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
employees: Employee[]
isAdmin: boolean
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
contractLabel: (employee: Employee) => string
isRowLocked: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean
isSiteValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean
canToggleSiteValidation: (employeeId: number) => boolean
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
isBulkSiteValidationChecked: boolean
isBulkSiteValidationIndeterminate: boolean
canBulkToggleSiteValidation: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowUpdatedAt: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string
}>()
const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
const onBulkSiteValidationChange = (event: Event) => {
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
}
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
watch(
() => props.isBulkValidationIndeterminate,
(isIndeterminate) => {
if (!bulkValidationInput.value) return
bulkValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
watch(
() => props.isBulkSiteValidationIndeterminate,
(isIndeterminate) => {
if (!bulkSiteValidationInput.value) return
bulkSiteValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.weekday }}<br>{{ day.dayDate }}</span>
<span>Jour/Nuit <br>sem.</span>
<span>Atelier <br>sem.</span>
<span>Total <br>sem.</span>
<span>Total <br>h. supp.</span>
<span>+25%</span>
<span>+50%</span>
<span>Total <br>récup.</span>
<span>Petit <br>déj.</span>
<span>Déj.</span>
<span>Dîner</span>
<span>Nuit.</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="row.employeeId"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
</div>
<div
v-for="daily in row.daily"
:key="daily.date"
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
<div v-if="daily.workshopMinutes">A {{ formatMinutes(daily.workshopMinutes) }}</div>
<div v-if="daily.hasBreakfast || daily.hasLunch || daily.hasDinner || daily.hasOvernight" class="text-[10px] flex gap-1 mt-0.5">
<span v-if="daily.hasBreakfast" title="Petit déjeuner">PD</span>
<span v-if="daily.hasLunch" title="Déjeuner">DJ</span>
<span v-if="daily.hasDinner" title="Dîner">DI</span>
<span v-if="daily.hasOvernight" title="Nuitée">NU</span>
</div>
</div>
<div class="font-semibold leading-4">
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyWorkshopMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyTotalMinutes) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
<div class="font-semibold">{{ row.weeklyBreakfastCount ?? 0 }}</div>
<div class="font-semibold">{{ row.weeklyLunchCount ?? 0 }}</div>
<div class="font-semibold">{{ row.weeklyDinnerCount ?? 0 }}</div>
<div class="font-semibold">{{ row.weeklyOvernightCount ?? 0 }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
}
defineProps<{
isWeekLoading: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
formatMinutes: (minutes: number) => string
}>()
</script>

View File

@@ -0,0 +1,207 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-3 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Mois</p>
<p>Montant </p>
<p>Commentaire</p>
</div>
<div v-if="bonuses.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune prime.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in bonuses"
:key="item.id"
class="grid grid-cols-3 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p>{{ item.amount }} </p>
<p>{{ item.comment ?? '-' }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification prime' : 'Nouvelle prime'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="bonus-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="bonus-month"
v-model="form.month"
type="month"
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="bonus-amount">
Montant () <span class="text-red-600">*</span>
</label>
<input
id="bonus-amount"
v-model.number="form.amount"
type="number"
step="0.01"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="bonus-comment">
Commentaire
</label>
<textarea
id="bonus-comment"
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { Bonus } from '~/services/dto/bonus'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
bonuses: Bonus[]
}>()
const emit = defineEmits<{
(event: 'create', data: { month: string; amount: number; comment?: string }): void
(event: 'update', id: number, data: { month: string; amount: number; comment?: string }): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Bonus | null>(null)
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
amount: 0,
comment: ''
})
const isFormValid = computed(() => {
return form.month && form.amount > 0
})
const monthLabels: Record<number, string> = {
1: 'Janvier',
2: 'Février',
3: 'Mars',
4: 'Avril',
5: 'Mai',
6: 'Juin',
7: 'Juillet',
8: 'Août',
9: 'Septembre',
10: 'Octobre',
11: 'Novembre',
12: 'Décembre'
}
const formatMonth = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const month = date.getMonth() + 1
const year = date.getFullYear()
return `${monthLabels[month]} ${year}`
}
const resetForm = () => {
form.month = currentYearMonth()
form.amount = 0
form.comment = ''
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Bonus) => {
isEditing.value = true
editingItem.value = item
form.month = item.month.substring(0, 7)
form.amount = item.amount
form.comment = item.comment ?? ''
isDrawerOpen.value = true
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
amount: form.amount,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data)
} else {
emit('create', data)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette prime ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<section class="mt-8">
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
<div class="overflow-hidden bg-white">
<div class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Contrat</p>
<p>Heures</p>
<p>Date de début</p>
@@ -10,11 +10,11 @@
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
Aucun historique de contrat.
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in contractHistory"
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
>
<p>{{ contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
@@ -133,21 +133,13 @@
</label>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
:disabled="isContractSubmitting"
@click="onUpdateContractDrawerOpen(false)"
>
Annuler
</button>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isContractSubmitting || !isContractEndDateValid"
>
Enregistrer
Modifier
</button>
</div>
</form>
@@ -248,21 +240,25 @@
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
:disabled="isCreateContractSubmitting"
@click="onUpdateCreateContractDrawerOpen(false)"
>
Annuler
</button>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="create-contract-is-driver">
<input
id="create-contract-is-driver"
v-model="createContractForm.isDriver"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Chauffeur
</label>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
>
Enregistrer
+ Ajouter
</button>
</div>
</form>
@@ -297,6 +293,7 @@ type CreateContractForm = {
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
isDriver: boolean
}
const props = defineProps<{

View File

@@ -1,35 +1,35 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
<p class="col-start-1 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Année acquis :</strong> {{
<p class="col-start-1 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours
</p>
<p class="col-start-2 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Pris :</strong>
<p class="col-start-2 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Pris :</strong>
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
</p>
<p class="col-start-3 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Reste à prendre :</strong>
<p class="col-start-3 p-[10px] border-b border-r border-b-white border-r-primary-500 bg-primary-500 text-white"><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Samedi acquis :</span>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
</p>
<p v-else class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Année N-1 acquis :</span>
<p v-else class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Année N-1 acquis :</span>
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
<p v-if="!isForfaitRule" class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.takenSaturdays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
<p v-if="!isForfaitRule" class="col-start-3 p-[10px] border-r border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours
</p>
<p v-else class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
<p v-else class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.previousYearTakenDays) }} Jours
</p>
<p v-if="isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
<p v-if="isForfaitRule" class="col-start-3 p-[10px] border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
</p>
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">

View File

@@ -0,0 +1,346 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-6 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Mois</p>
<p>Nombre de Km</p>
<p>Montant </p>
<p>Commentaire</p>
<p>Justif. Km</p>
<p>Justif. Montant</p>
</div>
<div v-if="allowances.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucun frais kilométrique.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in allowances"
:key="item.id"
class="grid grid-cols-6 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p>{{ item.kilometers }}</p>
<p>{{ item.amount ? item.amount + ' €' : '-' }}</p>
<p>{{ item.comment ?? '-' }}</p>
<p class="min-w-0">
<a
v-if="item.receiptPath"
:href="getKmReceiptUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.receiptName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
<p class="min-w-0">
<a
v-if="item.amountReceiptPath"
:href="getAmountReceiptUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.amountReceiptName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" title="Frais">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="mileage-month"
v-model="form.month"
type="month"
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-kilometers">
Nombre de Km
</label>
<input
id="mileage-kilometers"
v-model.number="form.kilometers"
type="number"
step="0.1"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-amount">
Montant ()
</label>
<input
id="mileage-amount"
v-model.number="form.amount"
type="number"
step="0.01"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
<p class="mt-1 text-sm text-neutral-500">Au moins un des deux champs doit être rempli</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-km-receipt">
Justificatif Km
</label>
<div v-if="isEditing && editingItem?.receiptName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.receiptName }}
</div>
<input
id="mileage-km-receipt"
ref="kmFileInput"
type="file"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onKmFileChange"
/>
<p v-if="kmFileError" class="mt-1 text-sm text-red-600">{{ kmFileError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-amount-receipt">
Justificatif Montant
</label>
<div v-if="isEditing && editingItem?.amountReceiptName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.amountReceiptName }}
</div>
<input
id="mileage-amount-receipt"
ref="amountFileInput"
type="file"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onAmountFileChange"
/>
<p v-if="amountFileError" class="mt-1 text-sm text-red-600">{{ amountFileError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="mileage-comment">
Commentaire
</label>
<textarea
id="mileage-comment"
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type {MileageAllowance} from '~/services/dto/mileage-allowance'
import {getKmReceiptUrl, getAmountReceiptUrl} from '~/services/mileage-allowances'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
allowances: MileageAllowance[]
apiBase: string
}>()
const emit = defineEmits<{
(event: 'create', data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
(event: 'update', id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<MileageAllowance | null>(null)
const selectedKmFile = ref<File | undefined>(undefined)
const selectedAmountFile = ref<File | undefined>(undefined)
const kmFileInput = ref<HTMLInputElement | null>(null)
const amountFileInput = ref<HTMLInputElement | null>(null)
const kmFileError = ref('')
const amountFileError = ref('')
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
kilometers: 0,
amount: 0,
comment: ''
})
const isFormValid = computed(() => {
return form.month && (form.kilometers > 0 || form.amount > 0) && !kmFileError.value && !amountFileError.value
})
const monthLabels: Record<number, string> = {
1: 'Janvier',
2: 'Février',
3: 'Mars',
4: 'Avril',
5: 'Mai',
6: 'Juin',
7: 'Juillet',
8: 'Août',
9: 'Septembre',
10: 'Octobre',
11: 'Novembre',
12: 'Décembre'
}
const formatMonth = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const month = date.getMonth() + 1
const year = date.getFullYear()
return `${monthLabels[month]} ${year}`
}
const resetForm = () => {
form.month = currentYearMonth()
form.kilometers = 0
form.amount = 0
form.comment = ''
selectedKmFile.value = undefined
selectedAmountFile.value = undefined
kmFileError.value = ''
amountFileError.value = ''
if (kmFileInput.value) {
kmFileInput.value.value = ''
}
if (amountFileInput.value) {
amountFileInput.value.value = ''
}
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: MileageAllowance) => {
isEditing.value = true
editingItem.value = item
// Extract YYYY-MM from YYYY-MM-DD
form.month = item.month.substring(0, 7)
form.kilometers = item.kilometers
form.amount = item.amount
form.comment = item.comment ?? ''
selectedKmFile.value = undefined
selectedAmountFile.value = undefined
if (kmFileInput.value) {
kmFileInput.value.value = ''
}
if (amountFileInput.value) {
amountFileInput.value.value = ''
}
isDrawerOpen.value = true
}
const onKmFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
kmFileError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedKmFile.value = undefined
target.value = ''
return
}
kmFileError.value = ''
selectedKmFile.value = file ?? undefined
}
const onAmountFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
amountFileError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedAmountFile.value = undefined
target.value = ''
return
}
amountFileError.value = ''
selectedAmountFile.value = file ?? undefined
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
kilometers: form.kilometers,
amount: form.amount,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data, selectedKmFile.value, selectedAmountFile.value)
} else {
emit('create', data, selectedKmFile.value, selectedAmountFile.value)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer ce frais kilométrique ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -0,0 +1,187 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-2 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Mois</p>
<p>Observation</p>
</div>
<div v-if="observations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune observation.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in observations"
:key="item.id"
class="grid grid-cols-2 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatMonth(item.month) }}</p>
<p class="truncate">{{ item.content }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification observation' : 'Nouvelle observation'">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="observation-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="observation-month"
v-model="form.month"
type="month"
class="capitalize mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="observation-content">
Observation <span class="text-red-600">*</span>
</label>
<textarea
id="observation-content"
v-model="form.content"
rows="5"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Observation..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { Observation } from '~/services/dto/observation'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
observations: Observation[]
}>()
const emit = defineEmits<{
(event: 'create', data: { month: string; content: string }): void
(event: 'update', id: number, data: { month: string; content: string }): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Observation | null>(null)
const currentYearMonth = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
const form = reactive({
month: currentYearMonth(),
content: ''
})
const isFormValid = computed(() => {
return form.month && form.content.trim().length > 0
})
const monthLabels: Record<number, string> = {
1: 'Janvier',
2: 'Février',
3: 'Mars',
4: 'Avril',
5: 'Mai',
6: 'Juin',
7: 'Juillet',
8: 'Août',
9: 'Septembre',
10: 'Octobre',
11: 'Novembre',
12: 'Décembre'
}
const formatMonth = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const month = date.getMonth() + 1
const year = date.getFullYear()
return `${monthLabels[month]} ${year}`
}
const resetForm = () => {
form.month = currentYearMonth()
form.content = ''
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Observation) => {
isEditing.value = true
editingItem.value = item
form.month = item.month.substring(0, 7)
form.content = item.content
isDrawerOpen.value = true
}
const onSubmit = () => {
const data = {
month: `${form.month}-01`,
content: form.content
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data)
} else {
emit('create', data)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette observation ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -22,8 +22,8 @@
</button>
</div>
<p class="text-[16px]">
<span class="font-semibold">RTT À LA DATE DU JOUR :</span>
{{ formatMinutes(summary?.availableMinutes ?? 0) }}
<span class="font-bold">RTT À LA SEMAINE {{ lastCompleteWeek }} : </span>
<span class="font-bold">{{ formatMinutes(summary?.availableMinutes ?? 0) }}</span>
</p>
<div class="flex justify-center">
<button
@@ -40,34 +40,53 @@
<table class="w-full table-fixed border-collapse text-[18px]">
<colgroup>
<col />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
</colgroup>
<thead>
<tr>
<th class="px-5 py-[10px] text-left font-bold text-primary-500 border border-primary-500">Semaine</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Heure</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th>
</tr>
</thead>
<tbody>
<!-- Report row (only on June when carry > 0) -->
<tr v-if="showReportRow">
<!-- Report N-1 row (RTT rollover carry, June only) -->
<tr v-if="showCarryRow" class="bg-tertiary-500">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
</tr>
<!-- Report mois précédent (cumulated balance from previous months, July+) -->
<tr v-if="showMonthReportRow" class="bg-tertiary-500">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
</tr>
<!-- Week rows (always 5) -->
@@ -84,19 +103,27 @@
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus25Minutes) }}</span>
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base25Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base50Minutes) }}</span>
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus25Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus50Minutes) }}</span>
<span v-if="week">{{ formatMinutes(week.base25Minutes + week.bonus25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base50Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus50Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
@@ -110,9 +137,11 @@
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500 border-t-2">Total</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.overtime) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
</tr>
@@ -121,9 +150,11 @@
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
</tr>
@@ -131,11 +162,13 @@
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base25 - (currentPayment?.paidBase25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus25 - (currentPayment?.paidBonus25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base50 - (currentPayment?.paidBase50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus50 - (currentPayment?.paidBonus50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(resteTotal) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
</tr>
</tbody>
</table>
@@ -225,6 +258,17 @@ const emit = defineEmits<{
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
}>()
// --- Last complete week number ---
const lastCompleteWeek = computed(() => {
const now = new Date()
const startOfYear = new Date(now.getFullYear(), 0, 1)
const dayOfYear = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000) + 1
const dayOfWeek = now.getDay() || 7 // Monday = 1, Sunday = 7
const currentWeek = Math.ceil((dayOfYear - dayOfWeek + 10) / 7)
return currentWeek - 1
})
// --- Month navigation ---
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
@@ -290,44 +334,113 @@ const paddedWeeks = computed((): (EmployeeRttWeekSummary | null)[] => {
return padded
})
// --- Report row ---
// --- Carry row (RTT rollover from previous year, June only) ---
const reportMonth = computed(() => {
const carryMonth = computed(() => {
if (!props.summary) return 6
const carryMonth = props.summary.carryMonth
// Report appears in the month AFTER carryMonth (wrapping 12 -> 1)
return carryMonth >= 12 ? 1 : carryMonth + 1
const cm = props.summary.carryMonth
return cm >= 12 ? 1 : cm + 1
})
const showReportRow = computed(() => {
return (
currentMonth.value === reportMonth.value &&
(props.summary?.carryFromPreviousYearMinutes ?? 0) > 0
)
const showCarryRow = computed(() => {
if (currentMonth.value !== carryMonth.value) return false
if ((props.summary?.carryFromPreviousYearMinutes ?? 0) === 0) return false
// On the first exercise, hide carry if carry month is before rttStartDate
if (props.summary?.rttStartDate) {
const startDate = new Date(props.summary.rttStartDate)
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
const startMonthDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1)
if (viewDate < startMonthDate) return false
}
return true
})
// --- Totals ---
// --- Month report row (cumulated balance from previous months) ---
// Months of the exercise in order, starting from the carry month
const exerciseMonths = computed((): number[] => {
const start = carryMonth.value
const startIdx = orderedMonths.indexOf(start as (typeof orderedMonths)[number])
if (startIdx === -1) return [...orderedMonths]
return [...orderedMonths.slice(startIdx), ...orderedMonths.slice(0, startIdx)]
})
const monthReport = computed(() => {
if (!props.summary) return { base25: 0, bonus25: 0, total25: 0, base50: 0, bonus50: 0, total50: 0, total: 0 }
const cm = currentMonth.value
const cmIdx = exerciseMonths.value.indexOf(cm)
const previousMonths = exerciseMonths.value.slice(0, cmIdx)
// Start from carry (included in the cumulation)
let base25 = props.summary.carryBase25Minutes
let bonus25 = props.summary.carryBonus25Minutes
let base50 = props.summary.carryBase50Minutes
let bonus50 = props.summary.carryBonus50Minutes
let total = props.summary.carryFromPreviousYearMinutes
// Add weeks from previous months
for (const w of props.summary.weeks) {
if (previousMonths.includes(w.month)) {
base25 += w.base25Minutes
bonus25 += w.bonus25Minutes
base50 += w.base50Minutes
bonus50 += w.bonus50Minutes
total += w.totalMinutes
}
}
// Subtract payments from previous months
for (const p of props.summary.monthPayments) {
if (previousMonths.includes(p.month)) {
base25 -= p.paidBase25Minutes
bonus25 -= p.paidBonus25Minutes
base50 -= p.paidBase50Minutes
bonus50 -= p.paidBonus50Minutes
total -= (p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
}
}
return { base25, bonus25, total25: base25 + bonus25, base50, bonus50, total50: base50 + bonus50, total }
})
const showMonthReportRow = computed(() => {
// Not on the carry month — carry row handles that
if (currentMonth.value === carryMonth.value) return false
// On the first exercise (containing rttStartDate), hide report for months before the start date
if (props.summary?.rttStartDate) {
const startDate = new Date(props.summary.rttStartDate)
const startYear = startDate.getFullYear()
const startMonth = startDate.getMonth() + 1
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
const startMonthDate = new Date(startYear, startMonth - 1, 1)
if (viewDate < startMonthDate) return false
}
const r = monthReport.value
return r.total !== 0
})
// --- Totals (current month weeks only) ---
const totals = computed(() => {
const weeks = weeksForCurrentMonth.value
const base = {
const positive = weeks.filter((w) => w.totalMinutes >= 0)
return {
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
base25: weeks.reduce((s, w) => s + w.base25Minutes, 0),
bonus25: weeks.reduce((s, w) => s + w.bonus25Minutes, 0),
base50: weeks.reduce((s, w) => s + w.base50Minutes, 0),
bonus50: weeks.reduce((s, w) => s + w.bonus50Minutes, 0),
base25: positive.reduce((s, w) => s + w.base25Minutes, 0),
bonus25: positive.reduce((s, w) => s + w.bonus25Minutes, 0),
total25: weeks.reduce((s, w) => s + w.base25Minutes + w.bonus25Minutes, 0),
base50: positive.reduce((s, w) => s + w.base50Minutes, 0),
bonus50: positive.reduce((s, w) => s + w.bonus50Minutes, 0),
total50: weeks.reduce((s, w) => s + w.base50Minutes + w.bonus50Minutes, 0),
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
}
if (showReportRow.value && props.summary) {
base.base25 += props.summary.carryBase25Minutes
base.bonus25 += props.summary.carryBonus25Minutes
base.base50 += props.summary.carryBase50Minutes
base.bonus50 += props.summary.carryBonus50Minutes
base.total += props.summary.carryFromPreviousYearMinutes
}
return base
})
const currentPayment = computed(() => {
@@ -341,8 +454,19 @@ const paidTotal = computed(() => {
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
})
const resteTotal = computed(() => {
return totals.value.total + paidTotal.value
const reste = computed(() => {
const total25 = monthReport.value.total25 + totals.value.total25
- (currentPayment.value?.paidBase25Minutes ?? 0) - (currentPayment.value?.paidBonus25Minutes ?? 0)
const total50 = monthReport.value.total50 + totals.value.total50
- (currentPayment.value?.paidBase50Minutes ?? 0) - (currentPayment.value?.paidBonus50Minutes ?? 0)
const base25 = Math.round(total25 / 1.25)
const bonus25 = total25 - base25
const base50 = Math.round(total50 / 1.5)
const bonus50 = total50 - base50
const total = monthReport.value.total + totals.value.total + paidTotal.value
return { base25, bonus25, total25, base50, bonus50, total50, total }
})
// --- Format ---
@@ -357,6 +481,11 @@ const formatMinutes = (minutes: number): string => {
return `${sign}${hours} h ${rest} m`
}
const formatCentiemes = (minutes: number): string => {
const value = minutes / 60
return value.toFixed(2).replace('.', ',')
}
// --- Payment drawer ---
const isPaymentDrawerOpen = ref(false)

View File

@@ -1,8 +1,8 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
@@ -42,10 +42,11 @@
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="employee in employees"
:key="employee.id"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: dayGridCols }"
>
<div class="text-neutral-900 min-w-0">
@@ -142,19 +143,19 @@
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pl-2 text-sm font-semibold text-neutral-700">
<div class="pl-2 text-sm font-semibold">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).dayMinutes)
}}
</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div class="text-sm font-semibold">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).nightMinutes)
}}
</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div class="text-sm font-semibold">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).totalMinutes)
}}
@@ -186,6 +187,7 @@
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,9 +1,9 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
@@ -14,12 +14,14 @@
<span>+25%</span>
<span>+50%</span>
<span>Total <br>récup.</span>
<span>Panier <br>nuit</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="row.employeeId"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0 hover:bg-tertiary-500"
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
@@ -67,6 +69,10 @@
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ (row.weeklyNightBasketCount ?? 0) > 0 ? row.weeklyNightBasketCount : '-' }}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,983 @@
import { computed, onMounted, ref, watch } from 'vue'
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import type { AbsenceType } from '~/services/dto/absence-type'
import type { Absence } from '~/services/dto/absence'
import type { HalfDay } from '~/services/dto/half-day'
import type { DriverHourRow } from '~/services/dto/work-hour'
import { listScopedEmployees } from '~/services/employees'
import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import { listPublicHolidays } from '~/services/public-holidays'
import {
bulkUpdateWorkHourSiteValidation,
bulkUpdateWorkHourValidation,
bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary,
listWorkHoursByDate,
updateWorkHourSiteValidation,
updateWorkHourValidation
} from '~/services/work-hours'
import {
formatDateLongFr,
formatWeekRangeFr,
getIsoWeekNumber,
getOffsetFromTodayYmd,
getWeekStartDate,
getTodayYmd,
parseYmd,
shiftYmd
} from '~/utils/date'
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
export const useDriverHoursPage = () => {
const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
const viewMode = ref<'day' | 'week'>('day')
const selectedDate = ref(getTodayYmd())
const employees = ref<Employee[]>([])
const employeeFilter = ref('')
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
const rows = ref<Record<number, DriverHourRow>>({})
const dayContext = ref<WorkHourDayContext | null>(null)
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
const isAbsenceDrawerOpen = ref(false)
const isAbsenceSubmitting = ref(false)
const editingAbsence = ref<Absence | null>(null)
const absenceForm = ref({
employeeId: '' as number | '',
typeId: '' as number | '',
startDate: '',
startHalf: 'AM' as HalfDay,
endDate: '',
endHalf: 'PM' as HalfDay,
comment: ''
})
const isLoading = ref(false)
const isWeekLoading = ref(false)
const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([])
const siteValidatingRowIds = ref<number[]>([])
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
const weekGridCols = '1.6fr repeat(7, 0.6fr) repeat(7, 0.6fr) repeat(4, 0.4fr)'
const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>()
for (const employee of employees.value) {
if (employee.site) {
siteMap.set(employee.site.id, employee.site)
}
}
return Array.from(siteMap.values()).sort((siteA, siteB) => {
const orderA = siteA.displayOrder ?? 0
const orderB = siteB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
return siteA.name.localeCompare(siteB.name, 'fr')
})
})
const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return employees.value.filter((employee) => {
if (employee.isDriver !== true) return false
const siteId = employee.site?.id
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return true
const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter)
})
})
const displayedEmployees = computed(() => {
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
})
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
if (!weeklySummary.value) return null
return {
...weeklySummary.value,
rows: weeklySummary.value.rows.filter((row) =>
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
)
}
})
const saveButtonClass = computed(() => {
if (isSubmitting.value || employees.value.length === 0) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
const isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId)
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
const canToggleSiteValidation = (employeeId: number) => {
if (!isSiteManager.value) return false
const row = rows.value[employeeId]
if (!row?.workHourId) return false
if (row.isValid) return false
return true
}
const canCreateEmptyValidationRow = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
if (!hasContractAtSelectedDate(employeeId)) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel
}
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const bulkValidatableEmployeeIds = computed(() => {
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
})
const isBulkValidationChecked = computed(() => {
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
})
const isBulkValidationIndeterminate = computed(() => {
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const bulkSiteValidatableEmployeeIds = computed(() => {
if (!isSiteManager.value) return []
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleSiteValidation(employeeId) || canCreateSiteValidationRowFromAbsence(employeeId))
})
const isBulkSiteValidationChecked = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isSiteValid ?? false)
})
const isBulkSiteValidationIndeterminate = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isSiteValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const canBulkToggleSiteValidation = computed(() => bulkSiteValidatableEmployeeIds.value.length > 0)
const dayContextByEmployeeId = computed(() => {
const map = new Map<number, WorkHourDayContext['rows'][number]>()
for (const row of dayContext.value?.rows ?? []) {
map.set(row.employeeId, row)
}
return map
})
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
const targetDate = target === 'yesterday'
? getOffsetFromTodayYmd(-1)
: target === 'tomorrow'
? getOffsetFromTodayYmd(1)
: getTodayYmd()
if (selectedDate.value === targetDate) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const selected = parseYmd(selectedDate.value)
if (!selected) {
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const today = new Date()
const targetDate = new Date(today)
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
const selectedWeekStart = getWeekStartDate(selected)
const targetWeekStart = getWeekStartDate(targetDate)
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
if (isActive) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const today = new Date()
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
const weekNumber = getIsoWeekNumber(today)
return `Sem. S${weekNumber}`
}
const formattedSelectedDate = computed(() => {
const parsed = parseYmd(selectedDate.value)
if (!parsed) return selectedDate.value
if (viewMode.value === 'week') {
return formatWeekRangeFr(parsed)
}
return formatDateLongFr(parsed)
})
const selectedYear = computed(() => {
const parsed = parseYmd(selectedDate.value)
return parsed ? parsed.getFullYear() : null
})
const selectedHolidayLabel = computed(() => {
const year = selectedYear.value
if (!year) return ''
return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
})
const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
const weekDayHeaders = computed(() => {
const days = weeklySummary.value?.days ?? []
return days.map((date) => {
const parsed = parseYmd(date)
if (!parsed) return { date, weekday: '', dayDate: '' }
const weekday = new Intl.DateTimeFormat('fr-FR', { weekday: 'short' }).format(parsed)
const dayDate = new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit' }).format(parsed)
return { date, weekday, dayDate }
})
})
const shiftDate = (steps: number) => {
const offset = viewMode.value === 'week' ? (steps * 7) : steps
const next = shiftYmd(selectedDate.value, offset)
if (!next) return
selectedDate.value = next
}
const setToday = () => { selectedDate.value = getTodayYmd() }
const setYesterday = () => { setToday(); shiftDate(-1) }
const setTomorrow = () => { setToday(); shiftDate(1) }
const setThisWeek = () => { selectedDate.value = getTodayYmd() }
const setPreviousWeek = () => {
const previousWeek = shiftYmd(getTodayYmd(), -7)
if (!previousWeek) return
selectedDate.value = previousWeek
}
const setNextWeek = () => {
const nextWeek = shiftYmd(getTodayYmd(), 7)
if (!nextWeek) return
selectedDate.value = nextWeek
}
const resetAbsenceForm = () => {
absenceForm.value = {
employeeId: '',
typeId: '',
startDate: '',
startHalf: 'AM',
endDate: '',
endHalf: 'PM',
comment: ''
}
}
const closeAbsenceDrawer = () => {
isAbsenceDrawerOpen.value = false
editingAbsence.value = null
resetAbsenceForm()
}
const toMinutes = (time: string): number => {
if (!time) return 0
const [hours, minutes] = time.split(':').map(Number)
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return 0
return (hours * 60) + minutes
}
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes)
const hours = Math.floor(safeMinutes / 60)
const rest = safeMinutes % 60
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
}
const minutesToTimeString = (minutes: number | null | undefined): string => {
if (minutes === null || minutes === undefined || minutes === 0) return ''
return formatMinutes(minutes)
}
const emptyRow = (): DriverHourRow => ({
workHourId: null,
dayHours: '',
nightHours: '',
workshopHours: '',
hasBreakfast: false,
hasLunch: false,
hasDinner: false,
hasOvernight: false,
isSiteValid: false,
isValid: false,
updatedAt: null
})
const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
if (row.isValid) return true
if (!isAdmin.value && row.isSiteValid) return true
return false
}
const contractLabel = (employee: Employee) => {
const contract = employee.contract
if (!contract) return '-'
return contract.name
}
const getRowMetrics = (employeeId: number) => {
const row = rows.value[employeeId] ?? emptyRow()
const credited = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
const dayMinutes = toMinutes(row.dayHours) + credited
const nightMinutes = toMinutes(row.nightHours)
const workshopMinutes = toMinutes(row.workshopHours)
const totalMinutes = dayMinutes + nightMinutes + workshopMinutes
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
return `${dayRow.absenceLabel} (${halfLabel})`
}
return `${dayRow.absenceLabel} (journée)`
}
const getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return { backgroundColor: '#6b7280' }
}
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt
if (!raw) return ''
const date = new Date(raw)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true
return dayRow.hasContractAtDate !== false
}
const hydrateRows = (workHours: WorkHour[]) => {
const byEmployeeId = new Map<number, WorkHour>()
for (const workHour of workHours) {
byEmployeeId.set(workHour.employee.id, workHour)
}
const nextRows: Record<number, DriverHourRow> = {}
for (const employee of employees.value) {
if (employee.isDriver !== true) continue
const workHour = byEmployeeId.get(employee.id)
nextRows[employee.id] = {
workHourId: workHour?.id ?? null,
dayHours: minutesToTimeString(workHour?.dayHoursMinutes),
nightHours: minutesToTimeString(workHour?.nightHoursMinutes),
workshopHours: minutesToTimeString(workHour?.workshopHoursMinutes),
hasBreakfast: workHour?.hasBreakfast ?? false,
hasLunch: workHour?.hasLunch ?? false,
hasDinner: workHour?.hasDinner ?? false,
hasOvernight: workHour?.hasOvernight ?? false,
isSiteValid: workHour?.isSiteValid ?? false,
isValid: workHour?.isValid ?? false,
updatedAt: workHour?.updatedAt ?? null
}
}
rows.value = nextRows
}
const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadPublicHolidaysForSelectedYear = async () => {
const year = selectedYear.value
if (!year) return
if (publicHolidaysByYear.value[year]) return
const holidays = await listPublicHolidays('metropole', year)
publicHolidaysByYear.value = {
...publicHolidaysByYear.value,
[year]: holidays
}
}
const loadAbsences = async () => {
absences.value = await listAbsences({
from: selectedDate.value,
to: selectedDate.value,
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
})
}
const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false
const start = absence.startDate.slice(0, 10)
const end = absence.endDate.slice(0, 10)
return selectedDate.value >= start && selectedDate.value <= end
}) ?? null
if (existing) {
editingAbsence.value = existing
absenceForm.value = {
employeeId,
typeId: existing.type?.id ?? '',
startDate: existing.startDate.slice(0, 10),
startHalf: existing.startHalf ?? 'AM',
endDate: existing.endDate.slice(0, 10),
endHalf: existing.endHalf ?? 'PM',
comment: existing.comment ?? ''
}
} else {
editingAbsence.value = null
absenceForm.value = {
employeeId,
typeId: '',
startDate: selectedDate.value,
startHalf: 'AM',
endDate: selectedDate.value,
endHalf: 'PM',
comment: ''
}
}
isAbsenceDrawerOpen.value = true
}
const refreshAfterAbsenceChange = async () => {
if (isAdmin.value) {
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
}
weeklySummary.value = null
await Promise.all([loadDayContext(), loadAbsences()])
}
const submitAbsence = async () => {
const form = absenceForm.value
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
isAbsenceSubmitting.value = true
try {
if (editingAbsence.value) {
await updateAbsence({
id: editingAbsence.value.id,
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: editingAbsence.value.comment ?? ''
})
} else {
await createAbsence({
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: ''
})
}
closeAbsenceDrawer()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}
}
const deleteAbsenceFromDrawer = async () => {
if (!editingAbsence.value || isAbsenceSubmitting.value) return
isAbsenceSubmitting.value = true
try {
await deleteAbsence(editingAbsence.value.id)
closeAbsenceDrawer()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}
}
const buildEmptyDriverEntry = (employeeId: number) => ({
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false,
dayHoursMinutes: null,
nightHoursMinutes: null,
workshopHoursMinutes: null,
hasBreakfast: false,
hasLunch: false,
hasDinner: false,
hasOvernight: false
})
const toggleValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
if (!row?.workHourId && checked) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [buildEmptyDriverEntry(employeeId)]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isValidationPending(employeeId)) return
validatingRowIds.value = [...validatingRowIds.value, employeeId]
try {
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isValid = checked
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleSiteValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
if (!row?.workHourId && checked) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [buildEmptyDriverEntry(employeeId)]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isSiteValidationPending(employeeId)) return
if (!canToggleSiteValidation(employeeId)) return
siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId]
try {
await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isSiteValid = checked
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = bulkValidatableEmployeeIds.value
if (employeeIds.length === 0) return
const pendingIds = new Set(validatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
}, { toast: false })
await loadWorkHours()
}
}
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée.'
})
return
}
validatingRowIds.value = Array.from(new Set([...validatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourValidation({
workDate: selectedDate.value,
isValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({ title: 'Erreur', message: 'Aucune ligne mise à jour.' })
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} ligne(s) validée(s).`
: `${result.updated} validation(s) retirée(s).`
})
} catch {
toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations.' })
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const toggleSiteValidationBulk = async (checked: boolean) => {
if (!isSiteManager.value) return
const employeeIds = bulkSiteValidatableEmployeeIds.value
if (employeeIds.length === 0) return
const pendingIds = new Set(siteValidatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateSiteValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
}, { toast: false })
await loadWorkHours()
}
}
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleSiteValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée côté site.'
})
return
}
siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourSiteValidation({
workDate: selectedDate.value,
isSiteValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({ title: 'Erreur', message: 'Aucune ligne site mise à jour.' })
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} validation(s) site enregistrée(s).`
: `${result.updated} validation(s) site retirée(s).`
})
} catch {
toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations site.' })
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const loadEmployees = async () => {
const scopedEmployees = await listScopedEmployees()
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
}
const loadWorkHours = async () => {
const workHours = await listWorkHoursByDate(selectedDate.value)
hydrateRows(workHours)
}
const loadWeeklySummary = async () => {
isWeekLoading.value = true
try {
weeklySummary.value = await getWeeklyWorkHourSummary(selectedDate.value)
} finally {
isWeekLoading.value = false
}
}
const loadDayContext = async () => {
dayContext.value = await getWorkHourDayContext(selectedDate.value)
}
const refreshByDate = async () => {
if (isAdmin.value) {
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
}
weeklySummary.value = null
await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
}
const loadPage = async () => {
isLoading.value = true
try {
await loadPublicHolidaysForSelectedYear()
await loadEmployees()
await loadAbsenceTypes()
await refreshByDate()
} finally {
isLoading.value = false
}
}
onMounted(loadPage)
watch(sites, (nextSites) => {
const currentSiteIds = nextSites.map((site) => site.id)
if (!sitesInitialized.value) {
if (currentSiteIds.length === 0) return
selectedSiteIds.value = currentSiteIds
sitesInitialized.value = true
return
}
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
}, { immediate: true })
watch(isAdmin, async (admin) => {
if (!admin) {
viewMode.value = 'day'
weeklySummary.value = null
await Promise.all([loadAbsenceTypes(), loadAbsences()])
return
}
await loadAbsenceTypes()
await loadAbsences()
}, { immediate: true })
watch(selectedDate, async () => {
await loadPublicHolidaysForSelectedYear()
await refreshByDate()
})
const handleSave = async () => {
if (isSubmitting.value || employees.value.length === 0) return
isSubmitting.value = true
try {
const driverEmployees = employees.value.filter(
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
)
const entries = driverEmployees.map((employee) => {
const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow()
const dayMin = toMinutes(row.dayHours)
const nightMin = toMinutes(row.nightHours)
const workshopMin = toMinutes(row.workshopHours)
return {
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false,
dayHoursMinutes: dayMin || null,
nightHoursMinutes: nightMin || null,
workshopHoursMinutes: workshopMin || null,
hasBreakfast: row.hasBreakfast,
hasLunch: row.hasLunch,
hasDinner: row.hasDinner,
hasOvernight: row.hasOvernight
}
})
if (entries.length === 0) return
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries
})
await refreshByDate()
} finally {
isSubmitting.value = false
}
}
return {
isAdmin,
isSelfUser,
isSiteManager,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
weeklySummary,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isRowLocked,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
}
}

View File

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

View File

@@ -43,7 +43,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
endDate: '',
isDriver: false
})
const createValidationTouched = reactive({
@@ -70,6 +71,17 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const lastEndedContractPeriod = computed(() => {
if (currentActiveContractPeriod.value) return null
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
const ended = history.filter((item) => item.endDate && item.endDate < today)
if (ended.length === 0) return null
return ended.reduce((latest, item) => (item.endDate! > latest.endDate! ? item : latest))
})
const editableContractPeriod = computed(() => currentActiveContractPeriod.value ?? lastEndedContractPeriod.value)
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
@@ -77,13 +89,15 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
if (active) {
if (!active.endDate) return true
return active.endDate > getTodayYmd()
}
return !!lastEndedContractPeriod.value
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
const active = editableContractPeriod.value
if (!active) return true
return !!active.endDate
})
@@ -134,15 +148,15 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
const period = editableContractPeriod.value
if (!current || !period) 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.contractId = period.contractId ?? current.contract?.id ?? ''
contractForm.contractName = period.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = period.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = period.contractNature
contractForm.startDate = period.startDate
contractForm.endDate = period.endDate ?? getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
@@ -171,8 +185,9 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
createContractForm.isDriver = false
createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
@@ -183,15 +198,16 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
const period = editableContractPeriod.value
if (!employee.value || isContractSubmitting.value || !period) return
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
if (contractForm.endDate < period.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
message: `La date de fin doit être postérieure au ${formatDate(period.startDate)}.`
})
return
}
@@ -224,8 +240,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (editableContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
@@ -244,7 +260,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()

View File

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

View File

@@ -0,0 +1,83 @@
import type { Ref } from 'vue'
import type { MileageAllowance } from '~/services/dto/mileage-allowance'
import type { Employee } from '~/services/dto/employee'
import {
listMileageAllowances,
createMileageAllowance,
updateMileageAllowance,
deleteMileageAllowance,
uploadKmReceipt,
uploadAmountReceipt
} from '~/services/mileage-allowances'
export const useEmployeeMileage = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const config = useRuntimeConfig()
const apiBase = (config.public.apiBase as string) ?? '/api'
const mileageAllowances = ref<MileageAllowance[]>([])
const isMileageLoading = ref(false)
const mileageDataLoaded = ref(false)
const loadMileageData = async () => {
if (!employee.value || isMileageLoading.value) return
isMileageLoading.value = true
try {
mileageAllowances.value = await listMileageAllowances(employee.value.id)
mileageDataLoaded.value = true
} finally {
isMileageLoading.value = false
}
}
const resetLoaded = () => {
mileageDataLoaded.value = false
}
const submitCreateMileage = async (data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
if (!employee.value) return
const result = await createMileageAllowance({
employeeId: employee.value.id,
month: data.month,
kilometers: data.kilometers,
amount: data.amount,
comment: data.comment
})
if (result?.id) {
if (kmFile) {
await uploadKmReceipt(apiBase, result.id, kmFile)
}
if (amountFile) {
await uploadAmountReceipt(apiBase, result.id, amountFile)
}
}
await reloadEmployee()
}
const submitUpdateMileage = async (id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
await updateMileageAllowance(id, data)
if (kmFile) {
await uploadKmReceipt(apiBase, id, kmFile)
}
if (amountFile) {
await uploadAmountReceipt(apiBase, id, amountFile)
}
await reloadEmployee()
}
const submitDeleteMileage = async (id: number) => {
await deleteMileageAllowance(id)
await reloadEmployee()
}
return {
mileageAllowances,
isMileageLoading,
mileageDataLoaded,
mileageApiBase: apiBase,
loadMileageData,
resetLoaded,
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage
}
}

View File

@@ -0,0 +1,61 @@
import type { Ref } from 'vue'
import type { Observation } from '~/services/dto/observation'
import type { Employee } from '~/services/dto/employee'
import {
listObservations,
createObservation,
updateObservation,
deleteObservation
} from '~/services/observations'
export const useEmployeeObservation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const observations = ref<Observation[]>([])
const isObservationLoading = ref(false)
const observationDataLoaded = ref(false)
const loadObservationData = async () => {
if (!employee.value || isObservationLoading.value) return
isObservationLoading.value = true
try {
observations.value = await listObservations(employee.value.id)
observationDataLoaded.value = true
} finally {
isObservationLoading.value = false
}
}
const resetLoaded = () => {
observationDataLoaded.value = false
}
const submitCreateObservation = async (data: { month: string; content: string }) => {
if (!employee.value) return
await createObservation({
employeeId: employee.value.id,
month: data.month,
content: data.content
})
await reloadEmployee()
}
const submitUpdateObservation = async (id: number, data: { month: string; content: string }) => {
await updateObservation(id, data)
await reloadEmployee()
}
const submitDeleteObservation = async (id: number) => {
await deleteObservation(id)
await reloadEmployee()
}
return {
observations,
isObservationLoading,
observationDataLoaded,
loadObservationData,
resetLoaded,
submitCreateObservation,
submitUpdateObservation,
submitDeleteObservation
}
}

View File

@@ -77,7 +77,7 @@ export const useHoursPage = () => {
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr) 0.3fr'
const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>()
@@ -99,6 +99,7 @@ export const useHoursPage = () => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return employees.value.filter((employee) => {
if (employee.isDriver === true) return false
const siteId = employee.site?.id
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return true
@@ -108,13 +109,19 @@ export const useHoursPage = () => {
})
})
const displayedEmployees = computed(() => {
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
})
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
if (!weeklySummary.value) return null
return {
...weeklySummary.value,
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId))
rows: weeklySummary.value.rows.filter((row) =>
visibleEmployeeIdSet.value.has(row.employeeId) && row.hasContractForWeek !== false
)
}
})
@@ -462,6 +469,9 @@ export const useHoursPage = () => {
const getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return { backgroundColor: '#6b7280' }
}
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
@@ -1035,7 +1045,7 @@ export const useHoursPage = () => {
isSubmitting.value = true
try {
const entries = employees.value
.filter((employee) => hasContractAtSelectedDate(employee.id))
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
.map((employee) => {
const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow()
@@ -1092,6 +1102,7 @@ export const useHoursPage = () => {
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,

View File

@@ -36,6 +36,21 @@
"create": "Impossible de créer l'utilisateur.",
"update": "Impossible de mettre à jour l'utilisateur.",
"delete": "Impossible de supprimer l'utilisateur."
},
"mileage": {
"create": "Impossible de créer le frais kilométrique.",
"update": "Impossible de mettre à jour le frais kilométrique.",
"delete": "Impossible de supprimer le frais kilométrique."
},
"bonus": {
"create": "Impossible de créer la prime.",
"update": "Impossible de mettre à jour la prime.",
"delete": "Impossible de supprimer la prime."
},
"observation": {
"create": "Impossible de créer l'observation.",
"update": "Impossible de mettre à jour l'observation.",
"delete": "Impossible de supprimer l'observation."
}
},
"success": {
@@ -67,6 +82,21 @@
"create": "Utilisateur créé.",
"update": "Utilisateur mis à jour.",
"delete": "Utilisateur supprimé."
},
"mileage": {
"create": "Frais kilométrique créé.",
"update": "Frais kilométrique mis à jour.",
"delete": "Frais kilométrique supprimé."
},
"bonus": {
"create": "Prime créée.",
"update": "Prime mise à jour.",
"delete": "Prime supprimée."
},
"observation": {
"create": "Observation créée.",
"update": "Observation mise à jour.",
"delete": "Observation supprimée."
}
}
}

View File

@@ -19,15 +19,29 @@
</NuxtLink>
</template>
<NuxtLink
v-if="isAdmin || !isDriver"
to="/hours"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/hours')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
:class="[
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
]"
>
<Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p>
</NuxtLink>
<NuxtLink
v-if="isAdmin || isDriver"
to="/driver-hours"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="[
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
]"
>
<Icon name="mdi:truck-outline" size="24"/>
<p>Heures Conducteurs</p>
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink
to="/employees"
@@ -70,6 +84,17 @@
<p>Utilisateurs</p>
</NuxtLink>
</template>
<NuxtLink
v-if="isSuperAdmin"
to="/audit-logs"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/audit-logs')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
<p>Journal</p>
</NuxtLink>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
@@ -91,5 +116,7 @@
const auth = useAuthStore()
const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
const isDriver = computed(() => auth.user?.isDriver ?? false)
const route = useRoute()
</script>

View File

@@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore()
if (!auth.checked) {
await auth.ensureSession()
}
const isSuperAdmin = auth.user?.roles?.includes('ROLE_SUPER_ADMIN')
if (!isSuperAdmin) {
return navigateTo('/')
}
})

View File

@@ -1,13 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Types d'absence</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un type
+ Ajouter un type
</button>
</div>
@@ -18,33 +18,33 @@
Aucun type pour le moment.
</div>
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_160px_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<span class="text-left">Code</span>
<span class="text-left">Libellé</span>
<span class="text-left">Couleur</span>
<span class="text-left">Compte en heures</span>
<span class="text-right">Actions</span>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-4 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span>Code</span>
<span>Libellé</span>
<span>Couleur</span>
<span>Compte en heures</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="type in absenceTypes"
:key="type.id"
class="grid grid-cols-[120px_160px_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-4 items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="openEdit(type)"
>
<span class="font-semibold text-left">{{ type.code }}</span>
<span class="text-left">{{ type.label }}</span>
<div class="flex items-center gap-2 justify-start">
<span>{{ type.code }}</span>
<span>{{ type.label }}</span>
<div class="flex items-center gap-2">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{ backgroundColor: type.color }"
/>
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
</div>
<div class="text-left">
<div>
<span
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
@@ -52,22 +52,6 @@
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(type)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(type)"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
@@ -145,20 +129,29 @@
La couleur est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="confirmDelete(editingType)"
>
Annuler
Supprimer
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
+ Ajouter
</button>
</div>
</form>

View File

@@ -0,0 +1,252 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1>
<div class="flex items-end gap-4 pb-6 flex-wrap">
<div>
<label class="text-md font-semibold text-neutral-700">Employé</label>
<select
v-model="filters.employeeId"
class="mt-2 h-[42px] 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"
>
<option :value="undefined">Tous</option>
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
{{ emp.lastName }} {{ emp.firstName }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">Du</label>
<input
v-model="filters.from"
type="date"
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>
<label class="text-md font-semibold text-neutral-700">Au</label>
<input
v-model="filters.to"
type="date"
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>
<label class="text-md font-semibold text-neutral-700">Type</label>
<select
v-model="filters.entityType"
class="mt-2 h-[42px] 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"
>
<option :value="undefined">Tous</option>
<option value="work_hour">Heures</option>
<option value="absence">Absences</option>
<option value="employee">Employé</option>
<option value="contract_suspension">Suspension</option>
<option value="rtt_payment">Paiement RTT</option>
<option value="fractioned_days">Jours fractionnés</option>
</select>
</div>
<button
type="button"
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="search"
>
Rechercher
</button>
</div>
<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="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Aucune entrée trouvée.
</div>
<template v-else>
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span>Date action</span>
<span>Utilisateur</span>
<span>Action</span>
<span>Type</span>
<span>Employé</span>
<span>Description</span>
<span>Date affectée</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<template v-for="log in logs" :key="log.id">
<div
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="toggleExpand(log.id)"
>
<span>{{ formatDateTime(log.createdAt) }}</span>
<span>{{ log.username }}</span>
<span>
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
{{ actionLabel(log.action) }}
</span>
</span>
<span>{{ entityTypeLabel(log.entityType) }}</span>
<span>{{ log.employeeName ?? '-' }}</span>
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
</div>
<div
v-if="expandedIds.has(log.id)"
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
>
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
<div v-if="log.changes.old">
<p class="font-bold text-red-600 mb-2">Ancien</p>
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
</div>
<div v-if="log.changes.new">
<p class="font-bold text-green-600 mb-2">Nouveau</p>
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
</div>
</div>
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
</div>
</template>
</div>
</div>
<div class="flex items-center justify-between pt-4">
<p class="text-md text-neutral-500">
{{ total }} résultat{{ total > 1 ? 's' : '' }} page {{ currentPage }}/{{ totalPages }}
</p>
<div class="flex gap-3">
<button
type="button"
:disabled="currentPage <= 1"
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
@click="goToPage(currentPage - 1)"
>
Précédent
</button>
<button
type="button"
:disabled="currentPage >= totalPages"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
@click="goToPage(currentPage + 1)"
>
Suivant
</button>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log'
import type { Employee } from '~/services/dto/employee'
import { fetchAuditLogs } from '~/services/audit-logs'
import { listEmployees } from '~/services/employees'
definePageMeta({
middleware: 'super-admin'
})
useHead({ title: 'Journal des actions' })
const logs = ref<AuditLog[]>([])
const employees = ref<Employee[]>([])
const isLoading = ref(false)
const expandedIds = ref(new Set<number>())
const total = ref(0)
const currentPage = ref(1)
const perPage = ref(50)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
const filters = reactive<{
employeeId?: number
from?: string
to?: string
entityType?: string
}>({})
const loadLogs = async (page = 1) => {
isLoading.value = true
try {
const result = await fetchAuditLogs({ ...filters, page })
logs.value = result.items
total.value = result.total
currentPage.value = result.page
perPage.value = result.perPage
expandedIds.value.clear()
} finally {
isLoading.value = false
}
}
const search = () => {
loadLogs(1)
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
loadLogs(page)
}
}
const toggleExpand = (id: number) => {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
}
const formatDateTime = (dt: string) => {
const d = new Date(dt)
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
const formatDate = (d: string) => {
return d.split('-').reverse().join('/')
}
const actionLabel = (action: string): string => {
const map: Record<string, string> = {
create: 'Créer',
update: 'Modifier',
delete: 'Suppr.',
validate: 'Valid.',
site_validate: 'Valid. site',
}
return map[action] ?? action
}
const actionClass = (action: string): string => {
const map: Record<string, string> = {
create: 'bg-green-500',
update: 'bg-blue-500',
delete: 'bg-red-500',
validate: 'bg-purple-500',
site_validate: 'bg-indigo-500',
}
return map[action] ?? 'bg-neutral-500'
}
const entityTypeLabel = (type: string): string => {
const map: Record<string, string> = {
work_hour: 'Heures',
absence: 'Absence',
employee: 'Employé',
contract_suspension: 'Suspension',
rtt_payment: 'RTT',
fractioned_days: 'Fract.',
}
return map[type] ?? type
}
onMounted(async () => {
employees.value = await listEmployees()
await loadLogs()
})
</script>

View File

@@ -14,7 +14,7 @@
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreateFromToday"
>
Ajouter une absence
+ Ajouter une absence
</button>
<button
type="button"

View File

@@ -0,0 +1,183 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures Conducteurs</h1>
</div>
<HoursToolbar
v-model:selected-date="selectedDate"
v-model:view-mode="viewMode"
v-model:selected-site-ids="selectedSiteIds"
v-model:employee-filter="employeeFilter"
:is-admin="isAdmin"
:sites="sites"
:absence-types="absenceTypes"
:formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass"
:week-shortcut-button-class="weekShortcutButtonClass"
:get-week-shortcut-label="getWeekShortcutLabel"
@set-yesterday="setYesterday"
@set-today="setToday"
@set-tomorrow="setTomorrow"
@set-previous-week="setPreviousWeek"
@set-this-week="setThisWeek"
@set-next-week="setNextWeek"
@shift-date="shiftDate"
/>
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="visibleEmployees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Aucun conducteur accessible.
</div>
<div v-else class="flex min-h-0 flex-col gap-4">
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
<DriverHoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="displayedEmployees"
:is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:contract-label="contractLabel"
:is-row-locked="isRowLocked"
:has-contract-at-selected-date="hasContractAtSelectedDate"
:is-validation-pending="isValidationPending"
:is-site-validation-pending="isSiteValidationPending"
:can-toggle-validation="canToggleValidation"
:can-toggle-site-validation="canToggleSiteValidation"
:can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
:on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-row-updated-at="getRowUpdatedAt"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
<DriverHoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
</div>
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
<button
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="saveButtonClass"
:disabled="isSubmitting || visibleEmployees.length === 0"
@click="handleSave"
>
Enregistrer
</button>
</div>
</div>
<AbsenceFormDrawer
v-model="isAbsenceDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="absenceForm"
:editing-absence="editingAbsence"
:is-submitting="isAbsenceSubmitting"
:lock-employee="true"
:lock-dates="true"
:show-comment="false"
@submit="submitAbsence"
@delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer"
/>
</div>
</template>
<script setup lang="ts">
const {
isAdmin,
isSiteManager,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isRowLocked,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
isSelectedDateHoliday,
handleSave
} = useDriverHoursPage()
useHead({
title: 'Heures Conducteurs'
})
</script>

View File

@@ -13,7 +13,16 @@
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between">
<div>
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<div class="flex items-center gap-4">
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<button
class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
title="Export heures annuelles"
@click="isYearlyHoursDrawerOpen = true"
>
<Icon name="mdi:printer" size="24" />
</button>
</div>
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
<div class="text-right">
@@ -55,6 +64,36 @@
<Icon name="mdi:schedule" size="24" class="align-self"/>
RTT
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'mileage'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'mileage'"
>
<Icon name="mdi:account-cash-outline" size="24" class="align-self"/>
Frais
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'bonus'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'bonus'"
>
<Icon name="mdi:money-100" size="24" class="align-self"/>
Prime
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'observation'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'observation'"
>
<Icon name="mdi:note-text-outline" size="24" class="align-self"/>
Observation
</button>
</div>
</div>
<div class="min-h-0 flex-1">
@@ -117,12 +156,66 @@
</div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
</div>
<div v-else-if="activeTab === 'mileage'" class="h-full">
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesMileageTab
v-else
class="h-full"
:allowances="mileageAllowances"
:api-base="mileageApiBase"
@create="submitCreateMileage"
@update="submitUpdateMileage"
@delete="submitDeleteMileage"
/>
</div>
<div v-else-if="activeTab === 'bonus'" class="h-full">
<div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesBonusTab
v-else
class="h-full"
:bonuses="bonuses"
@create="submitCreateBonus"
@update="submitUpdateBonus"
@delete="submitDeleteBonus"
/>
</div>
<div v-else-if="activeTab === 'observation'" class="h-full">
<div v-if="isObservationLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesObservationTab
v-else
class="h-full"
:observations="observations"
@create="submitCreateObservation"
@update="submitUpdateObservation"
@delete="submitDeleteObservation"
/>
</div>
</div>
</div>
<EmployeeYearlyHoursDrawer
v-if="employee"
v-model="isYearlyHoursDrawerOpen"
:employee-id="employee.id"
@submit="handleYearlyHoursPrint"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
const { printPdf } = usePdfPrinter()
const isYearlyHoursDrawerOpen = ref(false)
const {
employee,
isLoading,
@@ -173,9 +266,31 @@ const {
addSuspensionForm,
currentActiveContractPeriodId,
isLeaveLoading,
isRttLoading
isRttLoading,
mileageAllowances,
isMileageLoading,
mileageApiBase,
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage,
bonuses,
isBonusLoading,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus,
observations,
isObservationLoading,
submitCreateObservation,
submitUpdateObservation,
submitDeleteObservation
} = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (year: number) => {
if (!employee.value) return
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${year}`)
isYearlyHoursDrawerOpen.value = false
}
useHead(() => ({
title: employee.value
? `${employee.value.firstName} ${employee.value.lastName}`

View File

@@ -3,19 +3,43 @@
<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 class="flex items-center gap-3">
<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="handleLeaveRecapPrint"
>
Export récap. congés
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isSalaryRecapOpen = true"
>
Export récap. salaire
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un employé
</button>
</div>
</div>
<div class="flex gap-10 py-7">
<div class="flex gap-3 py-7">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
<select
v-model="contractStatusFilter"
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
>
<option value="active">Avec contrat</option>
<option value="inactive">Sans contrat</option>
<option value="all">Tous</option>
</select>
</div>
</div>
@@ -40,7 +64,7 @@
<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>
<p>{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
@@ -170,6 +194,17 @@
La date de fin est obligatoire pour un CDD.
</p>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="is-driver">
<input
id="is-driver"
v-model="form.isDriver"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Chauffeur
</label>
</div>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -189,6 +224,11 @@
</div>
</form>
</AppDrawer>
<SalaryRecapDrawer
v-model="isSalaryRecapOpen"
@submit="handleSalaryRecapPrint"
/>
</div>
</template>
@@ -200,7 +240,9 @@ 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 SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter'
useHead({
title: 'Employés'
@@ -209,6 +251,8 @@ useHead({
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isSalaryRecapOpen = ref(false)
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
const drawerTitle = computed(() =>
@@ -219,20 +263,21 @@ const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
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) => {
return employees.value.filter((employee) => {
const siteId = employee.site?.id
return !!siteId && selectedSiteIds.value.includes(siteId)
})
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return bySite
if (contractStatusFilter.value === 'active' && !employee.hasActiveContract) return false
if (contractStatusFilter.value === 'inactive' && employee.hasActiveContract) return false
return bySite.filter((employee) => {
if (!filter) return true
const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter)
@@ -246,7 +291,8 @@ const form = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
contractEndDate: '',
isDriver: false
})
const validationTouched = reactive({
@@ -431,7 +477,8 @@ const handleSubmit = async () => {
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null
contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver
})
}
@@ -442,6 +489,7 @@ const handleSubmit = async () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -485,9 +533,19 @@ const openCreate = () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
isDrawerOpen.value = true
}
const handleLeaveRecapPrint = async () => {
await printPdf('/leave-recap/print')
}
const handleSalaryRecapPrint = async (month: string) => {
await printPdf(`/salary-recap/print?month=${month}`)
isSalaryRecapOpen.value = false
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return

View File

@@ -38,7 +38,7 @@
<HoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="visibleEmployees"
:employees="displayedEmployees"
:is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
@@ -126,6 +126,7 @@ const {
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,

View File

@@ -68,7 +68,9 @@ const handleSubmit = async () => {
try {
await auth.login(username.value, password.value)
await router.push('/calendar')
const isAdmin = auth.user?.roles?.includes('ROLE_ADMIN')
const isDriver = auth.user?.isDriver
await router.push(isAdmin ? '/calendar' : isDriver ? '/driver-hours' : '/hours')
} finally {
isSubmitting.value = false
}

View File

@@ -1,13 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un site
+ Ajouter un site
</button>
</div>
@@ -18,26 +18,26 @@
Aucun site pour le moment.
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[1fr_140px_160px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-[1fr_140px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Nom</span>
<span class="text-left">Couleur</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="site in sites"
:key="site.id"
class="grid grid-cols-[1fr_140px_160px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-[1fr_140px] items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
draggable="true"
@click="openEdit(site)"
@dragstart="handleDragStart($event, site)"
@dragover="handleDragOver"
@drop="handleDrop($event, site)"
>
<span class="flex items-center gap-2 text-left cursor-pointer">
<span class="flex items-center gap-2 text-left">
<span class="select-none text-xs">::</span>
<span>{{ site.name }}</span>
</span>
@@ -48,22 +48,6 @@
/>
<span class="text-md uppercase text-neutral-500">{{ site.color }}</span>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(site)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(site)"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
@@ -98,20 +82,29 @@
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<div v-if="editingSite" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="confirmDelete(editingSite)"
>
Annuler
Supprimer
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
+ Ajouter
</button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un utilisateur
+ Ajouter un utilisateur
</button>
</div>
@@ -18,42 +18,40 @@
Aucun utilisateur pour le moment.
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[1fr_1fr_140px_1fr_140px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Utilisateur</span>
<span class="text-left">Employé</span>
<span class="text-left">Accès</span>
<span class="text-left">Sites</span>
<span class="text-right">Actions</span>
<span class="text-left">Statut</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="user in users"
:key="user.id"
class="grid grid-cols-[1fr_1fr_140px_1fr_140px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-5 items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="openEdit(user)"
>
<span class="text-left">{{ user.username }}</span>
<span class="text-left">
<span>{{ user.username }}</span>
<span>
{{ user.employee ? `${user.employee.firstName} ${user.employee.lastName}` : '-' }}
</span>
<span class="text-left text-sm text-neutral-600">
{{ getAccessLabel(user) }}
<span>{{ getAccessLabel(user) }}</span>
<span>{{ getSiteLabels(user) }}</span>
<span>
<span
v-if="user.isLocked"
class="inline-block rounded-full bg-red-100 px-3 py-1 text-sm font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="inline-block rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700"
>Actif</span>
</span>
<span class="text-left text-sm text-neutral-600">
{{ getSiteLabels(user) }}
</span>
<div class="flex justify-end">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(user)"
>
Modifier
</button>
</div>
</div>
</div>
</div>
@@ -177,20 +175,27 @@
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
>
Annuler
</button>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.isLocked"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Un compte verrouillé ne peut plus se connecter.
</p>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
{{ editingUser ? 'Modifier' : '+ Ajouter' }}
</button>
</div>
</form>
@@ -227,7 +232,8 @@ const form = reactive({
password: '',
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[]
siteIds: [] as number[],
isLocked: false
})
const validationTouched = reactive({
@@ -338,6 +344,7 @@ const resetForm = () => {
form.employeeId = ''
form.accessMode = 'admin'
form.siteIds = []
form.isLocked = false
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
@@ -365,6 +372,7 @@ const openEdit = (user: User) => {
}
form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked
const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
@@ -418,7 +426,8 @@ const handleSubmit = async () => {
username: form.username,
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId
employeeId,
isLocked: form.isLocked
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -442,7 +451,8 @@ const handleSubmit = async () => {
username: form.username,
plainPassword: form.password,
roles,
employeeId
employeeId,
isLocked: form.isLocked
})
if (form.accessMode === 'sites' && form.siteIds.length > 0) {

View File

@@ -0,0 +1,33 @@
import type { AuditLog } from './dto/audit-log'
export type AuditLogFilters = {
employeeId?: number
from?: string
to?: string
entityType?: string
page?: number
}
export type AuditLogPage = {
items: AuditLog[]
total: number
page: number
perPage: number
}
export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
const api = useApi()
const params: Record<string, string> = {}
if (filters.employeeId) params.employeeId = String(filters.employeeId)
if (filters.from) params.from = filters.from
if (filters.to) params.to = filters.to
if (filters.entityType) params.entityType = filters.entityType
if (filters.page) params.page = String(filters.page)
return api.get<AuditLogPage>(
'/audit-logs',
params,
{ toast: false }
)
}

View File

@@ -0,0 +1,54 @@
import type { Bonus } from './dto/bonus'
import { extractItems } from '~/utils/api'
export const listBonuses = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Bonus[] | { 'hydra:member'?: Bonus[] }>(
'/bonuses',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Bonus>(data)
}
export const createBonus = async (data: {
employeeId: number
month: string
amount: number
comment?: string
}) => {
const api = useApi()
return api.post<Bonus>('/bonuses', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.bonus.create',
toastErrorKey: 'errors.bonus.create'
})
}
export const updateBonus = async (id: number, data: {
month: string
amount: number
comment?: string
}) => {
const api = useApi()
return api.patch<Bonus>(`/bonuses/${id}`, {
month: data.month,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.bonus.update',
toastErrorKey: 'errors.bonus.update'
})
}
export const deleteBonus = async (id: number) => {
const api = useApi()
return api.delete(`/bonuses/${id}`, {}, {
toastSuccessKey: 'success.bonus.delete',
toastErrorKey: 'errors.bonus.delete'
})
}

View File

@@ -0,0 +1,12 @@
export type AuditLog = {
id: number
employeeName: string | null
employeeId: number | null
username: string
action: string
entityType: string
description: string
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
affectedDate: string | null
createdAt: string
}

View File

@@ -0,0 +1,7 @@
export type Bonus = {
id: number
month: string
amount: number
comment: string | null
createdAt: string
}

View File

@@ -32,4 +32,5 @@ export type EmployeeRttSummary = {
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
rttStartDate: string | null
}

View File

@@ -18,6 +18,7 @@ export type ContractHistoryItem = {
comment?: string | null
periodId?: number | null
suspensions?: ContractSuspension[]
isDriver?: boolean
}
export type Employee = {
@@ -26,6 +27,8 @@ export type Employee = {
lastName: string
site: Site
contract?: Contract | null
hasActiveContract?: boolean
isDriver?: boolean
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null

View File

@@ -0,0 +1,12 @@
export type MileageAllowance = {
id: number
month: string
kilometers: number
amount: number
comment: string | null
receiptPath: string | null
receiptName: string | null
amountReceiptPath: string | null
amountReceiptName: string | null
createdAt: string
}

View File

@@ -0,0 +1,6 @@
export type Observation = {
id: number
month: string
content: string
createdAt: string
}

View File

@@ -2,4 +2,5 @@ export type UserData = {
id: number
username: string
roles: string[]
isDriver: boolean
}

View File

@@ -4,5 +4,6 @@ export type User = {
id: number
username: string
roles: string[]
isLocked: boolean
employee?: Employee | null
}

View File

@@ -13,6 +13,13 @@ export type WorkHour = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
dayHoursMinutes?: number | null
nightHoursMinutes?: number | null
workshopHoursMinutes?: number | null
hasBreakfast?: boolean
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
isSiteValid?: boolean
isValid?: boolean
updatedAt?: string | null
@@ -28,17 +35,30 @@ export type WorkHourEntryPayload = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
dayHoursMinutes?: number | null
nightHoursMinutes?: number | null
workshopHoursMinutes?: number | null
hasBreakfast?: boolean
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
}
export type WeeklyWorkHourDailySummary = {
date: string
dayMinutes: number
nightMinutes: number
workshopMinutes?: number
totalMinutes: number
present?: number | null
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
hasNightBasket?: boolean
hasBreakfast?: boolean
hasLunch?: boolean
hasDinner?: boolean
hasOvernight?: boolean
}
export type WeeklyWorkHourRowSummary = {
@@ -52,12 +72,20 @@ export type WeeklyWorkHourRowSummary = {
daily: WeeklyWorkHourDailySummary[]
weeklyDayMinutes: number
weeklyNightMinutes: number
weeklyWorkshopMinutes?: number
weeklyTotalMinutes: number
weeklyPresenceCount?: number
weeklyOvertimeTotalMinutes?: number
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
weeklyNightBasketCount?: number
isDriver?: boolean
weeklyBreakfastCount?: number
weeklyLunchCount?: number
weeklyDinnerCount?: number
weeklyOvernightCount?: number
hasContractForWeek?: boolean
}
export type WeeklyWorkHourSummary = {
@@ -77,9 +105,24 @@ export type WorkHourDayContextRow = {
absentAfternoon: boolean
creditedMinutes: number
creditedPresenceUnits: number
isDriverContract?: boolean
}
export type WorkHourDayContext = {
workDate: string
rows: WorkHourDayContextRow[]
}
export type DriverHourRow = {
workHourId: number | null
dayHours: string
nightHours: string
workshopHours: string
hasBreakfast: boolean
hasLunch: boolean
hasDinner: boolean
hasOvernight: boolean
isSiteValid: boolean
isValid: boolean
updatedAt: string | null
}

View File

@@ -34,6 +34,7 @@ export const createEmployee = async (payload: {
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
isDriverInput?: boolean
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
@@ -43,7 +44,8 @@ export const createEmployee = async (payload: {
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null
contractEndDate: payload.contractEndDate ?? null,
isDriverInput: payload.isDriverInput ?? false
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -63,6 +65,7 @@ export const updateEmployee = async (
contractPaidLeaveSettled?: boolean
contractComment?: string | null
displayOrder?: number
isDriverInput?: boolean
}
) => {
const api = useApi()
@@ -91,6 +94,9 @@ export const updateEmployee = async (
if (payload.contractComment !== undefined) {
body.contractComment = payload.contractComment ?? null
}
if (payload.isDriverInput !== undefined) {
body.isDriverInput = payload.isDriverInput
}
return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,87 @@
import { $fetch } from 'ofetch'
import type { MileageAllowance } from './dto/mileage-allowance'
import { extractItems } from '~/utils/api'
export const listMileageAllowances = async (employeeId: number) => {
const api = useApi()
const data = await api.get<MileageAllowance[] | { 'hydra:member'?: MileageAllowance[] }>(
'/mileage_allowances',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<MileageAllowance>(data)
}
export const createMileageAllowance = async (data: {
employeeId: number
month: string
kilometers: number
amount: number
comment?: string
}) => {
const api = useApi()
return api.post<MileageAllowance>('/mileage_allowances', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
kilometers: data.kilometers,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.mileage.create',
toastErrorKey: 'errors.mileage.create'
})
}
export const updateMileageAllowance = async (id: number, data: {
month: string
kilometers: number
amount: number
comment?: string
}) => {
const api = useApi()
return api.patch<MileageAllowance>(`/mileage_allowances/${id}`, {
month: data.month,
kilometers: data.kilometers,
amount: data.amount,
comment: data.comment
}, {
toastSuccessKey: 'success.mileage.update',
toastErrorKey: 'errors.mileage.update'
})
}
export const deleteMileageAllowance = async (id: number) => {
const api = useApi()
return api.delete(`/mileage_allowances/${id}`, {}, {
toastSuccessKey: 'success.mileage.delete',
toastErrorKey: 'errors.mileage.delete'
})
}
export const uploadKmReceipt = async (baseURL: string, id: number, file: File) => {
const formData = new FormData()
formData.append('file', file)
return $fetch(`${baseURL}/mileage_allowances/${id}/receipt`, {
method: 'POST',
body: formData,
credentials: 'include'
})
}
export const uploadAmountReceipt = async (baseURL: string, id: number, file: File) => {
const formData = new FormData()
formData.append('file', file)
return $fetch(`${baseURL}/mileage_allowances/${id}/amount-receipt`, {
method: 'POST',
body: formData,
credentials: 'include'
})
}
export const getKmReceiptUrl = (baseURL: string, id: number): string => {
return `${baseURL}/mileage_allowances/${id}/receipt`
}
export const getAmountReceiptUrl = (baseURL: string, id: number): string => {
return `${baseURL}/mileage_allowances/${id}/amount-receipt`
}

View File

@@ -0,0 +1,50 @@
import type { Observation } from './dto/observation'
import { extractItems } from '~/utils/api'
export const listObservations = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Observation[] | { 'hydra:member'?: Observation[] }>(
'/observations',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Observation>(data)
}
export const createObservation = async (data: {
employeeId: number
month: string
content: string
}) => {
const api = useApi()
return api.post<Observation>('/observations', {
employee: `/api/employees/${data.employeeId}`,
month: data.month,
content: data.content
}, {
toastSuccessKey: 'success.observation.create',
toastErrorKey: 'errors.observation.create'
})
}
export const updateObservation = async (id: number, data: {
month: string
content: string
}) => {
const api = useApi()
return api.patch<Observation>(`/observations/${id}`, {
month: data.month,
content: data.content
}, {
toastSuccessKey: 'success.observation.update',
toastErrorKey: 'errors.observation.update'
})
}
export const deleteObservation = async (id: number) => {
const api = useApi()
return api.delete(`/observations/${id}`, {}, {
toastSuccessKey: 'success.observation.delete',
toastErrorKey: 'errors.observation.delete'
})
}

View File

@@ -16,6 +16,7 @@ export const createUser = async (payload: {
plainPassword: string
roles: string[]
employeeId?: number | null
isLocked?: boolean
}) => {
const api = useApi()
return api.post<User>(
@@ -24,7 +25,8 @@ export const createUser = async (payload: {
username: payload.username,
plainPassword: payload.plainPassword,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
},
{
toastSuccessKey: 'success.user.create',
@@ -38,12 +40,14 @@ export const updateUser = async (id: number, payload: {
plainPassword?: string
roles: string[]
employeeId?: number | null
isLocked?: boolean
}) => {
const api = useApi()
const body: Record<string, unknown> = {
username: payload.username,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
}
if (payload.plainPassword) {

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313125819 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create mileage_allowances table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE mileage_allowances (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, month DATE NOT NULL, kilometers DOUBLE PRECISION NOT NULL, comment TEXT DEFAULT NULL, receipt_path VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, employee_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_3B44830E8C03F15C ON mileage_allowances (employee_id)');
$this->addSql('ALTER TABLE mileage_allowances ADD CONSTRAINT FK_3B44830E8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP CONSTRAINT FK_3B44830E8C03F15C');
$this->addSql('DROP TABLE mileage_allowances');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313133548 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add receipt_name column to mileage_allowances';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances ADD receipt_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP receipt_name');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313151220 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create bonuses table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE bonuses (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, month DATE NOT NULL, amount DOUBLE PRECISION NOT NULL, comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_8535CFD28C03F15C ON bonuses (employee_id)');
$this->addSql('COMMENT ON COLUMN bonuses.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN bonuses.month IS \'(DC2Type:date_immutable)\'');
$this->addSql('ALTER TABLE bonuses ADD CONSTRAINT FK_8535CFD28C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE bonuses DROP CONSTRAINT FK_8535CFD28C03F15C');
$this->addSql('DROP TABLE bonuses');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260315100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_driver flag to employee_contract_periods';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods ADD is_driver BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN is_driver');
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260315100100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add driver-specific fields to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD day_hours_minutes INTEGER DEFAULT NULL');
$this->addSql('ALTER TABLE work_hours ADD night_hours_minutes INTEGER DEFAULT NULL');
$this->addSql('ALTER TABLE work_hours ADD has_breakfast BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE work_hours ADD has_lunch BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE work_hours ADD has_overnight BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN day_hours_minutes');
$this->addSql('ALTER TABLE work_hours DROP COLUMN night_hours_minutes');
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_breakfast');
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_lunch');
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_overnight');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260316100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add has_dinner column to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD has_dinner BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN has_dinner');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260316100100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add workshop_hours_minutes column to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD workshop_hours_minutes INTEGER DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN workshop_hours_minutes');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260318143503 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add amount column to mileage_allowances';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances ADD COLUMN amount DOUBLE PRECISION DEFAULT 0 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260319100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add amount receipt fields to mileage_allowances';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances ADD amount_receipt_path VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE mileage_allowances ADD amount_receipt_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount_receipt_path');
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount_receipt_name');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260325081258 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create observations table with unique constraint on (employee_id, month)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE observations (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, month DATE NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_BBC15BA88C03F15C ON observations (employee_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_observation_employee_month ON observations (employee_id, month)');
$this->addSql('ALTER TABLE observations ADD CONSTRAINT FK_BBC15BA88C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
$this->addSql("COMMENT ON COLUMN observations.month IS '(DC2Type:date_immutable)'");
$this->addSql("COMMENT ON COLUMN observations.created_at IS '(DC2Type:datetime_immutable)'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE observations DROP CONSTRAINT FK_BBC15BA88C03F15C');
$this->addSql('DROP TABLE observations');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260325084215 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_locked column to users table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD is_locked BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP is_locked');
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260330120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create audit_logs table for tracking user actions.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE audit_logs (
id SERIAL PRIMARY KEY,
employee_id INTEGER DEFAULT NULL,
username VARCHAR(180) NOT NULL,
action VARCHAR(30) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id INTEGER DEFAULT NULL,
description TEXT NOT NULL,
changes JSON DEFAULT NULL,
affected_date DATE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
CONSTRAINT fk_audit_employee FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE SET NULL
)');
$this->addSql('CREATE INDEX idx_audit_employee_created ON audit_logs (employee_id, created_at)');
$this->addSql('CREATE INDEX idx_audit_entity ON audit_logs (entity_type, entity_id)');
$this->addSql('CREATE INDEX idx_audit_created ON audit_logs (created_at)');
$this->addSql('CREATE INDEX idx_audit_affected_date ON audit_logs (affected_date)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_logs');
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;
use App\State\AuditLogProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
provider: AuditLogProvider::class,
parameters: [
new QueryParameter(key: 'employeeId'),
new QueryParameter(key: 'from'),
new QueryParameter(key: 'to'),
new QueryParameter(key: 'entityType'),
],
security: "is_granted('ROLE_SUPER_ADMIN')"
),
]
)]
final class AuditLogResource {}

View File

@@ -12,7 +12,7 @@ use App\State\EmployeeLeaveSummaryProvider;
operations: [
new Get(
uriTemplate: '/employees/{id}/leave-summary',
security: "is_granted('ROLE_USER')",
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeLeaveSummaryProvider::class
),
],

View File

@@ -14,7 +14,7 @@ use App\State\EmployeeRttSummaryProvider;
operations: [
new Get(
uriTemplate: '/employees/{id}/rtt-summary',
security: "is_granted('ROLE_USER')",
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeRttSummaryProvider::class
),
],
@@ -32,6 +32,7 @@ final class EmployeeRttSummary
public int $currentYearRecoveryMinutes = 0;
public int $availableMinutes = 0;
public int $totalPaidMinutes = 0;
public ?string $rttStartDate = null;
/** @var list<RttMonthPayment> */
public array $monthPayments = [];

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\EmployeeYearlyHoursPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/yearly-hours/print',
provider: EmployeeYearlyHoursPrintProvider::class,
parameters: [
new QueryParameter(key: 'employeeId', required: true),
new QueryParameter(key: 'year', required: true),
],
security: "is_granted('ROLE_USER')"
),
]
)]
final class EmployeeYearlyHoursPrint {}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\LeaveRecapPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/leave-recap/print',
provider: LeaveRecapPrintProvider::class,
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class LeaveRecapPrint {}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\SalaryRecapPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/salary-recap/print',
provider: SalaryRecapPrintProvider::class,
parameters: [
new QueryParameter(key: 'month', required: true),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class SalaryRecapPrint {}

View File

@@ -32,7 +32,12 @@ final class WorkHourBulkUpsert
* eveningFrom?:?string,
* eveningTo?:?string,
* isPresentMorning?:bool,
* isPresentAfternoon?:bool
* isPresentAfternoon?:bool,
* dayHoursMinutes?:?int,
* nightHoursMinutes?:?int,
* hasBreakfast?:bool,
* hasLunch?:bool,
* hasOvernight?:bool
* }>
*/
public array $entries = [];

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Dto;
final class AuditLogOutput
{
public function __construct(
public int $id,
public ?string $employeeName,
public ?int $employeeId,
public string $username,
public string $action,
public string $entityType,
public string $description,
public ?array $changes,
public ?string $affectedDate,
public string $createdAt,
) {}
}

View File

@@ -27,5 +27,7 @@ final class ContractHistoryItem
public ?int $periodId = null,
#[Groups(['employee:read'])]
public array $suspensions = [],
#[Groups(['employee:read'])]
public bool $isDriver = false,
) {}
}

View File

@@ -16,6 +16,7 @@ final class DayContextRow
public bool $absentAfternoon = false,
public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0,
public bool $isDriverContract = false,
) {}
public function addAbsence(
@@ -78,6 +79,7 @@ final class DayContextRow
'absentAfternoon' => $this->absentAfternoon,
'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits,
'isDriverContract' => $this->isDriverContract,
];
}

View File

@@ -10,10 +10,16 @@ final class WeeklyDaySummary
public string $date,
public int $dayMinutes,
public int $nightMinutes,
public int $workshopMinutes,
public int $totalMinutes,
public ?float $present = null,
public bool $hasAbsence = false,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
public bool $hasNightBasket = false,
public bool $hasBreakfast = false,
public bool $hasLunch = false,
public bool $hasDinner = false,
public bool $hasOvernight = false,
) {}
}

Some files were not shown because too many files have changed in this diff Show More