Files
SIRH/docs/superpowers/plans/2026-05-19-contract-phase-view.md
tristan 7ee2e91e71 docs(plan) : contract phase view implementation plan
Step-by-step task plan derived from the design spec, covering backend
service + providers, frontend composables + picker UI, and documentation
updates required by CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:24:38 +02:00

57 KiB
Raw Blame History

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.tsshowRttTab 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

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 weeklyHours et isDriver qui 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 getContractPhases dans Employee.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=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 :

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). 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 :
    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)
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

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

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 EmployeeContractPhaseResolver dans 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 ?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
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 contractPhases sur Employee

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 phaseId optionnel

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 isForfaitContract par "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 phaseId dans loadLeaveData
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 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
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 showRttTab pour 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.selectedPhase aux 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 phase dans 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 :

  1. Le picker est visible.
  2. Sélectionner la phase 39h passée → onglet RTT réapparaît, période Juin→Mai.
  3. Repasser sur FORFAIT → onglet RTT disparaît.
  4. 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 selectedPhase au 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 contractPhases sur 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 / useEmployeeRtt propagent phaseId, bornent year → Tasks 9, 10.
  • useEmployeeDetailPage orchestre, showRttTab driver par phase → Task 11.
  • Picker + bandeau frontend → Task 12.
  • RttTab.vue dé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 :

  • ContractPhase PHP DTO et TS type sont alignés (mêmes champs).
  • selectedPhase: Ref<ContractPhase | null> partagé entre useEmployeeDetailPage, useEmployeeLeave, useEmployeeRtt.
  • phaseId est number en TS / int en PHP, query param string validé par regex \d+.

Scope : un seul plan, un feature cohérent. Pas de décomposition supplémentaire nécessaire.