Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06173e7225 | ||
| cc868a1e82 | |||
|
|
90843dd997 | ||
| 8a449cf81b | |||
|
|
3926946a5f | ||
| b9c3a8a84f | |||
|
|
b2f6fdf222 | ||
| 0fe82c63c5 | |||
| 849d19f124 | |||
|
|
d230a252b6 | ||
| d46e7c04d5 | |||
|
|
fe0910a661 | ||
| ff7566d4cd | |||
|
|
2f25a3cd52 | ||
| 1fe7f2cdde | |||
|
|
9e411be3c3 | ||
| 90e63a463e | |||
|
|
51bf155b0e | ||
| 1095421424 | |||
|
|
be7c16778a | ||
| a8fe244b5c | |||
|
|
13c71abddc | ||
| 9581f9d8d9 | |||
| c2eaa06aff | |||
|
|
187a634cc8 | ||
| 0897154460 | |||
|
|
11331da6a1 | ||
| 399fd7335e | |||
|
|
46cb7f1a16 | ||
| b934f4d81f | |||
| 77c1cdcbbd |
@@ -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
3
.env
@@ -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 ###
|
||||
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -15,6 +15,7 @@
|
||||
## Stack
|
||||
- Backend: Symfony + API Platform + Doctrine ORM
|
||||
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
|
||||
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
|
||||
|
||||
## Project Structure
|
||||
- `src/` — Symfony domain, API resources, state providers/processors, services
|
||||
@@ -30,11 +31,23 @@
|
||||
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
|
||||
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
|
||||
- Contract nature (per period): CDI, CDD, INTERIM
|
||||
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
|
||||
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
|
||||
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
|
||||
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`).
|
||||
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
|
||||
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
||||
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
|
||||
|
||||
## Fériés
|
||||
- Source : API gouv via `PublicHolidayService` (cache 30j)
|
||||
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
|
||||
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
|
||||
- Création/édition d'absence **autorisée** sur un férié (bouton Modifier visible). En présence d'absence, le crédit d'heures suit `absence.type.countAsWorkedHours` (WorkedHoursCreditPolicy), pas le crédit virtuel férié.
|
||||
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
|
||||
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
|
||||
|
||||
## Validation Rules
|
||||
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
|
||||
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
||||
@@ -48,6 +61,18 @@
|
||||
- INTERIM: no overtime bonuses, no recovery time
|
||||
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
|
||||
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
|
||||
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
|
||||
|
||||
## Récap. congés (écran)
|
||||
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
|
||||
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
|
||||
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante − 14j)`. Pas de gate `isValid`.
|
||||
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
|
||||
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
|
||||
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
|
||||
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
|
||||
- Pas d'export PDF depuis cet écran
|
||||
- Doc détaillée : `doc/leave-recap-screen.md`
|
||||
|
||||
## Frais (MileageAllowance)
|
||||
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
App\Service\PublicHolidayService:
|
||||
arguments:
|
||||
$holidayUrl: '%env(HOLIDAY_URL)%'
|
||||
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
|
||||
|
||||
App\Service\Rtt\RttRecoveryComputationService:
|
||||
arguments:
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.82'
|
||||
app.version: '0.1.96'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -58,6 +58,9 @@ Documents complementaires:
|
||||
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
|
||||
- non mise à jour lors de modifications admin ou chef de site
|
||||
- affichée sous le nom de l'employé (visible admin uniquement)
|
||||
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
|
||||
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
|
||||
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
|
||||
|
||||
## 4) Absences
|
||||
|
||||
@@ -71,6 +74,10 @@ Documents complementaires:
|
||||
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
||||
- demi-journée: dégradé diagonal
|
||||
- journée complète: fond plein
|
||||
- Visibilité des employés dans le Calendrier:
|
||||
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
|
||||
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
|
||||
- même logique que l'écran Heures : « pas de contrat sur la période → masqué »
|
||||
|
||||
### Effet absence sur les heures
|
||||
|
||||
@@ -130,6 +137,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 +169,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)
|
||||
|
||||
@@ -323,9 +335,27 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
| Contrat | Contract.name |
|
||||
| CP N-1 restant | CDI/CDD: acquis N-1 − pris sur N-1. Forfait: report N-1 restant |
|
||||
| Samedi restant | CDI/CDD: samedis acquis N-1 − pris. Forfait: `-` |
|
||||
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
|
||||
| CP N | Forfait: restant sur quota année civile (acquis − pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition |
|
||||
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
|
||||
|
||||
## 10bis) Écran Récap. congés (tableau)
|
||||
|
||||
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
|
||||
- Endpoint: `GET /api/leave-recap`
|
||||
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
|
||||
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
|
||||
- Scoping :
|
||||
- `ROLE_ADMIN` : tous les employés
|
||||
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
||||
- `ROLE_SELF` : uniquement son employé lié
|
||||
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
|
||||
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 14 jours)`
|
||||
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
|
||||
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
||||
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
||||
- Colonnes identiques au PDF (voir §10)
|
||||
- Détails techniques : voir `doc/leave-recap-screen.md`
|
||||
|
||||
## 11) Récapitulatif Salaire (PDF mensuel)
|
||||
|
||||
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
||||
|
||||
110
doc/holiday-virtual-hours.md
Normal file
110
doc/holiday-virtual-hours.md
Normal 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
73
doc/leave-recap-screen.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||
@@ -1,44 +1,26 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="employee">
|
||||
Employé <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="employee"
|
||||
v-model="absenceForm.employeeId"
|
||||
:class="employeeFieldClass"
|
||||
:disabled="props.lockEmployee"
|
||||
>
|
||||
<option value="" disabled>Choisir un employé</option>
|
||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
|
||||
L'employé est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
|
||||
:options="employeeOptions"
|
||||
label="Employé *"
|
||||
empty-option-label="Choisir un employé"
|
||||
min-width=""
|
||||
:disabled="props.lockEmployee"
|
||||
:error="showEmployeeError ? `L'employé est obligatoire.` : ''"
|
||||
@update:model-value="onEmployeeChange"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="type">
|
||||
Type d'absence <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
v-model="absenceForm.typeId"
|
||||
:class="typeFieldClass"
|
||||
>
|
||||
<option value="" disabled>Choisir un type</option>
|
||||
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
|
||||
{{ type.label }} ({{ type.code }})
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
|
||||
Le type d'absence est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
|
||||
:options="typeOptions"
|
||||
label="Type d'absence *"
|
||||
empty-option-label="Choisir un type"
|
||||
min-width=""
|
||||
:error="showTypeError ? `Le type d'absence est obligatoire.` : ''"
|
||||
@update:model-value="onTypeChange"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
@@ -48,17 +30,15 @@
|
||||
id="start-date"
|
||||
v-model="absenceForm.startDate"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
|
||||
:disabled="props.lockDates"
|
||||
/>
|
||||
<select
|
||||
v-model="absenceForm.startHalf"
|
||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
||||
{{ half.label }}
|
||||
</option>
|
||||
</select>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.startHalf"
|
||||
:options="halfDayOptions"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -68,17 +48,15 @@
|
||||
id="end-date"
|
||||
v-model="absenceForm.endDate"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
||||
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
|
||||
:disabled="props.lockDates"
|
||||
/>
|
||||
<select
|
||||
v-model="absenceForm.endHalf"
|
||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
||||
{{ half.label }}
|
||||
</option>
|
||||
</select>
|
||||
<MalioSelect
|
||||
:model-value="absenceForm.endHalf"
|
||||
:options="halfDayOptions"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,13 +88,12 @@
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
<MalioButton
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
label="Valider"
|
||||
button-class="w-[200px]"
|
||||
:disabled="props.isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
@@ -189,20 +166,23 @@ const submitButtonClass = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const baseSelectClass =
|
||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||
const employeeFieldClass = computed(() => {
|
||||
if (showEmployeeError.value) {
|
||||
return `${baseSelectClass} border-red-500`
|
||||
}
|
||||
return `${baseSelectClass} border-neutral-300`
|
||||
})
|
||||
const typeFieldClass = computed(() => {
|
||||
if (showTypeError.value) {
|
||||
return `${baseSelectClass} border-red-500`
|
||||
}
|
||||
return `${baseSelectClass} border-neutral-300`
|
||||
})
|
||||
const employeeOptions = computed(() =>
|
||||
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
|
||||
)
|
||||
const typeOptions = computed(() =>
|
||||
props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id }))
|
||||
)
|
||||
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
|
||||
|
||||
const dateInputBaseClass =
|
||||
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
|
||||
|
||||
const onEmployeeChange = (value: string | number | null) => {
|
||||
absenceForm.value.employeeId = value === null ? '' : Number(value)
|
||||
}
|
||||
const onTypeChange = (value: string | number | null) => {
|
||||
absenceForm.value.typeId = value === null ? '' : Number(value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
|
||||
@@ -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 pt-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
|
||||
108
frontend/components/BulkYearlyHoursDrawer.vue
Normal file
108
frontend/components/BulkYearlyHoursDrawer.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div class="relative w-full max-w-[340px]">
|
||||
<input
|
||||
id="employee-search"
|
||||
v-model="model"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
|
||||
/>
|
||||
<Icon
|
||||
name="mdi:magnify"
|
||||
size="18"
|
||||
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>({required: true})
|
||||
|
||||
withDefaults(defineProps<{
|
||||
placeholder?: string
|
||||
}>(), {
|
||||
placeholder: "Recherche d'un employé"
|
||||
})
|
||||
</script>
|
||||
@@ -1,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 = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<div ref="root" class="relative inline-block w-fit max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-[320px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<span>Sites</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
|
||||
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="z-50 absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:for="`site-${site.id}`"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
|
||||
>
|
||||
<input
|
||||
:id="`site-${site.id}`"
|
||||
v-model="selectedSiteIds"
|
||||
:value="site.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="text-md text-neutral-800">{{ site.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { Site } from '~/services/dto/site'
|
||||
|
||||
const selectedSiteIds = defineModel<number[]>({ required: true })
|
||||
const isOpen = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
defineProps<{
|
||||
sites: Site[]
|
||||
}>()
|
||||
|
||||
const selectedCount = computed(() => selectedSiteIds.value.length)
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node | null
|
||||
if (!root.value || !target) return
|
||||
if (!root.value.contains(target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
@@ -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="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
@@ -68,19 +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,9 +202,10 @@ 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
|
||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
formatMinutes: (minutes: number) => string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -149,13 +149,13 @@
|
||||
<tr>
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase25Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus25Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes) : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Reste row -->
|
||||
@@ -187,41 +187,41 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.base25Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.bonus25Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.base50Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label>
|
||||
<label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label>
|
||||
<input
|
||||
v-model.number="paymentForm.bonus50Hours"
|
||||
type="number"
|
||||
step="0.5"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
@@ -500,10 +500,10 @@ const paymentForm = reactive({
|
||||
const prefillFromExistingPayment = (month: number) => {
|
||||
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
|
||||
if (existing) {
|
||||
paymentForm.base25Hours = existing.paidBase25Minutes / 60
|
||||
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
|
||||
paymentForm.base50Hours = existing.paidBase50Minutes / 60
|
||||
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
|
||||
paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100
|
||||
paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100
|
||||
paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100
|
||||
paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100
|
||||
} else {
|
||||
paymentForm.base25Hours = 0
|
||||
paymentForm.bonus25Hours = 0
|
||||
@@ -516,6 +516,14 @@ watch(() => paymentForm.month, (newMonth) => {
|
||||
prefillFromExistingPayment(newMonth)
|
||||
})
|
||||
|
||||
watch(() => paymentForm.base25Hours, (value) => {
|
||||
paymentForm.bonus25Hours = Math.round(value * 0.25 * 100) / 100
|
||||
})
|
||||
|
||||
watch(() => paymentForm.base50Hours, (value) => {
|
||||
paymentForm.bonus50Hours = Math.round(value * 0.50 * 100) / 100
|
||||
})
|
||||
|
||||
const openPaymentDrawer = () => {
|
||||
paymentForm.month = currentMonth.value
|
||||
prefillFromExistingPayment(currentMonth.value)
|
||||
|
||||
113
frontend/components/employees/WorkDaysHoursInput.vue
Normal file
113
frontend/components/employees/WorkDaysHoursInput.vue
Normal 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>
|
||||
@@ -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="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Absence / Holiday / Formation pills -->
|
||||
<div class="mb-3 flex flex-col gap-1">
|
||||
<p
|
||||
v-if="getRowAbsenceLabel(employee.id)"
|
||||
class="rounded-md px-2 py-1 text-xs text-white truncate"
|
||||
:style="getRowAbsenceStyle(employee.id)"
|
||||
>
|
||||
{{ getRowAbsenceLabel(employee.id) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-neutral-400"
|
||||
>
|
||||
Aucune absence
|
||||
</p>
|
||||
<p
|
||||
v-if="isHoliday"
|
||||
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
|
||||
style="background-color: #b3e5fc"
|
||||
>
|
||||
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="hasRowFormation(employee.id)"
|
||||
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
|
||||
>
|
||||
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
|
||||
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
|
||||
</p>
|
||||
<button
|
||||
v-if="!hasRowFormation(employee.id)"
|
||||
type="button"
|
||||
class="self-start text-xs font-semibold underline"
|
||||
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||
@click="onAbsenceClick(employee.id)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time inputs (TIME tracking) -->
|
||||
<div v-if="isTimeTracking(employee)" class="space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début matin</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].morningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin matin</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].morningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début après-midi</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].afternoonFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin après-midi</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].afternoonTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Début soir</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].eveningFrom"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-neutral-500">Fin soir</label>
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].eveningTo"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
|
||||
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
|
||||
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
|
||||
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Presence tracking -->
|
||||
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
v-model="rows[employee.id].isPresentMorning"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||
/>
|
||||
Matin
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
v-model="rows[employee.id].isPresentAfternoon"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||
/>
|
||||
Après-midi
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Validation status (non-admin) -->
|
||||
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
|
||||
<span v-if="!isSiteManager" class="flex items-center gap-1">
|
||||
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Validation checkbox (admin) -->
|
||||
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
:checked="rows[employee.id]?.isValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-neutral-700 font-semibold">Valider</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table layout -->
|
||||
<div class="overflow-y-auto min-h-0 hidden lg:block">
|
||||
<div
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span class="pl-2">Absence</span>
|
||||
<span class="pl-2">Statut</span>
|
||||
<span class="pl-4">Début matin</span>
|
||||
<span class="pr-2">Fin matin</span>
|
||||
<span class="pl-2">Début après-midi</span>
|
||||
@@ -26,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="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
@@ -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,11 +400,12 @@ const props = defineProps<{
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
hasRowFormation: (employeeId: number) => boolean
|
||||
getRowFormationLabel: (employeeId: number) => string
|
||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
getPresenceDayValue: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
|
||||
@@ -1,17 +1,90 @@
|
||||
<template>
|
||||
<div class="py-6 flex flex-col gap-3">
|
||||
<div class="flex gap-4">
|
||||
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||
<div class="py-4 flex flex-col gap-3 lg:py-6">
|
||||
<!-- Desktop: filters row -->
|
||||
<div class="hidden lg:flex lg:items-center lg:gap-4">
|
||||
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
groupClass="w-80"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="w-80">
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<!-- Mobile: search + filter button -->
|
||||
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
|
||||
<div class="flex-1 min-w-0">
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
|
||||
@click="filtersDrawerOpen = true"
|
||||
>
|
||||
<Icon name="mdi:filter-variant" size="22"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile filters drawer -->
|
||||
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
|
||||
<div class="space-y-6">
|
||||
<div v-if="sites.length > 0 && isAdmin">
|
||||
<label class="text-md font-semibold text-neutral-700">Sites</label>
|
||||
<div class="mt-2">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
groupClass="w-80"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isAdmin">
|
||||
<label class="text-md font-semibold text-neutral-700">Vue</label>
|
||||
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
|
||||
:class="viewModeButtonClass('day')"
|
||||
@click="viewMode = 'day'; filtersDrawerOpen = false"
|
||||
>
|
||||
<Icon name="mdi:calendar-clock" />
|
||||
Jour
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
|
||||
:class="viewModeButtonClass('week')"
|
||||
@click="viewMode = 'week'; filtersDrawerOpen = false"
|
||||
>
|
||||
<Icon name="mdi:calendar-week" />
|
||||
Semaine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
<!-- Date navigation -->
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
|
||||
<div
|
||||
v-if="viewMode === 'day'"
|
||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
||||
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,7 +114,7 @@
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
||||
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -70,7 +143,7 @@
|
||||
</div>
|
||||
|
||||
<PeriodStepperPicker
|
||||
width-class="w-[320px]"
|
||||
width-class="w-full lg:w-[320px]"
|
||||
:label="formattedSelectedDate"
|
||||
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
||||
:picker-value="pickerValue"
|
||||
@@ -82,7 +155,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<!-- Desktop: view mode toggle -->
|
||||
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
@@ -106,7 +180,7 @@
|
||||
|
||||
<div
|
||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||
class="flex flex-wrap items-center gap-6"
|
||||
class="hidden lg:flex flex-wrap items-center gap-6"
|
||||
>
|
||||
<p class="font-bold">Légende :</p>
|
||||
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||
@@ -120,9 +194,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '~/services/dto/site'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||
|
||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||
@@ -130,7 +203,7 @@ const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
|
||||
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
|
||||
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
isAdmin: boolean
|
||||
sites: Site[]
|
||||
absenceTypes: AbsenceType[]
|
||||
@@ -140,6 +213,8 @@ defineProps<{
|
||||
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||
}>()
|
||||
|
||||
const siteOptions = computed(() => props.sites.map((site) => ({ label: site.name, value: site.id })))
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'set-yesterday'): void
|
||||
(e: 'set-today'): void
|
||||
@@ -150,6 +225,8 @@ const emit = defineEmits<{
|
||||
(e: 'shift-date', value: number): void
|
||||
}>()
|
||||
|
||||
const filtersDrawerOpen = ref(false)
|
||||
|
||||
const pickerValue = computed(() => {
|
||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
||||
return selectedDate.value
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
@@ -407,6 +417,10 @@ export const useDriverHoursPage = () => {
|
||||
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
|
||||
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
|
||||
}
|
||||
|
||||
const hasContractAtSelectedDate = (employeeId: number) => {
|
||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||
if (!dayRow) return true
|
||||
@@ -467,7 +481,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 +954,7 @@ export const useDriverHoursPage = () => {
|
||||
saveButtonClass,
|
||||
formattedSelectedDate,
|
||||
isSelectedDateHoliday,
|
||||
selectedHolidayLabel,
|
||||
weekDayHeaders,
|
||||
shortcutButtonClass,
|
||||
weekShortcutButtonClass,
|
||||
@@ -972,6 +986,7 @@ export const useDriverHoursPage = () => {
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
openAbsenceDrawer,
|
||||
submitAbsence,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ export const useEmployeeDetailPage = () => {
|
||||
|
||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
||||
const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
|
||||
const employeeContractWorkLabel = computed(() => {
|
||||
const contract = employee.value?.contract
|
||||
if (!contract) return '-'
|
||||
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
|
||||
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours'
|
||||
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
|
||||
return contract.name || '-'
|
||||
})
|
||||
@@ -55,6 +56,9 @@ export const useEmployeeDetailPage = () => {
|
||||
await bonus.loadBonusData()
|
||||
} else if (activeTab.value === 'observation') {
|
||||
await observation.loadObservationData()
|
||||
} else if (isForfait.value && showLeaveTab.value) {
|
||||
// Eager load: needed for the "X jours restants" header label on forfait employees.
|
||||
await leave.loadLeaveData()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -63,6 +67,13 @@ export const useEmployeeDetailPage = () => {
|
||||
|
||||
const contract = useEmployeeContract(employee, loadEmployee)
|
||||
const leave = useEmployeeLeave(employee, loadEmployee)
|
||||
const forfaitRemainingDaysLabel = computed(() => {
|
||||
if (!isForfait.value) return ''
|
||||
const presence = leave.leaveSummary.value?.presenceDaysToToday
|
||||
if (presence === undefined || presence === null) return ''
|
||||
const remaining = 218 - presence
|
||||
return ` (${remaining} restants)`
|
||||
})
|
||||
const rtt = useEmployeeRtt(employee, loadEmployee)
|
||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||
const formation = useEmployeeFormation(employee, loadEmployee)
|
||||
@@ -86,7 +97,7 @@ export const useEmployeeDetailPage = () => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await contract.loadContracts()
|
||||
await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
|
||||
await loadEmployee()
|
||||
})
|
||||
|
||||
@@ -97,6 +108,7 @@ export const useEmployeeDetailPage = () => {
|
||||
showLeaveTab,
|
||||
showRttTab,
|
||||
employeeContractWorkLabel,
|
||||
forfaitRemainingDaysLabel,
|
||||
...contract,
|
||||
...leave,
|
||||
...rtt,
|
||||
|
||||
@@ -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.'
|
||||
@@ -484,6 +494,10 @@ export const useHoursPage = () => {
|
||||
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
|
||||
}
|
||||
|
||||
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
|
||||
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
|
||||
}
|
||||
|
||||
const getRowUpdatedAt = (employeeId: number): string => {
|
||||
const raw = rows.value[employeeId]?.updatedAt
|
||||
if (!raw) return ''
|
||||
@@ -584,7 +598,6 @@ export const useHoursPage = () => {
|
||||
|
||||
const openAbsenceDrawer = (employeeId: number) => {
|
||||
if (!hasContractAtSelectedDate(employeeId)) return
|
||||
if (isSelectedDateHoliday.value) return
|
||||
|
||||
const existing = absences.value.find((absence) => {
|
||||
if (absence.employee?.id !== employeeId) return false
|
||||
@@ -1127,6 +1140,7 @@ export const useHoursPage = () => {
|
||||
saveButtonClass,
|
||||
formattedSelectedDate,
|
||||
isSelectedDateHoliday,
|
||||
selectedHolidayLabel,
|
||||
weekDayHeaders,
|
||||
shortcutButtonClass,
|
||||
weekShortcutButtonClass,
|
||||
@@ -1164,6 +1178,7 @@ export const useHoursPage = () => {
|
||||
getRowAbsenceStyle,
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
|
||||
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
|
||||
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
|
||||
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -56,6 +57,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:00–21:00), heures de nuit (00:00–06:00 et 21:00–24: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 +208,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 +259,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 +388,9 @@ 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: 'Seuls les employés ayant au moins un jour de contrat sur le mois affiché apparaissent. Un employé dont le contrat s\'est terminé avant le 1er du mois (ou qui commence après la fin du mois) est masqué.' },
|
||||
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -441,6 +447,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.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 [scrollbar-gutter:stable] 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>
|
||||
|
||||
11
frontend/middleware/leave-recap-access.ts
Normal file
11
frontend/middleware/leave-recap-access.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!auth.user?.hasLeaveRecapAccess) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {enabled: false},
|
||||
ssr: false,
|
||||
extends: ['@malio/layer-ui'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
|
||||
260
frontend/package-lock.json
generated
260
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@malio/layer-ui": "^1.4.5",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.3.0",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
|
||||
@@ -1,14 +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">Types d'absence</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
<h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
|
||||
<MalioButton
|
||||
label="Ajouter un type"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un type
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -56,60 +55,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="code">
|
||||
Code <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
type="text"
|
||||
maxlength="10"
|
||||
:class="codeFieldClass"
|
||||
/>
|
||||
<p v-if="showCodeError" class="mt-1 text-sm text-red-600">
|
||||
Le code est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="label">
|
||||
Libellé <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="label"
|
||||
v-model="form.label"
|
||||
type="text"
|
||||
:class="labelFieldClass"
|
||||
/>
|
||||
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
|
||||
Le libellé est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
label="Code *"
|
||||
group-class="mt-2"
|
||||
:max-length="10"
|
||||
:error="showCodeError ? 'Le code est obligatoire.' : ''"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé *"
|
||||
group-class="mt-2"
|
||||
:error="showLabelError ? 'Le libellé est obligatoire.' : ''"
|
||||
/>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">
|
||||
Compté comme travaillé
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-6">
|
||||
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
||||
<input
|
||||
v-model="form.countAsWorkedHours"
|
||||
type="radio"
|
||||
class="h-4 w-4"
|
||||
:value="true"
|
||||
/>
|
||||
Oui
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
|
||||
<input
|
||||
v-model="form.countAsWorkedHours"
|
||||
type="radio"
|
||||
class="h-4 w-4"
|
||||
:value="false"
|
||||
/>
|
||||
Non
|
||||
</label>
|
||||
<MalioRadioButton
|
||||
v-model="form.countAsWorkedHours"
|
||||
name="countAsWorkedHours"
|
||||
:value="true"
|
||||
label="Oui"
|
||||
group-class="w-auto mt-0"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
v-model="form.countAsWorkedHours"
|
||||
name="countAsWorkedHours"
|
||||
:value="false"
|
||||
label="Non"
|
||||
group-class="w-auto mt-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -130,32 +109,29 @@
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||
<MalioButton
|
||||
label="Supprimer"
|
||||
variant="danger"
|
||||
button-class="w-full"
|
||||
@click="confirmDelete(editingType)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
type="submit"
|
||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
label="Modifier"
|
||||
button-class="w-full"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
<MalioButton
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
label="Valider"
|
||||
button-class="w-[200px]"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -164,7 +140,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
||||
|
||||
useHead({
|
||||
title: 'Types d\'absences'
|
||||
title: 'Types de statut'
|
||||
})
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
@@ -202,20 +178,6 @@ const showCodeError = computed(() => validationTouched.code && !isCodeValid.valu
|
||||
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
|
||||
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||
const codeFieldClass = computed(() => {
|
||||
if (showCodeError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const labelFieldClass = computed(() => {
|
||||
if (showLabelError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const colorFieldClass = computed(() => {
|
||||
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
|
||||
if (showColorError.value) {
|
||||
@@ -224,13 +186,6 @@ const colorFieldClass = computed(() => {
|
||||
return `${baseColorClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (isSubmitting.value || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const loadAbsenceTypes = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
|
||||
@@ -5,30 +5,37 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 py-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
|
||||
</div>
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
label="Sites"
|
||||
groupClass="relative z-50 w-80 h-10"
|
||||
display-select-all
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
label="Ajouter une absence"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreateFromToday"
|
||||
>
|
||||
+ Ajouter une absence
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Imprimer"
|
||||
variant="secondary"
|
||||
icon-name="mdi:printer"
|
||||
icon-position="left"
|
||||
@click="openPrint"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-80">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
<PeriodStepperPicker
|
||||
width-class="w-[260px]"
|
||||
@@ -111,9 +118,7 @@ import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/emplo
|
||||
import CalendarGrid from '~/components/CalendarGrid.vue'
|
||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Calendrier'
|
||||
@@ -136,6 +141,8 @@ const sites = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
|
||||
|
||||
// Filtres de sites (par défaut: tous sélectionnés à l'init).
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const sitesInitialized = ref(false)
|
||||
@@ -154,12 +161,27 @@ const sortedEmployees = computed(() => {
|
||||
// Employés visibles selon le filtre de sites.
|
||||
const employeeFilter = ref('')
|
||||
|
||||
// Un employé est considéré "présent" sur le mois affiché si au moins une de ses
|
||||
// périodes de contrat intersecte [début du mois ; fin du mois]. Sinon il est masqué.
|
||||
const hasContractInSelectedMonth = (employee: Employee): boolean => {
|
||||
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
|
||||
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
|
||||
const history = employee.contractHistory ?? []
|
||||
if (history.length === 0) return false
|
||||
return history.some((period) => {
|
||||
const start = period.startDate
|
||||
const end = period.endDate ?? '9999-12-31'
|
||||
return start <= monthEnd && end >= monthStart
|
||||
})
|
||||
}
|
||||
|
||||
const visibleEmployees = computed(() => {
|
||||
if (selectedSiteIds.value.length === 0) return []
|
||||
const filter = employeeFilter.value.trim().toLowerCase()
|
||||
return sortedEmployees.value.filter((employee) => {
|
||||
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
|
||||
if (!siteOk) return false
|
||||
if (!hasContractInSelectedMonth(employee)) return false
|
||||
if (!filter) return true
|
||||
const first = employee.firstName?.toLowerCase() ?? ''
|
||||
const last = employee.lastName?.toLowerCase() ?? ''
|
||||
@@ -490,14 +512,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 +528,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 +571,6 @@ const getCellInfo = (employeeId: number, date: string) => {
|
||||
|
||||
// Ouverture du drawer depuis une cellule.
|
||||
const openCreate = (employee: Employee, date: string) => {
|
||||
if (isHolidayDate(date)) {
|
||||
window.alert("Impossible de creer une absence un jour ferie.")
|
||||
return
|
||||
}
|
||||
|
||||
const existing = absences.value.find((absence) => {
|
||||
const start = normalizeDate(absence.startDate)
|
||||
const end = normalizeDate(absence.endDate)
|
||||
@@ -590,10 +607,6 @@ const openCreateFromToday = () => {
|
||||
form.typeId = ''
|
||||
const now = new Date()
|
||||
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
if (isHolidayDate(today)) {
|
||||
window.alert("Impossible de creer une absence un jour ferie.")
|
||||
return
|
||||
}
|
||||
form.startDate = today
|
||||
form.endDate = today
|
||||
form.startHalf = 'AM'
|
||||
|
||||
@@ -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"
|
||||
@@ -63,6 +64,7 @@
|
||||
:get-row-metrics="getRowMetrics"
|
||||
:get-row-absence-label="getRowAbsenceLabel"
|
||||
:get-row-absence-style="getRowAbsenceStyle"
|
||||
:get-row-contract-nature="getRowContractNature"
|
||||
:get-row-updated-at="getRowUpdatedAt"
|
||||
:on-absence-click="openAbsenceDrawer"
|
||||
:format-minutes="formatMinutes"
|
||||
@@ -107,6 +109,7 @@
|
||||
@delete="deleteAbsenceFromDrawer"
|
||||
@cancel="closeAbsenceDrawer"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -167,6 +170,7 @@ const {
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
openAbsenceDrawer,
|
||||
submitAbsence,
|
||||
@@ -174,6 +178,7 @@ const {
|
||||
closeAbsenceDrawer,
|
||||
formatMinutes,
|
||||
isSelectedDateHoliday,
|
||||
selectedHolidayLabel,
|
||||
handleSave
|
||||
} = useDriverHoursPage()
|
||||
|
||||
|
||||
@@ -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" />
|
||||
@@ -26,7 +26,7 @@
|
||||
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
||||
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}</p>
|
||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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">
|
||||
@@ -254,6 +257,7 @@ const {
|
||||
showRttTab,
|
||||
contractHistory,
|
||||
employeeContractWorkLabel,
|
||||
forfaitRemainingDaysLabel,
|
||||
contractForm,
|
||||
createContractForm,
|
||||
isContractDrawerOpen,
|
||||
@@ -274,6 +278,8 @@ const {
|
||||
requiresCreateContractEndDate,
|
||||
createContractEndDateFieldClass,
|
||||
isCreateContractFormValid,
|
||||
requiresCreateWorkDaysHours,
|
||||
selectedCreateContract,
|
||||
contractNatureLabel,
|
||||
contractHistoryLabel,
|
||||
formatDate,
|
||||
@@ -291,6 +297,7 @@ const {
|
||||
submitSuspension,
|
||||
addSuspensionForm,
|
||||
currentActiveContractPeriodId,
|
||||
interimAgencies,
|
||||
isLeaveLoading,
|
||||
isRttLoading,
|
||||
mileageAllowances,
|
||||
@@ -317,9 +324,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
|
||||
}
|
||||
|
||||
|
||||
@@ -4,42 +4,45 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="handleLeaveRecapPrint"
|
||||
>
|
||||
Export récap. congés
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="isSalaryRecapOpen = true"
|
||||
>
|
||||
Export récap. salaire
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
label="Export"
|
||||
variant="secondary"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
@click="openExportDrawer"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Ajouter un employé"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un employé
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 py-7">
|
||||
<div class="flex items-center gap-3 py-7">
|
||||
<div class="w-80">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||
<MalioInputText
|
||||
v-model="employeeFilter"
|
||||
label="Recherche d'un employé"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</div>
|
||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
|
||||
<select
|
||||
<div v-if="sites.length > 0" class="relative z-50 w-80">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedSiteIds"
|
||||
:options="siteOptions"
|
||||
groupClass="w-80"
|
||||
label="Sites"
|
||||
display-select-all
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioSelect
|
||||
v-model="contractStatusFilter"
|
||||
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
|
||||
>
|
||||
<option value="active">Avec contrat</option>
|
||||
<option value="inactive">Sans contrat</option>
|
||||
<option value="all">Tous</option>
|
||||
</select>
|
||||
label="Statut contrat"
|
||||
:options="contractStatusOptions"
|
||||
group-class="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +75,7 @@
|
||||
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
|
||||
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
|
||||
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
||||
<p><strong>Type:</strong> {{ employee.currentInterimAgencyName ? `${contractNatureLabel(employee.currentContractNature)} (${employee.currentInterimAgencyName})` : contractNatureLabel(employee.currentContractNature) }}</p>
|
||||
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
||||
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
||||
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||
@@ -147,6 +150,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 +209,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 +223,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
|
||||
@@ -225,22 +248,71 @@
|
||||
</form>
|
||||
</AppDrawer>
|
||||
|
||||
<SalaryRecapDrawer
|
||||
v-model="isSalaryRecapOpen"
|
||||
@submit="handleSalaryRecapPrint"
|
||||
/>
|
||||
<MalioDrawer v-model="isExportDrawerOpen" title="Export">
|
||||
<div class="space-y-4">
|
||||
<MalioSelect
|
||||
:model-value="exportChoice === '' ? null : exportChoice"
|
||||
:options="exportTypeOptions"
|
||||
label="Type d'export"
|
||||
empty-option-label="Choisir un export"
|
||||
group-class="mt-2"
|
||||
min-width=""
|
||||
@update:model-value="onExportChoiceChange"
|
||||
/>
|
||||
|
||||
<div v-if="exportChoice === 'salary-recap'">
|
||||
<label class="text-md font-semibold text-neutral-700" for="export-salary-month">
|
||||
Mois <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="export-salary-month"
|
||||
v-model="exportSalaryMonth"
|
||||
type="month"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else-if="exportChoice === 'yearly-hours'">
|
||||
<MalioSelect
|
||||
:model-value="exportYear"
|
||||
:options="exportYearOptions"
|
||||
label="Année *"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="exportMonth === '' ? null : exportMonth"
|
||||
:options="exportMonthOptions"
|
||||
label="Mois *"
|
||||
empty-option-label="Choisir un mois"
|
||||
min-width=""
|
||||
@update:model-value="(v) => { exportMonth = v === null ? '' : Number(v) }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<MalioButton
|
||||
label="Valider"
|
||||
button-class="w-[200px]"
|
||||
:disabled="!isExportValid"
|
||||
@click="handleExportValidate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {Contract} from '~/services/dto/contract'
|
||||
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
|
||||
import { requiresWorkDaysHours } from '~/utils/contract'
|
||||
import type {Employee} from '~/services/dto/employee'
|
||||
import type {Site} from '~/services/dto/site'
|
||||
import {listContracts} from '~/services/contracts'
|
||||
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
|
||||
import {listSites} from '~/services/sites'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
|
||||
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
|
||||
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
|
||||
import {usePdfPrinter} from '~/composables/usePdfPrinter'
|
||||
|
||||
@@ -251,7 +323,50 @@ useHead({
|
||||
const isDrawerOpen = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const isSalaryRecapOpen = ref(false)
|
||||
const isExportDrawerOpen = ref(false)
|
||||
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
|
||||
const exportYear = ref<number>(new Date().getFullYear())
|
||||
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
|
||||
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
|
||||
|
||||
const exportTypeOptions = [
|
||||
{ label: 'Récap. congés', value: 'leave-recap' },
|
||||
{ label: 'Récap. salaire', value: 'salary-recap' },
|
||||
{ label: 'Heures annuelles', value: 'yearly-hours' }
|
||||
]
|
||||
const exportYearOptions = computed(() => {
|
||||
const current = new Date().getFullYear()
|
||||
return Array.from({ length: 6 }, (_, i) => ({ label: String(current - i), value: current - i }))
|
||||
})
|
||||
const exportMonthOptions = [
|
||||
{ label: 'Janvier', value: 1 },
|
||||
{ label: 'Février', value: 2 },
|
||||
{ label: 'Mars', value: 3 },
|
||||
{ label: 'Avril', value: 4 },
|
||||
{ label: 'Mai', value: 5 },
|
||||
{ label: 'Juin', value: 6 },
|
||||
{ label: 'Juillet', value: 7 },
|
||||
{ label: 'Août', value: 8 },
|
||||
{ label: 'Septembre', value: 9 },
|
||||
{ label: 'Octobre', value: 10 },
|
||||
{ label: 'Novembre', value: 11 },
|
||||
{ label: 'Décembre', value: 12 }
|
||||
]
|
||||
|
||||
const isExportValid = computed(() => {
|
||||
if (!exportChoice.value) return false
|
||||
if (exportChoice.value === 'salary-recap') {
|
||||
return exportSalaryMonth.value.trim() !== ''
|
||||
}
|
||||
if (exportChoice.value === 'yearly-hours') {
|
||||
return exportYear.value > 0 && exportMonth.value !== ''
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const onExportChoiceChange = (value: string | number | null) => {
|
||||
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | ''
|
||||
}
|
||||
const { printPdf } = usePdfPrinter()
|
||||
const sitesInitialized = ref(false)
|
||||
const editingEmployee = ref<Employee | null>(null)
|
||||
@@ -262,9 +377,16 @@ const drawerTitle = computed(() =>
|
||||
const employees = ref<Employee[]>([])
|
||||
const sites = ref<Site[]>([])
|
||||
const contracts = ref<Contract[]>([])
|
||||
const interimAgencies = ref<InterimAgency[]>([])
|
||||
const employeeFilter = ref('')
|
||||
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
|
||||
const contractStatusOptions = [
|
||||
{ label: 'Avec contrat', value: 'active' },
|
||||
{ label: 'Sans contrat', value: 'inactive' },
|
||||
{ label: 'Tous', value: 'all' }
|
||||
]
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
|
||||
|
||||
const filteredEmployees = computed<Employee[]>(() => {
|
||||
if (selectedSiteIds.value.length === 0) return []
|
||||
@@ -292,7 +414,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 +434,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 +466,8 @@ const isFormValid = computed(
|
||||
: (isContractValid.value &&
|
||||
isContractNatureValid.value &&
|
||||
isContractStartDateValid.value &&
|
||||
isContractEndDateValid.value))
|
||||
isContractEndDateValid.value &&
|
||||
isScheduleValid.value))
|
||||
)
|
||||
|
||||
const showFirstNameError = computed(
|
||||
@@ -427,8 +567,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 +622,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 +636,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 +664,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,18 +694,34 @@ const openCreate = () => {
|
||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||
form.contractEndDate = ''
|
||||
form.isDriver = false
|
||||
form.workDaysHours = null
|
||||
form.interimAgencyId = ''
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const handleLeaveRecapPrint = async () => {
|
||||
await printPdf('/leave-recap/print')
|
||||
const openExportDrawer = () => {
|
||||
exportChoice.value = ''
|
||||
const now = new Date()
|
||||
exportYear.value = now.getFullYear()
|
||||
exportMonth.value = now.getMonth() + 1
|
||||
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
isExportDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const handleSalaryRecapPrint = async (month: string) => {
|
||||
await printPdf(`/salary-recap/print?month=${month}`)
|
||||
isSalaryRecapOpen.value = false
|
||||
const handleExportValidate = async () => {
|
||||
if (!isExportValid.value) return
|
||||
const choice = exportChoice.value
|
||||
isExportDrawerOpen.value = false
|
||||
if (choice === 'leave-recap') {
|
||||
await printPdf('/leave-recap/print')
|
||||
} else if (choice === 'salary-recap') {
|
||||
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
|
||||
} else if (choice === 'yearly-hours') {
|
||||
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const confirmDelete = async (employee: Employee) => {
|
||||
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
|
||||
if (!ok) return
|
||||
|
||||
@@ -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"
|
||||
@@ -69,6 +70,7 @@
|
||||
:get-row-absence-style="getRowAbsenceStyle"
|
||||
:has-row-formation="hasRowFormation"
|
||||
:get-row-formation-label="getRowFormationLabel"
|
||||
:get-row-contract-nature="getRowContractNature"
|
||||
:get-row-updated-at="getRowUpdatedAt"
|
||||
:get-presence-day-value="getPresenceDayValue"
|
||||
:on-absence-click="openAbsenceDrawer"
|
||||
@@ -114,6 +116,7 @@
|
||||
@delete="deleteAbsenceFromDrawer"
|
||||
@cancel="closeAbsenceDrawer"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -141,6 +144,7 @@ const {
|
||||
isSubmitting,
|
||||
dayGridCols,
|
||||
isSelectedDateHoliday,
|
||||
selectedHolidayLabel,
|
||||
weekGridCols,
|
||||
saveButtonClass,
|
||||
formattedSelectedDate,
|
||||
@@ -181,6 +185,7 @@ const {
|
||||
getRowAbsenceStyle,
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowContractNature,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
163
frontend/pages/leave-recap.vue
Normal file
163
frontend/pages/leave-recap.vue
Normal 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>
|
||||
@@ -9,31 +9,18 @@
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="username">
|
||||
Nom d'utilisateur
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputText
|
||||
v-model="username"
|
||||
label="Nom d'utilisateur"
|
||||
autocomplete="username"
|
||||
group-class="mt-2"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputPassword
|
||||
v-model="password"
|
||||
label="Mot de passe"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between pb-6">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
label="Ajouter un site"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un site
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -52,22 +51,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="name">
|
||||
Nom <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:class="nameFieldClass"
|
||||
/>
|
||||
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
|
||||
Le nom du site est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Nom *"
|
||||
group-class="mt-2"
|
||||
:error="showNameError ? 'Le nom du site est obligatoire.' : ''"
|
||||
/>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="color">
|
||||
Couleur <span class="text-red-600">*</span>
|
||||
@@ -83,32 +74,29 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="editingSite" class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
||||
<MalioButton
|
||||
label="Supprimer"
|
||||
variant="danger"
|
||||
button-class="w-full"
|
||||
@click="confirmDelete(editingSite)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
type="submit"
|
||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
label="Modifier"
|
||||
button-class="w-full"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex justify-center pt-2">
|
||||
<button
|
||||
<MalioButton
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
+ Ajouter
|
||||
</button>
|
||||
label="Valider"
|
||||
button-class="w-[200px]"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -146,22 +134,6 @@ const isFormValid = computed(() => isNameValid.value)
|
||||
|
||||
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||
const nameFieldClass = computed(() => {
|
||||
if (showNameError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (isSubmitting.value || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const loadSites = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,14 +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>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
|
||||
<MalioButton
|
||||
label="Ajouter"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un utilisateur
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -18,7 +17,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,43 +56,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppDrawer
|
||||
<!-- Mobile cards -->
|
||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="'m-' + user.id"
|
||||
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
|
||||
@click="openEdit(user)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
|
||||
<span
|
||||
v-if="user.isLocked"
|
||||
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
|
||||
>Verrouillé</span>
|
||||
<span
|
||||
v-else
|
||||
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
|
||||
>Actif</span>
|
||||
</div>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p v-if="user.employee" class="text-neutral-600">
|
||||
{{ user.employee.firstName }} {{ user.employee.lastName }}
|
||||
</p>
|
||||
<p class="text-neutral-500">
|
||||
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
|
||||
</p>
|
||||
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
|
||||
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MalioDrawer
|
||||
v-model="isDrawerOpen"
|
||||
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
|
||||
>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="username">
|
||||
Nom d'utilisateur <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="form.username"
|
||||
type="text"
|
||||
:class="usernameFieldClass"
|
||||
/>
|
||||
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
|
||||
Le nom d'utilisateur est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
:label="editingUser ? `Nom d'utilisateur` : `Nom d'utilisateur *`"
|
||||
group-class="mt-2"
|
||||
:error="showUsernameError ? `Le nom d'utilisateur est obligatoire.` : ''"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="password">
|
||||
Mot de passe
|
||||
<span v-if="!editingUser" class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:class="passwordFieldClass"
|
||||
:label="editingUser ? 'Mot de passe' : 'Mot de passe *'"
|
||||
:hint="editingUser ? 'Laisse vide pour ne pas changer le mot de passe.' : ''"
|
||||
:error="!editingUser && showPasswordError ? 'Le mot de passe est obligatoire.' : ''"
|
||||
/>
|
||||
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
|
||||
Laisse vide pour ne pas changer le mot de passe.
|
||||
</p>
|
||||
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
|
||||
Le mot de passe est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -135,40 +153,32 @@
|
||||
</div>
|
||||
|
||||
<div v-if="form.accessMode === 'self'">
|
||||
<label class="text-md font-semibold text-neutral-700" for="employee">
|
||||
Employé lié
|
||||
</label>
|
||||
<select
|
||||
id="employee"
|
||||
v-model="form.employeeId"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
>
|
||||
<option value="">Aucun</option>
|
||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
|
||||
Sélectionne un employé.
|
||||
</p>
|
||||
<MalioSelect
|
||||
:model-value="form.employeeId === '' ? null : form.employeeId"
|
||||
:options="employeeOptions"
|
||||
label="Employé lié"
|
||||
empty-option-label="Aucun"
|
||||
min-width=""
|
||||
:error="showSelfEmployeeError ? 'Sélectionne un employé.' : ''"
|
||||
@update:model-value="onEmployeeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.accessMode === 'sites'">
|
||||
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
|
||||
<div class="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
<div
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer"
|
||||
class="flex h-10 items-center rounded-md border border-neutral-200 px-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="cursor-pointer"
|
||||
:checked="form.siteIds.includes(site.id)"
|
||||
@change="toggleSite(site.id)"
|
||||
<MalioCheckbox
|
||||
:model-value="form.siteIds.includes(site.id)"
|
||||
:label="site.name"
|
||||
group-class="flex items-center"
|
||||
@update:model-value="toggleSite(site.id)"
|
||||
/>
|
||||
<span>{{ site.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="showSitesError" class="mt-1 text-sm text-red-600">
|
||||
Sélectionne au moins un site.
|
||||
@@ -176,30 +186,31 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="form.isLocked"
|
||||
type="checkbox"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
Un compte verrouillé ne peut plus se connecter.
|
||||
</p>
|
||||
<MalioCheckbox
|
||||
v-model="form.isLocked"
|
||||
label="Verrouiller le compte"
|
||||
hint="Un compte verrouillé ne peut plus se connecter."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioCheckbox
|
||||
v-model="form.hasLeaveRecapAccess"
|
||||
label="Accès à l'écran Récap. congés"
|
||||
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
<MalioButton
|
||||
type="submit"
|
||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
{{ editingUser ? 'Modifier' : '+ Ajouter' }}
|
||||
</button>
|
||||
:label="editingUser ? 'Modifier' : 'Valider'"
|
||||
button-class="w-[200px]"
|
||||
:disabled="isSubmitting || !isFormValid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -233,7 +244,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({
|
||||
@@ -296,27 +308,13 @@ const getSiteLabels = (user: User) => {
|
||||
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
|
||||
}
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||
const usernameFieldClass = computed(() => {
|
||||
if (showUsernameError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const passwordFieldClass = computed(() => {
|
||||
if (showPasswordError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const employeeOptions = computed(() =>
|
||||
employees.value.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
|
||||
)
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (isSubmitting.value || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const onEmployeeChange = (value: string | number | null) => {
|
||||
form.employeeId = value === null ? '' : Number(value)
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
isLoading.value = true
|
||||
@@ -345,6 +343,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 +372,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 +427,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 +453,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) {
|
||||
|
||||
@@ -15,5 +15,6 @@ export type EmployeeLeaveSummary = {
|
||||
previousYearRemainingDays: number
|
||||
previousYearPaidDays: number
|
||||
presenceDaysByMonth: Record<string, number>
|
||||
presenceDaysToToday: number
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
14
frontend/services/dto/leave-recap.ts
Normal file
14
frontend/services/dto/leave-recap.ts
Normal 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
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export type UserData = {
|
||||
username: string
|
||||
roles: string[]
|
||||
isDriver: boolean
|
||||
hasLeaveRecapAccess: boolean
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ export type User = {
|
||||
username: string
|
||||
roles: string[]
|
||||
isLocked: boolean
|
||||
hasLeaveRecapAccess: boolean
|
||||
employee?: Employee | null
|
||||
}
|
||||
|
||||
@@ -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,8 @@ export type WorkHourDayContextRow = {
|
||||
isDriverContract?: boolean
|
||||
hasFormation?: boolean
|
||||
formationLabel?: string | null
|
||||
virtualHolidayMinutes?: number
|
||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
||||
}
|
||||
|
||||
export type WorkHourDayContext = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
16
frontend/services/interim-agencies.ts
Normal file
16
frontend/services/interim-agencies.ts
Normal 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)
|
||||
}
|
||||
12
frontend/services/leave-recap.ts
Normal file
12
frontend/services/leave-recap.ts
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(', ')
|
||||
}
|
||||
|
||||
26
migrations/Version20260414100000.php
Normal file
26
migrations/Version20260414100000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
46
migrations/Version20260416100000.php
Normal file
46
migrations/Version20260416100000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
32
migrations/Version20260417120000.php
Normal file
32
migrations/Version20260417120000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
36
src/ApiResource/EmployeeLeaveRecap.php
Normal file
36
src/ApiResource/EmployeeLeaveRecap.php
Normal 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 = '';
|
||||
}
|
||||
@@ -38,4 +38,7 @@ final class EmployeeLeaveSummary
|
||||
|
||||
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
|
||||
public array $presenceDaysByMonth = [];
|
||||
|
||||
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
|
||||
public float $presenceDaysToToday = 0.0;
|
||||
}
|
||||
|
||||
24
src/ApiResource/EmployeeYearlyHoursBulkPrint.php
Normal file
24
src/ApiResource/EmployeeYearlyHoursBulkPrint.php
Normal 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 {}
|
||||
805
src/Command/DumpVerificationSnapshotCommand.php
Normal file
805
src/Command/DumpVerificationSnapshotCommand.php
Normal 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, '-');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ final class DayContextRow
|
||||
public bool $isDriverContract = false,
|
||||
public bool $hasFormation = false,
|
||||
public ?string $formationLabel = null,
|
||||
public int $virtualHolidayMinutes = 0,
|
||||
public ?string $contractNature = null,
|
||||
) {}
|
||||
|
||||
public function setFormation(string $label): void
|
||||
@@ -75,7 +77,9 @@ final class DayContextRow
|
||||
* creditedPresenceUnits:float,
|
||||
* isDriverContract:bool,
|
||||
* hasFormation:bool,
|
||||
* formationLabel:?string
|
||||
* formationLabel:?string,
|
||||
* virtualHolidayMinutes:int,
|
||||
* contractNature:?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
@@ -93,6 +97,8 @@ final class DayContextRow
|
||||
'isDriverContract' => $this->isDriverContract,
|
||||
'hasFormation' => $this->hasFormation,
|
||||
'formationLabel' => $this->formationLabel,
|
||||
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
|
||||
'contractNature' => $this->contractNature,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ final class WeeklyDaySummary
|
||||
public bool $hasLunch = false,
|
||||
public bool $hasDinner = false,
|
||||
public bool $hasOvernight = false,
|
||||
public int $virtualHolidayMinutes = 0,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -34,5 +34,6 @@ final class WeeklySummaryRow
|
||||
public int $weeklyDinnerCount = 0,
|
||||
public int $weeklyOvernightCount = 0,
|
||||
public bool $hasContractForWeek = true,
|
||||
public ?string $contractNature = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
51
src/Entity/InterimAgency.php
Normal file
51
src/Entity/InterimAgency.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,8 @@ final class EmployeeContractChangeRequestFactory
|
||||
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
||||
contractComment: $employee->getContractComment(),
|
||||
isDriver: $employee->getIsDriverInput(),
|
||||
workDaysHours: $employee->getWorkDaysHoursInput(),
|
||||
interimAgencyId: $employee->getInterimAgencyId(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
180
src/Service/Leave/LeaveRecapRowBuilder.php
Normal file
180
src/Service/Leave/LeaveRecapRowBuilder.php
Normal 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['remainingDays'], 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";
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
47
src/Service/WorkHours/DailyReferenceMinutesResolver.php
Normal file
47
src/Service/WorkHours/DailyReferenceMinutesResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
116
src/Service/WorkHours/HolidayVirtualHoursResolver.php
Normal file
116
src/Service/WorkHours/HolidayVirtualHoursResolver.php
Normal 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')]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
449
src/Service/WorkHours/YearlyHoursExportBuilder.php
Normal file
449
src/Service/WorkHours/YearlyHoursExportBuilder.php
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
145
src/State/EmployeeLeaveRecapProvider.php
Normal file
145
src/State/EmployeeLeaveRecapProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -119,8 +119,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||||
$summary->previousYearPaidDays = $paidLeaveDays;
|
||||
|
||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
||||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
||||
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
|
||||
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
|
||||
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
||||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth(
|
||||
$employee,
|
||||
$periodFrom,
|
||||
$periodTo,
|
||||
$n1AbsencesBudget
|
||||
);
|
||||
|
||||
// Same logic as presenceDaysByMonth but bounded at today: number of presence days
|
||||
// accumulated from leave year start up to today (inclusive).
|
||||
$today = new DateTimeImmutable('today');
|
||||
$cappedTo = $today < $periodTo ? $today : $periodTo;
|
||||
$summary->presenceDaysToToday = $today < $periodFrom
|
||||
? 0.0
|
||||
: array_sum($this->computePresenceDaysByMonth(
|
||||
$employee,
|
||||
$periodFrom,
|
||||
$cappedTo,
|
||||
$n1AbsencesBudget
|
||||
));
|
||||
|
||||
return $summary;
|
||||
}
|
||||
@@ -140,7 +161,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 +217,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)
|
||||
);
|
||||
@@ -489,19 +511,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)
|
||||
@@ -523,10 +546,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)) {
|
||||
@@ -554,7 +582,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),
|
||||
@@ -648,13 +679,40 @@ 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.
|
||||
*
|
||||
* @return array<string, float> YYYY-MM => presence day count
|
||||
*/
|
||||
private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
private function computePresenceDaysByMonth(
|
||||
Employee $employee,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
float $n1AbsencesBudget = 0.0
|
||||
): array {
|
||||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||||
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
|
||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||
@@ -664,10 +722,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
|
||||
: [];
|
||||
|
||||
// Sort absences chronologically so N-1 budget (forfait only) is consumed in date order:
|
||||
// earliest absences attribute to N-1 first, later ones overflow to N and reduce presence.
|
||||
$sortedAbsences = $absences;
|
||||
usort(
|
||||
$sortedAbsences,
|
||||
static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate()
|
||||
);
|
||||
|
||||
$remainingN1Budget = $n1AbsencesBudget;
|
||||
|
||||
// Count absence days per month, iterating day by day to handle multi-day absences
|
||||
// and properly distribute across months.
|
||||
$absenceDaysByMonth = [];
|
||||
foreach ($absences as $absence) {
|
||||
foreach ($sortedAbsences as $absence) {
|
||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
||||
|
||||
@@ -685,6 +753,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
// Forfait: leaves taken from N-1 stock do NOT decrement presence days.
|
||||
// We chronologically consume the N-1 budget before counting any absence.
|
||||
if ($remainingN1Budget > 0.0) {
|
||||
$consumed = min($remainingN1Budget, $dayAmount);
|
||||
$remainingN1Budget -= $consumed;
|
||||
$dayAmount -= $consumed;
|
||||
if ($dayAmount <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
86
src/State/EmployeeYearlyHoursBulkPrintProvider.php
Normal file
86
src/State/EmployeeYearlyHoursBulkPrintProvider.php
Normal 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.'"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,136 +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) {
|
||||
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
|
||||
if ($paidLeaveDays > 0.0) {
|
||||
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
|
||||
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, $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";
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user