diff --git a/docs/superpowers/plans/2026-03-12-contract-suspension.md b/docs/superpowers/plans/2026-03-12-contract-suspension.md new file mode 100644 index 0000000..81bfeab --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-contract-suspension.md @@ -0,0 +1,1550 @@ +# 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 +
+
+
+ + +
+
+ + +
+
+ +