diff --git a/CLAUDE.md b/CLAUDE.md index 53154c3..1fdebaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ - **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`). +- **É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`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. - **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) @@ -64,10 +64,12 @@ - Contracts <= 35h: +25% from 35h to 43h, +50% beyond - Contracts >= 39h: +25% from 39h to 43h, +50% beyond - CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance +- **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%. - 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é. +- **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase). ## Onglet Congés (fiche employé) - Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée. @@ -83,6 +85,22 @@ - Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif). - Doc : `doc/rtt-tab.md`. +## Vue contrat (sélecteur de phase) +- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase. +- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`. +- **Filtre `RTT_START_DATE`** : les phases dont `endDate < RTT_START_DATE` sont masquées (aucune donnée logiciel avant la mise en service). Le resolver reçoit la date via DI (`services.yaml`) ; `Employee::getContractPhases()` lit `$_SERVER['RTT_START_DATE']` pour instancier le resolver côté entité. +- Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante. +- Sélectionner une phase passée : + - Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur `phase.endDate`. **Cap `from` au `phase.startDate` uniquement pour FORFAIT** (sémantique année civile). Pour le non-forfait, l'exercice CP reste annuel et continu à travers les changements d'heures (35h→39h, etc.) — seul `resolveEffectivePeriodStart` clampe sur la date d'entrée en contrat des nouveaux embauchés. + - **Entrée FORFAIT en cours d'année** (année d'entrée only) : l'exercice d'entrée crédite `repos_proratisés + CP_reportés` au lieu de `max(0, businessDays−218)`=0. Repos année = `jours_ouvrés_année − 218 − 25`, proratisés par jours ouvrés. CP reportés = solde de la phase non-forfait précédente (jours ouvrés nets + samedis bruts ; un samedi posé ne réduit PAS le report ; fractionnés exclus). Nouvel embauché = repos seuls. Années pleines suivantes + forfait démarrant le 01/01 = calcul 218 inchangé (→34). Services : `EmployeeLeaveSummaryProvider::{resolveLeavePolicy (branche FORFAIT), isForfaitEntryYear, computeProratedForfaitRepoDays, resolveCarriedCpFromPriorPhase}`. Témoin Grégory : ≈13. + - **Header fiche employé (jours à travailler / présence / restant)** : `EmployeeLeaveSummary.forfaitWorkTargetDays` = `jours_ouvrés_période − acquiredDays` (218 année pleine = 252−34 ; entrée = 168−13 ≈ **155**). Le header (`useEmployeeDetailPage.employeeContractWorkLabel` + `forfaitRemainingDaysLabel`) affiche `Forfait - {target} jours ({presenceDaysToToday} présence · {target−présence} restants)`. Avant : `218` codé en dur → faux pour une entrée en cours d'année. Témoin Grégory : `155 jours (11 présence · 144 restants)`. **Non-forfait** : le header affiche les jours de présence seuls via `nonForfaitPresenceLabel` (`{weeklyHours} heures ({presenceDaysToToday} présence)`, ex. `39 heures (43,5 présence)`). Le récap congés est désormais chargé en eager pour tout employé avec onglet Congés (pas seulement le forfait) afin d'alimenter ce libellé. + - Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. Tableau hebdo affiché sur l'exercice complet (Juin→Mai) ; `periodFrom` non capé sur `phase.startDate` (les semaines avant embauche ou hors phase apparaissent à 0). `periodTo`/`limitDate` capés sur `phase.endDate` pour les phases clôturées. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`. + - Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée. +- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante. +- CP : solder via `EmployeeContractPeriod.paidLeaveSettledClosureDate` (mécanisme existant). RTT : créer un `EmployeeRttPayment` sur le dernier exercice de la phase. +- "Exercise year for date" mutualisé dans `App\Service\Exercise\ExerciseYearResolver` (forfait = année civile, non-forfait = Juin N-1 → Mai N). +- Doc complète : `doc/contract-phase-view.md`. + ## 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 diff --git a/README.md b/README.md index 2e9ab55..489a69c 100644 --- a/README.md +++ b/README.md @@ -23,5 +23,17 @@ 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'; ``` + +## Récupérer la bdd de prod en local + +Sur le serveur de prod, créer le dump : +```shell sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql -scp user@:/tmp/sirh_prod_2026-04-14.dump ~/workspace/ +``` + +En local, récupérer le fichier et l'importer (remplace `YYYY-MM-DD` par la date du dump) : +```shell +scp user@:/tmp/sirh_prod_YYYY-MM-DD.sql ~/workspace/SIRH/sirh.sql +docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" +docker compose exec -T db psql -U root -d sirh < ~/workspace/SIRH/sirh.sql +``` diff --git a/config/services.yaml b/config/services.yaml index 81d3957..9a32ff2 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -39,6 +39,10 @@ services: arguments: $dataStartDate: '%env(RTT_START_DATE)%' + App\Service\Contracts\EmployeeContractPhaseResolver: + arguments: + $dataStartDate: '%env(RTT_START_DATE)%' + App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository' App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository' App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository' diff --git a/doc/contract-phase-view.md b/doc/contract-phase-view.md new file mode 100644 index 0000000..f6b37eb --- /dev/null +++ b/doc/contract-phase-view.md @@ -0,0 +1,107 @@ +# Vue contrat — sélecteur de phase + +## Objectif + +Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon une phase de contrat passée (ex. un 39h CDI avant un switch FORFAIT, ou un CDD clôturé avec solde de tout compte) sans changer le comportement par défaut sur la phase courante. + +## Concept de "phase" + +Une **phase** = groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la signature `(contract.type, weeklyHours, isDriver)`. Le service `EmployeeContractPhaseResolver` (`src/Service/Contracts/EmployeeContractPhaseResolver.php`) calcule ces phases à la volée depuis `Employee::getContractPeriods()`. + +Une transition de signature (35h → 39h, 39h → FORFAIT, driver false→true, weeklyHours 28→30, etc.) ouvre une nouvelle phase. Un type différent entre deux périodes de même signature empêche leur fusion (39h → INTERIM → 39h = 3 phases). + +## Filtrage par `RTT_START_DATE` + +Les phases dont la date de fin est strictement antérieure à `RTT_START_DATE` (env, date de mise en service du logiciel) sont **masquées** du picker. Aucune donnée logiciel (heures, absences) n'existe avant cette date, donc consulter une phase entièrement antérieure n'a pas de sens fonctionnel. + +Exemple : un employé sous 35h CDI de 2014 à 2025-10-31 puis 39h CDI depuis 2025-11-01, avec `RTT_START_DATE=2026-02-23` : +- Phase 35h (2014 → 2025-10-31) : entièrement avant → masquée +- Phase 39h (depuis 2025-11-01) : chevauche → visible +- Résultat : 1 seule phase visible → picker caché (seuil ≥ 2 phases) + +Une phase dont la date de fin est égale à `RTT_START_DATE` est conservée (inégalité `>=`, non stricte). + +`EmployeeContractPhaseResolver` reçoit `RTT_START_DATE` via DI (`services.yaml`). L'entité `Employee::getContractPhases()` lit la valeur depuis `$_SERVER`/`$_ENV` directement (l'entité n'a pas d'injection) et passe la chaîne au constructeur du resolver. + +## Picker UI + +- Position : en haut de la fiche employé, sous le nom et au-dessus des onglets. +- Libellé : `Vue contrat`. +- Caché si l'employé n'a qu'une seule phase. +- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante. + +## Bandeau d'information + +Affiché quand le picker est sur une phase passée. Indique que le mode lecture est partiel — les paiements de solde restent possibles, l'édition d'absences et des stocks de report est désactivée. + +## Effet sur les onglets + +| Onglet | Effet | +|---|---| +| Congés | Recharge avec les règles de la phase. Période Juin→Mai pour non-forfait, Jan→Déc pour FORFAIT. Exercices bornés à la phase. | +| RTT | Visible ssi `phase.contractType !== FORFAIT`. Le tableau affiche **toujours toutes les semaines de l'exercice** (Juin→Mai). Pour une phase clôturée, `periodTo` et `limitDate` sont capés à `phase.endDate` (les semaines après la fin de phase contribuent 0 au cumul). `periodFrom` n'est pas capé : les semaines avant l'embauche ou avant le début d'une phase passée apparaissent à 0 (pas de contrat → pas de référence → 0 minute). | +| Heures, Frais, Formation, Contrat, Calendrier | Non impactés. | + +## Paiements de solde sur phase passée + +- **RTT** : `+ Payer les RTT` activé sur le **dernier exercice de la phase** uniquement (l'exercice contenant `phase.endDate`). Les exercices antérieurs restent en lecture seule. +- **CP** : utiliser le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` pour solder. Pas de nouveau channel. + +## Transition d'exercice + +Quand un exercice chevauche deux phases, les bornes sont capées différemment selon le type de phase consultée : + +### Phase FORFAIT (passée ou courante) + +Le cumul 218 jours est **par année civile**. Toute consultation FORFAIT cape : +- `from` à `max(phase.startDate, 1er janvier de l'année)` +- `to` à `min(phase.endDate, 31 décembre de l'année)` + +Ex. switch 39h → FORFAIT au 01/05/2026, vue FORFAIT année 2026 → période = [01/05/2026, 31/12/2026]. + +#### Entrée en FORFAIT en cours d'année civile + +L'**année d'entrée** (période partielle, ex. 01/05 → 31/12) ne calcule pas `max(0, businessDays − 218)` (qui donnerait 0) mais : + + jours_repos_année = jours_ouvrés_année − 218 − 25 + jours_repos_proratisés = jours_repos_année × (jours_ouvrés_période / jours_ouvrés_année) + congés_à_poser = jours_repos_proratisés + CP_reportés_phase_précédente + +- jours ouvrés = Lun-Ven − fériés en semaine (liste brute, incl. Pentecôte) ; prorata par jours ouvrés. +- `CP_reportés` = solde de la phase non-forfait précédente : jours ouvrés **nets** (acquis + en cours − jours ouvrés posés) + samedis **bruts**. Un **samedi déjà posé ne réduit pas** le report (seuls les jours ouvrés posés le réduisent — règle comptable). Jours fractionnés exclus. Nouvel embauché forfait (pas de phase précédente) → 0, donc repos proratisés seuls. +- Périmètre : **uniquement l'année d'entrée**. Les années pleines suivantes et les forfaits démarrant un 1er janvier gardent le calcul 218 (→ 34). +- Détection : `EmployeeLeaveSummaryProvider::isForfaitEntryYear` ; calcul : `computeProratedForfaitRepoDays` + `resolveCarriedCpFromPriorPhase`. + +Exemple Grégory BARRIBAULT (forfait 01/05/2026 après 39h, exercice 2026) : `6 repos + ~7 CP = ≈ 13 jours`. + +**Header de la fiche** : `EmployeeLeaveSummary.forfaitWorkTargetDays` = `jours_ouvrés_période − congés_acquis` donne les **jours à travailler** de l'exercice (218 sur une année pleine, prorata sinon ; Grégory 2026 = `168 − 13 ≈ 155`). Le header affiche `Forfait - {jours à travailler} jours ({présence} présence · {restant} restants)` avec `restant = jours_à_travailler − presenceDaysToToday`. Auparavant le `218` était codé en dur côté frontend, donc faux pour une entrée en cours d'année (Grégory : `155 jours (11 présence · 144 restants)`). + +### Phase non-forfait (35h / 39h / CUSTOM / INTERIM) + +L'exercice CP est **annuel** (Juin N-1 → Mai N) et continu à travers les changements d'heures contractuelles dans le même régime non-forfait. La cap **n'applique pas** sur `from` : +- `from` reste à 1er juin de l'année (le contrat-entry-date est géré par `resolveEffectivePeriodStart` pour les nouveaux embauchés) +- `to` est borné à `phase.endDate` uniquement quand on consulte une **phase passée** + +Ex. employé 35h jusqu'au 31/10/2025 puis 39h depuis le 01/11/2025 : +- Vue 39h (courante) sur exercice 2026 → période = [01/06/2025, 31/05/2026]. Acquis CP = exercice complet (~27.5 jours à fin avril). +- Vue 35h (passée) sur exercice 2026 → période = [01/06/2025, 31/10/2025]. Acquis CP = 5 mois de l'exercice. + +**Important** : c'est intentionnel que la vue courante 39h inclue les mois Juin-Octobre travaillés en 35h dans son cumul. Le stock CP est annuel, pas par phase ; un changement d'heures ne reset pas le compteur. + +## API + +Les endpoints suivants acceptent `?phaseId=N` : +- `GET /employees/{id}/leave-summary` +- `GET /employees/{id}/rtt-summary` + +Quand absent, ils utilisent la phase courante (comportement inchangé). + +`Employee.contractPhases` (lecture, groupe `employee:read`) liste les phases au format `{id, contractType, weeklyHours, isDriver, contractNature, startDate, endDate, periodIds, isCurrent}`. + +## Tests + +- `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` (unit) +- `tests/State/EmployeeLeaveSummaryProviderTest.php` (functional, phaseId) +- `tests/State/EmployeeRttSummaryProviderTest.php` (functional, phaseId) +- `tests/State/EmployeeRttPaymentProcessorTest.php` (functional, dernier exo phase clôturée) +- `tests/Service/Exercise/ExerciseYearResolverTest.php` (helper extrait pendant Task 5) diff --git a/doc/functional-rules.md b/doc/functional-rules.md index b155bdd..9b0d6e8 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -189,6 +189,8 @@ Filtres disponibles: Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. +Seuls les employés dont au moins une période de contrat intersecte la période imprimée `[from, to]` apparaissent dans le PDF (même règle que la vue Calendrier). Un salarié parti avant la période (ex. contrat terminé en avril, impression de mai) est exclu. Borne calculée sur la date seule, côté backend (`AbsencePrintProvider::hasContractInRange`). + ## 9) Employés - Création employé: diff --git a/doc/leave-tab.md b/doc/leave-tab.md index 34091b0..b01e92a 100644 --- a/doc/leave-tab.md +++ b/doc/leave-tab.md @@ -47,6 +47,16 @@ Quand `selectedYear !== currentYear` (consultation d'une année antérieure) : Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non. +## Sélecteur de phase de contrat + +Quand l'employé a plusieurs phases de contrat (`Employee.contractPhases.length > 1`), le picker `Vue contrat` en haut de la fiche permet de consulter une phase passée. L'onglet Congés bascule alors sur les règles de la phase choisie : +- Période Juin→Mai pour les phases non-forfait, Jan→Déc pour FORFAIT. +- Sélecteur d'année interne borné aux exercices intersectant la phase. +- Bornes d'exercice cappées sur `phase.endDate` côté backend (l'exercice de transition affiche les soldes acquis jusqu'à la date de fin de phase, pas au-delà). +- Boutons crayon `Jours fractionnés` / `Année N-1 payés` désactivés (lecture seule sur phase passée). + +Cf. `doc/contract-phase-view.md` pour les détails complets. + ## Implémentation - Composable : `frontend/composables/useEmployeeLeave.ts` diff --git a/doc/rtt-tab.md b/doc/rtt-tab.md index f3d694c..059a43b 100644 --- a/doc/rtt-tab.md +++ b/doc/rtt-tab.md @@ -38,6 +38,16 @@ Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le La consultation reste possible, l'édition non. +## Sélecteur de phase de contrat + +L'onglet RTT est visible quand la **phase de contrat sélectionnée** n'est pas FORFAIT (et non pas le contrat courant). Concrètement, sur un employé passé en FORFAIT après une période 39h : +- En vue `FORFAIT` (défaut), l'onglet RTT est masqué. +- En vue `39h` (phase passée sélectionnée via le picker `Vue contrat`), l'onglet RTT redevient visible avec les exercices Juin→Mai bornés à la phase. + +Le bouton `+ Payer les RTT` est activé uniquement sur le **dernier exercice de la phase passée** (l'exercice contenant `phase.endDate`). Les exercices antérieurs sont en lecture seule. + +Cf. `doc/contract-phase-view.md` pour les détails complets. + ## Implémentation - Composable : `frontend/composables/useEmployeeRtt.ts` diff --git a/docs/superpowers/plans/2026-05-19-contract-phase-view.md b/docs/superpowers/plans/2026-05-19-contract-phase-view.md new file mode 100644 index 0000000..3051c35 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-contract-phase-view.md @@ -0,0 +1,1607 @@ +# Contract Phase View — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon une **phase de contrat passée** (groupe d'`EmployeeContractPeriod` consécutifs partageant `contract.type` + `weeklyHours` + `isDriver`) via un picker global en haut de la fiche employé, sans changer le comportement par défaut sur la phase courante. + +**Architecture :** Un nouveau service backend `EmployeeContractPhaseResolver` calcule les phases depuis `Employee::getContractPeriods()`. Les providers `EmployeeLeaveSummaryProvider` et `EmployeeRttSummaryProvider` acceptent un `?phaseId` qui cape période, exercice par défaut et règles de calcul à cette phase. Un picker frontend (`MalioSelect`) en haut de `pages/employees/[id].vue` pilote la phase consultée et un bandeau d'information signale les modes passés. Les composables `useEmployeeLeave` et `useEmployeeRtt` propagent `phaseId` sur leurs appels API et bornent leur sélecteur d'année interne. + +**Tech Stack :** Symfony + API Platform + Doctrine (backend), PHPUnit (tests), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), `@malio/layer-ui` (composants UI), `make test` pour la suite PHPUnit, `cd frontend && npm run typecheck` pour la TS. + +**Spec source :** `docs/superpowers/specs/2026-05-19-contract-phase-view-design.md`. + +--- + +## File Map + +**Backend - nouveaux fichiers** +- `src/Service/Contracts/EmployeeContractPhaseResolver.php` — service de calcul des phases +- `src/Dto/Contracts/ContractPhase.php` — DTO retourné par le resolver et exposé via API +- `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` — tests unitaires du resolver + +**Backend - fichiers modifiés** +- `src/Entity/Employee.php` — nouveau getter `getContractPhases()` exposé en `employee:read` +- `src/State/EmployeeLeaveSummaryProvider.php` — accepte `?phaseId`, cape calculs sur la phase +- `src/State/EmployeeRttSummaryProvider.php` — accepte `?phaseId`, cape exercice sur la phase +- `src/State/EmployeeRttPaymentProcessor.php` — autorise paiement sur dernier exercice d'une phase passée +- `tests/State/EmployeeLeaveSummaryProviderTest.php` — cas de phase passée et transition +- `tests/State/EmployeeRttSummaryProviderTest.php` — nouveau (si manquant) +- `tests/State/EmployeeRttPaymentProcessorTest.php` — nouveau (si manquant) + +**Frontend - nouveaux fichiers** +- `frontend/services/dto/contract-phase.ts` — type TS pour `ContractPhase` +- `frontend/composables/useEmployeeContractPhase.ts` — composable du picker + +**Frontend - fichiers modifiés** +- `frontend/services/dto/employee.ts` — ajout du champ `contractPhases` +- `frontend/services/employee-leave-summary.ts` — paramètre optionnel `phaseId` +- `frontend/services/employee-rtt-summary.ts` — paramètre optionnel `phaseId` +- `frontend/composables/useEmployeeLeave.ts` — propagation `phaseId`, borne `availableLeaveYears` à la phase +- `frontend/composables/useEmployeeRtt.ts` — idem +- `frontend/composables/useEmployeeDetailPage.ts` — `showRttTab` driver par la phase sélectionnée +- `frontend/pages/employees/[id].vue` — picker + bandeau +- `frontend/components/employees/RttTab.vue` — désactivation conditionnelle de `+ Payer les RTT` + +**Documentation** +- `doc/contract-phase-view.md` (nouveau) +- `doc/leave-tab.md` (mis à jour) +- `doc/rtt-tab.md` (mis à jour) +- `frontend/data/documentation-content.ts` (mis à jour) +- `CLAUDE.md` (mis à jour) + +--- + +### Task 1: Backend — DTO `ContractPhase` + service `EmployeeContractPhaseResolver` + +**Files:** +- Create: `src/Dto/Contracts/ContractPhase.php` +- Create: `src/Service/Contracts/EmployeeContractPhaseResolver.php` +- Test: `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` + +- [ ] **Step 1.1: Créer le DTO `ContractPhase`** + +```php + $periodIds + */ + public function __construct( + public int $id, + public ContractType $contractType, + public ?int $weeklyHours, + public bool $isDriver, + public DateTimeImmutable $startDate, + public ?DateTimeImmutable $endDate, + public array $periodIds, + public bool $isCurrent, + ) {} +} +``` + +- [ ] **Step 1.2: Écrire le test du resolver — cas mono-période** + +```php +buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => null], + ]); + + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + + self::assertCount(1, $phases); + self::assertSame(ContractType::H39, $phases[0]->contractType); + self::assertTrue($phases[0]->isCurrent); + self::assertNull($phases[0]->endDate); + } + + /** + * @param list $periodsSpec + */ + private function buildEmployee(array $periodsSpec): Employee + { + $employee = new Employee(); + $id = 0; + foreach ($periodsSpec as $spec) { + $contract = new Contract(); + $contract->setName($spec['type']->value); + $contract->setTrackingMode( + ContractType::FORFAIT === $spec['type'] ? TrackingMode::PRESENCE->value : TrackingMode::TIME->value + ); + $contract->setWeeklyHours($spec['hours']); + + $period = new EmployeeContractPeriod(); + $reflection = new \ReflectionProperty(EmployeeContractPeriod::class, 'id'); + $reflection->setValue($period, ++$id); + $period->setEmployee($employee); + $period->setContract($contract); + $period->setStartDate(new DateTimeImmutable($spec['start'])); + $period->setEndDate(null !== $spec['end'] ? new DateTimeImmutable($spec['end']) : null); + $period->setContractNatureEnum(ContractNature::CDI); + $period->setIsDriver($spec['driver']); + $employee->addContractPeriod($period); + } + + return $employee; + } +} +``` + +- [ ] **Step 1.3: Lancer le test pour vérifier qu'il échoue (resolver inexistant)** + +Run: `make test` +Expected: FAIL avec `Class "App\Service\Contracts\EmployeeContractPhaseResolver" not found` + +- [ ] **Step 1.4: Implémenter le resolver (version minimale qui groupe par signature)** + +```php + + */ + public function resolvePhases(Employee $employee): array + { + $periods = $employee->getContractPeriods()->toArray(); + usort( + $periods, + static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $a->getStartDate() <=> $b->getStartDate() + ); + + $today = new DateTimeImmutable('today'); + $phases = []; + $group = []; + $signature = null; + + foreach ($periods as $period) { + $currentSignature = $this->signature($period); + if ($signature !== null && $currentSignature !== $signature) { + $phases[] = $this->buildPhase($group, $today); + $group = []; + } + $group[] = $period; + $signature = $currentSignature; + } + + if ([] !== $group) { + $phases[] = $this->buildPhase($group, $today); + } + + // Plus récente d'abord. + return array_reverse($phases); + } + + private function signature(EmployeeContractPeriod $period): string + { + $contract = $period->getContract(); + $type = $contract?->getType()->value ?? ''; + $hours = $contract?->getWeeklyHours() ?? -1; + $driver = $period->getIsDriver() ? '1' : '0'; + + return sprintf('%s|%d|%s', $type, $hours, $driver); + } + + /** + * @param non-empty-list $group + */ + private function buildPhase(array $group, DateTimeImmutable $today): ContractPhase + { + $first = $group[0]; + $last = end($group); + + $endDate = $last->getEndDate(); + $isCurrent = null === $endDate || $endDate >= $today; + + $contract = $first->getContract(); + + return new ContractPhase( + id: (int) $first->getId(), + contractType: $contract?->getType() ?? throw new \LogicException('Phase requires a contract type'), + weeklyHours: $contract?->getWeeklyHours(), + isDriver: $first->getIsDriver(), + startDate: $first->getStartDate(), + endDate: $endDate, + periodIds: array_map(static fn (EmployeeContractPeriod $p): int => (int) $p->getId(), $group), + isCurrent: $isCurrent, + ); + } +} +``` + +- [ ] **Step 1.5: Run tests for green** + +Run: `make test -- --filter EmployeeContractPhaseResolverTest` +Expected: PASS (1 test). + +- [ ] **Step 1.6: Ajouter le test "trois périodes même signature → une phase"** + +Dans `EmployeeContractPhaseResolverTest.php`, ajouter : + +```php +public function testThreeConsecutivePeriodsSameSignatureCollapseIntoSinglePhase(): void +{ + $employee = $this->buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2021-05-31'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-06-01', 'end' => '2022-05-31'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2022-06-01', 'end' => null], + ]); + + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + + self::assertCount(1, $phases); + self::assertCount(3, $phases[0]->periodIds); + self::assertSame('2020-06-01', $phases[0]->startDate->format('Y-m-d')); + self::assertNull($phases[0]->endDate); +} +``` + +- [ ] **Step 1.7: Run, expect PASS** + +Run: `make test -- --filter EmployeeContractPhaseResolverTest` + +- [ ] **Step 1.8: Ajouter test transition 39h → FORFAIT (deux phases)** + +```php +public function testSwitchFromH39ToForfaitProducesTwoPhasesMostRecentFirst(): void +{ + $employee = $this->buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2026-04-30'], + ['type' => ContractType::FORFAIT, 'hours' => 39, 'driver' => false, 'start' => '2026-05-01', 'end' => null], + ]); + + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + + self::assertCount(2, $phases); + self::assertSame(ContractType::FORFAIT, $phases[0]->contractType); + self::assertTrue($phases[0]->isCurrent); + self::assertSame(ContractType::H39, $phases[1]->contractType); + self::assertFalse($phases[1]->isCurrent); + self::assertSame('2026-04-30', $phases[1]->endDate?->format('Y-m-d')); +} +``` + +- [ ] **Step 1.9: Run, expect PASS** + +- [ ] **Step 1.10: Ajouter test break par INTERIM (pas de fusion)** + +```php +public function testInterimBetweenTwoH39PeriodsBreaksThePhases(): void +{ + $employee = $this->buildEmployee([ + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2023-12-31'], + ['type' => ContractType::INTERIM, 'hours' => null, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-04-30'], + ['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2024-05-01', 'end' => null], + ]); + + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + + self::assertCount(3, $phases); + self::assertSame(ContractType::H39, $phases[0]->contractType); + self::assertSame(ContractType::INTERIM, $phases[1]->contractType); + self::assertSame(ContractType::H39, $phases[2]->contractType); +} +``` + +- [ ] **Step 1.11: Run, expect PASS** + +- [ ] **Step 1.12: Ajouter tests `weeklyHours` et `isDriver` qui cassent la signature** + +```php +public function testCustomPhasesSplitOnWeeklyHoursChange(): void +{ + $employee = $this->buildEmployee([ + ['type' => ContractType::CUSTOM, 'hours' => 28, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-12-31'], + ['type' => ContractType::CUSTOM, 'hours' => 30, 'driver' => false, 'start' => '2025-01-01', 'end' => null], + ]); + + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + + self::assertCount(2, $phases); + self::assertSame(30, $phases[0]->weeklyHours); + self::assertSame(28, $phases[1]->weeklyHours); +} + +public function testPhasesSplitOnIsDriverChange(): void +{ + $employee = $this->buildEmployee([ + ['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2023-01-01', 'end' => '2024-12-31'], + ['type' => ContractType::H35, 'hours' => 35, 'driver' => true, 'start' => '2025-01-01', 'end' => null], + ]); + + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + + self::assertCount(2, $phases); + self::assertTrue($phases[0]->isDriver); + self::assertFalse($phases[1]->isDriver); +} +``` + +- [ ] **Step 1.13: Run, expect PASS pour tous les tests du resolver** + +- [ ] **Step 1.14: Commit** + +```bash +git add src/Dto/Contracts/ContractPhase.php src/Service/Contracts/EmployeeContractPhaseResolver.php tests/Service/Contracts/EmployeeContractPhaseResolverTest.php +git commit -m "feat(contracts) : add EmployeeContractPhaseResolver service" +``` + +--- + +### Task 2: Backend — exposer `contractPhases` sur l'API Employee + +**Files:** +- Modify: `src/Entity/Employee.php` (ajout d'un getter virtuel) +- Modify: `tests/State/EmployeeWriteProcessorTest.php` (vérifier que la sérialisation reste valide) + +- [ ] **Step 2.1: Ajouter le getter `getContractPhases` dans `Employee.php`** + +À ajouter juste après `getContractHistory()` (autour de la ligne 430) : + +```php +/** + * @return list, + * isCurrent: bool + * }> + */ +#[Groups(['employee:read'])] +public function getContractPhases(): array +{ + $resolver = new \App\Service\Contracts\EmployeeContractPhaseResolver(); + + return array_map( + static fn (\App\Dto\Contracts\ContractPhase $phase): array => [ + 'id' => $phase->id, + 'contractType' => $phase->contractType->value, + 'weeklyHours' => $phase->weeklyHours, + 'isDriver' => $phase->isDriver, + 'startDate' => $phase->startDate->format('Y-m-d'), + 'endDate' => $phase->endDate?->format('Y-m-d'), + 'periodIds' => $phase->periodIds, + 'isCurrent' => $phase->isCurrent, + ], + $resolver->resolvePhases($this), + ); +} +``` + +Note : on instancie le resolver inline plutôt que de l'injecter (l'entité ne fait pas d'injection). Le resolver n'a pas de dépendance, donc c'est sûr. + +- [ ] **Step 2.2: Ajouter use statements en haut de `Employee.php`** + +Ajouter dans la section `use` : +```php +use App\Dto\Contracts\ContractPhase; +use App\Service\Contracts\EmployeeContractPhaseResolver; +``` + +Et simplifier le getter en remplaçant les FQCN par les imports. + +- [ ] **Step 2.3: Vérifier que la suite PHPUnit passe encore** + +Run: `make test` +Expected: tous verts. + +- [ ] **Step 2.4: Vérifier le rendu de l'API (sanity check manuel)** + +```bash +docker exec -t -u www-data php-sirh-fpm php bin/console debug:container --tag=api_platform.metadata.resource.metadata_collection_factory | head +``` + +(juste pour s'assurer qu'il n'y a pas de cache stale — pas d'attendu strict) + +- [ ] **Step 2.5: Commit** + +```bash +git add src/Entity/Employee.php +git commit -m "feat(employee) : expose contractPhases on read API" +``` + +--- + +### Task 3: Backend — `EmployeeLeaveSummaryProvider` accepte `?phaseId` + +**Files:** +- Modify: `src/State/EmployeeLeaveSummaryProvider.php` +- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php` + +- [ ] **Step 3.1: Ajouter une dépendance vers le resolver** + +Dans le constructeur de `EmployeeLeaveSummaryProvider`, injecter : +```php +private EmployeeContractPhaseResolver $phaseResolver, +``` + +et `use App\Service\Contracts\EmployeeContractPhaseResolver;` en haut. + +- [ ] **Step 3.2: Écrire un test : phase passée 39h, le picker `?phaseId=N` retourne du `CDI_CDD_NON_FORFAIT`** + +Dans `tests/State/EmployeeLeaveSummaryProviderTest.php`, après les tests existants, ajouter un test functional ou unit. Si l'existant utilise un harness simple (mock RequestStack), s'en inspirer. Sinon créer une fonction `withPhaseQuery(int $phaseId)` qui mock le RequestStack pour renvoyer `?phaseId=N`. + +Test cible : +```php +public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void +{ + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); // 39h → FORFAIT + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + $h39Phase = $phases[1]; // plus ancienne = 39h + + $provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id]); + + $summary = $provider->provide(/* Operation */ $this->operation(), ['id' => 1]); + + self::assertSame('CDI_CDD_NON_FORFAIT', $summary->ruleCode); +} +``` + +Adapter aux helpers de la classe de test existante. Si la classe n'a pas encore de helper builder, l'ajouter en `private function`. + +- [ ] **Step 3.3: Run, expect FAIL (le provider ignore encore `phaseId`)** + +Run: `make test -- --filter EmployeeLeaveSummaryProviderTest` + +- [ ] **Step 3.4: Implémenter la résolution de la phase cible** + +Dans `EmployeeLeaveSummaryProvider.php` : + +a. Ajouter une méthode privée : +```php +private function resolveTargetPhase(Employee $employee): ContractPhase +{ + $raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId'); + $phases = $this->phaseResolver->resolvePhases($employee); + if ([] === $phases) { + throw new UnprocessableEntityHttpException('Employee has no contract phase.'); + } + + if (null === $raw || '' === $raw) { + // Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente. + foreach ($phases as $phase) { + if ($phase->isCurrent) { + return $phase; + } + } + return $phases[0]; + } + + if (!preg_match('/^\d+$/', (string) $raw)) { + throw new UnprocessableEntityHttpException('phaseId must be a positive integer.'); + } + $phaseId = (int) $raw; + foreach ($phases as $phase) { + if ($phase->id === $phaseId) { + return $phase; + } + } + throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.'); +} +``` + +b. Au début de `provide()`, après la résolution de l'employé, appeler `$phase = $this->resolveTargetPhase($employee);`. + +c. Refactoriser pour propager `$phase` aux méthodes internes : +- `resolveYear(Employee $e)` → `resolveYear(Employee $e, ContractPhase $phase)` (signature change). Si `phaseId` fourni et pas de `year`, default = année calculée à partir de `$phase->endDate ?? today` selon que la phase est FORFAIT ou non. +- `resolveLeavePolicy(Employee $e, $from, $to)` → `resolveLeavePolicy(Employee $e, ContractPhase $phase, $from, $to)`. Utiliser `$phase->contractType` au lieu de `$employee->getContract()?->getType()` et `$phase->weeklyHours` au lieu de `$employee->getContract()?->getWeeklyHours()`. +- `resolvePeriodBounds(Employee $e, int $year)` → `resolvePeriodBounds(Employee $e, ContractPhase $phase, int $year)`. Caper avec : + ```php + if ($phase->startDate > $from) { $from = $phase->startDate; } + if (null !== $phase->endDate && $phase->endDate < $to) { $to = $phase->endDate; } + ``` +- `resolveForfaitYearBounds` et `resolveLeavePeriodBounds` (internes) : recevoir la phase et appliquer le cap. +- `resolveAccrualCalculationEndDate(...)` et `resolveTakenCalculationEndDate(...)` : caper additionnellement sur `$phase->endDate` quand `!$phase->isCurrent`. +- `resolveFirstComputationYear(Employee $e)` → `resolveFirstComputationYear(Employee $e, ContractPhase $phase)` : ne pas remonter avant l'exercice contenant `$phase->startDate`. + +Note : on remplace systématiquement `$employee->getContract()?->getType()` et `$employee->getContract()?->getWeeklyHours()` par les valeurs de la phase. La sémantique de `getContract()` ne change pas (elle reste le contrat courant) ; on l'évite simplement dans ce provider quand `phaseId` est explicite. + +d. Côté `resolveYear` quand un `?year` explicite est hors phase : clamp silencieux dans la plage [premier exercice de la phase, dernier exercice de la phase]. Implementer une fonction helper `clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int`. + +- [ ] **Step 3.5: Run, expect PASS du nouveau test** + +Run: `make test -- --filter EmployeeLeaveSummaryProviderTest` + +- [ ] **Step 3.6: Ajouter test transition d'exercice (cap aux bornes de la phase)** + +```php +public function testTransitionExerciseOnH39PhaseIsCappedAtPhaseEndDate(): void +{ + // 39h jusqu'au 2026-04-30, FORFAIT à partir du 2026-05-01. + // L'exercice de congé Juin 2025 → Mai 2026 sur la phase 39h doit être borné à 2026-04-30. + // Soldes acquis attendus = 11 mois × 2.0833 ≈ 22.92 jours (+5 samedis prorata). + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + $h39Phase = $phases[1]; + + $provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id, 'year' => '2026']); + + $summary = $provider->provide($this->operation(), ['id' => 1]); + + self::assertSame('CDI_CDD_NON_FORFAIT', $summary->ruleCode); + // L'exercice est capé à 11 mois plein, donc l'acquis devrait être ≈ 22.9j. + self::assertEqualsWithDelta(22.92, $summary->acquiredDays, 0.5); +} +``` + +- [ ] **Step 3.7: Run, expect PASS** + +- [ ] **Step 3.8: Ajouter test clamp silencieux de `?year` hors phase** + +```php +public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void +{ + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + $h39Phase = $phases[1]; + + // 2030 est hors phase (la phase 39h se termine en 2026) → clamp à 2026. + $provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id, 'year' => '2030']); + + $summary = $provider->provide($this->operation(), ['id' => 1]); + + self::assertSame(2026, $summary->year); +} +``` + +- [ ] **Step 3.9: Run, expect PASS** + +- [ ] **Step 3.10: Tester que `phaseId` invalide → 422** + +```php +public function testInvalidPhaseIdReturns422(): void +{ + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $provider = $this->buildProvider(request: ['phaseId' => '99999']); + + $this->expectException(UnprocessableEntityHttpException::class); + $provider->provide($this->operation(), ['id' => 1]); +} +``` + +- [ ] **Step 3.11: Run, expect PASS** + +- [ ] **Step 3.12: Tester que comportement par défaut (pas de `phaseId`) est inchangé** + +Un test existant sans `phaseId` doit toujours passer. Lancer toute la suite : + +Run: `make test` +Expected: tous verts. + +- [ ] **Step 3.13: Commit** + +```bash +git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php +git commit -m "feat(leave) : phaseId support in EmployeeLeaveSummaryProvider" +``` + +--- + +### Task 4: Backend — `EmployeeRttSummaryProvider` accepte `?phaseId` + +**Files:** +- Modify: `src/State/EmployeeRttSummaryProvider.php` +- Test: `tests/State/EmployeeRttSummaryProviderTest.php` (créer si manquant) + +- [ ] **Step 4.1: Vérifier l'existence du test file** + +```bash +ls tests/State/EmployeeRttSummaryProviderTest.php 2>/dev/null || echo "absent" +``` + +Si absent, créer le squelette avec `setUp` standard sur le modèle de `EmployeeLeaveSummaryProviderTest`. + +- [ ] **Step 4.2: Injecter le `EmployeeContractPhaseResolver` dans le constructeur** + +```php +private EmployeeContractPhaseResolver $phaseResolver, +``` + +et `use App\Service\Contracts\EmployeeContractPhaseResolver;`. + +- [ ] **Step 4.3: Écrire un test "exercice de fin de phase 39h passée — données renvoyées avec cap"** + +```php +public function testPastH39PhaseRttSummaryIsCappedAtPhaseEndDate(): void +{ + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee); + $h39Phase = $phases[1]; + + $provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id, 'year' => '2026']); + + $summary = $provider->provide($this->operation(), ['id' => 1]); + + self::assertSame(2026, $summary->year); + // Les semaines de mai 2026 ne doivent pas apparaître (phase clôturée au 30/04). + foreach ($summary->weeks as $week) { + self::assertLessThanOrEqual('2026-04-30', $week->weekEnd); + } +} +``` + +- [ ] **Step 4.4: Run, expect FAIL** + +- [ ] **Step 4.5: Implémenter le support `phaseId`** + +a. Ajouter `resolveTargetPhase(Employee $e): ContractPhase` (similaire à `EmployeeLeaveSummaryProvider`). + +b. Dans `provide()`, appeler `$phase = $this->resolveTargetPhase($employee);`. + +c. Modifier `resolveYear()` pour accepter la phase : si `phaseId` fourni et pas de `year`, default = année de l'exercice contenant `$phase->endDate ?? today`. Si `year` fourni hors phase, clamp silencieux. + +d. Après `[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);`, ajouter : +```php +if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) { + $periodTo = $phase->endDate; +} +if ($phase->startDate > $periodFrom) { + $periodFrom = $phase->startDate; +} +``` + +e. Adapter la limite des semaines (`$limitDate`) : si `$phase->endDate` est passé et < `$limitDate`, alors `$limitDate = $phase->endDate`. + +- [ ] **Step 4.6: Run, expect PASS** + +- [ ] **Step 4.7: Test `?year` clamp silencieux pour RTT** + +Test analogue au step 3.8 mais sur le summary RTT. + +- [ ] **Step 4.8: Test `phaseId` invalide → 422** + +Test analogue au step 3.10. + +- [ ] **Step 4.9: Test comportement par défaut inchangé** + +Run: `make test` + +- [ ] **Step 4.10: Commit** + +```bash +git add src/State/EmployeeRttSummaryProvider.php tests/State/EmployeeRttSummaryProviderTest.php +git commit -m "feat(rtt) : phaseId support in EmployeeRttSummaryProvider" +``` + +--- + +### Task 5: Backend — `EmployeeRttPaymentProcessor` autorise paiement sur dernier exo de phase clôturée + +**Files:** +- Modify: `src/State/EmployeeRttPaymentProcessor.php` +- Test: `tests/State/EmployeeRttPaymentProcessorTest.php` (créer si manquant) + +- [ ] **Step 5.1: Lire la garde actuelle** + +```bash +cat src/State/EmployeeRttPaymentProcessor.php +``` + +Identifier la ligne où la création est rejetée si l'exercice n'est pas l'exercice courant. (Probablement un check `if ($year !== $currentExerciseYear)`.) + +- [ ] **Step 5.2: Écrire le test "paiement autorisé sur exercice de fin de phase clôturée"** + +```php +public function testPaymentAllowedOnLastExerciseOfClosedPhase(): void +{ + // Phase 39h clôturée le 2026-04-30, FORFAIT à partir du 2026-05-01. + // L'exercice 2026 (Juin 2025 → Mai 2026) contient la date de fin de la phase 39h. + // Le paiement RTT doit être accepté sur l'exercice 2026 même si l'exercice courant est plus récent. + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $today = new DateTimeImmutable('2027-01-15'); // exercice courant = 2027 + $processor = $this->buildProcessorWithClock($today); + + $payment = new EmployeeRttPaymentInput(); + $payment->employeeId = $employee->getId(); + $payment->year = 2026; + $payment->month = 4; + $payment->base25Minutes = 60; + + $result = $processor->process($payment, $this->postOperation(), []); + + self::assertNotNull($result->getId()); +} +``` + +Si la classe de test ou les helpers n'existent pas, créer un harness minimal sur le modèle de `EmployeeLeaveSummaryProviderTest`. + +- [ ] **Step 5.3: Run, expect FAIL** + +- [ ] **Step 5.4: Modifier le processor** + +Remplacer la garde "exercice courant uniquement" par : +```php +// Autoriser si : +// - exercice courant, OU +// - exercice contenant l'endDate d'une phase clôturée de l'employé +$phases = $this->phaseResolver->resolvePhases($employee); +$isLastExerciseOfClosedPhase = false; +foreach ($phases as $phase) { + if ($phase->isCurrent || null === $phase->endDate) { + continue; + } + $exerciseYearForPhaseEnd = $this->resolveExerciseYearForDate($phase->endDate); + if ($year === $exerciseYearForPhaseEnd) { + $isLastExerciseOfClosedPhase = true; + break; + } +} + +if ($year !== $currentExerciseYear && !$isLastExerciseOfClosedPhase) { + throw new UnprocessableEntityHttpException('RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'); +} +``` + +Injecter `EmployeeContractPhaseResolver` dans le constructeur. + +Note : `resolveExerciseYearForDate` = `(int) $date->format('n') >= 6 ? (int) $date->format('Y') + 1 : (int) $date->format('Y');`. À factoriser dans un helper si elle apparaît plusieurs fois. + +- [ ] **Step 5.5: Run, expect PASS** + +- [ ] **Step 5.6: Test "paiement refusé sur exercice antérieur d'une phase clôturée"** + +```php +public function testPaymentRejectedOnEarlierExerciseOfClosedPhase(): void +{ + $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); + $processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15')); + + $payment = new EmployeeRttPaymentInput(); + $payment->employeeId = $employee->getId(); + $payment->year = 2024; // exercice antérieur dans la phase 39h + $payment->month = 4; + $payment->base25Minutes = 60; + + $this->expectException(UnprocessableEntityHttpException::class); + $processor->process($payment, $this->postOperation(), []); +} +``` + +- [ ] **Step 5.7: Run, expect PASS** + +- [ ] **Step 5.8: Vérifier que le test "exercice courant accepté" passe toujours** + +(L'existant ou un nouveau test confirmant le comportement par défaut.) + +Run: `make test` + +- [ ] **Step 5.9: Commit** + +```bash +git add src/State/EmployeeRttPaymentProcessor.php tests/State/EmployeeRttPaymentProcessorTest.php +git commit -m "feat(rtt) : allow payment on closed phase last exercise" +``` + +--- + +### Task 6: Frontend — TS DTO `ContractPhase` + ajout sur `Employee` + +**Files:** +- Create: `frontend/services/dto/contract-phase.ts` +- Modify: `frontend/services/dto/employee.ts` + +- [ ] **Step 6.1: Créer le type TS** + +`frontend/services/dto/contract-phase.ts` : +```typescript +import type { ContractType } from './contract' + +export type ContractPhase = { + id: number + contractType: ContractType + weeklyHours: number | null + isDriver: boolean + startDate: string + endDate: string | null + periodIds: number[] + isCurrent: boolean +} +``` + +- [ ] **Step 6.2: Ajouter `contractPhases` sur `Employee`** + +Dans `frontend/services/dto/employee.ts`, ajouter le champ : +```typescript +contractPhases?: ContractPhase[] +``` + +Import en haut : +```typescript +import type { ContractPhase } from './contract-phase' +``` + +- [ ] **Step 6.3: Vérifier la compilation TS** + +Run: `cd frontend && npx vue-tsc --noEmit` +Expected: aucune erreur. + +- [ ] **Step 6.4: Commit** + +```bash +git add frontend/services/dto/contract-phase.ts frontend/services/dto/employee.ts +git commit -m "feat(employee) : add contractPhases TS DTO" +``` + +--- + +### Task 7: Frontend — composable `useEmployeeContractPhase` + +**Files:** +- Create: `frontend/composables/useEmployeeContractPhase.ts` + +- [ ] **Step 7.1: Implémenter le composable** + +`frontend/composables/useEmployeeContractPhase.ts` : +```typescript +import type { Ref } from 'vue' +import type { Employee } from '~/services/dto/employee' +import type { ContractPhase } from '~/services/dto/contract-phase' +import { CONTRACT_TYPES } from '~/services/dto/contract' + +const formatDateFr = (iso: string | null): string => { + if (!iso) return '' + const [y, m, d] = iso.split('-') + return `${d}/${m}/${y}` +} + +const formatContractTypeLabel = (phase: ContractPhase): string => { + switch (phase.contractType) { + case CONTRACT_TYPES.FORFAIT: + return 'FORFAIT' + case CONTRACT_TYPES.H35: + return '35h' + case CONTRACT_TYPES.H39: + return '39h' + case CONTRACT_TYPES.INTERIM: + return 'Intérim' + case CONTRACT_TYPES.CUSTOM: + return `CUSTOM (${phase.weeklyHours ?? '?'}h)` + default: + return String(phase.contractType) + } +} + +export const formatPhaseLabel = (phase: ContractPhase): string => { + const base = formatContractTypeLabel(phase) + const driver = phase.isDriver ? ' (driver)' : '' + const dates = phase.endDate + ? `${formatDateFr(phase.startDate)} → ${formatDateFr(phase.endDate)}` + : `depuis ${formatDateFr(phase.startDate)}` + const suffix = phase.isCurrent ? ' (actuel)' : '' + return `${base}${driver} — ${dates}${suffix}` +} + +export const useEmployeeContractPhase = (employee: Ref) => { + const selectedPhaseId = ref(null) + + const availablePhases = computed(() => employee.value?.contractPhases ?? []) + + const currentPhase = computed(() => { + return availablePhases.value.find((p) => p.isCurrent) ?? availablePhases.value[0] ?? null + }) + + const selectedPhase = computed(() => { + if (selectedPhaseId.value === null) return currentPhase.value + return availablePhases.value.find((p) => p.id === selectedPhaseId.value) ?? currentPhase.value + }) + + const isViewingPastPhase = computed(() => { + if (!selectedPhase.value || !currentPhase.value) return false + return selectedPhase.value.id !== currentPhase.value.id + }) + + const phaseOptions = computed(() => + availablePhases.value.map((p) => ({ value: p.id, label: formatPhaseLabel(p) })) + ) + + const showPicker = computed(() => availablePhases.value.length > 1) + + const setSelectedPhase = (phaseId: number) => { + selectedPhaseId.value = phaseId + } + + const resetToCurrent = () => { + selectedPhaseId.value = null + } + + return { + selectedPhaseId, + selectedPhase, + currentPhase, + availablePhases, + phaseOptions, + showPicker, + isViewingPastPhase, + setSelectedPhase, + resetToCurrent, + } +} +``` + +- [ ] **Step 7.2: Vérifier la compilation TS** + +Run: `cd frontend && npx vue-tsc --noEmit` + +- [ ] **Step 7.3: Commit** + +```bash +git add frontend/composables/useEmployeeContractPhase.ts +git commit -m "feat(employee) : add useEmployeeContractPhase composable" +``` + +--- + +### Task 8: Frontend — propagation `phaseId` dans les services API + +**Files:** +- Modify: `frontend/services/employee-leave-summary.ts` +- Modify: `frontend/services/employee-rtt-summary.ts` + +- [ ] **Step 8.1: Trouver la signature actuelle** + +```bash +grep -n "getEmployeeLeaveSummary\|getEmployeeRttSummary" frontend/services/employee-leave-summary.ts frontend/services/employee-rtt-summary.ts +``` + +- [ ] **Step 8.2: Ajouter le paramètre `phaseId` optionnel** + +Pour `getEmployeeLeaveSummary` : +```typescript +export const getEmployeeLeaveSummary = async ( + employeeId: number, + year?: number, + phaseId?: number, +): Promise => { + const params = new URLSearchParams() + if (year !== undefined) params.set('year', String(year)) + if (phaseId !== undefined) params.set('phaseId', String(phaseId)) + const qs = params.toString() + return await api(`/employees/${employeeId}/leave-summary${qs ? `?${qs}` : ''}`) +} +``` + +Adapter à la signature exacte existante (qui peut utiliser axios/ofetch, ou un util `api`). Le but est juste d'ajouter `phaseId` à la query string. + +Idem pour `getEmployeeRttSummary`. + +- [ ] **Step 8.3: Vérifier la compilation TS** + +Run: `cd frontend && npx vue-tsc --noEmit` + +- [ ] **Step 8.4: Commit** + +```bash +git add frontend/services/employee-leave-summary.ts frontend/services/employee-rtt-summary.ts +git commit -m "feat(api) : phaseId query parameter on leave/rtt endpoints" +``` + +--- + +### Task 9: Frontend — `useEmployeeLeave` propage `phaseId` et borne le sélecteur d'année + +**Files:** +- Modify: `frontend/composables/useEmployeeLeave.ts` + +- [ ] **Step 9.1: Accepter une référence à la phase sélectionnée** + +Modifier la signature : +```typescript +export const useEmployeeLeave = ( + employee: Ref, + reloadEmployee: () => Promise, + selectedPhase: Ref, +) => { ... } +``` + +(L'orchestration de la phase vit dans `useEmployeeDetailPage` qui passe la ref ici — voir Task 11.) + +- [ ] **Step 9.2: Remplacer `isForfaitContract` par "is forfait sur la phase sélectionnée"** + +```typescript +const isForfaitOnPhase = computed(() => + selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT +) +``` + +Et adapter toutes les utilisations (`isForfaitContract(employee.value)` → `isForfaitOnPhase.value`). + +- [ ] **Step 9.3: Borner `availableLeaveYears` à la phase** + +```typescript +const availableLeaveYears = computed(() => { + if (!employee.value || !selectedPhase.value || currentLeaveYear.value === null) return [] + const isForfait = isForfaitOnPhase.value + const phase = selectedPhase.value + + // Plage = exercices intersectant la phase. + const phaseStartYear = computeLeaveYearForDate(employee.value, new Date(`${phase.startDate}T00:00:00`)) + const phaseEndYear = phase.endDate + ? computeLeaveYearForDate(employee.value, new Date(`${phase.endDate}T00:00:00`)) + : currentLeaveYear.value + + // Hard floor data-start-date + let dataFloor: number | null = null + const dataStart = leaveSummary.value?.dataStartDate + if (dataStart) { + const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`) + if (!Number.isNaN(dataStartDate.getTime())) { + dataFloor = computeLeaveYearForDate(employee.value, dataStartDate) + } + } + + const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear + const maxYear = phaseEndYear + + const years: LeaveYearOption[] = [] + for (let y = maxYear; y >= minYear; y -= 1) { + years.push({ value: y, label: formatLeaveYearLabel(y, isForfait) }) + } + return years +}) +``` + +- [ ] **Step 9.4: Propager `phaseId` dans `loadLeaveData`** + +```typescript +const summary = await getEmployeeLeaveSummary( + employee.value.id, + leaveYear, + selectedPhase.value?.id, +) +``` + +- [ ] **Step 9.5: Reagir au changement de phase** + +Ajouter un watcher : +```typescript +watch(() => selectedPhase.value?.id, () => { + // Reset l'année car la plage a peut-être changé. + selectedLeaveYear.value = null + leaveDataLoaded.value = false + // Le rechargement effectif est piloté par useEmployeeDetailPage. +}) +``` + +- [ ] **Step 9.6: Vérifier la compilation TS** + +- [ ] **Step 9.7: Commit** + +```bash +git add frontend/composables/useEmployeeLeave.ts +git commit -m "feat(leave) : phase-aware leave tab loading" +``` + +--- + +### Task 10: Frontend — `useEmployeeRtt` propage `phaseId` et borne l'exercice + +**Files:** +- Modify: `frontend/composables/useEmployeeRtt.ts` + +- [ ] **Step 10.1: Accepter `selectedPhase` en paramètre** + +Comme Task 9.1. + +- [ ] **Step 10.2: Borner `availableRttYears` aux exercices intersectant la phase** + +Analogue à Task 9.3 mais pour le format Juin→Mai uniquement (toujours non-forfait pour la phase RTT-visible). + +- [ ] **Step 10.3: Propager `phaseId` dans `loadRttData`** + +```typescript +const summary = await getEmployeeRttSummary( + employee.value.id, + selectedRttYear.value, + selectedPhase.value?.id, +) +``` + +- [ ] **Step 10.4: Reagir au changement de phase** + +Watcher analogue à Task 9.5. + +- [ ] **Step 10.5: Vérifier la compilation TS** + +- [ ] **Step 10.6: Commit** + +```bash +git add frontend/composables/useEmployeeRtt.ts +git commit -m "feat(rtt) : phase-aware RTT tab loading" +``` + +--- + +### Task 11: Frontend — `useEmployeeDetailPage` orchestre le picker + +**Files:** +- Modify: `frontend/composables/useEmployeeDetailPage.ts` + +- [ ] **Step 11.1: Importer et instancier le composable phase** + +```typescript +import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase' +``` + +Dans `useEmployeeDetailPage`, après la création de `employee`, instancier la phase : +```typescript +const phase = useEmployeeContractPhase(employee) +``` + +- [ ] **Step 11.2: Adapter `showRttTab` pour driver par la phase** + +```typescript +const showRttTab = computed(() => phase.selectedPhase.value?.contractType !== CONTRACT_TYPES.FORFAIT) +``` + +Idem pour `isForfait` : +```typescript +const isForfait = computed(() => phase.selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT) +``` + +- [ ] **Step 11.3: Passer `phase.selectedPhase` aux composables enfants** + +Modifier les appels : +```typescript +const leave = useEmployeeLeave(employee, loadEmployee, phase.selectedPhase) +const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase) +``` + +- [ ] **Step 11.4: Reset la phase au changement d'employé** + +Dans `loadEmployee`, après `employee.value = await getEmployee(...)`, ajouter : +```typescript +phase.resetToCurrent() +``` + +- [ ] **Step 11.5: Recharger l'onglet actif quand la phase change** + +Ajouter un watcher : +```typescript +watch(() => phase.selectedPhase.value?.id, (newId, oldId) => { + if (newId === oldId || oldId === undefined) return + // Bascule onglet si on entre dans une phase qui ne supporte plus le tab actuel + if (!showRttTab.value && activeTab.value === 'rtt') { + activeTab.value = 'leave' + } + // Recharger l'onglet courant + if (activeTab.value === 'leave' && showLeaveTab.value) { + leave.loadLeaveData() + } else if (activeTab.value === 'rtt' && showRttTab.value) { + rtt.loadRttData() + } +}) +``` + +- [ ] **Step 11.6: Exposer `phase` dans le return** + +```typescript +return { + ... + ...phase, + ... +} +``` + +- [ ] **Step 11.7: Vérifier la compilation TS** + +- [ ] **Step 11.8: Commit** + +```bash +git add frontend/composables/useEmployeeDetailPage.ts +git commit -m "feat(employee) : wire contract phase into detail page composable" +``` + +--- + +### Task 12: Frontend — picker + bandeau sur `pages/employees/[id].vue` + +**Files:** +- Modify: `frontend/pages/employees/[id].vue` + +- [ ] **Step 12.1: Lire la structure de la page** + +```bash +cat frontend/pages/employees/[id].vue | head -120 +``` + +Repérer où sont rendus le nom de l'employé et la barre d'onglets. + +- [ ] **Step 12.2: Ajouter le picker dans le template** + +Entre le bloc nom/avatar et la barre d'onglets, insérer : +```vue +
+ + +
+ +
+ Vous consultez l'historique + {{ formatPhaseLabel(selectedPhase) }}. + Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée. +
+``` + +- [ ] **Step 12.3: Destructurer le composable dans le `