From a2874b545ac1f12909d872428b543ca980e1b732 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 19 May 2026 10:16:47 +0200 Subject: [PATCH 01/55] docs(spec) : contract phase view selector design Add design spec for the contract-phase picker on the employee detail page. Lets HR navigate past contract phases (e.g. 39h before a switch to FORFAIT, or a closed CDD) so they can view and settle leftover CP/RTT balances without changing the default behavior for the current contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-contract-phase-view-design.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-contract-phase-view-design.md diff --git a/docs/superpowers/specs/2026-05-19-contract-phase-view-design.md b/docs/superpowers/specs/2026-05-19-contract-phase-view-design.md new file mode 100644 index 0000000..ceb58c6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-contract-phase-view-design.md @@ -0,0 +1,230 @@ +# Vue contrat (sélecteur de phase) — Design Spec + +## Objectif + +Permettre à la RH de consulter les onglets Congés et RTT d'un employé **selon le contrat actuel ET selon ses contrats passés**, sans changement de comportement par défaut. + +Cas qui motive : un employé passe de 39h à FORFAIT. Tant que le contrat courant est FORFAIT, les soldes CP/RTT accumulés sous l'ancien contrat 39h sont invisibles ou faussés (l'onglet RTT est masqué, la période Congés passe de Juin→Mai à Jan→Déc, les règles d'acquisition appliquent du FORFAIT_218 à toute année consultée). Conséquence : la RH ne peut plus payer les soldes restants. + +Le même verrou existe pour toute fin de contrat avec **solde de tout compte** (notamment fin de CDD) suivie d'un nouveau contrat de type différent. + +## Principe directeur + +> Une fois passé en contrat X, on utilise toutes les règles X par défaut. Le sélecteur permet de revenir sur les phases passées pour les consulter et solder leurs reliquats. + +## Concept de "phase de contrat" + +Une **phase** est un groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la même **signature contractuelle** = `(contract.type, weeklyHours, isDriver)`. La fusion s'arrête dès qu'une période diffère sur l'un de ces trois axes, même si une période identique apparaît plus tard. + +La signature inclut `weeklyHours` et `isDriver` parce que : +- `weeklyHours` détermine les tranches d'heures supp (25%/50%), le rythme d'acquisition CP (cas 4h), la base contractuelle quotidienne. +- `isDriver` change l'écran (`/driver-hours` vs `/hours`) et les colonnes de WorkHour utilisées pour le calcul RTT. + +Exemples : +- CDD 39h → CDI 39h → FORFAIT : 2 phases (`39h`, `FORFAIT`). +- CDD 35h → CDI 39h : 2 phases (`35h`, `39h`). +- 3 CDD 39h consécutifs sans interruption : 1 phase (`39h`). +- 39h → INTERIM 4 mois → 39h : 3 phases (les `39h` ne fusionnent pas à travers l'`INTERIM`). +- CUSTOM 28h → CUSTOM 30h : 2 phases (`weeklyHours` diffère). +- 35h non-driver → 35h driver : 2 phases (`isDriver` diffère). + +La règle de groupement vit dans un service backend, pas dans le frontend. + +## Backend + +### Nouveau service `EmployeeContractPhaseResolver` + +Localisation : `src/Service/Contracts/EmployeeContractPhaseResolver.php`. + +```php +public function resolvePhases(Employee $employee): array; +``` + +Retour : liste ordonnée (plus récente d'abord) de `ContractPhase` : + +| Champ | Type | Description | +|---|---|---| +| `id` | `int` | `EmployeeContractPeriod.id` de la première période (par date) du groupe — sert d'identifiant stable. | +| `contractType` | `ContractType` | Type partagé par les périodes du groupe. | +| `weeklyHours` | `int` | Heures hebdomadaires (partagées par construction). | +| `isDriver` | `bool` | Driver flag (partagé par construction). | +| `startDate` | `DateTimeImmutable` | `startDate` de la première période. | +| `endDate` | `?DateTimeImmutable` | `endDate` de la dernière période, ou `null` si en cours. | +| `periodIds` | `list` | IDs des périodes composant la phase, par ordre chronologique. | +| `isCurrent` | `bool` | `true` si la phase couvre la date du jour (= `endDate === null` ou `endDate >= today`). | + +### Exposition API + +Nouveau computed field sur `Employee` (lecture seule, groupe `employee:read`) : + +```json +"contractPhases": [ + { "id": 42, "contractType": "FORFAIT", "weeklyHours": 39, "isDriver": false, "startDate": "2026-05-01", "endDate": null, "isCurrent": true }, + { "id": 17, "contractType": "THIRTY_NINE_HOURS", "weeklyHours": 39, "isDriver": false, "startDate": "2020-06-01", "endDate": "2026-04-30", "isCurrent": false } +] +``` + +Le calcul se fait à la sérialisation via un getter virtuel `Employee::getContractPhases(): array` qui délègue au resolver. + +### Endpoints impactés + +Les endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent un nouveau paramètre optionnel : + +- `?phaseId=N` : id de la phase à consulter. +- Si absent → phase courante (= comportement actuel inchangé). +- Si invalide (phase n'appartient pas à l'employé) → 422. +- `?year=YYYY` reste accepté en parallèle et continue de cibler un exercice précis. + +### Modifications des providers + +**`EmployeeLeaveSummaryProvider`** : +- Nouvelle méthode `resolveTargetPhase(Employee $e, ?int $phaseId): ContractPhase` qui retourne la phase demandée ou la phase courante. +- `resolveLeavePolicy(...)` reçoit la phase au lieu de lire `$employee->getContract()`. Le `contract.type` et le `weeklyHours` viennent de la signature de la phase (homogène par construction). +- `resolvePeriodBounds(...)` : les bornes de l'exercice sont en plus contraintes à `[max(periodStart, phase.startDate), min(periodEnd, phase.endDate ?? periodEnd)]`. +- `resolveYear(...)` : si `phaseId` fourni et pas de `year` explicite, default = dernier exercice intersectant la phase (= année de `phase.endDate` ou année courante si phase en cours). +- Si `?year` est fourni hors de la plage des exercices intersectant la phase → **clamp silencieux** à l'exercice valide le plus proche, pas d'erreur 422 (cohérence avec l'expérience picker frontend). +- `resolveAccrualCalculationEndDate(...)` et `resolveTakenCalculationEndDate(...)` : caps additionnels sur `phase.endDate` quand la phase n'est pas la phase courante. +- `resolveFirstComputationYear(...)` : restreint aux exercices intersectant la phase. + +**`EmployeeRttSummaryProvider`** : +- Mêmes principes : `?phaseId` côté API, `resolveTargetPhase`, bornes d'exercice cappées à la phase, `rttStartDate` exposé pour borner le sélecteur d'année frontend. +- Pour une phase FORFAIT, le tab RTT est masqué frontend, donc l'endpoint n'est pas appelé en pratique. Pas de garde spécifique côté backend, le comportement existant (retour d'un summary potentiellement vide/inutile) suffit. + +### Paiements de solde + +**RTT — `EmployeeRttPaymentProcessor`** : +- Garde actuelle "exercice courant uniquement" devient : "exercice courant OU dernier exercice d'une phase clôturée". +- Concrètement, autoriser la création d'un `EmployeeRttPayment` sur l'exercice contenant `phase.endDate` d'une phase non courante. +- Les exercices antérieurs au dernier de la phase restent verrouillés (lecture seule). + +**CP — settlement period-level** : +- Le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` reste le canal pour solder. Aucun changement de modèle. +- Le bouton "Année N-1 payés" (FORFAIT) et "Jours fractionnés" (non-FORFAIT) restent désactivés sur une phase passée — ce ne sont pas des paiements de solde mais des éditions de stock. + +### Audit + +- La création d'`EmployeeRttPayment` est déjà auditée (existant). +- La modification de `paidLeaveSettledClosureDate` est déjà auditée via `EmployeeContractPeriodManager` (existant). +- Aucun audit nouveau requis. + +## Frontend + +### Picker + +- Composant `MalioSelect` placé dans `pages/employees/[id].vue`, dans le header de la fiche, sous le nom de l'employé et au-dessus de la barre d'onglets. +- Libellé : `Vue contrat`. +- Options formatées : + - `FORFAIT — depuis 01/05/2026 (actuel)` + - `39h CDI — 01/06/2020 → 30/04/2026` +- **Caché** si `contractPhases.length <= 1` (employé mono-phase, ~majorité des cas). +- Sélection en mémoire (état du composable), **non persistée** entre navigations ou rechargements. Chaque ouverture de fiche démarre sur la phase courante. + +### Bandeau d'information + +Affiché quand `selectedPhase.id !== currentPhase.id` : + +> Vous consultez l'historique **{contractType} — jusqu'au {endDate}**. +> Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée. + +Style : bandeau jaune doux (`bg-warning-100 border-warning-300`), sous le picker, au-dessus des onglets. + +### Composables + +**Nouveau `useEmployeeContractPhase()`** : +- État : `selectedPhase`, `currentPhase` (computed depuis `employee.contractPhases`). +- Computed : `availablePhases`, `isViewingPastPhase`. +- API : `setSelectedPhase(phaseId)`, `resetToCurrent()`. +- `resetToCurrent()` appelé au changement d'employé. + +**`useEmployeeLeave`** : +- Reçoit `phaseId` en paramètre lors des appels à `getEmployeeLeaveSummary` / `listAbsences`. +- `availableLeaveYears` borné aux exercices intersectant la phase sélectionnée. +- `setSelectedPhase` côté parent → reset de `selectedLeaveYear` et reload. + +**`useEmployeeRtt`** : +- Idem pour `getEmployeeRttSummary`, `availableRttYears`. + +**`useEmployeeDetailPage`** : +- `showRttTab` devient : `selectedPhase.contractType !== FORFAIT`. +- La logique de fallback ("si sur l'onglet RTT et FORFAIT, basculer ailleurs") s'applique aussi quand on bascule de phase 39h vers phase FORFAIT. + +### Onglets + +- **Congés** : reçoit la phase via le composable. Bouton "Année N-1 payés" / "Jours fractionnés" reste désactivé sur phase passée (idem que sur exercice passé). +- **RTT** : visibilité driver par la phase. Bouton "+ Payer les RTT" activé **uniquement sur le dernier exercice de la phase passée**, désactivé sur les exercices antérieurs de la phase. +- **Heures, Frais, Formation, Contrat, Calendrier** : non impactés. + +### Format des libellés du picker + +Format de la phase : `{labelContractType} — {startDateFR} → {endDateFR}`, suffixé `(actuel)` si phase courante. + +`labelContractType` mapping : +- `FORFAIT` → `FORFAIT` +- `THIRTY_FIVE_HOURS` → `35h` +- `THIRTY_NINE_HOURS` → `39h` +- `INTERIM` → `Intérim` +- `CUSTOM` → `CUSTOM ({weeklyHours}h)` (les heures hebdo sont homogènes par construction dans une phase) + +Suffixe `(driver)` ajouté quand `isDriver=true`, ex. `35h CDI (driver) — ...`. + +## Migration et impact sur l'existant + +- Aucune migration de données. Le concept de phase est calculé à la volée depuis l'historique existant. +- Comportement inchangé pour tout employé avec une seule phase (cas standard). +- Comportement inchangé quand `phaseId` n'est pas fourni → phase courante. +- Pas de breaking change API : `contractPhases` est un champ additionnel ; `?phaseId` est un paramètre optionnel. + +## Tests + +### Unit + +- `EmployeeContractPhaseResolverTest` : + - Employé mono-période → 1 phase, `isCurrent=true`. + - Trois périodes même signature consécutives → 1 phase. + - Switch 39h → FORFAIT → 2 phases avec `startDate`/`endDate` correctes. + - 39h → INTERIM 4 mois → 39h → 3 phases (pas de fusion). + - 35h → 39h → 2 phases (type différent). + - CUSTOM 28h → CUSTOM 30h → 2 phases (`weeklyHours` diffère). + - 35h non-driver → 35h driver → 2 phases (`isDriver` diffère). + +### Functional + +- `EmployeeLeaveSummaryProvider` avec `phaseId` : + - Phase 39h passée → `ruleCode = CDI_CDD_NON_FORFAIT`, période Juin→Mai, exercice de transition capé à `phase.endDate`. + - Phase FORFAIT passée → `ruleCode = FORFAIT_218`, période Jan→Déc. + - `phaseId` invalide pour l'employé → 422. + - `?year` hors de la plage de la phase → clamp silencieux à l'exercice intersectant le plus proche. + +- `EmployeeRttSummaryProvider` avec `phaseId` : + - Phase 39h passée → données RTT renvoyées, bornes cappées sur `phase.endDate`. + - `?year` hors de la plage de la phase → clamp silencieux. + +- `EmployeeRttPaymentProcessor` : + - Création autorisée sur exercice de fin d'une phase passée. + - Création refusée sur un exercice antérieur d'une phase passée. + +### Documentation à mettre à jour + +Obligatoire par CLAUDE.md : + +- `doc/contract-phase-view.md` — nouveau fichier détaillant la fonctionnalité. +- `doc/leave-tab.md` — section "Sélecteur de phase" + interaction avec le sélecteur d'année. +- `doc/rtt-tab.md` — section "Sélecteur de phase" + règle de visibilité. +- `frontend/data/documentation-content.ts` — article niveau `admin`. +- `CLAUDE.md` — bloc "Vue contrat (sélecteur de phase)" sous Onglet Congés / Onglet RTT. + +## Hors scope + +- Surface d'alerte automatique sur les fiches employés ayant des soldes non payés sur des phases passées (potentiel follow-up). +- Persistance de la sélection du picker entre navigations. +- Picker exposé sur le calendrier global ou tout autre écran que la fiche employé. +- Modification de `WorkHourDayContext` (déjà date-driven, pas concerné). +- Évolution du mécanisme `paidLeaveSettledClosureDate` (canal existant suffisant). +- Cas exotiques : phases overlap (interdit par la modélisation actuelle), périodes avec dates incohérentes. + +## Décisions confirmées avec l'utilisateur + +- Picker global en haut de la fiche, **pas** par onglet. +- Phases groupées par `contract.type` consécutif. +- Sur une phase passée : exercices antérieurs visibles **en lecture seule**, seul le dernier exercice de la phase ouvre les actions de solde (RTT pay, CP settlement period-level). +- Comportement par défaut (phase courante) strictement inchangé. -- 2.39.5 From 7ee2e91e7168a39c8efc46462aae7dcb38483819 Mon Sep 17 00:00:00 2001 From: tristan Date: Tue, 19 May 2026 10:24:38 +0200 Subject: [PATCH 02/55] docs(plan) : contract phase view implementation plan Step-by-step task plan derived from the design spec, covering backend service + providers, frontend composables + picker UI, and documentation updates required by CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-19-contract-phase-view.md | 1607 +++++++++++++++++ 1 file changed, 1607 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-contract-phase-view.md 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 `