1551 lines
48 KiB
Markdown
1551 lines
48 KiB
Markdown
# 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.
|