# 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 `