# Contract Suspension Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow suspending employee contracts, with multiple suspensions per contract period. Suspensions reduce accruing leave days (CDI/CDD non forfait) and acquired days (forfait 218) prorata. **Architecture:** New `ContractSuspension` entity (OneToMany from `EmployeeContractPeriod`). Dedicated API endpoint for CRUD. Leave calculation modified in both `EmployeeLeaveSummaryProvider` (live display) and `LeaveBalanceComputationService` (rollover). Frontend drawer gets tabbed UI with Clôturer/Suspendre. **Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript (frontend) **Spec:** `docs/superpowers/specs/2026-03-12-contract-suspension-design.md` **No commits, no pushes — user handles git.** --- ## File Structure | File | Action | Responsibility | |------|--------|----------------| | `src/Entity/ContractSuspension.php` | Create | New entity with startDate, endDate, comment | | `src/Repository/ContractSuspensionRepository.php` | Create | Repository for ContractSuspension | | `src/Entity/EmployeeContractPeriod.php` | Modify | Add OneToMany relation to suspensions | | `migrations/Version20260312140000.php` | Create | Create contract_suspensions table | | `src/State/ContractSuspensionWriteProcessor.php` | Create | Custom processor: validation + persistence | | `tests/State/ContractSuspensionWriteProcessorTest.php` | Create | Tests for suspension processor | | `src/Dto/Employees/ContractHistoryItem.php` | Modify | Add periodId + suspensions array | | `src/Entity/Employee.php` | Modify | Add getContractPeriods(), getCurrentSuspensions() | | `src/Service/Leave/SuspensionDaysCalculator.php` | Create | Extract suspension day counting logic (shared) | | `tests/Service/Leave/SuspensionDaysCalculatorTest.php` | Create | Tests for suspension calculator | | `src/State/EmployeeLeaveSummaryProvider.php` | Modify | Use SuspensionDaysCalculator in accrual + forfait | | `src/Service/Leave/LeaveBalanceComputationService.php` | Modify | Use SuspensionDaysCalculator in accrual + forfait | | `tests/State/EmployeeLeaveSummaryProviderTest.php` | Modify | Test suspension impact on leave | | `frontend/services/dto/employee.ts` | Modify | Add ContractSuspension type and fields | | `frontend/services/contractSuspensions.ts` | Create | API service for suspension CRUD | | `frontend/components/employees/ContractTab.vue` | Modify | Tabbed drawer (Clôturer/Suspendre) | | `frontend/composables/useEmployeeDetailPage.ts` | Modify | Suspension form state + submit logic | --- ## Chunk 1: Entity, Migration, Repository ### Task 1: ContractSuspension Entity **Files:** - Create: `src/Entity/ContractSuspension.php` - Create: `src/Repository/ContractSuspensionRepository.php` - [ ] **Step 1: Create the repository** ```php */ class ContractSuspensionRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, ContractSuspension::class); } } ``` - [ ] **Step 2: Create the entity** ```php ['suspension:read']], denormalizationContext: ['groups' => ['suspension:write']], paginationEnabled: false, security: "is_granted('ROLE_ADMIN')", )] #[ORM\Entity(repositoryClass: ContractSuspensionRepository::class)] #[ORM\Table(name: 'contract_suspensions')] #[ORM\Index(columns: ['contract_period_id', 'start_date'], name: 'idx_suspension_period_start')] class ContractSuspension { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] #[Groups(['suspension:read', 'employee:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: EmployeeContractPeriod::class, inversedBy: 'suspensions')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[Groups(['suspension:write'])] private ?EmployeeContractPeriod $contractPeriod = null; #[ORM\Column(type: 'date_immutable')] #[Groups(['suspension:read', 'suspension:write', 'employee:read'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] private DateTimeImmutable $startDate; #[ORM\Column(type: 'date_immutable', nullable: true)] #[Groups(['suspension:read', 'suspension:write', 'employee:read'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] private ?DateTimeImmutable $endDate = null; #[ORM\Column(type: 'text', nullable: true)] #[Groups(['suspension:read', 'suspension:write', 'employee:read'])] private ?string $comment = null; #[ORM\Column(type: 'datetime_immutable')] private DateTimeImmutable $createdAt; public function __construct() { $this->createdAt = new DateTimeImmutable(); $this->startDate = new DateTimeImmutable('today'); } public function getId(): ?int { return $this->id; } public function getContractPeriod(): ?EmployeeContractPeriod { return $this->contractPeriod; } public function setContractPeriod(?EmployeeContractPeriod $contractPeriod): self { $this->contractPeriod = $contractPeriod; return $this; } public function getStartDate(): DateTimeImmutable { return $this->startDate; } public function setStartDate(DateTimeImmutable $startDate): self { $this->startDate = $startDate; return $this; } public function getEndDate(): ?DateTimeImmutable { return $this->endDate; } public function setEndDate(?DateTimeImmutable $endDate): self { $this->endDate = $endDate; return $this; } public function getComment(): ?string { return $this->comment; } public function setComment(?string $comment): self { $this->comment = $comment; return $this; } public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } } ``` - [ ] **Step 3: Verify entity is syntactically correct** Run: `docker exec php-sirh-fpm php -l src/Entity/ContractSuspension.php` Expected: No syntax errors --- ### Task 2: Add OneToMany relation on EmployeeContractPeriod **Files:** - Modify: `src/Entity/EmployeeContractPeriod.php` - [ ] **Step 1: Add the suspensions collection** Add import at top of file (after existing `use` statements): ```php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; ``` Add property after `$comment` (after line 44): ```php /** * @var Collection */ #[ORM\OneToMany(mappedBy: 'contractPeriod', targetEntity: ContractSuspension::class, cascade: ['persist', 'remove'])] private Collection $suspensions; ``` In the constructor (line 49-53), add after `$this->startDate = ...`: ```php $this->suspensions = new ArrayCollection(); ``` Add getter after `setComment()` method (after line 153): ```php /** * @return Collection */ public function getSuspensions(): Collection { return $this->suspensions; } ``` --- ### Task 3: Migration **Files:** - Create: `migrations/Version20260312140000.php` - [ ] **Step 1: Create the migration file** ```php addSql('CREATE TABLE contract_suspensions ( id SERIAL PRIMARY KEY, contract_period_id INT NOT NULL REFERENCES employee_contract_periods(id) ON DELETE CASCADE, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL )'); $this->addSql('CREATE INDEX idx_suspension_period_start ON contract_suspensions (contract_period_id, start_date)'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE contract_suspensions'); } } ``` - [ ] **Step 2: Run migration** Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction` Expected: Migration applied successfully - [ ] **Step 3: Verify schema is in sync** Run: `docker exec php-sirh-fpm php bin/console doctrine:schema:validate` Expected: OK (or only pre-existing warnings) --- ### Task 4: Expose suspensions in Employee read API **Files:** - Modify: `src/Dto/Employees/ContractHistoryItem.php` - Modify: `src/Entity/Employee.php` - [ ] **Step 1: Add periodId and suspensions to ContractHistoryItem DTO** In `src/Dto/Employees/ContractHistoryItem.php`, add two new constructor parameters after `$comment` (line 25): ```php #[Groups(['employee:read'])] public ?string $comment = null, #[Groups(['employee:read'])] public ?int $periodId = null, #[Groups(['employee:read'])] public array $suspensions = [], ``` - [ ] **Step 2: Update Employee::getContractHistory() to include suspensions** In `src/Entity/Employee.php`, find the `getContractHistory()` method (around line 253). In the `array_map` callback, update the `ContractHistoryItem` construction to pass suspensions: Replace the current `return new ContractHistoryItem(...)` block with: ```php $suspensionData = array_map( static fn (ContractSuspension $s): array => [ 'id' => $s->getId(), 'startDate' => $s->getStartDate()->format('Y-m-d'), 'endDate' => $s->getEndDate()?->format('Y-m-d'), 'comment' => $s->getComment(), ], $period->getSuspensions()->toArray() ); return new ContractHistoryItem( contractId: $contract?->getId(), contractName: $contract?->getName(), weeklyHours: $contract?->getWeeklyHours(), contractNature: $period->getContractNatureEnum()->value, startDate: $period->getStartDate()->format('Y-m-d'), endDate: $period->getEndDate()?->format('Y-m-d'), comment: $period->getComment(), periodId: $period->getId(), suspensions: $suspensionData, ); ``` Add `use App\Entity\ContractSuspension;` at the top of `Employee.php` if not already present. - [ ] **Step 3: Add getContractPeriods() getter to Employee** In `src/Entity/Employee.php`, add a public getter for the `$contractPeriods` collection (needed by leave calculation to access suspensions): ```php /** * @return Collection */ public function getContractPeriods(): Collection { return $this->contractPeriods; } ``` - [ ] **Step 4: Add getCurrentSuspensions() to Employee** In `src/Entity/Employee.php`, add a new method after `getCurrentContractEndDate()` (around line 247): ```php /** * @return list */ #[Groups(['employee:read'])] public function getCurrentSuspensions(): array { $currentPeriod = $this->resolveCurrentContractPeriod(); if (null === $currentPeriod) { return []; } return array_values(array_map( static fn (ContractSuspension $s): array => [ 'id' => $s->getId(), 'startDate' => $s->getStartDate()->format('Y-m-d'), 'endDate' => $s->getEndDate()?->format('Y-m-d'), 'comment' => $s->getComment(), ], $currentPeriod->getSuspensions()->toArray() )); } ``` - [ ] **Step 5: Verify no syntax errors** Run: `docker exec php-sirh-fpm php -l src/Entity/Employee.php && docker exec php-sirh-fpm php -l src/Dto/Employees/ContractHistoryItem.php` Expected: No syntax errors --- ## Chunk 2: Suspension API Processor ### Task 5: ContractSuspensionWriteProcessor **Files:** - Create: `src/State/ContractSuspensionWriteProcessor.php` - Create: `tests/State/ContractSuspensionWriteProcessorTest.php` - [ ] **Step 1: Write the test — valid suspension creation** ```php buildPeriodWithId(1, '2026-01-01', null); $suspension = new ContractSuspension(); $suspension->setContractPeriod($period); $suspension->setStartDate(new DateTimeImmutable('2026-03-01')); $suspension->setEndDate(new DateTimeImmutable('2026-04-30')); $suspension->setComment('Congé sans solde'); $persistProcessor = $this->createMock(ProcessorInterface::class); $persistProcessor->expects(self::once())->method('process')->willReturn($suspension); $entityManager = $this->createStub(EntityManagerInterface::class); $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); $result = $processor->process($suspension, new Post()); self::assertSame($suspension, $result); } public function testRejectsEndDateBeforeStartDate(): void { $period = $this->buildPeriodWithId(1, '2026-01-01', null); $suspension = new ContractSuspension(); $suspension->setContractPeriod($period); $suspension->setStartDate(new DateTimeImmutable('2026-05-01')); $suspension->setEndDate(new DateTimeImmutable('2026-03-01')); $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); $this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); } public function testRejectsStartDateBeforePeriodStart(): void { $period = $this->buildPeriodWithId(1, '2026-06-01', null); $suspension = new ContractSuspension(); $suspension->setContractPeriod($period); $suspension->setStartDate(new DateTimeImmutable('2026-01-01')); $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); $this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); } public function testRejectsOverlappingSuspension(): void { $period = $this->buildPeriodWithId(1, '2026-01-01', null); $existing = new ContractSuspension(); $existing->setContractPeriod($period); $existing->setStartDate(new DateTimeImmutable('2026-03-01')); $existing->setEndDate(new DateTimeImmutable('2026-04-30')); $period->getSuspensions()->add($existing); $suspension = new ContractSuspension(); $suspension->setContractPeriod($period); $suspension->setStartDate(new DateTimeImmutable('2026-04-01')); $suspension->setEndDate(new DateTimeImmutable('2026-05-31')); $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); $this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); } public function testRejectsClosedPeriod(): void { $period = $this->buildPeriodWithId(1, '2025-01-01', '2025-12-31'); $suspension = new ContractSuspension(); $suspension->setContractPeriod($period); $suspension->setStartDate(new DateTimeImmutable('2025-06-01')); $suspension->setEndDate(new DateTimeImmutable('2025-07-31')); $persistProcessor = $this->createStub(ProcessorInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class); $processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager); $this->expectException(\Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException::class); $processor->process($suspension, new Post()); } private function buildPeriodWithId(int $id, string $start, ?string $end): EmployeeContractPeriod { $period = new EmployeeContractPeriod(); $period->setStartDate(new DateTimeImmutable($start)); if (null !== $end) { $period->setEndDate(new DateTimeImmutable($end)); } $period->setContractNature(ContractNature::CDI); $ref = new ReflectionProperty($period, 'id'); $ref->setValue($period, $id); return $period; } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `docker exec php-sirh-fpm php bin/phpunit tests/State/ContractSuspensionWriteProcessorTest.php` Expected: FAIL — class ContractSuspensionWriteProcessor not found - [ ] **Step 3: Implement the processor** ```php persistProcessor->process($data, $operation, $uriVariables, $context); } $period = $data->getContractPeriod(); if (!$period instanceof EmployeeContractPeriod) { throw new UnprocessableEntityHttpException('contractPeriod is required.'); } $this->validate($data, $period); return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void { $startDate = $suspension->getStartDate(); $endDate = $suspension->getEndDate(); $periodEnd = $period->getEndDate(); if ($periodEnd instanceof DateTimeImmutable && $periodEnd < new DateTimeImmutable('today')) { throw new UnprocessableEntityHttpException('Impossible de suspendre une période de contrat clôturée.'); } if ($endDate instanceof DateTimeImmutable && $endDate < $startDate) { throw new UnprocessableEntityHttpException('La date de fin doit être postérieure à la date de début.'); } if ($startDate < $period->getStartDate()) { throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer avant le début du contrat.'); } $periodEnd = $period->getEndDate(); if ($periodEnd instanceof DateTimeImmutable) { if ($startDate > $periodEnd) { throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer après la fin du contrat.'); } if ($endDate instanceof DateTimeImmutable && $endDate > $periodEnd) { throw new UnprocessableEntityHttpException('La suspension ne peut pas se terminer après la fin du contrat.'); } } $this->validateNoOverlap($suspension, $period); } private function validateNoOverlap(ContractSuspension $suspension, EmployeeContractPeriod $period): void { $start = $suspension->getStartDate(); $end = $suspension->getEndDate(); foreach ($period->getSuspensions() as $existing) { if ($existing->getId() === $suspension->getId() && null !== $suspension->getId()) { continue; } $existingStart = $existing->getStartDate(); $existingEnd = $existing->getEndDate(); if (null === $end && null === $existingEnd) { throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.'); } if (null === $end) { if ($start <= $existingEnd) { throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.'); } continue; } if (null === $existingEnd) { if ($existingStart <= $end) { throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.'); } continue; } if ($start <= $existingEnd && $end >= $existingStart) { throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.'); } } } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `docker exec php-sirh-fpm php bin/phpunit tests/State/ContractSuspensionWriteProcessorTest.php` Expected: All 5 tests PASS --- ## Chunk 3: Leave Calculation — SuspensionDaysCalculator ### Task 6: SuspensionDaysCalculator (shared logic) Both `EmployeeLeaveSummaryProvider` and `LeaveBalanceComputationService` need the same logic to subtract suspended days from a month. Extract this into a small dedicated service. **Files:** - Create: `src/Service/Leave/SuspensionDaysCalculator.php` - Create: `tests/Service/Leave/SuspensionDaysCalculatorTest.php` - [ ] **Step 1: Write the test** ```php countSuspendedDaysInMonth( new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'), [] ); self::assertSame(0, $result); } public function testFullMonthSuspension(): void { $calc = new SuspensionDaysCalculator(); $suspension = $this->buildSuspension('2026-03-01', '2026-03-31'); $result = $calc->countSuspendedDaysInMonth( new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'), [$suspension] ); self::assertSame(31, $result); } public function testPartialMonthSuspension(): void { $calc = new SuspensionDaysCalculator(); $suspension = $this->buildSuspension('2026-03-10', '2026-03-20'); $result = $calc->countSuspendedDaysInMonth( new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'), [$suspension] ); self::assertSame(11, $result); } public function testSuspensionSpanningMultipleMonths(): void { $calc = new SuspensionDaysCalculator(); $suspension = $this->buildSuspension('2026-02-15', '2026-04-10'); // March fully covered $result = $calc->countSuspendedDaysInMonth( new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'), [$suspension] ); self::assertSame(31, $result); } public function testSuspensionWithoutEndDate(): void { $calc = new SuspensionDaysCalculator(); $suspension = $this->buildSuspension('2026-03-15', null); $result = $calc->countSuspendedDaysInMonth( new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'), [$suspension] ); self::assertSame(17, $result); } public function testMultipleSuspensionsInSameMonth(): void { $calc = new SuspensionDaysCalculator(); $s1 = $this->buildSuspension('2026-03-01', '2026-03-10'); $s2 = $this->buildSuspension('2026-03-20', '2026-03-25'); $result = $calc->countSuspendedDaysInMonth( new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'), [$s1, $s2] ); self::assertSame(16, $result); } public function testSuspensionOutsideMonthReturnsZero(): void { $calc = new SuspensionDaysCalculator(); $suspension = $this->buildSuspension('2026-01-01', '2026-01-31'); $result = $calc->countSuspendedDaysInMonth( new DateTimeImmutable('2026-03-01'), new DateTimeImmutable('2026-03-31'), [$suspension] ); self::assertSame(0, $result); } public function testCountSuspendedBusinessDays(): void { $calc = new SuspensionDaysCalculator(); // March 2-6, 2026 = Mon-Fri = 5 business days $suspension = $this->buildSuspension('2026-03-02', '2026-03-06'); $result = $calc->countSuspendedBusinessDays( new DateTimeImmutable('2026-01-01'), new DateTimeImmutable('2026-12-31'), [$suspension], [] ); self::assertSame(5, $result); } private function buildSuspension(string $start, ?string $end): ContractSuspension { $s = new ContractSuspension(); $s->setStartDate(new DateTimeImmutable($start)); if (null !== $end) { $s->setEndDate(new DateTimeImmutable($end)); } return $s; } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `docker exec php-sirh-fpm php bin/phpunit tests/Service/Leave/SuspensionDaysCalculatorTest.php` Expected: FAIL — class not found - [ ] **Step 3: Implement SuspensionDaysCalculator** ```php $suspensions */ public function countSuspendedDaysInMonth( DateTimeImmutable $monthStart, DateTimeImmutable $monthEnd, array $suspensions ): int { $total = 0; foreach ($suspensions as $suspension) { $sStart = $suspension->getStartDate(); $sEnd = $suspension->getEndDate() ?? $monthEnd; $overlapStart = $sStart > $monthStart ? $sStart : $monthStart; $overlapEnd = $sEnd < $monthEnd ? $sEnd : $monthEnd; if ($overlapStart > $overlapEnd) { continue; } $total += ((int) $overlapEnd->diff($overlapStart)->format('%a')) + 1; } return $total; } /** * Count business days (Mon-Fri, excl. public holidays) suspended within a period. * * @param list $suspensions * @param array $publicHolidays map of Y-m-d => label */ public function countSuspendedBusinessDays( DateTimeImmutable $periodStart, DateTimeImmutable $periodEnd, array $suspensions, array $publicHolidays ): int { $total = 0; foreach ($suspensions as $suspension) { $sStart = $suspension->getStartDate(); $sEnd = $suspension->getEndDate() ?? $periodEnd; $overlapStart = $sStart > $periodStart ? $sStart : $periodStart; $overlapEnd = $sEnd < $periodEnd ? $sEnd : $periodEnd; if ($overlapStart > $overlapEnd) { continue; } for ($cursor = $overlapStart; $cursor <= $overlapEnd; $cursor = $cursor->modify('+1 day')) { $weekDay = (int) $cursor->format('N'); $dayKey = $cursor->format('Y-m-d'); if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) { ++$total; } } } return $total; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `docker exec php-sirh-fpm php bin/phpunit tests/Service/Leave/SuspensionDaysCalculatorTest.php` Expected: All 8 tests PASS --- ## Chunk 4: Integrate suspension into leave calculation ### Task 7: Modify EmployeeLeaveSummaryProvider **Files:** - Modify: `src/State/EmployeeLeaveSummaryProvider.php` - [ ] **Step 1: Inject SuspensionDaysCalculator** Add `use App\Service\Leave\SuspensionDaysCalculator;` to imports. Add to constructor: `private SuspensionDaysCalculator $suspensionDaysCalculator` - [ ] **Step 2: Modify computeAccruedDaysFromStart signature** Change method signature at line 333 to add suspensions parameter: ```php private function computeAccruedDaysFromStart( float $acquiredDays, float $accrualPerMonth, DateTimeImmutable $periodStart, ?DateTimeImmutable $periodEnd, array $suspensions = [] ): float { ``` - [ ] **Step 3: Add suspension subtraction in the month loop** Inside the while loop (after line 358 `$coveredDays = ...`), add: ```php $suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions); $coveredDays = max(0, $coveredDays - $suspendedDays); ``` - [ ] **Step 4: Resolve suspensions and pass to computeAccruedDaysFromStart calls** Find where `computeAccruedDaysFromStart` is called (around lines 173-187). Before those calls, resolve the suspensions for the current period: ```php $suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to); ``` Then pass `$suspensions` as the 5th argument to both calls: ```php $generatedDays = $leavePolicy['accrualPerMonth'] > 0.0 ? $this->computeAccruedDaysFromStart( $leavePolicy['acquiredDays'], $leavePolicy['accrualPerMonth'], $effectiveFrom, $accrualCalculationEnd, $suspensions ) : 0.0; $generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0 ? $this->computeAccruedDaysFromStart( $leavePolicy['acquiredSaturdays'], $leavePolicy['saturdayAccrualPerMonth'], $effectiveFrom, $accrualCalculationEnd, $suspensions ) : 0.0; ``` - [ ] **Step 5: Modify the forfait branch to subtract suspended business days** In the forfait branch (around line 225-234), replace `$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];` (line 227) with: ```php $acquiredDays = $carryDays + $leavePolicy['acquiredDays']; $suspensions = $this->resolveSuspensionsForPeriod($employee, $from, $to); if ([] !== $suspensions) { $publicHolidays = $this->buildPublicHolidayMap($from, $to); $suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($from, $to, $suspensions, $publicHolidays); $totalBusinessDays = $this->countBusinessDays($from, $to); if ($totalBusinessDays > 0) { $effectiveBusinessDays = $totalBusinessDays - $suspendedBusinessDays; $acquiredDays = $carryDays + (float) max(0, $effectiveBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS); } } ``` - [ ] **Step 6: Add resolveSuspensionsForPeriod helper method** Add at the end of the class: ```php /** * @return list */ private function resolveSuspensionsForPeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array { $suspensions = []; foreach ($employee->getContractPeriods() as $period) { $periodStart = $period->getStartDate(); $periodEnd = $period->getEndDate(); if ($periodStart > $to) { continue; } if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) { continue; } foreach ($period->getSuspensions() as $suspension) { $suspensions[] = $suspension; } } return $suspensions; } ``` Note: `Employee::getContractPeriods()` was already added in Task 4 Step 3. Add `use App\Entity\ContractSuspension;` import in `EmployeeLeaveSummaryProvider.php`. - [ ] **Step 7: Run existing leave tests** Run: `docker exec php-sirh-fpm php bin/phpunit tests/State/EmployeeLeaveSummaryProviderTest.php` Expected: All existing tests still pass (no regression — employees without suspensions should behave identically) --- ### Task 8: Modify LeaveBalanceComputationService **Files:** - Modify: `src/Service/Leave/LeaveBalanceComputationService.php` - [ ] **Step 1: Inject SuspensionDaysCalculator** Add `use App\Entity\ContractSuspension;` and `use App\Service\Leave\SuspensionDaysCalculator;` to imports. Add to constructor: `private SuspensionDaysCalculator $suspensionDaysCalculator` - [ ] **Step 2: Modify computeAccruedDays signature** Change method signature at line 261 to add suspensions: ```php private function computeAccruedDays( float $annualCap, float $accrualPerMonth, DateTimeImmutable $periodStart, DateTimeImmutable $periodEnd, array $suspensions = [] ): float { ``` - [ ] **Step 3: Add suspension subtraction in the month loop** Inside the while loop (after line 282 `$coveredDays = ...`), add: ```php $suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions); $coveredDays = max(0, $coveredDays - $suspendedDays); ``` - [ ] **Step 4: Resolve suspensions in computeDynamicClosingForYear and pass to calls** In `computeDynamicClosingForYear()`, before the calls to `computeAccruedDays` (around lines 79-90), add: ```php $suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to); ``` Pass `$suspensions` as 5th argument to both `computeAccruedDays` calls: ```php $generatedDays = $this->computeAccruedDays( $this->resolveAnnualDays($employee), $this->resolveDaysAccrualPerMonth($employee), $effectiveFrom, $to, $suspensions ); $generatedSaturdays = $this->computeAccruedDays( $this->resolveAnnualSaturdays($employee), $this->resolveSaturdayAccrualPerMonth($employee), $effectiveFrom, $to, $suspensions ); ``` - [ ] **Step 5: Modify the forfait branch** In the forfait section (around line 69-76), after `$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;`, replace with: ```php $totalBusinessDays = $this->countBusinessDays($from, $to); $suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to); $suspendedBusinessDays = 0; if ([] !== $suspensions) { $publicHolidays = $this->buildPublicHolidayMap($from, $to); $suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($from, $to, $suspensions, $publicHolidays); } $effectiveBusinessDays = $totalBusinessDays - $suspendedBusinessDays; $acquiredDays = $carryDays + (float) max(0, $effectiveBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays; ``` - [ ] **Step 6: Add resolveSuspensionsForEmployeePeriod helper** ```php /** * @return list */ private function resolveSuspensionsForEmployeePeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array { $suspensions = []; foreach ($employee->getContractPeriods() as $period) { $periodStart = $period->getStartDate(); $periodEnd = $period->getEndDate(); if ($periodStart > $to) { continue; } if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) { continue; } foreach ($period->getSuspensions() as $suspension) { $suspensions[] = $suspension; } } return $suspensions; } ``` - [ ] **Step 7: Run all tests** Run: `docker exec php-sirh-fpm php bin/phpunit` Expected: All tests pass --- ## Chunk 5: Frontend — DTO, Service, Drawer UI ### Task 9: Frontend types and API service **Files:** - Modify: `frontend/services/dto/employee.ts` - Create: `frontend/services/contractSuspensions.ts` - [ ] **Step 1: Add ContractSuspension type and update Employee DTO** In `frontend/services/dto/employee.ts`, add the type and update Employee: ```typescript export type ContractSuspension = { id: number startDate: string endDate?: string | null comment?: string | null } ``` Add to `Employee` type: ```typescript currentSuspensions?: ContractSuspension[] ``` Add to `ContractHistoryItem` type: ```typescript suspensions?: ContractSuspension[] ``` - [ ] **Step 2: Create suspension API service** ```typescript import type { ContractSuspension } from './dto/employee' export const createSuspension = async (payload: { contractPeriodId: number startDate: string endDate?: string | null comment?: string | null }) => { const api = useApi() return api.post('/contract_suspensions', { contractPeriod: `/api/employee_contract_periods/${payload.contractPeriodId}`, startDate: payload.startDate, endDate: payload.endDate ?? null, comment: payload.comment ?? null }, { toastSuccessKey: 'success.suspension.create', toastErrorKey: 'errors.suspension.create' }) } export const updateSuspension = async ( id: number, payload: { startDate: string endDate?: string | null comment?: string | null } ) => { const api = useApi() return api.patch(`/contract_suspensions/${id}`, { startDate: payload.startDate, endDate: payload.endDate ?? null, comment: payload.comment ?? null }, { toastSuccessKey: 'success.suspension.update', toastErrorKey: 'errors.suspension.update' }) } ``` --- ### Task 10: Modify ContractTab.vue — tabbed drawer **Files:** - Modify: `frontend/components/employees/ContractTab.vue` - Modify: `frontend/composables/useEmployeeDetailPage.ts` - [ ] **Step 1: Add suspension props to ContractTab.vue** In the `defineProps` section of `ContractTab.vue`, add new props using the TypeScript generics syntax (matching the existing codebase pattern): ```typescript type SuspensionForm = { id: number | null startDate: string endDate: string comment: string } ``` Add to the existing `defineProps` (which uses TypeScript generics): ```typescript // Add these to the existing defineProps<{ ... }>() suspensionForms: SuspensionForm[] isSuspensionSubmitting: boolean onSubmitSuspension: (index: number) => void onAddSuspensionForm: () => void currentContractPeriodId?: number | null ``` - [ ] **Step 2: Change button label from "Clôturer" to "Modifier"** In the template, find the "Clôturer" button (around lines 28-35). Change the button text: From: `Clôturer` To: `Modifier` - [ ] **Step 3: Change drawer title and add tabs** Replace the drawer title from `"Clôturer le contrat"` to `"Modifier le contrat"`. Inside the `AppDrawer`, wrap the existing close-contract form content in a tab structure. Add at the start of the drawer content: ```vue
``` Add a `ref` in the script: ```typescript const drawerTab = ref<'close' | 'suspend'>('close') ``` Wrap the existing close-contract form content in `
...
`. - [ ] **Step 4: Add Suspendre tab content** After the close tab div, add: ```vue