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>
1608 lines
57 KiB
Markdown
1608 lines
57 KiB
Markdown
# Contract Phase View — Plan d'implémentation
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon une **phase de contrat passée** (groupe d'`EmployeeContractPeriod` consécutifs partageant `contract.type` + `weeklyHours` + `isDriver`) via un picker global en haut de la fiche employé, sans changer le comportement par défaut sur la phase courante.
|
||
|
||
**Architecture :** Un nouveau service backend `EmployeeContractPhaseResolver` calcule les phases depuis `Employee::getContractPeriods()`. Les providers `EmployeeLeaveSummaryProvider` et `EmployeeRttSummaryProvider` acceptent un `?phaseId` qui cape période, exercice par défaut et règles de calcul à cette phase. Un picker frontend (`MalioSelect`) en haut de `pages/employees/[id].vue` pilote la phase consultée et un bandeau d'information signale les modes passés. Les composables `useEmployeeLeave` et `useEmployeeRtt` propagent `phaseId` sur leurs appels API et bornent leur sélecteur d'année interne.
|
||
|
||
**Tech Stack :** Symfony + API Platform + Doctrine (backend), PHPUnit (tests), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), `@malio/layer-ui` (composants UI), `make test` pour la suite PHPUnit, `cd frontend && npm run typecheck` pour la TS.
|
||
|
||
**Spec source :** `docs/superpowers/specs/2026-05-19-contract-phase-view-design.md`.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
**Backend - nouveaux fichiers**
|
||
- `src/Service/Contracts/EmployeeContractPhaseResolver.php` — service de calcul des phases
|
||
- `src/Dto/Contracts/ContractPhase.php` — DTO retourné par le resolver et exposé via API
|
||
- `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` — tests unitaires du resolver
|
||
|
||
**Backend - fichiers modifiés**
|
||
- `src/Entity/Employee.php` — nouveau getter `getContractPhases()` exposé en `employee:read`
|
||
- `src/State/EmployeeLeaveSummaryProvider.php` — accepte `?phaseId`, cape calculs sur la phase
|
||
- `src/State/EmployeeRttSummaryProvider.php` — accepte `?phaseId`, cape exercice sur la phase
|
||
- `src/State/EmployeeRttPaymentProcessor.php` — autorise paiement sur dernier exercice d'une phase passée
|
||
- `tests/State/EmployeeLeaveSummaryProviderTest.php` — cas de phase passée et transition
|
||
- `tests/State/EmployeeRttSummaryProviderTest.php` — nouveau (si manquant)
|
||
- `tests/State/EmployeeRttPaymentProcessorTest.php` — nouveau (si manquant)
|
||
|
||
**Frontend - nouveaux fichiers**
|
||
- `frontend/services/dto/contract-phase.ts` — type TS pour `ContractPhase`
|
||
- `frontend/composables/useEmployeeContractPhase.ts` — composable du picker
|
||
|
||
**Frontend - fichiers modifiés**
|
||
- `frontend/services/dto/employee.ts` — ajout du champ `contractPhases`
|
||
- `frontend/services/employee-leave-summary.ts` — paramètre optionnel `phaseId`
|
||
- `frontend/services/employee-rtt-summary.ts` — paramètre optionnel `phaseId`
|
||
- `frontend/composables/useEmployeeLeave.ts` — propagation `phaseId`, borne `availableLeaveYears` à la phase
|
||
- `frontend/composables/useEmployeeRtt.ts` — idem
|
||
- `frontend/composables/useEmployeeDetailPage.ts` — `showRttTab` driver par la phase sélectionnée
|
||
- `frontend/pages/employees/[id].vue` — picker + bandeau
|
||
- `frontend/components/employees/RttTab.vue` — désactivation conditionnelle de `+ Payer les RTT`
|
||
|
||
**Documentation**
|
||
- `doc/contract-phase-view.md` (nouveau)
|
||
- `doc/leave-tab.md` (mis à jour)
|
||
- `doc/rtt-tab.md` (mis à jour)
|
||
- `frontend/data/documentation-content.ts` (mis à jour)
|
||
- `CLAUDE.md` (mis à jour)
|
||
|
||
---
|
||
|
||
### Task 1: Backend — DTO `ContractPhase` + service `EmployeeContractPhaseResolver`
|
||
|
||
**Files:**
|
||
- Create: `src/Dto/Contracts/ContractPhase.php`
|
||
- Create: `src/Service/Contracts/EmployeeContractPhaseResolver.php`
|
||
- Test: `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php`
|
||
|
||
- [ ] **Step 1.1: Créer le DTO `ContractPhase`**
|
||
|
||
```php
|
||
<?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
|
||
<?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
|
||
<?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 :
|
||
|
||
```php
|
||
public function testThreeConsecutivePeriodsSameSignatureCollapseIntoSinglePhase(): void
|
||
{
|
||
$employee = $this->buildEmployee([
|
||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2021-05-31'],
|
||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-06-01', 'end' => '2022-05-31'],
|
||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2022-06-01', 'end' => null],
|
||
]);
|
||
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
|
||
self::assertCount(1, $phases);
|
||
self::assertCount(3, $phases[0]->periodIds);
|
||
self::assertSame('2020-06-01', $phases[0]->startDate->format('Y-m-d'));
|
||
self::assertNull($phases[0]->endDate);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.7: Run, expect PASS**
|
||
|
||
Run: `make test -- --filter EmployeeContractPhaseResolverTest`
|
||
|
||
- [ ] **Step 1.8: Ajouter test transition 39h → FORFAIT (deux phases)**
|
||
|
||
```php
|
||
public function testSwitchFromH39ToForfaitProducesTwoPhasesMostRecentFirst(): void
|
||
{
|
||
$employee = $this->buildEmployee([
|
||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2026-04-30'],
|
||
['type' => ContractType::FORFAIT, 'hours' => 39, 'driver' => false, 'start' => '2026-05-01', 'end' => null],
|
||
]);
|
||
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
|
||
self::assertCount(2, $phases);
|
||
self::assertSame(ContractType::FORFAIT, $phases[0]->contractType);
|
||
self::assertTrue($phases[0]->isCurrent);
|
||
self::assertSame(ContractType::H39, $phases[1]->contractType);
|
||
self::assertFalse($phases[1]->isCurrent);
|
||
self::assertSame('2026-04-30', $phases[1]->endDate?->format('Y-m-d'));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.9: Run, expect PASS**
|
||
|
||
- [ ] **Step 1.10: Ajouter test break par INTERIM (pas de fusion)**
|
||
|
||
```php
|
||
public function testInterimBetweenTwoH39PeriodsBreaksThePhases(): void
|
||
{
|
||
$employee = $this->buildEmployee([
|
||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2023-12-31'],
|
||
['type' => ContractType::INTERIM, 'hours' => null, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-04-30'],
|
||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2024-05-01', 'end' => null],
|
||
]);
|
||
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
|
||
self::assertCount(3, $phases);
|
||
self::assertSame(ContractType::H39, $phases[0]->contractType);
|
||
self::assertSame(ContractType::INTERIM, $phases[1]->contractType);
|
||
self::assertSame(ContractType::H39, $phases[2]->contractType);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.11: Run, expect PASS**
|
||
|
||
- [ ] **Step 1.12: Ajouter tests `weeklyHours` et `isDriver` qui cassent la signature**
|
||
|
||
```php
|
||
public function testCustomPhasesSplitOnWeeklyHoursChange(): void
|
||
{
|
||
$employee = $this->buildEmployee([
|
||
['type' => ContractType::CUSTOM, 'hours' => 28, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-12-31'],
|
||
['type' => ContractType::CUSTOM, 'hours' => 30, 'driver' => false, 'start' => '2025-01-01', 'end' => null],
|
||
]);
|
||
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
|
||
self::assertCount(2, $phases);
|
||
self::assertSame(30, $phases[0]->weeklyHours);
|
||
self::assertSame(28, $phases[1]->weeklyHours);
|
||
}
|
||
|
||
public function testPhasesSplitOnIsDriverChange(): void
|
||
{
|
||
$employee = $this->buildEmployee([
|
||
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2023-01-01', 'end' => '2024-12-31'],
|
||
['type' => ContractType::H35, 'hours' => 35, 'driver' => true, 'start' => '2025-01-01', 'end' => null],
|
||
]);
|
||
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
|
||
self::assertCount(2, $phases);
|
||
self::assertTrue($phases[0]->isDriver);
|
||
self::assertFalse($phases[1]->isDriver);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.13: Run, expect PASS pour tous les tests du resolver**
|
||
|
||
- [ ] **Step 1.14: Commit**
|
||
|
||
```bash
|
||
git add src/Dto/Contracts/ContractPhase.php src/Service/Contracts/EmployeeContractPhaseResolver.php tests/Service/Contracts/EmployeeContractPhaseResolverTest.php
|
||
git commit -m "feat(contracts) : add EmployeeContractPhaseResolver service"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Backend — exposer `contractPhases` sur l'API Employee
|
||
|
||
**Files:**
|
||
- Modify: `src/Entity/Employee.php` (ajout d'un getter virtuel)
|
||
- Modify: `tests/State/EmployeeWriteProcessorTest.php` (vérifier que la sérialisation reste valide)
|
||
|
||
- [ ] **Step 2.1: Ajouter le getter `getContractPhases` dans `Employee.php`**
|
||
|
||
À ajouter juste après `getContractHistory()` (autour de la ligne 430) :
|
||
|
||
```php
|
||
/**
|
||
* @return list<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` :
|
||
```php
|
||
use App\Dto\Contracts\ContractPhase;
|
||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||
```
|
||
|
||
Et simplifier le getter en remplaçant les FQCN par les imports.
|
||
|
||
- [ ] **Step 2.3: Vérifier que la suite PHPUnit passe encore**
|
||
|
||
Run: `make test`
|
||
Expected: tous verts.
|
||
|
||
- [ ] **Step 2.4: Vérifier le rendu de l'API (sanity check manuel)**
|
||
|
||
```bash
|
||
docker exec -t -u www-data php-sirh-fpm php bin/console debug:container --tag=api_platform.metadata.resource.metadata_collection_factory | head
|
||
```
|
||
|
||
(juste pour s'assurer qu'il n'y a pas de cache stale — pas d'attendu strict)
|
||
|
||
- [ ] **Step 2.5: Commit**
|
||
|
||
```bash
|
||
git add src/Entity/Employee.php
|
||
git commit -m "feat(employee) : expose contractPhases on read API"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Backend — `EmployeeLeaveSummaryProvider` accepte `?phaseId`
|
||
|
||
**Files:**
|
||
- Modify: `src/State/EmployeeLeaveSummaryProvider.php`
|
||
- Test: `tests/State/EmployeeLeaveSummaryProviderTest.php`
|
||
|
||
- [ ] **Step 3.1: Ajouter une dépendance vers le resolver**
|
||
|
||
Dans le constructeur de `EmployeeLeaveSummaryProvider`, injecter :
|
||
```php
|
||
private EmployeeContractPhaseResolver $phaseResolver,
|
||
```
|
||
|
||
et `use App\Service\Contracts\EmployeeContractPhaseResolver;` en haut.
|
||
|
||
- [ ] **Step 3.2: Écrire un test : phase passée 39h, le picker `?phaseId=N` retourne du `CDI_CDD_NON_FORFAIT`**
|
||
|
||
Dans `tests/State/EmployeeLeaveSummaryProviderTest.php`, après les tests existants, ajouter un test functional ou unit. Si l'existant utilise un harness simple (mock RequestStack), s'en inspirer. Sinon créer une fonction `withPhaseQuery(int $phaseId)` qui mock le RequestStack pour renvoyer `?phaseId=N`.
|
||
|
||
Test cible :
|
||
```php
|
||
public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void
|
||
{
|
||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); // 39h → FORFAIT
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
$h39Phase = $phases[1]; // plus ancienne = 39h
|
||
|
||
$provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id]);
|
||
|
||
$summary = $provider->provide(/* Operation */ $this->operation(), ['id' => 1]);
|
||
|
||
self::assertSame('CDI_CDD_NON_FORFAIT', $summary->ruleCode);
|
||
}
|
||
```
|
||
|
||
Adapter aux helpers de la classe de test existante. Si la classe n'a pas encore de helper builder, l'ajouter en `private function`.
|
||
|
||
- [ ] **Step 3.3: Run, expect FAIL (le provider ignore encore `phaseId`)**
|
||
|
||
Run: `make test -- --filter EmployeeLeaveSummaryProviderTest`
|
||
|
||
- [ ] **Step 3.4: Implémenter la résolution de la phase cible**
|
||
|
||
Dans `EmployeeLeaveSummaryProvider.php` :
|
||
|
||
a. Ajouter une méthode privée :
|
||
```php
|
||
private function resolveTargetPhase(Employee $employee): ContractPhase
|
||
{
|
||
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||
$phases = $this->phaseResolver->resolvePhases($employee);
|
||
if ([] === $phases) {
|
||
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||
}
|
||
|
||
if (null === $raw || '' === $raw) {
|
||
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||
foreach ($phases as $phase) {
|
||
if ($phase->isCurrent) {
|
||
return $phase;
|
||
}
|
||
}
|
||
return $phases[0];
|
||
}
|
||
|
||
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||
}
|
||
$phaseId = (int) $raw;
|
||
foreach ($phases as $phase) {
|
||
if ($phase->id === $phaseId) {
|
||
return $phase;
|
||
}
|
||
}
|
||
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||
}
|
||
```
|
||
|
||
b. Au début de `provide()`, après la résolution de l'employé, appeler `$phase = $this->resolveTargetPhase($employee);`.
|
||
|
||
c. Refactoriser pour propager `$phase` aux méthodes internes :
|
||
- `resolveYear(Employee $e)` → `resolveYear(Employee $e, ContractPhase $phase)` (signature change). Si `phaseId` fourni et pas de `year`, default = année calculée à partir de `$phase->endDate ?? today` selon que la phase est FORFAIT ou non.
|
||
- `resolveLeavePolicy(Employee $e, $from, $to)` → `resolveLeavePolicy(Employee $e, ContractPhase $phase, $from, $to)`. Utiliser `$phase->contractType` au lieu de `$employee->getContract()?->getType()` et `$phase->weeklyHours` au lieu de `$employee->getContract()?->getWeeklyHours()`.
|
||
- `resolvePeriodBounds(Employee $e, int $year)` → `resolvePeriodBounds(Employee $e, ContractPhase $phase, int $year)`. Caper avec :
|
||
```php
|
||
if ($phase->startDate > $from) { $from = $phase->startDate; }
|
||
if (null !== $phase->endDate && $phase->endDate < $to) { $to = $phase->endDate; }
|
||
```
|
||
- `resolveForfaitYearBounds` et `resolveLeavePeriodBounds` (internes) : recevoir la phase et appliquer le cap.
|
||
- `resolveAccrualCalculationEndDate(...)` et `resolveTakenCalculationEndDate(...)` : caper additionnellement sur `$phase->endDate` quand `!$phase->isCurrent`.
|
||
- `resolveFirstComputationYear(Employee $e)` → `resolveFirstComputationYear(Employee $e, ContractPhase $phase)` : ne pas remonter avant l'exercice contenant `$phase->startDate`.
|
||
|
||
Note : on remplace systématiquement `$employee->getContract()?->getType()` et `$employee->getContract()?->getWeeklyHours()` par les valeurs de la phase. La sémantique de `getContract()` ne change pas (elle reste le contrat courant) ; on l'évite simplement dans ce provider quand `phaseId` est explicite.
|
||
|
||
d. Côté `resolveYear` quand un `?year` explicite est hors phase : clamp silencieux dans la plage [premier exercice de la phase, dernier exercice de la phase]. Implementer une fonction helper `clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int`.
|
||
|
||
- [ ] **Step 3.5: Run, expect PASS du nouveau test**
|
||
|
||
Run: `make test -- --filter EmployeeLeaveSummaryProviderTest`
|
||
|
||
- [ ] **Step 3.6: Ajouter test transition d'exercice (cap aux bornes de la phase)**
|
||
|
||
```php
|
||
public function testTransitionExerciseOnH39PhaseIsCappedAtPhaseEndDate(): void
|
||
{
|
||
// 39h jusqu'au 2026-04-30, FORFAIT à partir du 2026-05-01.
|
||
// L'exercice de congé Juin 2025 → Mai 2026 sur la phase 39h doit être borné à 2026-04-30.
|
||
// Soldes acquis attendus = 11 mois × 2.0833 ≈ 22.92 jours (+5 samedis prorata).
|
||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
$h39Phase = $phases[1];
|
||
|
||
$provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||
|
||
$summary = $provider->provide($this->operation(), ['id' => 1]);
|
||
|
||
self::assertSame('CDI_CDD_NON_FORFAIT', $summary->ruleCode);
|
||
// L'exercice est capé à 11 mois plein, donc l'acquis devrait être ≈ 22.9j.
|
||
self::assertEqualsWithDelta(22.92, $summary->acquiredDays, 0.5);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.7: Run, expect PASS**
|
||
|
||
- [ ] **Step 3.8: Ajouter test clamp silencieux de `?year` hors phase**
|
||
|
||
```php
|
||
public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void
|
||
{
|
||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
$h39Phase = $phases[1];
|
||
|
||
// 2030 est hors phase (la phase 39h se termine en 2026) → clamp à 2026.
|
||
$provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id, 'year' => '2030']);
|
||
|
||
$summary = $provider->provide($this->operation(), ['id' => 1]);
|
||
|
||
self::assertSame(2026, $summary->year);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.9: Run, expect PASS**
|
||
|
||
- [ ] **Step 3.10: Tester que `phaseId` invalide → 422**
|
||
|
||
```php
|
||
public function testInvalidPhaseIdReturns422(): void
|
||
{
|
||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||
$provider = $this->buildProvider(request: ['phaseId' => '99999']);
|
||
|
||
$this->expectException(UnprocessableEntityHttpException::class);
|
||
$provider->provide($this->operation(), ['id' => 1]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3.11: Run, expect PASS**
|
||
|
||
- [ ] **Step 3.12: Tester que comportement par défaut (pas de `phaseId`) est inchangé**
|
||
|
||
Un test existant sans `phaseId` doit toujours passer. Lancer toute la suite :
|
||
|
||
Run: `make test`
|
||
Expected: tous verts.
|
||
|
||
- [ ] **Step 3.13: Commit**
|
||
|
||
```bash
|
||
git add src/State/EmployeeLeaveSummaryProvider.php tests/State/EmployeeLeaveSummaryProviderTest.php
|
||
git commit -m "feat(leave) : phaseId support in EmployeeLeaveSummaryProvider"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Backend — `EmployeeRttSummaryProvider` accepte `?phaseId`
|
||
|
||
**Files:**
|
||
- Modify: `src/State/EmployeeRttSummaryProvider.php`
|
||
- Test: `tests/State/EmployeeRttSummaryProviderTest.php` (créer si manquant)
|
||
|
||
- [ ] **Step 4.1: Vérifier l'existence du test file**
|
||
|
||
```bash
|
||
ls tests/State/EmployeeRttSummaryProviderTest.php 2>/dev/null || echo "absent"
|
||
```
|
||
|
||
Si absent, créer le squelette avec `setUp` standard sur le modèle de `EmployeeLeaveSummaryProviderTest`.
|
||
|
||
- [ ] **Step 4.2: Injecter le `EmployeeContractPhaseResolver` dans le constructeur**
|
||
|
||
```php
|
||
private EmployeeContractPhaseResolver $phaseResolver,
|
||
```
|
||
|
||
et `use App\Service\Contracts\EmployeeContractPhaseResolver;`.
|
||
|
||
- [ ] **Step 4.3: Écrire un test "exercice de fin de phase 39h passée — données renvoyées avec cap"**
|
||
|
||
```php
|
||
public function testPastH39PhaseRttSummaryIsCappedAtPhaseEndDate(): void
|
||
{
|
||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||
$phases = (new EmployeeContractPhaseResolver())->resolvePhases($employee);
|
||
$h39Phase = $phases[1];
|
||
|
||
$provider = $this->buildProvider(request: ['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||
|
||
$summary = $provider->provide($this->operation(), ['id' => 1]);
|
||
|
||
self::assertSame(2026, $summary->year);
|
||
// Les semaines de mai 2026 ne doivent pas apparaître (phase clôturée au 30/04).
|
||
foreach ($summary->weeks as $week) {
|
||
self::assertLessThanOrEqual('2026-04-30', $week->weekEnd);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.4: Run, expect FAIL**
|
||
|
||
- [ ] **Step 4.5: Implémenter le support `phaseId`**
|
||
|
||
a. Ajouter `resolveTargetPhase(Employee $e): ContractPhase` (similaire à `EmployeeLeaveSummaryProvider`).
|
||
|
||
b. Dans `provide()`, appeler `$phase = $this->resolveTargetPhase($employee);`.
|
||
|
||
c. Modifier `resolveYear()` pour accepter la phase : si `phaseId` fourni et pas de `year`, default = année de l'exercice contenant `$phase->endDate ?? today`. Si `year` fourni hors phase, clamp silencieux.
|
||
|
||
d. Après `[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);`, ajouter :
|
||
```php
|
||
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) {
|
||
$periodTo = $phase->endDate;
|
||
}
|
||
if ($phase->startDate > $periodFrom) {
|
||
$periodFrom = $phase->startDate;
|
||
}
|
||
```
|
||
|
||
e. Adapter la limite des semaines (`$limitDate`) : si `$phase->endDate` est passé et < `$limitDate`, alors `$limitDate = $phase->endDate`.
|
||
|
||
- [ ] **Step 4.6: Run, expect PASS**
|
||
|
||
- [ ] **Step 4.7: Test `?year` clamp silencieux pour RTT**
|
||
|
||
Test analogue au step 3.8 mais sur le summary RTT.
|
||
|
||
- [ ] **Step 4.8: Test `phaseId` invalide → 422**
|
||
|
||
Test analogue au step 3.10.
|
||
|
||
- [ ] **Step 4.9: Test comportement par défaut inchangé**
|
||
|
||
Run: `make test`
|
||
|
||
- [ ] **Step 4.10: Commit**
|
||
|
||
```bash
|
||
git add src/State/EmployeeRttSummaryProvider.php tests/State/EmployeeRttSummaryProviderTest.php
|
||
git commit -m "feat(rtt) : phaseId support in EmployeeRttSummaryProvider"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Backend — `EmployeeRttPaymentProcessor` autorise paiement sur dernier exo de phase clôturée
|
||
|
||
**Files:**
|
||
- Modify: `src/State/EmployeeRttPaymentProcessor.php`
|
||
- Test: `tests/State/EmployeeRttPaymentProcessorTest.php` (créer si manquant)
|
||
|
||
- [ ] **Step 5.1: Lire la garde actuelle**
|
||
|
||
```bash
|
||
cat src/State/EmployeeRttPaymentProcessor.php
|
||
```
|
||
|
||
Identifier la ligne où la création est rejetée si l'exercice n'est pas l'exercice courant. (Probablement un check `if ($year !== $currentExerciseYear)`.)
|
||
|
||
- [ ] **Step 5.2: Écrire le test "paiement autorisé sur exercice de fin de phase clôturée"**
|
||
|
||
```php
|
||
public function testPaymentAllowedOnLastExerciseOfClosedPhase(): void
|
||
{
|
||
// Phase 39h clôturée le 2026-04-30, FORFAIT à partir du 2026-05-01.
|
||
// L'exercice 2026 (Juin 2025 → Mai 2026) contient la date de fin de la phase 39h.
|
||
// Le paiement RTT doit être accepté sur l'exercice 2026 même si l'exercice courant est plus récent.
|
||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||
$today = new DateTimeImmutable('2027-01-15'); // exercice courant = 2027
|
||
$processor = $this->buildProcessorWithClock($today);
|
||
|
||
$payment = new EmployeeRttPaymentInput();
|
||
$payment->employeeId = $employee->getId();
|
||
$payment->year = 2026;
|
||
$payment->month = 4;
|
||
$payment->base25Minutes = 60;
|
||
|
||
$result = $processor->process($payment, $this->postOperation(), []);
|
||
|
||
self::assertNotNull($result->getId());
|
||
}
|
||
```
|
||
|
||
Si la classe de test ou les helpers n'existent pas, créer un harness minimal sur le modèle de `EmployeeLeaveSummaryProviderTest`.
|
||
|
||
- [ ] **Step 5.3: Run, expect FAIL**
|
||
|
||
- [ ] **Step 5.4: Modifier le processor**
|
||
|
||
Remplacer la garde "exercice courant uniquement" par :
|
||
```php
|
||
// Autoriser si :
|
||
// - exercice courant, OU
|
||
// - exercice contenant l'endDate d'une phase clôturée de l'employé
|
||
$phases = $this->phaseResolver->resolvePhases($employee);
|
||
$isLastExerciseOfClosedPhase = false;
|
||
foreach ($phases as $phase) {
|
||
if ($phase->isCurrent || null === $phase->endDate) {
|
||
continue;
|
||
}
|
||
$exerciseYearForPhaseEnd = $this->resolveExerciseYearForDate($phase->endDate);
|
||
if ($year === $exerciseYearForPhaseEnd) {
|
||
$isLastExerciseOfClosedPhase = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ($year !== $currentExerciseYear && !$isLastExerciseOfClosedPhase) {
|
||
throw new UnprocessableEntityHttpException('RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.');
|
||
}
|
||
```
|
||
|
||
Injecter `EmployeeContractPhaseResolver` dans le constructeur.
|
||
|
||
Note : `resolveExerciseYearForDate` = `(int) $date->format('n') >= 6 ? (int) $date->format('Y') + 1 : (int) $date->format('Y');`. À factoriser dans un helper si elle apparaît plusieurs fois.
|
||
|
||
- [ ] **Step 5.5: Run, expect PASS**
|
||
|
||
- [ ] **Step 5.6: Test "paiement refusé sur exercice antérieur d'une phase clôturée"**
|
||
|
||
```php
|
||
public function testPaymentRejectedOnEarlierExerciseOfClosedPhase(): void
|
||
{
|
||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
|
||
|
||
$payment = new EmployeeRttPaymentInput();
|
||
$payment->employeeId = $employee->getId();
|
||
$payment->year = 2024; // exercice antérieur dans la phase 39h
|
||
$payment->month = 4;
|
||
$payment->base25Minutes = 60;
|
||
|
||
$this->expectException(UnprocessableEntityHttpException::class);
|
||
$processor->process($payment, $this->postOperation(), []);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5.7: Run, expect PASS**
|
||
|
||
- [ ] **Step 5.8: Vérifier que le test "exercice courant accepté" passe toujours**
|
||
|
||
(L'existant ou un nouveau test confirmant le comportement par défaut.)
|
||
|
||
Run: `make test`
|
||
|
||
- [ ] **Step 5.9: Commit**
|
||
|
||
```bash
|
||
git add src/State/EmployeeRttPaymentProcessor.php tests/State/EmployeeRttPaymentProcessorTest.php
|
||
git commit -m "feat(rtt) : allow payment on closed phase last exercise"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Frontend — TS DTO `ContractPhase` + ajout sur `Employee`
|
||
|
||
**Files:**
|
||
- Create: `frontend/services/dto/contract-phase.ts`
|
||
- Modify: `frontend/services/dto/employee.ts`
|
||
|
||
- [ ] **Step 6.1: Créer le type TS**
|
||
|
||
`frontend/services/dto/contract-phase.ts` :
|
||
```typescript
|
||
import type { ContractType } from './contract'
|
||
|
||
export type ContractPhase = {
|
||
id: number
|
||
contractType: ContractType
|
||
weeklyHours: number | null
|
||
isDriver: boolean
|
||
startDate: string
|
||
endDate: string | null
|
||
periodIds: number[]
|
||
isCurrent: boolean
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6.2: Ajouter `contractPhases` sur `Employee`**
|
||
|
||
Dans `frontend/services/dto/employee.ts`, ajouter le champ :
|
||
```typescript
|
||
contractPhases?: ContractPhase[]
|
||
```
|
||
|
||
Import en haut :
|
||
```typescript
|
||
import type { ContractPhase } from './contract-phase'
|
||
```
|
||
|
||
- [ ] **Step 6.3: Vérifier la compilation TS**
|
||
|
||
Run: `cd frontend && npx vue-tsc --noEmit`
|
||
Expected: aucune erreur.
|
||
|
||
- [ ] **Step 6.4: Commit**
|
||
|
||
```bash
|
||
git add frontend/services/dto/contract-phase.ts frontend/services/dto/employee.ts
|
||
git commit -m "feat(employee) : add contractPhases TS DTO"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Frontend — composable `useEmployeeContractPhase`
|
||
|
||
**Files:**
|
||
- Create: `frontend/composables/useEmployeeContractPhase.ts`
|
||
|
||
- [ ] **Step 7.1: Implémenter le composable**
|
||
|
||
`frontend/composables/useEmployeeContractPhase.ts` :
|
||
```typescript
|
||
import type { Ref } from 'vue'
|
||
import type { Employee } from '~/services/dto/employee'
|
||
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||
|
||
const formatDateFr = (iso: string | null): string => {
|
||
if (!iso) return ''
|
||
const [y, m, d] = iso.split('-')
|
||
return `${d}/${m}/${y}`
|
||
}
|
||
|
||
const formatContractTypeLabel = (phase: ContractPhase): string => {
|
||
switch (phase.contractType) {
|
||
case CONTRACT_TYPES.FORFAIT:
|
||
return 'FORFAIT'
|
||
case CONTRACT_TYPES.H35:
|
||
return '35h'
|
||
case CONTRACT_TYPES.H39:
|
||
return '39h'
|
||
case CONTRACT_TYPES.INTERIM:
|
||
return 'Intérim'
|
||
case CONTRACT_TYPES.CUSTOM:
|
||
return `CUSTOM (${phase.weeklyHours ?? '?'}h)`
|
||
default:
|
||
return String(phase.contractType)
|
||
}
|
||
}
|
||
|
||
export const formatPhaseLabel = (phase: ContractPhase): string => {
|
||
const base = formatContractTypeLabel(phase)
|
||
const driver = phase.isDriver ? ' (driver)' : ''
|
||
const dates = phase.endDate
|
||
? `${formatDateFr(phase.startDate)} → ${formatDateFr(phase.endDate)}`
|
||
: `depuis ${formatDateFr(phase.startDate)}`
|
||
const suffix = phase.isCurrent ? ' (actuel)' : ''
|
||
return `${base}${driver} — ${dates}${suffix}`
|
||
}
|
||
|
||
export const useEmployeeContractPhase = (employee: Ref<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**
|
||
|
||
```bash
|
||
git add frontend/composables/useEmployeeContractPhase.ts
|
||
git commit -m "feat(employee) : add useEmployeeContractPhase composable"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Frontend — propagation `phaseId` dans les services API
|
||
|
||
**Files:**
|
||
- Modify: `frontend/services/employee-leave-summary.ts`
|
||
- Modify: `frontend/services/employee-rtt-summary.ts`
|
||
|
||
- [ ] **Step 8.1: Trouver la signature actuelle**
|
||
|
||
```bash
|
||
grep -n "getEmployeeLeaveSummary\|getEmployeeRttSummary" frontend/services/employee-leave-summary.ts frontend/services/employee-rtt-summary.ts
|
||
```
|
||
|
||
- [ ] **Step 8.2: Ajouter le paramètre `phaseId` optionnel**
|
||
|
||
Pour `getEmployeeLeaveSummary` :
|
||
```typescript
|
||
export const getEmployeeLeaveSummary = async (
|
||
employeeId: number,
|
||
year?: number,
|
||
phaseId?: number,
|
||
): Promise<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**
|
||
|
||
```bash
|
||
git add frontend/services/employee-leave-summary.ts frontend/services/employee-rtt-summary.ts
|
||
git commit -m "feat(api) : phaseId query parameter on leave/rtt endpoints"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Frontend — `useEmployeeLeave` propage `phaseId` et borne le sélecteur d'année
|
||
|
||
**Files:**
|
||
- Modify: `frontend/composables/useEmployeeLeave.ts`
|
||
|
||
- [ ] **Step 9.1: Accepter une référence à la phase sélectionnée**
|
||
|
||
Modifier la signature :
|
||
```typescript
|
||
export const useEmployeeLeave = (
|
||
employee: Ref<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"**
|
||
|
||
```typescript
|
||
const isForfaitOnPhase = computed(() =>
|
||
selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT
|
||
)
|
||
```
|
||
|
||
Et adapter toutes les utilisations (`isForfaitContract(employee.value)` → `isForfaitOnPhase.value`).
|
||
|
||
- [ ] **Step 9.3: Borner `availableLeaveYears` à la phase**
|
||
|
||
```typescript
|
||
const availableLeaveYears = computed<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`**
|
||
|
||
```typescript
|
||
const summary = await getEmployeeLeaveSummary(
|
||
employee.value.id,
|
||
leaveYear,
|
||
selectedPhase.value?.id,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 9.5: Reagir au changement de phase**
|
||
|
||
Ajouter un watcher :
|
||
```typescript
|
||
watch(() => selectedPhase.value?.id, () => {
|
||
// Reset l'année car la plage a peut-être changé.
|
||
selectedLeaveYear.value = null
|
||
leaveDataLoaded.value = false
|
||
// Le rechargement effectif est piloté par useEmployeeDetailPage.
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 9.6: Vérifier la compilation TS**
|
||
|
||
- [ ] **Step 9.7: Commit**
|
||
|
||
```bash
|
||
git add frontend/composables/useEmployeeLeave.ts
|
||
git commit -m "feat(leave) : phase-aware leave tab loading"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Frontend — `useEmployeeRtt` propage `phaseId` et borne l'exercice
|
||
|
||
**Files:**
|
||
- Modify: `frontend/composables/useEmployeeRtt.ts`
|
||
|
||
- [ ] **Step 10.1: Accepter `selectedPhase` en paramètre**
|
||
|
||
Comme Task 9.1.
|
||
|
||
- [ ] **Step 10.2: Borner `availableRttYears` aux exercices intersectant la phase**
|
||
|
||
Analogue à Task 9.3 mais pour le format Juin→Mai uniquement (toujours non-forfait pour la phase RTT-visible).
|
||
|
||
- [ ] **Step 10.3: Propager `phaseId` dans `loadRttData`**
|
||
|
||
```typescript
|
||
const summary = await getEmployeeRttSummary(
|
||
employee.value.id,
|
||
selectedRttYear.value,
|
||
selectedPhase.value?.id,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 10.4: Reagir au changement de phase**
|
||
|
||
Watcher analogue à Task 9.5.
|
||
|
||
- [ ] **Step 10.5: Vérifier la compilation TS**
|
||
|
||
- [ ] **Step 10.6: Commit**
|
||
|
||
```bash
|
||
git add frontend/composables/useEmployeeRtt.ts
|
||
git commit -m "feat(rtt) : phase-aware RTT tab loading"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Frontend — `useEmployeeDetailPage` orchestre le picker
|
||
|
||
**Files:**
|
||
- Modify: `frontend/composables/useEmployeeDetailPage.ts`
|
||
|
||
- [ ] **Step 11.1: Importer et instancier le composable phase**
|
||
|
||
```typescript
|
||
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
||
```
|
||
|
||
Dans `useEmployeeDetailPage`, après la création de `employee`, instancier la phase :
|
||
```typescript
|
||
const phase = useEmployeeContractPhase(employee)
|
||
```
|
||
|
||
- [ ] **Step 11.2: Adapter `showRttTab` pour driver par la phase**
|
||
|
||
```typescript
|
||
const showRttTab = computed(() => phase.selectedPhase.value?.contractType !== CONTRACT_TYPES.FORFAIT)
|
||
```
|
||
|
||
Idem pour `isForfait` :
|
||
```typescript
|
||
const isForfait = computed(() => phase.selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT)
|
||
```
|
||
|
||
- [ ] **Step 11.3: Passer `phase.selectedPhase` aux composables enfants**
|
||
|
||
Modifier les appels :
|
||
```typescript
|
||
const leave = useEmployeeLeave(employee, loadEmployee, phase.selectedPhase)
|
||
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
||
```
|
||
|
||
- [ ] **Step 11.4: Reset la phase au changement d'employé**
|
||
|
||
Dans `loadEmployee`, après `employee.value = await getEmployee(...)`, ajouter :
|
||
```typescript
|
||
phase.resetToCurrent()
|
||
```
|
||
|
||
- [ ] **Step 11.5: Recharger l'onglet actif quand la phase change**
|
||
|
||
Ajouter un watcher :
|
||
```typescript
|
||
watch(() => phase.selectedPhase.value?.id, (newId, oldId) => {
|
||
if (newId === oldId || oldId === undefined) return
|
||
// Bascule onglet si on entre dans une phase qui ne supporte plus le tab actuel
|
||
if (!showRttTab.value && activeTab.value === 'rtt') {
|
||
activeTab.value = 'leave'
|
||
}
|
||
// Recharger l'onglet courant
|
||
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||
leave.loadLeaveData()
|
||
} else if (activeTab.value === 'rtt' && showRttTab.value) {
|
||
rtt.loadRttData()
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 11.6: Exposer `phase` dans le return**
|
||
|
||
```typescript
|
||
return {
|
||
...
|
||
...phase,
|
||
...
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11.7: Vérifier la compilation TS**
|
||
|
||
- [ ] **Step 11.8: Commit**
|
||
|
||
```bash
|
||
git add frontend/composables/useEmployeeDetailPage.ts
|
||
git commit -m "feat(employee) : wire contract phase into detail page composable"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Frontend — picker + bandeau sur `pages/employees/[id].vue`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/employees/[id].vue`
|
||
|
||
- [ ] **Step 12.1: Lire la structure de la page**
|
||
|
||
```bash
|
||
cat frontend/pages/employees/[id].vue | head -120
|
||
```
|
||
|
||
Repérer où sont rendus le nom de l'employé et la barre d'onglets.
|
||
|
||
- [ ] **Step 12.2: Ajouter le picker dans le template**
|
||
|
||
Entre le bloc nom/avatar et la barre d'onglets, insérer :
|
||
```vue
|
||
<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` :
|
||
```typescript
|
||
import { formatPhaseLabel } from '~/composables/useEmployeeContractPhase'
|
||
```
|
||
|
||
- [ ] **Step 12.4: Tester en local**
|
||
|
||
Démarrer le stack :
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 :
|
||
```typescript
|
||
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 :
|
||
```vue
|
||
<RttTab :selected-phase="selectedPhase" ... />
|
||
```
|
||
|
||
- [ ] **Step 13.5: Vérifier la compilation TS**
|
||
|
||
- [ ] **Step 13.6: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```markdown
|
||
# 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**
|
||
|
||
```bash
|
||
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 :
|
||
|
||
```markdown
|
||
## 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 :
|
||
|
||
```markdown
|
||
## 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 :
|
||
```typescript
|
||
{
|
||
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**
|
||
|
||
```bash
|
||
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é)"**
|
||
|
||
```markdown
|
||
## 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**
|
||
|
||
```bash
|
||
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.
|