Compare commits

..

17 Commits

Author SHA1 Message Date
gitea-actions
96185e2334 chore: bump version to v0.1.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-13 11:11:40 +00:00
7d53000fc2 fix : validation autorisée pour les contrats 4h sans heures ou absence
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 12:11:31 +01:00
gitea-actions
c317a2a026 chore: bump version to v0.1.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-13 10:59:58 +00:00
8846e83df1 feat : modification de l'affichage des congés
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 11:57:02 +01:00
gitea-actions
ff824f233a chore: bump version to v0.1.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-13 10:03:51 +00:00
c4c9dfceab feat : amélioration des perfs de la page employée en séparant les responsabilités et le chargement par onglet
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 11:03:41 +01:00
gitea-actions
ca6597cd38 chore: bump version to v0.1.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 2m4s
2026-03-13 09:26:43 +00:00
4a2c3a8eed feat : Ajout du système de RTT sur la page employé avec le repport annuel des heures
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-13 10:26:33 +01:00
gitea-actions
1858817649 chore: bump version to v0.1.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-12 15:58:05 +00:00
99f0f191f4 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-12 16:57:55 +01:00
96617f04bc fix : style du drawer de suspension 2026-03-12 16:57:45 +01:00
gitea-actions
25d961c367 chore: bump version to v0.1.30
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-03-12 15:46:17 +00:00
38f09914cb feat : ajout des suspensions et des jours de présence
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 16:46:06 +01:00
gitea-actions
e6819bc68a chore: bump version to v0.1.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-12 11:30:22 +00:00
6153175ca0 feat : ajout des icons dans la sidebar et redirection après login sur le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 12:30:13 +01:00
gitea-actions
49a1c07ed1 chore: bump version to v0.1.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-03-12 10:23:20 +00:00
9fe2397386 feat : ajout d'une date d'entrée pour les employés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 11:23:09 +01:00
62 changed files with 5873 additions and 705 deletions

View File

@@ -7,7 +7,21 @@
"Bash(docker compose:*)",
"Bash(make test:*)",
"Bash(grep:*)",
"Bash(docker exec:*)"
"Bash(docker exec:*)",
"Bash(php8.3 bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee 2>&1)",
"Read(//usr/bin/**)",
"Read(//usr/local/bin/**)",
"Bash(command -v php8.2)",
"Bash(command -v php8.1)",
"Bash(ls /usr/bin/php*)",
"Read(//opt/**)",
"Read(//home/m-tristan/.nix-profile/**)",
"Read(//home/m-tristan/.local/bin/**)",
"Bash(env)",
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
"Bash(which python3:*)",
"Bash(sudo apt-get:*)",
"Bash(npx xlsx-cli:*)"
]
}
}

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.27'
app.version: '0.1.35'

View File

@@ -0,0 +1,484 @@
# RTT : Affichage en heures + Paiement RTT
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Afficher les RTT en heures (plus en jours), permettre le paiement RTT via drawer avec majoration 25%/50%, stocker en BDD et afficher par mois.
**Architecture:** Nouvelle entity `EmployeeRttPayment` (employee, year, month, minutes, rate). Le provider RTT agrège les paiements par mois et les soustrait du disponible. Le frontend ajoute un drawer de saisie et deux lignes par mois (25% / 50%).
**Tech Stack:** Symfony + Doctrine + API Platform (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend)
---
## Contexte existant
- **Entity:** `EmployeeRttBalance` dans `src/Entity/EmployeeRttBalance.php` - stocke `openingMinutes` par exercice
- **Provider:** `src/State/EmployeeRttSummaryProvider.php` - calcule `availableMinutes = carry + currentYearRecovery`
- **Service:** `src/Service/Rtt/RttRecoveryComputationService.php` - calcul semaine par semaine
- **Frontend:** `frontend/components/employees/RttTab.vue` - affichage grille mois/semaines
- **DTO backend:** `src/ApiResource/EmployeeRttSummary.php` - champs `availableMinutes`, `weeks[]`
- **DTO frontend:** `frontend/services/dto/employee-rtt-summary.ts`
- Les minutes sont la base de calcul. 1 jour = 7h = 420 minutes.
- Exercice RTT : 1er juin N-1 -> 31 mai N (year = N)
---
### Task 1: Migration - Créer la table `employee_rtt_payments`
**Files:**
- Create: `migrations/Version20260309140000.php`
**Step 1: Créer la migration**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260309140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee_rtt_payments table for RTT paid hours tracking.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE employee_rtt_payments (
id SERIAL PRIMARY KEY,
employee_id INT NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
year INT NOT NULL,
month INT NOT NULL,
minutes INT NOT NULL,
rate VARCHAR(10) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
)
SQL);
$this->addSql('CREATE INDEX idx_rtt_payment_employee_year ON employee_rtt_payments (employee_id, year)');
$this->addSql("COMMENT ON TABLE employee_rtt_payments IS 'Paiements RTT par employe, mois et taux de majoration.'");
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.rate IS 'Taux de majoration: 25 ou 50.'");
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.minutes IS 'Minutes RTT payees pour ce mois et ce taux.'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_rtt_payments');
}
}
```
**Step 2: Exécuter la migration**
Run: `make migrate` ou `docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration exécutée sans erreur
**Step 3: Commit**
```bash
git add migrations/Version20260309140000.php
git commit -m "feat(rtt): add employee_rtt_payments table"
```
---
### Task 2: Entity + Repository `EmployeeRttPayment`
**Files:**
- Create: `src/Entity/EmployeeRttPayment.php`
- Create: `src/Repository/EmployeeRttPaymentRepository.php`
**Step 1: Créer l'entity**
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\EmployeeRttPaymentRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeRttPaymentRepository::class)]
#[ORM\Table(name: 'employee_rtt_payments')]
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
class EmployeeRttPayment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
#[ORM\Column(type: 'integer')]
private int $year = 0;
#[ORM\Column(type: 'integer')]
private int $month = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Minutes RTT payees pour ce mois et ce taux.'])]
private int $minutes = 0;
#[ORM\Column(length: 10, options: ['comment' => 'Taux de majoration: 25 ou 50.'])]
private string $rate = '25';
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
// Getters & setters (getId, getEmployee/setEmployee, getYear/setYear,
// getMonth/setMonth, getMinutes/setMinutes, getRate/setRate, touch)
// Suivre le même pattern que EmployeeLeaveBalance
}
```
**Step 2: Créer le repository**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeRttPayment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeRttPayment>
*/
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeRttPayment::class);
}
/**
* @return list<EmployeeRttPayment>
*/
public function findByEmployeeAndYear(Employee $employee, int $year): array
{
return $this->createQueryBuilder('p')
->andWhere('p.employee = :employee')
->andWhere('p.year = :year')
->setParameter('employee', $employee)
->setParameter('year', $year)
->orderBy('p.month', 'ASC')
->getQuery()
->getResult();
}
}
```
**Step 3: Vérifier le lint**
Run: `docker compose exec php php -l src/Entity/EmployeeRttPayment.php && docker compose exec php php -l src/Repository/EmployeeRttPaymentRepository.php`
Expected: No syntax errors
**Step 4: Commit**
```bash
git add src/Entity/EmployeeRttPayment.php src/Repository/EmployeeRttPaymentRepository.php
git commit -m "feat(rtt): add EmployeeRttPayment entity and repository"
```
---
### Task 3: API Resource + Provider + Processor pour le paiement RTT
**Files:**
- Create: `src/ApiResource/EmployeeRttPaymentInput.php`
- Create: `src/State/EmployeeRttPaymentProvider.php`
- Create: `src/State/EmployeeRttPaymentProcessor.php`
**Step 1: Créer l'API Resource**
Endpoint: `PATCH /employees/{id}/rtt-payments` (ROLE_ADMIN)
Body: `{ "month": 3, "minutes": 120, "rate": "25" }`
```php
// src/ApiResource/EmployeeRttPaymentInput.php
#[ApiResource(
operations: [
new Patch(
uriTemplate: '/employees/{id}/rtt-payments',
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeRttPaymentProvider::class,
processor: EmployeeRttPaymentProcessor::class
),
],
paginationEnabled: false
)]
final class EmployeeRttPaymentInput
{
public int $month = 0;
public int $minutes = 0;
public string $rate = '25';
public ?int $year = null;
}
```
**Step 2: Créer le Provider** (retourne un DTO vide, même pattern que `EmployeeFractionedDaysProvider`)
**Step 3: Créer le Processor**
Logique:
- Valider `rate` in `['25', '50']`, `month` in `[1..12]`, `minutes >= 0`
- Résoudre l'année (même logique exercice RTT que le provider existant)
- Persister un `EmployeeRttPayment`
**Step 4: Vérifier le lint**
**Step 5: Commit**
```bash
git add src/ApiResource/EmployeeRttPaymentInput.php src/State/EmployeeRttPaymentProvider.php src/State/EmployeeRttPaymentProcessor.php
git commit -m "feat(rtt): add PATCH endpoint for RTT payment"
```
---
### Task 4: Modifier le DTO + Provider RTT pour inclure les paiements par mois
**Files:**
- Modify: `src/ApiResource/EmployeeRttSummary.php`
- Modify: `src/State/EmployeeRttSummaryProvider.php`
- Create: `src/Dto/Rtt/RttMonthPayment.php`
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
**Step 1: Créer le DTO mois-paiement**
```php
// src/Dto/Rtt/RttMonthPayment.php
final class RttMonthPayment
{
public function __construct(
public int $month,
public int $paidMinutes25 = 0,
public int $paidMinutes50 = 0,
) {}
}
```
**Step 2: Ajouter au summary backend**
Dans `EmployeeRttSummary.php`, ajouter:
```php
public int $totalPaidMinutes = 0;
/** @var list<RttMonthPayment> */
public array $monthPayments = [];
```
Et modifier `availableMinutes` pour soustraire les paiements:
```php
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes
+ $summary->currentYearRecoveryMinutes
- $summary->totalPaidMinutes;
```
**Step 3: Modifier le provider** pour charger les paiements via le repository et les agréger par mois
Dans `EmployeeRttSummaryProvider::provide()`:
- Charger `$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year)`
- Agréger par mois + rate pour construire les `RttMonthPayment`
- Calculer `totalPaidMinutes = sum(minutes)`
- Soustraire du `availableMinutes`
**Step 4: Mettre à jour le DTO frontend**
Dans `frontend/services/dto/employee-rtt-summary.ts`:
```typescript
export type RttMonthPayment = {
month: number
paidMinutes25: number
paidMinutes50: number
}
export type EmployeeRttSummary = {
// ... champs existants ...
totalPaidMinutes: number
monthPayments: RttMonthPayment[]
}
```
**Step 5: Commit**
```bash
git commit -m "feat(rtt): include paid hours in RTT summary by month"
```
---
### Task 5: Frontend - Afficher les RTT en heures + lignes payées
**Files:**
- Modify: `frontend/components/employees/RttTab.vue`
**Step 1: Changer l'affichage du header de jours en heures**
Ligne 4 actuelle:
```html
<p>...: {{ formatDays(summary?.availableMinutes ?? 0) }}</p>
```
Remplacer par:
```html
<p>...: {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
```
**Step 2: Ajouter les 2 lignes de paiement par mois**
Après la ligne `Heure payée` existante (ligne 33-34), remplacer par 2 lignes distinctes:
```html
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
<div class="py-[6px] pl-3 border-b border-primary-500">{{ formatMinutes(getMonthPaid25(month.month)) }}</div>
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
<div class="py-[6px] pl-3">{{ formatMinutes(getMonthPaid50(month.month)) }}</div>
```
**Step 3: Ajouter les helpers computed**
```typescript
const paymentsByMonth = computed(() => {
const map = new Map<number, { paid25: number; paid50: number }>()
for (const mp of props.summary?.monthPayments ?? []) {
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
}
return map
})
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
```
**Step 4: Commit**
```bash
git commit -m "feat(rtt): display hours instead of days + paid hours per month"
```
---
### Task 6: Frontend - Drawer de paiement RTT
**Files:**
- Modify: `frontend/components/employees/RttTab.vue`
- Modify: `frontend/services/employee-rtt-summary.ts`
- Modify: `frontend/composables/useEmployeeDetailPage.ts`
- Modify: `frontend/pages/employees/[id].vue`
**Step 1: Ajouter le service API**
Dans `frontend/services/employee-rtt-summary.ts`:
```typescript
export const createRttPayment = async (
employeeId: number,
month: number,
minutes: number,
rate: '25' | '50',
year?: number
) => {
const api = useApi()
const body: Record<string, unknown> = { month, minutes, rate }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
}
```
**Step 2: Ajouter au composable**
Dans `useEmployeeDetailPage.ts`:
- Import `createRttPayment`
- Ajouter `submitRttPayment(month, minutes, rate)` qui appelle l'API puis `loadEmployee()`
- Exposer dans le return
**Step 3: Passer l'event dans la page**
Dans `frontend/pages/employees/[id].vue`:
- Destructurer `submitRttPayment`
- Ajouter `@submit-rtt-payment="submitRttPayment"` sur `<EmployeesRttTab>`
**Step 4: Ajouter le drawer dans RttTab.vue**
Même pattern que le drawer fractionnés dans LeaveTab:
- Import `AppDrawer`
- State: `isPaymentDrawerOpen`, `paymentForm: { month, minutes, rate }`
- Bouton "Payer les RTT" ouvre le drawer
- Formulaire avec:
- Select mois (Janvier..Décembre)
- Input number "Nombre d'heures" (step 0.5, converti en minutes au submit)
- Select rate: "25%" / "50%"
- Boutons Annuler / Enregistrer
- Emit `submit-rtt-payment` au submit
**Step 5: Commit**
```bash
git commit -m "feat(rtt): add payment drawer with month/hours/rate fields"
```
---
### Task 7: Documentation fonctionnelle
**Files:**
- Modify: `doc/functional-rules.md`
**Step 1: Mettre à jour la section RTT**
Ajouter après les règles RTT existantes:
- Paiement RTT: saisie RH via `PATCH /employees/{id}/rtt-payments`
- Stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
- Les heures payées sont soustraites du disponible RTT
- Affichage: 2 lignes par mois (25% et 50%)
- L'affichage global RTT est en heures (plus en jours)
**Step 2: Commit**
```bash
git commit -m "docs: update functional rules with RTT payment feature"
```
---
## Résumé des fichiers
| Action | Fichier |
|--------|---------|
| Create | `migrations/Version20260309140000.php` |
| Create | `src/Entity/EmployeeRttPayment.php` |
| Create | `src/Repository/EmployeeRttPaymentRepository.php` |
| Create | `src/ApiResource/EmployeeRttPaymentInput.php` |
| Create | `src/State/EmployeeRttPaymentProvider.php` |
| Create | `src/State/EmployeeRttPaymentProcessor.php` |
| Create | `src/Dto/Rtt/RttMonthPayment.php` |
| Modify | `src/ApiResource/EmployeeRttSummary.php` |
| Modify | `src/State/EmployeeRttSummaryProvider.php` |
| Modify | `frontend/services/dto/employee-rtt-summary.ts` |
| Modify | `frontend/services/employee-rtt-summary.ts` |
| Modify | `frontend/components/employees/RttTab.vue` |
| Modify | `frontend/composables/useEmployeeDetailPage.ts` |
| Modify | `frontend/pages/employees/[id].vue` |
| Modify | `doc/functional-rules.md` |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
# Employee Entry Date 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:** Add an `entryDate` field to Employee, automatically populated from `contractStartDate` at creation.
**Architecture:** New nullable `DATE` column on `employees` table. The `EmployeeWriteProcessor` sets `entryDate` from the first contract period's start date during employee creation. Exposed read-only in the API. No fallback needed — existing employees will be updated manually in prod DB.
**Tech Stack:** Symfony/Doctrine (backend), Nuxt/Vue/TypeScript (frontend)
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `src/Entity/Employee.php` | Modify | Add `entryDate` column + getter/setter, expose in `employee:read` |
| `src/State/EmployeeWriteProcessor.php` | Modify | Set `entryDate` from `contractStartDate` on creation |
| `migrations/Version20260312120000.php` | Create | Add `entry_date` column to `employees` table |
| `tests/State/EmployeeWriteProcessorTest.php` | Modify | Assert `entryDate` is set on new employee |
| `frontend/services/dto/employee.ts` | Modify | Add `entryDate` field to `Employee` type |
| `frontend/pages/employees/index.vue` | Modify | Display entry date in employee list |
---
## Chunk 1: Backend
### Task 1: Migration
**Files:**
- Create: `migrations/Version20260312120000.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 Version20260312120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add entry_date column to employees table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP entry_date');
}
}
```
- [ ] **Step 2: Run migration**
Run: `php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration applied successfully
- [ ] **Step 3: Commit**
```bash
git add migrations/Version20260312120000.php
git commit -m "feat : ajout colonne entry_date sur employees"
```
---
### Task 2: Entity — add `entryDate` property
**Files:**
- Modify: `src/Entity/Employee.php:56-61` (insert after `displayOrder`)
- [ ] **Step 1: Add the column, getter, and setter to Employee entity**
Add after the `displayOrder` property (line 58):
```php
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['employee:read'])]
private ?\DateTimeImmutable $entryDate = null;
```
Add getter and setter after `setDisplayOrder()` (after line 167):
```php
public function getEntryDate(): ?\DateTimeImmutable
{
return $this->entryDate;
}
public function setEntryDate(?\DateTimeImmutable $entryDate): self
{
$this->entryDate = $entryDate;
return $this;
}
```
- [ ] **Step 2: Verify schema is in sync**
Run: `php bin/console doctrine:schema:validate`
Expected: OK (or only existing unrelated warnings)
- [ ] **Step 3: Commit**
```bash
git add src/Entity/Employee.php
git commit -m "feat : ajout propriete entryDate sur Employee"
```
---
### Task 3: Set `entryDate` on employee creation
**Files:**
- Modify: `src/State/EmployeeWriteProcessor.php:60-71`
- Modify: `tests/State/EmployeeWriteProcessorTest.php`
- [ ] **Step 1: Write the failing test**
Add `use ApiPlatform\Metadata\Post;` to the imports at the top of the test file (alongside the existing `Delete` and `Patch` imports).
Then add this test method to `EmployeeWriteProcessorTest`:
```php
public function testSetsEntryDateOnNewEmployee(): void
{
$employee = new Employee();
$employee->setFirstName('Jane');
$employee->setLastName('Doe');
$employee->setContractStartDate('2026-04-01');
$employee->setContractNature('CDI');
$contract = new Contract()
->setName('35h')
->setTrackingMode(Contract::TRACKING_TIME)
->setWeeklyHours(35);
$employee->setContract($contract);
$persistProcessor = $this->createMock(ProcessorInterface::class);
$removeProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
$persistProcessor
->expects(self::once())
->method('process')
->willReturn($employee);
$periodManager
->expects(self::once())
->method('ensureContractPeriodExists');
$processor = new EmployeeWriteProcessor(
$persistProcessor,
$removeProcessor,
$entityManager,
$periodRepository,
$changeRequestFactory,
$periodManager
);
$processor->process($employee, new Post());
self::assertNotNull($employee->getEntryDate());
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
Expected: FAIL — `entryDate` is null
- [ ] **Step 3: Implement — set entryDate in EmployeeWriteProcessor**
In `src/State/EmployeeWriteProcessor.php`, inside the `if ($isNew)` block (line 60-71), add **before** `return $result;` (line 71):
```php
$data->setEntryDate($startDate);
```
The full block becomes:
```php
if ($isNew) {
$startDate = $changeRequest->contractStartDate ?? new DateTimeImmutable('1970-01-01');
$nature = $changeRequest->contractNature ?? ContractNature::CDI;
$this->periodManager->ensureContractPeriodExists(
employee: $data,
contract: $currentContract,
startDate: $startDate,
endDate: $changeRequest->contractEndDate,
nature: $nature
);
$data->setEntryDate($startDate);
return $result;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
Expected: PASS
- [ ] **Step 5: Run all EmployeeWriteProcessor tests**
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php`
Expected: All tests pass (no regression)
- [ ] **Step 6: Commit**
```bash
git add src/State/EmployeeWriteProcessor.php tests/State/EmployeeWriteProcessorTest.php
git commit -m "feat : remplissage automatique entryDate a la creation employe"
```
---
## Chunk 2: Frontend
### Task 4: Update frontend DTO and display entry date
**Files:**
- Modify: `frontend/services/dto/employee.ts:14-25`
- Modify: `frontend/pages/employees/index.vue`
- [ ] **Step 1: Add `entryDate` to the Employee DTO**
In `frontend/services/dto/employee.ts:24`, add `entryDate` after `displayOrder`:
```typescript
displayOrder?: number
entryDate?: string | null
```
- [ ] **Step 2: Display entry date in the employee card hover overlay**
In `frontend/pages/employees/index.vue`, inside the hover overlay `<div>` (line 49-54), add a new line after the "Site" line (after line 53):
```vue
<p><strong>Entree :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
```
This uses string splitting instead of `new Date()` to avoid timezone parsing issues with date-only strings.
- [ ] **Step 3: Verify in browser**
1. Check the API response for an employee: `GET /api/employees` should include `entryDate` field (confirms backend `employee:read` group works)
2. Open the employee list page, hover over a card — entry date should appear in the overlay
3. Create a new employee, verify the entry date shows the contract start date
4. Existing employees without entry date should show "-"
- [ ] **Step 4: Commit**
```bash
git add frontend/services/dto/employee.ts frontend/pages/employees/index.vue
git commit -m "feat : affichage date d'entree dans la liste employes"
```

View File

@@ -0,0 +1,563 @@
# Refonte onglet RTT — Plan d'implémentation
> **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:** Remplacer la vue annuelle RTT par une vue mensuelle avec tableau détaillé par semaine (base/25%/50%) et un système de paiement à 4 champs.
**Architecture:** Enrichir `RttRecoveryComputationService` pour retourner le détail base/bonus par palier. Modifier l'entité `EmployeeRttPayment` pour stocker 4 valeurs. Réécrire le composant `RttTab.vue` avec navigation mensuelle et tableau 7 colonnes.
**Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), PostgreSQL.
**Spec:** `docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md`
---
## Task 1: Enrichir le retour de `RttRecoveryComputationService::computeRecoveryByWeek`
**Files:**
- Create: `src/Dto/Rtt/WeekRecoveryDetail.php`
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php:97-206`
Actuellement `computeRecoveryByWeek` retourne `array<string, int>` (weekKey => totalMinutes). Il faut retourner `array<string, WeekRecoveryDetail>` avec le détail ventilé.
- [ ] **Step 1: Créer le DTO `WeekRecoveryDetail`**
```php
// src/Dto/Rtt/WeekRecoveryDetail.php
<?php
declare(strict_types=1);
namespace App\Dto\Rtt;
final class WeekRecoveryDetail
{
public function __construct(
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}
```
- [ ] **Step 2: Modifier `computeRecoveryByWeek` pour retourner `array<string, WeekRecoveryDetail>`**
Changer le retour de la méthode. Les variables internes existent déjà (`weeklyOvertimeTotalMinutes`, `weeklyOvertime25Minutes`, `weeklyOvertime50Minutes`). Il faut calculer en plus les bases séparées.
La logique de ventilation des heures de base entre palier 25% et palier 50% :
- `base25Minutes` = heures sup dans la tranche 25% = `min(overtimeMinutes, max(0, overtime25StartMinutes - overtimeReferenceMinutes))`... En fait, c'est plus simple :
- `base25Minutes` = `min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes))` quand overtimeTotal > 0
- Plus simplement : `base25Minutes` = heures entre le seuil 25% et 43h, `base50Minutes` = heures au-dessus de 43h
Reprenons la logique existante (lignes 189-202) :
- `overtimeReferenceMinutes` = seuil à partir duquel on compte les heures sup (max(35, weeklyHours) * 60 réparti sur les jours)
- `overtime25StartMinutes` = seuil à partir duquel les heures sup sont à 25% (39h si contrat >= 39h, sinon 35h)
- `weeklyOvertimeTotalMinutes` = max(0, worked - overtimeReference) — total heures sup brutes
- `weeklyOvertime25Minutes` = bonus 25% = round(min(worked, 43*60) - overtime25Start) * 0.25
- `weeklyOvertime50Minutes` = bonus 50% = round(max(0, worked - 43*60)) * 0.5
Pour la ventilation :
- `base25Minutes` = min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes)) — Non, c'est la tranche 25% en termes d'heures travaillées, pas en termes d'heures sup.
En fait :
- Les heures sup brutes = `weeklyOvertimeTotalMinutes` = `worked - overtimeReference`
- Les heures dans le palier 25% = heures entre `overtime25Start` et `min(worked, 43*60)` = c'est `max(0, min(worked, 43*60) - overtime25Start)`. C'est la base sur laquelle le 25% est calculé.
- Les heures dans le palier 50% = heures au-dessus de 43h = `max(0, worked - 43*60)`. C'est la base sur laquelle le 50% est calculé.
Modifier les lignes 191-202 :
```php
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
);
```
Les cas "zéro" (weekStart vide, limitDate dépassée, etc.) retournent `new WeekRecoveryDetail()` (tout à 0).
- [ ] **Step 3: Adapter `computeTotalRecoveryForExercise` pour retourner un `WeekRecoveryDetail` agrégé**
Cette méthode retournait `int`. Elle doit maintenant retourner un `WeekRecoveryDetail` qui agrège toutes les semaines (somme par champ). Le rollover et le provider en ont besoin pour la ventilation du carry-over.
```php
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
$total = new WeekRecoveryDetail();
foreach ($byWeek as $detail) {
$total = new WeekRecoveryDetail(
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
);
}
return $total;
}
```
- [ ] **Step 4: Vérifier que le code compile**
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
---
## Task 2: Modifier l'entité `EmployeeRttBalance` (carry-over ventilé) + rollover
**Files:**
- Modify: `src/Entity/EmployeeRttBalance.php`
- Modify: `src/Repository/EmployeeRttBalanceRepository.php`
- Modify: `src/Command/RttRolloverCommand.php`
Le carry-over doit être ventilé sur les mêmes 4 colonnes que le tableau (base25, bonus25, base50, bonus50) pour pouvoir afficher une ligne "Report" dans le mois de juin.
- [ ] **Step 1: Remplacer `openingMinutes` par 4 champs dans `EmployeeRttBalance`**
Remplacer la propriété `$openingMinutes` par :
```php
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])]
private int $openingBase25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])]
private int $openingBonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])]
private int $openingBase50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])]
private int $openingBonus50Minutes = 0;
```
Ajouter les getters/setters. Supprimer `getOpeningMinutes`/`setOpeningMinutes`. Ajouter un helper `getTotalOpeningMinutes()` qui retourne la somme des 4 champs.
- [ ] **Step 2: Adapter `RttRolloverCommand`**
`computeTotalRecoveryForExercise` retourne maintenant un `WeekRecoveryDetail`. Utiliser les 4 champs :
```php
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
$balance = new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningBase25Minutes($carry->base25Minutes)
->setOpeningBonus25Minutes($carry->bonus25Minutes)
->setOpeningBase50Minutes($carry->base50Minutes)
->setOpeningBonus50Minutes($carry->bonus50Minutes)
->setIsLocked(false)
;
```
- [ ] **Step 3: Adapter `EmployeeRttSummaryProvider::resolveCarryMinutes`**
Cette méthode retournait `int`. La renommer en `resolveCarry` et retourner un `WeekRecoveryDetail` :
```php
private function resolveCarry(Employee $employee, int $year): WeekRecoveryDetail
{
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
if (null !== $balance) {
return new WeekRecoveryDetail(
base25Minutes: $balance->getOpeningBase25Minutes(),
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
base50Minutes: $balance->getOpeningBase50Minutes(),
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
totalMinutes: $balance->getTotalOpeningMinutes(),
);
}
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
}
```
Adapter le provider pour utiliser le carry ventilé dans le summary :
- `carryFromPreviousYearMinutes` = carry->totalMinutes
- Ajouter les 4 champs de carry dans `EmployeeRttSummary` pour le frontend
- [ ] **Step 4: Ajouter les champs carry dans `EmployeeRttSummary`**
```php
public int $carryBase25Minutes = 0;
public int $carryBonus25Minutes = 0;
public int $carryBase50Minutes = 0;
public int $carryBonus50Minutes = 0;
```
- [ ] **Step 5: Générer et exécuter la migration**
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
Note : faire la migration après la Task 3 (EmployeeRttPayment) pour regrouper les changements dans une seule migration.
---
## Task 3: Modifier l'entité `EmployeeRttPayment` et la migration
**Files:**
- Modify: `src/Entity/EmployeeRttPayment.php`
- Modify: `src/Repository/EmployeeRttPaymentRepository.php`
- [ ] **Step 1: Remplacer `minutes` + `rate` par 4 champs dans l'entité**
Remplacer les propriétés `$minutes` et `$rate` par :
```php
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
private int $base25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
private int $bonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])]
private int $base50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])]
private int $bonus50Minutes = 0;
```
Ajouter les getters/setters correspondants. Supprimer `getMinutes`/`setMinutes`/`getRate`/`setRate`.
- [ ] **Step 2: Adapter le repository**
Remplacer `findOneByEmployeeYearMonthRate` par `findOneByEmployeeYearMonth` (plus besoin du rate) :
```php
public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment
{
return $this->findOneBy([
'employee' => $employee,
'year' => $year,
'month' => $month,
]);
}
```
- [ ] **Step 3: Générer et vérifier la migration**
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
Vérifier que la migration :
- Ajoute `base25_minutes`, `bonus25_minutes`, `base50_minutes`, `bonus50_minutes`
- Supprime `minutes` et `rate`
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
---
## Task 3: Adapter le DTO `RttMonthPayment` et `EmployeeRttWeekSummary`
**Files:**
- Modify: `src/Dto/Rtt/RttMonthPayment.php`
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
- [ ] **Step 1: Modifier `RttMonthPayment`**
Remplacer `paidMinutes25` et `paidMinutes50` par les 4 champs :
```php
final class RttMonthPayment
{
public function __construct(
public int $month,
public int $paidBase25Minutes = 0,
public int $paidBonus25Minutes = 0,
public int $paidBase50Minutes = 0,
public int $paidBonus50Minutes = 0,
) {}
}
```
- [ ] **Step 2: Enrichir `EmployeeRttWeekSummary`**
Ajouter les champs de détail :
```php
final class EmployeeRttWeekSummary
{
public function __construct(
public int $month,
public int $weekNumber,
public string $weekStart,
public string $weekEnd,
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}
```
Supprimer l'ancien champ `recoveryMinutes`.
---
## Task 4: Adapter le provider et le processor backend
**Files:**
- Modify: `src/State/EmployeeRttSummaryProvider.php`
- Modify: `src/ApiResource/EmployeeRttSummary.php`
- Modify: `src/ApiResource/EmployeeRttPaymentInput.php`
- Modify: `src/State/EmployeeRttPaymentProcessor.php`
- [ ] **Step 1: Adapter `EmployeeRttSummaryProvider::provide`**
Le mapping des semaines (ligne 87-96) doit utiliser les nouveaux champs du `WeekRecoveryDetail` :
```php
$summary->weeks = array_map(
static function (array $week) use ($currentByWeekStart) {
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
return new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
},
$weekRanges
);
```
Le `currentYearRecoveryMinutes` doit sommer les `totalMinutes` :
```php
$summary->currentYearRecoveryMinutes = array_sum(
array_map(static fn (WeekRecoveryDetail $d) => $d->totalMinutes, $currentByWeekStart)
);
```
Adapter l'agrégation des paiements (lignes 98-121) pour les 4 champs :
```php
foreach ($payments as $payment) {
$m = $payment->getMonth();
if (!isset($monthBuckets[$m])) {
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
}
foreach ($monthBuckets as $m => $bucket) {
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
}
```
- [ ] **Step 2: Adapter `EmployeeRttPaymentInput`**
```php
final class EmployeeRttPaymentInput
{
public int $month = 0;
public int $base25Minutes = 0;
public int $bonus25Minutes = 0;
public int $base50Minutes = 0;
public int $bonus50Minutes = 0;
public ?int $year = null;
}
```
- [ ] **Step 3: Adapter `EmployeeRttPaymentProcessor`**
Supprimer la validation du `rate`. Adapter le upsert :
```php
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
if (null === $payment) {
$payment = new EmployeeRttPayment();
$payment->setEmployee($employee);
$payment->setYear($year);
$payment->setMonth($data->month);
$this->entityManager->persist($payment);
}
$payment->setBase25Minutes($data->base25Minutes);
$payment->setBonus25Minutes($data->bonus25Minutes);
$payment->setBase50Minutes($data->base50Minutes);
$payment->setBonus50Minutes($data->bonus50Minutes);
$payment->touch();
$this->entityManager->flush();
```
- [ ] **Step 4: Vérifier**
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
---
## Task 5: Adapter le frontend — DTOs et service
**Files:**
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
- Modify: `frontend/services/employee-rtt-summary.ts`
- [ ] **Step 1: Mettre à jour les types TS**
```typescript
export type EmployeeRttWeekSummary = {
month: number
weekNumber: number
weekStart: string
weekEnd: string
overtimeMinutes: number
base25Minutes: number
bonus25Minutes: number
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
}
export type RttMonthPayment = {
month: number
paidBase25Minutes: number
paidBonus25Minutes: number
paidBase50Minutes: number
paidBonus50Minutes: number
}
export type EmployeeRttSummary = {
year: number
carryFromPreviousYearMinutes: number
carryBase25Minutes: number
carryBonus25Minutes: number
carryBase50Minutes: number
carryBonus50Minutes: number
currentYearRecoveryMinutes: number
totalPaidMinutes: number
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
}
```
- [ ] **Step 2: Adapter le service `createRttPayment`**
```typescript
export const createRttPayment = async (
employeeId: number,
month: number,
base25Minutes: number,
bonus25Minutes: number,
base50Minutes: number,
bonus50Minutes: number,
year?: number
) => {
const api = useApi()
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
}
```
---
## Task 6: Réécrire `RttTab.vue`
**Files:**
- Modify: `frontend/components/employees/RttTab.vue`
- [ ] **Step 1: Réécrire le composant complet**
Structure du template :
1. En-tête avec navigation mensuelle (flèches `<` `>`) et "RTT À LA DATE DU JOUR : X heure"
2. Tableau 7 colonnes : Semaine | Heure | Base | 25% | Base | 50% | Total
3. Si mois de juin (premier mois de l'exercice) et carry > 0 : ligne "Report" avec les 4 valeurs carry (colonne Heure = "-")
4. 5 lignes semaines (padding si < 5)
5. Ligne Total (somme par colonne, incluant le report si présent)
6. Ligne Payé (valeurs négatives, "-" pour colonne Heure)
7. Ligne Reste (Total - |Payé|, "-" pour colonne Heure)
8. Bouton "+ Payer les RRT"
9. Drawer de paiement avec 5 champs
Script setup :
- `currentMonthIndex` : ref (0-11) pour la navigation dans `orderedMonthIndexes` (toujours [5,6,7,8,9,10,11,0,1,2,3,4] = juin à mai)
- Initialiser `currentMonthIndex` au mois courant dans l'exercice
- `currentMonth` : computed qui retourne le numéro de mois (1-12) basé sur l'index
- `weeksForMonth` : computed filtrant les semaines du summary pour le mois courant, paddé à 5
- `monthPayment` : computed trouvant le paiement du mois dans `summary.monthPayments`
- Totaux par colonne : computed sommant les semaines
- `formatMinutes` : existant, réutiliser (format `Xh` ou `Xh Ym`)
- Navigation : `prevMonth` / `nextMonth` modifiant `currentMonthIndex` avec bornes [0, 11]
Drawer de paiement :
- Champs : Mois (select), Base 25% (number en heures), Heures 25% (number en heures), Base 50% (number en heures), Heures 50% (number en heures)
- Si paiement existant pour le mois sélectionné : pré-remplir en convertissant minutes → heures
- Emit : `submit-rtt-payment` avec les 4 valeurs converties en minutes + le mois
- [ ] **Step 2: Adapter le composant parent**
Chercher où `RttTab` est utilisé et adapter l'event handler `submit-rtt-payment` pour passer les 4 champs au lieu de `(month, minutes, rate)`.
Run: `grep -rn "submit-rtt-payment" frontend/` pour trouver le parent.
---
## Task 7: Test de bout en bout
- [ ] **Step 1: Vérifier le cache et la migration**
```bash
docker exec php-sirh-fpm php bin/console cache:clear
docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 2: Tester l'API**
Vérifier que `GET /api/employees/{id}/rtt-summary` retourne les nouveaux champs par semaine.
Vérifier que `PATCH /api/employees/{id}/rtt-payments` accepte les 4 champs.
- [ ] **Step 3: Tester le frontend**
- Navigation mensuelle (flèches, mois courant par défaut)
- Tableau : vérifier les valeurs par semaine
- Paiement : créer, modifier, vérifier pré-remplissage
- "RTT À LA DATE DU JOUR" : vérifier le cumul

View File

@@ -0,0 +1,187 @@
# Suspension de contrat — Design Spec
## Objectif
Permettre de suspendre un contrat employé. Une suspension empêche l'acquisition de congés durant la période concernée (prorata). S'applique aux CDI/CDD non forfait et aux forfaits 218.
## Contraintes
- Plusieurs suspensions possibles par période de contrat
- Pas de suppression de suspension (hors scope)
- Règle de calcul : on exclut les jours de suspension jusqu'au dernier mois complet terminé (cohérent avec la règle existante)
- Suspension sans date de fin = suspension en cours indéfiniment (exclut les mois jusqu'au dernier mois terminé)
- Les suspensions ne doivent pas se chevaucher sur une même période de contrat
## Modèle de données
### Nouvelle entité `ContractSuspension`
Nouvelle table `contract_suspensions` :
| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | SERIAL | non | PK |
| `contract_period_id` | INT | non | FK vers `employee_contract_periods`, CASCADE delete |
| `start_date` | DATE | non | Début de suspension |
| `end_date` | DATE | oui | Fin de suspension (null = en cours) |
| `comment` | TEXT | oui | Commentaire libre |
| `created_at` | TIMESTAMP | non | Date de création technique |
Index : `(contract_period_id, start_date)`.
Relation : `EmployeeContractPeriod` ← OneToMany → `ContractSuspension`.
## Backend — API
### Endpoint dédié
Les suspensions sont gérées via un endpoint dédié plutôt que via les champs transients Employee. Cela évite de complexifier le `EmployeeWriteProcessor` et permet de gérer N suspensions proprement.
**Nouvel ApiResource `ContractSuspension` :**
- `POST /api/contract_suspensions` — créer une suspension (body : `contractPeriod` IRI, `startDate`, `endDate`, `comment`)
- `PATCH /api/contract_suspensions/{id}` — modifier une suspension existante
- Security : `ROLE_ADMIN`
- Pagination désactivée
**Processor custom `ContractSuspensionWriteProcessor` :**
- Résout la période de contrat depuis l'IRI
- Validation :
- `startDate` requis
- `endDate >= startDate` si renseigné
- `startDate >= period.startDate`
- Pour les CDD/contrats avec date de fin : `startDate` et `endDate` dans les bornes de la période
- Pas de chevauchement avec les autres suspensions de la même période
- Rejet si la période de contrat est déjà clôturée (date de fin dans le passé)
### Lecture
Exposer les suspensions dans la sérialisation de l'Employee :
- `Employee::getCurrentSuspensions(): array` — retourne les suspensions de la période de contrat courante, groupe `employee:read`
- Ajouter les suspensions au `ContractHistoryItem` via `EmployeeContractPeriod::getSuspensions()`
## Backend — Calcul des congés
### Deux points d'impact
Le calcul d'acquisition existe à **deux endroits** qui doivent tous les deux prendre en compte les suspensions :
1. **`EmployeeLeaveSummaryProvider::computeAccruedDaysFromStart()`** — affichage live des congés en cours d'acquisition
2. **`LeaveBalanceComputationService::computeAccruedDays()`** — utilisé par le rollover (`LeaveRolloverCommand`) pour calculer le solde de report
Les deux méthodes ont la même structure (boucle mois par mois) et doivent être modifiées de la même manière.
### Modification des méthodes de calcul
Pour les deux méthodes, ajouter un paramètre optionnel : `array $suspensions = []` (tableau de `{start: DateTimeImmutable, end: ?DateTimeImmutable}`).
Dans la boucle mois par mois, pour chaque mois :
1. Calculer les jours couverts par la période de contrat (existant)
2. Pour chaque suspension, calculer le nombre de jours suspendus qui tombent dans ce mois
3. Soustraire le total des jours suspendus
4. Le ratio du mois = max(0, jours couverts - jours suspendus) / jours dans le mois
Cela gère automatiquement les suspensions qui commencent/finissent en milieu de mois (prorata).
Une suspension sans date de fin utilise la date de fin de calcul comme borne (dernier jour du mois précédent, cohérent avec la règle existante).
**Note :** chaque méthode est appelée deux fois — une pour les jours, une pour les samedis. La soustraction de suspension s'applique aux deux appels.
### Impact sur les forfaits 218
Pour les forfaits, les jours acquis en début d'exercice (ex: 34 jours pour 2026) sont réduits au prorata des jours de suspension.
Calcul : `jours acquis = base × (jours ouvrés effectifs / jours ouvrés totaux de l'exercice)`
`jours ouvrés effectifs = jours ouvrés totaux - jours ouvrés suspendus`.
Cela impacte `EmployeeLeaveSummaryProvider` dans la branche forfait et `LeaveBalanceComputationService` dans le calcul forfait de `computeDynamicClosingForYear()`.
### Passage des données de suspension aux méthodes
- **`EmployeeLeaveSummaryProvider`** : le provider a accès aux périodes de contrat via l'Employee. Il doit résoudre les suspensions de la période couvrant l'exercice et les passer aux méthodes de calcul.
- **`LeaveBalanceComputationService`** : le service utilise `$employee->getContractHistory()`. Il doit trouver les suspensions de la période couvrant l'exercice. L'accès au repository `EmployeeContractPeriodRepository` est déjà injecté — ajouter l'accès au repository `ContractSuspensionRepository` ou passer par la relation Doctrine.
### Impact sur la bascule d'exercice (rollover au 01/06)
Le rollover (`LeaveRolloverCommand`) appelle `LeaveBalanceComputationService::computeDynamicClosingForYear()` qui appelle `computeAccruedDays()`. En modifiant `computeAccruedDays()` pour accepter et traiter les suspensions, le rollover prendra automatiquement en compte les suspensions. Les jours acquis au rollover reflèteront la déduction.
**Exemple CDI :** exercice 2027 (juin 2026 - mai 2027), 2 suspensions totalisant 3 mois → au lieu de 25j acquis, l'employé bascule avec ~18.75j (9 mois effectifs × 2.083j/mois).
### Règles non impactées
- INTERIM : pas de congés gérés
## Frontend — UI
### Bouton et drawer
Le bouton "Clôturer" devient **"Modifier"**. Il ouvre le drawer existant avec le titre **"Modifier le contrat"**. Le bouton **"+ Ajouter"** (création de nouveau contrat) reste inchangé.
Le drawer contient 2 onglets :
**Onglet "Clôturer"** — contenu identique à l'actuel (type contrat, temps de travail, début contrat en readonly, date fin, commentaire, checkbox solde de tout compte).
**Onglet "Suspendre"** — formulaires empilés :
- Pour chaque suspension existante : un formulaire pré-rempli avec les 3 champs (date début, date fin, commentaire) et un bouton **"Modifier"**
- En bas : un bouton **"+ Ajouter"** qui ajoute un nouveau formulaire vide avec les 3 champs et un bouton **"Ajouter"**
- Chaque formulaire est indépendant (soumission individuelle)
### Champs par formulaire de suspension
- Date de début (required, input date)
- Date de fin (optionnel, input date)
- Commentaire (optionnel, textarea)
### Données nécessaires côté frontend
Nouveau type `ContractSuspension` (DTO) :
```typescript
type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
```
Ajouter au type `Employee` (DTO) :
- `currentSuspensions?: ContractSuspension[]`
Ajouter au type `ContractHistoryItem` :
- `suspensions?: ContractSuspension[]`
Nouveau service `frontend/services/contractSuspensions.ts` :
- `createSuspension(payload)` — POST
- `updateSuspension(id, payload)` — PATCH
## Exemples de calcul
### CDI/CDD non forfait
Contrat CDI démarré le 01/06/2026, exercice 2027 (juin 2026 - mai 2027).
Accrual : 25j / 12 mois = 2.083j/mois.
Sans suspension au 12/03/2027 (9 mois complets : juin-février) :
- En cours d'acquisition = 9 × 2.083 = 18.75j
Avec 2 suspensions (01/01 au 31/01 + 01/03 au 31/03 = 2 mois) au 12/04/2027 (10 mois complets - 2 suspendus = 8 mois effectifs) :
- En cours d'acquisition = 8 × 2.083 = 16.67j
Samedis (5/12 par mois) :
- Sans suspension : 9 × 0.417 = 3.75j
- Avec 2 suspensions : 8 × 0.417 = 3.33j
### Forfait 218
Exercice 2026 (année civile), 34 jours acquis, 252 jours ouvrés dans l'année.
Suspension de 2 mois (44 jours ouvrés).
- Jours ouvrés effectifs = 252 - 44 = 208
- Jours acquis = 34 × (208 / 252) = 28.06j
## Hors scope
- Suppression d'une suspension
- Affichage de la suspension dans l'historique des contrats (les données sont sérialisées mais pas de rendu spécifique dans le tableau historique)
- Auto-fermeture des suspensions lors de la clôture du contrat

View File

@@ -0,0 +1,117 @@
# Refonte onglet RTT employé
## Contexte
L'onglet RTT actuel affiche une grille annuelle de 12 mois avec les minutes de récupération par semaine. Il doit être remplacé par une vue mensuelle détaillée avec navigation, un tableau ventilé par palier de majoration (25% / 50%), et un système de paiement à 4 champs.
## Maquette de référence
Fichier : `RTT.png` à la racine du projet.
## Structure de la vue
### En-tête
- Navigation mensuelle : `< MOIS ANNÉE >` (flèches gauche/droite)
- Navigation limitée aux mois de l'exercice (juin N-1 à mai N)
- Mois courant affiché par défaut à l'ouverture
- En haut à droite : `RTT À LA DATE DU JOUR : X heure` (cumul annuel toutes semaines confondues)
### Tableau
7 colonnes :
| Semaine | Heure | Base | 25% | Base | 50% | Total |
|---------|-------|------|-----|------|-----|-------|
- **Semaine** : label "Semaine 1" à "Semaine 5" (toujours 5 lignes, vide si le mois n'a que 4 semaines)
- **Heure** : heures supplémentaires brutes de la semaine
- **Base** (1er) : heures de base dans le palier 25% (heures entre 35h et 39h pour un contrat 39h)
- **25%** : bonus = base 25% × 0.25
- **Base** (2e) : heures de base dans le palier 50% (heures au-delà de 43h)
- **50%** : bonus = base 50% × 0.50
- **Total** : somme de toutes les bases + tous les bonus
### Lignes de synthèse
- **Total** : somme des 5 semaines par colonne
- **Payé** : montants payés pour ce mois (affichés en négatif). Colonne "Heure" = "-"
- **Reste** : Total - |Payé| par colonne. Colonne "Heure" = "-"
### Bouton
`+ Payer les RRT` en bas, centré. Ouvre un drawer.
## Drawer de paiement
Champs :
1. **Mois** (select) : liste des mois de l'exercice
2. **Base 25%** (number, en heures)
3. **Heures 25%** (number, en heures)
4. **Base 50%** (number, en heures)
5. **Heures 50%** (number, en heures)
Si des paiements existent pour le mois sélectionné, le formulaire est pré-rempli pour modification.
Boutons : Annuler / Enregistrer.
## Rattachement semaine → mois
Règle existante conservée : une semaine est rattachée au mois de son **samedi** (voir `RttRecoveryComputationService::buildWeeksForExercise`).
## Backend
### Modification de `EmployeeRttSummary`
Le provider retourne les données pour un mois donné (paramètre query `?month=X`) en plus du cumul annuel.
Nouvelles données par semaine :
- `overtimeMinutes` : heures sup brutes
- `base25Minutes` : base palier 25%
- `bonus25Minutes` : bonus 25%
- `base50Minutes` : base palier 50%
- `bonus50Minutes` : bonus 50%
- `totalMinutes` : somme base + bonus
### Modification de `EmployeeRttPayment`
Remplacer les champs `minutes` (int) + `rate` (int 25/50) par :
- `base25Minutes` (int)
- `bonus25Minutes` (int)
- `base50Minutes` (int)
- `bonus50Minutes` (int)
Migration Doctrine nécessaire.
### Modification de `EmployeeRttPaymentInput`
Adapter les champs pour correspondre aux 4 nouvelles valeurs.
### Modification de `RttRecoveryComputationService`
`computeRecoveryByWeek` retourne déjà les minutes totales. Il faut enrichir le retour pour ventiler base/bonus par palier. La logique de calcul des paliers existe déjà en interne, il suffit de l'exposer.
## Frontend
### Stockage vs affichage
- Backend : stockage en **minutes** (inchangé)
- Frontend : conversion minutes ↔ heures à l'affichage et à la saisie
### Réécriture de `RttTab.vue`
- Supprimer la grille annuelle de 12 mois
- Navigation mensuelle avec état réactif (mois courant)
- Tableau HTML avec les 7 colonnes décrites
- 5 lignes semaines + Total + Payé + Reste
- Formatage en "Xh" ou "Xh Ym" (ex: "6h 30m")
### Modification du DTO TypeScript
Adapter `EmployeeRttSummary` et `EmployeeRttWeekSummary` pour les nouveaux champs.
## Unités de conversion
- Affichage : heures et minutes (ex: "6h 30m", "30 m")
- Saisie paiement : en heures décimales (number input)
- Stockage : minutes entières (int)

View File

@@ -17,7 +17,7 @@
</button>
</div>
<div class="p-6">
<div class="overflow-y-auto p-6" style="max-height: calc(100% - 65px)">
<slot />
</div>
</div>

View File

@@ -31,7 +31,7 @@
:disabled="isContractSubmitting || !canCloseCurrentContract"
@click="onOpenCloseContractDrawer"
>
Clôturer
Modifier
</button>
<button
type="button"
@@ -43,7 +43,30 @@
</button>
</div>
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
<AppDrawer :model-value="isContractDrawerOpen" title="Modifier le contrat" @update:model-value="onUpdateContractDrawerOpen">
<div class="mb-4 flex border-b border-neutral-200">
<button
type="button"
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'close'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'close'"
>
Clôturer
</button>
<button
type="button"
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'suspend'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'suspend'"
>
Suspendre
</button>
</div>
<div v-if="drawerTab === 'close'">
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
@@ -128,6 +151,62 @@
</button>
</div>
</form>
</div>
<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-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
@click="onAddSuspensionForm"
>
+ Ajouter une suspension
</button>
</div>
</AppDrawer>
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
@@ -195,6 +274,13 @@
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
type ContractForm = {
contractId: number | ''
contractName: string
@@ -213,7 +299,7 @@ type CreateContractForm = {
endDate: string
}
defineProps<{
const props = defineProps<{
contractHistory: ContractHistoryItem[]
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
contractHistoryLabel: (item: ContractHistoryItem) => string
@@ -245,5 +331,16 @@ defineProps<{
onUpdateCreateContractDrawerOpen: (open: boolean) => void
onSubmitCloseContract: () => void
onSubmitCreateContract: () => void
suspensionForms: SuspensionForm[]
isSuspensionSubmitting: boolean
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
}>()
const drawerTab = ref<'close' | 'suspend'>('close')
watch(() => props.isContractDrawerOpen, (open) => {
if (open) drawerTab.value = 'close'
})
</script>

View File

@@ -1,35 +1,54 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours</p>
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours</p>
</div>
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
<p><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
</div>
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
<p class="col-start-1 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours
</p>
<p class="col-start-2 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Pris :</strong>
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
</p>
<p class="col-start-3 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
</p>
<p v-else class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Année N-1 acquis :</span>
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.takenSaturdays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours
</p>
<p v-else class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.previousYearTakenDays) }} Jours
</p>
<p v-if="isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
</p>
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Fractionné acquis : </span>
<span>{{ formatCount(summary?.fractionedDays) }} Jours</span>
</div>
<button
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
class="flex items-center"
@click="openFractionedDrawer"
>
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
</div>
<div class="flex flex-col jutify-center gap-2 items-center py-3">
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
<Icon name="mdi:edit-box" size="24"/>
</button>
</div>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
<div v-for="month in months" :key="month.label"
class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
{{ month.label }}
</div>
@@ -54,6 +73,9 @@
</div>
</template>
</div>
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence :
{{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}
</div>
</div>
</div>
</div>
@@ -117,7 +139,7 @@ const emit = defineEmits<{
}>()
const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({ days: 0 })
const fractionedForm = reactive({days: 0})
const openFractionedDrawer = () => {
fractionedForm.days = props.summary?.fractionedDays ?? 0
@@ -150,6 +172,11 @@ const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
const currentYearTakenDays = computed(() => {
if (!props.summary) return null
return props.summary.takenDays - (props.summary.previousYearTakenDays ?? 0)
})
const displayedYear = computed(() => {
if (props.summary?.year) return props.summary.year
const today = new Date()
@@ -259,9 +286,12 @@ const months = computed(() => {
cells.push(null)
}
const monthKey = `${monthYear}-${String(monthIndex + 1).padStart(2, '0')}`
return {
label,
cells
cells,
monthKey
}
})
})
@@ -278,15 +308,15 @@ const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) =
if (day.leave) {
const color = day.leave.colors[0] ?? '#222783'
if (day.leave.am && day.leave.pm) {
return { backgroundColor: color }
return {backgroundColor: color}
}
const colorFaded = `${color}60`
const backgroundImage = day.leave.am
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
return { backgroundImage, backgroundColor: 'transparent' }
return {backgroundImage, backgroundColor: 'transparent'}
}
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
if (day.isHoliday) return {backgroundColor: 'rgb(179, 229, 252)'}
return undefined
}

View File

@@ -1,80 +1,212 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5">
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
<button class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px] text-md" @click="openNewPayment">
+ Payer les RTT
</button>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10 pb-4">
<div
v-for="month in months"
:key="month.month"
class="rounded-md bg-tertiary-500 text-primary-500"
<!-- Header bar -->
<div class="flex items-center justify-between rounded-t-md bg-tertiary-500 px-5 py-4 text-black border border-primary-500">
<div class="flex items-center">
<button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
:disabled="currentMonthIndex === 0"
@click="currentMonthIndex--"
>
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
{{ month.label }}
</div>
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
<template v-for="week in month.weeks" :key="week.key">
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
<span v-if="week.isEmpty">&nbsp;</span>
<span v-else>Semaine {{ week.weekNumber }}</span>
</div>
<div class="py-[6px] pl-3 border-b border-primary-500">
<span v-if="week.isEmpty">&nbsp;</span>
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
</div>
</template>
<div class="py-[6px] pl-3 border-r border-b border-primary-500 font-semibold">Total</div>
<div class="py-[6px] pl-3 border-b border-primary-500 font-semibold">{{ formatMinutes(month.totalMinutes) }}</div>
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
<div class="py-[6px] pl-3 border-b border-primary-500 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
@click="openEditPayment(month.month, '25')"
title="Modifier les heures payées"
>
<p>{{ formatMinutes(getMonthPaid25(month.month)) }}</p>
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
</div>
</div>
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
<div class="py-[6px] px-3 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
@click="openEditPayment(month.month, '50')"
title="Modifier les heures payées"
>
<p>{{ formatMinutes(getMonthPaid50(month.month)) }}</p>
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
</div>
</div>
</div>
</div>
<Icon name="mdi:chevron-left" size="24"/>
</button>
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
{{ currentMonthLabel }} {{ currentYear }}
</span>
<button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
:disabled="currentMonthIndex === 11"
@click="currentMonthIndex++"
>
<Icon name="mdi:chevron-right" size="24"/>
</button>
</div>
<p class="text-[16px]">
<span class="font-semibold">RTT À LA DATE DU JOUR :</span>
{{ formatMinutes(summary?.availableMinutes ?? 0) }}
</p>
<div class="flex justify-center">
<button
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
@click="openPaymentDrawer"
>
+ Payer les RRT
</button>
</div>
</div>
<AppDrawer v-model="isPaymentDrawerOpen" :title="isEditMode ? 'Modifier le paiement RTT' : 'Payer des RTT'">
<!-- Table -->
<div class="min-h-0 flex-1 overflow-y-auto">
<table class="w-full table-fixed border-collapse text-[18px]">
<colgroup>
<col />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
</colgroup>
<thead>
<tr>
<th class="px-5 py-[10px] text-left font-bold text-primary-500 border border-primary-500">Semaine</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Heure</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th>
</tr>
</thead>
<tbody>
<!-- Report row (only on June when carry > 0) -->
<tr v-if="showReportRow">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }}</td>
</tr>
<!-- Week rows (always 5) -->
<tr
v-for="(week, idx) in paddedWeeks"
:key="week ? week.weekStart : `empty-${idx}`"
>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">
<span v-if="week">Semaine {{ week.weekNumber }}</span>
<span v-else>&nbsp;</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.overtimeMinutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
<span v-else>0 h</span>
</td>
</tr>
<!-- Total row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500 border-t-2">Total</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.overtime) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
</tr>
<!-- Payé row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
</tr>
<!-- Reste row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base25 - (currentPayment?.paidBase25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus25 - (currentPayment?.paidBonus25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base50 - (currentPayment?.paidBase50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus50 - (currentPayment?.paidBonus50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(resteTotal) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Payment Drawer -->
<AppDrawer v-model="isPaymentDrawerOpen" title="Payer des RTT">
<form @submit.prevent="onSubmitPayment">
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Mois</label>
<select v-model.number="paymentForm.month" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
<select
v-model.number="paymentForm.month"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Nombre d'heures</label>
<input v-model.number="paymentForm.hours" type="number" step="0.5" min="0" class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20" />
<label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label>
<input
v-model.number="paymentForm.base25Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 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 class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label>
<input
v-model.number="paymentForm.bonus25Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 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 class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label>
<input
v-model.number="paymentForm.base50Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 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 class="mb-6">
<label class="block text-sm font-medium text-neutral-700">Taux</label>
<select v-model="paymentForm.rate" :disabled="isEditMode" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed">
<option value="25">25%</option>
<option value="50">50%</option>
</select>
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label>
<input
v-model.number="paymentForm.bonus50Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 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 class="flex justify-end gap-3">
<button type="button" class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" @click="isPaymentDrawerOpen = false">Annuler</button>
<button type="submit" class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600">Enregistrer</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="isPaymentDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
@@ -82,7 +214,7 @@
</template>
<script setup lang="ts">
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
@@ -90,27 +222,27 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(event: 'submit-rtt-payment', month: number, minutes: number, rate: '25' | '50'): void
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
}>()
const isPaymentDrawerOpen = ref(false)
const isEditMode = ref(false)
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
// --- Month navigation ---
const monthLabels = [
'Janvier',
'Fevrier',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Aout',
'Septembre',
'Octobre',
'Novembre',
'Decembre'
] as const
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
const monthLabels: Record<number, string> = {
1: 'JANVIER',
2: 'FEVRIER',
3: 'MARS',
4: 'AVRIL',
5: 'MAI',
6: 'JUIN',
7: 'JUILLET',
8: 'AOUT',
9: 'SEPTEMBRE',
10: 'OCTOBRE',
11: 'NOVEMBRE',
12: 'DECEMBRE',
}
const orderedMonthOptions = [
{ value: 6, label: 'Juin' },
@@ -124,97 +256,152 @@ const orderedMonthOptions = [
{ value: 2, label: 'Fevrier' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' }
{ value: 5, label: 'Mai' },
]
const paymentsByMonth = computed(() => {
const map = new Map<number, { paid25: number; paid50: number }>()
for (const mp of props.summary?.monthPayments ?? []) {
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
}
return map
// Initialize to current month's position in the exercise
const today = new Date()
const todayMonth = today.getMonth() + 1
const initialIndex = orderedMonths.indexOf(todayMonth as (typeof orderedMonths)[number])
const currentMonthIndex = ref(initialIndex >= 0 ? initialIndex : 0)
const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
const currentYear = computed(() => {
if (!props.summary) return ''
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
})
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
// --- Weeks for current month ---
const months = computed(() => {
type DisplayWeek = {
key: string
weekNumber: number
recoveryMinutes: number
isEmpty?: boolean
}
const byMonth = new Map<number, { month: number; label: string; weeks: DisplayWeek[]; totalMinutes: number }>()
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5]
for (const month of orderedMonths) {
byMonth.set(month, {
month,
label: monthLabels[month - 1],
weeks: [],
totalMinutes: 0
})
}
for (const week of props.summary?.weeks ?? []) {
const month = byMonth.get(week.month)
if (!month) continue
month.weeks.push({
key: week.weekStart,
weekNumber: week.weekNumber,
recoveryMinutes: week.recoveryMinutes
})
month.totalMinutes += week.recoveryMinutes
}
return orderedMonths
.map((monthNumber) => byMonth.get(monthNumber)!)
.filter(Boolean)
.map((month) => {
const minRows = 5
const missing = Math.max(0, minRows - month.weeks.length)
for (let i = 0; i < missing; i += 1) {
month.weeks.push({
key: `empty-${month.month}-${i}`,
weekNumber: 0,
recoveryMinutes: 0,
isEmpty: true
})
}
return month
})
const weeksForCurrentMonth = computed((): EmployeeRttWeekSummary[] => {
if (!props.summary) return []
return props.summary.weeks.filter((w) => w.month === currentMonth.value)
})
const formatMinutes = (minutes: number) => {
const paddedWeeks = computed((): (EmployeeRttWeekSummary | null)[] => {
const weeks = weeksForCurrentMonth.value
const padded: (EmployeeRttWeekSummary | null)[] = [...weeks]
while (padded.length < 5) {
padded.push(null)
}
return padded
})
// --- Report row ---
const reportMonth = computed(() => {
if (!props.summary) return 6
const carryMonth = props.summary.carryMonth
// Report appears in the month AFTER carryMonth (wrapping 12 -> 1)
return carryMonth >= 12 ? 1 : carryMonth + 1
})
const showReportRow = computed(() => {
return (
currentMonth.value === reportMonth.value &&
(props.summary?.carryFromPreviousYearMinutes ?? 0) > 0
)
})
// --- Totals ---
const totals = computed(() => {
const weeks = weeksForCurrentMonth.value
const base = {
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
base25: weeks.reduce((s, w) => s + w.base25Minutes, 0),
bonus25: weeks.reduce((s, w) => s + w.bonus25Minutes, 0),
base50: weeks.reduce((s, w) => s + w.base50Minutes, 0),
bonus50: weeks.reduce((s, w) => s + w.bonus50Minutes, 0),
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
}
if (showReportRow.value && props.summary) {
base.base25 += props.summary.carryBase25Minutes
base.bonus25 += props.summary.carryBonus25Minutes
base.base50 += props.summary.carryBase50Minutes
base.bonus50 += props.summary.carryBonus50Minutes
base.total += props.summary.carryFromPreviousYearMinutes
}
return base
})
const currentPayment = computed(() => {
if (!props.summary) return null
return props.summary.monthPayments.find((p) => p.month === currentMonth.value) ?? null
})
const paidTotal = computed(() => {
if (!currentPayment.value) return 0
const p = currentPayment.value
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
})
const resteTotal = computed(() => {
return totals.value.total + paidTotal.value
})
// --- Format ---
const formatMinutes = (minutes: number): string => {
if (minutes === 0) return '0 h'
const sign = minutes < 0 ? '- ' : ''
const abs = Math.abs(minutes)
const hours = Math.floor(abs / 60)
const rest = abs % 60
const sign = minutes < 0 ? '-' : ''
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
if (rest === 0) return `${sign}${hours} h`
return `${sign}${hours} h ${rest} m`
}
const openNewPayment = () => {
isEditMode.value = false
paymentForm.month = 6
paymentForm.hours = 0
paymentForm.rate = '25'
isPaymentDrawerOpen.value = true
// --- Payment drawer ---
const isPaymentDrawerOpen = ref(false)
const paymentForm = reactive({
month: 6,
base25Hours: 0,
bonus25Hours: 0,
base50Hours: 0,
bonus50Hours: 0,
})
const prefillFromExistingPayment = (month: number) => {
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
if (existing) {
paymentForm.base25Hours = existing.paidBase25Minutes / 60
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
paymentForm.base50Hours = existing.paidBase50Minutes / 60
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
} else {
paymentForm.base25Hours = 0
paymentForm.bonus25Hours = 0
paymentForm.base50Hours = 0
paymentForm.bonus50Hours = 0
}
}
const openEditPayment = (month: number, rate: '25' | '50') => {
isEditMode.value = true
paymentForm.month = month
paymentForm.rate = rate
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
paymentForm.hours = currentMinutes / 60
watch(() => paymentForm.month, (newMonth) => {
prefillFromExistingPayment(newMonth)
})
const openPaymentDrawer = () => {
paymentForm.month = currentMonth.value
prefillFromExistingPayment(currentMonth.value)
isPaymentDrawerOpen.value = true
}
const onSubmitPayment = () => {
const minutes = Math.round(paymentForm.hours * 60)
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
emit(
'submit-rtt-payment',
paymentForm.month,
Math.round(paymentForm.base25Hours * 60),
Math.round(paymentForm.bonus25Hours * 60),
Math.round(paymentForm.base50Hours * 60),
Math.round(paymentForm.bonus50Hours * 60),
)
isPaymentDrawerOpen.value = false
}
</script>

View File

@@ -0,0 +1,344 @@
import type { Ref } from 'vue'
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const toast = useToast()
const contracts = ref<Contract[]>([])
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
hydrateSuspensionForms()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
})
isContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
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 reloadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
const loadContracts = async () => {
contracts.value = await listContracts()
}
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
}
})
return {
contracts,
contractHistory,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
loadContracts
}
}

View File

@@ -1,65 +1,15 @@
import type { Contract } from '~/services/dto/contract'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { listContracts } from '~/services/contracts'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
import { getEmployee, updateEmployee } from '~/services/employees'
import { listPublicHolidays } from '~/services/public-holidays'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
import { getEmployee } from '~/services/employees'
export const useEmployeeDetailPage = () => {
const route = useRoute()
const toast = useToast()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const contracts = ref<Contract[]>([])
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const rttSummary = ref<EmployeeRttSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
@@ -68,117 +18,6 @@ export const useEmployeeDetailPage = () => {
return contract.name || '-'
})
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const loadEmployee = async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
@@ -188,141 +27,42 @@ export const useEmployeeDetailPage = () => {
isLoading.value = true
try {
const loadedEmployee = await getEmployee(employeeId)
employee.value = loadedEmployee
employee.value = await getEmployee(employeeId)
const now = new Date()
const isForfait = loadedEmployee.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
const rttYear = now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()
const from = isForfait
? `${leaveYear}-01-01`
: `${leaveYear - 1}-06-01`
const to = isForfait
? `${leaveYear}-12-31`
: `${leaveYear}-05-31`
const holidayYears = isForfait
? [leaveYear]
: [leaveYear - 1, leaveYear]
const [absences, summary, rtt, ...holidayResults] = await Promise.all([
listAbsences({
from,
to,
employeeId: loadedEmployee.id
}),
showLeaveTab.value
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
: Promise.resolve(null),
getEmployeeRttSummary(loadedEmployee.id, rttYear),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
leaveSummary.value = summary
rttSummary.value = rtt
publicHolidays.value = Object.assign({}, ...holidayResults)
if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract'
}
if (!showRttTab.value && activeTab.value === 'rtt') {
activeTab.value = 'contract'
}
leave.resetLoaded()
rtt.resetLoaded()
if (activeTab.value === 'leave' && showLeaveTab.value) {
await leave.loadLeaveData()
} else if (activeTab.value === 'rtt' && showRttTab.value) {
await rtt.loadRttData()
}
} finally {
isLoading.value = false
}
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee)
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
})
isContractDrawerOpen.value = false
await loadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
})
isCreateContractDrawerOpen.value = false
await loadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updateFractionedDays(employee.value.id, days, year)
await loadEmployee()
}
const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
await createRttPayment(employee.value.id, month, minutes, rate, year)
await loadEmployee()
}
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
watch(activeTab, (tab) => {
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
leave.loadLeaveData()
} else if (tab === 'rtt' && !rtt.rttDataLoaded.value && showRttTab.value) {
rtt.loadRttData()
}
})
onMounted(async () => {
contracts.value = await listContracts()
await contract.loadContracts()
await loadEmployee()
})
@@ -330,44 +70,11 @@ export const useEmployeeDetailPage = () => {
employee,
isLoading,
activeTab,
contracts,
employeeAbsences,
leaveSummary,
rttSummary,
publicHolidays,
showLeaveTab,
contractHistory,
showRttTab,
employeeContractWorkLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitRttPayment
...contract,
...leave,
...rtt
}
}

View File

@@ -0,0 +1,70 @@
import type { Ref } from 'vue'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays'
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isLeaveLoading = ref(false)
const leaveDataLoaded = ref(false)
const getLeaveYear = () => {
const now = new Date()
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
return isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
}
const loadLeaveData = async () => {
if (!employee.value || isLeaveLoading.value) return
isLeaveLoading.value = true
try {
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = getLeaveYear()
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
const [absences, summary, ...holidayResults] = await Promise.all([
listAbsences({ from, to, employeeId: employee.value.id }),
getEmployeeLeaveSummary(employee.value.id, leaveYear),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
leaveSummary.value = summary
publicHolidays.value = Object.assign({}, ...holidayResults)
leaveDataLoaded.value = true
} finally {
isLeaveLoading.value = false
}
}
const resetLoaded = () => {
leaveDataLoaded.value = false
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updateFractionedDays(employee.value.id, days, year)
await reloadEmployee()
}
return {
employeeAbsences,
leaveSummary,
publicHolidays,
isLeaveLoading,
leaveDataLoaded,
loadLeaveData,
resetLoaded,
submitFractionedDays
}
}

View File

@@ -0,0 +1,42 @@
import type { Ref } from 'vue'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { Employee } from '~/services/dto/employee'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const rttSummary = ref<EmployeeRttSummary | null>(null)
const isRttLoading = ref(false)
const rttDataLoaded = ref(false)
const loadRttData = async () => {
if (!employee.value || isRttLoading.value) return
isRttLoading.value = true
try {
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
rttDataLoaded.value = true
} finally {
isRttLoading.value = false
}
}
const resetLoaded = () => {
rttDataLoaded.value = false
}
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year)
await reloadEmployee()
}
return {
rttSummary,
isRttLoading,
rttDataLoaded,
loadRttData,
resetLoaded,
submitRttPayment
}
}

View File

@@ -138,19 +138,17 @@ export const useHoursPage = () => {
return true
}
const canCreateValidationRowFromAbsence = (employeeId: number) => {
const canCreateEmptyValidationRow = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
if (!hasContractAtSelectedDate(employeeId)) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
return !!dayRow?.absenceLabel || is4hContract(employeeId)
}
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
}
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
const bulkValidatableEmployeeIds = computed(() => {
return visibleEmployees.value
@@ -347,6 +345,10 @@ export const useHoursPage = () => {
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const is4hContract = (employeeId: number) => {
const employee = employees.value.find((e) => e.id === employeeId)
return employee?.contract?.weeklyHours === 4
}
const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
@@ -692,13 +694,8 @@ export const useHoursPage = () => {
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
@@ -746,13 +743,8 @@ export const useHoursPage = () => {
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
if (canCreateEmptyValidationRow(employeeId)) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{

View File

@@ -7,68 +7,67 @@
</div>
<nav class="flex-1 px-4 pb-6">
<template v-if="isAdmin">
<NuxtLink
to="/"
class="hidden flex items-center gap-3 px-4 pb-3 pt-6 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
active-class="bg-tertiary-500 text-primary-500 font-bold"
>
Tableau de bord
</NuxtLink>
<NuxtLink
to="/calendar"
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
class="flex items-center gap-2 pb-2 pt-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
:class="route.path.startsWith('/calendar')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Calendrier
<Icon name="mdi:calendar-blank" size="24"/>
<p>Calendrier</p>
</NuxtLink>
</template>
<NuxtLink
to="/hours"
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/hours')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Heures
<Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p>
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink
to="/employees"
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/employees')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Employés
<Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p>
</NuxtLink>
<NuxtLink
to="/sites"
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/sites')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Sites
<Icon name="mdi:business" size="24"/>
<p>Sites</p>
</NuxtLink>
<NuxtLink
to="/absence-types"
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/absence-types')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Types d'absence
<Icon name="mdi:umbrella-beach-outline" size="24"/>
<p>Types d'absence</p>
</NuxtLink>
<NuxtLink
to="/users"
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
class="flex items-center gap-3 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/users')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Utilisateurs
<Icon name="mdi:account-outline" size="24"/>
<p>Utilisateurs</p>
</NuxtLink>
</template>
</nav>

View File

@@ -578,10 +578,6 @@ const handleSubmit = async () => {
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
return
}
if (hasHolidayInRange(start, end)) {
window.alert("Impossible de creer une absence sur un jour ferie.")
return
}
const overlaps = absences.value.filter((absence) => {
if (absence.employee?.id !== Number(form.employeeId)) return false
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false

View File

@@ -12,13 +12,16 @@
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
<div>
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
<div class="text-right">
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
<div class="mt-12 border-b border-primary-500">
<div class="mt-[44px] border-b border-primary-500">
<div class="flex justify-center gap-16 text-2xl font-bold">
<button
class="pb-2 border-b-2 flex items-center gap-3"
@@ -42,6 +45,7 @@
Congé
</button>
<button
v-if="showRttTab"
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'rtt'
? 'border-primary-500 text-primary-500'
@@ -88,16 +92,31 @@
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
:on-submit-close-contract="submitContractUpdate"
:on-submit-create-contract="submitCreateContract"
:suspension-forms="suspensionForms"
:is-suspension-submitting="isSuspensionSubmitting"
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
/>
<EmployeesLeaveTab
v-else-if="showLeaveTab && activeTab === 'leave'"
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
/>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesLeaveTab
v-else
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
/>
</div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
</div>
</div>
</div>
</div>
@@ -114,6 +133,7 @@ const {
rttSummary,
publicHolidays,
showLeaveTab,
showRttTab,
contractHistory,
employeeContractWorkLabel,
contractForm,
@@ -146,7 +166,14 @@ const {
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitRttPayment
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
isLeaveLoading,
isRttLoading
} = useEmployeeDetailPage()
useHead(() => ({

View File

@@ -51,6 +51,7 @@
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
</div>
</NuxtLink>

View File

@@ -68,7 +68,7 @@ const handleSubmit = async () => {
try {
await auth.login(username.value, password.value)
await router.push('/')
await router.push('/calendar')
} finally {
isSubmitting.value = false
}

View File

@@ -0,0 +1,38 @@
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', {
contractPeriodId: payload.contractPeriodId,
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'Suspension créée',
toastErrorKey: 'Erreur lors de la création de la suspension'
})
}
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: 'Suspension modifiée',
toastErrorKey: 'Erreur lors de la modification de la suspension'
})
}

View File

@@ -10,5 +10,9 @@ export type EmployeeLeaveSummary = {
takenSaturdays: number
fractionedDays: number
accruingDays: number
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
presenceDaysByMonth: Record<string, number>
}

View File

@@ -3,22 +3,33 @@ export type EmployeeRttWeekSummary = {
weekNumber: number
weekStart: string
weekEnd: string
recoveryMinutes: number
overtimeMinutes: number
base25Minutes: number
bonus25Minutes: number
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
}
export type RttMonthPayment = {
month: number
paidMinutes25: number
paidMinutes50: number
paidBase25Minutes: number
paidBonus25Minutes: number
paidBase50Minutes: number
paidBonus50Minutes: number
}
export type EmployeeRttSummary = {
year: number
carryMonth: number
carryFromPreviousYearMinutes: number
carryBase25Minutes: number
carryBonus25Minutes: number
carryBase50Minutes: number
carryBonus50Minutes: number
currentYearRecoveryMinutes: number
totalPaidMinutes: number
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
}

View File

@@ -1,6 +1,13 @@
import type { Site } from './site'
import type { Contract } from './contract'
export type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
export type ContractHistoryItem = {
contractId?: number | null
contractName?: string | null
@@ -9,6 +16,8 @@ export type ContractHistoryItem = {
startDate: string
endDate?: string | null
comment?: string | null
periodId?: number | null
suspensions?: ContractSuspension[]
}
export type Employee = {
@@ -22,4 +31,6 @@ export type Employee = {
currentContractEndDate?: string | null
contractHistory?: ContractHistoryItem[]
displayOrder?: number
entryDate?: string | null
currentSuspensions?: ContractSuspension[]
}

View File

@@ -6,9 +6,17 @@ export const getEmployeeRttSummary = async (employeeId: number, year?: number) =
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
}
export const createRttPayment = async (employeeId: number, month: number, minutes: number, rate: '25' | '50', year?: number) => {
export const createRttPayment = async (
employeeId: number,
month: number,
base25Minutes: number,
bonus25Minutes: number,
base50Minutes: number,
bonus50Minutes: number,
year?: number
) => {
const api = useApi()
const body: Record<string, unknown> = { month, minutes, rate }
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260312120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add entry_date column to employees table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP entry_date');
}
}

View File

@@ -0,0 +1,34 @@
<?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');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313080007 extends AbstractMigration
{
public function getDescription(): string
{
return 'RTT redesign: split opening_minutes and minutes+rate into 4 fields (base25, bonus25, base50, bonus50)';
}
public function up(Schema $schema): void
{
// employee_rtt_balances: replace opening_minutes with 4 fields
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_base25_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_bonus25_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_base50_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_bonus50_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_minutes');
// employee_rtt_payments: replace minutes+rate with 4 fields
$this->addSql('DROP INDEX IF EXISTS uniq_rtt_payment_employee_year_month_rate');
$this->addSql('ALTER TABLE employee_rtt_payments ADD base25_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_payments ADD bonus25_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_payments ADD base50_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_payments ADD bonus50_minutes INT DEFAULT 0 NOT NULL');
$this->addSql('ALTER TABLE employee_rtt_payments DROP minutes');
$this->addSql('ALTER TABLE employee_rtt_payments DROP rate');
}
public function down(Schema $schema): void
{
// employee_rtt_balances: restore opening_minutes
$this->addSql('ALTER TABLE employee_rtt_balances ADD opening_minutes INT NOT NULL DEFAULT 0');
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_base25_minutes');
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_bonus25_minutes');
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_base50_minutes');
$this->addSql('ALTER TABLE employee_rtt_balances DROP opening_bonus50_minutes');
// employee_rtt_payments: restore minutes+rate
$this->addSql('ALTER TABLE employee_rtt_payments ADD minutes INT NOT NULL DEFAULT 0');
$this->addSql("ALTER TABLE employee_rtt_payments ADD rate VARCHAR(10) NOT NULL DEFAULT '25'");
$this->addSql('ALTER TABLE employee_rtt_payments DROP base25_minutes');
$this->addSql('ALTER TABLE employee_rtt_payments DROP bonus25_minutes');
$this->addSql('ALTER TABLE employee_rtt_payments DROP base50_minutes');
$this->addSql('ALTER TABLE employee_rtt_payments DROP bonus50_minutes');
$this->addSql('CREATE UNIQUE INDEX uniq_rtt_payment_employee_year_month_rate ON employee_rtt_payments (employee_id, year, month, rate)');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260313092249 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add month column to employee_rtt_balances for flexible carry-over positioning';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_rtt_balances ADD month INT DEFAULT 5 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_rtt_balances DROP month');
}
}

View File

@@ -20,15 +20,21 @@ use App\State\EmployeeLeaveSummaryProvider;
)]
final class EmployeeLeaveSummary
{
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
public int $year = 0;
public bool $isSupported = false;
public string $ruleCode = '';
public float $acquiredDays = 0.0;
public float $remainingDays = 0.0;
public float $takenDays = 0.0;
public float $acquiredSaturdays = 0.0;
public float $remainingSaturdays = 0.0;
public float $takenSaturdays = 0.0;
public float $fractionedDays = 0.0;
public float $accruingDays = 0.0;
public float $previousYearAcquiredDays = 0.0;
public float $previousYearTakenDays = 0.0;
public float $previousYearRemainingDays = 0.0;
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = [];
}

View File

@@ -22,8 +22,10 @@ use App\State\EmployeeRttPaymentProvider;
)]
final class EmployeeRttPaymentInput
{
public int $month = 0;
public int $minutes = 0;
public string $rate = '25';
public ?int $year = null;
public int $month = 0;
public int $base25Minutes = 0;
public int $bonus25Minutes = 0;
public int $base50Minutes = 0;
public int $bonus50Minutes = 0;
public ?int $year = null;
}

View File

@@ -23,7 +23,12 @@ use App\State\EmployeeRttSummaryProvider;
final class EmployeeRttSummary
{
public int $year = 0;
public int $carryMonth = 5;
public int $carryFromPreviousYearMinutes = 0;
public int $carryBase25Minutes = 0;
public int $carryBonus25Minutes = 0;
public int $carryBase50Minutes = 0;
public int $carryBonus50Minutes = 0;
public int $currentYearRecoveryMinutes = 0;
public int $availableMinutes = 0;
public int $totalPaidMinutes = 0;

View File

@@ -92,7 +92,7 @@ final class RttRolloverCommand extends Command
try {
$previousYear = $targetYear - 1;
$carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
} catch (Throwable $e) {
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
++$skipped;
@@ -103,12 +103,15 @@ final class RttRolloverCommand extends Command
$balance = new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningMinutes($carryMinutes)
->setOpeningBase25Minutes($carry->base25Minutes)
->setOpeningBonus25Minutes($carry->bonus25Minutes)
->setOpeningBase50Minutes($carry->base50Minutes)
->setOpeningBonus50Minutes($carry->bonus50Minutes)
->setIsLocked(false)
;
$this->entityManager->persist($balance);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carryMinutes]);
$this->logger->info('Balance created.', ['employeeId' => $employee->getId(), 'year' => $targetYear, 'carryMinutes' => $carry->totalMinutes]);
++$created;
}

View File

@@ -23,5 +23,9 @@ final class ContractHistoryItem
public ?string $endDate,
#[Groups(['employee:read'])]
public ?string $comment = null,
#[Groups(['employee:read'])]
public ?int $periodId = null,
#[Groups(['employee:read'])]
public array $suspensions = [],
) {}
}

View File

@@ -11,6 +11,11 @@ final class EmployeeRttWeekSummary
public int $weekNumber,
public string $weekStart,
public string $weekEnd,
public int $recoveryMinutes,
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}

View File

@@ -8,7 +8,9 @@ final class RttMonthPayment
{
public function __construct(
public int $month,
public int $paidMinutes25 = 0,
public int $paidMinutes50 = 0,
public int $paidBase25Minutes = 0,
public int $paidBonus25Minutes = 0,
public int $paidBase50Minutes = 0,
public int $paidBonus50Minutes = 0,
) {}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Dto\Rtt;
final class WeekRecoveryDetail
{
public function __construct(
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}

View File

@@ -0,0 +1,140 @@
<?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')]
private ?EmployeeContractPeriod $contractPeriod = null;
#[Groups(['suspension:write'])]
private ?int $contractPeriodId = 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 getContractPeriodId(): ?int
{
return $this->contractPeriodId;
}
public function setContractPeriodId(?int $contractPeriodId): self
{
$this->contractPeriodId = $contractPeriodId;
return $this;
}
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;
}
}

View File

@@ -14,7 +14,9 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
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(
normalizationContext: ['groups' => ['employee:read', 'site:read']],
@@ -57,6 +59,11 @@ class Employee
#[Groups(['employee:read', 'employee:write'])]
private int $displayOrder = 0;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['employee:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $entryDate = null;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -166,6 +173,18 @@ class Employee
return $this;
}
public function getEntryDate(): ?DateTimeImmutable
{
return $this->entryDate;
}
public function setEntryDate(?DateTimeImmutable $entryDate): self
{
$this->entryDate = $entryDate;
return $this;
}
public function getContractNature(): ?string
{
return $this->contractNature;
@@ -244,6 +263,36 @@ class Employee
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
/**
* @return list<array{id: null|int, startDate: string, endDate: null|string, comment: null|string}>
*/
#[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()
));
}
/**
* @return Collection<int, EmployeeContractPeriod>
*/
public function getContractPeriods(): Collection
{
return $this->contractPeriods;
}
/**
* @return list<ContractHistoryItem>
*/
@@ -260,6 +309,16 @@ class Employee
static function (EmployeeContractPeriod $period): ContractHistoryItem {
$contract = $period->getContract();
$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(),
@@ -268,6 +327,8 @@ class Employee
startDate: $period->getStartDate()->format('Y-m-d'),
endDate: $period->getEndDate()?->format('Y-m-d'),
comment: $period->getComment(),
periodId: $period->getId(),
suspensions: $suspensionData,
);
},
$periods

View File

@@ -7,6 +7,8 @@ namespace App\Entity;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
@@ -43,13 +45,20 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null;
/**
* @var Collection<int, ContractSuspension>
*/
#[ORM\OneToMany(mappedBy: 'contractPeriod', targetEntity: ContractSuspension::class, cascade: ['persist', 'remove'])]
private Collection $suspensions;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->startDate = new DateTimeImmutable('today');
$this->createdAt = new DateTimeImmutable();
$this->startDate = new DateTimeImmutable('today');
$this->suspensions = new ArrayCollection();
}
public function getId(): ?int
@@ -151,4 +160,12 @@ class EmployeeContractPeriod
return $this;
}
/**
* @return Collection<int, ContractSuspension>
*/
public function getSuspensions(): Collection
{
return $this->suspensions;
}
}

View File

@@ -26,8 +26,20 @@ class EmployeeRttBalance
#[ORM\Column(type: 'integer', options: ['comment' => 'Annee d exercice (year = annee de fin, ex: 2026 = 01/06/2025 -> 31/05/2026).'])]
private int $year = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])]
private int $openingMinutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois de fin du report (1-12). Le report s affiche dans le mois suivant.', 'default' => 5])]
private int $month = 5;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])]
private int $openingBase25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])]
private int $openingBonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])]
private int $openingBase50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])]
private int $openingBonus50Minutes = 0;
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])]
private bool $isLocked = false;
@@ -74,18 +86,71 @@ class EmployeeRttBalance
return $this;
}
public function getOpeningMinutes(): int
public function getMonth(): int
{
return $this->openingMinutes;
return $this->month;
}
public function setOpeningMinutes(int $openingMinutes): self
public function setMonth(int $month): self
{
$this->openingMinutes = $openingMinutes;
$this->month = $month;
return $this;
}
public function getOpeningBase25Minutes(): int
{
return $this->openingBase25Minutes;
}
public function setOpeningBase25Minutes(int $openingBase25Minutes): self
{
$this->openingBase25Minutes = $openingBase25Minutes;
return $this;
}
public function getOpeningBonus25Minutes(): int
{
return $this->openingBonus25Minutes;
}
public function setOpeningBonus25Minutes(int $openingBonus25Minutes): self
{
$this->openingBonus25Minutes = $openingBonus25Minutes;
return $this;
}
public function getOpeningBase50Minutes(): int
{
return $this->openingBase50Minutes;
}
public function setOpeningBase50Minutes(int $openingBase50Minutes): self
{
$this->openingBase50Minutes = $openingBase50Minutes;
return $this;
}
public function getOpeningBonus50Minutes(): int
{
return $this->openingBonus50Minutes;
}
public function setOpeningBonus50Minutes(int $openingBonus50Minutes): self
{
$this->openingBonus50Minutes = $openingBonus50Minutes;
return $this;
}
public function getTotalOpeningMinutes(): int
{
return $this->openingBase25Minutes + $this->openingBonus25Minutes + $this->openingBase50Minutes + $this->openingBonus50Minutes;
}
public function isLocked(): bool
{
return $this->isLocked;

View File

@@ -28,11 +28,17 @@ class EmployeeRttPayment
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
private int $month = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])]
private int $minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
private int $base25Minutes = 0;
#[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])]
private string $rate = '';
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
private int $bonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])]
private int $base50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])]
private int $bonus50Minutes = 0;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
@@ -88,26 +94,50 @@ class EmployeeRttPayment
return $this;
}
public function getMinutes(): int
public function getBase25Minutes(): int
{
return $this->minutes;
return $this->base25Minutes;
}
public function setMinutes(int $minutes): self
public function setBase25Minutes(int $base25Minutes): self
{
$this->minutes = $minutes;
$this->base25Minutes = $base25Minutes;
return $this;
}
public function getRate(): string
public function getBonus25Minutes(): int
{
return $this->rate;
return $this->bonus25Minutes;
}
public function setRate(string $rate): self
public function setBonus25Minutes(int $bonus25Minutes): self
{
$this->rate = $rate;
$this->bonus25Minutes = $bonus25Minutes;
return $this;
}
public function getBase50Minutes(): int
{
return $this->base50Minutes;
}
public function setBase50Minutes(int $base50Minutes): self
{
$this->base50Minutes = $base50Minutes;
return $this;
}
public function getBonus50Minutes(): int
{
return $this->bonus50Minutes;
}
public function setBonus50Minutes(int $bonus50Minutes): self
{
$this->bonus50Minutes = $bonus50Minutes;
return $this;
}

View File

@@ -0,0 +1,20 @@
<?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);
}
}

View File

@@ -19,13 +19,12 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
parent::__construct($registry, EmployeeRttPayment::class);
}
public function findOneByEmployeeYearMonthRate(Employee $employee, int $year, int $month, string $rate): ?EmployeeRttPayment
public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment
{
return $this->findOneBy([
'employee' => $employee,
'year' => $year,
'month' => $month,
'rate' => $rate,
]);
}

View File

@@ -138,6 +138,59 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return $qb->getQuery()->getOneOrNullResult();
}
/**
* Count weekend worked days by month.
* >= 5h total = 1.0 day, < 5h = 0.5 day.
*
* @return array<string, float> YYYY-MM => weekend worked day count
*/
public function countWeekendWorkedDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$sql = <<<'SQL'
SELECT TO_CHAR(work_date, 'YYYY-MM') AS month,
SUM(
CASE
WHEN total_minutes >= 300 THEN 1.0
WHEN total_minutes > 0 THEN 0.5
ELSE 0
END
) AS cnt
FROM (
SELECT work_date,
COALESCE(
EXTRACT(EPOCH FROM (morning_to::time - morning_from::time)) / 60, 0
)
+ COALESCE(
EXTRACT(EPOCH FROM (afternoon_to::time - afternoon_from::time)) / 60, 0
)
+ COALESCE(
EXTRACT(EPOCH FROM (evening_to::time - evening_from::time)) / 60, 0
) AS total_minutes
FROM work_hours
WHERE employee_id = :employee
AND work_date >= :from
AND work_date <= :to
AND EXTRACT(ISODOW FROM work_date) IN (6, 7)
AND (morning_from IS NOT NULL OR afternoon_from IS NOT NULL OR evening_from IS NOT NULL)
) sub
GROUP BY month
SQL;
$conn = $this->getEntityManager()->getConnection();
$rows = $conn->fetchAllAssociative($sql, [
'employee' => $employee->getId(),
'from' => $from->format('Y-m-d'),
'to' => $to->format('Y-m-d'),
]);
$result = [];
foreach ($rows as $row) {
$result[(string) $row['month']] = (float) $row['cnt'];
}
return $result;
}
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
{
$workDate = DateTimeImmutable::createFromInterface($date);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Absence;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Enum\LeaveRuleCode;
use App\Repository\AbsenceRepository;
@@ -29,6 +30,7 @@ final readonly class LeaveBalanceComputationService
private EmployeeContractPeriodRepository $periodRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
) {}
/**
@@ -67,7 +69,9 @@ final readonly class LeaveBalanceComputationService
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
$totalBusinessDays = $this->countBusinessDays($from, $to);
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
@@ -76,17 +80,22 @@ final readonly class LeaveBalanceComputationService
continue;
}
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForEmployeePeriod($employee, $from, $to)
);
$generatedDays = $this->computeAccruedDays(
$this->resolveAnnualDays($employee),
$this->resolveDaysAccrualPerMonth($employee),
$effectiveFrom,
$to
$to,
$suspensions
);
$generatedSaturdays = $this->computeAccruedDays(
$this->resolveAnnualSaturdays($employee),
$this->resolveSaturdayAccrualPerMonth($employee),
$effectiveFrom,
$to
$to,
$suspensions
);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
@@ -262,7 +271,8 @@ final readonly class LeaveBalanceComputationService
float $annualCap,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
DateTimeImmutable $periodEnd
DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
return 0.0;
@@ -280,6 +290,10 @@ final readonly class LeaveBalanceComputationService
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
if ([] !== $suspensions) {
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
}
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
@@ -404,6 +418,31 @@ final readonly class LeaveBalanceComputationService
return [$takenDays, $takenSaturdays];
}
/**
* @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;
}
/**
* @return array{bool, bool}
*/

View File

@@ -0,0 +1,106 @@
<?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;
}
/**
* Return adjusted suspensions where the first month of each suspension is excluded (grace period).
*
* @param list<ContractSuspension> $suspensions
*
* @return list<ContractSuspension>
*/
public function applyFirstMonthGrace(array $suspensions): array
{
$adjusted = [];
foreach ($suspensions as $suspension) {
$gracedStart = $suspension->getStartDate()->modify('+1 month');
$end = $suspension->getEndDate();
if ($end instanceof DateTimeImmutable && $gracedStart > $end) {
continue;
}
$copy = new ContractSuspension();
$copy->setStartDate($gracedStart);
$copy->setEndDate($end);
$adjusted[] = $copy;
}
return $adjusted;
}
/**
* 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;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\Rtt;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Contract;
use App\Entity\Employee;
@@ -70,7 +71,7 @@ final readonly class RttRecoveryComputationService
return $weeks;
}
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): int
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
@@ -86,13 +87,25 @@ final readonly class RttRecoveryComputationService
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
return array_sum($byWeek);
$total = new WeekRecoveryDetail();
foreach ($byWeek as $detail) {
$total = new WeekRecoveryDetail(
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
);
}
return $total;
}
/**
* @param list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
*
* @return array<string, int>
* @return array<string, WeekRecoveryDetail>
*/
public function computeRecoveryByWeek(
Employee $employee,
@@ -148,13 +161,13 @@ final readonly class RttRecoveryComputationService
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
if ($effectiveEnd < $effectiveStart) {
$results[$weekKey] = 0;
$results[$weekKey] = new WeekRecoveryDetail();
continue;
}
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
$results[$weekKey] = 0;
$results[$weekKey] = new WeekRecoveryDetail();
continue;
}
@@ -177,7 +190,7 @@ final readonly class RttRecoveryComputationService
}
if ([] === $weekDays) {
$results[$weekKey] = 0;
$results[$weekKey] = new WeekRecoveryDetail();
continue;
}
@@ -191,15 +204,22 @@ final readonly class RttRecoveryComputationService
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
$results[$weekKey] = ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
);
}
return $results;

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DatePeriod;
use DateTime;
@@ -22,6 +23,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
@@ -30,6 +32,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -132,10 +135,15 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
}
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$publicHolidays = $this->buildPublicHolidayMap($start, $end);
$segments = [];
foreach ($days as $day) {
if (isset($publicHolidays[$day->format('Y-m-d')])) {
continue;
}
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
$isSame = $isFirst && $isLast;
@@ -246,4 +254,27 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setIsValid(false)
;
}
/**
* @return array<string, string>
*/
private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
}

View File

@@ -0,0 +1,122 @@
<?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 && null !== $data->getContractPeriodId()) {
$period = $this->entityManager->find(EmployeeContractPeriod::class, $data->getContractPeriodId());
if ($period instanceof EmployeeContractPeriod) {
$data->setContractPeriod($period);
}
}
if (!$period instanceof EmployeeContractPeriod) {
throw new UnprocessableEntityHttpException('contractPeriodId is required.');
}
$this->validate($data, $period);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void
{
// Compare as Y-m-d strings to avoid timezone issues between Doctrine and API Platform DateTimeImmutable
$startDate = $suspension->getStartDate()->format('Y-m-d');
$endDate = $suspension->getEndDate()?->format('Y-m-d');
$periodStart = $period->getStartDate()->format('Y-m-d');
$periodEnd = $period->getEndDate()?->format('Y-m-d');
if (null !== $periodEnd && $periodEnd < new DateTimeImmutable('today')->format('Y-m-d')) {
throw new UnprocessableEntityHttpException('Impossible de suspendre une période de contrat clôturée.');
}
if (null !== $endDate && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('La date de fin doit être postérieure à la date de début.');
}
if ($startDate < $periodStart) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer avant le début du contrat.');
}
if (null !== $periodEnd) {
if ($startDate > $periodEnd) {
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer après la fin du contrat.');
}
if (null !== $endDate && $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()->format('Y-m-d');
$end = $suspension->getEndDate()?->format('Y-m-d');
foreach ($period->getSuspensions() as $existing) {
if ($existing->getId() === $suspension->getId() && null !== $suspension->getId()) {
continue;
}
$existingStart = $existing->getStartDate()->format('Y-m-d');
$existingEnd = $existing->getEndDate()?->format('Y-m-d');
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.');
}
}
}
}

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveSummary;
use App\Entity\Absence;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractNature;
@@ -17,8 +18,10 @@ use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\SuspensionDaysCalculator;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
@@ -50,6 +53,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private LeaveBalanceComputationService $leaveBalanceComputationService,
private PublicHolidayServiceInterface $publicHolidayService,
private SuspensionDaysCalculator $suspensionDaysCalculator,
private WorkHourRepository $workHourRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
@@ -86,16 +91,22 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
$summary->isSupported = true;
$summary->ruleCode = $yearSummary['ruleCode'];
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
$summary->fractionedDays = $fractionedDays;
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->takenDays = $yearSummary['takenDays'];
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
$summary->isSupported = true;
$summary->ruleCode = $yearSummary['ruleCode'];
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
$summary->fractionedDays = $fractionedDays;
$summary->accruingDays = $yearSummary['accruingDays'];
$summary->takenDays = $yearSummary['takenDays'];
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
return $summary;
}
@@ -109,7 +120,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* takenDays: float,
* takenSaturdays: float,
* remainingDays: float,
* remainingSaturdays: float
* remainingSaturdays: float,
* previousYearAcquiredDays: float,
* previousYearTakenDays: float,
* previousYearRemainingDays: float
* }
*/
private function computeYearSummary(Employee $employee, int $targetYear): ?array
@@ -170,12 +184,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
);
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
? $this->computeAccruedDaysFromStart(
$leavePolicy['acquiredDays'],
$leavePolicy['accrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd
$accrualCalculationEnd,
$suspensions
)
: 0.0;
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
@@ -183,7 +201,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$leavePolicy['acquiredSaturdays'],
$leavePolicy['saturdayAccrualPerMonth'],
$effectiveFrom,
$accrualCalculationEnd
$accrualCalculationEnd,
$suspensions
)
: 0.0;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
@@ -201,6 +220,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$takenDays += $openingBalance->getTakenDays();
$takenSaturdays += $openingBalance->getTakenSaturdays();
}
$previousYearAcquired = 0.0;
$previousYearTaken = 0.0;
$previousYearRemaining = 0.0;
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
$availableAcquired = max(0.0, $carryDays);
$takenFromAcquired = min($availableAcquired, $takenDays);
@@ -224,26 +247,38 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
} else {
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
// Suspensions do not impact forfait 218 leave calculation.
// Taken days are first deducted from N-1 carry, then from current year.
$previousYearAcquired = $carryDays;
$takenFromPrevious = min(max(0.0, $previousYearAcquired), $takenDays);
$previousYearTaken = $takenFromPrevious;
$takenFromCurrent = $takenDays - $takenFromPrevious;
$previousYearRemaining = max(0.0, $previousYearAcquired - $takenFromPrevious);
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
$remainingDays = max(0.0, $acquiredDays - $takenDays);
$remainingDays = max(0.0, $acquiredDays - $takenFromCurrent);
$acquiredSaturdays = 0.0;
$remainingSaturdays = 0.0;
$previousRemainingDays = $remainingDays;
$previousRemainingDays = $previousYearRemaining + $remainingDays;
$previousRemainingSaturdays = 0.0;
}
if ($year === $targetYear) {
$targetSummary = [
'ruleCode' => $leavePolicy['ruleCode'],
'acquiredDays' => $acquiredDays,
'acquiredSaturdays' => $acquiredSaturdays,
'accruingDays' => $accruingDays,
'takenDays' => $takenDays,
'takenSaturdays' => $takenSaturdays,
'remainingDays' => $remainingDays,
'remainingSaturdays' => $remainingSaturdays,
'ruleCode' => $leavePolicy['ruleCode'],
'acquiredDays' => $acquiredDays,
'acquiredSaturdays' => $acquiredSaturdays,
'accruingDays' => $accruingDays,
'takenDays' => $takenDays,
'takenSaturdays' => $takenSaturdays,
'remainingDays' => $remainingDays,
'remainingSaturdays' => $remainingSaturdays,
'previousYearAcquiredDays' => $previousYearAcquired,
'previousYearTakenDays' => $previousYearTaken,
'previousYearRemainingDays' => $previousYearRemaining,
];
}
}
@@ -334,7 +369,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
float $acquiredDays,
float $accrualPerMonth,
DateTimeImmutable $periodStart,
?DateTimeImmutable $periodEnd
?DateTimeImmutable $periodEnd,
array $suspensions = []
): float {
if ($accrualPerMonth <= 0.0) {
return $acquiredDays;
@@ -356,6 +392,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
}
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
if ([] !== $suspensions) {
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
$coveredDays = max(0, $coveredDays - $suspendedDays);
}
$daysInMonth = (int) $cursor->format('t');
$coveredMonths += $coveredDays / $daysInMonth;
@@ -514,6 +554,66 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $map;
}
/**
* Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days.
*
* @return array<string, float> YYYY-MM => presence day count
*/
private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
// Count absence days per month (0.5 for half-days).
$absenceDaysByMonth = [];
foreach ($absences as $absence) {
$date = DateTimeImmutable::createFromInterface($absence->getStartDate());
$monthKey = $date->format('Y-m');
$days = 1.0;
if ($absence->getStartHalf() === $absence->getEndHalf()) {
$days = 0.5;
}
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $days;
}
// Count business days and public holidays per month.
$result = [];
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthKey = $cursor->format('Y-m');
$monthStart = $cursor < $from ? $from : $cursor;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$businessDays = 0;
for (
$day = $monthStart;
$day <= $monthEnd;
$day = $day->modify('+1 day')
) {
$weekDay = (int) $day->format('N');
if ($weekDay <= 5 && !isset($publicHolidays[$day->format('Y-m-d')])) {
++$businessDays;
}
}
$weekend = $weekendWorkedDays[$monthKey] ?? 0.0;
$absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;
$presence = max(0.0, (float) $businessDays + $weekend - $absenced);
if ($presence > 0.0) {
$result[$monthKey] = $presence;
}
$cursor = $cursor->modify('first day of next month');
}
return $result;
}
/**
* @return array{DateTimeImmutable, DateTimeImmutable}
*/
@@ -706,6 +806,31 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return [$takenDays, $takenSaturdays];
}
/**
* @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;
}
/**
* @return array{bool, bool}
*/

View File

@@ -16,8 +16,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use function in_array;
final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
{
public function __construct(
@@ -42,32 +40,27 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
throw new NotFoundHttpException('Employee not found.');
}
if (!in_array($data->rate, ['25', '50'], true)) {
throw new UnprocessableEntityHttpException('rate must be "25" or "50".');
}
if ($data->month < 1 || $data->month > 12) {
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
}
if ($data->minutes < 0) {
throw new UnprocessableEntityHttpException('minutes must be >= 0.');
}
$year = $data->year ?? $this->resolveCurrentExerciseYear();
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonthRate($employee, $year, $data->month, $data->rate);
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
if (null === $payment) {
$payment = new EmployeeRttPayment();
$payment->setEmployee($employee);
$payment->setYear($year);
$payment->setMonth($data->month);
$payment->setRate($data->rate);
$this->entityManager->persist($payment);
}
$payment->setMinutes($data->minutes);
$payment->setBase25Minutes($data->base25Minutes);
$payment->setBonus25Minutes($data->bonus25Minutes);
$payment->setBase50Minutes($data->base50Minutes);
$payment->setBonus50Minutes($data->bonus50Minutes);
$payment->touch();
$this->entityManager->flush();
$data->year = $year;

View File

@@ -9,6 +9,7 @@ use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeRttSummary;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Dto\Rtt\RttMonthPayment;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Entity\Employee;
use App\Entity\User;
use App\Repository\EmployeeRepository;
@@ -76,22 +77,36 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$limitDate = $periodFrom->modify('-1 day');
}
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
$carryMinutes = $this->resolveCarryMinutes($employee, $year);
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
$summary = new EmployeeRttSummary();
$summary->year = $year;
$summary->carryFromPreviousYearMinutes = $carryMinutes;
$summary->currentYearRecoveryMinutes = array_sum($currentByWeekStart);
$summary->carryMonth = $carryMonth;
$summary->carryFromPreviousYearMinutes = $carry->totalMinutes;
$summary->carryBase25Minutes = $carry->base25Minutes;
$summary->carryBonus25Minutes = $carry->bonus25Minutes;
$summary->carryBase50Minutes = $carry->base50Minutes;
$summary->carryBonus50Minutes = $carry->bonus50Minutes;
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
$summary->weeks = array_map(
static fn (array $week) => new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
recoveryMinutes: (int) ($currentByWeekStart[$week['start']->format('Y-m-d')] ?? 0),
),
static function (array $week) use ($currentByWeekStart) {
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
return new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
},
$weekRanges
);
@@ -101,21 +116,20 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
foreach ($payments as $payment) {
$m = $payment->getMonth();
if (!isset($monthBuckets[$m])) {
$monthBuckets[$m] = ['paidMinutes25' => 0, 'paidMinutes50' => 0];
}
if ('25' === $payment->getRate()) {
$monthBuckets[$m]['paidMinutes25'] += $payment->getMinutes();
} else {
$monthBuckets[$m]['paidMinutes50'] += $payment->getMinutes();
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
}
$monthPayments = [];
$totalPaidMinutes = 0;
foreach ($monthBuckets as $m => $bucket) {
$monthPayments[] = new RttMonthPayment($m, $bucket['paidMinutes25'], $bucket['paidMinutes50']);
$totalPaidMinutes += $bucket['paidMinutes25'] + $bucket['paidMinutes50'];
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
}
$summary->totalPaidMinutes = $totalPaidMinutes;
@@ -125,14 +139,29 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
return $summary;
}
private function resolveCarryMinutes(Employee $employee, int $year): int
/**
* @return array{WeekRecoveryDetail, int} [carry, month]
*/
private function resolveCarry(Employee $employee, int $year): array
{
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
if (null !== $balance) {
return $balance->getOpeningMinutes();
return [
new WeekRecoveryDetail(
base25Minutes: $balance->getOpeningBase25Minutes(),
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
base50Minutes: $balance->getOpeningBase50Minutes(),
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
totalMinutes: $balance->getTotalOpeningMinutes(),
),
$balance->getMonth(),
];
}
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
return [
$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1),
5,
];
}
private function resolveYear(): int

View File

@@ -68,6 +68,9 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
nature: $nature
);
$data->setEntryDate($startDate);
$this->entityManager->flush();
return $result;
}

View File

@@ -134,13 +134,15 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
continue;
}
$is4hContract = 4 === $contract->getWeeklyHours();
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {
$this->entityManager->remove($existing);
++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true) {
// Si une absence existe ce jour, on garde une ligne technique pour pouvoir valider la journée.
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
// Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour()
->setEmployee($employee)
->setWorkDate($workDate)

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\SuspensionDaysCalculator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
@@ -16,7 +17,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
{
public function testComputeAccruedDaysProratesPartialFirstMonth(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$service = $this->createServiceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
@@ -32,7 +33,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
public function testComputeAccruedDaysTotalMatchesAlainCase(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$service = $this->createServiceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$days = $method->invoke(
@@ -55,7 +56,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void
{
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
$service = $this->createServiceWithoutConstructor();
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
$result = $method->invoke(
@@ -68,4 +69,15 @@ final class LeaveBalanceComputationServiceTest extends TestCase
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
private function createServiceWithoutConstructor(): LeaveBalanceComputationService
{
$rc = new ReflectionClass(LeaveBalanceComputationService::class);
$service = $rc->newInstanceWithoutConstructor();
$prop = $rc->getProperty('suspensionDaysCalculator');
$prop->setValue($service, new SuspensionDaysCalculator());
return $service;
}
}

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Leave;
use App\Entity\ContractSuspension;
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);
}
public function testFirstMonthGraceShiftsStartByOneMonth(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-15', '2026-06-30');
$result = $calc->applyFirstMonthGrace([$suspension]);
self::assertCount(1, $result);
self::assertEquals(new DateTimeImmutable('2026-04-15'), $result[0]->getStartDate());
self::assertEquals(new DateTimeImmutable('2026-06-30'), $result[0]->getEndDate());
}
public function testFirstMonthGraceRemovesSuspensionShorterThanOneMonth(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-10', '2026-03-25');
$result = $calc->applyFirstMonthGrace([$suspension]);
self::assertCount(0, $result);
}
public function testFirstMonthGraceOpenEndedSuspension(): void
{
$calc = new SuspensionDaysCalculator();
$suspension = $this->buildSuspension('2026-03-01', null);
$result = $calc->applyFirstMonthGrace([$suspension]);
self::assertCount(1, $result);
self::assertEquals(new DateTimeImmutable('2026-04-01'), $result[0]->getStartDate());
self::assertNull($result[0]->getEndDate());
}
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;
}
}

View File

@@ -14,6 +14,7 @@ use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\PublicHolidayServiceInterface;
use App\State\AbsenceWriteProcessor;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
@@ -35,7 +36,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
@@ -63,7 +64,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -84,7 +85,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -106,7 +107,7 @@ final class AbsenceWriteProcessorTest extends TestCase
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
@@ -140,4 +141,12 @@ final class AbsenceWriteProcessorTest extends TestCase
return $security;
}
private function createEmptyHolidayServiceStub(): PublicHolidayServiceInterface
{
$service = $this->createStub(PublicHolidayServiceInterface::class);
$service->method('getHolidaysDayByYears')->willReturn([]);
return $service;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\ContractSuspension;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\State\ContractSuspensionWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @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(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(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(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(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;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Tests\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract;
use App\Entity\Employee;
@@ -194,6 +195,54 @@ final class EmployeeWriteProcessorTest extends TestCase
self::assertSame($employee, $result);
}
public function testSetsEntryDateOnNewEmployee(): void
{
$employee = new Employee();
$employee->setFirstName('Jane');
$employee->setLastName('Doe');
$employee->setContractStartDate('2026-04-01');
$employee->setContractNature('CDI');
$contract = new Contract()
->setName('35h')
->setTrackingMode(Contract::TRACKING_TIME)
->setWeeklyHours(35)
;
$employee->setContract($contract);
$persistProcessor = $this->createMock(ProcessorInterface::class);
$removeProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
$persistProcessor
->expects(self::once())
->method('process')
->willReturn($employee)
;
$periodManager
->expects(self::once())
->method('ensureContractPeriodExists')
;
$processor = new EmployeeWriteProcessor(
$persistProcessor,
$removeProcessor,
$entityManager,
$periodRepository,
$changeRequestFactory,
$periodManager
);
$processor->process($employee, new Post());
self::assertNotNull($employee->getEntryDate());
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
}
public function testDeleteOperationDelegatesToRemoveProcessor(): void
{
$employee = $this->buildEmployeeWithId(45);