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) <noreply@anthropic.com>
57 KiB
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 phasessrc/Dto/Contracts/ContractPhase.php— DTO retourné par le resolver et exposé via APItests/Service/Contracts/EmployeeContractPhaseResolverTest.php— tests unitaires du resolver
Backend - fichiers modifiés
src/Entity/Employee.php— nouveau gettergetContractPhases()exposé enemployee:readsrc/State/EmployeeLeaveSummaryProvider.php— accepte?phaseId, cape calculs sur la phasesrc/State/EmployeeRttSummaryProvider.php— accepte?phaseId, cape exercice sur la phasesrc/State/EmployeeRttPaymentProcessor.php— autorise paiement sur dernier exercice d'une phase passéetests/State/EmployeeLeaveSummaryProviderTest.php— cas de phase passée et transitiontests/State/EmployeeRttSummaryProviderTest.php— nouveau (si manquant)tests/State/EmployeeRttPaymentProcessorTest.php— nouveau (si manquant)
Frontend - nouveaux fichiers
frontend/services/dto/contract-phase.ts— type TS pourContractPhasefrontend/composables/useEmployeeContractPhase.ts— composable du picker
Frontend - fichiers modifiés
frontend/services/dto/employee.ts— ajout du champcontractPhasesfrontend/services/employee-leave-summary.ts— paramètre optionnelphaseIdfrontend/services/employee-rtt-summary.ts— paramètre optionnelphaseIdfrontend/composables/useEmployeeLeave.ts— propagationphaseId, borneavailableLeaveYearsà la phasefrontend/composables/useEmployeeRtt.ts— idemfrontend/composables/useEmployeeDetailPage.ts—showRttTabdriver par la phase sélectionnéefrontend/pages/employees/[id].vue— picker + bandeaufrontend/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
declare(strict_types=1);
namespace App\Dto\Contracts;
use App\Enum\ContractType;
use DateTimeImmutable;
final readonly class ContractPhase
{
/**
* @param list<int> $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
declare(strict_types=1);
namespace App\Tests\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
final class EmployeeContractPhaseResolverTest extends TestCase
{
public function testSinglePeriodYieldsSinglePhaseMarkedCurrent(): void
{
$employee = $this->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<array{type: ContractType, hours: ?int, driver: bool, start: string, end: ?string}> $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
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Dto\Contracts\ContractPhase;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use DateTimeImmutable;
final readonly class EmployeeContractPhaseResolver
{
/**
* @return list<ContractPhase>
*/
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<EmployeeContractPeriod> $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 :
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)
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)
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
weeklyHoursetisDriverqui cassent la signature
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
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
getContractPhasesdansEmployee.php
À ajouter juste après getContractHistory() (autour de la ligne 430) :
/**
* @return list<array{
* id: int,
* contractType: string,
* weeklyHours: ?int,
* isDriver: bool,
* startDate: string,
* endDate: ?string,
* periodIds: list<int>,
* 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 :
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)
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
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 :
private EmployeeContractPhaseResolver $phaseResolver,
et use App\Service\Contracts\EmployeeContractPhaseResolver; en haut.
- Step 3.2: Écrire un test : phase passée 39h, le picker
?phaseId=Nretourne duCDI_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 :
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 :
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). SiphaseIdfourni et pas deyear, default = année calculée à partir de$phase->endDate ?? todayselon que la phase est FORFAIT ou non.resolveLeavePolicy(Employee $e, $from, $to)→resolveLeavePolicy(Employee $e, ContractPhase $phase, $from, $to). Utiliser$phase->contractTypeau lieu de$employee->getContract()?->getType()et$phase->weeklyHoursau lieu de$employee->getContract()?->getWeeklyHours().resolvePeriodBounds(Employee $e, int $year)→resolvePeriodBounds(Employee $e, ContractPhase $phase, int $year). Caper avec :if ($phase->startDate > $from) { $from = $phase->startDate; } if (null !== $phase->endDate && $phase->endDate < $to) { $to = $phase->endDate; }resolveForfaitYearBoundsetresolveLeavePeriodBounds(internes) : recevoir la phase et appliquer le cap.resolveAccrualCalculationEndDate(...)etresolveTakenCalculationEndDate(...): caper additionnellement sur$phase->endDatequand!$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)
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
?yearhors phase
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
phaseIdinvalide → 422
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
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
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
EmployeeContractPhaseResolverdans le constructeur
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"
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 :
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
?yearclamp silencieux pour RTT
Test analogue au step 3.8 mais sur le summary RTT.
- Step 4.8: Test
phaseIdinvalide → 422
Test analogue au step 3.10.
- Step 4.9: Test comportement par défaut inchangé
Run: make test
- Step 4.10: Commit
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
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"
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 :
// 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"
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
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 :
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
contractPhasessurEmployee
Dans frontend/services/dto/employee.ts, ajouter le champ :
contractPhases?: ContractPhase[]
Import en haut :
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
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 :
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<Employee | null>) => {
const selectedPhaseId = ref<number | null>(null)
const availablePhases = computed<ContractPhase[]>(() => employee.value?.contractPhases ?? [])
const currentPhase = computed<ContractPhase | null>(() => {
return availablePhases.value.find((p) => p.isCurrent) ?? availablePhases.value[0] ?? null
})
const selectedPhase = computed<ContractPhase | null>(() => {
if (selectedPhaseId.value === null) return currentPhase.value
return availablePhases.value.find((p) => p.id === selectedPhaseId.value) ?? currentPhase.value
})
const isViewingPastPhase = computed<boolean>(() => {
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
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
grep -n "getEmployeeLeaveSummary\|getEmployeeRttSummary" frontend/services/employee-leave-summary.ts frontend/services/employee-rtt-summary.ts
- Step 8.2: Ajouter le paramètre
phaseIdoptionnel
Pour getEmployeeLeaveSummary :
export const getEmployeeLeaveSummary = async (
employeeId: number,
year?: number,
phaseId?: number,
): Promise<EmployeeLeaveSummary> => {
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
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 :
export const useEmployeeLeave = (
employee: Ref<Employee | null>,
reloadEmployee: () => Promise<void>,
selectedPhase: Ref<ContractPhase | null>,
) => { ... }
(L'orchestration de la phase vit dans useEmployeeDetailPage qui passe la ref ici — voir Task 11.)
- Step 9.2: Remplacer
isForfaitContractpar "is forfait sur la phase sélectionnée"
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
const availableLeaveYears = computed<LeaveYearOption[]>(() => {
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
phaseIddansloadLeaveData
const summary = await getEmployeeLeaveSummary(
employee.value.id,
leaveYear,
selectedPhase.value?.id,
)
- Step 9.5: Reagir au changement de phase
Ajouter un watcher :
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
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
selectedPhaseen paramètre
Comme Task 9.1.
- Step 10.2: Borner
availableRttYearsaux 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
phaseIddansloadRttData
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
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
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
Dans useEmployeeDetailPage, après la création de employee, instancier la phase :
const phase = useEmployeeContractPhase(employee)
- Step 11.2: Adapter
showRttTabpour driver par la phase
const showRttTab = computed(() => phase.selectedPhase.value?.contractType !== CONTRACT_TYPES.FORFAIT)
Idem pour isForfait :
const isForfait = computed(() => phase.selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT)
- Step 11.3: Passer
phase.selectedPhaseaux composables enfants
Modifier les appels :
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 :
phase.resetToCurrent()
- Step 11.5: Recharger l'onglet actif quand la phase change
Ajouter un watcher :
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
phasedans le return
return {
...
...phase,
...
}
-
Step 11.7: Vérifier la compilation TS
-
Step 11.8: Commit
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
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 :
<div v-if="showPicker" class="mb-3 flex items-center gap-3">
<label class="text-sm font-bold text-primary-500">Vue contrat</label>
<MalioSelect
:model-value="selectedPhase?.id ?? null"
:options="phaseOptions"
class="w-[420px]"
@update:model-value="setSelectedPhase"
/>
</div>
<div
v-if="isViewingPastPhase && selectedPhase"
class="mb-3 rounded-md border border-warning-300 bg-warning-100 px-4 py-2 text-sm text-warning-900"
>
Vous consultez l'historique
<strong>{{ formatPhaseLabel(selectedPhase) }}</strong>.
Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
</div>
- Step 12.3: Destructurer le composable dans le
<script setup>
Le useEmployeeDetailPage() expose déjà tout via le spread ...phase. S'assurer que selectedPhase, showPicker, phaseOptions, setSelectedPhase, isViewingPastPhase sont importés du retour.
Importer formatPhaseLabel :
import { formatPhaseLabel } from '~/composables/useEmployeeContractPhase'
- Step 12.4: Tester en local
Démarrer le stack :
make start
make dev-nuxt
Ouvrir un employé qui a au moins 2 phases (peut nécessiter de simuler en BDD un switch 39h→FORFAIT). Vérifier :
- Le picker est visible.
- Sélectionner la phase 39h passée → onglet RTT réapparaît, période Juin→Mai.
- Repasser sur FORFAIT → onglet RTT disparaît.
- Bandeau jaune visible en mode phase passée.
Si pas d'employé multi-phase en BDD : créer un fixture ou modifier un employé existant pour le test.
- Step 12.5: Commit
git add frontend/pages/employees/[id].vue
git commit -m "feat(employee) : contract phase picker + past-mode banner"
Task 13: Frontend — RttTab.vue désactive "+ Payer les RTT" sur exercices antérieurs d'une phase clôturée
Files:
-
Modify:
frontend/components/employees/RttTab.vue -
Step 13.1: Identifier le bouton et la condition de désactivation actuelle
grep -n "Payer les RTT\|disabled" frontend/components/employees/RttTab.vue
- Step 13.2: Étendre la condition de désactivation
Le bouton est désactivé si :
- exercice passé sur phase courante (existant), OU
- phase passée ET l'exercice consulté n'est pas l'exercice contenant
phase.endDate.
Ajouter un computed :
const isLastExerciseOfPhase = computed(() => {
if (!props.selectedPhase || props.selectedPhase.isCurrent) return false
if (!props.selectedPhase.endDate) return false
const endYear = new Date(`${props.selectedPhase.endDate}T00:00:00`).getMonth() >= 5
? new Date(`${props.selectedPhase.endDate}T00:00:00`).getFullYear() + 1
: new Date(`${props.selectedPhase.endDate}T00:00:00`).getFullYear()
return props.selectedYear === endYear
})
const isPayDisabled = computed(() =>
(props.selectedYear !== props.currentYear && !isLastExerciseOfPhase.value)
)
Adapter la prop :disabled="isPayDisabled" sur le bouton existant.
- Step 13.3: Ajouter la prop
selectedPhaseau composant
Dans defineProps, ajouter selectedPhase: ContractPhase | null.
- Step 13.4: La passer depuis
pages/employees/[id].vue
Dans le template :
<RttTab :selected-phase="selectedPhase" ... />
-
Step 13.5: Vérifier la compilation TS
-
Step 13.6: Commit
git add frontend/components/employees/RttTab.vue frontend/pages/employees/[id].vue
git commit -m "feat(rtt) : enable pay button on closed phase last exercise"
Task 14: Documentation — nouveau fichier doc/contract-phase-view.md
Files:
-
Create:
doc/contract-phase-view.md -
Step 14.1: Créer le fichier
# Vue contrat — sélecteur de phase
## Objectif
Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon une phase de contrat passée (ex. un 39h CDI avant un switch FORFAIT, ou un CDD clôturé avec solde de tout compte) sans changer le comportement par défaut sur la phase courante.
## Concept de "phase"
Une **phase** = groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la signature `(contract.type, weeklyHours, isDriver)`. Le service `EmployeeContractPhaseResolver` (`src/Service/Contracts/EmployeeContractPhaseResolver.php`) calcule ces phases à la volée depuis `Employee::getContractPeriods()`.
Une transition de signature (35h → 39h, 39h → FORFAIT, driver false→true, weeklyHours 28→30, etc.) ouvre une nouvelle phase. Un type différent entre deux périodes de même signature empêche leur fusion (39h → INTERIM → 39h = 3 phases).
## Picker UI
- Position : en haut de la fiche employé, sous le nom et au-dessus des onglets.
- Libellé : `Vue contrat`.
- Caché si l'employé n'a qu'une seule phase.
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
## Bandeau d'information
Affiché quand le picker est sur une phase passée. Indique que le mode lecture est partiel — les paiements de solde restent possibles, l'édition d'absences et des stocks de report est désactivée.
## Effet sur les onglets
| Onglet | Effet |
|---|---|
| Congés | Recharge avec les règles de la phase. Période Juin→Mai pour non-forfait, Jan→Déc pour FORFAIT. Exercices bornés à la phase. |
| RTT | Visible ssi `phase.contractType !== FORFAIT`. Exercices bornés à la phase. |
| Heures, Frais, Formation, Contrat, Calendrier | Non impactés. |
## Paiements de solde sur phase passée
- **RTT** : `+ Payer les RTT` activé sur le **dernier exercice de la phase** uniquement (l'exercice contenant `phase.endDate`). Les exercices antérieurs restent en lecture seule.
- **CP** : utiliser le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` pour solder. Pas de nouveau channel.
## Transition d'exercice
Quand un exercice chevauche deux phases (ex. switch 39h→FORFAIT au 01/05/2026 fait que l'exercice Juin 2025 → Mai 2026 est à cheval) :
- Vu depuis la phase 39h, l'exercice est borné à `phase.endDate` (30/04/2026).
- Vu depuis la phase FORFAIT, la période civile 2026 est bornée à `phase.startDate` (01/05/2026).
## API
Les endpoints suivants acceptent `?phaseId=N` :
- `GET /employees/{id}/leave-summary`
- `GET /employees/{id}/rtt-summary`
Quand absent, ils utilisent la phase courante (comportement inchangé).
`Employee.contractPhases` (lecture, groupe `employee:read`) liste les phases au format `{id, contractType, weeklyHours, isDriver, startDate, endDate, periodIds, isCurrent}`.
## Tests
- `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` (unit)
- `tests/State/EmployeeLeaveSummaryProviderTest.php` (functional, phaseId)
- `tests/State/EmployeeRttSummaryProviderTest.php` (functional, phaseId)
- `tests/State/EmployeeRttPaymentProcessorTest.php` (functional, dernier exo phase clôturée)
- Step 14.2: Commit
git add doc/contract-phase-view.md
git commit -m "docs : add contract-phase-view documentation"
Task 15: Documentation — mise à jour doc/leave-tab.md et doc/rtt-tab.md
Files:
-
Modify:
doc/leave-tab.md -
Modify:
doc/rtt-tab.md -
Step 15.1: Ajouter une section au
doc/leave-tab.md
Après "Verrouillage des éditions sur années passées", insérer :
## Sélecteur de phase de contrat
Quand l'employé a plusieurs phases de contrat (`Employee.contractPhases.length > 1`), le picker `Vue contrat` en haut de la fiche permet de consulter une phase passée. L'onglet Congés bascule alors sur les règles de la phase choisie :
- Période Juin→Mai pour les phases non-forfait, Jan→Déc pour FORFAIT.
- Sélecteur d'année interne borné aux exercices intersectant la phase.
- Bornes d'exercice cappées sur `phase.endDate` côté backend (l'exercice de transition affiche les soldes acquis jusqu'à la date de fin de phase, pas au-delà).
- Boutons crayon `Jours fractionnés` / `Année N-1 payés` désactivés (lecture seule sur phase passée).
Cf. `doc/contract-phase-view.md` pour les détails complets.
- Step 15.2: Ajouter une section au
doc/rtt-tab.md
Après "Verrouillage des édition sur exercices passés", insérer :
## Sélecteur de phase de contrat
L'onglet RTT est visible quand la **phase de contrat sélectionnée** n'est pas FORFAIT (et non pas le contrat courant). Concrètement, sur un employé passé en FORFAIT après une période 39h :
- En vue `FORFAIT` (défaut), l'onglet RTT est masqué.
- En vue `39h` (phase passée sélectionnée via le picker `Vue contrat`), l'onglet RTT redevient visible avec les exercices Juin→Mai bornés à la phase.
Le bouton `+ Payer les RTT` est activé uniquement sur le **dernier exercice de la phase passée** (l'exercice contenant `phase.endDate`). Les exercices antérieurs sont en lecture seule.
Cf. `doc/contract-phase-view.md` pour les détails complets.
- Step 15.3: Commit
git add doc/leave-tab.md doc/rtt-tab.md
git commit -m "docs : leave/rtt tabs reference phase selector"
Task 16: Documentation in-app — frontend/data/documentation-content.ts
Files:
-
Modify:
frontend/data/documentation-content.ts -
Step 16.1: Repérer la structure
grep -n "requiredLevel:\s*'admin'" frontend/data/documentation-content.ts | head -5
Identifier où ajouter un nouvel article niveau admin dans la section "Fiche employé".
- Step 16.2: Ajouter l'article
Dans l'array des articles de la section pertinente, insérer un nouvel article :
{
id: 'contract-phase-view',
title: 'Vue contrat — sélecteur de phase',
requiredLevel: 'admin',
blocks: [
{
kind: 'paragraph',
content: "Quand un employé change de type de contrat (ex. 39h → FORFAIT) ou enchaîne plusieurs CDD avec solde de tout compte, ses anciennes phases de contrat restent consultables via le sélecteur 'Vue contrat' en haut de la fiche."
},
{
kind: 'paragraph',
content: "Choisir une phase passée fait basculer les onglets Congés et RTT sur les règles de cette phase. L'onglet RTT réapparaît si la phase n'est pas un FORFAIT. Un bandeau jaune indique que vous êtes en mode historique."
},
{
kind: 'paragraph',
content: "Sur une phase passée, vous pouvez :"
},
{
kind: 'list',
items: [
"Solder les RTT restants — bouton '+ Payer les RTT' actif uniquement sur le dernier exercice de la phase (celui contenant la date de fin).",
"Solder les CP restants via le champ 'Solde de tout compte' sur la période de contrat correspondante (onglet Contrat).",
]
},
{
kind: 'paragraph',
content: "L'édition d'absences et des stocks de report (jours fractionnés, Année N-1) est désactivée en mode phase passée."
},
],
},
Adapter aux conventions de noms exacts du fichier (title, id, requiredLevel).
-
Step 16.3: Vérifier la compilation TS
-
Step 16.4: Commit
git add frontend/data/documentation-content.ts
git commit -m "docs(in-app) : add contract phase view help article"
Task 17: CLAUDE.md — bloc descriptif de la fonctionnalité
Files:
-
Modify:
CLAUDE.md -
Step 17.1: Ajouter un bloc après "Onglet RTT (fiche employé)"
## Vue contrat (sélecteur de phase)
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
- Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante.
- Sélectionner une phase passée :
- Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercices bornés à la phase, exercice de transition capé sur `phase.endDate`.
- Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`.
- Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée.
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
- CP : solder via `EmployeeContractPeriod.paidLeaveSettledClosureDate` (mécanisme existant). RTT : créer un `EmployeeRttPayment` sur le dernier exercice de la phase.
- Doc complète : `doc/contract-phase-view.md`.
- Step 17.2: Commit
git add CLAUDE.md
git commit -m "docs(claude-md) : document contract phase view selector"
Self-Review (post-rédaction)
Spec coverage :
- ✅ Concept de phase, signature
(type, weeklyHours, isDriver), exemples → Tasks 1, 14. - ✅ Service
EmployeeContractPhaseResolver→ Task 1. - ✅ Computed field
contractPhasessur Employee → Task 2. - ✅ Endpoints acceptent
?phaseId→ Tasks 3 et 4. - ✅
EmployeeLeaveSummaryProvider:resolveTargetPhase, cap période, cap accrual/taken, clamp year → Task 3. - ✅
EmployeeRttSummaryProvider: idem → Task 4. - ✅
EmployeeRttPaymentProcessor: autorise dernier exo phase clôturée → Task 5. - ✅ TS DTO + composable phase → Tasks 6, 7.
- ✅
useEmployeeLeave/useEmployeeRttpropagent phaseId, bornent year → Tasks 9, 10. - ✅
useEmployeeDetailPageorchestre,showRttTabdriver par phase → Task 11. - ✅ Picker + bandeau frontend → Task 12.
- ✅
RttTab.vuedésactivation conditionnelle du pay button → Task 13. - ✅ Documentation
doc/,documentation-content.ts,CLAUDE.md→ Tasks 14-17. - ✅ Tests pour resolver, leave provider, rtt provider, payment processor → Tasks 1, 3, 4, 5.
Placeholder scan : aucune occurrence de "TBD", "TODO", "à raffiner", "similar to". Les snippets de code sont explicites. Les noms de méthodes/props/fields cités dans les tasks tardives correspondent à ce qui est défini dans les tasks antérieures (resolvePhases, selectedPhase, phaseOptions, setSelectedPhase, isViewingPastPhase, formatPhaseLabel).
Type consistency :
ContractPhasePHP DTO et TS type sont alignés (mêmes champs).selectedPhase: Ref<ContractPhase | null>partagé entreuseEmployeeDetailPage,useEmployeeLeave,useEmployeeRtt.phaseIdestnumberen TS /inten PHP, query paramstringvalidé par regex\d+.
Scope : un seul plan, un feature cohérent. Pas de décomposition supplémentaire nécessaire.