Compare commits

...

159 Commits

Author SHA1 Message Date
gitea-actions
3ec0d4b074 chore: bump version to v0.1.100
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 40s
2026-04-29 15:45:14 +00:00
eaf8a11e2b feat: ajout des commentaires à la semaine (#15)
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

- [x] Pas de régression
- [ ] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #15
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 15:45:02 +00:00
gitea-actions
02fc94fbed chore: bump version to v0.1.99
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-29 15:28:10 +00:00
eb5910dffe feat : surlignage des jours fériés sur la vue semaine des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Quand un employé n'a pas d'absence sur un jour férié, la cellule prend le fond bleu clair (#b3e5fc) et affiche le nom du férié au survol — cohérent avec la vue jour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:27:46 +02:00
78f73ed2e9 feat : ajout des jours fériés sur l'export PDF des heures
Affiche désormais une ligne dédiée pour chaque jour férié (Lun-Ven) avec la mention "Férié : {nom}" et le total créditant les heures contractuelles, comme sur l'écran Heures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:21:59 +02:00
eacf52425a fix : récap salaire chauffeur, comptage des repas (déjeuner + dîner)
Un jour avec déjeuner ET dîner cochés ne comptait qu'1 repas (||) au lieu de 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:27:00 +02:00
gitea-actions
6f43c3356f chore: bump version to v0.1.98
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 44s
2026-04-29 09:43:56 +00:00
13eeeb9c86 feat : ajout colonne Cumul sur l'écran RTT (#18)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Affiche le solde RTT à la fin de chaque semaine (report N-1 + somme
totalMinutes des semaines − paiements des mois antérieurs). Permet la
comparaison ligne à ligne avec un suivi RH externe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

| 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: #18
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 09:43:46 +00:00
gitea-actions
973de2d094 chore: bump version to v0.1.97
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-27 13:02:01 +00:00
74c109713c fix : malio UI
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-27 15:01:51 +02:00
gitea-actions
06173e7225 chore: bump version to v0.1.96
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m59s
2026-04-27 12:08:31 +00:00
cc868a1e82 feat: ajout malio UI + décompte des jours de présence forfait (#17)
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: #17
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-27 12:08:24 +00:00
gitea-actions
90843dd997 chore: bump version to v0.1.95
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-20 14:19:05 +00:00
8a449cf81b feat : paiement RTT en centièmes d'heures + auto-calcul bonus
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Input step passé de 0.5 à 0.01 pour accepter les centièmes (xx,xx)
- Labels mis à jour "(centièmes)" au lieu de "(heures)"
- Auto-remplissage du bonus 25% (base × 0.25) et 50% (base × 0.50)
- Ligne "Payé" affiche désormais les centièmes en gris comme les autres lignes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:18:54 +02:00
gitea-actions
3926946a5f chore: bump version to v0.1.94
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-20 10:12:10 +00:00
b9c3a8a84f [#SIRH-25] Version mobile (#16)
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: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 10:12:05 +00:00
gitea-actions
b2f6fdf222 chore: bump version to v0.1.93
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-20 06:25:18 +00:00
0fe82c63c5 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-20 08:25:11 +02:00
849d19f124 fix : autoriser docker/php/config/php.ini dans .dockerignore pour le build prod
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:24:39 +02:00
gitea-actions
d230a252b6 chore: bump version to v0.1.92
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
2026-04-20 06:21:05 +00:00
d46e7c04d5 fix : copier la config PHP custom (memory_limit 512M) dans l'image de prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:20:26 +02:00
gitea-actions
fe0910a661 chore: bump version to v0.1.91
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-17 14:58:36 +00:00
ff7566d4cd feat : export PDF heures groupé depuis la liste employés + memory_limit 256M
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Nouveau endpoint GET /yearly-hours/print-all (admin, par mois uniquement)
- Service YearlyHoursExportBuilder extrait du provider existant (logique partagée)
- EmployeeYearlyHoursPrintProvider refactorisé pour utiliser le builder
- Template print-all.html.twig avec saut de page entre chaque employé
- Drawer BulkYearlyHoursDrawer avec loader "Génération en cours..."
- Bouton "Export heures" ajouté sur la page liste employés
- PHP memory_limit passé de 128M à 256M dans php.ini (nécessaire pour Dompdf multi-employés)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:57:58 +02:00
gitea-actions
2f25a3cd52 chore: bump version to v0.1.90
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-17 09:47:42 +00:00
1fe7f2cdde feat : agence d'intérim sur les contrats INTERIM + renommage Types d'absence en Types de statut + colonne Absence en Statut
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Nouvelle entité InterimAgency (table interim_agencies, API lecture seule)
- Sélecteur agence conditionnel dans les formulaires création employé et ajout contrat
- Affichage "Intérim (NomAgence)" sur la liste employés et l'historique contrat
- Date de fin obligatoire côté frontend pour CDD et INTERIM (aligné backend)
- Renommage "Types d'absence" → "Types de statut" (sidebar, page, titre)
- Renommage en-tête "Absence" → "Statut" sur les vues jour heures et conducteurs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:47:14 +02:00
gitea-actions
9e411be3c3 chore: bump version to v0.1.89
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-04-17 09:05:24 +00:00
90e63a463e feat : autoriser la création d'absences sur les jours fériés depuis le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:04:57 +02:00
gitea-actions
51bf155b0e chore: bump version to v0.1.88
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-17 06:59:10 +00:00
1095421424 feat : modification des exports PDF et affichage du type de contrat sur l'écran des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-17 08:58:58 +02:00
gitea-actions
be7c16778a chore: bump version to v0.1.87
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 46s
2026-04-16 13:52:31 +00:00
a8fe244b5c feat : modification de la gestion des jours fériés
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-04-16 15:52:19 +02:00
gitea-actions
13c71abddc chore: bump version to v0.1.86
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-04-14 13:55:11 +00:00
9581f9d8d9 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:55:03 +02:00
c2eaa06aff fix : écran du récap. congés ordre d'affichage + Calcule des jours ouvrés pour les FORFAIT 2026-04-14 15:54:57 +02:00
gitea-actions
187a634cc8 chore: bump version to v0.1.85
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-14 13:08:56 +00:00
0897154460 feat : ajout d'un écran pour le récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:08:45 +02:00
gitea-actions
11331da6a1 chore: bump version to v0.1.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-14 09:25:55 +00:00
399fd7335e fix : exclusion de certain jour férié et affichage différent des jours férié dans la page d'heure
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 11:25:44 +02:00
gitea-actions
46cb7f1a16 chore: bump version to v0.1.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-14 06:38:09 +00:00
b934f4d81f Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:38:01 +02:00
77c1cdcbbd fix : on masque la validation chef site 2026-04-14 08:37:54 +02:00
gitea-actions
de302d9ded chore: bump version to v0.1.82
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-14 06:25:17 +00:00
ef18210bf7 fix : export du récap congés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:24:43 +02:00
gitea-actions
055d92153b chore: bump version to v0.1.81
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m19s
2026-04-13 07:41:52 +00:00
4cd30de3e3 feat : ajout d'un onglet formation
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-13 09:41:36 +02:00
gitea-actions
b185accdbb chore: bump version to v0.1.80
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 26s
2026-04-08 06:47:00 +00:00
a4bda53f57 fix : split deficit weeks by weekdays count when no hours worked
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
When a week spans two months and has zero worked hours (e.g. RTT
all week), the proportional split by worked minutes gave 0 to both
months. Now falls back to splitting by weekday count.
2026-04-08 08:17:07 +02:00
gitea-actions
c255000a5e chore: bump version to v0.1.79
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-03 13:18:40 +00:00
b8b9368ad0 [#SIRH-6] Faire une doc de type wiki (#14)
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: #14
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-03 13:18:32 +00:00
gitea-actions
10a0ab0809 chore: bump version to v0.1.78
All checks were successful
Build & Push Docker Image / build (push) Successful in 38s
Auto Tag Develop / tag (push) Successful in 6s
2026-04-02 12:34:10 +00:00
055f1187f9 fix : wording
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-02 14:34:00 +02:00
gitea-actions
f3ed359d3f chore: bump version to v0.1.77
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 57s
2026-04-02 10:05:12 +00:00
906c245451 feat(deploy) : add maintenance mode with automatic toggle during deploy
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-02 11:56:13 +02:00
gitea-actions
100ab340d4 chore: bump version to v0.1.76
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-02 08:55:05 +00:00
0257e59671 [#SIRH-21] Revoir l'affichage des RTT pour les semaines qui se chevauchent (#13)
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: #13
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-02 08:54:55 +00:00
gitea-actions
f9979c9a19 chore: bump version to v0.1.75
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 30s
2026-04-02 06:59:10 +00:00
1091147100 [#SIRH-20] Ajouter pour les forfaits le paiement de congés N-1 (#12)
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é

Reviewed-on: #12
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-02 06:59:03 +00:00
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
gitea-actions
96185e2334 chore: bump version to v0.1.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-13 11:11:40 +00:00
7d53000fc2 fix : validation autorisée pour les contrats 4h sans heures ou absence
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 12:11:31 +01:00
gitea-actions
c317a2a026 chore: bump version to v0.1.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-13 10:59:58 +00:00
8846e83df1 feat : modification de l'affichage des congés
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 11:57:02 +01:00
gitea-actions
ff824f233a chore: bump version to v0.1.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-13 10:03:51 +00:00
c4c9dfceab feat : amélioration des perfs de la page employée en séparant les responsabilités et le chargement par onglet
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 11:03:41 +01:00
gitea-actions
ca6597cd38 chore: bump version to v0.1.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 2m4s
2026-03-13 09:26:43 +00:00
4a2c3a8eed feat : Ajout du système de RTT sur la page employé avec le repport annuel des heures
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-13 10:26:33 +01:00
gitea-actions
1858817649 chore: bump version to v0.1.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-12 15:58:05 +00:00
99f0f191f4 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-12 16:57:55 +01:00
96617f04bc fix : style du drawer de suspension 2026-03-12 16:57:45 +01:00
255 changed files with 20485 additions and 2280 deletions

View File

@@ -21,7 +21,13 @@
"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:*)",
"Bash(python3:*)"
]
}
}

24
.dockerignore Normal file
View File

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

7
.env
View File

@@ -36,6 +36,13 @@ DEFAULT_URI=http://localhost
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> app ###
RTT_START_DATE=2026-02-23
# Comma-separated list of public holiday labels to exclude from the government API response
# (typically the "journée de solidarité" worked in many companies)
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
###< app ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

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" />

143
CLAUDE.md Normal file
View File

@@ -0,0 +1,143 @@
# SIRH
## Mandatory Rules
- Any functional change MUST update `doc/` in the same intervention
- Any functional change MUST update the in-app documentation (`frontend/data/documentation-content.ts`) in the same intervention
- At the end of every feature addition or functional modification, update this CLAUDE.md to reflect new patterns, rules, or conventions introduced
## Commands
- `make start` — start Docker stack
- `make test` — run backend tests (PHPUnit)
- `make dev-nuxt` — dev frontend
- `cd frontend && npm run build` — build frontend
- `php bin/console cache:clear && php bin/console cache:warmup` — clear cache after deploy
## Stack
- Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
## Project Structure
- `src/` — Symfony domain, API resources, state providers/processors, services
- `frontend/` — Nuxt app (pages, components, composables, services)
- `migrations/` — Doctrine migrations (always include working `down()`)
- `doc/` — functional rules and business documentation
## Functional Rules
- Reference: `doc/functional-rules.md` (mandatory reading before any business logic change)
- Complementary: `doc/leave-rollover.md`, `doc/rtt-rollover.md`
## Domain Model
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`).
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
- Création/édition d'absence **autorisée** sur un férié (bouton Modifier visible). En présence d'absence, le crédit d'heures suit `absence.type.countAsWorkedHours` (WorkedHoursCreditPolicy), pas le crédit virtuel férié.
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
## Commentaires de semaine
- Entité `EmployeeWeekComment` : commentaire libre par employé et semaine ISO (unique `(employee_id, week_start_date)`). `week_start_date` = lundi.
- CRUD `/employee_week_comments` `ROLE_ADMIN`. Write processor audite via `AuditLogger`.
- Picto bulle vue semaine (HoursWeekView + DriverHoursWeekView) : fond bleu/rouge. Intégré dans `WeeklySummaryRow.comment/commentId`.
- Doc : `doc/week-comments.md`.
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
- Any real modification resets both `isSiteValid=false` and `isValid=false`
- No-op saves preserve existing validations
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
- INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 14j)`. Pas de gate `isValid`.
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
- Pas d'export PDF depuis cet écran
- Doc détaillée : `doc/leave-recap-screen.md`
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
## Formations
- Onglet "Formation" sur la fiche employé (admin uniquement)
- Champs : date début, date fin, justificatif PDF optionnel, commentaire
- Validation: dates obligatoires, `endDate >= startDate`, fichier PDF uniquement
- Justificatif stocké dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf` (année/mois = startDate)
- Suppression et remplacement du justificatif nettoient l'ancien fichier disque
- Tri tableau par `startDate DESC`
- Affichage écran Heures (jour) : pill "Formation" (indigo) dans la colonne Absence. Quand une formation existe, le bouton "Modifier" de la colonne Absence est masqué (lockdown complet du jour pour la gestion d'absence)
- Affichage Calendrier : cellule "F" (indigo) si formation seule, ou icône école en coin si formation + absence. Cellules avec formation non cliquables. Légende dédiée. PDF export : code "F" indigo ou astérisque à côté du code d'absence
- Le CRUD formation est exclusivement géré depuis la fiche employé > onglet Formation
## Frontend Patterns
### Table styling (standard across all pages)
- Header: `grid border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`
- Body wrapper: `border-x border-b border-primary-500 rounded-b-md`
- Rows: `grid items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500`
- Page wrapper for scroll: `h-full flex flex-col overflow-hidden`, table container: `min-h-0 overflow-auto rounded-md bg-white`
### Drawer buttons (AppDrawer)
- Edit mode: `grid grid-cols-2 gap-3` → Supprimer (red, left) + Modifier (primary, right)
- Create mode: centered `+ Ajouter` button, w-[200px]
- Exception: Users drawer has NO delete button
- All "Ajouter" buttons across the app use "+" prefix
### API Platform (backend)
- Custom operations use Processor (write) / Provider (read)
- File uploads: `deserialize: false` on Post, access file via RequestStack
- Upload dir: `%kernel.project_dir%/var/uploads`
## Audit Logging
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
- Documentation: `doc/audit-logging.md`
## Backend Conventions
- Prefer explicit DTOs over associative arrays
- Business rules in backend (providers/processors/services), frontend is display/interaction only
- Keep backend PHP DTOs aligned with frontend TS DTOs (`frontend/services/dto/*`)
- Update unit tests when constructor/service signatures change
## In-App Documentation
- Content: `frontend/data/documentation-content.ts` — structured TypeScript data with all user-facing documentation
- Types: `frontend/types/documentation.ts` — DocSection, DocArticle, DocBlock
- Composable: `frontend/composables/useDocumentation.ts` — role-based filtering (employee < site_manager < admin)
- Components: `frontend/components/documentation/` — DocumentationPage, DocumentationSection, DocumentationArticle
- Page: `frontend/pages/documentation.vue`
- 3 access levels: `employee` (ROLE_SELF), `site_manager` (ROLE_USER), `admin` (ROLE_ADMIN) — cumulative (admin sees everything)
- Each section/article has a `requiredLevel` that controls visibility
- When adding or modifying a feature, update the corresponding section in `documentation-content.ts`
## Language
- UI is in French
- User communicates in French
- Code (variables, comments) in English

View File

@@ -1,4 +1,5 @@
# SIRH
Application de gestion des absences employée
## Importer un dump de prod en dev
@@ -17,3 +18,10 @@ 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';
```
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/

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

@@ -25,10 +25,20 @@ services:
App\Service\PublicHolidayService:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
App\Service\Rtt\RttRecoveryComputationService:
arguments:
$rttStartDate: '%env(RTT_START_DATE)%'
App\State\EmployeeRttSummaryProvider:
arguments:
$rttStartDate: '%env(RTT_START_DATE)%'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.30'
app.version: '0.1.100'

View File

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

View File

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

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

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

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

50
deploy/maintenance.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maintenance en cours</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: #1f2937;
}
.container {
text-align: center;
padding: 3rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
max-width: 500px;
width: 90%;
}
.icon {
font-size: 4rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
color: #111827;
}
p {
font-size: 1rem;
color: #6b7280;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">&#128736;</div>
<h1>Maintenance en cours</h1>
<p>L'application est temporairement indisponible pour mise a jour. Elle sera de retour dans quelques instants.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name sirh.malio-dev.fr;
root /var/www/sirh/public;
# Maintenance mode : si le fichier maintenance.on existe, renvoyer la page 503
if (-f /var/www/sirh/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

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.

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

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

58
doc/formations.md Normal file
View File

@@ -0,0 +1,58 @@
# Formations
Onglet **Formation** accessible depuis la fiche employé. Permet de tracer les formations suivies par un salarié.
## Accès
- Réservé aux administrateurs (`ROLE_ADMIN`)
- Invisible pour les autres rôles
## Champs
| Champ | Type | Obligatoire |
| --- | --- | --- |
| Date de début | date | oui |
| Date de fin | date | oui |
| Justificatif | fichier PDF | non |
| Commentaire | texte libre | non |
## Règles de validation
- La date de fin doit être supérieure ou égale à la date de début
- Seuls les fichiers PDF sont acceptés pour le justificatif
- Un employé peut avoir plusieurs formations (aucune unicité imposée)
## Stockage
Les justificatifs PDF sont stockés dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf`, où l'année et le mois sont ceux de la date de début de la formation. Le nom d'origine du fichier est conservé en base pour l'affichage et le téléchargement.
Lors de la suppression d'une formation, le fichier associé est automatiquement supprimé du disque. Lors du remplacement d'un justificatif, l'ancien fichier est également supprimé.
## Tri
Les formations sont affichées dans le tableau par **date de début décroissante**.
## Affichage sur les autres écrans
### Écran des heures (vue jour)
Dans la colonne "Absence", lorsqu'un salarié est en formation sur la date sélectionnée, une pastille indigo **Formation** est affichée sous la pastille d'absence éventuelle. Cette pastille est uniquement informative :
- Le bouton **Modifier** de la colonne Absence est masqué : aucune création/modification/suppression d'absence n'est possible sur un jour en formation
- La gestion CRUD d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**
### Calendrier
Dans le calendrier mensuel, les formations sont affichées de deux façons :
- **Jour avec formation uniquement** : la cellule est teintée en indigo avec le code `F`
- **Jour avec absence + formation** : la cellule garde la couleur de l'absence et une icône école est ajoutée en coin supérieur droit
Une entrée "Formation" est visible dans la légende du calendrier. Les cellules contenant une formation sont **non cliquables** (aucune création/édition d'absence possible). La gestion d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**.
### Export PDF du calendrier
L'impression du calendrier d'absences reprend le même principe :
- **Jour avec formation uniquement** : cellule indigo avec le code `F`
- **Jour avec absence + formation** : le code de l'absence est suivi d'un astérisque (`*`)

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
@@ -54,6 +58,9 @@ Documents complementaires:
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
- non mise à jour lors de modifications admin ou chef de site
- affichée sous le nom de l'employé (visible admin uniquement)
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
## 4) Absences
@@ -67,6 +74,10 @@ Documents complementaires:
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
- demi-journée: dégradé diagonal
- journée complète: fond plein
- Visibilité des employés dans le Calendrier:
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
- même logique que l'écran Heures : « pas de contrat sur la période → masqué »
### Effet absence sur les heures
@@ -112,18 +123,61 @@ 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
- agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"
## 6bis) Heures Conducteurs
- Écran dédié `/driver-hours` pour les employés dont le contrat est marqué `isDriver = true`
- Les conducteurs sont exclus de l'écran `/hours` classique
- Colonnes spécifiques (vue jour):
- Heure de jour (durée HH:MM via TimeSelect)
- Heure de nuit (durée HH:MM via TimeSelect)
- Heure atelier (durée HH:MM via TimeSelect)
- Total (somme jour + nuit + atelier, calculé)
- Petit déjeuner (checkbox)
- Déjeuner (checkbox)
- Dîner (checkbox)
- Nuitée (checkbox)
- Stockage backend:
- `dayHoursMinutes`, `nightHoursMinutes` et `workshopHoursMinutes` (entiers, minutes) sur `WorkHour`
- `hasBreakfast`, `hasLunch`, `hasDinner`, `hasOvernight` (booleans) sur `WorkHour`
- les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
- Absences `countAsWorkedHours=true`: les minutes créditées sont ajoutées aux heures de jour (vue jour et vue semaine), même logique que les employés classiques
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
- Vue semaine:
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`).
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
- absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
## 8) Impression absences (PDF)
@@ -145,16 +199,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 +230,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`)
@@ -190,6 +258,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- pour `FORFAIT`:
- pris: basé sur toutes les absences (demi-journées incluses)
- restants = acquis - pris (borné à 0)
- paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait.
- report annuel:
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
@@ -219,12 +288,14 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- total mensuel des minutes de récupération
- compteur global exercice = `report N-1 + acquis N`
- attribution mensuelle des semaines:
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
- une semaine ISO qui chevauche deux mois est affichée dans **les deux mois**, avec les valeurs réparties proportionnellement aux minutes travaillées de chaque portion
- le calcul des heures supplémentaires reste hebdomadaire (seuils 35h/39h/43h appliqués sur la semaine entière), seul l'affichage est scindé
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparaît en mars (avec la part des heures de lun-mar) et en avril (avec la part mer-dim)
- logique de calcul:
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
- date limite de calcul: uniquement les semaines terminées (jusqu'au dernier dimanche), **ou** la semaine en cours si tous les jours existants sont validés RH (`isValid = true`). En cas de fin de contrat en milieu de semaine, seuls les jours jusqu'à la date de fin sont vérifiés.
- compteur global:
- affiché en **jours** (1 jour = 7h = 420 minutes)
- report:
@@ -242,10 +313,119 @@ 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
- colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel)
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures paiements antérieurs), affichée à partir de juillet (masquée si nul)
- Reste = Report cumulé + Total du mois Payé du mois (balance courante en fin de mois)
- affichage:
- le compteur global RTT est affiché en **heures** (format `Xh00`)
## 10) 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: restant sur quota année civile (acquis pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 10bis) Écran Récap. congés (tableau)
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
- Endpoint: `GET /api/leave-recap`
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
- Scoping :
- `ROLE_ADMIN` : tous les employés
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
- `ROLE_SELF` : uniquement son employé lié
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
- Formule : `cutoffDate = dimanche(lundi_semaine_courante 14 jours)`
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
- `isValid` n'entre PAS en compte : cutoff purement temporel
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
- Colonnes identiques au PDF (voir §10)
- Détails techniques : voir `doc/leave-recap-screen.md`
## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
- Sélecteur de mois (défaut = mois courant), génère un PDF A3 paysage
- Endpoint: `GET /api/salary-recap/print?month=YYYY-MM`
- Données groupées par site, un en-tête par site
### Colonnes du tableau
| Colonne | Source | Logique |
|---------|--------|---------|
| Nom | Employee | firstName + lastName |
| Base | Contract.name | Via EmployeeContractResolver pour le mois |
| Jour de présence Cadre | WorkHour | Uniquement FORFAIT (PRESENCE). Somme isPresentMorning (0.5) + isPresentAfternoon (0.5) |
| Heures de nuit | WorkHour | Non-chauffeurs: calcul intervalles nuit (00:00-06:00, 21:00-24:00). Chauffeurs: somme nightHoursMinutes |
| Panier de nuit | WorkHour | Nombre de jours où (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit 4h entre 21h-6h) |
| Heures payés | EmployeeRttPayment | Somme base25Minutes + base50Minutes du mois, convertie en heures |
| Congés - Nombre | Absence code 'C' | Jours (demi-journées = 0.5) |
| Congés - Date | Absence code 'C' | Dates formatées dd/mm |
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) |
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle |
## 12) Frais
- Onglet "Frais" sur la fiche employé (icône `mdi:account-cash-outline`)
- Entité `MileageAllowance` (table `mileage_allowances`)
- Champs:
- `month` (mois, obligatoire)
- `kilometers` (nombre de km, optionnel)
- `amount` (montant en €, optionnel)
- `comment` (commentaire, optionnel)
- `receiptPath` / `receiptName` (justificatif Km, PDF)
- `amountReceiptPath` / `amountReceiptName` (justificatif Montant, PDF)
- Règle de validation:
- le mois est obligatoire
- au moins un des deux champs `kilometers` ou `amount` doit être > 0
- les deux peuvent être remplis simultanément
- Tableau: colonnes Mois, Nombre de Km, Montant €, Commentaire, Justif. Km, Justif. Montant
- Deux justificatifs distincts (upload PDF uniquement):
- Justificatif Km : upload via `/mileage_allowances/{id}/receipt`, téléchargement via GET même URL
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
## 13) Observations
- Onglet "Observation" sur la fiche employé (icône `mdi:note-text-outline`)
- Entité `Observation` (table `observations`)
- Champs:
- `month` (mois, obligatoire)
- `content` (texte d'observation, obligatoire)
- Contrainte: une seule observation par mois par employé (unique sur `employee_id + month`)
- Tableau: colonnes Mois | Observation
- Drawer avec champs mois (`type="month"`) et textarea "Observation"
- CRUD standard: création, modification, suppression avec confirmation
## 14) Verrouillage utilisateur
- Champ `isLocked` (boolean, default false) sur l'entité `User`
- Un admin peut verrouiller/déverrouiller un utilisateur depuis la page Utilisateurs (checkbox dans le drawer)
- Un utilisateur verrouillé ne peut plus se connecter (vérification via `UserChecker` sur les firewalls `login` et `api`)
- Colonne "Statut" dans le tableau utilisateurs avec label "Actif" (vert) ou "Verrouillé" (rouge)
## 15) Notifications
- Icône cloche en topbar:
- badge = nombre de notifications non lues
@@ -258,3 +438,33 @@ 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, absence, week-end ou jour férié sont affichés
- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie)
### Colonnes selon le mode de suivi
- **TIME (non-chauffeur)**: Date | Absence | Début matin | Fin matin | Début après-midi | Fin après-midi | Début soir | Fin soir | Total
- **PRESENCE (forfait)**: Date | Absence | Présence matin | Présence après-midi | Total
- **Chauffeur**: Date | Absence | Heures jour | Heures nuit | Heures atelier | Total
### Changement de contrat en cours d'année
- Si l'employé change de mode de suivi (TIME/PRESENCE) ou de statut chauffeur en cours d'année, le PDF affiche des sections séparées avec les colonnes adaptées à chaque période
- Le nom du contrat est affiché en sous-titre de chaque section
### Calcul du total
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie.
### Nom du fichier
- Format: `{nom}_{prenom}_{annee}.pdf`

View File

@@ -0,0 +1,110 @@
# Crédit automatique des heures sur jour férié (Lun-Ven)
## Règle
Tout jour férié du **lundi au vendredi** crédite automatiquement les **heures contractuelles attendues** pour ce jour, pour tout contrat **autre que Forfait** (`trackingMode``PRESENCE`). Les heures ainsi créditées sont dites *virtuelles* : aucune ligne n'est créée dans `work_hours`, elles sont injectées à l'affichage et au calcul.
### Référence contractuelle par jour
| Contrat | Lun-Jeu | Ven | Sam-Dim |
|-----------------|---------|-------|---------|
| 35h | 7h | 7h | 0 |
| 39h | 8h | 7h | 0 |
| CUSTOM (avec planning `workDaysHours`) | minutes du jour programmé, 0 sinon | idem | 0 |
| INTERIM 35h | 7h | 7h | 0 |
| FORFAIT | — | — | — |
La référence par jour est calculée par `App\Service\WorkHours\DailyReferenceMinutesResolver`.
### Planning `workDaysHours`
Tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h) doit déclarer un planning précis sur sa `EmployeeContractPeriod` : colonne JSON `work_days_hours = {"1": 120, "4": 120}` (iso day → minutes). La somme doit égaler `weeklyHours × 60`.
- **Sur un jour du planning** : crédit férié = minutes programmées (ex. Ewa Lun → 120 min).
- **Sur un jour hors planning** : crédit férié = 0 (elle n'aurait pas travaillé).
- Même logique appliquée par `WorkedHoursCreditPolicy::resolveContractDayMinutes` pour les crédits d'absence — un 4h en absence mardi (non programmée) = 0 crédit.
Validation à l'écriture : `EmployeeContractPeriodValidator::assertWorkDaysHours`. Le frontend expose un bloc « Jours travaillés » (cases Lun-Ven + input `HH:MM`) sur les formulaires de création employé + d'ajout de contrat, visible uniquement quand le contrat le requiert.
**Limitation actuelle** : l'édition in-place d'un schedule sur une période active existante n'est **pas exposée** via l'UI. Le drawer « Modifier le contrat » affiche le schedule en lecture seule à titre informatif. Pour corriger un schedule, la démarche est : clôturer le contrat en cours + créer un nouveau contrat avec le schedule corrigé. Si un besoin d'édition directe émerge, ajouter `workDaysHours` dans `EmployeeContractChangeRequest::hasPeriodChangeRequest()` et la logique d'update dans `EmployeeContractPeriodManager`.
### Fériés exclus
Les fériés listés dans l'env `EXCLUDED_PUBLIC_HOLIDAYS` (par défaut `Lundi de Pentecôte` — journée de solidarité) **ne donnent pas** de crédit virtuel : le `PublicHolidayService` les filtre en amont, donc `HolidayVirtualHoursResolver` ne les voit pas comme fériés.
### Interaction avec saisie
Quand l'employé saisit des heures ce jour-là :
- `heures finales = max(heures saisies + crédit d'absence éventuel, heures contractuelles de référence)`
Exemples avec un contrat 39h et un férié un lundi :
| Saisie employé | Total affiché | Interprétation |
|------------------|---------------|----------------|
| Aucune | 8h | Crédit 100% virtuel |
| Matin 09:00-13:00 (4h) | 8h | Le minimum contractuel l'emporte |
| 09:00-12:00 + 13:00-19:00 (9h) | 9h | Les heures saisies l'emportent |
### Interaction avec absences
La création d'absence sur un férié Lun-Ven est **autorisée** (bouton Modifier visible). Dès qu'une absence est déclarée sur le jour (matin et/ou après-midi), le crédit virtuel férié **est désactivé** pour ce jour : c'est `absence.type.countAsWorkedHours` qui pilote le crédit d'heures, via `WorkedHoursCreditPolicy`.
- `countAsWorkedHours = true` (ex. maladie payée) : crédit calculé normalement (7h/8h selon contrat × halfUnits/2). Même quantité que la référence virtuelle si journée complète, donc résultat identique — mais la source du crédit est l'absence, pas le férié.
- `countAsWorkedHours = false` (ex. congé sans solde) : crédit = 0. Le férié ne compense pas.
Cette règle évite le double-crédit (absence + férié virtuel) et respecte le paramétrage fonctionnel du type d'absence.
## Impact technique
### Affichage
- **Écran Heures (vue jour)** : sur un férié Lun-Ven non-Forfait, la colonne Total affiche la valeur effective (référence ou saisie, selon max). Un chip "Férié : Xh comptées" apparaît sous le pill bleu du férié.
- **Écran Heures Conducteurs (vue jour)** : idem, plus un indicateur `= Xh (férié)` sous l'input "Heures jour" pour signaler que le crédit est imputé au bucket jour.
- **Vues semaine** : les totaux hebdomadaires intègrent les minutes virtuelles. Un marqueur `F + Xh` apparaît dans la cellule du jour férié.
- **Onglet RTT** : les semaines contenant un férié Lun-Ven gagnent du temps crédité, ce qui peut générer des heures sup (25% / 50%) là où l'ancienne règle produisait un déficit.
### Calcul RTT
Le service `App\Service\WorkHours\HolidayVirtualHoursResolver` est injecté dans `RttRecoveryComputationService::computeRecoveryByWeek()`. Pour chaque jour ouvré :
```
effectiveMinutes = resolveEffectiveDailyMinutes(contract, date, metrics.totalMinutes + credited)
weeklyTotalMinutes += effectiveMinutes
```
Le reste du calcul (tranches +25%, +50%, base 25% à partir de 35h/39h) demeure inchangé ; seul le total hebdo injecté a évolué.
### Calcul hebdomadaire d'affichage
`WorkHourWeeklySummaryProvider` applique la même substitution sur `weeklyDayMinutes` et `weeklyTotalMinutes`. Le DTO `WeeklyDaySummary` expose désormais un champ `virtualHolidayMinutes` utilisé par les vues semaine.
### Contexte jour
`WorkHourDayContextProvider` expose `virtualHolidayMinutes` dans `DayContextRow` pour permettre au frontend de calculer le total journalier en temps réel pendant la saisie (sans aller-retour).
### Frontend
Le composable `frontend/composables/useHolidayVirtualHours.ts` réplique la règle côté client et est consommé par `useHoursPage.ts::getRowMetrics` et `useDriverHoursPage.ts::getRowMetrics`.
## Impact historique
La règle est appliquée **à chaque lecture** depuis les `WorkHour` — donc l'exercice courant et tout exercice recalculé live bénéficient automatiquement de la nouvelle règle sans migration.
Les reports N-1 stockés dans `employee_rtt_balances.opening_*_minutes` ont été saisis manuellement par la RH (valeurs officielles) et ne sont **pas recalculés** : ces snapshots restent la source de vérité pour les soldes d'ouverture.
## Services impliqués
| Composant | Rôle |
|-----------|------|
| `DailyReferenceMinutesResolver` | Résolution "minutes contractuelles par jour" (logique partagée, anciennement dupliquée). |
| `HolidayVirtualHoursResolver` | Décide si la règle s'applique et renvoie le crédit virtuel ou la valeur effective. |
| `RttRecoveryComputationService` | Applique la substitution dans le calcul hebdo RTT. |
| `WorkHourWeeklySummaryProvider` | Applique la substitution dans les totaux hebdo UI. |
| `WorkHourDayContextProvider` | Expose `virtualHolidayMinutes` par salarié/jour. |
| `useHolidayVirtualHours.ts` (frontend) | Réplique la règle en live côté client. |
## Tests
- `tests/Service/WorkHours/HolidayVirtualHoursResolverTest.php` couvre les scénarios par contrat + jours ouvrés/chômés.
- `make test` (PHPUnit) valide l'intégration RTT / hebdo / contexte jour.

73
doc/leave-recap-screen.md Normal file
View File

@@ -0,0 +1,73 @@
# Écran Récap. congés
## Objet
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
## Cutoff
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante 14 jours)`.
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
Implémentation : `App\Util\LeaveRecapCutoff::resolveCutoff()` côté backend, helper `parseYmd` +
`getIsoWeekNumber` côté frontend pour l'affichage du badge.
## Colonnes
Identiques au PDF :
- Nom
- Prénom
- Contrat
- CP N-1 restant
- CP N
- Samedis acquis
- RTT
Pour les admins et chefs de site, une colonne **Site** est ajoutée à gauche.
## Scoping
| Profil | Données visibles |
|---------------|-----------------------------------------|
| `ROLE_ADMIN` | Tous les employés actifs, tous sites |
| `ROLE_USER` (chef de site) | Employés actifs des sites autorisés via `UserSiteRole` |
| `ROLE_SELF` | Uniquement l'employé lié à son compte |
## Flag d'accès
Le champ `User.hasLeaveRecapAccess` (boolean, défaut `false`) conditionne :
- L'affichage de l'entrée "Récap. congés" dans la sidebar
- L'accès à la route `/leave-recap` (middleware `leave-recap-access.ts`)
- L'endpoint API `GET /api/leave-recap` (le provider renvoie `403` si le flag est faux)
Le flag s'applique même aux admins : un admin sans le flag ne voit pas l'écran. Il se configure
dans le drawer de création/édition d'un utilisateur.
## Service partagé
`App\Service\Leave\LeaveRecapRowBuilder::build(Employee $employee, DateTimeImmutable $asOfDate)`
construit une ligne de récap. Il est utilisé par :
- `LeaveRecapPrintProvider` (PDF admin) avec `$asOfDate = today`
- `EmployeeLeaveRecapProvider` (écran) avec `$asOfDate = cutoff`
## Propagation du cutoff dans les calculs
`EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate`.
Lorsqu'il est fourni et appliqué à l'année cible, il remplace "today" dans :
- `resolveAccrualCalculationEndDate()` — la borne d'accrual devient le dernier jour du mois
précédant `asOfDate` (au lieu du mois précédent today).
- `resolveTakenCalculationEndDate()` — les absences postérieures à `asOfDate` sont ignorées.
Pour les années antérieures (carry forward), le comportement reste inchangé (pas de cap).
Le RTT est capé via `RttRecoveryComputationService::computeTotalRecoveryForExercise(..., $limitDate)`
qui existait déjà, en passant `cutoff` comme date de référence.

View File

@@ -32,9 +32,9 @@ Principe:
## 4) Attribution mensuelle des semaines
- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
- une semaine ISO qui chevauche deux mois est affichee dans **les deux mois**, avec les valeurs reparties proportionnellement aux minutes travaillees de chaque portion
- le calcul des heures supplementaires reste hebdomadaire (seuils 35h/39h/43h appliques sur la semaine entiere), seul l'affichage est scinde
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparait en mars (part lun-mar) et en avril (part mer-dim)
## 5) Table cible

View File

@@ -1,4 +1,7 @@
[Date]
; Defines the default timezone used by the date functions
; http://php.net/date.timezone
date.timezone = Europe/Paris
date.timezone = Europe/Paris
[PHP]
memory_limit = 256M

View File

@@ -0,0 +1,563 @@
# Refonte onglet RTT — Plan d'implémentation
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer la vue annuelle RTT par une vue mensuelle avec tableau détaillé par semaine (base/25%/50%) et un système de paiement à 4 champs.
**Architecture:** Enrichir `RttRecoveryComputationService` pour retourner le détail base/bonus par palier. Modifier l'entité `EmployeeRttPayment` pour stocker 4 valeurs. Réécrire le composant `RttTab.vue` avec navigation mensuelle et tableau 7 colonnes.
**Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), PostgreSQL.
**Spec:** `docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md`
---
## Task 1: Enrichir le retour de `RttRecoveryComputationService::computeRecoveryByWeek`
**Files:**
- Create: `src/Dto/Rtt/WeekRecoveryDetail.php`
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php:97-206`
Actuellement `computeRecoveryByWeek` retourne `array<string, int>` (weekKey => totalMinutes). Il faut retourner `array<string, WeekRecoveryDetail>` avec le détail ventilé.
- [ ] **Step 1: Créer le DTO `WeekRecoveryDetail`**
```php
// src/Dto/Rtt/WeekRecoveryDetail.php
<?php
declare(strict_types=1);
namespace App\Dto\Rtt;
final class WeekRecoveryDetail
{
public function __construct(
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}
```
- [ ] **Step 2: Modifier `computeRecoveryByWeek` pour retourner `array<string, WeekRecoveryDetail>`**
Changer le retour de la méthode. Les variables internes existent déjà (`weeklyOvertimeTotalMinutes`, `weeklyOvertime25Minutes`, `weeklyOvertime50Minutes`). Il faut calculer en plus les bases séparées.
La logique de ventilation des heures de base entre palier 25% et palier 50% :
- `base25Minutes` = heures sup dans la tranche 25% = `min(overtimeMinutes, max(0, overtime25StartMinutes - overtimeReferenceMinutes))`... En fait, c'est plus simple :
- `base25Minutes` = `min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes))` quand overtimeTotal > 0
- Plus simplement : `base25Minutes` = heures entre le seuil 25% et 43h, `base50Minutes` = heures au-dessus de 43h
Reprenons la logique existante (lignes 189-202) :
- `overtimeReferenceMinutes` = seuil à partir duquel on compte les heures sup (max(35, weeklyHours) * 60 réparti sur les jours)
- `overtime25StartMinutes` = seuil à partir duquel les heures sup sont à 25% (39h si contrat >= 39h, sinon 35h)
- `weeklyOvertimeTotalMinutes` = max(0, worked - overtimeReference) — total heures sup brutes
- `weeklyOvertime25Minutes` = bonus 25% = round(min(worked, 43*60) - overtime25Start) * 0.25
- `weeklyOvertime50Minutes` = bonus 50% = round(max(0, worked - 43*60)) * 0.5
Pour la ventilation :
- `base25Minutes` = min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes)) — Non, c'est la tranche 25% en termes d'heures travaillées, pas en termes d'heures sup.
En fait :
- Les heures sup brutes = `weeklyOvertimeTotalMinutes` = `worked - overtimeReference`
- Les heures dans le palier 25% = heures entre `overtime25Start` et `min(worked, 43*60)` = c'est `max(0, min(worked, 43*60) - overtime25Start)`. C'est la base sur laquelle le 25% est calculé.
- Les heures dans le palier 50% = heures au-dessus de 43h = `max(0, worked - 43*60)`. C'est la base sur laquelle le 50% est calculé.
Modifier les lignes 191-202 :
```php
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
);
```
Les cas "zéro" (weekStart vide, limitDate dépassée, etc.) retournent `new WeekRecoveryDetail()` (tout à 0).
- [ ] **Step 3: Adapter `computeTotalRecoveryForExercise` pour retourner un `WeekRecoveryDetail` agrégé**
Cette méthode retournait `int`. Elle doit maintenant retourner un `WeekRecoveryDetail` qui agrège toutes les semaines (somme par champ). Le rollover et le provider en ont besoin pour la ventilation du carry-over.
```php
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
$total = new WeekRecoveryDetail();
foreach ($byWeek as $detail) {
$total = new WeekRecoveryDetail(
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
);
}
return $total;
}
```
- [ ] **Step 4: Vérifier que le code compile**
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
---
## Task 2: Modifier l'entité `EmployeeRttBalance` (carry-over ventilé) + rollover
**Files:**
- Modify: `src/Entity/EmployeeRttBalance.php`
- Modify: `src/Repository/EmployeeRttBalanceRepository.php`
- Modify: `src/Command/RttRolloverCommand.php`
Le carry-over doit être ventilé sur les mêmes 4 colonnes que le tableau (base25, bonus25, base50, bonus50) pour pouvoir afficher une ligne "Report" dans le mois de juin.
- [ ] **Step 1: Remplacer `openingMinutes` par 4 champs dans `EmployeeRttBalance`**
Remplacer la propriété `$openingMinutes` par :
```php
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])]
private int $openingBase25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])]
private int $openingBonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])]
private int $openingBase50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])]
private int $openingBonus50Minutes = 0;
```
Ajouter les getters/setters. Supprimer `getOpeningMinutes`/`setOpeningMinutes`. Ajouter un helper `getTotalOpeningMinutes()` qui retourne la somme des 4 champs.
- [ ] **Step 2: Adapter `RttRolloverCommand`**
`computeTotalRecoveryForExercise` retourne maintenant un `WeekRecoveryDetail`. Utiliser les 4 champs :
```php
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
$balance = new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningBase25Minutes($carry->base25Minutes)
->setOpeningBonus25Minutes($carry->bonus25Minutes)
->setOpeningBase50Minutes($carry->base50Minutes)
->setOpeningBonus50Minutes($carry->bonus50Minutes)
->setIsLocked(false)
;
```
- [ ] **Step 3: Adapter `EmployeeRttSummaryProvider::resolveCarryMinutes`**
Cette méthode retournait `int`. La renommer en `resolveCarry` et retourner un `WeekRecoveryDetail` :
```php
private function resolveCarry(Employee $employee, int $year): WeekRecoveryDetail
{
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
if (null !== $balance) {
return new WeekRecoveryDetail(
base25Minutes: $balance->getOpeningBase25Minutes(),
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
base50Minutes: $balance->getOpeningBase50Minutes(),
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
totalMinutes: $balance->getTotalOpeningMinutes(),
);
}
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
}
```
Adapter le provider pour utiliser le carry ventilé dans le summary :
- `carryFromPreviousYearMinutes` = carry->totalMinutes
- Ajouter les 4 champs de carry dans `EmployeeRttSummary` pour le frontend
- [ ] **Step 4: Ajouter les champs carry dans `EmployeeRttSummary`**
```php
public int $carryBase25Minutes = 0;
public int $carryBonus25Minutes = 0;
public int $carryBase50Minutes = 0;
public int $carryBonus50Minutes = 0;
```
- [ ] **Step 5: Générer et exécuter la migration**
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
Note : faire la migration après la Task 3 (EmployeeRttPayment) pour regrouper les changements dans une seule migration.
---
## Task 3: Modifier l'entité `EmployeeRttPayment` et la migration
**Files:**
- Modify: `src/Entity/EmployeeRttPayment.php`
- Modify: `src/Repository/EmployeeRttPaymentRepository.php`
- [ ] **Step 1: Remplacer `minutes` + `rate` par 4 champs dans l'entité**
Remplacer les propriétés `$minutes` et `$rate` par :
```php
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
private int $base25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
private int $bonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])]
private int $base50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])]
private int $bonus50Minutes = 0;
```
Ajouter les getters/setters correspondants. Supprimer `getMinutes`/`setMinutes`/`getRate`/`setRate`.
- [ ] **Step 2: Adapter le repository**
Remplacer `findOneByEmployeeYearMonthRate` par `findOneByEmployeeYearMonth` (plus besoin du rate) :
```php
public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment
{
return $this->findOneBy([
'employee' => $employee,
'year' => $year,
'month' => $month,
]);
}
```
- [ ] **Step 3: Générer et vérifier la migration**
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
Vérifier que la migration :
- Ajoute `base25_minutes`, `bonus25_minutes`, `base50_minutes`, `bonus50_minutes`
- Supprime `minutes` et `rate`
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
---
## Task 3: Adapter le DTO `RttMonthPayment` et `EmployeeRttWeekSummary`
**Files:**
- Modify: `src/Dto/Rtt/RttMonthPayment.php`
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
- [ ] **Step 1: Modifier `RttMonthPayment`**
Remplacer `paidMinutes25` et `paidMinutes50` par les 4 champs :
```php
final class RttMonthPayment
{
public function __construct(
public int $month,
public int $paidBase25Minutes = 0,
public int $paidBonus25Minutes = 0,
public int $paidBase50Minutes = 0,
public int $paidBonus50Minutes = 0,
) {}
}
```
- [ ] **Step 2: Enrichir `EmployeeRttWeekSummary`**
Ajouter les champs de détail :
```php
final class EmployeeRttWeekSummary
{
public function __construct(
public int $month,
public int $weekNumber,
public string $weekStart,
public string $weekEnd,
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}
```
Supprimer l'ancien champ `recoveryMinutes`.
---
## Task 4: Adapter le provider et le processor backend
**Files:**
- Modify: `src/State/EmployeeRttSummaryProvider.php`
- Modify: `src/ApiResource/EmployeeRttSummary.php`
- Modify: `src/ApiResource/EmployeeRttPaymentInput.php`
- Modify: `src/State/EmployeeRttPaymentProcessor.php`
- [ ] **Step 1: Adapter `EmployeeRttSummaryProvider::provide`**
Le mapping des semaines (ligne 87-96) doit utiliser les nouveaux champs du `WeekRecoveryDetail` :
```php
$summary->weeks = array_map(
static function (array $week) use ($currentByWeekStart) {
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
return new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
},
$weekRanges
);
```
Le `currentYearRecoveryMinutes` doit sommer les `totalMinutes` :
```php
$summary->currentYearRecoveryMinutes = array_sum(
array_map(static fn (WeekRecoveryDetail $d) => $d->totalMinutes, $currentByWeekStart)
);
```
Adapter l'agrégation des paiements (lignes 98-121) pour les 4 champs :
```php
foreach ($payments as $payment) {
$m = $payment->getMonth();
if (!isset($monthBuckets[$m])) {
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
}
foreach ($monthBuckets as $m => $bucket) {
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
}
```
- [ ] **Step 2: Adapter `EmployeeRttPaymentInput`**
```php
final class EmployeeRttPaymentInput
{
public int $month = 0;
public int $base25Minutes = 0;
public int $bonus25Minutes = 0;
public int $base50Minutes = 0;
public int $bonus50Minutes = 0;
public ?int $year = null;
}
```
- [ ] **Step 3: Adapter `EmployeeRttPaymentProcessor`**
Supprimer la validation du `rate`. Adapter le upsert :
```php
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
if (null === $payment) {
$payment = new EmployeeRttPayment();
$payment->setEmployee($employee);
$payment->setYear($year);
$payment->setMonth($data->month);
$this->entityManager->persist($payment);
}
$payment->setBase25Minutes($data->base25Minutes);
$payment->setBonus25Minutes($data->bonus25Minutes);
$payment->setBase50Minutes($data->base50Minutes);
$payment->setBonus50Minutes($data->bonus50Minutes);
$payment->touch();
$this->entityManager->flush();
```
- [ ] **Step 4: Vérifier**
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
---
## Task 5: Adapter le frontend — DTOs et service
**Files:**
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
- Modify: `frontend/services/employee-rtt-summary.ts`
- [ ] **Step 1: Mettre à jour les types TS**
```typescript
export type EmployeeRttWeekSummary = {
month: number
weekNumber: number
weekStart: string
weekEnd: string
overtimeMinutes: number
base25Minutes: number
bonus25Minutes: number
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
}
export type RttMonthPayment = {
month: number
paidBase25Minutes: number
paidBonus25Minutes: number
paidBase50Minutes: number
paidBonus50Minutes: number
}
export type EmployeeRttSummary = {
year: number
carryFromPreviousYearMinutes: number
carryBase25Minutes: number
carryBonus25Minutes: number
carryBase50Minutes: number
carryBonus50Minutes: number
currentYearRecoveryMinutes: number
totalPaidMinutes: number
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
}
```
- [ ] **Step 2: Adapter le service `createRttPayment`**
```typescript
export const createRttPayment = async (
employeeId: number,
month: number,
base25Minutes: number,
bonus25Minutes: number,
base50Minutes: number,
bonus50Minutes: number,
year?: number
) => {
const api = useApi()
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
}
```
---
## Task 6: Réécrire `RttTab.vue`
**Files:**
- Modify: `frontend/components/employees/RttTab.vue`
- [ ] **Step 1: Réécrire le composant complet**
Structure du template :
1. En-tête avec navigation mensuelle (flèches `<` `>`) et "RTT À LA DATE DU JOUR : X heure"
2. Tableau 7 colonnes : Semaine | Heure | Base | 25% | Base | 50% | Total
3. Si mois de juin (premier mois de l'exercice) et carry > 0 : ligne "Report" avec les 4 valeurs carry (colonne Heure = "-")
4. 5 lignes semaines (padding si < 5)
5. Ligne Total (somme par colonne, incluant le report si présent)
6. Ligne Payé (valeurs négatives, "-" pour colonne Heure)
7. Ligne Reste (Total - |Payé|, "-" pour colonne Heure)
8. Bouton "+ Payer les RRT"
9. Drawer de paiement avec 5 champs
Script setup :
- `currentMonthIndex` : ref (0-11) pour la navigation dans `orderedMonthIndexes` (toujours [5,6,7,8,9,10,11,0,1,2,3,4] = juin à mai)
- Initialiser `currentMonthIndex` au mois courant dans l'exercice
- `currentMonth` : computed qui retourne le numéro de mois (1-12) basé sur l'index
- `weeksForMonth` : computed filtrant les semaines du summary pour le mois courant, paddé à 5
- `monthPayment` : computed trouvant le paiement du mois dans `summary.monthPayments`
- Totaux par colonne : computed sommant les semaines
- `formatMinutes` : existant, réutiliser (format `Xh` ou `Xh Ym`)
- Navigation : `prevMonth` / `nextMonth` modifiant `currentMonthIndex` avec bornes [0, 11]
Drawer de paiement :
- Champs : Mois (select), Base 25% (number en heures), Heures 25% (number en heures), Base 50% (number en heures), Heures 50% (number en heures)
- Si paiement existant pour le mois sélectionné : pré-remplir en convertissant minutes → heures
- Emit : `submit-rtt-payment` avec les 4 valeurs converties en minutes + le mois
- [ ] **Step 2: Adapter le composant parent**
Chercher où `RttTab` est utilisé et adapter l'event handler `submit-rtt-payment` pour passer les 4 champs au lieu de `(month, minutes, rate)`.
Run: `grep -rn "submit-rtt-payment" frontend/` pour trouver le parent.
---
## Task 7: Test de bout en bout
- [ ] **Step 1: Vérifier le cache et la migration**
```bash
docker exec php-sirh-fpm php bin/console cache:clear
docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 2: Tester l'API**
Vérifier que `GET /api/employees/{id}/rtt-summary` retourne les nouveaux champs par semaine.
Vérifier que `PATCH /api/employees/{id}/rtt-payments` accepte les 4 champs.
- [ ] **Step 3: Tester le frontend**
- Navigation mensuelle (flèches, mois courant par défaut)
- Tableau : vérifier les valeurs par semaine
- Paiement : créer, modifier, vérifier pré-remplissage
- "RTT À LA DATE DU JOUR" : vérifier le cumul

View File

@@ -0,0 +1,117 @@
# Refonte onglet RTT employé
## Contexte
L'onglet RTT actuel affiche une grille annuelle de 12 mois avec les minutes de récupération par semaine. Il doit être remplacé par une vue mensuelle détaillée avec navigation, un tableau ventilé par palier de majoration (25% / 50%), et un système de paiement à 4 champs.
## Maquette de référence
Fichier : `RTT.png` à la racine du projet.
## Structure de la vue
### En-tête
- Navigation mensuelle : `< MOIS ANNÉE >` (flèches gauche/droite)
- Navigation limitée aux mois de l'exercice (juin N-1 à mai N)
- Mois courant affiché par défaut à l'ouverture
- En haut à droite : `RTT À LA DATE DU JOUR : X heure` (cumul annuel toutes semaines confondues)
### Tableau
7 colonnes :
| Semaine | Heure | Base | 25% | Base | 50% | Total |
|---------|-------|------|-----|------|-----|-------|
- **Semaine** : label "Semaine 1" à "Semaine 5" (toujours 5 lignes, vide si le mois n'a que 4 semaines)
- **Heure** : heures supplémentaires brutes de la semaine
- **Base** (1er) : heures de base dans le palier 25% (heures entre 35h et 39h pour un contrat 39h)
- **25%** : bonus = base 25% × 0.25
- **Base** (2e) : heures de base dans le palier 50% (heures au-delà de 43h)
- **50%** : bonus = base 50% × 0.50
- **Total** : somme de toutes les bases + tous les bonus
### Lignes de synthèse
- **Total** : somme des 5 semaines par colonne
- **Payé** : montants payés pour ce mois (affichés en négatif). Colonne "Heure" = "-"
- **Reste** : Total - |Payé| par colonne. Colonne "Heure" = "-"
### Bouton
`+ Payer les RRT` en bas, centré. Ouvre un drawer.
## Drawer de paiement
Champs :
1. **Mois** (select) : liste des mois de l'exercice
2. **Base 25%** (number, en heures)
3. **Heures 25%** (number, en heures)
4. **Base 50%** (number, en heures)
5. **Heures 50%** (number, en heures)
Si des paiements existent pour le mois sélectionné, le formulaire est pré-rempli pour modification.
Boutons : Annuler / Enregistrer.
## Rattachement semaine → mois
Règle existante conservée : une semaine est rattachée au mois de son **samedi** (voir `RttRecoveryComputationService::buildWeeksForExercise`).
## Backend
### Modification de `EmployeeRttSummary`
Le provider retourne les données pour un mois donné (paramètre query `?month=X`) en plus du cumul annuel.
Nouvelles données par semaine :
- `overtimeMinutes` : heures sup brutes
- `base25Minutes` : base palier 25%
- `bonus25Minutes` : bonus 25%
- `base50Minutes` : base palier 50%
- `bonus50Minutes` : bonus 50%
- `totalMinutes` : somme base + bonus
### Modification de `EmployeeRttPayment`
Remplacer les champs `minutes` (int) + `rate` (int 25/50) par :
- `base25Minutes` (int)
- `bonus25Minutes` (int)
- `base50Minutes` (int)
- `bonus50Minutes` (int)
Migration Doctrine nécessaire.
### Modification de `EmployeeRttPaymentInput`
Adapter les champs pour correspondre aux 4 nouvelles valeurs.
### Modification de `RttRecoveryComputationService`
`computeRecoveryByWeek` retourne déjà les minutes totales. Il faut enrichir le retour pour ventiler base/bonus par palier. La logique de calcul des paliers existe déjà en interne, il suffit de l'exposer.
## Frontend
### Stockage vs affichage
- Backend : stockage en **minutes** (inchangé)
- Frontend : conversion minutes ↔ heures à l'affichage et à la saisie
### Réécriture de `RttTab.vue`
- Supprimer la grille annuelle de 12 mois
- Navigation mensuelle avec état réactif (mois courant)
- Tableau HTML avec les 7 colonnes décrites
- 5 lignes semaines + Total + Payé + Reste
- Formatage en "Xh" ou "Xh Ym" (ex: "6h 30m")
### Modification du DTO TypeScript
Adapter `EmployeeRttSummary` et `EmployeeRttWeekSummary` pour les nouveaux champs.
## Unités de conversion
- Affichage : heures et minutes (ex: "6h 30m", "30 m")
- Saisie paiement : en heures décimales (number input)
- Stockage : minutes entières (int)

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/

View File

@@ -1,44 +1,26 @@
<template>
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé <span class="text-red-600">*</span>
</label>
<select
id="employee"
v-model="absenceForm.employeeId"
:class="employeeFieldClass"
:disabled="props.lockEmployee"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
L'employé est obligatoire.
</p>
</div>
<MalioSelect
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
:options="employeeOptions"
label="Employé *"
empty-option-label="Choisir un employé"
min-width=""
:disabled="props.lockEmployee"
:error="showEmployeeError ? `L'employé est obligatoire.` : ''"
@update:model-value="onEmployeeChange"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="type">
Type d'absence <span class="text-red-600">*</span>
</label>
<select
id="type"
v-model="absenceForm.typeId"
:class="typeFieldClass"
>
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
Le type d'absence est obligatoire.
</p>
</div>
<MalioSelect
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
:options="typeOptions"
label="Type d'absence *"
empty-option-label="Choisir un type"
min-width=""
:error="showTypeError ? `Le type d'absence est obligatoire.` : ''"
@update:model-value="onTypeChange"
/>
<div class="space-y-4">
<div>
@@ -48,17 +30,15 @@
id="start-date"
v-model="absenceForm.startDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.startHalf"
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
{{ half.label }}
</option>
</select>
<MalioSelect
:model-value="absenceForm.startHalf"
:options="halfDayOptions"
min-width=""
@update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }"
/>
</div>
</div>
<div>
@@ -68,17 +48,15 @@
id="end-date"
v-model="absenceForm.endDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.endHalf"
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
{{ half.label }}
</option>
</select>
<MalioSelect
:model-value="absenceForm.endHalf"
:options="halfDayOptions"
min-width=""
@update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }"
/>
</div>
</div>
</div>
@@ -93,30 +71,30 @@
/>
</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">
<MalioButton
type="submit"
label="Valider"
button-class="w-[200px]"
:disabled="props.isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</template>
@@ -188,20 +166,23 @@ const submitButtonClass = computed(() => {
return ''
})
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const employeeFieldClass = computed(() => {
if (showEmployeeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const typeFieldClass = computed(() => {
if (showTypeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const employeeOptions = computed(() =>
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
)
const typeOptions = computed(() =>
props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id }))
)
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
const dateInputBaseClass =
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
const onEmployeeChange = (value: string | number | null) => {
absenceForm.value.employeeId = value === null ? '' : Number(value)
}
const onTypeChange = (value: string | number | null) => {
absenceForm.value.typeId = value === null ? '' : Number(value)
}
watch(
() => props.modelValue,

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

@@ -4,20 +4,20 @@
<div class="absolute inset-0 bg-black/40" @click="close" />
</Transition>
<Transition name="drawer-panel">
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl">
<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="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl flex flex-col">
<div class="shrink-0 flex items-center justify-between px-[20px] pt-8 pb-8">
<h2 class="text-[32px] font-semibold text-primary-500">
{{ title }}
</h2>
<button
type="button"
class="rounded-md p-2 text-neutral-500 hover:bg-neutral-100"
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
@click="close"
>
<Icon name="mdi:close" size="24"/>
</button>
</div>
<div class="overflow-y-auto p-6" style="max-height: calc(100% - 65px)">
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
<slot />
</div>
</div>

View File

@@ -1,7 +1,14 @@
<template>
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-6 text-xl text-white">
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 px-4 py-3 text-white lg:p-5">
<div class="flex h-full items-center justify-between lg:justify-end">
<button
type="button"
class="rounded-md p-1 text-white hover:text-neutral-200 lg:hidden"
@click="$emit('toggleSidebar')"
>
<Icon name="mdi:menu" size="28"/>
</button>
<div class="flex gap-4 text-xl text-white lg:gap-6">
<div v-if="isAdmin" ref="bellRoot" class="relative">
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
<Icon name="mdi:bell-plus" size="36"/>
@@ -15,8 +22,8 @@
<div
v-if="isNotificationsOpen"
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
:style="{ top: `${navbarBottom + 20}px` }"
class="fixed right-2 z-30 w-[calc(100vw-1rem)] max-w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg lg:right-[20px]"
:style="{ top: `${navbarBottom + 10}px` }"
>
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
Notifications
@@ -66,7 +73,7 @@
<div ref="userMenuRoot" class="relative flex gap-4">
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<p class="self-center">{{ user?.username }}</p>
<p class="hidden self-center sm:block">{{ user?.username }}</p>
</button>
<div
v-if="isUserMenuOpen"
@@ -103,6 +110,10 @@ defineProps<{
user?: User
}>()
defineEmits<{
(event: 'toggleSidebar'): void
}>()
const formatTimeAgo = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()

View File

@@ -0,0 +1,108 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export heures (tous les employés)">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-year">
Année <span class="text-red-600">*</span>
</label>
<select
id="bulk-yearly-hours-year"
v-model="selectedYear"
:class="selectFieldClass"
>
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-month">
Mois <span class="text-red-600">*</span>
</label>
<select
id="bulk-yearly-hours-month"
v-model="selectedMonth"
:class="selectFieldClass"
>
<option value="" disabled>Sélectionner un mois</option>
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoading || selectedMonth === ''"
>
<template v-if="isLoading">
Génération en cours...
</template>
<template v-else>
Imprimer
</template>
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
isLoading?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', payload: { year: number; month: number | null }): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const months = [
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Février' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Août' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Décembre' }
]
const selectedYear = ref(currentYear)
const currentMonth = new Date().getMonth() + 1
const selectedMonth = ref<number | ''>(currentMonth)
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => {
if (selectedMonth.value === '') return
emit('submit', {
year: selectedYear.value,
month: selectedMonth.value
})
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
selectedYear.value = currentYear
selectedMonth.value = currentMonth
}
}
)
</script>

View File

@@ -45,9 +45,9 @@
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:class="getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
:disabled="getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)"
>
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -67,15 +67,20 @@
{{ getCellInfo(employee.id, day.date)?.code }}
</span>
</template>
<Icon
v-if="getCellInfo(employee.id, day.date)?.hasFormation && getCellInfo(employee.id, day.date)?.code !== 'F'"
name="mdi:school"
size="12"
class="absolute top-0 right-0 text-indigo-600 bg-white rounded-bl-md p-0.5"
title="Formation"
/>
</button>
</template>
<template v-else>
<button
type="button"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@click="handleCellClick(employee, day.date)"
>
<span></span>
@@ -107,7 +112,7 @@ const props = defineProps<{
visibleEmployees: Employee[]
gridStyle: Record<string, string>
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string } | null
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string; hasFormation?: boolean } | null
formatEmployeeName: (employee: Employee) => string
isHolidayDate: (date: string) => boolean
}>()

View File

@@ -1,26 +0,0 @@
<template>
<div class="relative w-full max-w-[340px]">
<input
id="employee-search"
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
/>
<Icon
name="mdi:magnify"
size="18"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
/>
</div>
</template>
<script setup lang="ts">
const model = defineModel<string>({required: true})
withDefaults(defineProps<{
placeholder?: string
}>(), {
placeholder: "Recherche d'un employé"
})
</script>

View File

@@ -0,0 +1,100 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export heures">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
Année <span class="text-red-600">*</span>
</label>
<select
id="yearly-hours-year"
v-model="selectedYear"
:class="selectFieldClass"
>
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-month">
Mois
</label>
<select
id="yearly-hours-month"
v-model="selectedMonth"
:class="selectFieldClass"
>
<option value="">Toute l'année</option>
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
employeeId: number
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', payload: { year: number; month: number | null }): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const months = [
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Février' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Août' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Décembre' }
]
const selectedYear = ref(currentYear)
const selectedMonth = ref<number | ''>('')
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => {
emit('submit', {
year: selectedYear.value,
month: selectedMonth.value === '' ? null : selectedMonth.value
})
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
selectedYear.value = currentYear
selectedMonth.value = ''
}
}
)
</script>

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

@@ -1,69 +0,0 @@
<template>
<div ref="root" class="relative inline-block w-fit max-w-full">
<button
type="button"
class="inline-flex w-[320px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
@click="isOpen = !isOpen"
>
<span>Sites</span>
<span class="inline-flex items-center gap-2">
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
</span>
</button>
<div
v-if="isOpen"
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
>
<div class="flex flex-col gap-2">
<label
v-for="site in sites"
:key="site.id"
:for="`site-${site.id}`"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
<span class="text-md text-neutral-800">{{ site.name }}</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Site } from '~/services/dto/site'
const selectedSiteIds = defineModel<number[]>({ required: true })
const isOpen = ref(false)
const root = ref<HTMLElement | null>(null)
defineProps<{
sites: Site[]
}>()
const selectedCount = computed(() => selectedSiteIds.value.length)
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (!root.value || !target) return
if (!root.value.contains(target)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -0,0 +1,26 @@
<template>
<article :id="`doc-${article.id}`" class="scroll-mt-6">
<h3 class="text-lg font-bold text-primary-500 mb-3">{{ article.title }}</h3>
<div class="space-y-3">
<template v-for="(block, idx) in article.blocks" :key="idx">
<p v-if="block.type === 'paragraph'" class="text-sm text-neutral-700 leading-relaxed">
{{ block.content }}
</p>
<ul v-else-if="block.type === 'list'" class="list-disc list-inside space-y-1 text-sm text-neutral-700 pl-2">
<li v-for="(item, i) in block.content.split('\n')" :key="i">{{ item }}</li>
</ul>
<div v-else-if="block.type === 'note'" class="bg-tertiary-500 border-l-4 border-primary-500 p-3 rounded-r-md">
<p class="text-sm text-neutral-700 leading-relaxed">{{ block.content }}</p>
</div>
</template>
</div>
</article>
</template>
<script setup lang="ts">
import type { DocArticle } from '~/types/documentation'
defineProps<{
article: DocArticle
}>()
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="h-full flex gap-8">
<!-- Table des matières -->
<nav class="w-64 flex-shrink-0 overflow-y-auto pr-4 border-r border-neutral-200">
<h1 class="text-xl font-bold text-primary-500 mb-6">Documentation</h1>
<div v-for="section in visibleSections" :key="section.id" class="mb-4">
<div class="flex items-center gap-2 mb-1">
<Icon :name="section.icon" size="18" class="text-neutral-500"/>
<span class="text-sm font-semibold text-neutral-700">{{ section.title }}</span>
</div>
<ul class="pl-7 space-y-0.5">
<li v-for="article in section.articles" :key="article.id">
<button
class="text-xs text-neutral-500 hover:text-primary-500 text-left w-full py-0.5 transition-colors"
:class="activeArticleId === article.id ? 'text-primary-500 font-bold' : ''"
@click="scrollToArticle(article.id)"
>
{{ article.title }}
</button>
</li>
</ul>
</div>
</nav>
<!-- Contenu -->
<div ref="contentRef" class="flex-1 overflow-y-auto pr-4">
<DocumentationSection
v-for="section in visibleSections"
:key="section.id"
:section="section"
/>
</div>
</div>
</template>
<script setup lang="ts">
const { visibleSections, activeArticleId, scrollToArticle } = useDocumentation()
const contentRef = ref<HTMLElement | null>(null)
onMounted(() => {
if (!contentRef.value) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.id.replace('doc-', '')
activeArticleId.value = id
break
}
}
},
{
root: contentRef.value,
rootMargin: '-10% 0px -80% 0px',
threshold: 0,
},
)
nextTick(() => {
const articles = contentRef.value?.querySelectorAll('[id^="doc-"]')
articles?.forEach(el => observer.observe(el))
})
onUnmounted(() => observer.disconnect())
})
</script>

View File

@@ -0,0 +1,23 @@
<template>
<section class="mb-10">
<div class="flex items-center gap-3 border-b-2 border-primary-500 pb-3 mb-6">
<Icon :name="section.icon" size="28" class="text-primary-500"/>
<h2 class="text-xl font-bold text-primary-500">{{ section.title }}</h2>
</div>
<div class="space-y-8 pl-2">
<DocumentationArticle
v-for="article in section.articles"
:key="article.id"
:article="article"
/>
</div>
</section>
</template>
<script setup lang="ts">
import type { DocSection } from '~/types/documentation'
defineProps<{
section: DocSection
}>()
</script>

View File

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

View File

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

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,13 +10,13 @@
<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>{{ item.interimAgencyName ? `${contractNatureLabel(item.contractNature)} (${item.interimAgencyName})` : contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
@@ -50,7 +50,7 @@
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'close'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'close'"
>
Clôturer
@@ -60,7 +60,7 @@
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'suspend'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-neutral-400 hover:text-neutral-600'"
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'suspend'"
>
Suspendre
@@ -108,6 +108,13 @@
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div>
<WorkDaysHoursInput
v-if="contractForm.workDaysHours"
:model-value="contractForm.workDaysHours"
:contract-weekly-hours="contractForm.weeklyHours ?? null"
disabled
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
Commentaire
@@ -133,21 +140,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>
@@ -201,7 +200,7 @@
<button
type="button"
class="w-full rounded-md border-2 border-dashed border-neutral-300 px-4 py-3 text-base font-semibold text-neutral-500 transition hover:border-primary-500 hover:text-primary-500"
class="w-full rounded-md border-2 border-dashed border-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
@click="onAddSuspensionForm"
>
+ Ajouter une suspension
@@ -222,6 +221,22 @@
</select>
</div>
<div v-if="createContractForm.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="create-interim-agency">
Agence d'intérim
</label>
<select
id="create-interim-agency"
v-model="createContractForm.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
Temps de travail <span class="text-red-600">*</span>
@@ -248,21 +263,31 @@
<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>
<WorkDaysHoursInput
v-if="requiresCreateWorkDaysHours"
v-model="createContractForm.workDaysHours"
:contract-weekly-hours="selectedCreateContract?.weeklyHours ?? null"
/>
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-center">
<button
type="submit"
class="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>
@@ -273,6 +298,8 @@
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
import type { InterimAgency } from '~/services/interim-agencies'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
type SuspensionForm = {
id: number | null
@@ -290,6 +317,7 @@ type ContractForm = {
endDate: string
paidLeaveSettled: boolean
comment: string
workDaysHours: Record<number, number> | null
}
type CreateContractForm = {
@@ -297,6 +325,9 @@ type CreateContractForm = {
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
isDriver: boolean
workDaysHours: Record<number, number> | null
interimAgencyId: number | ''
}
const props = defineProps<{
@@ -325,6 +356,8 @@ const props = defineProps<{
requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string
isCreateContractFormValid: boolean
requiresCreateWorkDaysHours: boolean
selectedCreateContract: Contract | null
onOpenCloseContractDrawer: () => void
onOpenCreateContractDrawer: () => void
onUpdateContractDrawerOpen: (open: boolean) => void
@@ -336,6 +369,7 @@ const props = defineProps<{
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
interimAgencies: InterimAgency[]
}>()
const drawerTab = ref<'close' | 'suspend'>('close')

View File

@@ -0,0 +1,251 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Date de début</p>
<p>Date de fin</p>
<p>Justificatif</p>
<p>Commentaire</p>
</div>
<div v-if="formations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune formation.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in formations"
:key="item.id"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
<p class="min-w-0">
<a
v-if="item.justificatifPath"
:href="getFormationJustificatifUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.justificatifName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
<p class="truncate">{{ item.comment ?? '-' }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" title="Formation">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-start-date">
Date de début <span class="text-red-600">*</span>
</label>
<input
id="formation-start-date"
v-model="form.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-end-date">
Date de fin <span class="text-red-600">*</span>
</label>
<input
id="formation-end-date"
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
<p v-if="isDateRangeInvalid" class="mt-1 text-sm text-red-600">La date de fin doit être postérieure ou égale à la date de début.</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-justificatif">
Justificatif
</label>
<div v-if="isEditing && editingItem?.justificatifName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.justificatifName }}
</div>
<input
id="formation-justificatif"
ref="justificatifInput"
type="file"
accept="application/pdf"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onJustificatifChange"
/>
<p v-if="justificatifError" class="mt-1 text-sm text-red-600">{{ justificatifError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-comment">
Commentaire
</label>
<textarea
id="formation-comment"
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type {Formation} from '~/services/dto/formation'
import {getFormationJustificatifUrl} from '~/services/formations'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
formations: Formation[]
apiBase: string
}>()
const emit = defineEmits<{
(event: 'create', data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'update', id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Formation | null>(null)
const selectedJustificatif = ref<File | undefined>(undefined)
const justificatifInput = ref<HTMLInputElement | null>(null)
const justificatifError = ref('')
const form = reactive({
startDate: '',
endDate: '',
comment: ''
})
const isDateRangeInvalid = computed(() => {
if (!form.startDate || !form.endDate) return false
return form.endDate < form.startDate
})
const isFormValid = computed(() => {
return Boolean(form.startDate) && Boolean(form.endDate) && !isDateRangeInvalid.value && !justificatifError.value
})
const formatDate = (dateStr: string): string => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
return date.toLocaleDateString('fr-FR')
}
const resetForm = () => {
form.startDate = ''
form.endDate = ''
form.comment = ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Formation) => {
isEditing.value = true
editingItem.value = item
form.startDate = item.startDate
form.endDate = item.endDate
form.comment = item.comment ?? ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
isDrawerOpen.value = true
}
const onJustificatifChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
justificatifError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedJustificatif.value = undefined
target.value = ''
return
}
justificatifError.value = ''
selectedJustificatif.value = file ?? undefined
}
const onSubmit = () => {
const data = {
startDate: form.startDate,
endDate: form.endDate,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data, selectedJustificatif.value)
} else {
emit('create', data, selectedJustificatif.value)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette formation ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -1,35 +1,66 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours</p>
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours</p>
</div>
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
<p><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
</div>
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
<p class="col-start-1 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours
</p>
<p class="col-start-2 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Pris :</strong>
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
</p>
<p class="col-start-3 p-[10px] border-b border-r border-b-white border-r-primary-500 bg-primary-500 text-white"><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
</p>
<p v-else class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Année N-1 acquis :</span>
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.takenSaturdays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-3 p-[10px] border-r border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours
</p>
<p v-else class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.previousYearTakenDays) }} Jours
</p>
<p v-if="isForfaitRule" class="col-start-3 p-[10px] border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
</p>
<div v-if="isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Année n-1 payés : </span>
<span> {{ formatCount(summary?.previousYearPaidDays) }} Jours</span>
</div>
<button
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
class="flex items-center"
@click="openPaidLeaveDrawer"
>
<Icon name="mdi:edit-box" size="24"/>
</button>
</div>
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Fractionné acquis : </span>
<span>{{ formatCount(summary?.fractionedDays) }} Jours</span>
</div>
<button
class="flex items-center"
@click="openFractionedDrawer"
>
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
</div>
<div class="flex flex-col jutify-center gap-2 items-center py-3">
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
<Icon name="mdi:edit-box" size="24"/>
</button>
</div>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
<div v-for="month in months" :key="month.label"
class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
{{ month.label }}
</div>
@@ -54,7 +85,9 @@
</div>
</template>
</div>
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence : {{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}</div>
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence :
{{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}
</div>
</div>
</div>
</div>
@@ -91,6 +124,39 @@
</div>
</form>
</AppDrawer>
<AppDrawer v-model="isPaidLeaveDrawerOpen" title="Congés N-1 payés">
<form class="space-y-4" @submit.prevent="handleSubmitPaidLeave">
<div>
<label class="text-md font-semibold text-neutral-700" for="paid-leave-days">
Nombre de jours <span class="text-red-600">*</span>
</label>
<input
id="paid-leave-days"
v-model="paidLeaveForm.days"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="isPaidLeaveDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
@@ -115,10 +181,14 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'update-fractioned-days', days: number): void
(event: 'update-paid-leave-days', days: number): void
}>()
const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({ days: 0 })
const fractionedForm = reactive({days: 0})
const isPaidLeaveDrawerOpen = ref(false)
const paidLeaveForm = reactive({days: 0})
const openFractionedDrawer = () => {
fractionedForm.days = props.summary?.fractionedDays ?? 0
@@ -132,6 +202,18 @@ const handleSubmitFractioned = () => {
isFractionedDrawerOpen.value = false
}
const openPaidLeaveDrawer = () => {
paidLeaveForm.days = props.summary?.previousYearPaidDays ?? 0
isPaidLeaveDrawerOpen.value = true
}
const handleSubmitPaidLeave = () => {
const value = Number(paidLeaveForm.days)
if (Number.isNaN(value) || value < 0) return
emit('update-paid-leave-days', value)
isPaidLeaveDrawerOpen.value = false
}
const monthLabels = [
'Janvier',
'Fevrier',
@@ -151,6 +233,11 @@ const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
const currentYearTakenDays = computed(() => {
if (!props.summary) return null
return props.summary.takenDays - (props.summary.previousYearTakenDays ?? 0)
})
const displayedYear = computed(() => {
if (props.summary?.year) return props.summary.year
const today = new Date()
@@ -282,15 +369,15 @@ const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) =
if (day.leave) {
const color = day.leave.colors[0] ?? '#222783'
if (day.leave.am && day.leave.pm) {
return { backgroundColor: color }
return {backgroundColor: color}
}
const colorFaded = `${color}60`
const backgroundImage = day.leave.am
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
return { backgroundImage, backgroundColor: 'transparent' }
return {backgroundImage, backgroundColor: 'transparent'}
}
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
if (day.isHoliday) return {backgroundColor: 'rgb(179, 229, 252)'}
return undefined
}

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

@@ -1,80 +1,256 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5">
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
<button class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px] text-md" @click="openNewPayment">
+ Payer les RTT
</button>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10 pb-4">
<div
v-for="month in months"
:key="month.month"
class="rounded-md bg-tertiary-500 text-primary-500"
<!-- Header bar -->
<div class="flex items-center justify-between rounded-t-md bg-tertiary-500 px-5 py-4 text-black border border-primary-500">
<div class="flex items-center">
<button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
:disabled="currentMonthIndex === 0"
@click="currentMonthIndex--"
>
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
{{ month.label }}
</div>
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
<template v-for="week in month.weeks" :key="week.key">
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
<span v-if="week.isEmpty">&nbsp;</span>
<span v-else>Semaine {{ week.weekNumber }}</span>
</div>
<div class="py-[6px] pl-3 border-b border-primary-500">
<span v-if="week.isEmpty">&nbsp;</span>
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
</div>
</template>
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
<div class="py-[6px] pl-3 border-b border-primary-500 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
@click="openEditPayment(month.month, '25')"
title="Modifier les heures payées"
>
<p>{{ formatMinutes(getMonthPaid25(month.month)) }}</p>
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
</div>
</div>
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
<div class="py-[6px] px-3 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
@click="openEditPayment(month.month, '50')"
title="Modifier les heures payées"
>
<p>{{ formatMinutes(getMonthPaid50(month.month)) }}</p>
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
</div>
</div>
</div>
</div>
<Icon name="mdi:chevron-left" size="24"/>
</button>
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
{{ currentMonthLabel }} {{ currentYear }}
</span>
<button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
:disabled="currentMonthIndex === 11"
@click="currentMonthIndex++"
>
<Icon name="mdi:chevron-right" size="24"/>
</button>
</div>
<p class="text-[16px]">
<span class="font-bold">RTT À LA SEMAINE {{ lastCompleteWeek }} : </span>
<span class="font-bold">{{ formatMinutes(summary?.availableMinutes ?? 0) }}</span>
</p>
<div class="flex justify-center">
<button
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
@click="openPaymentDrawer"
>
+ Payer les RTT
</button>
</div>
</div>
<AppDrawer v-model="isPaymentDrawerOpen" :title="isEditMode ? 'Modifier le paiement RTT' : 'Payer des RTT'">
<!-- Table -->
<div class="min-h-0 flex-1 overflow-y-auto">
<table class="w-full table-fixed border-collapse text-[18px]">
<colgroup>
<col />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
</colgroup>
<thead>
<tr>
<th class="px-5 py-[10px] text-left font-bold text-primary-500 border border-primary-500">Semaine</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Heure</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Cumul</th>
</tr>
</thead>
<tbody>
<!-- Report N-1 row (RTT rollover carry, June only) -->
<tr v-if="showCarryRow" class="bg-tertiary-500">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
</tr>
<!-- Report mois précédent (cumulated balance from previous months, July+) -->
<tr v-if="showMonthReportRow" class="bg-tertiary-500">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
</tr>
<!-- Week rows (always 5) -->
<tr
v-for="(week, idx) in paddedWeeks"
:key="week ? week.weekStart : `empty-${idx}`"
>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">
<span v-if="week">Semaine {{ week.weekNumber }}</span>
<span v-else>&nbsp;</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.overtimeMinutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base25Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus25Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.base25Minutes + week.bonus25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base50Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus50Minutes : 0) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.cumulativeBalanceMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(week.cumulativeBalanceMinutes) }}</span></span>
<span v-else>&nbsp;</span>
</td>
</tr>
<!-- Total row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500 border-t-2">Total</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.overtime) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total) }}</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-t-2">-</td>
</tr>
<!-- Payé row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase25Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus25Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes) : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr>
<!-- Reste row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total25) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr>
</tbody>
</table>
</div>
<!-- Payment Drawer -->
<AppDrawer v-model="isPaymentDrawerOpen" title="Payer des RTT">
<form @submit.prevent="onSubmitPayment">
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Mois</label>
<select v-model.number="paymentForm.month" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
<select
v-model.number="paymentForm.month"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Nombre d'heures</label>
<input v-model.number="paymentForm.hours" type="number" step="0.5" min="0" class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20" />
<label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label>
<input
v-model.number="paymentForm.base25Hours"
type="number"
step="0.01"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label>
<input
v-model.number="paymentForm.bonus25Hours"
type="number"
step="0.01"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label>
<input
v-model.number="paymentForm.base50Hours"
type="number"
step="0.01"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-neutral-700">Taux</label>
<select v-model="paymentForm.rate" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
<option value="25">25%</option>
<option value="50">50%</option>
</select>
<label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label>
<input
v-model.number="paymentForm.bonus50Hours"
type="number"
step="0.01"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="flex justify-end gap-3">
<button type="button" class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" @click="isPaymentDrawerOpen = false">Annuler</button>
<button type="submit" class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600">Enregistrer</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="isPaymentDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
@@ -82,7 +258,7 @@
</template>
<script setup lang="ts">
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
@@ -90,27 +266,38 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(event: 'submit-rtt-payment', month: number, minutes: number, rate: '25' | '50'): void
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
}>()
const isPaymentDrawerOpen = ref(false)
const isEditMode = ref(false)
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
// --- Last complete week number ---
const monthLabels = [
'Janvier',
'Fevrier',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Aout',
'Septembre',
'Octobre',
'Novembre',
'Decembre'
] as const
const lastCompleteWeek = computed(() => {
const now = new Date()
const startOfYear = new Date(now.getFullYear(), 0, 1)
const dayOfYear = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000) + 1
const dayOfWeek = now.getDay() || 7 // Monday = 1, Sunday = 7
const currentWeek = Math.ceil((dayOfYear - dayOfWeek + 10) / 7)
return currentWeek - 1
})
// --- Month navigation ---
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
const monthLabels: Record<number, string> = {
1: 'JANVIER',
2: 'FEVRIER',
3: 'MARS',
4: 'AVRIL',
5: 'MAI',
6: 'JUIN',
7: 'JUILLET',
8: 'AOUT',
9: 'SEPTEMBRE',
10: 'OCTOBRE',
11: 'NOVEMBRE',
12: 'DECEMBRE',
}
const orderedMonthOptions = [
{ value: 6, label: 'Juin' },
@@ -124,97 +311,245 @@ const orderedMonthOptions = [
{ value: 2, label: 'Fevrier' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' }
{ value: 5, label: 'Mai' },
]
const paymentsByMonth = computed(() => {
const map = new Map<number, { paid25: number; paid50: number }>()
for (const mp of props.summary?.monthPayments ?? []) {
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
}
return map
// Initialize to current month's position in the exercise
const today = new Date()
const todayMonth = today.getMonth() + 1
const initialIndex = orderedMonths.indexOf(todayMonth as (typeof orderedMonths)[number])
const currentMonthIndex = ref(initialIndex >= 0 ? initialIndex : 0)
const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
const currentYear = computed(() => {
if (!props.summary) return ''
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
})
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
// --- Weeks for current month ---
const months = computed(() => {
type DisplayWeek = {
key: string
weekNumber: number
recoveryMinutes: number
isEmpty?: boolean
}
const byMonth = new Map<number, { month: number; label: string; weeks: DisplayWeek[]; totalMinutes: number }>()
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5]
for (const month of orderedMonths) {
byMonth.set(month, {
month,
label: monthLabels[month - 1],
weeks: [],
totalMinutes: 0
})
}
for (const week of props.summary?.weeks ?? []) {
const month = byMonth.get(week.month)
if (!month) continue
month.weeks.push({
key: week.weekStart,
weekNumber: week.weekNumber,
recoveryMinutes: week.recoveryMinutes
})
month.totalMinutes += week.recoveryMinutes
}
return orderedMonths
.map((monthNumber) => byMonth.get(monthNumber)!)
.filter(Boolean)
.map((month) => {
const minRows = 5
const missing = Math.max(0, minRows - month.weeks.length)
for (let i = 0; i < missing; i += 1) {
month.weeks.push({
key: `empty-${month.month}-${i}`,
weekNumber: 0,
recoveryMinutes: 0,
isEmpty: true
})
}
return month
})
const weeksForCurrentMonth = computed((): EmployeeRttWeekSummary[] => {
if (!props.summary) return []
return props.summary.weeks.filter((w) => w.month === currentMonth.value)
})
const formatMinutes = (minutes: number) => {
const paddedWeeks = computed((): (EmployeeRttWeekSummary | null)[] => {
const weeks = weeksForCurrentMonth.value
const padded: (EmployeeRttWeekSummary | null)[] = [...weeks]
while (padded.length < 5) {
padded.push(null)
}
return padded
})
// --- Carry row (RTT rollover from previous year, June only) ---
const carryMonth = computed(() => {
if (!props.summary) return 6
const cm = props.summary.carryMonth
return cm >= 12 ? 1 : cm + 1
})
const showCarryRow = computed(() => {
if (currentMonth.value !== carryMonth.value) return false
if ((props.summary?.carryFromPreviousYearMinutes ?? 0) === 0) return false
// On the first exercise, hide carry if carry month is before rttStartDate
if (props.summary?.rttStartDate) {
const startDate = new Date(props.summary.rttStartDate)
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
const startMonthDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1)
if (viewDate < startMonthDate) return false
}
return true
})
// --- Month report row (cumulated balance from previous months) ---
// Months of the exercise in order, starting from the carry month
const exerciseMonths = computed((): number[] => {
const start = carryMonth.value
const startIdx = orderedMonths.indexOf(start as (typeof orderedMonths)[number])
if (startIdx === -1) return [...orderedMonths]
return [...orderedMonths.slice(startIdx), ...orderedMonths.slice(0, startIdx)]
})
const monthReport = computed(() => {
if (!props.summary) return { base25: 0, bonus25: 0, total25: 0, base50: 0, bonus50: 0, total50: 0, total: 0 }
const cm = currentMonth.value
const cmIdx = exerciseMonths.value.indexOf(cm)
const previousMonths = exerciseMonths.value.slice(0, cmIdx)
// Start from carry (included in the cumulation)
let base25 = props.summary.carryBase25Minutes
let bonus25 = props.summary.carryBonus25Minutes
let base50 = props.summary.carryBase50Minutes
let bonus50 = props.summary.carryBonus50Minutes
let total = props.summary.carryFromPreviousYearMinutes
// Add weeks from previous months
for (const w of props.summary.weeks) {
if (previousMonths.includes(w.month)) {
base25 += w.base25Minutes
bonus25 += w.bonus25Minutes
base50 += w.base50Minutes
bonus50 += w.bonus50Minutes
total += w.totalMinutes
}
}
// Subtract payments from previous months
for (const p of props.summary.monthPayments) {
if (previousMonths.includes(p.month)) {
base25 -= p.paidBase25Minutes
bonus25 -= p.paidBonus25Minutes
base50 -= p.paidBase50Minutes
bonus50 -= p.paidBonus50Minutes
total -= (p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
}
}
return { base25, bonus25, total25: base25 + bonus25, base50, bonus50, total50: base50 + bonus50, total }
})
const showMonthReportRow = computed(() => {
// Not on the carry month — carry row handles that
if (currentMonth.value === carryMonth.value) return false
// On the first exercise (containing rttStartDate), hide report for months before the start date
if (props.summary?.rttStartDate) {
const startDate = new Date(props.summary.rttStartDate)
const startYear = startDate.getFullYear()
const startMonth = startDate.getMonth() + 1
const viewYear = currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
const viewDate = new Date(viewYear, currentMonth.value - 1, 1)
const startMonthDate = new Date(startYear, startMonth - 1, 1)
if (viewDate < startMonthDate) return false
}
const r = monthReport.value
return r.total !== 0
})
// --- Totals (current month weeks only) ---
const totals = computed(() => {
const weeks = weeksForCurrentMonth.value
const positive = weeks.filter((w) => w.totalMinutes >= 0)
return {
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
base25: positive.reduce((s, w) => s + w.base25Minutes, 0),
bonus25: positive.reduce((s, w) => s + w.bonus25Minutes, 0),
total25: weeks.reduce((s, w) => s + w.base25Minutes + w.bonus25Minutes, 0),
base50: positive.reduce((s, w) => s + w.base50Minutes, 0),
bonus50: positive.reduce((s, w) => s + w.bonus50Minutes, 0),
total50: weeks.reduce((s, w) => s + w.base50Minutes + w.bonus50Minutes, 0),
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
}
})
const currentPayment = computed(() => {
if (!props.summary) return null
return props.summary.monthPayments.find((p) => p.month === currentMonth.value) ?? null
})
const paidTotal = computed(() => {
if (!currentPayment.value) return 0
const p = currentPayment.value
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
})
const reste = computed(() => {
const total25 = monthReport.value.total25 + totals.value.total25
- (currentPayment.value?.paidBase25Minutes ?? 0) - (currentPayment.value?.paidBonus25Minutes ?? 0)
const total50 = monthReport.value.total50 + totals.value.total50
- (currentPayment.value?.paidBase50Minutes ?? 0) - (currentPayment.value?.paidBonus50Minutes ?? 0)
const base25 = Math.round(total25 / 1.25)
const bonus25 = total25 - base25
const base50 = Math.round(total50 / 1.5)
const bonus50 = total50 - base50
const total = monthReport.value.total + totals.value.total + paidTotal.value
return { base25, bonus25, total25, base50, bonus50, total50, total }
})
// --- Format ---
const formatMinutes = (minutes: number): string => {
if (minutes === 0) return '0 h'
const sign = minutes < 0 ? '- ' : ''
const abs = Math.abs(minutes)
const hours = Math.floor(abs / 60)
const rest = abs % 60
const sign = minutes < 0 ? '-' : ''
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
if (rest === 0) return `${sign}${hours} h`
return `${sign}${hours} h ${rest} m`
}
const openNewPayment = () => {
isEditMode.value = false
paymentForm.month = 6
paymentForm.hours = 0
paymentForm.rate = '25'
isPaymentDrawerOpen.value = true
const formatCentiemes = (minutes: number): string => {
const value = minutes / 60
return value.toFixed(2).replace('.', ',')
}
const openEditPayment = (month: number, rate: '25' | '50') => {
isEditMode.value = true
paymentForm.month = month
paymentForm.rate = rate
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
paymentForm.hours = currentMinutes / 60
// --- Payment drawer ---
const isPaymentDrawerOpen = ref(false)
const paymentForm = reactive({
month: 6,
base25Hours: 0,
bonus25Hours: 0,
base50Hours: 0,
bonus50Hours: 0,
})
const prefillFromExistingPayment = (month: number) => {
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
if (existing) {
paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100
paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100
paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100
paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100
} else {
paymentForm.base25Hours = 0
paymentForm.bonus25Hours = 0
paymentForm.base50Hours = 0
paymentForm.bonus50Hours = 0
}
}
watch(() => paymentForm.month, (newMonth) => {
prefillFromExistingPayment(newMonth)
})
watch(() => paymentForm.base25Hours, (value) => {
paymentForm.bonus25Hours = Math.round(value * 0.25 * 100) / 100
})
watch(() => paymentForm.base50Hours, (value) => {
paymentForm.bonus50Hours = Math.round(value * 0.50 * 100) / 100
})
const openPaymentDrawer = () => {
paymentForm.month = currentMonth.value
prefillFromExistingPayment(currentMonth.value)
isPaymentDrawerOpen.value = true
}
const onSubmitPayment = () => {
const minutes = Math.round(paymentForm.hours * 60)
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
emit(
'submit-rtt-payment',
paymentForm.month,
Math.round(paymentForm.base25Hours * 60),
Math.round(paymentForm.bonus25Hours * 60),
Math.round(paymentForm.base50Hours * 60),
Math.round(paymentForm.bonus50Hours * 60),
)
isPaymentDrawerOpen.value = false
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<div class="flex items-center justify-between">
<p class="text-md font-semibold text-neutral-700">
Jours travaillés <span v-if="!disabled" class="text-red-600">*</span>
</p>
<p class="text-sm" :class="totalIsValid ? 'text-green-700' : 'text-red-600'">
{{ formatTotal(totalMinutes) }} / {{ formatTotal(expectedMinutes) }}
</p>
</div>
<p v-if="!disabled" class="text-xs text-neutral-500">Somme requise = {{ expectedMinutes / 60 }}h (total hebdo du contrat).</p>
<div class="space-y-1">
<div v-for="day in days" :key="day.iso" class="flex items-center gap-3">
<label class="inline-flex items-center gap-2 min-w-[120px]">
<input
:checked="day.active"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
:disabled="disabled"
@change="onToggleDay(day.iso, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-md text-neutral-700">{{ day.label }}</span>
</label>
<input
:value="day.time"
type="time"
step="60"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:bg-neutral-100 disabled:text-neutral-400"
:disabled="disabled || !day.active"
@input="onChangeTime(day.iso, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<p v-if="!totalIsValid" class="text-sm text-red-600">
La somme des heures par jour doit égaler exactement {{ expectedMinutes / 60 }}h.
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
modelValue: Record<number, number> | null
contractWeeklyHours: number | null
disabled?: boolean
}>(), { disabled: false })
const emit = defineEmits<{
'update:modelValue': [value: Record<number, number>]
}>()
const DAY_LABELS: Record<number, string> = { 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi' }
const expectedMinutes = computed(() => (props.contractWeeklyHours ?? 0) * 60)
const days = computed(() => {
const raw = props.modelValue ?? {}
return [1, 2, 3, 4, 5].map((iso) => {
const active = Object.prototype.hasOwnProperty.call(raw, iso)
const minutes = Number(raw[iso] ?? 0)
return {
iso,
label: DAY_LABELS[iso],
active,
time: active ? minutesToTime(minutes) : '00:00',
}
})
})
const totalMinutes = computed(() => {
const raw = props.modelValue ?? {}
return Object.values(raw).reduce((sum, n) => sum + (Number(n) || 0), 0)
})
const totalIsValid = computed(() => totalMinutes.value === expectedMinutes.value && expectedMinutes.value > 0)
function minutesToTime(minutes: number): string {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
function timeToMinutes(value: string): number {
const [h, m] = value.split(':').map(Number)
return (h || 0) * 60 + (m || 0)
}
function onToggleDay(iso: number, active: boolean) {
const next = { ...(props.modelValue ?? {}) }
if (active) {
next[iso] = next[iso] ?? 0
} else {
delete next[iso]
}
emit('update:modelValue', next)
}
function onChangeTime(iso: number, value: string) {
const next = { ...(props.modelValue ?? {}) }
const minutes = timeToMinutes(value)
next[iso] = minutes
emit('update:modelValue', next)
}
function formatTotal(min: number): string {
const h = Math.floor(min / 60)
const m = min % 60
return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
}
defineExpose({ totalIsValid, totalMinutes })
</script>

View File

@@ -1,12 +1,180 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<!-- Mobile card layout -->
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
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"
v-for="employee in employees"
:key="'m-' + employee.id"
class="rounded-md border border-primary-500 bg-white p-4"
>
<!-- Employee name + site -->
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
</p>
<p class="text-sm text-neutral-500 truncate">
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</p>
</div>
<!-- Absence / Holiday / Formation pills -->
<div class="mb-3 flex flex-col gap-1">
<p
v-if="getRowAbsenceLabel(employee.id)"
class="rounded-md px-2 py-1 text-xs text-white truncate"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) }}
</p>
<p
v-else
class="text-xs text-neutral-400"
>
Aucune absence
</p>
<p
v-if="isHoliday"
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p
v-if="hasRowFormation(employee.id)"
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
<button
v-if="!hasRowFormation(employee.id)"
type="button"
class="self-start text-xs font-semibold underline"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<!-- Time inputs (TIME tracking) -->
<div v-if="isTimeTracking(employee)" class="space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début matin</label>
<TimeSelect
v-model="rows[employee.id].morningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin matin</label>
<TimeSelect
v-model="rows[employee.id].morningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début soir</label>
<TimeSelect
v-model="rows[employee.id].eveningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin soir</label>
<TimeSelect
v-model="rows[employee.id].eveningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
</div>
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
</div>
</div>
<!-- Presence tracking -->
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentMorning"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
Matin
</label>
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentAfternoon"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
Après-midi
</label>
</div>
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
</div>
<!-- Validation status (non-admin) -->
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
<span v-if="!isSiteManager" class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
</span>
<span class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
</span>
</div>
<!-- Validation checkbox (admin) -->
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-neutral-700 font-semibold">Valider</span>
</div>
</div>
</div>
<!-- Desktop table layout -->
<div class="overflow-y-auto min-h-0 hidden lg:block">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-2">Statut</span>
<span class="pl-4">Début matin</span>
<span class="pr-2">Fin matin</span>
<span class="pl-2">Début après-midi</span>
@@ -26,26 +194,15 @@
@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-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="employee in employees"
:key="employee.id"
class="grid w-full min-w-0 items-center gap-1 border-b border-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">
@@ -54,7 +211,9 @@
<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>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
@@ -68,19 +227,39 @@
</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>
<div class="flex flex-col gap-1 min-w-0">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p
v-if="hasRowFormation(employee.id)"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
:title="getRowFormationLabel(employee.id)"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
</div>
<button
v-if="!hasRowFormation(employee.id)"
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="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)"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
@@ -142,19 +321,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)
}}
@@ -169,16 +348,8 @@
@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>
<div v-else-if="!isSiteManager" class="text-right p-5">
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
@@ -186,6 +357,7 @@
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -194,6 +366,7 @@
import type {Employee} from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue'
import type {HourRow} from './types'
import { contractNatureLabel } from '~/utils/contract'
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
const bulkValidationInput = ref<HTMLInputElement | null>(null)
@@ -205,6 +378,7 @@ const props = defineProps<{
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
@@ -226,9 +400,12 @@ const props = defineProps<{
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 }
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void

View File

@@ -1,17 +1,90 @@
<template>
<div class="py-6 flex flex-col gap-3">
<div class="flex gap-4">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
<div class="py-4 flex flex-col gap-3 lg:py-6">
<!-- Desktop: filters row -->
<div class="hidden lg:flex lg:items-center lg:gap-4">
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<div v-if="isAdmin" class="w-80">
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
</div>
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
<!-- Mobile: search + filter button -->
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
<div class="flex-1 min-w-0">
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<button
type="button"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
@click="filtersDrawerOpen = true"
>
<Icon name="mdi:filter-variant" size="22"/>
</button>
</div>
<!-- Mobile filters drawer -->
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
<div class="space-y-6">
<div v-if="sites.length > 0 && isAdmin">
<label class="text-md font-semibold text-neutral-700">Sites</label>
<div class="mt-2">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
</div>
<div v-if="isAdmin">
<label class="text-md font-semibold text-neutral-700">Vue</label>
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('week')"
@click="viewMode = 'week'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
</div>
</AppDrawer>
<!-- Date navigation -->
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
<div
v-if="viewMode === 'day'"
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -41,7 +114,7 @@
<div
v-else
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -70,7 +143,7 @@
</div>
<PeriodStepperPicker
width-class="w-[320px]"
width-class="w-full lg:w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
@@ -82,7 +155,8 @@
/>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<!-- Desktop: view mode toggle -->
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
@@ -106,7 +180,7 @@
<div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6"
class="hidden lg:flex flex-wrap items-center gap-6"
>
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
@@ -120,9 +194,8 @@
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import AppDrawer from '~/components/AppDrawer.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
@@ -130,7 +203,7 @@ const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{
const props = defineProps<{
isAdmin: boolean
sites: Site[]
absenceTypes: AbsenceType[]
@@ -140,6 +213,8 @@ defineProps<{
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>()
const siteOptions = computed(() => props.sites.map((site) => ({ label: site.name, value: site.id })))
const emit = defineEmits<{
(e: 'set-yesterday'): void
(e: 'set-today'): void
@@ -150,6 +225,8 @@ const emit = defineEmits<{
(e: 'shift-date', value: number): void
}>()
const filtersDrawerOpen = ref(false)
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value

View File

@@ -1,9 +1,74 @@
<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">
<!-- Mobile cards -->
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<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"
v-for="row in weeklySummary?.rows ?? []"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-xs text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div>
<!-- Daily breakdown -->
<div class="mb-3 space-y-1">
<div
v-for="(daily, i) in row.daily"
:key="daily.date"
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
:style="getDailyCellStyle(daily)"
:title="cellTitle(daily)"
>
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
</div>
</div>
<!-- Weekly totals -->
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="flex justify-between">
<span class="text-neutral-500">Total sem.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">H. supp.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+25%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+50%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">Récup.</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
</div>
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
<span class="text-neutral-500">Panier nuit</span>
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
</div>
</div>
</div>
</div>
<!-- Desktop table -->
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
@@ -14,12 +79,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">
@@ -27,7 +94,12 @@
{{ 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>
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span></span>
<button v-if="isAdmin" type="button" class="flex items-center text-white p-1" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
<Icon name="mdi:comment-text-outline" size="12"/>
</button>
</p>
</div>
<div
@@ -36,7 +108,7 @@
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
:title="cellTitle(daily)"
>
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
<template v-else>
@@ -67,6 +139,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>
@@ -75,24 +151,43 @@
<script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
import { contractNatureLabel } from '~/utils/contract'
const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM
}
const HOLIDAY_BG_COLOR = '#b3e5fc'
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
holidayLabel?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
return undefined
}
const cellTitle = (daily: {
hasAbsence?: boolean
absenceLabel?: string | null
holidayLabel?: string | null
}) => {
const parts: string[] = []
if (daily.absenceLabel) parts.push(daily.absenceLabel)
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
return parts.join(' — ')
}
defineProps<{
isWeekLoading: boolean
isAdmin: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string
}>()
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
</script>

View File

@@ -0,0 +1,81 @@
<template>
<MalioDrawer v-model="drawerOpen" title="Commentaire">
<form class="space-y-4" @submit.prevent="onSave">
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
<div class="text-md font-semibold text-neutral-700">{{ formatWeekRange }}</div>
<MalioInputTextArea
v-model="content"
label="Commentaire"
:size="8"
:max-length="5000"
:show-counter="true"
resize="vertical"
/>
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-between gap-3">
<MalioButton
v-if="commentId"
label="Supprimer"
variant="danger"
:disabled="isSubmitting"
@click="onDelete"
/>
<MalioButton
label="Enregistrer"
button-class="ml-auto"
:disabled="isSubmitting || !canSubmit"
@click="onSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { createWeekComment, deleteWeekComment, updateWeekComment } from '~/services/employee-week-comments'
import { getIsoWeekNumber, parseYmd } from '~/utils/date'
const props = defineProps<{
modelValue: boolean
employeeId: number | null
weekStart: string
weekEnd: string
initialContent: string
commentId: number | null
employeeLabel?: string
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
const drawerOpen = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })
const content = ref('')
const isSubmitting = ref(false)
watch(() => [props.modelValue, props.initialContent] as const, ([open, init]) => { if (open) content.value = init ?? '' }, { immediate: true })
const formatWeekRange = computed(() => {
const fmt = (ymd: string) => { const p = ymd.split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : ymd }
const start = parseYmd(props.weekStart)
const weekLabel = start ? `S${getIsoWeekNumber(start)}` : ''
return weekLabel ? `${weekLabel} du ${fmt(props.weekStart)} au ${fmt(props.weekEnd)}` : `${fmt(props.weekStart)}${fmt(props.weekEnd)}`
})
const canSubmit = computed(() => content.value.trim().length > 0 || props.commentId !== null)
const onSave = async () => {
if (!props.employeeId || isSubmitting.value) return
const trimmed = content.value.trim()
isSubmitting.value = true
try {
if (trimmed === '' && props.commentId) await deleteWeekComment(props.commentId)
else if (trimmed !== '' && props.commentId) await updateWeekComment(props.commentId, trimmed)
else if (trimmed !== '') await createWeekComment({ employeeId: props.employeeId, weekStartDate: props.weekStart, content: trimmed })
emit('saved'); drawerOpen.value = false
} finally { isSubmitting.value = false }
}
const onDelete = async () => {
if (!props.commentId || isSubmitting.value) return
isSubmitting.value = true
try { await deleteWeekComment(props.commentId); emit('saved'); drawerOpen.value = false } finally { isSubmitting.value = false }
}
</script>

View File

@@ -23,7 +23,7 @@
<button
type="button"
tabindex="-1"
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
class="hidden lg:inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
:disabled="props.disabled"
@mousedown.prevent
@click="toggleOpen"
@@ -149,8 +149,11 @@ const toggleOpen = () => {
}
}
const isMobile = () => window.innerWidth < 1024
const openMenu = () => {
if (props.disabled) return
if (isMobile()) return
if (!isOpen.value) {
isOpen.value = true
nextTick(updateMenuPosition)
@@ -165,8 +168,28 @@ const closeMenu = () => {
isOpen.value = false
}
const snapToNearest15 = (time: string): string => {
const [h, m] = time.split(':').map(Number)
const snapped = Math.round(m / 15) * 15
if (snapped === 60) {
const newH = h + 1
if (newH > 23) return '23:45'
return `${String(newH).padStart(2, '0')}:00`
}
return `${String(h).padStart(2, '0')}:${String(snapped).padStart(2, '0')}`
}
const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value)
let value = inputValue.value
if (isMobile()) {
value = clampTime(value)
const normalized = normalizeTypedTime(value)
if (normalized !== null && normalized !== '') {
value = snapToNearest15(normalized)
}
inputValue.value = value
}
const normalized = normalizeTypedTime(value)
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
emit('update:modelValue', '')
inputValue.value = ''
@@ -184,13 +207,26 @@ const onInput = (event: Event) => {
if (masked !== inputValue.value) {
inputValue.value = masked
}
openMenu()
if (!isMobile()) {
openMenu()
}
}
const clampTime = (value: string): string => {
const normalized = normalizeTypedTime(value)
if (normalized === null || normalized === '') return value
const [h, m] = normalized.split(':').map(Number)
if (h > 23 || (h === 23 && m > 45)) return '23:45'
return normalized
}
const onInputBlur = () => {
// Laisse le temps au click menu de passer avant fermeture.
setTimeout(() => {
if (menu.value?.contains(document.activeElement)) return
if (isMobile()) {
inputValue.value = clampTime(inputValue.value)
}
commitInput()
}, 50)
}

View File

@@ -0,0 +1,39 @@
import { documentationSections } from '~/data/documentation-content'
import type { DocAccessLevel, DocSection } from '~/types/documentation'
const LEVEL_HIERARCHY: Record<DocAccessLevel, number> = {
employee: 0,
site_manager: 1,
admin: 2,
}
function getUserLevel(roles: string[]): number {
if (roles.includes('ROLE_ADMIN') || roles.includes('ROLE_SUPER_ADMIN')) return 2
if (roles.includes('ROLE_USER')) return 1
return 0
}
export function useDocumentation() {
const auth = useAuthStore()
const userLevel = computed(() => getUserLevel(auth.user?.roles ?? []))
const visibleSections = computed<DocSection[]>(() => {
return documentationSections
.filter(s => LEVEL_HIERARCHY[s.requiredLevel] <= userLevel.value)
.map(s => ({
...s,
articles: s.articles.filter(a => LEVEL_HIERARCHY[a.requiredLevel] <= userLevel.value),
}))
.filter(s => s.articles.length > 0)
})
const activeArticleId = ref<string | null>(null)
const scrollToArticle = (articleId: string) => {
activeArticleId.value = articleId
const el = document.getElementById(`doc-${articleId}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return { visibleSections, activeArticleId, scrollToArticle }
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,408 @@
import type { Ref } from 'vue'
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { listInterimAgencies, type InterimAgency } from '~/services/interim-agencies'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const toast = useToast()
const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: '',
workDaysHours: null as Record<number, number> | null
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
isDriver: false,
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
const base = item.weeklyHours !== null && item.weeklyHours !== undefined
? `${item.weeklyHours} heures`
: (item.contractName ?? '-')
const scheduleSummary = formatWorkDaysHoursSummary(item.workDaysHours)
return scheduleSummary ? `${base} (${scheduleSummary})` : base
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const lastEndedContractPeriod = computed(() => {
if (currentActiveContractPeriod.value) return null
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
const ended = history.filter((item) => item.endDate && item.endDate < today)
if (ended.length === 0) return null
return ended.reduce((latest, item) => (item.endDate! > latest.endDate! ? item : latest))
})
const editableContractPeriod = computed(() => currentActiveContractPeriod.value ?? lastEndedContractPeriod.value)
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (active) {
if (!active.endDate) return true
return active.endDate > getTodayYmd()
}
return !!lastEndedContractPeriod.value
})
const canCreateContract = computed(() => {
const active = editableContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const selectedCreateContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(createContractForm.contractId)) ?? null
)
const requiresCreateWorkDaysHours = computed(() =>
requiresWorkDaysHours(selectedCreateContract.value, createContractForm.contractNature)
)
const createScheduleTotalMinutes = computed(() => {
const raw = createContractForm.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isCreateScheduleValid = computed(() => {
if (!requiresCreateWorkDaysHours.value) return true
const expected = (selectedCreateContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && createScheduleTotalMinutes.value === expected
})
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value &&
isCreateScheduleValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const period = editableContractPeriod.value
if (!current || !period) return
contractForm.contractId = period.contractId ?? current.contract?.id ?? ''
contractForm.contractName = period.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = period.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = period.contractNature
contractForm.startDate = period.startDate
contractForm.endDate = period.endDate ?? getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
contractForm.workDaysHours = period.workDaysHours ?? null
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
hydrateSuspensionForms()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.isDriver = false
createContractForm.workDaysHours = null
createContractForm.interimAgencyId = ''
createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const submitContractUpdate = async () => {
const period = editableContractPeriod.value
if (!employee.value || isContractSubmitting.value || !period) return
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < period.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(period.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
})
isContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (editableContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver,
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null,
interimAgencyId: createContractForm.contractNature === 'INTERIM' && createContractForm.interimAgencyId !== '' ? Number(createContractForm.interimAgencyId) : null
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
const submitSuspension = async (index: number) => {
const form = suspensionForms.value[index]
if (!form || !form.startDate) return
const periodId = currentActiveContractPeriodId.value
if (!periodId) return
isSuspensionSubmitting.value = true
try {
if (form.id) {
await updateSuspension(form.id, {
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
} else {
await createSuspension({
contractPeriodId: periodId,
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
}
await reloadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
const loadContracts = async () => {
contracts.value = await listContracts()
}
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
watch(() => createContractForm.contractNature, (nature) => {
if (nature !== 'INTERIM') {
createContractForm.interimAgencyId = ''
}
})
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
}
})
watch(requiresCreateWorkDaysHours, (required) => {
if (!required) {
createContractForm.workDaysHours = null
}
})
return {
contracts,
contractHistory,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
interimAgencies,
loadContracts,
loadInterimAgencies
}
}

View File

@@ -1,211 +1,24 @@
import type { Contract } from '~/services/dto/contract'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { listContracts } from '~/services/contracts'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
import { getEmployee, updateEmployee } from '~/services/employees'
import { listPublicHolidays } from '~/services/public-holidays'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { getEmployee } from '~/services/employees'
export const useEmployeeDetailPage = () => {
const route = useRoute()
const toast = useToast()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const contracts = ref<Contract[]>([])
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const rttSummary = ref<EmployeeRttSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
return contract.name || '-'
})
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
hydrateSuspensionForms()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const loadEmployee = async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
@@ -215,180 +28,76 @@ export const useEmployeeDetailPage = () => {
isLoading.value = true
try {
const loadedEmployee = await getEmployee(employeeId)
employee.value = loadedEmployee
employee.value = await getEmployee(employeeId)
const now = new Date()
const isForfait = loadedEmployee.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
const rttYear = now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()
const from = isForfait
? `${leaveYear}-01-01`
: `${leaveYear - 1}-06-01`
const to = isForfait
? `${leaveYear}-12-31`
: `${leaveYear}-05-31`
const holidayYears = isForfait
? [leaveYear]
: [leaveYear - 1, leaveYear]
const [absences, summary, rtt, ...holidayResults] = await Promise.all([
listAbsences({
from,
to,
employeeId: loadedEmployee.id
}),
showLeaveTab.value
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
: Promise.resolve(null),
getEmployeeRttSummary(loadedEmployee.id, rttYear),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
leaveSummary.value = summary
rttSummary.value = rtt
publicHolidays.value = Object.assign({}, ...holidayResults)
if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract'
}
if (!showRttTab.value && activeTab.value === 'rtt') {
activeTab.value = 'contract'
}
leave.resetLoaded()
rtt.resetLoaded()
mileage.resetLoaded()
formation.resetLoaded()
bonus.resetLoaded()
observation.resetLoaded()
if (activeTab.value === 'leave' && showLeaveTab.value) {
await leave.loadLeaveData()
} else if (activeTab.value === 'rtt' && showRttTab.value) {
await rtt.loadRttData()
} else if (activeTab.value === 'mileage') {
await mileage.loadMileageData()
} else if (activeTab.value === 'formation') {
await formation.loadFormationData()
} else if (activeTab.value === 'bonus') {
await bonus.loadBonusData()
} else if (activeTab.value === 'observation') {
await observation.loadObservationData()
} else if (isForfait.value && showLeaveTab.value) {
// Eager load: needed for the "X jours restants" header label on forfait employees.
await leave.loadLeaveData()
}
} finally {
isLoading.value = false
}
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee)
const forfaitRemainingDaysLabel = computed(() => {
if (!isForfait.value) return ''
const presence = leave.leaveSummary.value?.presenceDaysToToday
if (presence === undefined || presence === null) return ''
const remaining = 218 - presence
return ` (${remaining} restants)`
})
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee)
const observation = useEmployeeObservation(employee, loadEmployee)
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
})
isContractDrawerOpen.value = false
await loadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
})
isCreateContractDrawerOpen.value = false
await loadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
const submitSuspension = async (index: number) => {
const form = suspensionForms.value[index]
if (!form || !form.startDate) return
const periodId = currentActiveContractPeriodId.value
if (!periodId) return
isSuspensionSubmitting.value = true
try {
if (form.id) {
await updateSuspension(form.id, {
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
} else {
await createSuspension({
contractPeriodId: periodId,
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
}
await loadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updateFractionedDays(employee.value.id, days, year)
await loadEmployee()
}
const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
await createRttPayment(employee.value.id, month, minutes, rate, year)
await loadEmployee()
}
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
watch(activeTab, (tab) => {
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
leave.loadLeaveData()
} else if (tab === 'rtt' && !rtt.rttDataLoaded.value && showRttTab.value) {
rtt.loadRttData()
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
mileage.loadMileageData()
} else if (tab === 'formation' && !formation.formationDataLoaded.value) {
formation.loadFormationData()
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
bonus.loadBonusData()
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
observation.loadObservationData()
}
})
onMounted(async () => {
contracts.value = await listContracts()
await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
await loadEmployee()
})
@@ -396,49 +105,16 @@ export const useEmployeeDetailPage = () => {
employee,
isLoading,
activeTab,
contracts,
employeeAbsences,
leaveSummary,
rttSummary,
publicHolidays,
showLeaveTab,
contractHistory,
showRttTab,
employeeContractWorkLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId
forfaitRemainingDaysLabel,
...contract,
...leave,
...rtt,
...mileage,
...formation,
...bonus,
...observation
}
}

View File

@@ -0,0 +1,73 @@
import type { Ref } from 'vue'
import type { Formation } from '~/services/dto/formation'
import type { Employee } from '~/services/dto/employee'
import {
listFormations,
createFormation,
updateFormation,
deleteFormation,
uploadFormationJustificatif
} from '~/services/formations'
export const useEmployeeFormation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const config = useRuntimeConfig()
const apiBase = (config.public.apiBase as string) ?? '/api'
const formations = ref<Formation[]>([])
const isFormationLoading = ref(false)
const formationDataLoaded = ref(false)
const loadFormationData = async () => {
if (!employee.value || isFormationLoading.value) return
isFormationLoading.value = true
try {
formations.value = await listFormations(employee.value.id)
formationDataLoaded.value = true
} finally {
isFormationLoading.value = false
}
}
const resetLoaded = () => {
formationDataLoaded.value = false
}
const submitCreateFormation = async (data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
if (!employee.value) return
const result = await createFormation({
employeeId: employee.value.id,
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
})
if (result?.id && justificatifFile) {
await uploadFormationJustificatif(apiBase, result.id, justificatifFile)
}
await reloadEmployee()
}
const submitUpdateFormation = async (id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
await updateFormation(id, data)
if (justificatifFile) {
await uploadFormationJustificatif(apiBase, id, justificatifFile)
}
await reloadEmployee()
}
const submitDeleteFormation = async (id: number) => {
await deleteFormation(id)
await reloadEmployee()
}
return {
formations,
isFormationLoading,
formationDataLoaded,
formationApiBase: apiBase,
loadFormationData,
resetLoaded,
submitCreateFormation,
submitUpdateFormation,
submitDeleteFormation
}
}

View File

@@ -0,0 +1,78 @@
import type { Ref } from 'vue'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays'
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isLeaveLoading = ref(false)
const leaveDataLoaded = ref(false)
const getLeaveYear = () => {
const now = new Date()
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
return isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
}
const loadLeaveData = async () => {
if (!employee.value || isLeaveLoading.value) return
isLeaveLoading.value = true
try {
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = getLeaveYear()
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
const [absences, summary, ...holidayResults] = await Promise.all([
listAbsences({ from, to, employeeId: employee.value.id }),
getEmployeeLeaveSummary(employee.value.id, leaveYear),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
leaveSummary.value = summary
publicHolidays.value = Object.assign({}, ...holidayResults)
leaveDataLoaded.value = true
} finally {
isLeaveLoading.value = false
}
}
const resetLoaded = () => {
leaveDataLoaded.value = false
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updateFractionedDays(employee.value.id, days, year)
await reloadEmployee()
}
const submitPaidLeaveDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updatePaidLeaveDays(employee.value.id, days, year)
await reloadEmployee()
}
return {
employeeAbsences,
leaveSummary,
publicHolidays,
isLeaveLoading,
leaveDataLoaded,
loadLeaveData,
resetLoaded,
submitFractionedDays,
submitPaidLeaveDays
}
}

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

@@ -0,0 +1,42 @@
import type { Ref } from 'vue'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { Employee } from '~/services/dto/employee'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const rttSummary = ref<EmployeeRttSummary | null>(null)
const isRttLoading = ref(false)
const rttDataLoaded = ref(false)
const loadRttData = async () => {
if (!employee.value || isRttLoading.value) return
isRttLoading.value = true
try {
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
rttDataLoaded.value = true
} finally {
isRttLoading.value = false
}
}
const resetLoaded = () => {
rttDataLoaded.value = false
}
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year)
await reloadEmployee()
}
return {
rttSummary,
isRttLoading,
rttDataLoaded,
loadRttData,
resetLoaded,
submitRttPayment
}
}

View File

@@ -73,11 +73,11 @@ export const useHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
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
)
}
})
@@ -138,19 +145,17 @@ export const useHoursPage = () => {
return true
}
const canCreateValidationRowFromAbsence = (employeeId: number) => {
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 && hasContractAtSelectedDate(employeeId)
return !!dayRow?.absenceLabel || is4hContract(employeeId)
}
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
}
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const bulkValidatableEmployeeIds = computed(() => {
return visibleEmployees.value
@@ -347,6 +352,10 @@ export const useHoursPage = () => {
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const is4hContract = (employeeId: number) => {
const employee = employees.value.find((e) => e.id === employeeId)
return employee?.contract?.weeklyHours === 4
}
const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
@@ -438,10 +447,21 @@ export const useHoursPage = () => {
nightMinutes += nightIntervalMinutes(from, to)
}
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
const dayRow = dayContextByEmployeeId.value.get(employeeId)
const creditedMinutes = dayRow?.creditedMinutes ?? 0
totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes }
let dayMinutes = Math.max(0, totalMinutes - nightMinutes)
// Virtual holiday credit: the backend already applies the contract-period
// schedule (workDaysHours) and the absence-override rule, so just use the
// computed value instead of recomputing on the client.
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
if (virtualHolidayMinutes > totalMinutes) {
dayMinutes += virtualHolidayMinutes - totalMinutes
totalMinutes = virtualHolidayMinutes
}
return { dayMinutes, nightMinutes, totalMinutes, virtualHolidayMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
@@ -449,7 +469,6 @@ export const useHoursPage = () => {
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.'
@@ -460,10 +479,25 @@ 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' }
}
const hasRowFormation = (employeeId: number): boolean => {
return dayContextByEmployeeId.value.get(employeeId)?.hasFormation === true
}
const getRowFormationLabel = (employeeId: number): string => {
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
}
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
}
const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt
if (!raw) return ''
@@ -564,7 +598,6 @@ export const useHoursPage = () => {
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
@@ -692,13 +725,8 @@ export const useHoursPage = () => {
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
@@ -746,13 +774,8 @@ export const useHoursPage = () => {
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
@@ -1043,7 +1066,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()
@@ -1089,6 +1112,15 @@ export const useHoursPage = () => {
}
}
const isWeekCommentDrawerOpen = ref(false)
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
if (!weeklySummary.value) return
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
isWeekCommentDrawerOpen.value = true
}
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
return {
isAdmin,
isSelfUser,
@@ -1100,6 +1132,7 @@ export const useHoursPage = () => {
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,
@@ -1116,6 +1149,7 @@ export const useHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
@@ -1151,6 +1185,9 @@ export const useHoursPage = () => {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,
@@ -1158,6 +1195,10 @@ export const useHoursPage = () => {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
}
}

View File

@@ -0,0 +1,607 @@
import type { DocSection } from '~/types/documentation'
export const documentationSections: DocSection[] = [
// ============================================================
// EMPLOYEE LEVEL
// ============================================================
{
id: 'connexion',
title: 'Connexion et navigation',
requiredLevel: 'employee',
icon: 'mdi:login',
articles: [
{
id: 'login',
title: 'Se connecter',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Pour accéder à l\'application, rendez-vous sur la page de connexion et saisissez vos identifiants.' },
{ type: 'list', content: 'Saisissez votre nom d\'utilisateur\nSaisissez votre mot de passe\nCliquez sur le bouton "Connexion"' },
{ type: 'note', content: 'Si vous ne parvenez pas à vous connecter, contactez votre administrateur RH. Votre compte a peut-être été verrouillé.' },
],
},
{
id: 'navigation',
title: 'Naviguer dans la vue jour',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
],
},
{
id: 'perimetre',
title: 'Périmètre d\'accès',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Votre accès dépend du rôle qui vous a été attribué par l\'administrateur.' },
{ type: 'list', content: 'Employé : accès à la saisie de ses propres heures uniquement\nChef de site : accès aux heures des employés de ses sites autorisés + validation\nAdministrateur : accès complet à toutes les fonctionnalités' },
],
},
],
},
{
id: 'saisie-heures',
title: 'Saisie des heures',
requiredLevel: 'employee',
icon: 'mdi:clock-time-four-outline',
articles: [
{
id: 'saisie-time',
title: 'Mode horaire (TIME)',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'En mode horaire, vous saisissez vos heures via des créneaux matin, après-midi et soir.' },
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:0021:00), heures de nuit (00:0006:00 et 21:0024:00) et total.' },
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) et la création d\'absences sont autorisées.' },
{ type: 'note', content: 'Crédit automatique sur jour férié Lun-Ven : pour tout contrat hors Forfait et s\'il n\'y a pas d\'absence déclarée, un jour férié compte au minimum les heures contractuelles attendues (35h → 7h, 39h → 8h Lun-Jeu / 7h Ven). Si vous saisissez des heures supérieures à cette référence, ce sont vos heures qui sont comptées ; sinon c\'est la référence. Les conducteurs reçoivent ce crédit dans leur bucket "Heures jour". **Si une absence est posée sur le férié**, c\'est le paramétrage du type d\'absence (compte les heures oui/non) qui pilote les heures comptées, le crédit virtuel férié ne s\'applique plus.' },
{ type: 'note', content: 'Contrats non-standards (4h, 25h, 28h, etc.) : un planning par jour travaillé doit être saisi à la création/modification du contrat (bloc « Jours travaillés » avec case à cocher + horaire par jour). Le crédit férié et le crédit d\'absence ne s\'appliquent que sur les jours programmés, avec les heures programmées. Ex. un 4h Lundi 2h + Jeudi 2h : férié le lundi → +2h, férié le mardi → 0h.' },
],
},
{
id: 'saisie-presence',
title: 'Mode présence (PRESENCE)',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'En mode présence (contrats forfait), vous indiquez simplement si vous étiez présent le matin et/ou l\'après-midi.' },
{ type: 'list', content: 'Cochez "Présent matin" pour indiquer une demi-journée de travail le matin\nCochez "Présent après-midi" pour indiquer une demi-journée l\'après-midi\nChaque demi-journée cochée compte pour 0.5 jour de présence' },
],
},
{
id: 'comprendre-calculs',
title: 'Comprendre les calculs affichés',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Les colonnes de calcul sont mises à jour automatiquement en fonction de votre saisie.' },
{ type: 'list', content: 'Jour : total des heures dans la plage 06:0021:00\nNuit : total des heures dans les plages 00:0006:00 et 21:0024:00\nTotal : somme des heures de jour et de nuit' },
],
},
{
id: 'commentaire-semaine',
title: 'Commentaires de semaine (admin)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Sur la vue semaine, un picto bulle permet d\'attacher un commentaire libre sur la semaine d\'un employé.' },
{ type: 'list', content: 'Bulle bleue : pas de commentaire\nBulle rouge : un commentaire existe\nClic : ouvre le drawer avec textarea' },
{ type: 'note', content: 'Les commentaires n\'affectent aucun calcul. Pour supprimer, videz la textarea puis Enregistrer, ou bouton Supprimer.' },
],
},
],
},
{
id: 'saisie-conducteurs',
title: 'Saisie conducteurs',
requiredLevel: 'employee',
icon: 'mdi:truck-outline',
articles: [
{
id: 'conducteur-heures',
title: 'Saisie des heures conducteur',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Les conducteurs disposent d\'un écran dédié accessible via le menu "Heures Conducteurs". Ils n\'apparaissent pas sur l\'écran classique des heures.' },
{ type: 'list', content: 'Heures de jour : durée au format HH:MM\nHeures de nuit : durée au format HH:MM\nHeures atelier : durée au format HH:MM\nTotal : calculé automatiquement (jour + nuit + atelier)' },
],
},
{
id: 'conducteur-indemnites',
title: 'Indemnités conducteur',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'En plus des heures, vous pouvez cocher les indemnités correspondant à votre journée.' },
{ type: 'list', content: 'Petit déjeuner\nDéjeuner\nDîner\nNuitée' },
{ type: 'paragraph', content: 'La même logique de validation s\'applique que pour les heures classiques.' },
],
},
],
},
{
id: 'absences-validations',
title: 'Absences et validations',
requiredLevel: 'employee',
icon: 'mdi:information-outline',
articles: [
{
id: 'comprendre-absences',
title: 'Comprendre les absences affichées',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Quand une absence est posée sur votre journée, elle apparaît dans la colonne dédiée avec un fond coloré selon le type d\'absence.' },
{ type: 'list', content: 'Absence du matin (AM) : verrouille le créneau matin\nAbsence de l\'après-midi (PM) : verrouille les créneaux après-midi et soir\nAbsence journée complète : verrouille tous les créneaux' },
{ type: 'note', content: 'Vous ne pouvez pas modifier les créneaux horaires verrouillés par une absence. Seul un administrateur peut retirer ou modifier l\'absence.' },
],
},
{
id: 'comprendre-validations',
title: 'Comprendre les validations',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Vos heures passent par un processus de double validation avant d\'être définitivement enregistrées.' },
{ type: 'list', content: 'Validation chef de site : votre chef de site vérifie et valide vos heures. La ligne est alors verrouillée pour vous.\nValidation RH : l\'administrateur RH valide définitivement. La ligne est complètement verrouillée.' },
{ type: 'paragraph', content: 'Une fois validée, vous ne pouvez plus modifier la ligne. Si une correction est nécessaire, contactez votre chef de site ou l\'administrateur RH.' },
{ type: 'note', content: 'Toute vraie modification effectuée par un administrateur remet automatiquement les deux validations à zéro.' },
],
},
],
},
// ============================================================
// SITE MANAGER LEVEL
// ============================================================
{
id: 'validation-site',
title: 'Validation de site',
requiredLevel: 'site_manager',
icon: 'mdi:check-decagram-outline',
articles: [
{
id: 'role-chef-site',
title: 'Rôle du chef de site',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'En tant que chef de site, vous êtes responsable de la vérification et de la validation des heures saisies par les employés de votre site.' },
{ type: 'paragraph', content: 'Le workflow de validation suit un circuit en 3 étapes : l\'employé saisit ses heures → le chef de site valide → l\'admin RH valide définitivement.' },
],
},
{
id: 'validation-individuelle',
title: 'Validation individuelle',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'Pour valider une ligne d\'heures individuellement :' },
{ type: 'list', content: 'Cochez la case de validation site sur la ligne de l\'employé\nLa ligne est immédiatement verrouillée pour l\'employé\nL\'administrateur RH peut toujours corriger une ligne que vous avez validée' },
],
},
{
id: 'validation-masse',
title: 'Validation en masse',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'Pour gagner du temps, vous pouvez valider toutes les lignes en une seule action.' },
{ type: 'list', content: 'Cliquez sur le bouton de validation en masse\nToutes les lignes de la date affichée sont validées d\'un coup\nUtile quand toutes les saisies sont correctes' },
{ type: 'note', content: 'Quand toutes les lignes de votre site sont validées pour une date donnée, les administrateurs RH reçoivent automatiquement une notification.' },
],
},
{
id: 'difference-validations',
title: 'Validation site vs validation RH',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'Il est important de comprendre la différence entre les deux niveaux de validation.' },
{ type: 'list', content: 'Validation site : verrouille la ligne pour les employés, mais l\'admin RH peut encore modifier\nValidation RH : verrouillage complet, seul l\'admin peut retirer cette validation\nLe chef de site ne voit pas et ne peut pas agir sur la validation RH' },
],
},
],
},
// ============================================================
// ADMIN LEVEL
// ============================================================
{
id: 'administration',
title: 'Administration',
requiredLevel: 'admin',
icon: 'mdi:cog-outline',
articles: [
{
id: 'gestion-sites',
title: 'Gestion des sites',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les sites organisent les employés et les accès dans l\'application. Chaque site possède un nom et une couleur utilisée dans toute l\'interface.' },
{ type: 'list', content: 'Créer, modifier ou supprimer un site depuis le menu "Sites"\nL\'ordre d\'affichage est modifiable par glisser-déposer\nLa couleur du site est utilisée pour identifier visuellement les employés' },
],
},
{
id: 'gestion-types-absence',
title: 'Gestion des types de statut',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les types de statut définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
],
},
{
id: 'gestion-utilisateurs',
title: 'Gestion des utilisateurs',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Chaque personne qui se connecte à l\'application a un compte utilisateur distinct de sa fiche employé.' },
{ type: 'list', content: 'Nom d\'utilisateur : unique, sert de login\nMot de passe : défini à la création, modifiable\nRôle : Admin (accès complet), User (chef de site), Self (employé)\nSites autorisés : pour les chefs de site, définit leur périmètre\nAssociation employé : lie le compte à une fiche employé\nVerrouillage : un compte verrouillé ne peut plus se connecter' },
{ type: 'note', content: 'Il n\'est pas possible de supprimer un utilisateur (sécurité). Pour bloquer l\'accès, utilisez le verrouillage de compte.' },
],
},
{
id: 'taches-automatiques',
title: 'Tâches automatiques (crons)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
],
},
],
},
{
id: 'employes-contrats',
title: 'Employés et contrats',
requiredLevel: 'admin',
icon: 'mdi:account-group-outline',
articles: [
{
id: 'liste-employes',
title: 'Liste et recherche d\'employés',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La page Employés affiche tous les employés sous forme de cartes.' },
{ type: 'list', content: 'Recherche par nom\nFiltrage par site (multi-sélection)\nFiltrage par statut de contrat : "Avec contrat" (défaut), "Sans contrat", "Tous"\n"Avec contrat" = employés ayant une période de contrat active à la date du jour' },
],
},
{
id: 'creation-employe',
title: 'Créer un employé',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nAgence d\'intérim (visible uniquement pour INTERIM, optionnel)\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
],
},
{
id: 'types-contrat',
title: 'Types de contrat',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le type de contrat détermine le mode de suivi et les règles de calcul appliquées.' },
{ type: 'list', content: 'FORFAIT : suivi en jours (mode PRESENCE), base 218 jours/an\n35 HEURES : suivi horaire (mode TIME), 35h/semaine\n39 HEURES : suivi horaire (mode TIME), 39h/semaine\nCUSTOM : heures personnalisées (ex: 4h, 20h), 1h sup = 1h récup sans bonus\nINTERIM : travail temporaire, pas de récupération ni de congés gérés' },
{ type: 'note', content: 'Le mode de suivi (TIME ou PRESENCE) est lié au type de contrat et ne peut pas être modifié indépendamment.' },
],
},
{
id: 'suivi-contrat',
title: 'Suivi contrat et historique',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet "Suivi contrat" sur la fiche employé affiche l\'historique complet des périodes de contrat.' },
{ type: 'list', content: 'Chaque ligne : nature (CDI/CDD/INTERIM), type de contrat, date début, date fin ou "En cours"\nAjouter un contrat : disponible uniquement si le contrat en cours est clôturé\nClôturer un contrat : définir la date de fin + option "Solde de tout compte"\nSuspension : ajouter une période de suspension avec dates et commentaire' },
{ type: 'note', content: 'La case "Soldé dans le solde de tout compte" remet le report des congés à 0 pour l\'exercice suivant.' },
],
},
{
id: 'statut-conducteur',
title: 'Statut conducteur',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le statut conducteur est un flag activé sur une période de contrat. Un employé peut changer de statut conducteur selon la période.' },
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
],
},
],
},
{
id: 'double-validation',
title: 'Saisie et double validation',
requiredLevel: 'admin',
icon: 'mdi:shield-check-outline',
articles: [
{
id: 'validation-rh',
title: 'Validation RH',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La validation RH est le niveau de validation le plus élevé, réservé aux administrateurs.' },
{ type: 'list', content: 'Verrouille complètement la ligne (heures et absences)\nSeul un administrateur peut retirer cette validation\nPeut être appliquée individuellement ou en masse' },
],
},
{
id: 'regles-reinitialisation',
title: 'Règles de réinitialisation',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' },
{ type: 'list', content: 'Toute vraie modification d\'une ligne remet les deux validations (site et RH) à faux\nUn enregistrement sans changement réel préserve les validations existantes\nLa date de modification est mise à jour uniquement quand un employé modifie ses propres heures' },
{ type: 'note', content: 'La date de modification est visible uniquement par les administrateurs, sous le nom de l\'employé dans la vue jour.' },
],
},
],
},
{
id: 'vue-semaine-hs',
title: 'Vue semaine et heures supplémentaires',
requiredLevel: 'admin',
icon: 'mdi:calendar-week',
articles: [
{
id: 'vue-semaine',
title: 'Vue semaine',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La vue semaine est réservée aux administrateurs. Elle affiche une synthèse hebdomadaire par employé avec les heures supplémentaires calculées.' },
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération\nLes jours fériés sont signalés sur la cellule du jour : fond bleu clair quand pas d\'absence, nom du férié au survol' },
],
},
{
id: 'calcul-hs',
title: 'Calcul des heures supplémentaires',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus\nINTERIM : aucune récupération, aucun bonus' },
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d\'abord des heures à 50%, puis des heures à 25%.' },
],
},
{
id: 'vue-semaine-conducteurs',
title: 'Vue semaine conducteurs',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La vue semaine conducteurs affiche des colonnes spécifiques.' },
{ type: 'list', content: 'Totaux jour / nuit / atelier par jour et par semaine\nPanier de nuit (PN) : affiché quand heures nuit > heures jour OU nuit ≥ 4h\nCompteurs hebdomadaires : petit déjeuner, déjeuner, dîner, nuitée\nRTT calculé sur jour + nuit + atelier (au lieu des créneaux classiques)' },
],
},
],
},
{
id: 'absences-calendrier',
title: 'Absences et calendrier',
requiredLevel: 'admin',
icon: 'mdi:calendar-blank',
articles: [
{
id: 'poser-absence',
title: 'Poser une absence',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
],
},
{
id: 'effet-absences-heures',
title: 'Effet sur les heures',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'impact d\'une absence sur les heures dépend du type d\'absence et du mode de suivi.' },
{ type: 'list', content: 'Standard : efface les créneaux horaires correspondants\nSi "Compté comme travaillé" en mode TIME : crédite des minutes selon le contrat actif\nSi "Compté comme travaillé" en mode PRESENCE : aucun crédit (seules les cases cochées comptent)' },
{ type: 'note', content: 'Les absences comptées comme travaillées impactent le calcul des heures supplémentaires et du RTT.' },
],
},
{
id: 'calendrier-mensuel',
title: 'Calendrier mensuel',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
{ type: 'note', content: 'Seuls les employés ayant au moins un jour de contrat sur le mois affiché apparaissent. Un employé dont le contrat s\'est terminé avant le 1er du mois (ou qui commence après la fin du mois) est masqué.' },
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
],
},
],
},
{
id: 'conges-payes',
title: 'Congés payés',
requiredLevel: 'admin',
icon: 'mdi:umbrella-beach-outline',
articles: [
{
id: 'regles-cdi-cdd',
title: 'Règles CDI/CDD non-forfait',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Pour les contrats CDI et CDD (hors forfait), l\'exercice de congés va du 1er juin (N-1) au 31 mai (N).' },
{ type: 'list', content: 'Acquisition annuelle : 25 jours + 5 samedis\nAcquisition mensuelle : 2,08 jours + 0,42 samedi par mois\nProratisation en cas de début/fin ou suspension en cours de mois\nContrat 4h : 10 jours annuels, 0 samedi, 0,83 jour/mois' },
],
},
{
id: 'regles-forfait',
title: 'Règles FORFAIT',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Pour les contrats forfait, l\'exercice suit l\'année civile (1er janvier au 31 décembre).' },
{ type: 'list', content: 'Calcul : jours ouvrés de l\'année 218 + bonus weekend/férié\nBonus : 1 jour par jour travaillé un weekend ou jour férié (0.5 si demi-journée)\nPas de samedis\nPas de jours en cours d\'acquisition' },
],
},
{
id: 'maladie-longue',
title: 'Arrêt maladie long',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'En cas d\'arrêt maladie de plus d\'un mois, les règles d\'acquisition sont modifiées.' },
{ type: 'list', content: 'Premier mois de maladie : acquisition normale\nAprès le premier mois : acquisition réduite (facteur 0,80)\nDétection automatique à partir des absences MALADIE consécutives (tolérance de gap ≤ 3 jours)' },
],
},
{
id: 'report-conges',
title: 'Report annuel et rollover',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le reliquat de congés de l\'exercice précédent est automatiquement reporté dans les acquis du nouvel exercice.' },
{ type: 'list', content: 'Report automatique le 1er juin (CDI/CDD non-forfait) ou 1er janvier (forfait)\nSi "Solde de tout compte" coché sur le contrat clôturé : report remis à 0\nJours fractionnés : saisie manuelle par la RH, ajoutés aux acquis' },
],
},
{
id: 'consommation-conges',
title: 'Règle de consommation',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les absences s\'imputent selon un ordre précis.' },
{ type: 'list', content: 'D\'abord sur les acquis (report N-1)\nPuis sur les jours en cours d\'acquisition\nEn cours d\'acquisition peut devenir négatif temporairement (se reconstitue avec les acquisitions suivantes)' },
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
],
},
{
id: 'ecran-recap-conges',
title: 'Écran Récap. congés',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
],
},
],
},
{
id: 'rtt',
title: 'RTT (Récupération de Temps de Travail)',
requiredLevel: 'admin',
icon: 'mdi:timer-sand',
articles: [
{
id: 'rtt-principe',
title: 'Principe et exercice RTT',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le RTT correspond aux heures supplémentaires accumulées, converties en temps de récupération. L\'exercice RTT va du 1er juin (N-1) au 31 mai (N).' },
{ type: 'paragraph', content: 'L\'onglet RTT sur la fiche employé affiche le détail hebdomadaire regroupé par mois, avec un compteur global en heures (1 jour = 7h = 420 minutes).' },
],
},
{
id: 'rtt-compteurs',
title: 'Compteurs RTT',
requiredLevel: 'admin',
blocks: [
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
],
},
{
id: 'rtt-paiement',
title: 'Paiement RTT',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' },
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
],
},
{
id: 'rtt-semaines-mois',
title: 'Attribution des semaines aux mois',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Chaque semaine ISO est attribuée à un seul mois dans le tableau RTT.' },
{ type: 'list', content: 'Une semaine est attribuée au mois qui contient son samedi\nSi le samedi tombe en début de mois suivant, la semaine est dans ce mois suivant' },
],
},
],
},
{
id: 'frais-primes-observations',
title: 'Frais, primes et observations',
requiredLevel: 'admin',
icon: 'mdi:account-cash-outline',
articles: [
{
id: 'frais',
title: 'Onglet Frais',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet Frais sur la fiche employé permet de saisir les frais kilométriques et les montants associés.' },
{ type: 'list', content: 'Mois : obligatoire\nKilomètres : nombre de km (optionnel)\nMontant : en euros (optionnel)\nCommentaire : optionnel\nDeux justificatifs PDF distincts : un pour les km, un pour le montant' },
{ type: 'note', content: 'Au moins un des deux champs (kilomètres ou montant) doit être supérieur à 0. Un seul enregistrement par mois par employé.' },
],
},
{
id: 'formation',
title: 'Onglet Formation',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet Formation sur la fiche employé permet de tracer les formations suivies par le salarié.' },
{ type: 'list', content: 'Date de début : obligatoire\nDate de fin : obligatoire (doit être postérieure ou égale à la date de début)\nJustificatif PDF : optionnel\nCommentaire : optionnel' },
{ type: 'note', content: 'Les formations sont triées par date de début décroissante. Cliquer sur une ligne permet de la modifier ou la supprimer.' },
{ type: 'paragraph', content: 'Les formations sont également affichées en consultation sur l\'écran des heures (pastille indigo "Formation" dans la colonne Absence, sans bouton Modifier) et dans le calendrier (cellule "F" indigo ou icône école si couplée à une absence, cellule non cliquable). La modification et la suppression d\'une formation se font exclusivement depuis cet onglet.' },
],
},
{
id: 'primes',
title: 'Onglet Prime',
requiredLevel: 'admin',
blocks: [
{ type: 'list', content: 'Mois : obligatoire\nMontant en euros : obligatoire\nCommentaire : optionnel\nUne seule prime par mois par employé' },
],
},
{
id: 'observations',
title: 'Onglet Observation',
requiredLevel: 'admin',
blocks: [
{ type: 'list', content: 'Mois : obligatoire\nTexte d\'observation : obligatoire\nUne seule observation par mois par employé\nNote libre pour le suivi RH' },
],
},
],
},
{
id: 'exports',
title: 'Exports et impressions',
requiredLevel: 'admin',
icon: 'mdi:file-pdf-box',
articles: [
{
id: 'export-recap-conges',
title: 'Export récap. congés',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 portrait récapitulant les congés de tous les employés actifs.' },
{ type: 'list', content: 'Accessible depuis la page Employés (bouton "Export récap. congés")\nGénère un PDF à la date du jour\nDonnées groupées par site\nColonnes : nom, contrat, CP N-1 restant, samedi restant, CP N, RTT' },
],
},
{
id: 'export-recap-salaire',
title: 'Récapitulatif salaire',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
],
},
{
id: 'impression-absences',
title: 'Impression absences',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A3 paysage du calendrier d\'absences avec des filtres.' },
{ type: 'list', content: 'Filtres : période (du/au), sites, nature de contrat, type de contrat\nTous les filtres sont cochés par défaut\nCalendrier coloré par type d\'absence' },
],
},
{
id: 'export-heures-annuelles',
title: 'Export heures annuelles',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' },
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' },
],
},
],
},
]

View File

@@ -36,6 +36,33 @@
"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."
},
"formation": {
"create": "Impossible de créer la formation.",
"update": "Impossible de mettre à jour la formation.",
"delete": "Impossible de supprimer la formation."
},
"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."
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
},
"weekComment": {
"save": "Impossible d'enregistrer le commentaire de semaine.",
"delete": "Impossible de supprimer le commentaire de semaine."
}
},
"success": {
@@ -67,6 +94,30 @@
"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é."
},
"formation": {
"create": "Formation créée.",
"update": "Formation mise à jour.",
"delete": "Formation supprimée."
},
"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."
},
"weekComment": {
"save": "Commentaire enregistré.",
"delete": "Commentaire supprimé."
}
}
}

View File

@@ -1,11 +1,40 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
<div class="h-[75px]">
<!-- Mobile overlay -->
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="sidebarOpen = false"
/>
</Transition>
<!-- Sidebar -->
<aside
:class="[
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:translate-x-0 lg:flex-shrink-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex h-[75px] items-center justify-between">
<img src="/malio.png" alt="Logo" class="w-auto"/>
<button
type="button"
class="mr-3 rounded-md p-1 text-primary-500 hover:text-secondary-500 lg:hidden"
@click="sidebarOpen = false"
>
<Icon name="mdi:close" size="24"/>
</button>
</div>
<nav class="flex-1 px-4 pb-6">
<nav class="flex-1 overflow-y-auto px-4 pb-6">
<template v-if="isAdmin">
<NuxtLink
to="/calendar"
@@ -13,21 +42,38 @@
:class="route.path.startsWith('/calendar')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:calendar-blank" size="24"/>
<p>Calendrier</p>
</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' : ''
]"
@click="closeSidebarOnMobile"
>
<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' : ''
]"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:truck-outline" size="24"/>
<p>Heures Conducteurs</p>
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink
to="/employees"
@@ -35,16 +81,30 @@
:class="route.path.startsWith('/employees')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p>
</NuxtLink>
<NuxtLink
v-if="hasLeaveRecapAccess"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/leave-recap')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink
to="/sites"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/sites')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:business" size="24"/>
<p>Sites</p>
@@ -55,9 +115,10 @@
:class="route.path.startsWith('/absence-types')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:umbrella-beach-outline" size="24"/>
<p>Types d'absence</p>
<p>Types de statut</p>
</NuxtLink>
<NuxtLink
to="/users"
@@ -65,11 +126,45 @@
:class="route.path.startsWith('/users')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:account-outline" size="24"/>
<p>Utilisateurs</p>
</NuxtLink>
</template>
<NuxtLink
v-if="hasLeaveRecapAccess && !isAdmin"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<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'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
<p>Journal</p>
</NuxtLink>
<NuxtLink
to="/documentation"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/documentation')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:book-open-page-variant-outline" size="24"/>
<p>Documentation</p>
</NuxtLink>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
@@ -77,9 +172,9 @@
</div>
</aside>
<div class="h-full flex-1 overflow-hidden flex flex-col">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto px-8 py-12">
<div class="h-full flex-1 overflow-hidden flex flex-col min-w-0">
<AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<main class="flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6 lg:px-8 lg:py-12">
<slot/>
</main>
</div>
@@ -91,5 +186,13 @@
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 hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
const route = useRoute()
const sidebarOpen = ref(false)
const closeSidebarOnMobile = () => {
sidebarOpen.value = false
}
</script>

View File

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

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

@@ -2,6 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: {enabled: false},
ssr: false,
extends: ['@malio/layer-ui'],
app: {
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@malio/layer-ui": "^1.4.6",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",
"nuxt-toast": "^1.4.0",

View File

@@ -1,14 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<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"
<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 de statut</h1>
<MalioButton
label="Ajouter un type"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
Ajouter un type
</button>
/>
</div>
<div
@@ -18,33 +17,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,80 +51,44 @@
{{ 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>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="code">
Code <span class="text-red-600">*</span>
</label>
<input
id="code"
v-model="form.code"
type="text"
maxlength="10"
:class="codeFieldClass"
/>
<p v-if="showCodeError" class="mt-1 text-sm text-red-600">
Le code est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="label">
Libellé <span class="text-red-600">*</span>
</label>
<input
id="label"
v-model="form.label"
type="text"
:class="labelFieldClass"
/>
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
Le libellé est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.code"
label="Code *"
group-class="mt-2"
:max-length="10"
:error="showCodeError ? 'Le code est obligatoire.' : ''"
/>
<MalioInputText
v-model="form.label"
label="Libellé *"
group-class="mt-2"
:error="showLabelError ? 'Le libellé est obligatoire.' : ''"
/>
<div>
<label class="text-md font-semibold text-neutral-700">
Compté comme travaillé
</label>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="true"
/>
Oui
</label>
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="false"
/>
Non
</label>
<MalioRadioButton
v-model="form.countAsWorkedHours"
name="countAsWorkedHours"
:value="true"
label="Oui"
group-class="w-auto mt-0"
/>
<MalioRadioButton
v-model="form.countAsWorkedHours"
name="countAsWorkedHours"
:value="false"
label="Non"
group-class="w-auto mt-0"
/>
</div>
</div>
<div>
@@ -145,24 +108,30 @@
La couleur est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
>
Annuler
</button>
<button
<div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2">
<MalioButton
label="Supprimer"
variant="danger"
button-class="w-full"
@click="confirmDelete(editingType)"
/>
<MalioButton
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
label="Modifier"
button-class="w-full"
:disabled="isSubmitting || !isFormValid"
/>
</div>
<div v-else class="flex justify-center pt-2">
<MalioButton
type="submit"
label="Valider"
button-class="w-[200px]"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</div>
</template>
@@ -171,7 +140,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
useHead({
title: 'Types d\'absences'
title: 'Types de statut'
})
const isDrawerOpen = ref(false)
@@ -209,20 +178,6 @@ const showCodeError = computed(() => validationTouched.code && !isCodeValid.valu
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const labelFieldClass = computed(() => {
if (showLabelError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const colorFieldClass = computed(() => {
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
if (showColorError.value) {
@@ -231,13 +186,6 @@ const colorFieldClass = computed(() => {
return `${baseColorClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadAbsenceTypes = async () => {
isLoading.value = true
try {

View File

@@ -0,0 +1,254 @@
<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>
<option value="paid_leave_days">Congés N-1 payé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.',
paid_leave_days: 'Congés payés',
}
return map[type] ?? type
}
onMounted(async () => {
employees.value = await listEmployees()
await loadLogs()
})
</script>

View File

@@ -5,30 +5,37 @@
</div>
<div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
</div>
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
label="Sites"
groupClass="relative z-50 w-80 h-10"
display-select-all
/>
<div class="flex gap-4">
<button
type="button"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
<MalioButton
label="Ajouter une absence"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateFromToday"
>
Ajouter une absence
</button>
<button
type="button"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
/>
<MalioButton
label="Imprimer"
variant="secondary"
icon-name="mdi:printer"
icon-position="left"
@click="openPrint"
>
Imprimer
</button>
/>
</div>
</div>
<div class="flex justify-between">
<div class="flex items-center gap-4">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<PeriodStepperPicker
width-class="w-[260px]"
@@ -49,6 +56,10 @@
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-indigo-500"></div>
<p>FORMATION</p>
</div>
</div>
</div>
@@ -99,15 +110,15 @@ import {HALF_DAYS} from '~/services/dto/half-day'
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listFormationsByDateRange} from '~/services/formations'
import type {Formation} from '~/services/dto/formation'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
title: 'Calendrier'
@@ -130,6 +141,8 @@ const sites = computed(() => {
})
})
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
// Filtres de sites (par défaut: tous sélectionnés à l'init).
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
@@ -148,12 +161,27 @@ const sortedEmployees = computed(() => {
// Employés visibles selon le filtre de sites.
const employeeFilter = ref('')
// Un employé est considéré "présent" sur le mois affiché si au moins une de ses
// périodes de contrat intersecte [début du mois ; fin du mois]. Sinon il est masqué.
const hasContractInSelectedMonth = (employee: Employee): boolean => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
const history = employee.contractHistory ?? []
if (history.length === 0) return false
return history.some((period) => {
const start = period.startDate
const end = period.endDate ?? '9999-12-31'
return start <= monthEnd && end >= monthStart
})
}
const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return sortedEmployees.value.filter((employee) => {
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
if (!siteOk) return false
if (!hasContractInSelectedMonth(employee)) return false
if (!filter) return true
const first = employee.firstName?.toLowerCase() ?? ''
const last = employee.lastName?.toLowerCase() ?? ''
@@ -163,6 +191,7 @@ const visibleEmployees = computed(() => {
// Données de référence et absences du mois affiché.
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const formations = ref<Formation[]>([])
const publicHolidays = ref<Record<string, string>>({})
// États UI.
@@ -384,12 +413,18 @@ const loadAbsences = async () => {
})
}
const loadFormations = async () => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
formations.value = await listFormationsByDateRange(monthStart, monthEnd)
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences(), loadFormations()])
})
watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await loadAbsences()
await Promise.all([loadAbsences(), loadFormations()])
})
watch(selectedYear, async () => {
@@ -441,14 +476,51 @@ const cellAbsenceMap = computed(() => {
return map
})
// Jours fériés (interdit pour la création).
// Indexation des formations par cellule pour un lookup O(1).
const cellFormationMap = computed(() => {
const set = new Set<string>()
const monthStart = monthStartDate.value
const monthEnd = monthEndDate.value
for (const formation of formations.value) {
const employeeId = formation.employee?.id
if (!employeeId) continue
const startDate = normalizeDate(formation.startDate)
const endDate = normalizeDate(formation.endDate)
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) continue
const rangeStart = start < monthStart ? monthStart : start
const rangeEnd = end > monthEnd ? monthEnd : end
if (rangeEnd < rangeStart) continue
for (
let currentDate = new Date(rangeStart.getTime());
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
set.add(`${employeeId}-${dateKey}`)
}
}
return set
})
const hasFormationOn = (employeeId: number, date: string): boolean => {
return cellFormationMap.value.has(`${employeeId}-${date}`)
}
// Jours fériés.
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
}
// Renvoie l'absence effective pour une cellule (ou un "Férié").
// Renvoie l'absence effective pour une cellule (ou un "Férié" si pas d'absence).
const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) {
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (!absence && isHolidayDate(date)) {
return {
id: 0,
code: 'Férié',
@@ -456,8 +528,16 @@ const getCellAbsence = (employeeId: number, date: string) => {
textColor: '#0f172a'
}
}
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return absence
if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (hasFormationOn(employeeId, date)) {
return {
id: 0,
code: 'F',
color: '#6366f1',
textColor: '#fff',
hasFormation: true
}
}
return null
}
@@ -491,11 +571,6 @@ const getCellInfo = (employeeId: number, date: string) => {
// Ouverture du drawer depuis une cellule.
const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
@@ -532,10 +607,6 @@ const openCreateFromToday = () => {
form.typeId = ''
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
if (isHolidayDate(today)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
form.startDate = today
form.endDate = today
form.startHalf = 'AM'
@@ -578,10 +649,6 @@ const handleSubmit = async () => {
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
return
}
if (hasHolidayInRange(start, end)) {
window.alert("Impossible de creer une absence sur un jour ferie.")
return
}
const overlaps = absences.value.filter((absence) => {
if (absence.employee?.id !== Number(form.employeeId)) return false
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false

View File

@@ -0,0 +1,9 @@
<template>
<DocumentationPage/>
</template>
<script setup lang="ts">
useHead({
title: 'Documentation',
})
</script>

View File

@@ -0,0 +1,205 @@
<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"
:holiday-label="selectedHolidayLabel"
: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-contract-nature="getRowContractNature"
: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"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/>
</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"
/>
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</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,
getRowContractNature,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
isSelectedDateHoliday,
selectedHolidayLabel,
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useDriverHoursPage()
useHead({
title: 'Heures Conducteurs'
})
</script>

View File

@@ -13,15 +13,24 @@
<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"
@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">
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
<div class="mt-12 border-b border-primary-500">
<div class="mt-[44px] border-b border-primary-500">
<div class="flex justify-center gap-16 text-2xl font-bold">
<button
class="pb-2 border-b-2 flex items-center gap-3"
@@ -45,6 +54,7 @@
Congé
</button>
<button
v-if="showRttTab"
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'rtt'
? 'border-primary-500 text-primary-500'
@@ -54,6 +64,46 @@
<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 === 'formation'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'formation'"
>
<Icon name="mdi:school-outline" size="24" class="align-self"/>
Formation
</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">
@@ -85,6 +135,8 @@
:requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid"
:requires-create-work-days-hours="requiresCreateWorkDaysHours"
:selected-create-contract="selectedCreateContract"
:on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen"
@@ -96,22 +148,102 @@
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
:interim-agencies="interimAgencies"
/>
<EmployeesLeaveTab
v-else-if="showLeaveTab && activeTab === 'leave'"
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
/>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesLeaveTab
v-else
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
@update-paid-leave-days="submitPaidLeaveDays"
/>
</div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
</div>
<div v-else-if="activeTab === 'mileage'" class="h-full">
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesMileageTab
v-else
class="h-full"
:allowances="mileageAllowances"
:api-base="mileageApiBase"
@create="submitCreateMileage"
@update="submitUpdateMileage"
@delete="submitDeleteMileage"
/>
</div>
<div v-else-if="activeTab === 'formation'" class="h-full">
<div v-if="isFormationLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesFormationTab
v-else
class="h-full"
:formations="formations"
:api-base="formationApiBase"
@create="submitCreateFormation"
@update="submitUpdateFormation"
@delete="submitDeleteFormation"
/>
</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,
@@ -122,8 +254,10 @@ const {
rttSummary,
publicHolidays,
showLeaveTab,
showRttTab,
contractHistory,
employeeContractWorkLabel,
forfaitRemainingDaysLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
@@ -144,6 +278,8 @@ const {
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel,
contractHistoryLabel,
formatDate,
@@ -154,14 +290,47 @@ const {
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitPaidLeaveDays,
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId
currentActiveContractPeriodId,
interimAgencies,
isLeaveLoading,
isRttLoading,
mileageAllowances,
isMileageLoading,
mileageApiBase,
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage,
formations,
isFormationLoading,
formationApiBase,
submitCreateFormation,
submitUpdateFormation,
submitDeleteFormation,
bonuses,
isBonusLoading,
submitCreateBonus,
submitUpdateBonus,
submitDeleteBonus,
observations,
isObservationLoading,
submitCreateObservation,
submitUpdateObservation,
submitDeleteObservation
} = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
if (!employee.value) return
const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${payload.year}${monthParam}`)
isYearlyHoursDrawerOpen.value = false
}
useHead(() => ({
title: employee.value
? `${employee.value.firstName} ${employee.value.lastName}`

View File

@@ -3,19 +3,46 @@
<div class="shrink-0">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un employé
</button>
</div>
<div class="flex gap-10 py-7">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
<div class="flex items-center gap-3">
<MalioButton
label="Export"
variant="secondary"
icon-name="mdi:download"
icon-position="left"
@click="openExportDrawer"
/>
<MalioButton
label="Ajouter un employé"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
/>
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
</div>
<div class="flex items-center gap-3 py-7">
<div class="w-80">
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<div v-if="sites.length > 0" class="relative z-50 w-80">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<MalioSelect
v-model="contractStatusFilter"
label="Statut contrat"
:options="contractStatusOptions"
group-class="w-40"
/>
</div>
</div>
@@ -40,7 +67,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>
@@ -48,7 +75,7 @@
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Type:</strong> {{ employee.currentInterimAgencyName ? `${contractNatureLabel(employee.currentContractNature)} (${employee.currentInterimAgencyName})` : contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
@@ -57,90 +84,53 @@
</NuxtLink>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="first-name">
Prénom <span class="text-red-600">*</span>
</label>
<input
id="first-name"
v-model="form.firstName"
type="text"
:class="firstNameFieldClass"
/>
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
Le prénom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="last-name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="last-name"
v-model="form.lastName"
type="text"
:class="lastNameFieldClass"
/>
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
Le nom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="site">
Site <span class="text-red-600">*</span>
</label>
<select
id="site"
v-model="form.siteId"
:class="siteFieldClass"
>
<option value="">Aucun site</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
Le site est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.firstName"
label="Prénom *"
group-class="mt-2"
:error="showFirstNameError ? 'Le prénom est obligatoire.' : ''"
/>
<MalioInputText
v-model="form.lastName"
label="Nom *"
group-class="mt-2"
:error="showLastNameError ? 'Le nom est obligatoire.' : ''"
/>
<MalioSelect
:model-value="form.siteId === '' ? null : form.siteId"
:options="formSiteOptions"
label="Site *"
min-width=""
:error="showSiteError ? 'Le site est obligatoire.' : ''"
@update:model-value="(v) => { form.siteId = v === null ? '' : Number(v) }"
/>
<template v-if="!editingEmployee">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select
id="contract-nature"
v-model="form.contractNature"
:class="contractNatureFieldClass"
>
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le type de contrat est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le temps de travail est obligatoire.
</p>
</div>
<MalioSelect
:model-value="form.contractNature"
:options="contractNatureFormOptions"
label="Type de contrat *"
min-width=""
:error="showContractNatureError ? 'Le type de contrat est obligatoire.' : ''"
@update:model-value="(v) => { if (v !== null) form.contractNature = v as 'CDI' | 'CDD' | 'INTERIM' }"
/>
<MalioSelect
v-if="form.contractNature === 'INTERIM'"
:model-value="form.interimAgencyId === '' ? null : form.interimAgencyId"
:options="interimAgencyOptions"
label="Agence d'intérim"
min-width=""
@update:model-value="(v) => { form.interimAgencyId = v === null ? '' : Number(v) }"
/>
<MalioSelect
:model-value="form.contractId === '' ? null : form.contractId"
:options="contractFormOptions"
label="Temps de travail *"
min-width=""
:error="showContractError ? 'Le temps de travail est obligatoire.' : ''"
@update:model-value="(v) => { form.contractId = v === null ? '' : Number(v) }"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span>
@@ -149,7 +139,7 @@
id="contract-start-date"
v-model="form.contractStartDate"
type="date"
:class="contractStartDateFieldClass"
:class="[dateInputBaseClass, form.contractStartDate ? 'border-black' : 'border-m-muted', showContractStartDateError ? '!border-m-danger' : '']"
/>
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
@@ -164,43 +154,107 @@
id="contract-end-date"
v-model="form.contractEndDate"
type="date"
:class="contractEndDateFieldClass"
:class="[dateInputBaseClass, form.contractEndDate ? 'border-black' : 'border-m-muted', showContractEndDateError ? '!border-m-danger' : '']"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD.
La date de fin est obligatoire pour un CDD ou un Intérim.
</p>
</div>
<div class="flex h-10 items-center rounded-md border border-neutral-200 bg-neutral-50 px-3">
<MalioCheckbox
v-model="form.isDriver"
label="Chauffeur"
group-class="flex items-center"
/>
</div>
<WorkDaysHoursInput
v-if="requiresSchedule"
v-model="form.workDaysHours"
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
/>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
<MalioButton
label="Annuler"
variant="tertiary"
@click="isDrawerOpen = false"
>
Annuler
</button>
<button
/>
<MalioButton
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
label="Enregistrer"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
<MalioDrawer v-model="isExportDrawerOpen" title="Export">
<div class="space-y-4">
<MalioSelect
:model-value="exportChoice === '' ? null : exportChoice"
:options="exportTypeOptions"
label="Type d'export"
empty-option-label="Choisir un export"
group-class="mt-2"
min-width=""
@update:model-value="onExportChoiceChange"
/>
<div v-if="exportChoice === 'salary-recap'">
<label class="text-md font-semibold text-neutral-700" for="export-salary-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="export-salary-month"
v-model="exportSalaryMonth"
type="month"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
/>
</div>
<template v-else-if="exportChoice === 'yearly-hours'">
<MalioSelect
:model-value="exportYear"
:options="exportYearOptions"
label="Année *"
min-width=""
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
/>
<MalioSelect
:model-value="exportMonth === '' ? null : exportMonth"
:options="exportMonthOptions"
label="Mois *"
empty-option-label="Choisir un mois"
min-width=""
@update:model-value="(v) => { exportMonth = v === null ? '' : Number(v) }"
/>
</template>
<div class="flex justify-center pt-2">
<MalioButton
label="Valider"
button-class="w-[200px]"
:disabled="!isExportValid"
@click="handleExportValidate"
/>
</div>
</div>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import type {Contract} from '~/services/dto/contract'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
import { requiresWorkDaysHours } from '~/utils/contract'
import type {Employee} from '~/services/dto/employee'
import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter'
useHead({
title: 'Employés'
@@ -209,6 +263,51 @@ useHead({
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isExportDrawerOpen = ref(false)
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
const exportYear = ref<number>(new Date().getFullYear())
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
const exportTypeOptions = [
{ label: 'Récap. congés', value: 'leave-recap' },
{ label: 'Récap. salaire', value: 'salary-recap' },
{ label: 'Heures annuelles', value: 'yearly-hours' }
]
const exportYearOptions = computed(() => {
const current = new Date().getFullYear()
return Array.from({ length: 6 }, (_, i) => ({ label: String(current - i), value: current - i }))
})
const exportMonthOptions = [
{ label: 'Janvier', value: 1 },
{ label: 'Février', value: 2 },
{ label: 'Mars', value: 3 },
{ label: 'Avril', value: 4 },
{ label: 'Mai', value: 5 },
{ label: 'Juin', value: 6 },
{ label: 'Juillet', value: 7 },
{ label: 'Août', value: 8 },
{ label: 'Septembre', value: 9 },
{ label: 'Octobre', value: 10 },
{ label: 'Novembre', value: 11 },
{ label: 'Décembre', value: 12 }
]
const isExportValid = computed(() => {
if (!exportChoice.value) return false
if (exportChoice.value === 'salary-recap') {
return exportSalaryMonth.value.trim() !== ''
}
if (exportChoice.value === 'yearly-hours') {
return exportYear.value > 0 && exportMonth.value !== ''
}
return true
})
const onExportChoiceChange = (value: string | number | null) => {
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | ''
}
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
const drawerTitle = computed(() =>
@@ -218,21 +317,29 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const contractStatusOptions = [
{ label: 'Avec contrat', value: 'active' },
{ label: 'Sans contrat', value: 'inactive' },
{ label: 'Tous', value: 'all' }
]
const selectedSiteIds = ref<number[]>([])
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
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 +353,10 @@ const form = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
contractEndDate: '',
isDriver: false,
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
})
const validationTouched = reactive({
@@ -264,6 +374,21 @@ const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
const selectedContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(form.contractId)) ?? null
)
const requiresSchedule = computed(() =>
!editingEmployee.value && requiresWorkDaysHours(selectedContract.value, form.contractNature)
)
const scheduleTotalMinutes = computed(() => {
const raw = form.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isScheduleValid = computed(() => {
if (!requiresSchedule.value) return true
const expected = (selectedContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && scheduleTotalMinutes.value === expected
})
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
@@ -281,7 +406,8 @@ const isFormValid = computed(
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
isContractEndDateValid.value &&
isScheduleValid.value))
)
const showFirstNameError = computed(
@@ -306,63 +432,23 @@ const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const lastNameFieldClass = computed(() => {
if (showLastNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const siteFieldClass = computed(() => {
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showSiteError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const contractFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractNatureFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractNatureError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractStartDateFieldClass = computed(() => {
if (showContractStartDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const contractEndDateFieldClass = computed(() => {
if (showContractEndDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const dateInputBaseClass =
'mt-2 h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const formSiteOptions = computed(() =>
sites.value.map((site) => ({ label: site.name, value: site.id }))
)
const interimAgencyOptions = computed(() =>
interimAgencies.value.map((agency) => ({ label: agency.name, value: agency.id }))
)
const contractFormOptions = computed(() =>
contracts.value.map((contract) => ({ label: contract.name, value: contract.id }))
)
const contractNatureFormOptions = [
{ label: 'CDI', value: 'CDI' },
{ label: 'CDD', value: 'CDD' },
{ label: 'Intérim', value: 'INTERIM' }
]
const loadEmployees = async () => {
isLoading.value = true
@@ -381,8 +467,12 @@ const loadContracts = async () => {
contracts.value = await listContracts()
}
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
await Promise.all([loadEmployees(), loadSites(), loadContracts(), loadInterimAgencies()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
@@ -431,7 +521,10 @@ const handleSubmit = async () => {
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null
contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver,
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null,
interimAgencyId: form.contractNature === 'INTERIM' && form.interimAgencyId !== '' ? Number(form.interimAgencyId) : null
})
}
@@ -442,6 +535,9 @@ const handleSubmit = async () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -468,6 +564,18 @@ watch(showsContractEndDateComputed, (shows) => {
}
})
watch(() => form.contractNature, (nature) => {
if (nature !== 'INTERIM') {
form.interimAgencyId = ''
}
})
watch(requiresSchedule, (required) => {
if (!required) {
form.workDaysHours = null
}
})
const openEdit = (employee: Employee) => {
editingEmployee.value = employee
form.firstName = employee.firstName
@@ -485,9 +593,35 @@ const openCreate = () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
isDrawerOpen.value = true
}
const openExportDrawer = () => {
exportChoice.value = ''
const now = new Date()
exportYear.value = now.getFullYear()
exportMonth.value = now.getMonth() + 1
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
isExportDrawerOpen.value = true
}
const handleExportValidate = async () => {
if (!isExportValid.value) return
const choice = exportChoice.value
isExportDrawerOpen.value = false
if (choice === 'leave-recap') {
await printPdf('/leave-recap/print')
} else if (choice === 'salary-recap') {
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
} else if (choice === 'yearly-hours') {
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
}
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
</div>
<HoursToolbar
@@ -38,11 +38,12 @@
<HoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="visibleEmployees"
:employees="displayedEmployees"
:is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
@@ -67,6 +68,9 @@
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel"
:get-row-contract-nature="getRowContractNature"
:get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
@@ -77,11 +81,13 @@
<HoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/>
</div>
@@ -112,6 +118,18 @@
@delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer"
/>
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</div>
</template>
@@ -126,6 +144,7 @@ const {
selectedSiteIds,
employees,
visibleEmployees,
displayedEmployees,
rows,
absenceTypes,
absenceForm,
@@ -138,6 +157,7 @@ const {
isSubmitting,
dayGridCols,
isSelectedDateHoliday,
selectedHolidayLabel,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
@@ -176,6 +196,9 @@ const {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,
@@ -183,7 +206,11 @@ const {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useHoursPage()
useHead({

View File

@@ -0,0 +1,163 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Récap. congés</h1>
<span
v-if="cutoffLabel"
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
>
<Icon name="mdi:calendar-check-outline" size="18"/>
{{ cutoffLabel }}
</span>
</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="rows.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun employé à afficher.
</div>
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div
:class="`grid ${gridColsClass} 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 v-if="showSiteColumn" class="text-left">Site</span>
<span class="text-left">Nom</span>
<span class="text-left">Prénom</span>
<span class="text-left">Contrat</span>
<span class="text-right">CP N-1 restant</span>
<span class="text-right">Samedis</span>
<span class="text-right">CP N</span>
<span class="text-right">RTT</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in rows"
:key="row.employeeId"
:class="`grid ${gridColsClass} items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0`"
>
<span v-if="showSiteColumn" class="truncate">
<span
v-if="row.siteName"
class="inline-block rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
<span v-else class="text-neutral-500">-</span>
</span>
<span class="truncate">{{ row.lastName }}</span>
<span class="truncate">{{ row.firstName }}</span>
<span class="truncate">{{ row.contractName ?? '-' }}</span>
<span class="text-right tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
<span class="text-right tabular-nums">{{ row.acquiredSaturdays }}</span>
<span class="text-right tabular-nums">{{ row.cpN }}</span>
<span class="text-right tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
<!-- Mobile cards -->
<div v-if="!isLoading && rows.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="row in rows"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.lastName }} {{ row.firstName }}
</p>
<span
v-if="showSiteColumn && row.siteName"
class="inline-block shrink-0 rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
</div>
<p v-if="row.contractName" class="mb-3 text-sm text-neutral-600">{{ row.contractName }}</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">CP N-1</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Samedis</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.acquiredSaturdays }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">CP N</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">RTT</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { LeaveRecapRow } from '~/services/dto/leave-recap'
import { fetchLeaveRecap } from '~/services/leave-recap'
import { formatYmdToFr, getIsoWeekNumber, parseYmd } from '~/utils/date'
definePageMeta({ middleware: ['leave-recap-access'] })
useHead({ title: 'Récap. congés' })
const auth = useAuthStore()
const rows = ref<LeaveRecapRow[]>([])
const isLoading = ref(false)
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfOnly = computed(() => {
const roles = auth.user?.roles ?? []
return roles.includes('ROLE_SELF') && !roles.includes('ROLE_ADMIN')
})
const showSiteColumn = computed(() => !isSelfOnly.value)
const gridColsClass = computed(() =>
showSiteColumn.value
? 'grid-cols-[1.2fr_1fr_1fr_1.2fr_140px_100px_100px_120px]'
: 'grid-cols-[1fr_1fr_1.2fr_140px_100px_100px_120px]'
)
const cutoffLabel = computed(() => {
const ymd = rows.value[0]?.cutoffDate
if (!ymd) return ''
const parsed = parseYmd(ymd)
if (!parsed) return ''
const week = getIsoWeekNumber(parsed)
return `Arrêté au ${formatYmdToFr(ymd)} (fin S${week})`
})
const formatNumber = (value: number) => {
if (!Number.isFinite(value)) return '-'
return value.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
}
const load = async () => {
isLoading.value = true
try {
rows.value = await fetchLeaveRecap()
} finally {
isLoading.value = false
}
}
onMounted(load)
// Silence unused linter warning for isAdmin (kept for future site grouping)
void isAdmin
</script>

View File

@@ -9,31 +9,18 @@
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit"
>
<div>
<label class="text-sm font-semibold text-neutral-700" for="username">
Nom d'utilisateur
</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<MalioInputText
v-model="username"
label="Nom d'utilisateur"
autocomplete="username"
group-class="mt-2"
/>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<MalioInputPassword
v-model="password"
label="Mot de passe"
autocomplete="current-password"
/>
<button
type="submit"
@@ -68,7 +55,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,14 +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"
<MalioButton
label="Ajouter un site"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
Ajouter un site
</button>
/>
</div>
<div
@@ -18,26 +17,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,42 +47,18 @@
/>
<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>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="name"
v-model="form.name"
type="text"
:class="nameFieldClass"
/>
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
Le nom du site est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.name"
label="Nom *"
group-class="mt-2"
:error="showNameError ? 'Le nom du site est obligatoire.' : ''"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
@@ -98,24 +73,30 @@
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
>
Annuler
</button>
<button
<div v-if="editingSite" class="grid grid-cols-2 gap-3 pt-2">
<MalioButton
label="Supprimer"
variant="danger"
button-class="w-full"
@click="confirmDelete(editingSite)"
/>
<MalioButton
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
label="Modifier"
button-class="w-full"
:disabled="isSubmitting || !isFormValid"
/>
</div>
<div v-else class="flex justify-center pt-2">
<MalioButton
type="submit"
label="Valider"
button-class="w-[200px]"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</div>
</template>
@@ -153,22 +134,6 @@ const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadSites = async () => {
isLoading.value = true
try {

View File

@@ -1,14 +1,13 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<MalioButton
label="Ajouter"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
Ajouter un utilisateur
</button>
/>
</div>
<div
@@ -18,83 +17,100 @@
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">
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<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>
<AppDrawer
<!-- Mobile cards -->
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
Chargement...
</div>
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="user in users"
:key="'m-' + user.id"
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
@click="openEdit(user)"
>
<div class="flex items-center justify-between gap-2 mb-2">
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
<span
v-if="user.isLocked"
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
>Actif</span>
</div>
<div class="space-y-1 text-sm">
<p v-if="user.employee" class="text-neutral-600">
{{ user.employee.firstName }} {{ user.employee.lastName }}
</p>
<p class="text-neutral-500">
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
</p>
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
</p>
</div>
</div>
</div>
<MalioDrawer
v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="username">
Nom d'utilisateur <span class="text-red-600">*</span>
</label>
<input
id="username"
v-model="form.username"
type="text"
:class="usernameFieldClass"
/>
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
Le nom d'utilisateur est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.username"
:label="editingUser ? `Nom d'utilisateur` : `Nom d'utilisateur *`"
group-class="mt-2"
:error="showUsernameError ? `Le nom d'utilisateur est obligatoire.` : ''"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="password">
Mot de passe
<span v-if="!editingUser" class="text-red-600">*</span>
</label>
<input
id="password"
<MalioInputPassword
v-model="form.password"
type="password"
:class="passwordFieldClass"
:label="editingUser ? 'Mot de passe' : 'Mot de passe *'"
:hint="editingUser ? 'Laisse vide pour ne pas changer le mot de passe.' : ''"
:error="!editingUser && showPasswordError ? 'Le mot de passe est obligatoire.' : ''"
/>
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
Laisse vide pour ne pas changer le mot de passe.
</p>
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
Le mot de passe est obligatoire.
</p>
</div>
<div>
@@ -137,64 +153,64 @@
</div>
<div v-if="form.accessMode === 'self'">
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé lié
</label>
<select
id="employee"
v-model="form.employeeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucun</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
Sélectionne un employé.
</p>
<MalioSelect
:model-value="form.employeeId === '' ? null : form.employeeId"
:options="employeeOptions"
label="Employé lié"
empty-option-label="Aucun"
min-width=""
:error="showSelfEmployeeError ? 'Sélectionne un employé.' : ''"
@update:model-value="onEmployeeChange"
/>
</div>
<div v-if="form.accessMode === 'sites'">
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<label
<div
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer"
class="flex h-10 items-center rounded-md border border-neutral-200 px-3"
>
<input
type="checkbox"
class="cursor-pointer"
:checked="form.siteIds.includes(site.id)"
@change="toggleSite(site.id)"
<MalioCheckbox
:model-value="form.siteIds.includes(site.id)"
:label="site.name"
group-class="flex items-center"
@update:model-value="toggleSite(site.id)"
/>
<span>{{ site.name }}</span>
</label>
</div>
</div>
<p v-if="showSitesError" class="mt-1 text-sm text-red-600">
Sélectionne au moins un site.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
>
Annuler
</button>
<button
<div>
<MalioCheckbox
v-model="form.isLocked"
label="Verrouiller le compte"
hint="Un compte verrouillé ne peut plus se connecter."
/>
</div>
<div>
<MalioCheckbox
v-model="form.hasLeaveRecapAccess"
label="Accès à l'écran Récap. congés"
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap."
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
:label="editingUser ? 'Modifier' : 'Valider'"
button-class="w-[200px]"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</div>
</template>
@@ -227,7 +243,9 @@ const form = reactive({
password: '',
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[]
siteIds: [] as number[],
isLocked: false,
hasLeaveRecapAccess: false
})
const validationTouched = reactive({
@@ -290,27 +308,13 @@ const getSiteLabels = (user: User) => {
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
}
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const usernameFieldClass = computed(() => {
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const passwordFieldClass = computed(() => {
if (showPasswordError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const employeeOptions = computed(() =>
employees.value.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
)
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const onEmployeeChange = (value: string | number | null) => {
form.employeeId = value === null ? '' : Number(value)
}
const loadData = async () => {
isLoading.value = true
@@ -338,6 +342,8 @@ const resetForm = () => {
form.employeeId = ''
form.accessMode = 'admin'
form.siteIds = []
form.isLocked = false
form.hasLeaveRecapAccess = false
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
@@ -365,6 +371,8 @@ const openEdit = (user: User) => {
}
form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
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,9 @@ const handleSubmit = async () => {
username: form.username,
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId
employeeId,
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -442,7 +452,9 @@ const handleSubmit = async () => {
username: form.username,
plainPassword: form.password,
roles,
employeeId
employeeId,
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
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

@@ -10,6 +10,11 @@ export type EmployeeLeaveSummary = {
takenSaturdays: number
fractionedDays: number
accruingDays: number
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
previousYearPaidDays: number
presenceDaysByMonth: Record<string, number>
presenceDaysToToday: number
}

View File

@@ -3,22 +3,35 @@ export type EmployeeRttWeekSummary = {
weekNumber: number
weekStart: string
weekEnd: string
recoveryMinutes: number
overtimeMinutes: number
base25Minutes: number
bonus25Minutes: number
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
cumulativeBalanceMinutes: number
}
export type RttMonthPayment = {
month: number
paidMinutes25: number
paidMinutes50: number
paidBase25Minutes: number
paidBonus25Minutes: number
paidBase50Minutes: number
paidBonus50Minutes: number
}
export type EmployeeRttSummary = {
year: number
carryMonth: number
carryFromPreviousYearMinutes: number
carryBase25Minutes: number
carryBonus25Minutes: number
carryBase50Minutes: number
carryBonus50Minutes: number
currentYearRecoveryMinutes: number
totalPaidMinutes: number
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
rttStartDate: string | null
}

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