Compare commits

...

22 Commits

Author SHA1 Message Date
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
102 changed files with 4736 additions and 530 deletions

3
.env
View File

@@ -38,6 +38,9 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
###> app ### ###> app ###
RTT_START_DATE=2026-02-23 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 ### ###< app ###
###> nelmio/cors-bundle ### ###> nelmio/cors-bundle ###

View File

@@ -31,10 +31,19 @@
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM - Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM - Contract nature (per period): CDI, CDD, INTERIM
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **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: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) - 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` - 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`.
## Validation Rules ## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation) - `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit - `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -49,11 +58,33 @@
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - 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 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.
## 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) ## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé - Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0` - Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis - 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 ## Frontend Patterns
### Table styling (standard across all pages) ### Table styling (standard across all pages)

View File

@@ -23,3 +23,5 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
```sql ```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie'; 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

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

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.79' app.version: '0.1.89'

View File

@@ -23,3 +23,4 @@ DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23 RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/" HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"

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

@@ -161,10 +161,14 @@ Documents complementaires:
## 7) Fériés ## 7) Fériés
- Les jours fériés sont identifiés et affiché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 - 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
- Règle courante: - Règle courante:
- absences bloquées sur jour férié - 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 autorisée - 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) ## 8) Impression absences (PDF)
@@ -326,6 +330,24 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition | | CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` | | 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) ## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`) - Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)

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

@@ -4,13 +4,13 @@
<div class="absolute inset-0 bg-black/40" @click="close" /> <div class="absolute inset-0 bg-black/40" @click="close" />
</Transition> </Transition>
<Transition name="drawer-panel"> <Transition name="drawer-panel">
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl"> <div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl flex flex-col">
<div class="flex items-center justify-between px-[20px] pt-8 pb-8"> <div class="shrink-0 flex items-center justify-between px-[20px] pt-8 pb-8">
<h2 class="text-[32px] font-semibold text-primary-500"> <h2 class="text-[32px] font-semibold text-primary-500">
{{ title }} {{ title }}
</h2> </h2>
</div> </div>
<div class="overflow-y-auto px-[20px]" style="max-height: calc(100% - 65px)"> <div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
<slot /> <slot />
</div> </div>
</div> </div>

View File

@@ -45,9 +45,9 @@
<button <button
type="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="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)" :style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)" :disabled="getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)" @click="handleCellClick(employee, day.date)"
> >
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel"> <span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -67,15 +67,20 @@
{{ getCellInfo(employee.id, day.date)?.code }} {{ getCellInfo(employee.id, day.date)?.code }}
</span> </span>
</template> </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> </button>
</template> </template>
<template v-else> <template v-else>
<button <button
type="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="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)" :style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@click="handleCellClick(employee, day.date)" @click="handleCellClick(employee, day.date)"
> >
<span></span> <span></span>
@@ -107,7 +112,7 @@ const props = defineProps<{
visibleEmployees: Employee[] visibleEmployees: Employee[]
gridStyle: Record<string, string> gridStyle: Record<string, string>
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined 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 formatEmployeeName: (employee: Employee) => string
isHolidayDate: (date: string) => boolean isHolidayDate: (date: string) => boolean
}>() }>()

View File

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="drawerOpen" title="Export heures annuelles"> <AppDrawer v-model="drawerOpen" title="Export heures">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year"> <label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
@@ -14,6 +14,20 @@
</select> </select>
</div> </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"> <div class="flex justify-center pt-2">
<button <button
type="submit" type="submit"
@@ -37,7 +51,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void (event: 'update:modelValue', value: boolean): void
(event: 'submit', year: number): void (event: 'submit', payload: { year: number; month: number | null }): void
}>() }>()
const drawerOpen = computed({ const drawerOpen = computed({
@@ -47,13 +61,31 @@ const drawerOpen = computed({
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i) 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 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 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 selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => { const handleSubmit = () => {
emit('submit', selectedYear.value) emit('submit', {
year: selectedYear.value,
month: selectedMonth.value === '' ? null : selectedMonth.value
})
} }
watch( watch(
@@ -61,6 +93,7 @@ watch(
(isOpen) => { (isOpen) => {
if (!isOpen) { if (!isOpen) {
selectedYear.value = currentYear selectedYear.value = currentYear
selectedMonth.value = ''
} }
} }
) )

View File

@@ -25,19 +25,7 @@
@change="onBulkValidationChange" @change="onBulkValidationChange"
/> />
</span> </span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2"> <span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span> <span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div> </div>
@@ -54,7 +42,9 @@
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <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="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5" class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
@@ -68,19 +58,30 @@
</p> </p>
</div> </div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5"> <div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p <div class="flex flex-col gap-1 min-w-0">
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate" <p
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'" class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:title="getRowAbsenceLabel(employee.id) || ''" :class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:style="getRowAbsenceStyle(employee.id)" :title="getRowAbsenceLabel(employee.id) || ''"
> :style="getRowAbsenceStyle(employee.id)"
{{ getRowAbsenceLabel(employee.id) || '—' }} >
</p> {{ 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 <button
type="button" type="button"
class="self-start text-left text-xs font-semibold underline" 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'" :class="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)" :disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)" @click="onAbsenceClick(employee.id)"
> >
Modifier Modifier
@@ -91,6 +92,12 @@
v-model="rows[employee.id].dayHours" v-model="rows[employee.id].dayHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)" :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>
<div class="pl-2"> <div class="pl-2">
<TimeSelect <TimeSelect
@@ -147,16 +154,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)" @change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/> />
</div> </div>
<div v-else class="text-right p-5"> <div v-else-if="!isSiteManager" class="text-right p-5">
<input <span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span> <span v-else class="text-xs text-neutral-500">-</span>
</div> </div>
<div v-if="!isAdmin"> <div v-if="!isAdmin">
@@ -173,6 +172,7 @@
import type { Employee } from '~/services/dto/employee' import type { Employee } from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue' import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { DriverHourRow } from '~/services/dto/work-hour' import type { DriverHourRow } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true }) const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null) const bulkValidationInput = ref<HTMLInputElement | null>(null)
@@ -184,6 +184,7 @@ const props = defineProps<{
isSiteManager: boolean isSiteManager: boolean
dayGridCols: string dayGridCols: string
isHoliday: boolean isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string contractLabel: (employee: Employee) => string
isRowLocked: (employeeId: number) => boolean isRowLocked: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean hasContractAtSelectedDate: (employeeId: number) => boolean
@@ -201,7 +202,7 @@ const props = defineProps<{
onToggleSiteValidation: (employeeId: number, checked: boolean) => void onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (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; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string

View File

@@ -33,7 +33,9 @@
{{ row.firstName }} {{ row.lastName }} {{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span> <span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p> </p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p> <p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div> </div>
<div <div
@@ -89,6 +91,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const getDailyCellStyle = (daily: { const getDailyCellStyle = (daily: {
hasAbsence?: boolean hasAbsence?: boolean

View File

@@ -108,6 +108,13 @@
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p> <p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div> </div>
<WorkDaysHoursInput
v-if="contractForm.workDaysHours"
:model-value="contractForm.workDaysHours"
:contract-weekly-hours="contractForm.weeklyHours ?? null"
disabled
/>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="contract-comment"> <label class="text-md font-semibold text-neutral-700" for="contract-comment">
Commentaire Commentaire
@@ -252,7 +259,13 @@
</label> </label>
</div> </div>
<div class="flex justify-center pt-2"> <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 <button
type="submit" 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" 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"
@@ -269,6 +282,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Contract } from '~/services/dto/contract' import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee' import type { ContractHistoryItem } from '~/services/dto/employee'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
type SuspensionForm = { type SuspensionForm = {
id: number | null id: number | null
@@ -286,6 +300,7 @@ type ContractForm = {
endDate: string endDate: string
paidLeaveSettled: boolean paidLeaveSettled: boolean
comment: string comment: string
workDaysHours: Record<number, number> | null
} }
type CreateContractForm = { type CreateContractForm = {
@@ -294,6 +309,7 @@ type CreateContractForm = {
startDate: string startDate: string
endDate: string endDate: string
isDriver: boolean isDriver: boolean
workDaysHours: Record<number, number> | null
} }
const props = defineProps<{ const props = defineProps<{
@@ -322,6 +338,8 @@ const props = defineProps<{
requiresCreateContractEndDate: boolean requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string createContractEndDateFieldClass: string
isCreateContractFormValid: boolean isCreateContractFormValid: boolean
requiresCreateWorkDaysHours: boolean
selectedCreateContract: Contract | null
onOpenCloseContractDrawer: () => void onOpenCloseContractDrawer: () => void
onOpenCreateContractDrawer: () => void onOpenCreateContractDrawer: () => void
onUpdateContractDrawerOpen: (open: boolean) => void onUpdateContractDrawerOpen: (open: boolean) => void

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

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

@@ -26,19 +26,7 @@
@change="onBulkValidationChange" @change="onBulkValidationChange"
/> />
</span> </span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2"> <span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span> <span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div> </div>
@@ -55,7 +43,9 @@
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <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="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5" class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
@@ -69,19 +59,39 @@
</p> </p>
</div> </div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5"> <div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p <div class="flex flex-col gap-1 min-w-0">
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate" <p
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'" class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:title="getRowAbsenceLabel(employee.id) || ''" :class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:style="getRowAbsenceStyle(employee.id)" :title="getRowAbsenceLabel(employee.id) || ''"
> :style="getRowAbsenceStyle(employee.id)"
{{ getRowAbsenceLabel(employee.id) || '—' }} >
</p> {{ 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 <button
v-if="!hasRowFormation(employee.id)"
type="button" type="button"
class="self-start text-left text-xs font-semibold underline" 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'" :class="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)" :disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)" @click="onAbsenceClick(employee.id)"
> >
Modifier Modifier
@@ -170,16 +180,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)" @change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/> />
</div> </div>
<div v-else class="text-right p-5"> <div v-else-if="!isSiteManager" class="text-right p-5">
<input <span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span> <span v-else class="text-xs text-neutral-500">-</span>
</div> </div>
<div v-if="!isAdmin"> <div v-if="!isAdmin">
@@ -196,6 +198,7 @@
import type {Employee} from '~/services/dto/employee' import type {Employee} from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue' import TimeSelect from '~/components/ui/TimeSelect.vue'
import type {HourRow} from './types' import type {HourRow} from './types'
import { contractNatureLabel } from '~/utils/contract'
const rows = defineModel<Record<number, HourRow>>('rows', {required: true}) const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
const bulkValidationInput = ref<HTMLInputElement | null>(null) const bulkValidationInput = ref<HTMLInputElement | null>(null)
@@ -207,6 +210,7 @@ const props = defineProps<{
isSiteManager: boolean isSiteManager: boolean
dayGridCols: string dayGridCols: string
isHoliday: boolean isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean isPresenceTracking: (employee: Employee) => boolean
@@ -228,9 +232,11 @@ const props = defineProps<{
onToggleSiteValidation: (employeeId: number, checked: boolean) => void onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (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 getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void

View File

@@ -29,7 +29,9 @@
{{ row.firstName }} {{ row.lastName }} {{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span> <span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p> </p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p> <p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div> </div>
<div <div
@@ -81,6 +83,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract' import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
import { contractNatureLabel } from '~/utils/contract'
const isInterimContract = (contractType?: ContractType | null) => { const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM return contractType === CONTRACT_TYPES.INTERIM

View File

@@ -71,7 +71,7 @@ export const useDriverHoursPage = () => {
const dayGridCols = computed(() => { const dayGridCols = computed(() => {
const metricCol = '0.4fr' 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 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}` return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
}) })
@@ -368,12 +368,23 @@ export const useDriverHoursPage = () => {
const getRowMetrics = (employeeId: number) => { const getRowMetrics = (employeeId: number) => {
const row = rows.value[employeeId] ?? emptyRow() const row = rows.value[employeeId] ?? emptyRow()
const credited = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0 const dayRow = dayContextByEmployeeId.value.get(employeeId)
const dayMinutes = toMinutes(row.dayHours) + credited const credited = dayRow?.creditedMinutes ?? 0
let dayMinutes = toMinutes(row.dayHours) + credited
const nightMinutes = toMinutes(row.nightHours) const nightMinutes = toMinutes(row.nightHours)
const workshopMinutes = toMinutes(row.workshopHours) const workshopMinutes = toMinutes(row.workshopHours)
const totalMinutes = dayMinutes + nightMinutes + workshopMinutes let totalMinutes = dayMinutes + nightMinutes + workshopMinutes
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes }
// Virtual holiday credit: backend already applies the contract-period
// schedule and absence-override rule; consume the value as-is.
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
if (virtualHolidayMinutes > totalMinutes) {
const delta = virtualHolidayMinutes - totalMinutes
dayMinutes += delta
totalMinutes = virtualHolidayMinutes
}
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes, virtualHolidayMinutes }
} }
const getRowAbsenceLabel = (employeeId: number) => { const getRowAbsenceLabel = (employeeId: number) => {
@@ -381,7 +392,6 @@ export const useDriverHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) { if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré' return 'Contrat non démarré'
} }
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return '' if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') { if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.' const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -467,7 +477,6 @@ export const useDriverHoursPage = () => {
const openAbsenceDrawer = (employeeId: number) => { const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => { const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false if (absence.employee?.id !== employeeId) return false
@@ -941,6 +950,7 @@ export const useDriverHoursPage = () => {
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
isSelectedDateHoliday, isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders, weekDayHeaders,
shortcutButtonClass, shortcutButtonClass,
weekShortcutButtonClass, weekShortcutButtonClass,

View File

@@ -5,7 +5,7 @@ import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees' import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions' import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date' import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract' import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = { type SuspensionForm = {
id: number | null id: number | null
@@ -32,7 +32,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
startDate: '', startDate: '',
endDate: '', endDate: '',
paidLeaveSettled: false, paidLeaveSettled: false,
comment: '' comment: '',
workDaysHours: null as Record<number, number> | null
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -44,7 +45,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM', contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '', startDate: '',
endDate: '', endDate: '',
isDriver: false isDriver: false,
workDaysHours: null as Record<number, number> | null
}) })
const createValidationTouched = reactive({ const createValidationTouched = reactive({
@@ -59,10 +61,11 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const formatDate = (value?: string | null) => formatNullableYmdToFr(value) const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => { const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) { const base = item.weeklyHours !== null && item.weeklyHours !== undefined
return `${item.weeklyHours} heures` ? `${item.weeklyHours} heures`
} : (item.contractName ?? '-')
return item.contractName ?? '-' const scheduleSummary = formatWorkDaysHoursSummary(item.workDaysHours)
return scheduleSummary ? `${base} (${scheduleSummary})` : base
} }
const currentActiveContractPeriod = computed(() => { const currentActiveContractPeriod = computed(() => {
@@ -111,11 +114,27 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature)) const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '') const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '') 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(() => const isCreateContractFormValid = computed(() =>
isCreateContractValid.value && isCreateContractValid.value &&
isCreateContractNatureValid.value && isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value && isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value isCreateContractEndDateValid.value &&
isCreateScheduleValid.value
) )
const baseInputClass = const baseInputClass =
@@ -159,6 +178,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractForm.endDate = period.endDate ?? getTodayYmd() contractForm.endDate = period.endDate ?? getTodayYmd()
contractForm.paidLeaveSettled = false contractForm.paidLeaveSettled = false
contractForm.comment = '' contractForm.comment = ''
contractForm.workDaysHours = period.workDaysHours ?? null
} }
const openCloseContractDrawer = () => { const openCloseContractDrawer = () => {
@@ -186,6 +206,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.contractNature = 'CDI' createContractForm.contractNature = 'CDI'
createContractForm.endDate = '' createContractForm.endDate = ''
createContractForm.isDriver = false createContractForm.isDriver = false
createContractForm.workDaysHours = null
createContractForm.startDate = editableContractPeriod.value?.endDate createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate) ? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd() : getTodayYmd()
@@ -261,7 +282,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: createContractForm.contractNature, contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate, contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null, contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver isDriverInput: createContractForm.isDriver,
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null
}) })
isCreateContractDrawerOpen.value = false isCreateContractDrawerOpen.value = false
await reloadEmployee() await reloadEmployee()
@@ -319,6 +341,12 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
} }
}) })
watch(requiresCreateWorkDaysHours, (required) => {
if (!required) {
createContractForm.workDaysHours = null
}
})
return { return {
contracts, contracts,
contractHistory, contractHistory,
@@ -342,6 +370,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
requiresCreateContractEndDate, requiresCreateContractEndDate,
createContractEndDateFieldClass, createContractEndDateFieldClass,
isCreateContractFormValid, isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel, contractNatureLabel,
contractHistoryLabel, contractHistoryLabel,
formatDate, formatDate,

View File

@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
const route = useRoute() const route = useRoute()
const employee = ref<Employee | null>(null) const employee = ref<Employee | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus' | 'observation'>('contract') const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM') const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT) const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
@@ -39,6 +39,7 @@ export const useEmployeeDetailPage = () => {
leave.resetLoaded() leave.resetLoaded()
rtt.resetLoaded() rtt.resetLoaded()
mileage.resetLoaded() mileage.resetLoaded()
formation.resetLoaded()
bonus.resetLoaded() bonus.resetLoaded()
observation.resetLoaded() observation.resetLoaded()
@@ -48,6 +49,8 @@ export const useEmployeeDetailPage = () => {
await rtt.loadRttData() await rtt.loadRttData()
} else if (activeTab.value === 'mileage') { } else if (activeTab.value === 'mileage') {
await mileage.loadMileageData() await mileage.loadMileageData()
} else if (activeTab.value === 'formation') {
await formation.loadFormationData()
} else if (activeTab.value === 'bonus') { } else if (activeTab.value === 'bonus') {
await bonus.loadBonusData() await bonus.loadBonusData()
} else if (activeTab.value === 'observation') { } else if (activeTab.value === 'observation') {
@@ -62,6 +65,7 @@ export const useEmployeeDetailPage = () => {
const leave = useEmployeeLeave(employee, loadEmployee) const leave = useEmployeeLeave(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee) const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee) const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee) const bonus = useEmployeeBonus(employee, loadEmployee)
const observation = useEmployeeObservation(employee, loadEmployee) const observation = useEmployeeObservation(employee, loadEmployee)
@@ -72,6 +76,8 @@ export const useEmployeeDetailPage = () => {
rtt.loadRttData() rtt.loadRttData()
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) { } else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
mileage.loadMileageData() mileage.loadMileageData()
} else if (tab === 'formation' && !formation.formationDataLoaded.value) {
formation.loadFormationData()
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) { } else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
bonus.loadBonusData() bonus.loadBonusData()
} else if (tab === 'observation' && !observation.observationDataLoaded.value) { } else if (tab === 'observation' && !observation.observationDataLoaded.value) {
@@ -95,6 +101,7 @@ export const useEmployeeDetailPage = () => {
...leave, ...leave,
...rtt, ...rtt,
...mileage, ...mileage,
...formation,
...bonus, ...bonus,
...observation ...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

@@ -73,7 +73,7 @@ export const useHoursPage = () => {
const dayGridCols = computed(() => { const dayGridCols = computed(() => {
const metricCol = '0.4fr' 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}` return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
}) })
@@ -447,10 +447,21 @@ export const useHoursPage = () => {
nightMinutes += nightIntervalMinutes(from, to) 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 totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes) let dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes }
// 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) => { const getRowAbsenceLabel = (employeeId: number) => {
@@ -458,7 +469,6 @@ export const useHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) { if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré' return 'Contrat non démarré'
} }
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return '' if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') { if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.' const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -476,6 +486,14 @@ export const useHoursPage = () => {
return { backgroundColor: dayRow.absenceColor || '#dc2626' } 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 getRowUpdatedAt = (employeeId: number): string => { const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt const raw = rows.value[employeeId]?.updatedAt
if (!raw) return '' if (!raw) return ''
@@ -576,7 +594,6 @@ export const useHoursPage = () => {
const openAbsenceDrawer = (employeeId: number) => { const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => { const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false if (absence.employee?.id !== employeeId) return false
@@ -1119,6 +1136,7 @@ export const useHoursPage = () => {
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
isSelectedDateHoliday, isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders, weekDayHeaders,
shortcutButtonClass, shortcutButtonClass,
weekShortcutButtonClass, weekShortcutButtonClass,
@@ -1154,6 +1172,8 @@ export const useHoursPage = () => {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,

View File

@@ -56,6 +56,9 @@ export const documentationSections: DocSection[] = [
{ 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: '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: '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: '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.' },
], ],
}, },
{ {
@@ -384,7 +387,8 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' }, { 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\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' }, { 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: '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.' },
], ],
}, },
], ],
@@ -441,6 +445,17 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' }, { 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.' },
],
},
], ],
}, },
{ {
@@ -503,6 +518,17 @@ export const documentationSections: DocSection[] = [
{ 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é.' }, { 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', id: 'primes',
title: 'Onglet Prime', title: 'Onglet Prime',

View File

@@ -42,6 +42,11 @@
"update": "Impossible de mettre à jour le frais kilométrique.", "update": "Impossible de mettre à jour le frais kilométrique.",
"delete": "Impossible de supprimer 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": { "bonus": {
"create": "Impossible de créer la prime.", "create": "Impossible de créer la prime.",
"update": "Impossible de mettre à jour la prime.", "update": "Impossible de mettre à jour la prime.",
@@ -51,6 +56,9 @@
"create": "Impossible de créer l'observation.", "create": "Impossible de créer l'observation.",
"update": "Impossible de mettre à jour l'observation.", "update": "Impossible de mettre à jour l'observation.",
"delete": "Impossible de supprimer l'observation." "delete": "Impossible de supprimer l'observation."
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
} }
}, },
"success": { "success": {
@@ -88,6 +96,11 @@
"update": "Frais kilométrique mis à jour.", "update": "Frais kilométrique mis à jour.",
"delete": "Frais kilométrique supprimé." "delete": "Frais kilométrique supprimé."
}, },
"formation": {
"create": "Formation créée.",
"update": "Formation mise à jour.",
"delete": "Formation supprimée."
},
"bonus": { "bonus": {
"create": "Prime créée.", "create": "Prime créée.",
"update": "Prime mise à jour.", "update": "Prime mise à jour.",

View File

@@ -53,6 +53,17 @@
<Icon name="mdi:account-group-outline" size="24"/> <Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p> <p>Employés</p>
</NuxtLink> </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'
: ''"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink <NuxtLink
to="/sites" to="/sites"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500" class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
@@ -84,6 +95,15 @@
<p>Utilisateurs</p> <p>Utilisateurs</p>
</NuxtLink> </NuxtLink>
</template> </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' : ''"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink <NuxtLink
v-if="isSuperAdmin" v-if="isSuperAdmin"
to="/audit-logs" to="/audit-logs"
@@ -128,5 +148,6 @@ const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false) const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
const isDriver = computed(() => auth.user?.isDriver ?? false) const isDriver = computed(() => auth.user?.isDriver ?? false)
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
const route = useRoute() const route = useRoute()
</script> </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

@@ -49,6 +49,10 @@
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div> <div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p> <p>{{ type.label }}</p>
</div> </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>
</div> </div>
@@ -99,6 +103,8 @@ import {HALF_DAYS} from '~/services/dto/half-day'
import {listEmployees, updateEmployeeOrder} from '~/services/employees' import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types' import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences' import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listFormationsByDateRange} from '~/services/formations'
import type {Formation} from '~/services/dto/formation'
import {listPublicHolidays} from '~/services/public-holidays' import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date' import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee' import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
@@ -163,6 +169,7 @@ const visibleEmployees = computed(() => {
// Données de référence et absences du mois affiché. // Données de référence et absences du mois affiché.
const absenceTypes = ref<AbsenceType[]>([]) const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([]) const absences = ref<Absence[]>([])
const formations = ref<Formation[]>([])
const publicHolidays = ref<Record<string, string>>({}) const publicHolidays = ref<Record<string, string>>({})
// États UI. // États UI.
@@ -384,12 +391,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 () => { onMounted(async () => {
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()]) await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences(), loadFormations()])
}) })
watch([selectedMonth, selectedYear, selectedSiteIds], async () => { watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await loadAbsences() await Promise.all([loadAbsences(), loadFormations()])
}) })
watch(selectedYear, async () => { watch(selectedYear, async () => {
@@ -441,14 +454,51 @@ const cellAbsenceMap = computed(() => {
return map 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) => { const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date]) 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) => { const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) { const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (!absence && isHolidayDate(date)) {
return { return {
id: 0, id: 0,
code: 'Férié', code: 'Férié',
@@ -456,8 +506,16 @@ const getCellAbsence = (employeeId: number, date: string) => {
textColor: '#0f172a' textColor: '#0f172a'
} }
} }
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`) if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (absence) return absence if (hasFormationOn(employeeId, date)) {
return {
id: 0,
code: 'F',
color: '#6366f1',
textColor: '#fff',
hasFormation: true
}
}
return null return null
} }
@@ -491,11 +549,6 @@ const getCellInfo = (employeeId: number, date: string) => {
// Ouverture du drawer depuis une cellule. // Ouverture du drawer depuis une cellule.
const openCreate = (employee: Employee, date: string) => { const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
const existing = absences.value.find((absence) => { const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate) const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate) const end = normalizeDate(absence.endDate)
@@ -532,10 +585,6 @@ const openCreateFromToday = () => {
form.typeId = '' form.typeId = ''
const now = new Date() const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate()) const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
if (isHolidayDate(today)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
form.startDate = today form.startDate = today
form.endDate = today form.endDate = today
form.startHalf = 'AM' form.startHalf = 'AM'

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager" :is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols" :day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday" :is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel" :contract-label="contractLabel"
:is-row-locked="isRowLocked" :is-row-locked="isRowLocked"
:has-contract-at-selected-date="hasContractAtSelectedDate" :has-contract-at-selected-date="hasContractAtSelectedDate"
@@ -107,6 +108,7 @@
@delete="deleteAbsenceFromDrawer" @delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer" @cancel="closeAbsenceDrawer"
/> />
</div> </div>
</template> </template>
@@ -174,6 +176,7 @@ const {
closeAbsenceDrawer, closeAbsenceDrawer,
formatMinutes, formatMinutes,
isSelectedDateHoliday, isSelectedDateHoliday,
selectedHolidayLabel,
handleSave handleSave
} = useDriverHoursPage() } = useDriverHoursPage()

View File

@@ -17,7 +17,7 @@
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1> <h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<button <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" class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
title="Export heures annuelles" title="Export heures"
@click="isYearlyHoursDrawerOpen = true" @click="isYearlyHoursDrawerOpen = true"
> >
<Icon name="mdi:printer" size="24" /> <Icon name="mdi:printer" size="24" />
@@ -74,6 +74,16 @@
<Icon name="mdi:account-cash-outline" size="24" class="align-self"/> <Icon name="mdi:account-cash-outline" size="24" class="align-self"/>
Frais Frais
</button> </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 <button
class="pb-2 border-b-2 flex items-center gap-3" class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'bonus' :class="activeTab === 'bonus'
@@ -125,6 +135,8 @@
:requires-create-contract-end-date="requiresCreateContractEndDate" :requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass" :create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid" :is-create-contract-form-valid="isCreateContractFormValid"
:requires-create-work-days-hours="requiresCreateWorkDaysHours"
:selected-create-contract="selectedCreateContract"
:on-open-close-contract-drawer="openCloseContractDrawer" :on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer" :on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen" :on-update-contract-drawer-open="setContractDrawerOpen"
@@ -171,6 +183,20 @@
@delete="submitDeleteMileage" @delete="submitDeleteMileage"
/> />
</div> </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-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"> <div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement... Chargement...
@@ -250,6 +276,8 @@ const {
requiresCreateContractEndDate, requiresCreateContractEndDate,
createContractEndDateFieldClass, createContractEndDateFieldClass,
isCreateContractFormValid, isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel, contractNatureLabel,
contractHistoryLabel, contractHistoryLabel,
formatDate, formatDate,
@@ -275,6 +303,12 @@ const {
submitCreateMileage, submitCreateMileage,
submitUpdateMileage, submitUpdateMileage,
submitDeleteMileage, submitDeleteMileage,
formations,
isFormationLoading,
formationApiBase,
submitCreateFormation,
submitUpdateFormation,
submitDeleteFormation,
bonuses, bonuses,
isBonusLoading, isBonusLoading,
submitCreateBonus, submitCreateBonus,
@@ -287,9 +321,10 @@ const {
submitDeleteObservation submitDeleteObservation
} = useEmployeeDetailPage() } = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (year: number) => { const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
if (!employee.value) return if (!employee.value) return
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${year}`) const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${payload.year}${monthParam}`)
isYearlyHoursDrawerOpen.value = false isYearlyHoursDrawerOpen.value = false
} }

View File

@@ -205,6 +205,11 @@
Chauffeur Chauffeur
</label> </label>
</div> </div>
<WorkDaysHoursInput
v-if="requiresSchedule"
v-model="form.workDaysHours"
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
/>
</template> </template>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <button
@@ -234,6 +239,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type {Contract} from '~/services/dto/contract' 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 {Employee} from '~/services/dto/employee'
import type {Site} from '~/services/dto/site' import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts' import {listContracts} from '~/services/contracts'
@@ -292,7 +299,8 @@ const form = reactive({
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM', contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '', contractStartDate: '',
contractEndDate: '', contractEndDate: '',
isDriver: false isDriver: false,
workDaysHours: null as Record<number, number> | null
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -310,6 +318,21 @@ const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '') const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '') const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => isContractNature(form.contractNature)) 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 isContractStartDateValid = computed(() => form.contractStartDate !== '')
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature)) const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature)) const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
@@ -327,7 +350,8 @@ const isFormValid = computed(
: (isContractValid.value && : (isContractValid.value &&
isContractNatureValid.value && isContractNatureValid.value &&
isContractStartDateValid.value && isContractStartDateValid.value &&
isContractEndDateValid.value)) isContractEndDateValid.value &&
isScheduleValid.value))
) )
const showFirstNameError = computed( const showFirstNameError = computed(
@@ -478,7 +502,8 @@ const handleSubmit = async () => {
contractNature: form.contractNature, contractNature: form.contractNature,
contractStartDate: form.contractStartDate, contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null, contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver isDriverInput: form.isDriver,
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null
}) })
} }
@@ -490,6 +515,7 @@ const handleSubmit = async () => {
form.contractStartDate = new Date().toISOString().slice(0, 10) form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = '' form.contractEndDate = ''
form.isDriver = false form.isDriver = false
form.workDaysHours = null
editingEmployee.value = null editingEmployee.value = null
isDrawerOpen.value = false isDrawerOpen.value = false
await loadEmployees() await loadEmployees()
@@ -516,6 +542,12 @@ watch(showsContractEndDateComputed, (shows) => {
} }
}) })
watch(requiresSchedule, (required) => {
if (!required) {
form.workDaysHours = null
}
})
const openEdit = (employee: Employee) => { const openEdit = (employee: Employee) => {
editingEmployee.value = employee editingEmployee.value = employee
form.firstName = employee.firstName form.firstName = employee.firstName
@@ -534,6 +566,7 @@ const openCreate = () => {
form.contractStartDate = new Date().toISOString().slice(0, 10) form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = '' form.contractEndDate = ''
form.isDriver = false form.isDriver = false
form.workDaysHours = null
isDrawerOpen.value = true isDrawerOpen.value = true
} }

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager" :is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols" :day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday" :is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel" :contract-label="contractLabel"
:is-time-tracking="isTimeTracking" :is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking" :is-presence-tracking="isPresenceTracking"
@@ -67,6 +68,8 @@
:get-row-metrics="getRowMetrics" :get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel" :get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle" :get-row-absence-style="getRowAbsenceStyle"
:has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel"
:get-row-updated-at="getRowUpdatedAt" :get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
@@ -112,6 +115,7 @@
@delete="deleteAbsenceFromDrawer" @delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer" @cancel="closeAbsenceDrawer"
/> />
</div> </div>
</template> </template>
@@ -139,6 +143,7 @@ const {
isSubmitting, isSubmitting,
dayGridCols, dayGridCols,
isSelectedDateHoliday, isSelectedDateHoliday,
selectedHolidayLabel,
weekGridCols, weekGridCols,
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
@@ -177,6 +182,8 @@ const {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,

View File

@@ -0,0 +1,121 @@
<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-4xl font-bold text-primary-500">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>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<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>
</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

@@ -189,6 +189,20 @@
</p> </p>
</div> </div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.hasLeaveRecapAccess"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
</p>
</div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<button <button
type="submit" type="submit"
@@ -233,7 +247,8 @@ const form = reactive({
accessMode: 'admin' as 'admin' | 'self' | 'sites', accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '', employeeId: '' as number | '',
siteIds: [] as number[], siteIds: [] as number[],
isLocked: false isLocked: false,
hasLeaveRecapAccess: false
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -345,6 +360,7 @@ const resetForm = () => {
form.accessMode = 'admin' form.accessMode = 'admin'
form.siteIds = [] form.siteIds = []
form.isLocked = false form.isLocked = false
form.hasLeaveRecapAccess = false
editingUser.value = null editingUser.value = null
validationTouched.username = false validationTouched.username = false
validationTouched.password = false validationTouched.password = false
@@ -373,6 +389,7 @@ const openEdit = (user: User) => {
form.employeeId = user.employee?.id ?? '' form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked form.isLocked = user.isLocked
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
const siteRoles = userAccessById.value.get(user.id) ?? [] const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number') form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
@@ -427,7 +444,8 @@ const handleSubmit = async () => {
plainPassword: form.password.trim() ? form.password : undefined, plainPassword: form.password.trim() ? form.password : undefined,
roles, roles,
employeeId, employeeId,
isLocked: form.isLocked isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
}) })
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? [] const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -452,7 +470,8 @@ const handleSubmit = async () => {
plainPassword: form.password, plainPassword: form.password,
roles, roles,
employeeId, employeeId,
isLocked: form.isLocked isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
}) })
if (form.accessMode === 'sites' && form.siteIds.length > 0) { if (form.accessMode === 'sites' && form.siteIds.length > 0) {

View File

@@ -19,6 +19,7 @@ export type ContractHistoryItem = {
periodId?: number | null periodId?: number | null
suspensions?: ContractSuspension[] suspensions?: ContractSuspension[]
isDriver?: boolean isDriver?: boolean
workDaysHours?: Record<number, number> | null
} }
export type Employee = { export type Employee = {

View File

@@ -0,0 +1,12 @@
import type { Employee } from './employee'
export type Formation = {
id: number
startDate: string
endDate: string
comment: string | null
justificatifPath: string | null
justificatifName: string | null
createdAt: string
employee?: Employee
}

View File

@@ -0,0 +1,14 @@
export type LeaveRecapRow = {
employeeId: number
lastName: string
firstName: string
siteId: number | null
siteName: string | null
siteColor: string | null
contractName: string | null
cpN1Remaining: number
cpN: string
acquiredSaturdays: string
rtt: string
cutoffDate: string
}

View File

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

View File

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

View File

@@ -59,6 +59,7 @@ export type WeeklyWorkHourDailySummary = {
hasLunch?: boolean hasLunch?: boolean
hasDinner?: boolean hasDinner?: boolean
hasOvernight?: boolean hasOvernight?: boolean
virtualHolidayMinutes?: number
} }
export type WeeklyWorkHourRowSummary = { export type WeeklyWorkHourRowSummary = {
@@ -86,6 +87,7 @@ export type WeeklyWorkHourRowSummary = {
weeklyDinnerCount?: number weeklyDinnerCount?: number
weeklyOvernightCount?: number weeklyOvernightCount?: number
hasContractForWeek?: boolean hasContractForWeek?: boolean
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
} }
export type WeeklyWorkHourSummary = { export type WeeklyWorkHourSummary = {
@@ -106,6 +108,9 @@ export type WorkHourDayContextRow = {
creditedMinutes: number creditedMinutes: number
creditedPresenceUnits: number creditedPresenceUnits: number
isDriverContract?: boolean isDriverContract?: boolean
hasFormation?: boolean
formationLabel?: string | null
virtualHolidayMinutes?: number
} }
export type WorkHourDayContext = { export type WorkHourDayContext = {

View File

@@ -35,6 +35,7 @@ export const createEmployee = async (payload: {
contractStartDate?: string contractStartDate?: string
contractEndDate?: string | null contractEndDate?: string | null
isDriverInput?: boolean isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
}) => { }) => {
const api = useApi() const api = useApi()
return api.post<Employee>('/employees', { return api.post<Employee>('/employees', {
@@ -45,7 +46,8 @@ export const createEmployee = async (payload: {
contractNature: payload.contractNature, contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate, contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null, contractEndDate: payload.contractEndDate ?? null,
isDriverInput: payload.isDriverInput ?? false isDriverInput: payload.isDriverInput ?? false,
workDaysHoursInput: payload.workDaysHoursInput ?? null
}, { }, {
toastSuccessKey: 'success.employee.create', toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create' toastErrorKey: 'errors.employee.create'
@@ -66,6 +68,7 @@ export const updateEmployee = async (
contractComment?: string | null contractComment?: string | null
displayOrder?: number displayOrder?: number
isDriverInput?: boolean isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
} }
) => { ) => {
const api = useApi() const api = useApi()
@@ -97,6 +100,9 @@ export const updateEmployee = async (
if (payload.isDriverInput !== undefined) { if (payload.isDriverInput !== undefined) {
body.isDriverInput = payload.isDriverInput body.isDriverInput = payload.isDriverInput
} }
if (payload.workDaysHoursInput !== undefined) {
body.workDaysHoursInput = payload.workDaysHoursInput
}
return api.patch<Employee>(`/employees/${id}`, body, { return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update', toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,82 @@
import { $fetch } from 'ofetch'
import type { Formation } from './dto/formation'
import { extractItems } from '~/utils/api'
export const listFormations = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Formation[] | { 'hydra:member'?: Formation[] }>(
'/formations',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Formation>(data)
}
export const listFormationsByDateRange = async (from: string, to: string) => {
const api = useApi()
const data = await api.get<Formation[] | { 'hydra:member'?: Formation[] }>(
'/formations',
{
'startDate[before]': to,
'endDate[after]': from
},
{ toast: false }
)
return extractItems<Formation>(data)
}
export const createFormation = async (data: {
employeeId: number
startDate: string
endDate: string
comment?: string
}) => {
const api = useApi()
return api.post<Formation>('/formations', {
employee: `/api/employees/${data.employeeId}`,
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
}, {
toastSuccessKey: 'success.formation.create',
toastErrorKey: 'errors.formation.create'
})
}
export const updateFormation = async (id: number, data: {
startDate: string
endDate: string
comment?: string
}) => {
const api = useApi()
return api.patch<Formation>(`/formations/${id}`, {
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
}, {
toastSuccessKey: 'success.formation.update',
toastErrorKey: 'errors.formation.update'
})
}
export const deleteFormation = async (id: number) => {
const api = useApi()
return api.delete(`/formations/${id}`, {}, {
toastSuccessKey: 'success.formation.delete',
toastErrorKey: 'errors.formation.delete'
})
}
export const uploadFormationJustificatif = async (baseURL: string, id: number, file: File) => {
const formData = new FormData()
formData.append('file', file)
return $fetch(`${baseURL}/formations/${id}/justificatif`, {
method: 'POST',
body: formData,
credentials: 'include'
})
}
export const getFormationJustificatifUrl = (baseURL: string, id: number): string => {
return `${baseURL}/formations/${id}/justificatif`
}

View File

@@ -0,0 +1,12 @@
import type { LeaveRecapRow } from './dto/leave-recap'
import { extractItems } from '~/utils/api'
export const fetchLeaveRecap = async (): Promise<LeaveRecapRow[]> => {
const api = useApi()
const data = await api.get<LeaveRecapRow[] | { 'hydra:member'?: LeaveRecapRow[] }>(
'/leave-recap',
{},
{ toastErrorKey: 'errors.leaveRecap.load' }
)
return extractItems<LeaveRecapRow>(data)
}

View File

@@ -17,6 +17,7 @@ export const createUser = async (payload: {
roles: string[] roles: string[]
employeeId?: number | null employeeId?: number | null
isLocked?: boolean isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => { }) => {
const api = useApi() const api = useApi()
return api.post<User>( return api.post<User>(
@@ -26,7 +27,8 @@ export const createUser = async (payload: {
plainPassword: payload.plainPassword, plainPassword: payload.plainPassword,
roles: payload.roles, roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null, employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
}, },
{ {
toastSuccessKey: 'success.user.create', toastSuccessKey: 'success.user.create',
@@ -41,13 +43,15 @@ export const updateUser = async (id: number, payload: {
roles: string[] roles: string[]
employeeId?: number | null employeeId?: number | null
isLocked?: boolean isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => { }) => {
const api = useApi() const api = useApi()
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
username: payload.username, username: payload.username,
roles: payload.roles, roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null, employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
} }
if (payload.plainPassword) { if (payload.plainPassword) {

View File

@@ -19,3 +19,43 @@ export const requiresContractEndDate = (nature: ContractNature) => {
export const isContractNature = (value: string): value is ContractNature => { export const isContractNature = (value: string): value is ContractNature => {
return (CONTRACT_NATURES as readonly string[]).includes(value) return (CONTRACT_NATURES as readonly string[]).includes(value)
} }
/**
* Whether a contract + nature pair requires the per-day schedule (workDaysHours).
* Mirrors EmployeeContractPeriodValidator::assertWorkDaysHours on the backend.
*/
export const requiresWorkDaysHours = (
contract: { trackingMode?: string | null; weeklyHours?: number | null } | null | undefined,
nature: ContractNature
): boolean => {
if (!contract) return false
if (nature === 'INTERIM') return false
if (contract.trackingMode === 'PRESENCE') return false
if (contract.weeklyHours === 35 || contract.weeklyHours === 39) return false
return true
}
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
/**
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
* Returns null when the schedule is empty/unset.
*/
export const formatWorkDaysHoursSummary = (
workDaysHours: Record<number, number> | null | undefined
): string | null => {
if (!workDaysHours) return null
const entries = Object.entries(workDaysHours)
.map(([iso, minutes]) => [Number(iso), Number(minutes)] as const)
.filter(([iso, minutes]) => iso >= 1 && iso <= 5 && minutes > 0)
.sort(([a], [b]) => a - b)
if (entries.length === 0) return null
return entries
.map(([iso, minutes]) => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
const suffix = m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
return `${DAY_SHORT_LABELS[iso]} ${suffix}`
})
.join(', ')
}

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260416100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add work_days_hours JSON on employee_contract_periods (schedule for non-standard contracts) + seed Ewa and Nadia';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods ADD work_days_hours JSON DEFAULT NULL');
// Seed the two known 4h employees currently in production.
// Ewa DALEMBA: Lundi 2h + Jeudi 2h
// Nadia GARRAUD: Mardi 2h + Vendredi 2h
// Filter on last_name + first_name (not ids) to stay safe across environments,
// and only on periods without an already-set schedule to remain idempotent.
$this->addSql(
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"1\":120,\"4\":120}' "
.'FROM employees e '
.'WHERE ecp.employee_id = e.id '
."AND e.last_name = 'DALEMBA' AND e.first_name = 'Ewa' "
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
);
$this->addSql(
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"2\":120,\"5\":120}' "
.'FROM employees e '
.'WHERE ecp.employee_id = e.id '
."AND e.last_name = 'GARRAUD' AND e.first_name = 'Nadia' "
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP work_days_hours');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\EmployeeLeaveRecapProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/leave-recap',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: EmployeeLeaveRecapProvider::class,
),
],
)]
final class EmployeeLeaveRecap
{
public int $employeeId = 0;
public string $lastName = '';
public string $firstName = '';
public ?int $siteId = null;
public ?string $siteName = null;
public ?string $siteColor = null;
public ?string $contractName = null;
public int $contractSortKey = 99;
public float $cpN1Remaining = 0.0;
public string $cpN = '-';
public string $acquiredSaturdays = '-';
public string $rtt = '-';
public string $cutoffDate = '';
}

View File

@@ -0,0 +1,805 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Entity\Employee;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Leave\LeaveRecapRowBuilder;
use App\Service\Rtt\RttRecoveryComputationService;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:verification:snapshot',
description: 'Dump per-employee Markdown snapshot of RTT (monthly tab view) and leave balances, to serve as a regression baseline before business-rule refactors.'
)]
final class DumpVerificationSnapshotCommand extends Command
{
private const array MONTH_LABELS = [
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',
];
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeContractPeriodRepository $contractPeriodRepository,
private readonly EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private readonly LeaveRecapRowBuilder $leaveRecapRowBuilder,
private readonly RttRecoveryComputationService $rttRecoveryService,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly EmployeeRttPaymentRepository $rttPaymentRepository,
private readonly WorkHourRepository $workHourRepository,
private readonly AbsenceRepository $absenceRepository,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument(
'employee_ids',
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
'Employee IDs to snapshot (space-separated).'
)
->addOption(
'output-dir',
null,
InputOption::VALUE_OPTIONAL,
'Output directory (relative to project root, or absolute).',
'docs/verifications'
)
->addOption(
'rtt-year',
null,
InputOption::VALUE_OPTIONAL,
'RTT exercise year (ending year, e.g. 2026 = June 2025 → May 2026). Defaults to current exercise.'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ids = array_map('intval', $input->getArgument('employee_ids'));
$outputDirOpt = (string) $input->getOption('output-dir');
$outputDir = str_starts_with($outputDirOpt, '/')
? $outputDirOpt
: $this->projectDir.'/'.$outputDirOpt;
if (!is_dir($outputDir) && !mkdir($outputDir, 0o755, true) && !is_dir($outputDir)) {
$io->error('Could not create output directory: '.$outputDir);
return Command::FAILURE;
}
$today = new DateTimeImmutable('today');
$rttYearOpt = $input->getOption('rtt-year');
$rttYear = null !== $rttYearOpt && '' !== (string) $rttYearOpt
? (int) $rttYearOpt
: $this->resolveCurrentRttExerciseYear($today);
foreach ($ids as $id) {
$employee = $this->employeeRepository->find($id);
if (!$employee instanceof Employee) {
$io->warning(sprintf('Employee id=%d not found — skipped.', $id));
continue;
}
$markdown = $this->buildEmployeeDoc($employee, $rttYear, $today);
$slug = $this->slugify($employee->getFirstName().'-'.$employee->getLastName());
$filename = sprintf('%s/verification-rtt-conges-%s.md', $outputDir, $slug);
file_put_contents($filename, $markdown);
$io->success(sprintf('Wrote %s', $filename));
}
return Command::SUCCESS;
}
private function buildEmployeeDoc(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$parts = [];
$parts[] = $this->buildHeader($employee, $rttYear, $today);
$parts[] = $this->buildProfileSection($employee);
$parts[] = $this->buildLeaveSection($employee, $today);
$parts[] = $this->buildRecapRowSection($employee, $today);
$parts[] = $this->buildRttSection($employee, $rttYear, $today);
return implode("\n\n", $parts)."\n";
}
private function buildHeader(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$rttFrom = sprintf('01/06/%d', $rttYear - 1);
$rttTo = sprintf('31/05/%d', $rttYear);
return sprintf(
"# Vérification RTT & Congés — %s %s (id=%d)\n\n"
."Généré le %s. \n"
."Exercice RTT de référence : **%d** (%s → %s). \n"
."Pour les contrats Forfait, l'exercice de congés est l'année civile.",
$employee->getFirstName(),
$employee->getLastName(),
(int) $employee->getId(),
$today->format('Y-m-d'),
$rttYear,
$rttFrom,
$rttTo
);
}
private function buildProfileSection(Employee $employee): string
{
$contract = $employee->getContract();
$contractName = $contract?->getName() ?? '—';
$tracking = $contract?->getTrackingMode() ?? '—';
$weekly = $contract?->getWeeklyHours();
$weeklyLabel = null === $weekly ? '—' : ($weekly.'h');
$nature = $employee->getCurrentContractNature();
$lines = [];
$lines[] = '## 1. Profil';
$lines[] = '';
$lines[] = sprintf('- **ID** : %d', (int) $employee->getId());
$lines[] = sprintf('- **Nom / Prénom** : %s %s', $employee->getLastName(), $employee->getFirstName());
$lines[] = sprintf('- **Contrat actif** : %s — tracking `%s` — %s', $contractName, $tracking, $weeklyLabel);
$lines[] = sprintf('- **Nature** : %s', $nature);
$lines[] = '';
$lines[] = '### Périodes de contrat';
$lines[] = '';
$lines[] = '| Début | Fin | Contrat | Nature | Conducteur | Solde CP soldé | Commentaire |';
$lines[] = '|-------|-----|---------|--------|------------|----------------|-------------|';
$periods = $this->contractPeriodRepository->findBy(['employee' => $employee], ['startDate' => 'ASC']);
foreach ($periods as $period) {
$lines[] = sprintf(
'| %s | %s | %s | %s | %s | %s | %s |',
$period->getStartDate()->format('Y-m-d'),
null !== $period->getEndDate() ? $period->getEndDate()->format('Y-m-d') : '—',
$period->getContract()?->getName() ?? '—',
$period->getContractNature(),
$period->getIsDriver() ? 'oui' : 'non',
$period->isPaidLeaveSettled() ? 'oui' : 'non',
str_replace("\n", ' ', (string) ($period->getComment() ?? ''))
);
}
return implode("\n", $lines);
}
private function buildLeaveSection(Employee $employee, DateTimeImmutable $today): string
{
$lines = [];
$lines[] = '## 2. Congés';
$lines[] = '';
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
if (null === $yearSummary) {
$lines[] = '_Aucun résumé congés disponible (contrat non supporté : INTERIM ou autre)._';
return implode("\n", $lines);
}
// Forfait: recompute with paid leave days if any.
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
[$from, $to] = $isForfait
? [
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
]
: [
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
];
$lines[] = sprintf('**Règle applicable** : `%s`', $yearSummary['ruleCode']);
$lines[] = sprintf('**Période** : %s → %s', $from->format('Y-m-d'), $to->format('Y-m-d'));
$lines[] = '';
$lines[] = '### 2.1 Soldes (tels que calculés aujourd\'hui)';
$lines[] = '';
$lines[] = '| Indicateur | Valeur |';
$lines[] = '|------------|--------|';
$lines[] = sprintf('| Acquis (report N-1) | %s j |', $this->fmtDays($yearSummary['acquiredDays']));
$lines[] = sprintf('| Acquis samedis | %s j |', $this->fmtDays($yearSummary['acquiredSaturdays']));
$lines[] = sprintf('| En cours d\'acquisition | %s j |', $this->fmtDays($yearSummary['accruingDays']));
$lines[] = sprintf('| Pris | %s j |', $this->fmtDays($yearSummary['takenDays']));
$lines[] = sprintf('| Pris samedis | %s j |', $this->fmtDays($yearSummary['takenSaturdays']));
$lines[] = sprintf('| Restant (report N-1) | %s j |', $this->fmtDays($yearSummary['remainingDays']));
$lines[] = sprintf('| Restant samedis | %s j |', $this->fmtDays($yearSummary['remainingSaturdays']));
if ($isForfait) {
$lines[] = sprintf('| N-1 acquis | %s j |', $this->fmtDays($yearSummary['previousYearAcquiredDays']));
$lines[] = sprintf('| N-1 pris | %s j |', $this->fmtDays($yearSummary['previousYearTakenDays']));
$lines[] = sprintf('| N-1 restant | %s j |', $this->fmtDays($yearSummary['previousYearRemainingDays']));
$lines[] = sprintf('| N-1 payés | %s j |', $this->fmtDays($paidLeaveDays));
}
$lines[] = '';
$lines[] = '### 2.2 Absences de la période';
$lines[] = '';
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
if ([] === $absences) {
$lines[] = '_Aucune absence sur la période._';
} else {
$lines[] = '| Début | Fin | Demi-début | Demi-fin | Type | Commentaire |';
$lines[] = '|-------|-----|------------|----------|------|-------------|';
foreach ($absences as $absence) {
$lines[] = sprintf(
'| %s | %s | %s | %s | %s (%s) | %s |',
$absence->getStartDate()->format('Y-m-d'),
$absence->getEndDate()->format('Y-m-d'),
$absence->getStartHalf()->value,
$absence->getEndHalf()->value,
$absence->getType()?->getCode() ?? '—',
$absence->getType()?->getLabel() ?? '—',
str_replace("\n", ' ', (string) ($absence->getComment() ?? ''))
);
}
}
$lines[] = '';
$lines[] = '### 2.3 Jours de présence par mois (calcul provider)';
$lines[] = '';
$presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $leaveYear);
if ([] === $presenceDaysByMonth) {
$lines[] = '_Aucun jour de présence sur la période._';
} else {
$lines[] = '| Mois | Jours de présence |';
$lines[] = '|------|-------------------|';
ksort($presenceDaysByMonth);
foreach ($presenceDaysByMonth as $monthKey => $days) {
$lines[] = sprintf('| %s | %s |', $monthKey, $this->fmtDays($days));
}
}
return implode("\n", $lines);
}
/**
* @return array<string, float>
*/
private function computePresenceDaysByMonth(Employee $employee, int $leaveYear): array
{
// The provider method is private; we re-invoke `provide()` via its public path by
// calling computeYearSummary then reading $summary->presenceDaysByMonth.
// But computeYearSummary doesn't populate that. So we call the provider publicly
// through LeaveRecapRowBuilder? No — we just call the summary API resource directly
// via a small helper below.
//
// Workaround: reuse the provider's provide() would require security; instead we
// rebuild the map from WorkHour/absences here, mirroring the provider logic.
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
[$from, $to] = $isForfait
? [
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
]
: [
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
];
// Leave this aggregated figure available only for forfait (this is where the UI
// shows it). For non-forfait we skip — the UI doesn't show presence per month.
if (!$isForfait) {
return [];
}
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
$absenceDaysByMonth = [];
foreach ($absences as $absence) {
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
if ((int) $day->format('N') >= 6) {
continue;
}
$startDate = $absence->getStartDate()->format('Y-m-d');
$endDate = $absence->getEndDate()->format('Y-m-d');
$startHalf = $absence->getStartHalf()->value;
$endHalf = $absence->getEndHalf()->value;
$dateStr = $day->format('Y-m-d');
$isStart = $dateStr === $startDate;
$isEnd = $dateStr === $endDate;
if ($startDate === $endDate) {
$am = 'AM' === $startHalf;
$pm = 'PM' === $endHalf;
} elseif ($isStart) {
$am = 'AM' === $startHalf;
$pm = true;
} elseif ($isEnd) {
$am = true;
$pm = 'PM' === $endHalf;
} else {
$am = true;
$pm = true;
}
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($dayAmount <= 0.0) {
continue;
}
$mk = $day->format('Y-m');
$absenceDaysByMonth[$mk] = ($absenceDaysByMonth[$mk] ?? 0.0) + $dayAmount;
}
}
$result = [];
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthKey = $cursor->format('Y-m');
$monthStart = $cursor < $from ? $from : $cursor;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$businessDays = 0;
for ($day = $monthStart; $day <= $monthEnd; $day = $day->modify('+1 day')) {
if ((int) $day->format('N') <= 5) {
++$businessDays;
}
}
$weekend = $weekendWorkedDays[$monthKey] ?? 0.0;
$absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;
$presence = max(0.0, (float) $businessDays + $weekend - $absenced);
if ($presence > 0.0) {
$result[$monthKey] = $presence;
}
$cursor = $cursor->modify('first day of next month');
}
return $result;
}
private function buildRecapRowSection(Employee $employee, DateTimeImmutable $today): string
{
$row = $this->leaveRecapRowBuilder->build($employee);
$lines = [];
$lines[] = '## 3. Ligne écran « Récap. congés » (live, as of today)';
$lines[] = '';
$lines[] = '| CP N-1 restant | CP N | Samedis | RTT |';
$lines[] = '|----------------|------|---------|-----|';
$lines[] = sprintf(
'| %s | %s | %s | %s |',
(string) $row['cpN1Remaining'],
$row['cpN'],
$row['acquiredSaturdays'],
$row['rtt']
);
return implode("\n", $lines);
}
private function buildRttSection(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$lines = [];
$lines[] = '## 4. RTT — Onglet par mois';
$lines[] = '';
$contract = $employee->getContract();
$trackingMode = $contract?->getTrackingMode();
if (TrackingMode::PRESENCE->value === $trackingMode) {
$lines[] = '_Contrat en mode `PRESENCE` (Forfait) : aucun calcul RTT (heures supplémentaires)._';
$lines[] = '_Sur l\'UI, l\'onglet RTT ne contient aucune donnée exploitable._';
$lines[] = '';
$lines[] = '> Voir toutefois la section Congés pour les bonus week-end / jours fériés travaillés intégrés au stock Forfait (acquisDays).';
return implode("\n", $lines);
}
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($rttYear);
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $w): array => ['weekNumber' => (int) $w['weekNumber'], 'start' => $w['start'], 'end' => $w['end']],
$weeks
);
$currentExerciseYear = $this->resolveCurrentRttExerciseYear($today);
if ($rttYear > $currentExerciseYear) {
$limitDate = $periodFrom->modify('-1 day');
} else {
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
}
$recoveryByWeek = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
[$carry, $carryMonth] = $this->resolveCarry($employee, $rttYear);
$weekSummaries = $this->buildWeekSummaries($weekRanges, $recoveryByWeek, $periodFrom, $periodTo);
$weekSummaries = $this->distributeDeficits($weekSummaries, $carry);
// Aggregate payments per month.
$paymentsByMonth = [];
foreach ($this->rttPaymentRepository->findByEmployeeAndYear($employee, $rttYear) as $payment) {
$m = $payment->getMonth();
if (!isset($paymentsByMonth[$m])) {
$paymentsByMonth[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$paymentsByMonth[$m]['base25'] += $payment->getBase25Minutes();
$paymentsByMonth[$m]['bonus25'] += $payment->getBonus25Minutes();
$paymentsByMonth[$m]['base50'] += $payment->getBase50Minutes();
$paymentsByMonth[$m]['bonus50'] += $payment->getBonus50Minutes();
}
$lines[] = sprintf('**Limite des semaines prises en compte** : %s (exclut la semaine en cours incomplète)', $limitDate->format('Y-m-d'));
$lines[] = sprintf('**Report N-1 (carry)** : `Base 25%%=%s` / `+25%%=%s` / `Base 50%%=%s` / `+50%%=%s` — **Total %s**', $this->fmtMin($carry->base25Minutes), $this->fmtMin($carry->bonus25Minutes), $this->fmtMin($carry->base50Minutes), $this->fmtMin($carry->bonus50Minutes), $this->fmtMin($carry->totalMinutes));
$lines[] = '';
// Iterate the 12 exercise months (June → May).
$cumulativeCarry = [
'base25' => $carry->base25Minutes,
'bonus25' => $carry->bonus25Minutes,
'base50' => $carry->base50Minutes,
'bonus50' => $carry->bonus50Minutes,
];
$monthsInExercise = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5];
foreach ($monthsInExercise as $i => $month) {
$calYear = $month >= 6 ? $rttYear - 1 : $rttYear;
$label = self::MONTH_LABELS[$month].' '.$calYear;
$lines[] = '### '.$label;
$lines[] = '';
$lines[] = '| Ligne | Heure | Base 25% | +25% | Total 25% | Base 50% | +50% | Total 50% | Total |';
$lines[] = '|-------|-------|----------|------|-----------|----------|------|-----------|-------|';
// Report line only on the first month (June).
if (6 === $month) {
$lines[] = sprintf(
'| Report N-1 | | %s | %s | %s | %s | %s | %s | %s |',
$this->fmtMin($carry->base25Minutes),
$this->fmtMin($carry->bonus25Minutes),
$this->fmtMin($carry->base25Minutes + $carry->bonus25Minutes),
$this->fmtMin($carry->base50Minutes),
$this->fmtMin($carry->bonus50Minutes),
$this->fmtMin($carry->base50Minutes + $carry->bonus50Minutes),
$this->fmtMin($carry->totalMinutes),
);
}
$monthWeeks = array_values(array_filter($weekSummaries, static fn (EmployeeRttWeekSummary $w): bool => $w->month === $month));
$totals = ['over' => 0, 'b25' => 0, 's25' => 0, 'b50' => 0, 's50' => 0, 'total' => 0];
foreach ($monthWeeks as $w) {
$lines[] = sprintf(
'| Semaine %d (%s → %s) | %s | %s | %s | %s | %s | %s | %s | %s |',
$w->weekNumber,
$w->weekStart,
$w->weekEnd,
$this->fmtMin($w->overtimeMinutes),
$this->fmtMin($w->base25Minutes),
$this->fmtMin($w->bonus25Minutes),
$this->fmtMin($w->base25Minutes + $w->bonus25Minutes),
$this->fmtMin($w->base50Minutes),
$this->fmtMin($w->bonus50Minutes),
$this->fmtMin($w->base50Minutes + $w->bonus50Minutes),
$this->fmtMin($w->totalMinutes),
);
$totals['over'] += $w->overtimeMinutes;
$totals['b25'] += $w->base25Minutes;
$totals['s25'] += $w->bonus25Minutes;
$totals['b50'] += $w->base50Minutes;
$totals['s50'] += $w->bonus50Minutes;
$totals['total'] += $w->totalMinutes;
}
if ([] === $monthWeeks && 6 !== $month) {
$lines[] = '| _aucune semaine_ | | | | | | | | |';
}
$lines[] = sprintf(
'| **Total** | %s | %s | %s | %s | %s | %s | %s | **%s** |',
$this->fmtMin($totals['over']),
$this->fmtMin($totals['b25']),
$this->fmtMin($totals['s25']),
$this->fmtMin($totals['b25'] + $totals['s25']),
$this->fmtMin($totals['b50']),
$this->fmtMin($totals['s50']),
$this->fmtMin($totals['b50'] + $totals['s50']),
$this->fmtMin($totals['total']),
);
$p = $paymentsByMonth[$month] ?? null;
$hasPayment = null !== $p;
if ($hasPayment) {
$lines[] = sprintf(
'| Payé | | -%s | -%s | -%s | -%s | -%s | -%s | -%s |',
$this->fmtMin($p['base25']),
$this->fmtMin($p['bonus25']),
$this->fmtMin($p['base25'] + $p['bonus25']),
$this->fmtMin($p['base50']),
$this->fmtMin($p['bonus50']),
$this->fmtMin($p['base50'] + $p['bonus50']),
$this->fmtMin($p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50']),
);
} else {
$lines[] = '| Payé | | 0h | 0h | 0h | 0h | 0h | 0h | 0h |';
}
// Cumulative carry update — add month totals, subtract payments.
$cumulativeCarry['base25'] += $totals['b25'] - ($p['base25'] ?? 0);
$cumulativeCarry['bonus25'] += $totals['s25'] - ($p['bonus25'] ?? 0);
$cumulativeCarry['base50'] += $totals['b50'] - ($p['base50'] ?? 0);
$cumulativeCarry['bonus50'] += $totals['s50'] - ($p['bonus50'] ?? 0);
$cb25 = $cumulativeCarry['base25'];
$cs25 = $cumulativeCarry['bonus25'];
$cb50 = $cumulativeCarry['base50'];
$cs50 = $cumulativeCarry['bonus50'];
$cTotal = $cb25 + $cs25 + $cb50 + $cs50;
$lines[] = sprintf(
'| **Reste (cumul)** | | %s | %s | %s | %s | %s | %s | **%s** |',
$this->fmtMin($cb25),
$this->fmtMin($cs25),
$this->fmtMin($cb25 + $cs25),
$this->fmtMin($cb50),
$this->fmtMin($cs50),
$this->fmtMin($cb50 + $cs50),
$this->fmtMin($cTotal),
);
$lines[] = '';
}
// Final summary.
$currentYearRecovery = array_sum(array_map(static fn (EmployeeRttWeekSummary $w): int => $w->totalMinutes, $weekSummaries));
$totalPaid = 0;
foreach ($paymentsByMonth as $p) {
$totalPaid += $p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50'];
}
$available = $carry->totalMinutes + $currentYearRecovery - $totalPaid;
$lines[] = '### Solde RTT total (fin de période calculée)';
$lines[] = '';
$lines[] = sprintf('- Report N-1 (opening) : **%s**', $this->fmtMin($carry->totalMinutes));
$lines[] = sprintf('- Cumul récupération exercice : **%s**', $this->fmtMin($currentYearRecovery));
$lines[] = sprintf('- Total payé : **%s**', $this->fmtMin($totalPaid));
$lines[] = sprintf('- **Disponible** : **%s**', $this->fmtMin($available));
return implode("\n", $lines);
}
/**
* Mirrors EmployeeRttSummaryProvider::buildWeekSummaries().
*
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
*
* @return list<EmployeeRttWeekSummary>
*/
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
{
$result = [];
foreach ($weekRanges as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
$startMonth = (int) $effectiveStart->format('n');
$endMonth = (int) $effectiveEnd->format('n');
if ($startMonth === $endMonth) {
$result[] = new EmployeeRttWeekSummary(
month: $startMonth,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
continue;
}
$monthMinutes = [];
$monthWeekdays = [];
foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
if ((int) new DateTimeImmutable($date)->format('N') < 6) {
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
}
}
$totalWorked = array_sum($monthMinutes);
$totalWeekdays = array_sum($monthWeekdays);
foreach ([$startMonth, $endMonth] as $m) {
if ($totalWorked > 0) {
$ratio = ($monthMinutes[$m] ?? 0) / $totalWorked;
} elseif ($totalWeekdays > 0) {
$ratio = ($monthWeekdays[$m] ?? 0) / $totalWeekdays;
} else {
$ratio = 0.0;
}
$result[] = new EmployeeRttWeekSummary(
month: $m,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
base25Minutes: (int) round($detail->base25Minutes * $ratio),
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
);
}
}
return $result;
}
/**
* Mirrors the deficit-distribution step in EmployeeRttSummaryProvider::provide().
*
* @param list<EmployeeRttWeekSummary> $weeks
*
* @return list<EmployeeRttWeekSummary>
*/
private function distributeDeficits(array $weeks, WeekRecoveryDetail $carry): array
{
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
continue;
}
$deficit = -$week->totalMinutes;
$from50 = min($deficit, max(0, $cumulative50));
$from25 = $deficit - $from50;
$cumulative50 -= $from50;
$cumulative25 -= $from25;
$weeks[$i] = new EmployeeRttWeekSummary(
month: $week->month,
weekNumber: $week->weekNumber,
weekStart: $week->weekStart,
weekEnd: $week->weekEnd,
overtimeMinutes: $week->overtimeMinutes,
base25Minutes: $from25 > 0 ? -$from25 : 0,
bonus25Minutes: 0,
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
);
}
return $weeks;
}
/**
* @return array{WeekRecoveryDetail, int}
*/
private function resolveCarry(Employee $employee, int $year): array
{
$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(),
),
$balance->getMonth(),
];
}
return [$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), 5];
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function resolveCurrentRttExerciseYear(DateTimeImmutable $today): int
{
$y = (int) $today->format('Y');
$m = (int) $today->format('n');
return $m >= 6 ? $y + 1 : $y;
}
private function fmtMin(int $minutes): string
{
if (0 === $minutes) {
return '0h';
}
$sign = $minutes < 0 ? '-' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? sprintf('%s%dh', $sign, $h) : sprintf('%s%dh%02d', $sign, $h, $m);
}
private function fmtDays(float $value): string
{
if (abs($value - round($value)) < 0.001) {
return (string) (int) round($value);
}
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function slugify(string $value): string
{
$value = trim($value);
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if (false === $ascii) {
$ascii = $value;
}
$ascii = strtolower($ascii);
$ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii) ?? $ascii;
return trim($ascii, '-');
}
}

View File

@@ -29,5 +29,10 @@ final class ContractHistoryItem
public array $suspensions = [], public array $suspensions = [],
#[Groups(['employee:read'])] #[Groups(['employee:read'])]
public bool $isDriver = false, public bool $isDriver = false,
/**
* @var null|array<int, int> iso-day → minutes
*/
#[Groups(['employee:read'])]
public ?array $workDaysHours = null,
) {} ) {}
} }

View File

@@ -17,8 +17,17 @@ final class DayContextRow
public int $creditedMinutes = 0, public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0, public float $creditedPresenceUnits = 0.0,
public bool $isDriverContract = false, public bool $isDriverContract = false,
public bool $hasFormation = false,
public ?string $formationLabel = null,
public int $virtualHolidayMinutes = 0,
) {} ) {}
public function setFormation(string $label): void
{
$this->hasFormation = true;
$this->formationLabel = $label;
}
public function addAbsence( public function addAbsence(
?string $label, ?string $label,
?string $color, ?string $color,
@@ -64,7 +73,11 @@ final class DayContextRow
* absentMorning:bool, * absentMorning:bool,
* absentAfternoon:bool, * absentAfternoon:bool,
* creditedMinutes:int, * creditedMinutes:int,
* creditedPresenceUnits:float * creditedPresenceUnits:float,
* isDriverContract:bool,
* hasFormation:bool,
* formationLabel:?string,
* virtualHolidayMinutes:int
* } * }
*/ */
public function toArray(): array public function toArray(): array
@@ -80,6 +93,9 @@ final class DayContextRow
'creditedMinutes' => $this->creditedMinutes, 'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits, 'creditedPresenceUnits' => $this->creditedPresenceUnits,
'isDriverContract' => $this->isDriverContract, 'isDriverContract' => $this->isDriverContract,
'hasFormation' => $this->hasFormation,
'formationLabel' => $this->formationLabel,
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
]; ];
} }

View File

@@ -21,5 +21,6 @@ final class WeeklyDaySummary
public bool $hasLunch = false, public bool $hasLunch = false,
public bool $hasDinner = false, public bool $hasDinner = false,
public bool $hasOvernight = false, public bool $hasOvernight = false,
public int $virtualHolidayMinutes = 0,
) {} ) {}
} }

View File

@@ -34,5 +34,6 @@ final class WeeklySummaryRow
public int $weeklyDinnerCount = 0, public int $weeklyDinnerCount = 0,
public int $weeklyOvernightCount = 0, public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true, public bool $hasContractForWeek = true,
public ?string $contractNature = null,
) {} ) {}
} }

View File

@@ -92,6 +92,12 @@ class Employee
#[Groups(['employee:write'])] #[Groups(['employee:write'])]
private ?bool $isDriverInput = null; private ?bool $isDriverInput = null;
/**
* @var null|array<int, int> iso-day → minutes, write-only (propagated to EmployeeContractPeriod)
*/
#[Groups(['employee:write'])]
private ?array $workDaysHoursInput = null;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
@@ -261,6 +267,34 @@ class Employee
return $this; return $this;
} }
/**
* @return null|array<int, int>
*/
public function getWorkDaysHoursInput(): ?array
{
return $this->workDaysHoursInput;
}
/**
* @param null|array<int|string, mixed> $workDaysHoursInput
*/
public function setWorkDaysHoursInput(?array $workDaysHoursInput): self
{
if (null === $workDaysHoursInput) {
$this->workDaysHoursInput = null;
return $this;
}
$normalized = [];
foreach ($workDaysHoursInput as $key => $value) {
$normalized[(int) $key] = (int) $value;
}
$this->workDaysHoursInput = $normalized;
return $this;
}
#[Groups(['employee:read'])] #[Groups(['employee:read'])]
public function getHasActiveContract(): bool public function getHasActiveContract(): bool
{ {
@@ -358,6 +392,7 @@ class Employee
periodId: $period->getId(), periodId: $period->getId(),
suspensions: $suspensionData, suspensions: $suspensionData,
isDriver: $period->getIsDriver(), isDriver: $period->getIsDriver(),
workDaysHours: $period->getWorkDaysHours(),
); );
}, },
$periods $periods

View File

@@ -45,6 +45,16 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'boolean', options: ['default' => false])] #[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $paidLeaveSettled = false; private bool $paidLeaveSettled = false;
/**
* Map ISO weekday (1=Mon..5=Fri) → minutes worked that day.
* Required for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM)
* so that férié credit and absence credit respect the actual schedule.
*
* @var null|array<int, int>
*/
#[ORM\Column(type: 'json', nullable: true)]
private ?array $workDaysHours = null;
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null; private ?string $comment = null;
@@ -176,6 +186,24 @@ class EmployeeContractPeriod
return $this; return $this;
} }
/**
* @return null|array<int, int>
*/
public function getWorkDaysHours(): ?array
{
return $this->workDaysHours;
}
/**
* @param null|array<int, int> $workDaysHours
*/
public function setWorkDaysHours(?array $workDaysHours): self
{
$this->workDaysHours = $workDaysHours;
return $this;
}
/** /**
* @return Collection<int, ContractSuspension> * @return Collection<int, ContractSuspension>
*/ */

192
src/Entity/Formation.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\FormationRepository;
use App\State\FormationDeleteProcessor;
use App\State\FormationJustificatifDownloadProvider;
use App\State\FormationJustificatifUploadProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
security: "is_granted('ROLE_ADMIN')"
),
new GetCollection(
security: "is_granted('ROLE_ADMIN')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
processor: FormationDeleteProcessor::class,
),
new Post(
uriTemplate: '/formations/{id}/justificatif',
security: "is_granted('ROLE_ADMIN')",
deserialize: false,
processor: FormationJustificatifUploadProcessor::class,
),
new Get(
uriTemplate: '/formations/{id}/justificatif',
security: "is_granted('ROLE_ADMIN')",
provider: FormationJustificatifDownloadProvider::class,
),
],
normalizationContext: [
'groups' => ['formation:read', 'employee:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'groups' => ['formation:write'],
'datetime_format' => 'Y-m-d',
],
order: ['startDate' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: FormationRepository::class)]
#[ORM\Table(name: 'formations')]
class Formation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['formation:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['formation:read', 'formation:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['formation:read', 'formation:write'])]
private ?DateTimeImmutable $startDate = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['formation:read', 'formation:write'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['formation:read', 'formation:write'])]
private ?string $comment = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['formation:read'])]
private ?string $justificatifPath = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['formation:read'])]
private ?string $justificatifName = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['formation:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(?DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getJustificatifPath(): ?string
{
return $this->justificatifPath;
}
public function setJustificatifPath(?string $justificatifPath): self
{
$this->justificatifPath = $justificatifPath;
return $this;
}
public function getJustificatifName(): ?string
{
return $this->justificatifName;
}
public function setJustificatifName(?string $justificatifName): self
{
$this->justificatifName = $justificatifName;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -90,6 +90,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[SerializedName('isLocked')] #[SerializedName('isLocked')]
private bool $isLocked = false; private bool $isLocked = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['user:write'])]
#[SerializedName('hasLeaveRecapAccess')]
private bool $hasLeaveRecapAccess = false;
/** /**
* @var Collection<int, UserSiteRole> * @var Collection<int, UserSiteRole>
*/ */
@@ -224,6 +229,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
#[Groups(['user:read'])]
#[SerializedName('hasLeaveRecapAccess')]
public function hasLeaveRecapAccess(): bool
{
return $this->hasLeaveRecapAccess;
}
public function setHasLeaveRecapAccess(bool $hasLeaveRecapAccess): self
{
$this->hasLeaveRecapAccess = $hasLeaveRecapAccess;
return $this;
}
#[Groups(['user:read'])] #[Groups(['user:read'])]
public function getIsDriver(): bool public function getIsDriver(): bool
{ {

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\Formation;
use DateTimeImmutable;
interface FormationReadRepositoryInterface
{
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array;
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\Formation;
use App\Repository\Contract\FormationReadRepositoryInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Formation>
*/
final class FormationRepository extends ServiceEntityRepository implements FormationReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Formation::class);
}
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('f')
->leftJoin('f.employee', 'e')
->addSelect('e')
->andWhere('f.startDate <= :date')
->andWhere('f.endDate >= :date')
->andWhere('f.employee IN (:employees)')
->setParameter('date', $date)
->setParameter('employees', $employees)
;
// @var list<Formation>
return $qb->getQuery()->getResult();
}
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('f')
->leftJoin('f.employee', 'e')
->addSelect('e')
->andWhere('f.startDate <= :to')
->andWhere('f.endDate >= :from')
->andWhere('f.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
// @var list<Formation>
return $qb->getQuery()->getResult();
}
}

View File

@@ -9,6 +9,9 @@ use DateTimeImmutable;
final readonly class EmployeeContractChangeRequest final readonly class EmployeeContractChangeRequest
{ {
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function __construct( public function __construct(
public ?ContractNature $contractNature, public ?ContractNature $contractNature,
public ?DateTimeImmutable $contractStartDate, public ?DateTimeImmutable $contractStartDate,
@@ -16,6 +19,7 @@ final readonly class EmployeeContractChangeRequest
public ?bool $contractPaidLeaveSettled, public ?bool $contractPaidLeaveSettled,
public ?string $contractComment, public ?string $contractComment,
public ?bool $isDriver = null, public ?bool $isDriver = null,
public ?array $workDaysHours = null,
) {} ) {}
public function hasPeriodChangeRequest(): bool public function hasPeriodChangeRequest(): bool

View File

@@ -20,6 +20,7 @@ final class EmployeeContractChangeRequestFactory
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(), contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(), contractComment: $employee->getContractComment(),
isDriver: $employee->getIsDriverInput(), isDriver: $employee->getIsDriverInput(),
workDaysHours: $employee->getWorkDaysHoursInput(),
); );
} }

View File

@@ -12,6 +12,9 @@ use DateTimeImmutable;
final class EmployeeContractPeriodBuilder final class EmployeeContractPeriodBuilder
{ {
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function build( public function build(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -19,6 +22,7 @@ final class EmployeeContractPeriodBuilder
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
): EmployeeContractPeriod { ): EmployeeContractPeriod {
return new EmployeeContractPeriod() return new EmployeeContractPeriod()
->setEmployee($employee) ->setEmployee($employee)
@@ -27,6 +31,7 @@ final class EmployeeContractPeriodBuilder
->setEndDate($endDate) ->setEndDate($endDate)
->setContractNature($nature) ->setContractNature($nature)
->setIsDriver($isDriver) ->setIsDriver($isDriver)
->setWorkDaysHours($workDaysHours)
; ;
} }
} }

View File

@@ -29,15 +29,17 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
): void { ): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature); $this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate); $covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) { if (null !== $covered) {
return; return;
} }
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver); $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$this->entityManager->flush(); $this->entityManager->flush();
} }
@@ -75,8 +77,10 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
ContractNature $nature, ContractNature $nature,
?EmployeeContractPeriod $todayPeriod, ?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
): void { ): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature); $this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
if (null !== $todayPeriod) { if (null !== $todayPeriod) {
$this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod); $this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
@@ -86,10 +90,13 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
} }
} }
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver); $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$this->entityManager->flush(); $this->entityManager->flush();
} }
/**
* @param null|array<int, int> $workDaysHours
*/
private function persistNewPeriod( private function persistNewPeriod(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -97,8 +104,9 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
): void { ): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver); $period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
$this->entityManager->persist($period); $this->entityManager->persist($period);
} }
} }

View File

@@ -12,6 +12,9 @@ use DateTimeImmutable;
interface EmployeeContractPeriodManagerInterface interface EmployeeContractPeriodManagerInterface
{ {
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function ensureContractPeriodExists( public function ensureContractPeriodExists(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -19,6 +22,7 @@ interface EmployeeContractPeriodManagerInterface
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
): void; ): void;
public function closeCurrentPeriod( public function closeCurrentPeriod(
@@ -29,6 +33,9 @@ interface EmployeeContractPeriodManagerInterface
bool $isAlreadyEnded = false bool $isAlreadyEnded = false
): void; ): void;
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function createNextPeriod( public function createNextPeriod(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -37,5 +44,6 @@ interface EmployeeContractPeriodManagerInterface
ContractNature $nature, ContractNature $nature,
?EmployeeContractPeriod $todayPeriod, ?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
): void; ): void;
} }

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Service\Contracts; namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\EmployeeContractPeriod; use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature; use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -60,4 +62,63 @@ final class EmployeeContractPeriodValidator
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.'); throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
} }
} }
/**
* Validates the per-period work schedule (`workDaysHours`) against the contract.
*
* Mandatory for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM,
* non-Forfait). Forbidden on standard/forfait/interim contracts (ambiguity).
* When provided, sum of minutes MUST equal weeklyHours × 60.
*
* @param null|array<int, int> $workDaysHours
*/
public function assertWorkDaysHours(?Contract $contract, ContractNature $nature, ?array $workDaysHours): void
{
if (null === $contract) {
return;
}
$trackingMode = $contract->getTrackingMode();
$weeklyHours = $contract->getWeeklyHours();
$isStandard = 35 === $weeklyHours || 39 === $weeklyHours;
$isForfait = TrackingMode::PRESENCE->value === $trackingMode;
$isInterim = ContractNature::INTERIM === $nature;
if ($isForfait || $isInterim || $isStandard) {
if (null !== $workDaysHours && [] !== $workDaysHours) {
throw new UnprocessableEntityHttpException('workDaysHours must not be provided for Forfait, Interim or 35h/39h contracts.');
}
return;
}
if (null === $workDaysHours || [] === $workDaysHours) {
throw new UnprocessableEntityHttpException('workDaysHours is required for non-standard contracts.');
}
$totalMinutes = 0;
foreach ($workDaysHours as $isoDay => $minutes) {
if (!is_int($isoDay) && !(is_string($isoDay) && ctype_digit($isoDay))) {
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri) as integers.');
}
$iso = (int) $isoDay;
if ($iso < 1 || $iso > 5) {
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri).');
}
if (!is_int($minutes) || $minutes < 0) {
throw new UnprocessableEntityHttpException('workDaysHours values must be non-negative integer minutes.');
}
$totalMinutes += $minutes;
}
$expectedMinutes = ($weeklyHours ?? 0) * 60;
if ($totalMinutes !== $expectedMinutes) {
throw new UnprocessableEntityHttpException(sprintf(
'workDaysHours total must equal contract weekly hours: got %d min, expected %d min.',
$totalMinutes,
$expectedMinutes
));
}
}
} }

View File

@@ -23,6 +23,20 @@ readonly class EmployeeContractResolver
return $period?->getContract(); return $period?->getContract();
} }
/**
* @return null|array<int, int> workDaysHours (iso day → minutes) for the contract period active on $date
*/
public function resolveWorkDaysMinutesForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?array
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
$raw = $period?->getWorkDaysHours();
if (null === $raw) {
return null;
}
return $this->normalizeWorkDaysMinutes($raw);
}
public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool
{ {
$period = $this->periodRepository->findOneCoveringDate($employee, $date); $period = $this->periodRepository->findOneCoveringDate($employee, $date);
@@ -84,6 +98,57 @@ readonly class EmployeeContractResolver
return $period?->getContractNatureEnum() ?? ContractNature::CDI; return $period?->getContractNatureEnum() ?? ContractNature::CDI;
} }
/**
* @param list<Employee> $employees
* @param list<string> $days
*
* @return array<int, array<string, null|array<int, int>>>
*/
public function resolveWorkDaysMinutesForEmployeesAndDays(array $employees, array $days): array
{
$resolved = [];
if ([] === $employees || [] === $days) {
return $resolved;
}
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
$resolved[$employeeId][$day] = null;
}
}
$from = new DateTimeImmutable(min($days));
$to = new DateTimeImmutable(max($days));
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
foreach ($periods as $period) {
$employeeId = $period->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$raw = $period->getWorkDaysHours();
if (null === $raw) {
continue;
}
$normalized = $this->normalizeWorkDaysMinutes($raw);
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $normalized;
}
}
return $resolved;
}
/** /**
* @param list<Employee> $employees * @param list<Employee> $employees
* @param list<string> $days * @param list<string> $days
@@ -177,4 +242,23 @@ readonly class EmployeeContractResolver
return $resolved; return $resolved;
} }
/**
* @param array<int|string, mixed> $raw
*
* @return array<int, int>
*/
private function normalizeWorkDaysMinutes(array $raw): array
{
$result = [];
foreach ($raw as $key => $value) {
$iso = (int) $key;
if ($iso < 1 || $iso > 5) {
continue;
}
$result[$iso] = (int) $value;
}
return $result;
}
} }

View File

@@ -71,7 +71,10 @@ final readonly class LeaveBalanceComputationService
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year); $fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
$totalBusinessDays = $this->countBusinessDays($from, $to); // Business days for forfait must use the RAW holiday list (excluded holidays
// like "Lundi de Pentecôte" / journée de solidarité still count as non-working
// days for the 218-day legal target).
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS); $baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays; $acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
@@ -406,6 +409,29 @@ final readonly class LeaveBalanceComputationService
return $map; return $map;
} }
/**
* @return array<string, string>
*/
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/** /**
* @param list<Absence> $absences * @param list<Absence> $absences
* *

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use Throwable;
final readonly class LeaveRecapRowBuilder
{
public function __construct(
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private WorkHourRepository $workHourRepository,
) {}
/**
* Builds a leave recap row for one employee.
*
* - $asOfDate = null → live behavior (identical to legacy PDF export): accrual capped at
* previous month end, ALL booked absences counted (incl. future ones), RTT uses today
* - $asOfDate = non-null → frozen snapshot at that date: accrual capped at the previous
* month end before asOfDate, absences after asOfDate excluded, RTT uses asOfDate
*
* @return array{
* lastName: string,
* firstName: string,
* contractName: ?string,
* cpN1Remaining: float|string,
* cpN: string,
* acquiredSaturdays: string,
* rtt: string
* }
*/
public function build(Employee $employee, ?DateTimeImmutable $asOfDate = null): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$rttReference = $asOfDate ?? new DateTimeImmutable('today');
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, 0.0, $asOfDate);
if (null !== $yearSummary) {
if ($isForfait) {
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays, $asOfDate);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $rttReference));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $reference): int
{
$month = (int) $reference->format('n');
$year = (int) $reference->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $reference->format('N');
$limitDate = 7 === $isoDay ? $reference : $reference->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $reference->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $reference);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -17,11 +17,22 @@ use Throwable;
final readonly class PublicHolidayService implements PublicHolidayServiceInterface final readonly class PublicHolidayService implements PublicHolidayServiceInterface
{ {
/**
* @var list<string>
*/
private array $excludedLabels;
public function __construct( public function __construct(
private HttpClientInterface $client, private HttpClientInterface $client,
private string $holidayUrl, private string $holidayUrl,
private CacheInterface $cache, private CacheInterface $cache,
) {} string $excludedLabels = '',
) {
$this->excludedLabels = array_values(array_filter(
array_map('trim', explode(',', $excludedLabels)),
static fn (string $label): bool => '' !== $label,
));
}
/** /**
* @throws TransportExceptionInterface * @throws TransportExceptionInterface
@@ -35,7 +46,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$zone = strtolower(trim($zone)); $zone = strtolower(trim($zone));
$key = "public_holidays_{$zone}_all"; $key = "public_holidays_{$zone}_all";
return $this->cache->get($key, function (ItemInterface $item) use ($zone): array { $holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$item->expiresAfter(30 * 86400); $item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}.json"; $url = $this->holidayUrl."{$zone}.json";
@@ -56,6 +67,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true); return json_decode($response->getContent(), true);
}); });
return $this->applyExclusions($holidays);
} }
/** /**
@@ -65,6 +78,19 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
* @throws ClientExceptionInterface * @throws ClientExceptionInterface
*/ */
public function getHolidaysDayByYears(string $zone, string $years): array public function getHolidaysDayByYears(string $zone, string $years): array
{
return $this->applyExclusions($this->fetchHolidaysByYears($zone, $years));
}
public function getRawHolidaysDayByYears(string $zone, string $years): array
{
return $this->fetchHolidaysByYears($zone, $years);
}
/**
* @return array<string, string>
*/
private function fetchHolidaysByYears(string $zone, string $years): array
{ {
$zone = strtolower(trim($zone)); $zone = strtolower(trim($zone));
$years = trim($years); $years = trim($years);
@@ -89,4 +115,21 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true); return json_decode($response->getContent(), true);
}); });
} }
/**
* @param array<string, string> $holidays
*
* @return array<string, string>
*/
private function applyExclusions(array $holidays): array
{
if ([] === $this->excludedLabels) {
return $holidays;
}
return array_filter(
$holidays,
fn (string $label): bool => !in_array($label, $this->excludedLabels, true),
);
}
} }

View File

@@ -9,4 +9,11 @@ interface PublicHolidayServiceInterface
public function getHolidaysDay(string $zone): array; public function getHolidaysDay(string $zone): array;
public function getHolidaysDayByYears(string $zone, string $years): array; public function getHolidaysDayByYears(string $zone, string $years): array;
/**
* Same as getHolidaysDayByYears but WITHOUT the configured exclusions applied.
* Used for legal/contractual computations (e.g. forfait 218 days) where excluded
* holidays (journée de solidarité) must still count as non-working days.
*/
public function getRawHolidaysDayByYears(string $zone, string $years): array;
} }

View File

@@ -16,6 +16,8 @@ use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable; use DateTimeImmutable;
@@ -29,6 +31,8 @@ final readonly class RttRecoveryComputationService
private AbsenceSegmentsResolver $absenceSegmentsResolver, private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy, private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
string $rttStartDate = '', string $rttStartDate = '',
) { ) {
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null; $this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
@@ -126,6 +130,7 @@ final readonly class RttRecoveryComputationService
$contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days); $contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days); $naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days);
$workDaysByDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays([$employee], $days);
$employeeId = (int) $employee->getId(); $employeeId = (int) $employee->getId();
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]);
@@ -137,7 +142,8 @@ final readonly class RttRecoveryComputationService
$metricsByDate[$dateKey] = $this->computeMetrics($workHour); $metricsByDate[$dateKey] = $this->computeMetrics($workHour);
} }
$creditedByDate = []; $creditedByDate = [];
$hasAbsenceByDate = [];
foreach ($absences as $absence) { foreach ($absences as $absence) {
$start = $absence->getStartDate()->format('Y-m-d'); $start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d'); $end = $absence->getEndDate()->format('Y-m-d');
@@ -148,7 +154,10 @@ final readonly class RttRecoveryComputationService
} }
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date); [$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0) if ($absentMorning || $absentAfternoon) {
$hasAbsenceByDate[$date] = true;
}
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon); + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
} }
} }
@@ -188,14 +197,22 @@ final readonly class RttRecoveryComputationService
$dailyWorkedMinutes = []; $dailyWorkedMinutes = [];
$employeeContractsByDate = []; $employeeContractsByDate = [];
foreach ($weekDays as $date) { foreach ($weekDays as $date) {
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null; $contractAtDate = $contractsByDate[$employeeId][$date] ?? null;
$employeeContractsByDate[$date] = $contractAtDate;
if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) { if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
continue; continue;
} }
$metrics = $metricsByDate[$date] ?? new WorkMetrics(); $metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0); $metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes; $effectiveMinutes = $this->holidayVirtualHoursResolver->resolveEffectiveDailyMinutes(
$dailyWorkedMinutes[$date] = $metrics->totalMinutes; $contractAtDate,
new DateTimeImmutable($date),
$metrics->totalMinutes,
$hasAbsenceByDate[$date] ?? false,
$workDaysByDate[$employeeId][$date] ?? null,
);
$weeklyTotalMinutes += $effectiveMinutes;
$dailyWorkedMinutes[$date] = $effectiveMinutes;
} }
if ([] === $weekDays) { if ([] === $weekDays) {
@@ -437,16 +454,6 @@ final readonly class RttRecoveryComputationService
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{ {
if ($isoWeekDay >= 6 || null === $weeklyHours || $weeklyHours <= 0) { return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
return 0;
}
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
if (35 === $weeklyHours) {
return 7 * 60;
}
return (int) round(($weeklyHours * 60) / 5);
} }
} }

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
final readonly class DailyReferenceMinutesResolver
{
/**
* Returns the contractual expected minutes for a given weekday.
*
* - Saturday/Sunday: always 0
* - If $workDaysMinutes is provided (per-employee schedule on `EmployeeContractPeriod`),
* it takes precedence: returns the minutes for that iso day if scheduled, 0 otherwise.
* - Else 35h: 7h every weekday
* - Else 39h: 8h Mon-Thu, 7h Fri
* - Else other positive values: weeklyHours/5 per weekday
* - Else null/<=0 weeklyHours: 0
*
* @param int $isoWeekDay 1 = Monday ... 7 = Sunday
* @param null|array<int, int> $workDaysMinutes iso-day → minutes (1=Mon, ..., 5=Fri)
*/
public function resolve(?int $weeklyHours, int $isoWeekDay, ?array $workDaysMinutes = null): int
{
if ($isoWeekDay >= 6) {
return 0;
}
if (null !== $workDaysMinutes) {
return (int) ($workDaysMinutes[$isoWeekDay] ?? 0);
}
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
if (35 === $weeklyHours) {
return 7 * 60;
}
return (int) round(($weeklyHours * 60) / 5);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Throwable;
/**
* Applies the business rule: a public holiday from Monday to Friday, for any
* non-Forfait contract, credits the contractually expected daily hours.
* If the employee has also entered hours that day, the effective total is the
* max between entered minutes and the contractual reference.
*/
final readonly class HolidayVirtualHoursResolver
{
public function __construct(
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private PublicHolidayServiceInterface $publicHolidayService,
private EmployeeContractResolver $contractResolver,
) {}
/**
* Returns the effective daily minutes to count for RTT and weekly total
* aggregation, applying the holiday credit when applicable.
*
* If an absence is declared on the day, the absence dictates the credit
* (via WorkedHoursCreditPolicy) and the holiday virtual rule is bypassed —
* $actualMinutes already includes the absence credit.
*
* @param null|array<int, int> $workDaysMinutes per-employee schedule (iso day → minutes)
*/
public function resolveEffectiveDailyMinutes(
?Contract $contract,
DateTimeImmutable $date,
int $actualMinutes,
bool $hasAbsenceOnDate = false,
?array $workDaysMinutes = null,
): int {
$reference = $this->resolveVirtualCredit($contract, $date, $hasAbsenceOnDate, $workDaysMinutes);
if (0 === $reference) {
return $actualMinutes;
}
return max($actualMinutes, $reference);
}
/**
* Returns the virtual credit (reference minutes) alone — 0 if the rule
* does not apply (weekend, non-holiday, Forfait contract, absence declared,
* or employee schedule indicates a non-working day). Used by the frontend.
*
* @param null|array<int, int> $workDaysMinutes per-employee schedule (iso day → minutes)
*/
public function resolveVirtualCredit(
?Contract $contract,
DateTimeImmutable $date,
bool $hasAbsenceOnDate = false,
?array $workDaysMinutes = null,
): int {
if ($hasAbsenceOnDate) {
return 0;
}
$isoDay = (int) $date->format('N');
if ($isoDay >= 6) {
return 0;
}
if (null === $contract) {
return 0;
}
if (TrackingMode::PRESENCE->value === $contract->getTrackingMode()) {
return 0;
}
if (!$this->isPublicHoliday($date)) {
return 0;
}
return $this->dailyReferenceResolver->resolve($contract->getWeeklyHours(), $isoDay, $workDaysMinutes);
}
/**
* Convenience helper: resolves the schedule internally for a single employee/date.
* Used by callers that have an Employee in hand (e.g. DayContext, LeaveRecap).
*/
public function resolveVirtualCreditForEmployee(
Employee $employee,
DateTimeImmutable $date,
bool $hasAbsenceOnDate = false,
): int {
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $date);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $date);
return $this->resolveVirtualCredit($contract, $date, $hasAbsenceOnDate, $workDaysMinutes);
}
private function isPublicHoliday(DateTimeImmutable $date): bool
{
try {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', $date->format('Y'));
} catch (Throwable) {
return false;
}
return isset($holidays[$date->format('Y-m-d')]);
}
}

View File

@@ -14,6 +14,7 @@ final readonly class WorkedHoursCreditPolicy
{ {
public function __construct( public function __construct(
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
) {} ) {}
/** /**
@@ -38,9 +39,11 @@ final readonly class WorkedHoursCreditPolicy
return 0; return 0;
} }
$weekday = (int) $workDate->format('N'); $weekday = (int) $workDate->format('N');
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback). $workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday); // Quand un planning est configuré sur la période (contrats non-standards),
// il prime : jour non programmé = 0 crédit, sinon on utilise les minutes prévues.
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday, $workDaysMinutes);
if ($dayMinutes <= 0) { if ($dayMinutes <= 0) {
return 0; return 0;
} }
@@ -74,34 +77,14 @@ final readonly class WorkedHoursCreditPolicy
return 0.0; return 0.0;
} }
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int /**
* Single source of truth = {@see DailyReferenceMinutesResolver}. Weekend=0,
* schedule precedence, 35h/39h fixed rules, fallback = weeklyHours/5.
*
* @param null|array<int, int> $workDaysMinutes planning iso-day → minutes (priorité absolue si fourni)
*/
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay, ?array $workDaysMinutes = null): int
{ {
// Week-end non travaillé dans cette politique. return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay, $workDaysMinutes);
if ($isoWeekDay >= 6) {
return 0;
}
// Règle fixe: 35h => 7h/jour.
if (35 === $weeklyHours) {
return 7 * 60;
}
// Règle fixe: 39h => 8h lundi-jeudi, 7h le vendredi.
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
// Cas spécifique métier: contrat 4h/semaine réparti sur 2 jours => 2h/jour.
if (4 === $weeklyHours) {
return 2 * 60;
}
// Contrat non renseigné/invalide: aucun crédit.
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
// Fallback générique: répartition homogène sur 5 jours ouvrés.
return (int) round(($weeklyHours * 60) / 5);
} }
} }

View File

@@ -6,9 +6,11 @@ namespace App\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Entity\Formation;
use App\Enum\ContractNature; use App\Enum\ContractNature;
use App\Enum\HalfDay; use App\Enum\HalfDay;
use App\Repository\AbsenceRepository; use App\Repository\AbsenceRepository;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Service\PublicHolidayServiceInterface; use App\Service\PublicHolidayServiceInterface;
use DateInterval; use DateInterval;
@@ -30,6 +32,7 @@ class AbsencePrintProvider implements ProviderInterface
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository, private EmployeeRepository $employeeRepository,
private AbsenceRepository $absenceRepository, private AbsenceRepository $absenceRepository,
private FormationReadRepositoryInterface $formationRepository,
private PublicHolidayServiceInterface $publicHolidayService, private PublicHolidayServiceInterface $publicHolidayService,
) {} ) {}
@@ -58,24 +61,27 @@ class AbsencePrintProvider implements ProviderInterface
$workContractIds = $this->parseIds($request->query->get('workContracts')); $workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures')); $contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds); $employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees); $absences = $this->loadAbsences($fromDate, $toDate, $employees);
$formations = $this->formationRepository->findByDateRangeAndEmployees($fromDate, $toDate, $employees);
$days = $this->buildDays($fromDate, $toDate); $days = $this->buildDays($fromDate, $toDate);
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate); $absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
$holidayMap = $this->buildHolidayMap($fromDate, $toDate); $formationMap = $this->buildFormationMap($formations, $fromDate, $toDate);
$holidayMap = $this->buildHolidayMap($fromDate, $toDate);
$options = new Options(); $options = new Options();
$options->set('isRemoteEnabled', true); $options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options); $dompdf = new Dompdf($options);
$html = $this->twig->render('absence/print.html.twig', [ $html = $this->twig->render('absence/print.html.twig', [
'from' => $fromDate, 'from' => $fromDate,
'to' => $toDate, 'to' => $toDate,
'days' => $days, 'days' => $days,
'employees' => $employees, 'employees' => $employees,
'absenceMap' => $absenceMap, 'absenceMap' => $absenceMap,
'holidayMap' => $holidayMap, 'formationMap' => $formationMap,
'holidayMap' => $holidayMap,
]); ]);
$dompdf->loadHtml($html); $dompdf->loadHtml($html);
@@ -203,6 +209,37 @@ class AbsencePrintProvider implements ProviderInterface
return $map; return $map;
} }
/**
* @param list<Formation> $formations
*
* @return array<int, array<string, bool>>
*/
private function buildFormationMap(array $formations, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
foreach ($formations as $formation) {
$employeeId = $formation->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$formationStart = DateTimeImmutable::createFromInterface($formation->getStartDate());
$formationEnd = DateTimeImmutable::createFromInterface($formation->getEndDate());
$start = max($formationStart, $from);
$end = min($formationEnd, $to);
$current = $start;
while ($current <= $end) {
$map[$employeeId][$current->format('Y-m-d')] = true;
$current = $current->add(new DateInterval('P1D'));
}
}
return $map;
}
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{ {
$map = []; $map = [];

View File

@@ -14,7 +14,6 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger; use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use DateInterval; use DateInterval;
use DatePeriod; use DatePeriod;
use DateTime; use DateTime;
@@ -24,7 +23,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
final readonly class AbsenceWriteProcessor implements ProcessorInterface final readonly class AbsenceWriteProcessor implements ProcessorInterface
{ {
@@ -33,7 +31,6 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository, private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security, private Security $security,
private PublicHolidayServiceInterface $publicHolidayService,
private AuditLogger $auditLogger, private AuditLogger $auditLogger,
) {} ) {}
@@ -167,15 +164,10 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.'); throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
} }
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day')); $days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$publicHolidays = $this->buildPublicHolidayMap($start, $end);
$segments = []; $segments = [];
foreach ($days as $day) { foreach ($days as $day) {
if (isset($publicHolidays[$day->format('Y-m-d')])) {
continue;
}
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d'); $isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d'); $isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
$isSame = $isFirst && $isLast; $isSame = $isFirst && $isLast;
@@ -286,27 +278,4 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setIsValid(false) ->setIsValid(false)
; ;
} }
/**
* @return array<string, string>
*/
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
} }

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveRecap;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractType;
use App\Repository\EmployeeRepository;
use App\Security\EmployeeScopeService;
use App\Service\Leave\LeaveRecapRowBuilder;
use App\Util\LeaveRecapCutoff;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class EmployeeLeaveRecapProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private EmployeeRepository $employeeRepository,
private EmployeeScopeService $employeeScopeService,
private LeaveRecapRowBuilder $rowBuilder,
private EntityManagerInterface $entityManager,
) {}
/**
* @return list<EmployeeLeaveRecap>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
if (!$user->hasLeaveRecapAccess()) {
throw new AccessDeniedHttpException('Leave recap access not granted.');
}
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('today'));
$cutoffYmd = $cutoff->format('Y-m-d');
$employees = $this->resolveScopedEmployees($user);
$rows = [];
foreach ($employees as $employee) {
if (!$employee->getHasActiveContract()) {
continue;
}
$row = $this->rowBuilder->build($employee, $cutoff);
$resource = new EmployeeLeaveRecap();
$resource->employeeId = (int) $employee->getId();
$resource->lastName = $row['lastName'] ?? '';
$resource->firstName = $row['firstName'] ?? '';
$site = $employee->getSite();
$resource->siteId = $site?->getId();
$resource->siteName = $site?->getName();
$resource->siteColor = $site?->getColor();
$resource->contractName = $row['contractName'] ?? null;
$resource->contractSortKey = $this->resolveContractSortKey($employee->getContract());
$resource->cpN1Remaining = is_numeric($row['cpN1Remaining']) ? (float) $row['cpN1Remaining'] : 0.0;
$resource->cpN = (string) $row['cpN'];
$resource->acquiredSaturdays = (string) $row['acquiredSaturdays'];
$resource->rtt = (string) $row['rtt'];
$resource->cutoffDate = $cutoffYmd;
$rows[] = $resource;
$this->entityManager->clear();
}
usort($rows, static function (EmployeeLeaveRecap $a, EmployeeLeaveRecap $b): int {
$siteCmp = strcmp((string) ($a->siteName ?? 'zzz'), (string) ($b->siteName ?? 'zzz'));
if (0 !== $siteCmp) {
return $siteCmp;
}
$contractCmp = $a->contractSortKey <=> $b->contractSortKey;
if (0 !== $contractCmp) {
return $contractCmp;
}
$lastCmp = strcmp($a->lastName, $b->lastName);
if (0 !== $lastCmp) {
return $lastCmp;
}
return strcmp($a->firstName, $b->firstName);
});
return $rows;
}
/**
* Sort order: FORFAIT → 39h → 35h → 25h → 4h → autres.
*/
private function resolveContractSortKey(?Contract $contract): int
{
if (null === $contract) {
return 99;
}
if (ContractType::FORFAIT === $contract->getType()) {
return 0;
}
$weeklyHours = $contract->getWeeklyHours();
return match ($weeklyHours) {
39 => 1,
35 => 2,
25 => 3,
4 => 4,
default => 99,
};
}
/**
* @return list<Employee>
*/
private function resolveScopedEmployees(User $user): array
{
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return $this->employeeRepository->findForPrintBySiteIds([]);
}
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
$employee = $user->getEmployee();
return $employee instanceof Employee ? [$employee] : [];
}
$siteIds = $this->employeeScopeService->getAllowedSiteIds($user);
if ([] === $siteIds) {
return [];
}
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
}
}

View File

@@ -140,7 +140,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* previousYearRemainingDays: float * previousYearRemainingDays: float
* } * }
*/ */
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0): ?array public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
{ {
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1); $firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
if ($targetYear < $firstYear) { if ($targetYear < $firstYear) {
@@ -196,8 +196,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$carrySaturdays = 0.0; $carrySaturdays = 0.0;
} }
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee); $effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee); $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace( $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to) $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
); );
@@ -330,6 +331,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $this->resolveCurrentLeaveYear($today); return $this->resolveCurrentLeaveYear($today);
} }
public function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
}
private function resolveEffectivePeriodStart( private function resolveEffectivePeriodStart(
Employee $employee, Employee $employee,
DateTimeImmutable $from, DateTimeImmutable $from,
@@ -482,19 +490,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
string $ruleCode, string $ruleCode,
int $year, int $year,
DateTimeImmutable $periodEnd, DateTimeImmutable $periodEnd,
Employee $employee Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable { ): ?DateTimeImmutable {
$today = new DateTimeImmutable('today'); $reference = $asOfDate ?? new DateTimeImmutable('today');
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode $currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
? (int) $today->format('Y') ? (int) $reference->format('Y')
: $this->resolveCurrentLeaveYear($today); : $this->resolveCurrentLeaveYear($reference);
if ($year < $currentYear) { if ($year < $currentYear) {
$end = $periodEnd; $end = $periodEnd;
} elseif ($year > $currentYear) { } elseif ($year > $currentYear) {
$end = null; $end = null;
} else { } else {
$lastDayPreviousMonth = $today $lastDayPreviousMonth = $reference
->modify('first day of this month') ->modify('first day of this month')
->modify('-1 day') ->modify('-1 day')
->setTime(0, 0) ->setTime(0, 0)
@@ -516,10 +525,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function resolveTakenCalculationEndDate( private function resolveTakenCalculationEndDate(
DateTimeImmutable $periodEnd, DateTimeImmutable $periodEnd,
Employee $employee Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable { ): ?DateTimeImmutable {
$end = $periodEnd; $end = $periodEnd;
if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) {
$end = $asOfDate;
}
// Cap at contract end date if the employee has left. // Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate(); $contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) { if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
@@ -547,7 +561,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
{ {
$type = $employee->getContract()?->getType(); $type = $employee->getContract()?->getType();
if (ContractType::FORFAIT === $type) { if (ContractType::FORFAIT === $type) {
$businessDaysInPeriod = $this->countBusinessDays($from, $to); // Business days for forfait must use the RAW holiday list (excluded holidays like
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
// the 218-day legal target).
$businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$publicHolidays = $this->buildPublicHolidayMap($from, $to); $publicHolidays = $this->buildPublicHolidayMap($from, $to);
$weekdayHolidays = array_filter( $weekdayHolidays = array_filter(
array_keys($publicHolidays), array_keys($publicHolidays),
@@ -641,6 +658,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $map; return $map;
} }
/**
* @return array<string, string>
*/
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/** /**
* Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days. * Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days.
* *
@@ -778,13 +818,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return null !== $balance ? $balance->getFractionedDays() : 0.0; return null !== $balance ? $balance->getFractionedDays() : 0.0;
} }
private function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
}
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
{ {
$year = (int) $today->format('Y'); $year = (int) $today->format('Y');

View File

@@ -294,17 +294,28 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
} }
// Week spans two months — split proportionally by daily worked minutes // Week spans two months — split proportionally by daily worked minutes
$monthMinutes = []; $monthMinutes = [];
$monthWeekdays = [];
foreach ($detail->dailyMinutes as $date => $mins) { foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n'); $m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins; $monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
$isoDay = (int) new DateTimeImmutable($date)->format('N');
if ($isoDay < 6) {
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
}
} }
$totalWorked = array_sum($monthMinutes); $totalWorked = array_sum($monthMinutes);
$totalWeekdays = array_sum($monthWeekdays);
foreach ([$startMonth, $endMonth] as $month) { foreach ([$startMonth, $endMonth] as $month) {
$portion = $monthMinutes[$month] ?? 0; if ($totalWorked > 0) {
$ratio = $totalWorked > 0 ? $portion / $totalWorked : 0.0; $ratio = ($monthMinutes[$month] ?? 0) / $totalWorked;
} elseif ($totalWeekdays > 0) {
$ratio = ($monthWeekdays[$month] ?? 0) / $totalWeekdays;
} else {
$ratio = 0.0;
}
$result[] = new EmployeeRttWeekSummary( $result[] = new EmployeeRttWeekSummary(
month: $month, month: $month,

View File

@@ -69,6 +69,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
endDate: $changeRequest->contractEndDate, endDate: $changeRequest->contractEndDate,
nature: $nature, nature: $nature,
isDriver: $changeRequest->isDriver ?? false, isDriver: $changeRequest->isDriver ?? false,
workDaysHours: $changeRequest->workDaysHours,
); );
$data->setEntryDate($startDate); $data->setEntryDate($startDate);
@@ -138,6 +139,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
nature: $nature, nature: $nature,
todayPeriod: $effectivePeriod, todayPeriod: $effectivePeriod,
isDriver: $changeRequest->isDriver ?? false, isDriver: $changeRequest->isDriver ?? false,
workDaysHours: $changeRequest->workDaysHours,
); );
return $result; return $result;

View File

@@ -9,6 +9,8 @@ use ApiPlatform\State\ProviderInterface;
use App\Dto\WorkHours\WorkMetrics; use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode; use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository; use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
@@ -62,8 +64,22 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
} }
$year = (int) $yearRaw; $year = (int) $yearRaw;
$from = new DateTimeImmutable("{$year}-01-01"); $monthRaw = (string) $request->query->get('month', '');
$to = new DateTimeImmutable("{$year}-12-31"); $month = null;
if ('' !== $monthRaw) {
if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) {
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
}
$month = (int) $monthRaw;
}
if (null !== $month) {
$from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
$to = $from->modify('last day of this month');
} else {
$from = new DateTimeImmutable("{$year}-01-01");
$to = new DateTimeImmutable("{$year}-12-31");
}
$days = $this->buildDays($from, $to); $days = $this->buildDays($from, $to);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
@@ -83,28 +99,39 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
$absenceData, $absenceData,
); );
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')); $employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$contractLabel = $this->buildContractLabel($employee);
$options = new Options(); $options = new Options();
$options->set('isRemoteEnabled', true); $options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options); $dompdf = new Dompdf($options);
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [ $html = $this->twig->render('employee-yearly-hours/print.html.twig', [
'employeeName' => $employeeName, 'employeeName' => $employeeName,
'year' => $year, 'contractLabel' => $contractLabel,
'segments' => $segments, 'year' => $year,
'month' => $month,
'segments' => $segments,
]); ]);
$dompdf->loadHtml($html); $dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait'); $dompdf->setPaper('A4', 'portrait');
$dompdf->render(); $dompdf->render();
$filename = sprintf( $filename = null !== $month
'%s_%s_%d.pdf', ? sprintf(
$this->sanitizeFilename($employee->getLastName() ?? ''), '%s_%s_%d-%02d.pdf',
$this->sanitizeFilename($employee->getFirstName() ?? ''), $this->sanitizeFilename($employee->getLastName() ?? ''),
$year, $this->sanitizeFilename($employee->getFirstName() ?? ''),
); $year,
$month,
)
: sprintf(
'%s_%s_%d.pdf',
$this->sanitizeFilename($employee->getLastName() ?? ''),
$this->sanitizeFilename($employee->getFirstName() ?? ''),
$year,
);
return new Response($dompdf->output(), Response::HTTP_OK, [ return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
@@ -112,6 +139,36 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
]); ]);
} }
private function buildContractLabel(Employee $employee): ?string
{
$contract = $employee->getContract();
if (null === $contract) {
return null;
}
$natureRaw = $employee->getCurrentContractNature();
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
$natureLabel = match ($nature) {
ContractNature::CDI => 'CDI',
ContractNature::CDD => 'CDD',
ContractNature::INTERIM => 'Intérim',
};
$contractType = $contract->getType();
if (ContractType::FORFAIT === $contractType) {
return $natureLabel.' Forfait';
}
$weeklyHours = $contract->getWeeklyHours();
if (null !== $weeklyHours && $weeklyHours > 0) {
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
}
$name = $contract->getName();
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
}
/** /**
* @return list<string> * @return list<string>
*/ */
@@ -211,13 +268,44 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
$currentRows = []; $currentRows = [];
$currentName = null; $currentName = null;
// Crop the output window to [first data day, today] to avoid padding the
// export with empty rows (notably weekends before the first saisie or after today).
$firstDataDate = null;
foreach ($days as $date) { foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null; $hasRow = null !== ($workHoursByDate[$date] ?? null)
$isDriver = $driverByDate[$date] ?? false; || ($absenceData['hasDayAbsence'][$date] ?? false);
$wh = $workHoursByDate[$date] ?? null; if ($hasRow) {
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false); $firstDataDate = $date;
if (!$hasData) { break;
}
}
if (null === $firstDataDate) {
return [];
}
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
foreach ($days as $date) {
if ($date < $firstDataDate || $date > $todayYmd) {
continue;
}
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
// Keep weekend rows even when empty so the reader can distinguish
// worked vs non-worked Saturdays/Sundays at a glance.
if (!$hasData && !$isWeekend) {
continue;
}
if (!$hasData && null === $contract) {
continue; continue;
} }
@@ -244,6 +332,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
$row = [ $row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'), 'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel, 'absenceLabel' => $absenceLabel,
'isWeekend' => $isWeekend,
]; ];
if ('presence' === $mode) { if ('presence' === $mode) {

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class FormationDeleteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$data instanceof Formation) {
return null;
}
$justificatifPath = $data->getJustificatifPath();
if (null !== $justificatifPath) {
$absolutePath = sprintf('%s/%s', $this->uploadDir, $justificatifPath);
if (file_exists($absolutePath)) {
unlink($absolutePath);
}
}
$this->entityManager->remove($data);
$this->entityManager->flush();
return null;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class FormationJustificatifDownloadProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BinaryFileResponse
{
$formation = $this->entityManager->find(Formation::class, $uriVariables['id']);
if (null === $formation) {
throw new NotFoundHttpException('Formation not found.');
}
$justificatifPath = $formation->getJustificatifPath();
if (null === $justificatifPath) {
throw new NotFoundHttpException('No justificatif found for this formation.');
}
$absolutePath = sprintf('%s/%s', $this->uploadDir, $justificatifPath);
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('Justificatif file not found.');
}
$response = new BinaryFileResponse($absolutePath);
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$formation->getJustificatifName() ?? 'justificatif.pdf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
final readonly class FormationJustificatifUploadProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private RequestStack $requestStack,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
{
if (!$data instanceof Formation) {
throw new BadRequestHttpException('Invalid entity.');
}
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (null === $file) {
throw new BadRequestHttpException('No file uploaded.');
}
if ('application/pdf' !== $file->getMimeType()) {
throw new BadRequestHttpException('Only PDF files are accepted.');
}
$startDate = $data->getStartDate();
$year = $startDate?->format('Y') ?? date('Y');
$monthNumber = $startDate?->format('m') ?? date('m');
$relativePath = sprintf('formations/%s/%s', $year, $monthNumber);
$absoluteDir = sprintf('%s/%s', $this->uploadDir, $relativePath);
if (!is_dir($absoluteDir)) {
mkdir($absoluteDir, 0o755, true);
}
$filename = Uuid::v4()->toRfc4122().'.pdf';
$fullRelative = sprintf('%s/%s', $relativePath, $filename);
$originalName = $file->getClientOriginalName();
$previousPath = $data->getJustificatifPath();
$file->move($absoluteDir, $filename);
$data->setJustificatifPath($fullRelative);
$data->setJustificatifName($originalName);
$this->entityManager->flush();
if (null !== $previousPath) {
$previousAbsolute = sprintf('%s/%s', $this->uploadDir, $previousPath);
if (file_exists($previousAbsolute)) {
unlink($previousAbsolute);
}
}
return new JsonResponse(['path' => $fullRelative, 'name' => $originalName], Response::HTTP_OK);
}
}

View File

@@ -6,21 +6,13 @@ namespace App\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository; use App\Service\Leave\LeaveRecapRowBuilder;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Dompdf\Options; use Dompdf\Options;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment; use Twig\Environment;
class LeaveRecapPrintProvider implements ProviderInterface class LeaveRecapPrintProvider implements ProviderInterface
@@ -28,12 +20,8 @@ class LeaveRecapPrintProvider implements ProviderInterface
public function __construct( public function __construct(
private Environment $twig, private Environment $twig,
private EmployeeRepository $employeeRepository, private EmployeeRepository $employeeRepository,
private EmployeeLeaveSummaryProvider $leaveSummaryProvider, private LeaveRecapRowBuilder $rowBuilder,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private WorkHourRepository $workHourRepository,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -59,7 +47,7 @@ class LeaveRecapPrintProvider implements ProviderInterface
]; ];
} }
$siteGroups[$siteId]['employees'][] = $this->buildEmployeeRow($employee, $today); $siteGroups[$siteId]['employees'][] = $this->rowBuilder->build($employee);
$this->entityManager->clear(); $this->entityManager->clear();
} }
@@ -84,129 +72,4 @@ class LeaveRecapPrintProvider implements ProviderInterface
'Content-Disposition' => 'inline; filename="'.$filename.'"', 'Content-Disposition' => 'inline; filename="'.$filename.'"',
]); ]);
} }
private function buildEmployeeRow(Employee $employee, DateTimeImmutable $today): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
if (null !== $yearSummary) {
if ($isForfait) {
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $today));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $today): int
{
$month = (int) $today->format('n');
$year = (int) $today->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
} }

View File

@@ -18,12 +18,14 @@ use App\Repository\MileageAllowanceRepository;
use App\Repository\ObservationRepository; use App\Repository\ObservationRepository;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Dompdf\Options; use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment; use Twig\Environment;
class SalaryRecapPrintProvider implements ProviderInterface class SalaryRecapPrintProvider implements ProviderInterface
@@ -39,6 +41,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
private MileageAllowanceRepository $mileageAllowanceRepository, private MileageAllowanceRepository $mileageAllowanceRepository,
private ObservationRepository $observationRepository, private ObservationRepository $observationRepository,
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private PublicHolidayServiceInterface $publicHolidayService,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -71,6 +74,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$days = $this->buildDays($from, $to); $days = $this->buildDays($from, $to);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($from, $to);
$workHourMap = $this->buildWorkHourMap($workHours); $workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences); $absenceMap = $this->buildAbsenceMap($absences);
@@ -79,7 +83,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$mileageMap = $this->buildMileageMap($mileages); $mileageMap = $this->buildMileageMap($mileages);
$observationMap = $this->buildObservationMap($observations); $observationMap = $this->buildObservationMap($observations);
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap); $siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap);
$options = new Options(); $options = new Options();
$options->set('isRemoteEnabled', true); $options->set('isRemoteEnabled', true);
@@ -208,6 +212,29 @@ class SalaryRecapPrintProvider implements ProviderInterface
return $map; return $map;
} }
/**
* @return array<string, string> Y-m-d → label
*/
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
@@ -236,6 +263,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
array $bonusMap, array $bonusMap,
array $mileageMap, array $mileageMap,
array $observationMap, array $observationMap,
array $holidayMap,
): array { ): array {
$siteGroups = []; $siteGroups = [];
@@ -257,6 +285,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$bonusMap[$employeeId] ?? 0.0, $bonusMap[$employeeId] ?? 0.0,
$mileageMap[$employeeId] ?? 0.0, $mileageMap[$employeeId] ?? 0.0,
$observationMap[$employeeId] ?? '', $observationMap[$employeeId] ?? '',
$holidayMap,
); );
if (!isset($siteGroups[$siteId])) { if (!isset($siteGroups[$siteId])) {
@@ -285,18 +314,20 @@ class SalaryRecapPrintProvider implements ProviderInterface
float $bonusAmount, float $bonusAmount,
float $mileageKm, float $mileageKm,
string $observation, string $observation,
array $holidayMap,
): array { ): array {
$contractName = null; $contractName = null;
$presenceDays = 0.0; $presenceDays = 0.0;
$nightMinutesTotal = 0; $nightMinutesTotal = 0;
$nightBasketCount = 0; $nightBasketCount = 0;
$sundayMinutesTotal = 0; $sundayMinutesTotal = 0;
$isDriverAnyDay = false; $holidayMinutesTotal = 0;
$driverBreakfast = 0; $isDriverAnyDay = false;
$driverMeals = 0; $driverBreakfast = 0;
$driverOvernight = 0; $driverMeals = 0;
$driverSaturdays = 0; $driverOvernight = 0;
$isForfait = false; $driverSaturdays = 0;
$isForfait = false;
foreach ($days as $date) { foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null; $contract = $contractsByDate[$date] ?? null;
@@ -318,10 +349,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N'); $dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
$isHoliday = isset($holidayMap[$date]);
if ($isDriver) { if ($isDriver) {
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0; $nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
$dayMin = $wh->getDayHoursMinutes() ?? 0; $dayMin = $wh->getDayHoursMinutes() ?? 0;
$nightMin = $wh->getNightHoursMinutes() ?? 0; $nightMin = $wh->getNightHoursMinutes() ?? 0;
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) { if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
++$nightBasketCount; ++$nightBasketCount;
} }
@@ -336,12 +370,16 @@ class SalaryRecapPrintProvider implements ProviderInterface
++$driverOvernight; ++$driverOvernight;
} }
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || ($wh->getWorkshopHoursMinutes() ?? 0) > 0)) { if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || $workshopMin > 0)) {
++$driverSaturdays; ++$driverSaturdays;
} }
if (7 === $dayOfWeek) { if (7 === $dayOfWeek) {
$sundayMinutesTotal += $dayMin + $nightMin + ($wh->getWorkshopHoursMinutes() ?? 0); $sundayMinutesTotal += $dayMin + $nightMin + $workshopMin;
}
if ($isHoliday) {
$holidayMinutesTotal += $dayMin + $nightMin + $workshopMin;
} }
} else { } else {
$metrics = $this->computeNightMinutes($wh); $metrics = $this->computeNightMinutes($wh);
@@ -359,6 +397,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh); $sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
} }
if ($isHoliday) {
$holidayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
}
if ($isForfait) { if ($isForfait) {
if ($wh->getIsPresentMorning()) { if ($wh->getIsPresentMorning()) {
$presenceDays += 0.5; $presenceDays += 0.5;
@@ -373,9 +415,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
$conges = $this->countAbsencesByCode($absences, ['C']); $conges = $this->countAbsencesByCode($absences, ['C']);
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']); $maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
$nightHours = round($nightMinutesTotal / 60, 2); $nightHours = round($nightMinutesTotal / 60, 2);
$paidHours = round($rttPaidMinutes / 60, 2); $paidHours = round($rttPaidMinutes / 60, 2);
$sundayHours = round($sundayMinutesTotal / 60, 2); $sundayHours = round($sundayMinutesTotal / 60, 2);
$holidayHours = round($holidayMinutesTotal / 60, 2);
return [ return [
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'), 'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
@@ -387,6 +430,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
'nightBasketCount' => $nightBasketCount, 'nightBasketCount' => $nightBasketCount,
'paidHours' => $paidHours, 'paidHours' => $paidHours,
'sundayHours' => $sundayHours, 'sundayHours' => $sundayHours,
'holidayHours' => $holidayHours,
'bonusAmount' => $bonusAmount, 'bonusAmount' => $bonusAmount,
'congesCount' => $conges['count'], 'congesCount' => $conges['count'],
'congesDates' => $conges['dates'], 'congesDates' => $conges['dates'],

View File

@@ -11,8 +11,10 @@ use App\Dto\WorkHours\DayContextRow;
use App\Entity\User; use App\Entity\User;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -27,9 +29,11 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
private RequestStack $requestStack, private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository, private EmployeeScopedRepositoryInterface $employeeRepository,
private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceReadRepositoryInterface $absenceRepository,
private FormationReadRepositoryInterface $formationRepository,
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver, private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy, private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
@@ -40,9 +44,10 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
throw new AccessDeniedHttpException('Authentication required.'); throw new AccessDeniedHttpException('Authentication required.');
} }
$workDate = $this->resolveWorkDate(); $workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user); $employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees); $absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$formations = $this->formationRepository->findByDateAndEmployees($workDate, $employees);
$rowsByEmployeeId = []; $rowsByEmployeeId = [];
foreach ($employees as $employee) { foreach ($employees as $employee) {
@@ -53,10 +58,12 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
// On initialise toutes les lignes, même sans absence ce jour-là. // On initialise toutes les lignes, même sans absence ce jour-là.
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate); $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
$rowsByEmployeeId[$employeeId] = new DayContextRow( $rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId, employeeId: $employeeId,
hasContractAtDate: null !== $contract, hasContractAtDate: null !== $contract,
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate), isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
); );
} }
@@ -87,6 +94,22 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
); );
} }
foreach ($formations as $formation) {
$employeeId = $formation->getEmployee()?->getId();
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
continue;
}
$rowsByEmployeeId[$employeeId]->setFormation('Formation');
}
// If an absence is declared on the day, the absence dictates the hours credited
// (via WorkedHoursCreditPolicy). The holiday virtual credit must not stack on top.
foreach ($rowsByEmployeeId as $row) {
if ($row->absentMorning || $row->absentAfternoon) {
$row->virtualHolidayMinutes = 0;
}
}
$response = new WorkHourDayContext(); $response = new WorkHourDayContext();
$response->workDate = $dateKey; $response->workDate = $dateKey;
$response->rows = array_map( $response->rows = array_map(

View File

@@ -23,6 +23,8 @@ use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -41,6 +43,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private AbsenceSegmentsResolver $absenceSegmentsResolver, private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy, private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
@@ -117,6 +121,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days); $contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days); $isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = []; $metricsByEmployeeDate = [];
foreach ($workHours as $workHour) { foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId(); $employeeId = $workHour->getEmployee()?->getId();
@@ -276,6 +281,25 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
++$weeklyNightBasketCount; ++$weeklyNightBasketCount;
} }
// Apply the Mon-Fri public holiday credit rule: for non-Forfait contracts,
// if the total worked is below the contract-expected daily hours, top it up.
// Virtual minutes are always accounted against the "day" bucket.
// When an absence is declared on the day, the holiday credit is bypassed —
// the absence (via WorkedHoursCreditPolicy) dictates the hours.
$virtualHolidayMinutes = $this->holidayVirtualHoursResolver
->resolveVirtualCredit(
$contractAtDate,
new DateTimeImmutable($date),
$absenceByEmployeeDate[$employeeId][$date] ?? false,
$workDaysByEmployeeDate[$employeeId][$date] ?? null,
)
;
if ($virtualHolidayMinutes > $totalMinutes) {
$delta = $virtualHolidayMinutes - $totalMinutes;
$dayMinutes += $delta;
$totalMinutes = $virtualHolidayMinutes;
}
$weeklyDayMinutes += $dayMinutes; $weeklyDayMinutes += $dayMinutes;
$weeklyNightMinutes += $nightMinutes; $weeklyNightMinutes += $nightMinutes;
$weeklyWorkshopMinutes += $workshopMinutes; $weeklyWorkshopMinutes += $workshopMinutes;
@@ -299,6 +323,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
hasLunch: $hasLunch, hasLunch: $hasLunch,
hasDinner: $hasDinner, hasDinner: $hasDinner,
hasOvernight: $hasOvernight, hasOvernight: $hasOvernight,
virtualHolidayMinutes: $virtualHolidayMinutes,
); );
} }
@@ -344,6 +369,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyDinnerCount: $weeklyDinnerCount, weeklyDinnerCount: $weeklyDinnerCount,
weeklyOvernightCount: $weeklyOvernightCount, weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek, hasContractForWeek: $hasContractForWeek,
contractNature: $weekAnchorContractNature->value,
); );
} }
@@ -512,23 +538,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{ {
// Week-end hors base de référence. return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
if ($isoWeekDay >= 6) {
return 0;
}
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
if (35 === $weeklyHours) {
return 7 * 60;
}
return (int) round(($weeklyHours * 60) / 5);
} }
} }

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Util;
use DateTimeImmutable;
/**
* Leave recap cutoff rule: as-of end of ISO week S-2 (Sunday 23:59:59).
*
* Example: Tuesday 2026-04-14 (S16) → Sunday 2026-04-05 23:59:59 (end of S14).
*/
final class LeaveRecapCutoff
{
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
{
$currentWeekMonday = $today->modify('monday this week')->setTime(0, 0);
$cutoffWeekMonday = $currentWeekMonday->modify('-14 days');
return $cutoffWeekMonday->modify('+6 days')->setTime(23, 59, 59);
}
}

View File

@@ -84,6 +84,11 @@
background: #b3e5fc; background: #b3e5fc;
} }
.formation {
background: #6366f1;
color: #fff;
}
.body-cell { .body-cell {
height: 6mm; height: 6mm;
padding: 0 !important; padding: 0 !important;
@@ -239,11 +244,15 @@
{% for day in days %} {% for day in days %}
{% set isHoliday = holidayMap[day.date] ?? null %} {% set isHoliday = holidayMap[day.date] ?? null %}
{% set info = absenceMap[employee.id][day.date] ?? null %} {% set info = absenceMap[employee.id][day.date] ?? null %}
{% set hasFormation = formationMap[employee.id][day.date] ?? false %}
{% set isFormationOnly = hasFormation and not info and not isHoliday %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %} {% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %} {% set isWeekend = day.date|date('N') in [6, 7] %}
<td class="col-day body-cell{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday and not info.half %} background-color: {{ info.color }};{% endif %}"> <td class="col-day body-cell{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}{% if isFormationOnly %} formation{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday and not info.half %} background-color: {{ info.color }};{% endif %}">
{% if isHoliday %} {% if isHoliday %}
<span class="full-cell code">Férié</span> <span class="full-cell code">Férié</span>
{% elseif isFormationOnly %}
<span class="full-cell code">F</span>
{% elseif info %} {% elseif info %}
{% if info.half %} {% if info.half %}
<table class="half-table"> <table class="half-table">
@@ -259,7 +268,7 @@
</tr> </tr>
</table> </table>
{% else %} {% else %}
<span class="full-cell code">{{ info.code }}</span> <span class="full-cell code">{{ info.code }}{% if hasFormation %}*{% endif %}</span>
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>

View File

@@ -14,10 +14,24 @@
font-size: 9px; font-size: 9px;
} }
.title-bar {
position: relative;
margin: 0 0 4mm 0;
}
h1 { h1 {
text-align: center; text-align: center;
font-size: 16px; font-size: 16px;
margin: 0 0 4mm 0; margin: 0;
}
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 9px;
color: #333;
padding-top: 4px;
} }
h2 { h2 {
@@ -54,11 +68,70 @@
td.time { text-align: center; } td.time { text-align: center; }
td.presence { text-align: center; } td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; } td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td.date { color: #333; }
.signature-footer {
page-break-inside: avoid;
margin-top: 6mm;
}
.signature-intro {
text-align: center;
font-weight: 700;
margin-bottom: 6mm;
font-size: 11px;
}
.signature-blocks {
display: table;
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 4mm 0;
}
.signature-block {
display: table-cell;
border: 1px solid #0a0a0a;
padding: 3mm;
vertical-align: top;
width: 33.33%;
}
.signature-block .title {
text-align: center;
font-weight: 700;
font-size: 11px;
margin-bottom: 7mm;
text-decoration: underline;
}
.signature-block .line {
margin-bottom: 2mm;
font-size: 10px;
}
.signature-block .signature-line {
margin-top: 6mm;
margin-bottom: 18mm;
font-size: 10px;
}
</style> </style>
</head> </head>
<body> <body>
<h1>{{ employeeName }} - {{ year }}</h1> {% set months = {
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'
} %}
<div class="title-bar">
<h1>
{{ employeeName }}{% if contractLabel %} - {{ contractLabel }}{% endif %}<br>
{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}
</h1>
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
</div>
{% for segment in segments %} {% for segment in segments %}
{% if segments|length > 1 %} {% if segments|length > 1 %}
@@ -78,7 +151,7 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr> <tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td> <td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
@@ -102,7 +175,7 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr> <tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.dayHours }}</td> <td class="time">{{ row.dayHours }}</td>
@@ -130,7 +203,7 @@
</thead> </thead>
<tbody> <tbody>
{% for row in segment.rows %} {% for row in segment.rows %}
<tr> <tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td> <td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td> <td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.morningFrom }}</td> <td class="time">{{ row.morningFrom }}</td>
@@ -147,5 +220,36 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="signature-footer">
<div class="signature-intro">
Nom + Prénom<br>
Signature avec mention « bon pour accord »
</div>
<div class="signature-blocks">
<div class="signature-block">
<p class="title">Direction</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
<div class="signature-block">
<p class="title">Responsable usine</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
<div class="signature-block">
<p class="title">Salarié</p>
<p class="line">Nom : ...............</p>
<p class="line">Prénom : ...............</p>
<p class="line">Mention : ........................................</p>
<p class="signature-line">Signature :</p>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@@ -28,13 +28,22 @@
.date-box { .date-box {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; left: 0;
border: 2px solid #000; border: 2px solid #000;
padding: 4px 12px; padding: 4px 12px;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
} }
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 10px;
color: #333;
padding-top: 6px;
}
table.recap { table.recap {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -77,8 +86,9 @@
<body> <body>
<div class="title-bar"> <div class="title-bar">
<h1>RECAPITULATIF CONGES & RTT</h1>
<div class="date-box">{{ today|date('d/m/Y') }}</div> <div class="date-box">{{ today|date('d/m/Y') }}</div>
<h1>RECAPITULATIF CONGES & RTT</h1>
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
</div> </div>
<table class="recap"> <table class="recap">

View File

@@ -11,7 +11,7 @@
margin: 0; margin: 0;
padding: 2mm; padding: 2mm;
font-family: Helvetica, sans-serif; font-family: Helvetica, sans-serif;
font-size: 10px; font-size: 9px;
} }
.title-bar { .title-bar {
@@ -28,7 +28,7 @@
.month-box { .month-box {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; left: 0;
border: 2px solid #000; border: 2px solid #000;
padding: 4px 12px; padding: 4px 12px;
font-size: 14px; font-size: 14px;
@@ -36,16 +36,25 @@
text-transform: uppercase; text-transform: uppercase;
} }
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 10px;
color: #333;
padding-top: 6px;
}
table.recap { table.recap {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: auto; table-layout: auto;
border: 4px solid #0a0a0a; border: 2px solid #0a0a0a;
} }
th, td { th, td {
border: 2px solid #0a0a0a; border: 1px solid #0a0a0a;
padding: 3px 3px; padding: 2px 2px;
vertical-align: middle; vertical-align: middle;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@@ -60,7 +69,7 @@
thead th { thead th {
text-align: center; text-align: center;
font-weight: 700; font-weight: 700;
font-size: 10px; font-size: 9px;
white-space: normal; white-space: normal;
} }
@@ -74,16 +83,16 @@
text-align: left; text-align: left;
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
font-size: 10px; font-size: 9px;
} }
td.obs { td.obs {
text-align: left; text-align: left;
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
font-size: 9px; font-size: 8px;
} }
tbody td { font-size: 10px; } tbody td { font-size: 9px; }
</style> </style>
</head> </head>
<body> <body>
@@ -94,43 +103,45 @@
} %} } %}
<div class="title-bar"> <div class="title-bar">
<h1>RECAPITULATIF SALAIRE DU {{ from|date('d/m/Y') }} au {{ to|date('d/m/Y') }}</h1>
<div class="month-box">{{ months[from|date('n')|number_format] }} {{ from|date('Y') }}</div> <div class="month-box">{{ months[from|date('n')|number_format] }} {{ from|date('Y') }}</div>
<h1>RECAPITULATIF SALAIRE DU {{ from|date('d/m/Y') }} au {{ to|date('d/m/Y') }}</h1>
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
</div> </div>
<table class="recap"> <table class="recap">
<thead> <thead>
<tr> <tr>
<th rowspan="2" style="width: 24mm; text-align: left;">Nom</th> <th rowspan="2" style="width: 20mm; text-align: left;">Nom</th>
<th rowspan="2" style="width: 12mm;">Base</th> <th rowspan="2" style="width: 10mm;">Base</th>
<th rowspan="2" style="width: 12mm;">Jour de<br>présence<br>Cadre</th> <th rowspan="2" style="width: 10mm;">Jour de<br>présence<br>Cadre</th>
<th rowspan="2" style="width: 9mm;">Frais<br>Kms</th> <th rowspan="2" style="width: 8mm;">Frais<br>Kms</th>
<th rowspan="2" style="width: 9mm;">Heures<br>de<br>nuit</th> <th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th>
<th rowspan="2" style="width: 9mm;">Panier<br>de<br>nuit</th> <th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th>
<th rowspan="2" style="width: 12mm;">Heures<br>payés</th> <th rowspan="2" style="width: 10mm;">Heures<br>payés</th>
<th rowspan="2" style="width: 9mm;">Heures<br>dim.</th> <th rowspan="2" style="width: 8mm;">Heures<br>férié</th>
<th rowspan="2" style="width: 9mm;">Prime</th> <th rowspan="2" style="width: 8mm;">Heures<br>dim.</th>
<th rowspan="2" style="width: 8mm;">Prime</th>
<th colspan="2">Congés</th> <th colspan="2">Congés</th>
<th colspan="2">Maladie</th> <th colspan="2">Maladie</th>
<th colspan="4">CHAUFFEUR</th> <th colspan="4">CHAUFFEUR</th>
<th rowspan="2" style="width: 26mm;">Observations</th> <th rowspan="2" style="width: 20mm;">Observations</th>
</tr> </tr>
<tr> <tr>
<th style="width: 10mm;">Nbre</th> <th style="width: 8mm;">Nbre</th>
<th style="width: 26mm;">Date</th> <th style="width: 22mm;">Date</th>
<th style="width: 10mm;">Nbre</th> <th style="width: 8mm;">Nbre</th>
<th style="width: 26mm;">Date</th> <th style="width: 22mm;">Date</th>
<th style="width: 8mm;">PDJ</th> <th style="width: 7mm;">PDJ</th>
<th style="width: 10mm;">REPAS</th> <th style="width: 9mm;">REPAS</th>
<th style="width: 12mm;">NUITEE</th> <th style="width: 10mm;">NUITEE</th>
<th style="width: 12mm;">samedi</th> <th style="width: 10mm;">samedi</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for siteId, group in siteGroups %} {% for siteId, group in siteGroups %}
{% set siteColor = group.color ?? '#B3E5FC' %} {% set siteColor = group.color ?? '#B3E5FC' %}
<tr class="site-header"> <tr class="site-header">
<td style="background: {{ siteColor }}; text-align: left;" colspan="18"> <td style="background: {{ siteColor }}; text-align: left;" colspan="19">
{{ group.name }} {{ group.name }}
</td> </td>
</tr> </tr>
@@ -143,6 +154,7 @@
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td> <td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td> <td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td> <td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
<td class="num">{{ row.holidayHours > 0 ? row.holidayHours : '' }}</td>
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td> <td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td> <td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td> <td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
@@ -157,7 +169,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="18">Aucun employé.</td> <td colspan="19">Aucun employé.</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}

View File

@@ -94,6 +94,87 @@ final class EmployeeContractPeriodValidatorTest extends TestCase
$this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-10'), $currentPeriod); $this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-10'), $currentPeriod);
} }
public function testAssertWorkDaysHoursAcceptsNullForStandardContract(): void
{
$this->validator->assertWorkDaysHours($this->buildContract(35), ContractNature::CDI, null);
self::assertTrue(true); // no exception
}
public function testAssertWorkDaysHoursRejectsScheduleOn35hContract(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->validator->assertWorkDaysHours($this->buildContract(35), ContractNature::CDI, [1 => 120]);
}
public function testAssertWorkDaysHoursRejectsScheduleOnForfaitContract(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->validator->assertWorkDaysHours($this->buildContract(null, Contract::TRACKING_PRESENCE), ContractNature::CDI, [1 => 120]);
}
public function testAssertWorkDaysHoursAcceptsNullForInterim(): void
{
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::INTERIM, null);
self::assertTrue(true);
}
public function testAssertWorkDaysHoursRequiresScheduleForCustomContract(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('workDaysHours is required');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, null);
}
public function testAssertWorkDaysHoursRequiresScheduleForCustomContractOnEmptyArray(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('workDaysHours is required');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, []);
}
public function testAssertWorkDaysHoursRejectsIsoOutsideOneToFive(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('iso weekdays 1-5');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [6 => 120, 7 => 120]);
}
public function testAssertWorkDaysHoursRejectsIsoZero(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('iso weekdays 1-5');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [0 => 240]);
}
public function testAssertWorkDaysHoursRejectsNegativeMinutes(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('non-negative integer minutes');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => -120, 4 => 360]);
}
public function testAssertWorkDaysHoursRejectsSumMismatch(): void
{
$this->expectException(UnprocessableEntityHttpException::class);
$this->expectExceptionMessage('total must equal contract weekly hours');
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => 60, 4 => 60]);
}
public function testAssertWorkDaysHoursAcceptsValidScheduleFor4hContract(): void
{
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => 120, 4 => 120]);
self::assertTrue(true);
}
private function buildContract(?int $weeklyHours, string $trackingMode = Contract::TRACKING_TIME): Contract
{
return new Contract()
->setName('Test')
->setTrackingMode($trackingMode)
->setWeeklyHours($weeklyHours)
;
}
private function buildCurrentPeriod(string $startDate, ?string $endDate): EmployeeContractPeriod private function buildCurrentPeriod(string $startDate, ?string $endDate): EmployeeContractPeriod
{ {
$contract = new Contract() $contract = new Contract()

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Contract;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* @internal
*/
final class HolidayVirtualHoursResolverTest extends TestCase
{
private HolidayVirtualHoursResolver $resolver;
protected function setUp(): void
{
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
$holidayService->method('getHolidaysDayByYears')->willReturnCallback(
static fn (string $zone, string $year): array => [
// Mon 14/07/2025 (lundi)
'2025-07-14' => '14 juillet',
// Fri 15/08/2025 (vendredi)
'2025-08-15' => '15 août',
// Sat 11/11/2025 (samedi)
'2025-11-15' => 'Samedi test',
// Thu 25/12/2025
'2025-12-25' => 'Noël',
]
);
$this->resolver = new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$holidayService,
$this->createStub(EmployeeContractResolver::class),
);
}
public function testReturnsZeroWhenContractIsNull(): void
{
self::assertSame(0, $this->resolver->resolveVirtualCredit(null, new DateTimeImmutable('2025-07-14')));
}
public function testReturnsZeroForForfaitPresenceContract(): void
{
$contract = new Contract()
->setName('Forfait')
->setTrackingMode('PRESENCE')
->setWeeklyHours(null)
;
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function testReturnsZeroWhenDayIsNotHoliday(): void
{
$contract = $this->build35hContract();
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-07')));
}
public function testReturnsZeroWhenHolidayFallsOnSaturday(): void
{
$contract = $this->build35hContract();
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-11-15')));
}
public function test35hMondayGetsSevenHours(): void
{
$contract = $this->build35hContract();
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function test39hMondayGetsEightHours(): void
{
$contract = $this->build39hContract();
self::assertSame(8 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function test39hFridayGetsSevenHours(): void
{
$contract = $this->build39hContract();
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-08-15')));
}
public function testCustomContractUsesProRataReference(): void
{
$contract = new Contract()
->setName('28h')
->setTrackingMode('TIME')
->setWeeklyHours(28)
;
// 28h / 5 = 5.6h = 336 min
self::assertSame(336, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function testInterimContractAlsoReceivesCredit(): void
{
$contract = new Contract()
->setName('Interim')
->setTrackingMode('TIME')
->setWeeklyHours(35)
;
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
}
public function testEffectiveDailyMinutesReturnsActualWhenGreaterThanReference(): void
{
$contract = $this->build39hContract();
// 10h worked on a férié Monday with 39h contract (ref = 8h)
self::assertSame(600, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-14'), 600));
}
public function testEffectiveDailyMinutesReturnsReferenceWhenActualLower(): void
{
$contract = $this->build39hContract();
// 4h worked on a férié Monday with 39h contract (ref = 8h) → 8h
self::assertSame(8 * 60, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-14'), 240));
}
public function testEffectiveDailyMinutesDelegatesWhenRuleDoesNotApply(): void
{
$contract = $this->build39hContract();
// Non-holiday day: rule does not apply, return actual
self::assertSame(420, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-07'), 420));
}
public function testFallsBackGracefullyWhenHolidayServiceFails(): void
{
$failingService = $this->createStub(PublicHolidayServiceInterface::class);
$failingService->method('getHolidaysDayByYears')->willThrowException(new RuntimeException('boom'));
$resolver = new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$failingService,
$this->createStub(EmployeeContractResolver::class),
);
self::assertSame(0, $resolver->resolveVirtualCredit($this->build35hContract(), new DateTimeImmutable('2025-07-14')));
}
public function testScheduledWorkdayGetsCreditOnHoliday(): void
{
// 4h contract, schedule Mon 2h + Thu 2h
$contract = new Contract()->setName('4h')->setTrackingMode('TIME')->setWeeklyHours(4);
// Holiday 2025-07-14 is a Monday → 120 min credit
self::assertSame(120, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14'), false, [1 => 120, 4 => 120]));
}
public function testUnscheduledWorkdayGetsZeroOnHoliday(): void
{
$contract = new Contract()->setName('4h')->setTrackingMode('TIME')->setWeeklyHours(4);
// Holiday 2025-07-14 is a Monday but schedule only Tue+Fri → 0
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14'), false, [2 => 120, 5 => 120]));
}
private function build35hContract(): Contract
{
return new Contract()
->setName('35h')
->setTrackingMode('TIME')
->setWeeklyHours(35)
;
}
private function build39hContract(): Contract
{
return new Contract()
->setName('39h')
->setTrackingMode('TIME')
->setWeeklyHours(39)
;
}
}

View File

@@ -9,6 +9,7 @@ use App\Entity\AbsenceType;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTime; use DateTime;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -20,7 +21,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
{ {
public function testComputeCreditedMinutesFor35hHalfDay(): void public function testComputeCreditedMinutesFor35hHalfDay(): void
{ {
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false); $minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
@@ -28,19 +29,52 @@ final class WorkedHoursCreditPolicyTest extends TestCase
self::assertSame(210, $minutes); self::assertSame(210, $minutes);
} }
public function testComputeCreditedMinutesFor4hContractFullDay(): void public function testComputeCreditedMinutesFor4hContractUsesWorkDaysSchedule(): void
{ {
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); // 4h contract with schedule Mon 2h + Thu 2h
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true); $schedule = [1 => 120, 4 => 120];
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true); // 2026-02-16 is a Monday: full day absence credits 2h (matches scheduled Monday)
self::assertSame(120, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
}
self::assertSame(120, $minutes); public function testComputeCreditedMinutesFor4hContractOnUnscheduledDayReturnsZero(): void
{
// 4h contract with schedule Mon 2h + Thu 2h
$schedule = [1 => 120, 4 => 120];
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(
trackMode: Contract::TRACKING_TIME,
weeklyHours: 4,
countAsWorked: true,
start: '2026-02-17',
end: '2026-02-17',
);
// 2026-02-17 is a Tuesday — not a scheduled workday → 0 credit
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-17', true, true));
}
public function testComputeCreditedMinutesHalfDayOnAsymmetricScheduleDay(): void
{
// Asymmetric schedule: Monday is a 3h day (180 min)
$schedule = [1 => 180];
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub($schedule), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 3, countAsWorked: true);
// 2026-02-16 Monday, morning only → round(180/2 * 1) = 90 min
self::assertSame(90, $policy->computeCreditedMinutes($absence, '2026-02-16', true, false));
// Afternoon only → same 90 min (half of day)
self::assertSame(90, $policy->computeCreditedMinutes($absence, '2026-02-16', false, true));
// Full day → 180 min
self::assertSame(180, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
} }
public function testComputeCreditedPresenceUnitsForPresenceContract(): void public function testComputeCreditedPresenceUnitsForPresenceContract(): void
{ {
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
// Forfait : les absences ne créditent jamais de présence, seules les checkboxes comptent. // Forfait : les absences ne créditent jamais de présence, seules les checkboxes comptent.
@@ -50,15 +84,20 @@ final class WorkedHoursCreditPolicyTest extends TestCase
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
{ {
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub()); $policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false); $absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true)); self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true)); self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
} }
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence private function buildAbsence(
{ string $trackMode,
?int $weeklyHours,
bool $countAsWorked,
string $start = '2026-02-16',
string $end = '2026-02-16',
): Absence {
$contract = new Contract() $contract = new Contract()
->setName('Contrat test') ->setName('Contrat test')
->setTrackingMode($trackMode) ->setTrackingMode($trackMode)
@@ -79,18 +118,25 @@ final class WorkedHoursCreditPolicyTest extends TestCase
return new Absence() return new Absence()
->setEmployee($employee) ->setEmployee($employee)
->setType($type) ->setType($type)
->setStartDate(new DateTime('2026-02-16')) ->setStartDate(new DateTime($start))
->setEndDate(new DateTime('2026-02-16')) ->setEndDate(new DateTime($end))
; ;
} }
private function buildResolverStub(): EmployeeContractResolver /**
* @param null|array<int, int> $schedule
*/
private function buildResolverStub(?array $schedule = null): EmployeeContractResolver
{ {
$resolver = $this->createStub(EmployeeContractResolver::class); $resolver = $this->createStub(EmployeeContractResolver::class);
$resolver $resolver
->method('resolveForEmployeeAndDate') ->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract()) ->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
; ;
$resolver
->method('resolveWorkDaysMinutesForEmployeeAndDate')
->willReturn($schedule)
;
return $resolver; return $resolver;
} }

View File

@@ -15,7 +15,6 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger; use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use App\State\AbsenceWriteProcessor; use App\State\AbsenceWriteProcessor;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -37,7 +36,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub(); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM); $absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
@@ -65,7 +64,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub(); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -86,7 +85,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub(); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -108,7 +107,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub(); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class)); $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createStub(AuditLogger::class));
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
@@ -142,12 +141,4 @@ final class AbsenceWriteProcessorTest extends TestCase
return $security; return $security;
} }
private function createEmptyHolidayServiceStub(): PublicHolidayServiceInterface
{
$service = $this->createStub(PublicHolidayServiceInterface::class);
$service->method('getHolidaysDayByYears')->willReturn([]);
return $service;
}
} }

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