# 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
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\ContractSuspension;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<ContractSuspension>
 */
class ContractSuspensionRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, ContractSuspension::class);
    }
}
```

- [ ] **Step 2: Create the entity**

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ContractSuspensionRepository;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

#[ApiResource(
    operations: [
        new GetCollection(),
        new Post(processor: ContractSuspensionWriteProcessor::class),
        new Patch(processor: ContractSuspensionWriteProcessor::class),
    ],
    normalizationContext: ['groups' => ['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<int, ContractSuspension>
 */
#[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<int, ContractSuspension>
 */
public function getSuspensions(): Collection
{
    return $this->suspensions;
}
```

---

### Task 3: Migration

**Files:**
- Create: `migrations/Version20260312140000.php`

- [ ] **Step 1: Create the migration file**

```php
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260312140000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Create contract_suspensions table';
    }

    public function up(Schema $schema): void
    {
        $this->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<int, EmployeeContractPeriod>
 */
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<array{id: int|null, startDate: string, endDate: string|null, comment: string|null}>
 */
#[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
<?php

declare(strict_types=1);

namespace App\Tests\State;

use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;

/**
 * @internal
 */
final class ContractSuspensionWriteProcessorTest extends TestCase
{
    public function testPersistsValidSuspension(): void
    {
        $period = $this->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
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

final readonly class ContractSuspensionWriteProcessor implements ProcessorInterface
{
    public function __construct(
        #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
        private ProcessorInterface $persistProcessor,
        private EntityManagerInterface $entityManager,
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): mixed {
        if (!$data instanceof ContractSuspension) {
            return $this->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
<?php

declare(strict_types=1);

namespace App\Tests\Service\Leave;

use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use App\Service\Leave\SuspensionDaysCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;

/**
 * @internal
 */
final class SuspensionDaysCalculatorTest extends TestCase
{
    public function testNoSuspensionsReturnsZero(): void
    {
        $calc = new SuspensionDaysCalculator();
        $result = $calc->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
<?php

declare(strict_types=1);

namespace App\Service\Leave;

use App\Entity\ContractSuspension;
use DateTimeImmutable;

final class SuspensionDaysCalculator
{
    /**
     * Count calendar days suspended within a month window [monthStart, monthEnd].
     *
     * @param list<ContractSuspension> $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<ContractSuspension> $suspensions
     * @param array<string, string>    $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<ContractSuspension>
 */
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<ContractSuspension>
 */
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<ContractSuspension>('/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<ContractSuspension>(`/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
<div class="mb-4 flex border-b border-neutral-200">
    <button
        class="pb-2 px-4 border-b-2 font-semibold"
        :class="drawerTab === 'close'
            ? 'border-primary-500 text-primary-500'
            : 'border-transparent text-neutral-400 hover:text-neutral-600'"
        @click="drawerTab = 'close'"
    >
        Clôturer
    </button>
    <button
        class="pb-2 px-4 border-b-2 font-semibold"
        :class="drawerTab === 'suspend'
            ? 'border-primary-500 text-primary-500'
            : 'border-transparent text-neutral-400 hover:text-neutral-600'"
        @click="drawerTab = 'suspend'"
    >
        Suspendre
    </button>
</div>
```

Add a `ref` in the script:

```typescript
const drawerTab = ref<'close' | 'suspend'>('close')
```

Wrap the existing close-contract form content in `<div v-if="drawerTab === 'close'">...</div>`.

- [ ] **Step 4: Add Suspendre tab content**

After the close tab div, add:

```vue
<div v-if="drawerTab === 'suspend'" class="space-y-6">
    <div
        v-for="(form, index) in suspensionForms"
        :key="form.id ?? `new-${index}`"
        class="space-y-4 rounded-lg border border-neutral-200 p-4"
    >
        <div>
            <label class="text-md font-semibold text-neutral-700">
                Date de début <span class="text-red-600">*</span>
            </label>
            <input
                v-model="form.startDate"
                type="date"
                class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
            />
        </div>
        <div>
            <label class="text-md font-semibold text-neutral-700">
                Date de fin
            </label>
            <input
                v-model="form.endDate"
                type="date"
                class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
            />
        </div>
        <div>
            <label class="text-md font-semibold text-neutral-700">
                Commentaire
            </label>
            <textarea
                v-model="form.comment"
                rows="3"
                class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
            />
        </div>
        <button
            type="button"
            class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
            :disabled="!form.startDate || isSuspensionSubmitting"
            @click="onSubmitSuspension(index)"
        >
            {{ form.id ? 'Modifier' : '+ Ajouter' }}
        </button>
    </div>

    <button
        type="button"
        class="w-full rounded-md border-2 border-dashed border-neutral-300 px-4 py-3 text-base font-semibold text-neutral-500 transition hover:border-primary-500 hover:text-primary-500"
        @click="onAddSuspensionForm"
    >
        + Ajouter une suspension
    </button>
</div>
```

---

### Task 11: Suspension form logic in composable

**Files:**
- Modify: `frontend/composables/useEmployeeDetailPage.ts`

- [ ] **Step 1: Add suspension imports and state**

Add imports:
```typescript
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
```

Add reactive state alongside the existing contract form state:

```typescript
type SuspensionForm = {
  id: number | null
  startDate: string
  endDate: string
  comment: string
}

const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
```

- [ ] **Step 2: Add hydration logic**

Add a function to populate suspension forms from current employee data:

```typescript
const hydrateSuspensionForms = () => {
    const current = employee.value?.currentSuspensions ?? []
    suspensionForms.value = current.map(s => ({
        id: s.id,
        startDate: s.startDate,
        endDate: s.endDate ?? '',
        comment: s.comment ?? ''
    }))
}
```

Call `hydrateSuspensionForms()` inside `openCloseContractDrawer()` (which becomes `openModifyContractDrawer()`).

- [ ] **Step 3: Add submit function**

```typescript
const submitSuspension = async (index: number) => {
    const form = suspensionForms.value[index]
    if (!form || !form.startDate) return

    const periodId = currentActiveContractPeriodId.value
    if (!periodId) return

    isSuspensionSubmitting.value = true
    try {
        if (form.id) {
            await updateSuspension(form.id, {
                startDate: form.startDate,
                endDate: form.endDate || null,
                comment: form.comment || null
            })
        } else {
            await createSuspension({
                contractPeriodId: periodId,
                startDate: form.startDate,
                endDate: form.endDate || null,
                comment: form.comment || null
            })
        }
        await loadEmployee()
        hydrateSuspensionForms()
    } finally {
        isSuspensionSubmitting.value = false
    }
}
```

- [ ] **Step 4: Add addSuspensionForm function**

```typescript
const addSuspensionForm = () => {
    suspensionForms.value.push({
        id: null,
        startDate: '',
        endDate: '',
        comment: ''
    })
}
```

- [ ] **Step 5: Resolve currentActiveContractPeriodId**

Add a computed that extracts the period ID from the already-existing `currentActiveContractPeriod` computed (the `periodId` field was already added to `ContractHistoryItem` in Task 4 and Task 9):

```typescript
const currentActiveContractPeriodId = computed<number | null>(() => {
    const period = currentActiveContractPeriod.value
    return period?.periodId ?? null
})
```

Where `currentActiveContractPeriod` already exists in the composable (around line 80).

- [ ] **Step 6: Export new values from composable**

Add to the return object:

```typescript
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
```

- [ ] **Step 7: Pass new props from [id].vue to ContractTab**

In `frontend/pages/employees/[id].vue`, destructure the new values from the composable and pass them as props to `<EmployeesContractTab>`:

```vue
:suspension-forms="suspensionForms"
:is-suspension-submitting="isSuspensionSubmitting"
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
```

- [ ] **Step 8: Verify in browser**

1. Open an employee detail page
2. Click "Modifier" button — drawer should open with 2 tabs
3. Click "Suspendre" tab — should show "+ Ajouter une suspension" button
4. Click it, fill in dates, submit — suspension should be created
5. Reopen drawer — suspension should be pre-filled
6. Check leave tab — accruing days should be reduced

---

## Chunk 6: Add i18n toast keys

### Task 12: Add toast translation keys

**Files:**
- Find and modify the i18n file(s) for success/error messages

- [ ] **Step 1: Locate i18n files**

Search for existing toast keys like `success.employee.create` to find the translation file.

- [ ] **Step 2: Add suspension keys**

Add:
```
success.suspension.create: "Suspension créée"
success.suspension.update: "Suspension modifiée"
errors.suspension.create: "Erreur lors de la création de la suspension"
errors.suspension.update: "Erreur lors de la modification de la suspension"
```

**Note:** If the project uses the toast key as a fallback label when no translation file is found, adding these keys to the translation file is optional — the key string itself will be displayed. Check the `useApi` composable behavior to confirm.
