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 `