Compare commits

..

29 Commits

Author SHA1 Message Date
gitea-actions
3926946a5f chore: bump version to v0.1.94
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-20 10:12:10 +00:00
b9c3a8a84f [#SIRH-25] Version mobile (#16)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:47:14 +02:00
gitea-actions
9e411be3c3 chore: bump version to v0.1.89
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-04-17 09:05:24 +00:00
90e63a463e feat : autoriser la création d'absences sur les jours fériés depuis le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:04:57 +02:00
gitea-actions
51bf155b0e chore: bump version to v0.1.88
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-17 06:59:10 +00:00
1095421424 feat : modification des exports PDF et affichage du type de contrat sur l'écran des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-17 08:58:58 +02:00
gitea-actions
be7c16778a chore: bump version to v0.1.87
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 46s
2026-04-16 13:52:31 +00:00
a8fe244b5c feat : modification de la gestion des jours fériés
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-04-16 15:52:19 +02:00
gitea-actions
13c71abddc chore: bump version to v0.1.86
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-04-14 13:55:11 +00:00
9581f9d8d9 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:55:03 +02:00
c2eaa06aff fix : écran du récap. congés ordre d'affichage + Calcule des jours ouvrés pour les FORFAIT 2026-04-14 15:54:57 +02:00
gitea-actions
187a634cc8 chore: bump version to v0.1.85
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-14 13:08:56 +00:00
0897154460 feat : ajout d'un écran pour le récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:08:45 +02:00
gitea-actions
11331da6a1 chore: bump version to v0.1.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-14 09:25:55 +00:00
399fd7335e fix : exclusion de certain jour férié et affichage différent des jours férié dans la page d'heure
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 11:25:44 +02:00
gitea-actions
46cb7f1a16 chore: bump version to v0.1.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-14 06:38:09 +00:00
b934f4d81f Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:38:01 +02:00
77c1cdcbbd fix : on masque la validation chef site 2026-04-14 08:37:54 +02:00
gitea-actions
de302d9ded chore: bump version to v0.1.82
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-14 06:25:17 +00:00
ef18210bf7 fix : export du récap congés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:24:43 +02:00
102 changed files with 5154 additions and 865 deletions

View File

@@ -3,6 +3,7 @@
.env.local
.env.test
docker/
!docker/php/config/php.ini
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example

3
.env
View File

@@ -38,6 +38,9 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
###> app ###
RTT_START_DATE=2026-02-23
# Comma-separated list of public holiday labels to exclude from the government API response
# (typically the "journée de solidarité" worked in many companies)
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
###< app ###
###> nelmio/cors-bundle ###

View File

@@ -30,11 +30,21 @@
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
- Création/édition d'absence **autorisée** sur un férié (bouton Modifier visible). En présence d'absence, le crédit d'heures suit `absence.type.countAsWorkedHours` (WorkedHoursCreditPolicy), pas le crédit virtuel férié.
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -49,6 +59,17 @@
- 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.
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 14j)`. Pas de gate `isValid`.
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
- Pas d'export PDF depuis cet écran
- Doc détaillée : `doc/leave-recap-screen.md`
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`

View File

@@ -23,3 +23,5 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
```
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/

View File

@@ -25,6 +25,7 @@ services:
App\Service\PublicHolidayService:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
App\Service\Rtt\RttRecoveryComputationService:
arguments:

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.81'
app.version: '0.1.94'

View File

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

View File

@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y \
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \

View File

@@ -130,6 +130,7 @@ Documents complementaires:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
- agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"
## 6bis) Heures Conducteurs
@@ -161,10 +162,14 @@ Documents complementaires:
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
- absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
## 8) Impression absences (PDF)
@@ -326,6 +331,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 |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 10bis) Écran Récap. congés (tableau)
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
- Endpoint: `GET /api/leave-recap`
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
- Scoping :
- `ROLE_ADMIN` : tous les employés
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
- `ROLE_SELF` : uniquement son employé lié
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
- Formule : `cutoffDate = dimanche(lundi_semaine_courante 14 jours)`
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
- `isValid` n'entre PAS en compte : cutoff purement temporel
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
- Colonnes identiques au PDF (voir §10)
- Détails techniques : voir `doc/leave-recap-screen.md`
## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)

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

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

View File

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

View File

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

View File

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

View File

@@ -45,9 +45,9 @@
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:class="getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation"
:disabled="getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)"
>
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -80,9 +80,7 @@
<button
type="button"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@click="handleCellClick(employee, day.date)"
>
<span></span>

View File

@@ -1,5 +1,5 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export heures annuelles">
<AppDrawer v-model="drawerOpen" title="Export heures">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
@@ -14,6 +14,20 @@
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-month">
Mois
</label>
<select
id="yearly-hours-month"
v-model="selectedMonth"
:class="selectFieldClass"
>
<option value="">Toute l'année</option>
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
@@ -37,7 +51,7 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', year: number): void
(event: 'submit', payload: { year: number; month: number | null }): void
}>()
const drawerOpen = computed({
@@ -47,13 +61,31 @@ const drawerOpen = computed({
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const months = [
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Février' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Août' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Décembre' }
]
const selectedYear = ref(currentYear)
const selectedMonth = ref<number | ''>('')
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => {
emit('submit', selectedYear.value)
emit('submit', {
year: selectedYear.value,
month: selectedMonth.value === '' ? null : selectedMonth.value
})
}
watch(
@@ -61,6 +93,7 @@ watch(
(isOpen) => {
if (!isOpen) {
selectedYear.value = currentYear
selectedMonth.value = ''
}
}
)

View File

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

View File

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

View File

@@ -16,7 +16,7 @@
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
>
<p>{{ contractNatureLabel(item.contractNature) }}</p>
<p>{{ item.interimAgencyName ? `${contractNatureLabel(item.contractNature)} (${item.interimAgencyName})` : contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
@@ -108,6 +108,13 @@
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div>
<WorkDaysHoursInput
v-if="contractForm.workDaysHours"
:model-value="contractForm.workDaysHours"
:contract-weekly-hours="contractForm.weeklyHours ?? null"
disabled
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
Commentaire
@@ -214,6 +221,22 @@
</select>
</div>
<div v-if="createContractForm.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="create-interim-agency">
Agence d'intérim
</label>
<select
id="create-interim-agency"
v-model="createContractForm.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
Temps de travail <span class="text-red-600">*</span>
@@ -252,7 +275,13 @@
</label>
</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
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"
@@ -269,6 +298,8 @@
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
import type { InterimAgency } from '~/services/interim-agencies'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
type SuspensionForm = {
id: number | null
@@ -286,6 +317,7 @@ type ContractForm = {
endDate: string
paidLeaveSettled: boolean
comment: string
workDaysHours: Record<number, number> | null
}
type CreateContractForm = {
@@ -294,6 +326,8 @@ type CreateContractForm = {
startDate: string
endDate: string
isDriver: boolean
workDaysHours: Record<number, number> | null
interimAgencyId: number | ''
}
const props = defineProps<{
@@ -322,6 +356,8 @@ const props = defineProps<{
requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string
isCreateContractFormValid: boolean
requiresCreateWorkDaysHours: boolean
selectedCreateContract: Contract | null
onOpenCloseContractDrawer: () => void
onOpenCreateContractDrawer: () => void
onUpdateContractDrawerOpen: (open: boolean) => void
@@ -333,6 +369,7 @@ const props = defineProps<{
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
interimAgencies: InterimAgency[]
}>()
const drawerTab = ref<'close' | 'suspend'>('close')

View File

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

View File

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

View File

@@ -1,17 +1,68 @@
<template>
<div class="py-6 flex flex-col gap-3">
<div class="flex gap-4">
<div class="py-4 flex flex-col gap-3 lg:py-6">
<!-- Desktop: filters row -->
<div class="hidden lg:flex lg:gap-4">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div v-if="isAdmin" class="w-80 max-w-full">
<div v-if="isAdmin" class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
<!-- Mobile: search + filter button -->
<div v-if="isAdmin" class="flex gap-2 lg:hidden">
<div class="flex-1 min-w-0">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
<button
type="button"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
@click="filtersDrawerOpen = true"
>
<Icon name="mdi:filter-variant" size="22"/>
</button>
</div>
<!-- Mobile filters drawer -->
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
<div class="space-y-6">
<div v-if="sites.length > 0 && isAdmin">
<label class="text-md font-semibold text-neutral-700">Sites</label>
<div class="mt-2">
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites" />
</div>
</div>
<div v-if="isAdmin">
<label class="text-md font-semibold text-neutral-700">Vue</label>
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('week')"
@click="viewMode = 'week'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
</div>
</AppDrawer>
<!-- Date navigation -->
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
<div
v-if="viewMode === 'day'"
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -41,7 +92,7 @@
<div
v-else
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -70,7 +121,7 @@
</div>
<PeriodStepperPicker
width-class="w-[320px]"
width-class="w-full lg:w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
@@ -82,7 +133,8 @@
/>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<!-- Desktop: view mode toggle -->
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
@@ -106,7 +158,7 @@
<div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6"
class="hidden lg:flex flex-wrap items-center gap-6"
>
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
@@ -123,6 +175,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import AppDrawer from '~/components/AppDrawer.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
@@ -150,6 +203,8 @@ const emit = defineEmits<{
(e: 'shift-date', value: number): void
}>()
const filtersDrawerOpen = ref(false)
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value

View File

@@ -1,7 +1,71 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<!-- Mobile cards -->
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-xs text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div>
<!-- Daily breakdown -->
<div class="mb-3 space-y-1">
<div
v-for="(daily, i) in row.daily"
:key="daily.date"
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
:style="getDailyCellStyle(daily)"
>
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
</div>
</div>
<!-- Weekly totals -->
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="flex justify-between">
<span class="text-neutral-500">Total sem.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">H. supp.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+25%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+50%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">Récup.</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
</div>
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
<span class="text-neutral-500">Panier nuit</span>
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
</div>
</div>
</div>
</div>
<!-- Desktop table -->
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
@@ -29,7 +93,9 @@
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
<p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div>
<div
@@ -81,6 +147,7 @@
<script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
import { contractNatureLabel } from '~/utils/contract'
const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM

View File

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

View File

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

View File

@@ -4,8 +4,9 @@ import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { listInterimAgencies, type InterimAgency } from '~/services/interim-agencies'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = {
id: number | null
@@ -17,6 +18,7 @@ type SuspensionForm = {
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const toast = useToast()
const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
@@ -32,7 +34,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
comment: '',
workDaysHours: null as Record<number, number> | null
})
const validationTouched = reactive({
@@ -44,7 +47,9 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
isDriver: false
isDriver: false,
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
})
const createValidationTouched = reactive({
@@ -59,10 +64,11 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
const base = item.weeklyHours !== null && item.weeklyHours !== undefined
? `${item.weeklyHours} heures`
: (item.contractName ?? '-')
const scheduleSummary = formatWorkDaysHoursSummary(item.workDaysHours)
return scheduleSummary ? `${base} (${scheduleSummary})` : base
}
const currentActiveContractPeriod = computed(() => {
@@ -111,11 +117,27 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const selectedCreateContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(createContractForm.contractId)) ?? null
)
const requiresCreateWorkDaysHours = computed(() =>
requiresWorkDaysHours(selectedCreateContract.value, createContractForm.contractNature)
)
const createScheduleTotalMinutes = computed(() => {
const raw = createContractForm.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isCreateScheduleValid = computed(() => {
if (!requiresCreateWorkDaysHours.value) return true
const expected = (selectedCreateContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && createScheduleTotalMinutes.value === expected
})
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
isCreateContractEndDateValid.value &&
isCreateScheduleValid.value
)
const baseInputClass =
@@ -159,6 +181,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractForm.endDate = period.endDate ?? getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
contractForm.workDaysHours = period.workDaysHours ?? null
}
const openCloseContractDrawer = () => {
@@ -186,6 +209,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.isDriver = false
createContractForm.workDaysHours = null
createContractForm.interimAgencyId = ''
createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd()
@@ -261,7 +286,9 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver
isDriverInput: createContractForm.isDriver,
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null,
interimAgencyId: createContractForm.contractNature === 'INTERIM' && createContractForm.interimAgencyId !== '' ? Number(createContractForm.interimAgencyId) : null
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
@@ -313,12 +340,28 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contracts.value = await listContracts()
}
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
watch(() => createContractForm.contractNature, (nature) => {
if (nature !== 'INTERIM') {
createContractForm.interimAgencyId = ''
}
})
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
}
})
watch(requiresCreateWorkDaysHours, (required) => {
if (!required) {
createContractForm.workDaysHours = null
}
})
return {
contracts,
contractHistory,
@@ -342,6 +385,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel,
contractHistoryLabel,
formatDate,
@@ -356,6 +401,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
loadContracts
interimAgencies,
loadContracts,
loadInterimAgencies
}
}

View File

@@ -86,7 +86,7 @@ export const useEmployeeDetailPage = () => {
})
onMounted(async () => {
await contract.loadContracts()
await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
await loadEmployee()
})

View File

@@ -73,7 +73,7 @@ export const useHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
@@ -447,10 +447,21 @@ export const useHoursPage = () => {
nightMinutes += nightIntervalMinutes(from, to)
}
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
const dayRow = dayContextByEmployeeId.value.get(employeeId)
const creditedMinutes = dayRow?.creditedMinutes ?? 0
totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes }
let dayMinutes = Math.max(0, totalMinutes - nightMinutes)
// Virtual holiday credit: the backend already applies the contract-period
// schedule (workDaysHours) and the absence-override rule, so just use the
// computed value instead of recomputing on the client.
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
if (virtualHolidayMinutes > totalMinutes) {
dayMinutes += virtualHolidayMinutes - totalMinutes
totalMinutes = virtualHolidayMinutes
}
return { dayMinutes, nightMinutes, totalMinutes, virtualHolidayMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
@@ -458,7 +469,6 @@ export const useHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -584,7 +594,6 @@ export const useHoursPage = () => {
const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false
@@ -1127,6 +1136,7 @@ export const useHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,

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: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:0021:00), heures de nuit (00:0006:00 et 21:0024:00) et total.' },
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) et la création d\'absences sont autorisées.' },
{ type: 'note', content: 'Crédit automatique sur jour férié Lun-Ven : pour tout contrat hors Forfait et s\'il n\'y a pas d\'absence déclarée, un jour férié compte au minimum les heures contractuelles attendues (35h → 7h, 39h → 8h Lun-Jeu / 7h Ven). Si vous saisissez des heures supérieures à cette référence, ce sont vos heures qui sont comptées ; sinon c\'est la référence. Les conducteurs reçoivent ce crédit dans leur bucket "Heures jour". **Si une absence est posée sur le férié**, c\'est le paramétrage du type d\'absence (compte les heures oui/non) qui pilote les heures comptées, le crédit virtuel férié ne s\'applique plus.' },
{ type: 'note', content: 'Contrats non-standards (4h, 25h, 28h, etc.) : un planning par jour travaillé doit être saisi à la création/modification du contrat (bloc « Jours travaillés » avec case à cocher + horaire par jour). Le crédit férié et le crédit d\'absence ne s\'appliquent que sur les jours programmés, avec les heures programmées. Ex. un 4h Lundi 2h + Jeudi 2h : férié le lundi → +2h, férié le mardi → 0h.' },
],
},
{
@@ -204,10 +207,10 @@ export const documentationSections: DocSection[] = [
},
{
id: 'gestion-types-absence',
title: 'Gestion des types d\'absence',
title: 'Gestion des types de statut',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les types d\'absence définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'paragraph', content: 'Les types de statut définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
],
@@ -255,7 +258,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nAgence d\'intérim (visible uniquement pour INTERIM, optionnel)\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
],
},
{
@@ -384,7 +387,8 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair\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.' },
],
},
{
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.' },
],
},
],
},
{

View File

@@ -56,6 +56,9 @@
"create": "Impossible de créer l'observation.",
"update": "Impossible de mettre à jour l'observation.",
"delete": "Impossible de supprimer l'observation."
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
}
},
"success": {

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Types d'absence</h1>
<h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@@ -164,7 +164,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
useHead({
title: 'Types d\'absences'
title: 'Types de statut'
})
const isDrawerOpen = ref(false)

View File

@@ -490,14 +490,15 @@ const hasFormationOn = (employeeId: number, date: string): boolean => {
return cellFormationMap.value.has(`${employeeId}-${date}`)
}
// Jours fériés (interdit pour la création).
// Jours fériés.
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
}
// Renvoie l'absence effective pour une cellule (ou un "Férié").
// Renvoie l'absence effective pour une cellule (ou un "Férié" si pas d'absence).
const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) {
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (!absence && isHolidayDate(date)) {
return {
id: 0,
code: 'Férié',
@@ -505,7 +506,6 @@ const getCellAbsence = (employeeId: number, date: string) => {
textColor: '#0f172a'
}
}
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (hasFormationOn(employeeId, date)) {
return {
@@ -549,11 +549,6 @@ const getCellInfo = (employeeId: number, date: string) => {
// Ouverture du drawer depuis une cellule.
const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
@@ -590,10 +585,6 @@ const openCreateFromToday = () => {
form.typeId = ''
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
if (isHolidayDate(today)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
form.startDate = today
form.endDate = today
form.startHalf = 'AM'

View File

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

View File

@@ -17,7 +17,7 @@
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<button
class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
title="Export heures annuelles"
title="Export heures"
@click="isYearlyHoursDrawerOpen = true"
>
<Icon name="mdi:printer" size="24" />
@@ -135,6 +135,8 @@
:requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid"
:requires-create-work-days-hours="requiresCreateWorkDaysHours"
:selected-create-contract="selectedCreateContract"
:on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen"
@@ -146,6 +148,7 @@
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
:interim-agencies="interimAgencies"
/>
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
@@ -274,6 +277,8 @@ const {
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel,
contractHistoryLabel,
formatDate,
@@ -291,6 +296,7 @@ const {
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
interimAgencies,
isLeaveLoading,
isRttLoading,
mileageAllowances,
@@ -317,9 +323,10 @@ const {
submitDeleteObservation
} = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (year: number) => {
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
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
}

View File

@@ -18,6 +18,13 @@
>
Export récap. salaire
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isYearlyHoursBulkOpen = true"
>
Export heures
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@@ -72,7 +79,7 @@
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Type:</strong> {{ employee.currentInterimAgencyName ? `${contractNatureLabel(employee.currentContractNature)} (${employee.currentInterimAgencyName})` : contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
@@ -147,6 +154,21 @@
Le type de contrat est obligatoire.
</p>
</div>
<div v-if="form.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="interim-agency">
Agence d'intérim
</label>
<select
id="interim-agency"
v-model="form.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
@@ -191,7 +213,7 @@
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD.
La date de fin est obligatoire pour un CDD ou un Intérim.
</p>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
@@ -205,6 +227,11 @@
Chauffeur
</label>
</div>
<WorkDaysHoursInput
v-if="requiresSchedule"
v-model="form.workDaysHours"
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
/>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -229,18 +256,28 @@
v-model="isSalaryRecapOpen"
@submit="handleSalaryRecapPrint"
/>
<BulkYearlyHoursDrawer
v-model="isYearlyHoursBulkOpen"
:is-loading="isYearlyHoursBulkLoading"
@submit="handleBulkYearlyHoursPrint"
/>
</div>
</template>
<script setup lang="ts">
import type {Contract} from '~/services/dto/contract'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
import { requiresWorkDaysHours } from '~/utils/contract'
import type {Employee} from '~/services/dto/employee'
import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import BulkYearlyHoursDrawer from '~/components/BulkYearlyHoursDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter'
@@ -252,6 +289,8 @@ const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isSalaryRecapOpen = ref(false)
const isYearlyHoursBulkOpen = ref(false)
const isYearlyHoursBulkLoading = ref(false)
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
@@ -262,6 +301,7 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const selectedSiteIds = ref<number[]>([])
@@ -292,7 +332,9 @@ const form = reactive({
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: '',
isDriver: false
isDriver: false,
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
})
const validationTouched = reactive({
@@ -310,6 +352,21 @@ const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
const selectedContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(form.contractId)) ?? null
)
const requiresSchedule = computed(() =>
!editingEmployee.value && requiresWorkDaysHours(selectedContract.value, form.contractNature)
)
const scheduleTotalMinutes = computed(() => {
const raw = form.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isScheduleValid = computed(() => {
if (!requiresSchedule.value) return true
const expected = (selectedContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && scheduleTotalMinutes.value === expected
})
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
@@ -327,7 +384,8 @@ const isFormValid = computed(
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
isContractEndDateValid.value &&
isScheduleValid.value))
)
const showFirstNameError = computed(
@@ -427,8 +485,12 @@ const loadContracts = async () => {
contracts.value = await listContracts()
}
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
await Promise.all([loadEmployees(), loadSites(), loadContracts(), loadInterimAgencies()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
@@ -478,7 +540,9 @@ const handleSubmit = async () => {
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver
isDriverInput: form.isDriver,
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null,
interimAgencyId: form.contractNature === 'INTERIM' && form.interimAgencyId !== '' ? Number(form.interimAgencyId) : null
})
}
@@ -490,6 +554,8 @@ const handleSubmit = async () => {
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -516,6 +582,18 @@ watch(showsContractEndDateComputed, (shows) => {
}
})
watch(() => form.contractNature, (nature) => {
if (nature !== 'INTERIM') {
form.interimAgencyId = ''
}
})
watch(requiresSchedule, (required) => {
if (!required) {
form.workDaysHours = null
}
})
const openEdit = (employee: Employee) => {
editingEmployee.value = employee
form.firstName = employee.firstName
@@ -534,6 +612,8 @@ const openCreate = () => {
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
isDrawerOpen.value = true
}
@@ -546,6 +626,17 @@ const handleSalaryRecapPrint = async (month: string) => {
isSalaryRecapOpen.value = false
}
const handleBulkYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
isYearlyHoursBulkLoading.value = true
try {
const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print-all?year=${payload.year}${monthParam}`)
isYearlyHoursBulkOpen.value = false
} finally {
isYearlyHoursBulkLoading.value = false
}
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
</div>
<HoursToolbar
@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
@@ -114,6 +115,7 @@
@delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer"
/>
</div>
</template>
@@ -141,6 +143,7 @@ const {
isSubmitting,
dayGridCols,
isSelectedDateHoliday,
selectedHolidayLabel,
weekGridCols,
saveButtonClass,
formattedSelectedDate,

View File

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

View File

@@ -1,13 +1,13 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-secondary-500 lg:px-4 lg:text-md"
@click="openCreate"
>
+ Ajouter un utilisateur
+ Ajouter
</button>
</div>
@@ -18,7 +18,8 @@
Aucun utilisateur pour le moment.
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Utilisateur</span>
<span class="text-left">Employé</span>
@@ -56,6 +57,42 @@
</div>
</div>
<!-- Mobile cards -->
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
Chargement...
</div>
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="user in users"
:key="'m-' + user.id"
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
@click="openEdit(user)"
>
<div class="flex items-center justify-between gap-2 mb-2">
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
<span
v-if="user.isLocked"
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
>Actif</span>
</div>
<div class="space-y-1 text-sm">
<p v-if="user.employee" class="text-neutral-600">
{{ user.employee.firstName }} {{ user.employee.lastName }}
</p>
<p class="text-neutral-500">
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
</p>
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
</p>
</div>
</div>
</div>
<AppDrawer
v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
@@ -189,6 +226,20 @@
</p>
</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">
<button
type="submit"
@@ -233,7 +284,8 @@ const form = reactive({
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[],
isLocked: false
isLocked: false,
hasLeaveRecapAccess: false
})
const validationTouched = reactive({
@@ -345,6 +397,7 @@ const resetForm = () => {
form.accessMode = 'admin'
form.siteIds = []
form.isLocked = false
form.hasLeaveRecapAccess = false
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
@@ -373,6 +426,7 @@ const openEdit = (user: User) => {
form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
@@ -427,7 +481,8 @@ const handleSubmit = async () => {
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId,
isLocked: form.isLocked
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -452,7 +507,8 @@ const handleSubmit = async () => {
plainPassword: form.password,
roles,
employeeId,
isLocked: form.isLocked
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
if (form.accessMode === 'sites' && form.siteIds.length > 0) {

View File

@@ -19,6 +19,9 @@ export type ContractHistoryItem = {
periodId?: number | null
suspensions?: ContractSuspension[]
isDriver?: boolean
workDaysHours?: Record<number, number> | null
interimAgencyId?: number | null
interimAgencyName?: string | null
}
export type Employee = {
@@ -36,4 +39,6 @@ export type Employee = {
displayOrder?: number
entryDate?: string | null
currentSuspensions?: ContractSuspension[]
currentInterimAgencyId?: number | null
currentInterimAgencyName?: string | null
}

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
roles: string[]
isDriver: boolean
hasLeaveRecapAccess: boolean
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
import { extractItems } from '~/utils/api'
export type InterimAgency = {
id: number
name: string
}
export const listInterimAgencies = async (): Promise<InterimAgency[]> => {
const api = useApi()
const data = await api.get<InterimAgency[] | { 'hydra:member'?: InterimAgency[] }>(
'/interim_agencies',
{},
{ toast: false }
)
return extractItems<InterimAgency>(data)
}

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

View File

@@ -13,9 +13,49 @@ export const showsContractEndDate = (nature: ContractNature) => {
}
export const requiresContractEndDate = (nature: ContractNature) => {
return nature === 'CDD'
return nature === 'CDD' || nature === 'INTERIM'
}
export const isContractNature = (value: string): value is ContractNature => {
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,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,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create interim_agencies table and add interim_agency_id to employee_contract_periods';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE interim_agencies (id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE)');
$this->addSql('ALTER TABLE employee_contract_periods ADD interim_agency_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT fk_ecp_interim_agency FOREIGN KEY (interim_agency_id) REFERENCES interim_agencies (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX idx_ecp_interim_agency ON employee_contract_periods (interim_agency_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT IF EXISTS fk_ecp_interim_agency');
$this->addSql('DROP INDEX IF EXISTS idx_ecp_interim_agency');
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN interim_agency_id');
$this->addSql('DROP TABLE interim_agencies');
}
}

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,24 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\EmployeeYearlyHoursBulkPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/yearly-hours/print-all',
provider: EmployeeYearlyHoursBulkPrintProvider::class,
parameters: [
new QueryParameter(key: 'year', required: true),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class EmployeeYearlyHoursBulkPrint {}

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,14 @@ final class ContractHistoryItem
public array $suspensions = [],
#[Groups(['employee:read'])]
public bool $isDriver = false,
/**
* @var null|array<int, int> iso-day → minutes
*/
#[Groups(['employee:read'])]
public ?array $workDaysHours = null,
#[Groups(['employee:read'])]
public ?int $interimAgencyId = null,
#[Groups(['employee:read'])]
public ?string $interimAgencyName = null,
) {}
}

View File

@@ -19,6 +19,7 @@ final class DayContextRow
public bool $isDriverContract = false,
public bool $hasFormation = false,
public ?string $formationLabel = null,
public int $virtualHolidayMinutes = 0,
) {}
public function setFormation(string $label): void
@@ -75,7 +76,8 @@ final class DayContextRow
* creditedPresenceUnits:float,
* isDriverContract:bool,
* hasFormation:bool,
* formationLabel:?string
* formationLabel:?string,
* virtualHolidayMinutes:int
* }
*/
public function toArray(): array
@@ -93,6 +95,7 @@ final class DayContextRow
'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 $hasDinner = false,
public bool $hasOvernight = false,
public int $virtualHolidayMinutes = 0,
) {}
}

View File

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

View File

@@ -92,6 +92,15 @@ class Employee
#[Groups(['employee:write'])]
private ?bool $isDriverInput = null;
/**
* @var null|array<int, int> iso-day → minutes, write-only (propagated to EmployeeContractPeriod)
*/
#[Groups(['employee:write'])]
private ?array $workDaysHoursInput = null;
#[Groups(['employee:write'])]
private ?int $interimAgencyId = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -261,6 +270,58 @@ class Employee
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;
}
public function getInterimAgencyId(): ?int
{
return $this->interimAgencyId;
}
public function setInterimAgencyId(?int $interimAgencyId): self
{
$this->interimAgencyId = $interimAgencyId;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentInterimAgencyId(): ?int
{
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getId();
}
#[Groups(['employee:read'])]
public function getCurrentInterimAgencyName(): ?string
{
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getName();
}
#[Groups(['employee:read'])]
public function getHasActiveContract(): bool
{
@@ -358,6 +419,9 @@ class Employee
periodId: $period->getId(),
suspensions: $suspensionData,
isDriver: $period->getIsDriver(),
workDaysHours: $period->getWorkDaysHours(),
interimAgencyId: $period->getInterimAgency()?->getId(),
interimAgencyName: $period->getInterimAgency()?->getName(),
);
},
$periods

View File

@@ -45,6 +45,20 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'boolean', options: ['default' => 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\ManyToOne(targetEntity: InterimAgency::class)]
#[ORM\JoinColumn(nullable: true)]
private ?InterimAgency $interimAgency = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null;
@@ -176,6 +190,36 @@ class EmployeeContractPeriod
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;
}
public function getInterimAgency(): ?InterimAgency
{
return $this->interimAgency;
}
public function setInterimAgency(?InterimAgency $interimAgency): self
{
$this->interimAgency = $interimAgency;
return $this;
}
/**
* @return Collection<int, ContractSuspension>
*/

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
],
normalizationContext: ['groups' => ['interim_agency:read']],
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
order: ['name' => 'ASC'],
)]
#[ORM\Entity]
#[ORM\Table(name: 'interim_agencies')]
class InterimAgency
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['interim_agency:read', 'employee:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 150, unique: true)]
#[Groups(['interim_agency:read', 'employee:read'])]
private string $name = '';
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
}

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,15 @@ namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\InterimAgency;
use App\Enum\ContractNature;
use DateTimeImmutable;
final class EmployeeContractPeriodBuilder
{
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function build(
Employee $employee,
Contract $contract,
@@ -19,6 +23,8 @@ final class EmployeeContractPeriodBuilder
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
?InterimAgency $interimAgency = null,
): EmployeeContractPeriod {
return new EmployeeContractPeriod()
->setEmployee($employee)
@@ -27,6 +33,8 @@ final class EmployeeContractPeriodBuilder
->setEndDate($endDate)
->setContractNature($nature)
->setIsDriver($isDriver)
->setWorkDaysHours($workDaysHours)
->setInterimAgency($interimAgency)
;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Entity\InterimAgency;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
@@ -29,15 +30,19 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) {
return;
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->flush();
}
@@ -75,8 +80,11 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
ContractNature $nature,
?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
if (null !== $todayPeriod) {
$this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
@@ -86,10 +94,14 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
}
}
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->flush();
}
/**
* @param null|array<int, int> $workDaysHours
*/
private function persistNewPeriod(
Employee $employee,
Contract $contract,
@@ -97,8 +109,24 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate,
ContractNature $nature,
bool $isDriver = false,
?array $workDaysHours = null,
?InterimAgency $interimAgency = null,
): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->persist($period);
}
private function resolveInterimAgency(?int $id): ?InterimAgency
{
if (null === $id) {
return null;
}
$agency = $this->entityManager->find(InterimAgency::class, $id);
if (null === $agency) {
throw new UnprocessableEntityHttpException(sprintf('Interim agency with id %d not found.', $id));
}
return $agency;
}
}

View File

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

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -60,4 +62,63 @@ final class EmployeeContractPeriodValidator
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 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
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
@@ -84,6 +98,57 @@ readonly class EmployeeContractResolver
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<string> $days
@@ -177,4 +242,23 @@ readonly class EmployeeContractResolver
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);
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);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
@@ -406,6 +409,29 @@ final readonly class LeaveBalanceComputationService
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
*

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
{
/**
* @var list<string>
*/
private array $excludedLabels;
public function __construct(
private HttpClientInterface $client,
private string $holidayUrl,
private CacheInterface $cache,
) {}
string $excludedLabels = '',
) {
$this->excludedLabels = array_values(array_filter(
array_map('trim', explode(',', $excludedLabels)),
static fn (string $label): bool => '' !== $label,
));
}
/**
* @throws TransportExceptionInterface
@@ -35,7 +46,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$zone = strtolower(trim($zone));
$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);
$url = $this->holidayUrl."{$zone}.json";
@@ -56,6 +67,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true);
});
return $this->applyExclusions($holidays);
}
/**
@@ -65,6 +78,19 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
* @throws ClientExceptionInterface
*/
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));
$years = trim($years);
@@ -89,4 +115,21 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
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 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\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
@@ -29,6 +31,8 @@ final readonly class RttRecoveryComputationService
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
string $rttStartDate = '',
) {
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
@@ -126,6 +130,7 @@ final readonly class RttRecoveryComputationService
$contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$naturesByDate = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days);
$workDaysByDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays([$employee], $days);
$employeeId = (int) $employee->getId();
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]);
@@ -137,7 +142,8 @@ final readonly class RttRecoveryComputationService
$metricsByDate[$dateKey] = $this->computeMetrics($workHour);
}
$creditedByDate = [];
$creditedByDate = [];
$hasAbsenceByDate = [];
foreach ($absences as $absence) {
$start = $absence->getStartDate()->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);
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
if ($absentMorning || $absentAfternoon) {
$hasAbsenceByDate[$date] = true;
}
$creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
}
}
@@ -188,14 +197,22 @@ final readonly class RttRecoveryComputationService
$dailyWorkedMinutes = [];
$employeeContractsByDate = [];
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) {
continue;
}
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes;
$dailyWorkedMinutes[$date] = $metrics->totalMinutes;
$effectiveMinutes = $this->holidayVirtualHoursResolver->resolveEffectiveDailyMinutes(
$contractAtDate,
new DateTimeImmutable($date),
$metrics->totalMinutes,
$hasAbsenceByDate[$date] ?? false,
$workDaysByDate[$employeeId][$date] ?? null,
);
$weeklyTotalMinutes += $effectiveMinutes;
$dailyWorkedMinutes[$date] = $effectiveMinutes;
}
if ([] === $weekDays) {
@@ -437,16 +454,6 @@ final readonly class RttRecoveryComputationService
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{
if ($isoWeekDay >= 6 || 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);
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
}
}

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(
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
) {}
/**
@@ -38,9 +39,11 @@ final readonly class WorkedHoursCreditPolicy
return 0;
}
$weekday = (int) $workDate->format('N');
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday);
$weekday = (int) $workDate->format('N');
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
// 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) {
return 0;
}
@@ -74,34 +77,14 @@ final readonly class WorkedHoursCreditPolicy
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.
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);
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay, $workDaysMinutes);
}
}

View File

@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use DateInterval;
use DateTimeImmutable;
class YearlyHoursExportBuilder
{
public function __construct(
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
/**
* @return list<string>
*/
public function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = [];
$current = $from;
while ($current <= $to) {
$days[] = $current->format('Y-m-d');
$current = $current->add(new DateInterval('P1D'));
}
return $days;
}
/**
* @param list<Employee> $employees
*
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
*/
public function buildForEmployees(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = $this->buildDays($from, $to);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days);
$results = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
$segments = $this->buildSegments(
$days,
$contractMap[$employeeId] ?? [],
$driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [],
$absenceData,
);
if ([] === $segments) {
continue;
}
$results[] = [
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
'contractLabel' => $this->buildContractLabel($employee),
'segments' => $segments,
];
}
return $results;
}
/**
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
*/
public function buildForEmployee(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
return $this->buildForEmployees([$employee], $from, $to);
}
public 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 array<int, array<string, WorkHour>>
*/
private function buildWorkHourMap(array $workHours): array
{
$map = [];
foreach ($workHours as $wh) {
$employeeId = $wh->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$date = $wh->getWorkDate()->format('Y-m-d');
$map[$employeeId][$date] = $wh;
}
return $map;
}
/**
* @return array<int, list<Absence>>
*/
private function buildAbsenceMap(array $absences, array $days): array
{
$map = [];
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$map[$employeeId][] = $absence;
}
return $map;
}
/**
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
*/
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
{
$credited = [];
$labels = [];
$absentMorning = [];
$absentAfternoon = [];
$hasDayAbsence = [];
foreach ($absences as $absence) {
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
if ($date < $start || $date > $end) {
continue;
}
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($isMorning || $isAfternoon) {
$hasDayAbsence[$date] = true;
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) {
$labels[$date] = $absence->getType()?->getLabel() ?? '';
}
}
$credited[$date] = ($credited[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
}
}
return [
'credited' => $credited,
'labels' => $labels,
'absentMorning' => $absentMorning,
'absentAfternoon' => $absentAfternoon,
'hasDayAbsence' => $hasDayAbsence,
];
}
/**
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/
private function buildSegments(
array $days,
array $contractsByDate,
array $driverByDate,
array $workHoursByDate,
array $absenceData,
): array {
$segments = [];
$currentMode = null;
$currentRows = [];
$currentName = null;
$firstDataDate = null;
foreach ($days as $date) {
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|| ($absenceData['hasDayAbsence'][$date] ?? false);
if ($hasRow) {
$firstDataDate = $date;
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;
if (!$hasData && !$isWeekend) {
continue;
}
if (!$hasData && null === $contract) {
continue;
}
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
$contractName = $contract?->getName();
if ($mode !== $currentMode) {
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
$currentMode = $mode;
$currentRows = [];
$currentName = $contractName;
}
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null;
$row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel,
'isWeekend' => $isWeekend,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['presentMorning'] = $morning > 0;
$row['presentAfternoon'] = $afternoon > 0;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
$row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin);
$row['workshopHours'] = $this->formatMinutes($workshopMin);
$row['total'] = $this->formatMinutes($totalMin);
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
}
$currentRows[] = $row;
}
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
return $segments;
}
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{
if ($isDriver) {
return 'driver';
}
if (TrackingMode::PRESENCE->value === $trackingMode) {
return 'presence';
}
return 'time';
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '';
}
$h = intdiv($minutes, 60);
$m = $minutes % 60;
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
}
}

View File

@@ -14,7 +14,6 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DatePeriod;
use DateTime;
@@ -24,7 +23,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
@@ -33,7 +31,6 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
private PublicHolidayServiceInterface $publicHolidayService,
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.');
}
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$publicHolidays = $this->buildPublicHolidayMap($start, $end);
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$segments = [];
foreach ($days as $day) {
if (isset($publicHolidays[$day->format('Y-m-d')])) {
continue;
}
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
$isSame = $isFirst && $isLast;
@@ -286,27 +278,4 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->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
* }
*/
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);
if ($targetYear < $firstYear) {
@@ -196,8 +196,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$carrySaturdays = 0.0;
}
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
);
@@ -330,6 +331,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
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(
Employee $employee,
DateTimeImmutable $from,
@@ -482,19 +490,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
string $ruleCode,
int $year,
DateTimeImmutable $periodEnd,
Employee $employee
Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable {
$today = new DateTimeImmutable('today');
$reference = $asOfDate ?? new DateTimeImmutable('today');
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
? (int) $today->format('Y')
: $this->resolveCurrentLeaveYear($today);
? (int) $reference->format('Y')
: $this->resolveCurrentLeaveYear($reference);
if ($year < $currentYear) {
$end = $periodEnd;
} elseif ($year > $currentYear) {
$end = null;
} else {
$lastDayPreviousMonth = $today
$lastDayPreviousMonth = $reference
->modify('first day of this month')
->modify('-1 day')
->setTime(0, 0)
@@ -516,10 +525,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function resolveTakenCalculationEndDate(
DateTimeImmutable $periodEnd,
Employee $employee
Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable {
$end = $periodEnd;
if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) {
$end = $asOfDate;
}
// Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
@@ -547,7 +561,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
{
$type = $employee->getContract()?->getType();
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);
$weekdayHolidays = array_filter(
array_keys($publicHolidays),
@@ -641,6 +658,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
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.
*
@@ -778,13 +818,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
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
{
$year = (int) $today->format('Y');

View File

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

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\EmployeeRepository;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Twig\Environment;
class EmployeeYearlyHoursBulkPrintProvider implements ProviderInterface
{
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private YearlyHoursExportBuilder $exportBuilder,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
{
$request = $this->requestStack->getCurrentRequest();
if (!$request) {
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
}
$yearRaw = (string) $request->query->get('year');
if (!preg_match('/^\d{4}$/', $yearRaw)) {
throw new UnprocessableEntityHttpException('year must use YYYY format.');
}
$year = (int) $yearRaw;
$monthRaw = (string) $request->query->get('month', '');
$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");
}
$employees = $this->employeeRepository->findAll();
usort($employees, fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? ''));
$entries = $this->exportBuilder->buildForEmployees($employees, $from, $to);
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('employee-yearly-hours/print-all.html.twig', [
'entries' => $entries,
'year' => $year,
'month' => $month,
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = null !== $month
? sprintf('heures_tous_%d-%02d.pdf', $year, $month)
: sprintf('heures_tous_%d.pdf', $year);
return new Response($dompdf->output(), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
}

View File

@@ -6,17 +6,9 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateInterval;
use App\Service\WorkHours\YearlyHoursExportBuilder;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
@@ -32,11 +24,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
private Environment $twig,
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private YearlyHoursExportBuilder $exportBuilder,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -62,49 +50,58 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
}
$year = (int) $yearRaw;
$from = new DateTimeImmutable("{$year}-01-01");
$to = new DateTimeImmutable("{$year}-12-31");
$days = $this->buildDays($from, $to);
$monthRaw = (string) $request->query->get('month', '');
$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;
}
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
$absences = $this->absenceRepository->findForPrint($from, $to, [$employee]);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days);
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");
}
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceData = $this->buildAbsenceData($absences, $days, $employee);
$entries = $this->exportBuilder->buildForEmployee($employee, $from, $to);
$segments = $this->buildSegments(
$employee,
$days,
$contractMap[$employee->getId()] ?? [],
$driverMap[$employee->getId()] ?? [],
$workHourMap[$employee->getId()] ?? [],
$absenceData,
);
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$contractLabel = $this->exportBuilder->buildContractLabel($employee);
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [
'employeeName' => $employeeName,
'year' => $year,
'segments' => $segments,
'employeeName' => $employeeName,
'contractLabel' => $contractLabel,
'year' => $year,
'month' => $month,
'segments' => $entries[0]['segments'] ?? [],
]);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = sprintf(
'%s_%s_%d.pdf',
$this->sanitizeFilename($employee->getLastName() ?? ''),
$this->sanitizeFilename($employee->getFirstName() ?? ''),
$year,
);
$filename = null !== $month
? sprintf(
'%s_%s_%d-%02d.pdf',
$this->sanitizeFilename($employee->getLastName() ?? ''),
$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, [
'Content-Type' => 'application/pdf',
@@ -112,305 +109,6 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
]);
}
/**
* @return list<string>
*/
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$days = [];
$current = $from;
while ($current <= $to) {
$days[] = $current->format('Y-m-d');
$current = $current->add(new DateInterval('P1D'));
}
return $days;
}
/**
* @return array<int, array<string, WorkHour>>
*/
private function buildWorkHourMap(array $workHours): array
{
$map = [];
foreach ($workHours as $wh) {
$employeeId = $wh->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$date = $wh->getWorkDate()->format('Y-m-d');
$map[$employeeId][$date] = $wh;
}
return $map;
}
/**
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
*/
private function buildAbsenceData(array $absences, array $days, Employee $employee): array
{
$credited = [];
$labels = [];
$absentMorning = [];
$absentAfternoon = [];
$hasDayAbsence = [];
foreach ($absences as $absence) {
$absEmployeeId = $absence->getEmployee()?->getId();
if ($absEmployeeId !== $employee->getId()) {
continue;
}
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
if ($date < $start || $date > $end) {
continue;
}
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($isMorning || $isAfternoon) {
$hasDayAbsence[$date] = true;
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
if (!isset($labels[$date])) {
$labels[$date] = $absence->getType()?->getLabel() ?? '';
}
}
$credited[$date] = ($credited[$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
}
}
return [
'credited' => $credited,
'labels' => $labels,
'absentMorning' => $absentMorning,
'absentAfternoon' => $absentAfternoon,
'hasDayAbsence' => $hasDayAbsence,
];
}
/**
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/
private function buildSegments(
Employee $employee,
array $days,
array $contractsByDate,
array $driverByDate,
array $workHoursByDate,
array $absenceData,
): array {
$segments = [];
$currentMode = null;
$currentRows = [];
$currentName = null;
foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
if (!$hasData) {
continue;
}
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
$contractName = $contract?->getName();
if ($mode !== $currentMode) {
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
$currentMode = $mode;
$currentRows = [];
$currentName = $contractName;
}
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null;
$row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel,
];
if ('presence' === $mode) {
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$total = $morning + $afternoon;
$row['presentMorning'] = $morning > 0;
$row['presentAfternoon'] = $afternoon > 0;
$row['total'] = $total > 0 ? (string) $total : '';
} elseif ('driver' === $mode) {
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
$row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin);
$row['workshopHours'] = $this->formatMinutes($workshopMin);
$row['total'] = $this->formatMinutes($totalMin);
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
}
$currentRows[] = $row;
}
if (null !== $currentMode && [] !== $currentRows) {
$segments[] = [
'mode' => $currentMode,
'contractName' => $currentName,
'rows' => $currentRows,
];
}
return $segments;
}
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{
if ($isDriver) {
return 'driver';
}
if (TrackingMode::PRESENCE->value === $trackingMode) {
return 'presence';
}
return 'time';
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '';
}
$h = intdiv($minutes, 60);
$m = $minutes % 60;
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
}
private function sanitizeFilename(string $name): string
{
$name = str_replace(' ', '_', $name);

View File

@@ -6,21 +6,13 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
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\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\Service\Leave\LeaveRecapRowBuilder;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
class LeaveRecapPrintProvider implements ProviderInterface
@@ -28,12 +20,8 @@ class LeaveRecapPrintProvider implements ProviderInterface
public function __construct(
private Environment $twig,
private EmployeeRepository $employeeRepository,
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private LeaveRecapRowBuilder $rowBuilder,
private EntityManagerInterface $entityManager,
private WorkHourRepository $workHourRepository,
) {}
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();
}
@@ -84,129 +72,4 @@ class LeaveRecapPrintProvider implements ProviderInterface
'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\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DateTimeImmutable;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
class SalaryRecapPrintProvider implements ProviderInterface
@@ -39,6 +41,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
private MileageAllowanceRepository $mileageAllowanceRepository,
private ObservationRepository $observationRepository,
private EmployeeContractResolver $contractResolver,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -71,6 +74,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$days = $this->buildDays($from, $to);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($from, $to);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences);
@@ -79,7 +83,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$mileageMap = $this->buildMileageMap($mileages);
$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->set('isRemoteEnabled', true);
@@ -208,6 +212,29 @@ class SalaryRecapPrintProvider implements ProviderInterface
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>
*/
@@ -236,6 +263,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
array $bonusMap,
array $mileageMap,
array $observationMap,
array $holidayMap,
): array {
$siteGroups = [];
@@ -257,6 +285,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
$bonusMap[$employeeId] ?? 0.0,
$mileageMap[$employeeId] ?? 0.0,
$observationMap[$employeeId] ?? '',
$holidayMap,
);
if (!isset($siteGroups[$siteId])) {
@@ -285,18 +314,20 @@ class SalaryRecapPrintProvider implements ProviderInterface
float $bonusAmount,
float $mileageKm,
string $observation,
array $holidayMap,
): array {
$contractName = null;
$presenceDays = 0.0;
$nightMinutesTotal = 0;
$nightBasketCount = 0;
$sundayMinutesTotal = 0;
$isDriverAnyDay = false;
$driverBreakfast = 0;
$driverMeals = 0;
$driverOvernight = 0;
$driverSaturdays = 0;
$isForfait = false;
$contractName = null;
$presenceDays = 0.0;
$nightMinutesTotal = 0;
$nightBasketCount = 0;
$sundayMinutesTotal = 0;
$holidayMinutesTotal = 0;
$isDriverAnyDay = false;
$driverBreakfast = 0;
$driverMeals = 0;
$driverOvernight = 0;
$driverSaturdays = 0;
$isForfait = false;
foreach ($days as $date) {
$contract = $contractsByDate[$date] ?? null;
@@ -318,10 +349,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
$isHoliday = isset($holidayMap[$date]);
if ($isDriver) {
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
$dayMin = $wh->getDayHoursMinutes() ?? 0;
$nightMin = $wh->getNightHoursMinutes() ?? 0;
$dayMin = $wh->getDayHoursMinutes() ?? 0;
$nightMin = $wh->getNightHoursMinutes() ?? 0;
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
++$nightBasketCount;
}
@@ -336,12 +370,16 @@ class SalaryRecapPrintProvider implements ProviderInterface
++$driverOvernight;
}
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || ($wh->getWorkshopHoursMinutes() ?? 0) > 0)) {
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || $workshopMin > 0)) {
++$driverSaturdays;
}
if (7 === $dayOfWeek) {
$sundayMinutesTotal += $dayMin + $nightMin + ($wh->getWorkshopHoursMinutes() ?? 0);
$sundayMinutesTotal += $dayMin + $nightMin + $workshopMin;
}
if ($isHoliday) {
$holidayMinutesTotal += $dayMin + $nightMin + $workshopMin;
}
} else {
$metrics = $this->computeNightMinutes($wh);
@@ -359,6 +397,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
}
if ($isHoliday) {
$holidayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
}
if ($isForfait) {
if ($wh->getIsPresentMorning()) {
$presenceDays += 0.5;
@@ -373,9 +415,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
$conges = $this->countAbsencesByCode($absences, ['C']);
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
$nightHours = round($nightMinutesTotal / 60, 2);
$paidHours = round($rttPaidMinutes / 60, 2);
$sundayHours = round($sundayMinutesTotal / 60, 2);
$nightHours = round($nightMinutesTotal / 60, 2);
$paidHours = round($rttPaidMinutes / 60, 2);
$sundayHours = round($sundayMinutesTotal / 60, 2);
$holidayHours = round($holidayMinutesTotal / 60, 2);
return [
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
@@ -387,6 +430,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
'nightBasketCount' => $nightBasketCount,
'paidHours' => $paidHours,
'sundayHours' => $sundayHours,
'holidayHours' => $holidayHours,
'bonusAmount' => $bonusAmount,
'congesCount' => $conges['count'],
'congesDates' => $conges['dates'],

View File

@@ -14,6 +14,7 @@ use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -32,6 +33,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
@@ -56,10 +58,12 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
// On initialise toutes les lignes, même sans absence ce jour-là.
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
$rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
hasContractAtDate: null !== $contract,
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
);
}
@@ -98,6 +102,14 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
$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->workDate = $dateKey;
$response->rows = array_map(

View File

@@ -23,6 +23,8 @@ use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -41,6 +43,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {}
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);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
@@ -276,6 +281,25 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
++$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;
$weeklyNightMinutes += $nightMinutes;
$weeklyWorkshopMinutes += $workshopMinutes;
@@ -299,6 +323,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
hasLunch: $hasLunch,
hasDinner: $hasDinner,
hasOvernight: $hasOvernight,
virtualHolidayMinutes: $virtualHolidayMinutes,
);
}
@@ -344,6 +369,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyDinnerCount: $weeklyDinnerCount,
weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek,
contractNature: $weekAnchorContractNature->value,
);
}
@@ -512,23 +538,6 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{
// Week-end hors base de référence.
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);
return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
}
}

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

@@ -0,0 +1,271 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Export heures - {% 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'
} %}{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}</title>
<style>
@page { size: A4 portrait; margin: 4mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 9px;
}
.employee-section {
page-break-before: always;
}
.employee-section:first-child {
page-break-before: auto;
}
.title-bar {
position: relative;
margin: 0 0 4mm 0;
}
h1 {
text-align: center;
font-size: 16px;
margin: 0;
}
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 9px;
color: #333;
padding-top: 4px;
}
h2 {
font-size: 12px;
margin: 4mm 0 2mm 0;
padding: 2px 6px;
background: #e8e8e8;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 2px solid #0a0a0a;
}
th, td {
border: 1px solid #0a0a0a;
padding: 2px 4px;
vertical-align: middle;
white-space: nowrap;
}
thead th {
text-align: center;
font-weight: 700;
font-size: 9px;
background: #d9e2f3;
}
td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; }
td.time { text-align: center; }
td.presence { text-align: center; }
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>
</head>
<body>
{% 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'
} %}
{% for entry in entries %}
<div class="employee-section">
<div class="title-bar">
<h1>
{{ entry.employeeName }}{% if entry.contractLabel %} - {{ entry.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 entry.segments %}
{% if entry.segments|length > 1 %}
<h2>{{ segment.contractName ?? 'Contrat inconnu' }}{% if segment.mode == 'driver' %} (Chauffeur){% endif %}</h2>
{% endif %}
{% if segment.mode == 'presence' %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Présence matin</th>
<th>Présence après-midi</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elseif segment.mode == 'driver' %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Heures jour</th>
<th>Heures nuit</th>
<th>Heures atelier</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<table>
<thead>
<tr>
<th>Date</th>
<th>Absence</th>
<th>Début matin</th>
<th>Fin matin</th>
<th>Début après-midi</th>
<th>Fin après-midi</th>
<th>Début soir</th>
<th>Fin soir</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td>
<td class="time">{{ row.afternoonTo }}</td>
<td class="time">{{ row.eveningFrom }}</td>
<td class="time">{{ row.eveningTo }}</td>
<td class="total">{{ row.total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% 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>
</div>
{% endfor %}
</body>
</html>

View File

@@ -14,10 +14,24 @@
font-size: 9px;
}
.title-bar {
position: relative;
margin: 0 0 4mm 0;
}
h1 {
text-align: center;
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 {
@@ -54,11 +68,70 @@
td.time { text-align: center; }
td.presence { text-align: center; }
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>
</head>
<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 %}
{% if segments|length > 1 %}
@@ -78,7 +151,7 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr>
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
@@ -102,7 +175,7 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr>
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.dayHours }}</td>
@@ -130,7 +203,7 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr>
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="time">{{ row.morningFrom }}</td>
@@ -147,5 +220,36 @@
{% endif %}
{% 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>
</html>

View File

@@ -28,13 +28,22 @@
.date-box {
position: absolute;
top: 0;
right: 0;
left: 0;
border: 2px solid #000;
padding: 4px 12px;
font-size: 14px;
font-weight: 700;
}
.export-date {
position: absolute;
top: 0;
right: 0;
font-size: 10px;
color: #333;
padding-top: 6px;
}
table.recap {
width: 100%;
border-collapse: collapse;
@@ -77,8 +86,9 @@
<body>
<div class="title-bar">
<h1>RECAPITULATIF CONGES & RTT</h1>
<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>
<table class="recap">

View File

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

View File

@@ -94,6 +94,87 @@ final class EmployeeContractPeriodValidatorTest extends TestCase
$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
{
$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\Employee;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTime;
use PHPUnit\Framework\TestCase;
@@ -20,7 +21,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
{
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);
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
@@ -28,19 +29,52 @@ final class WorkedHoursCreditPolicyTest extends TestCase
self::assertSame(210, $minutes);
}
public function testComputeCreditedMinutesFor4hContractFullDay(): void
public function testComputeCreditedMinutesFor4hContractUsesWorkDaysSchedule(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
// 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);
$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
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver());
$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.
@@ -50,15 +84,20 @@ final class WorkedHoursCreditPolicyTest extends TestCase
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);
self::assertSame(0, $policy->computeCreditedMinutes($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()
->setName('Contrat test')
->setTrackingMode($trackMode)
@@ -79,18 +118,25 @@ final class WorkedHoursCreditPolicyTest extends TestCase
return new Absence()
->setEmployee($employee)
->setType($type)
->setStartDate(new DateTime('2026-02-16'))
->setEndDate(new DateTime('2026-02-16'))
->setStartDate(new DateTime($start))
->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
->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
;
$resolver
->method('resolveWorkDaysMinutesForEmployeeAndDate')
->willReturn($schedule)
;
return $resolver;
}

View File

@@ -15,7 +15,6 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\PublicHolidayServiceInterface;
use App\State\AbsenceWriteProcessor;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
@@ -37,7 +36,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$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);
@@ -65,7 +64,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$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);
@@ -86,7 +85,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$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);
@@ -108,7 +107,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$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);
@@ -142,12 +141,4 @@ final class AbsenceWriteProcessorTest extends TestCase
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