Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff824f233a | ||
| c4c9dfceab | |||
|
|
ca6597cd38 | ||
| 4a2c3a8eed |
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.31'
|
||||
app.version: '0.1.33'
|
||||
|
||||
563
docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md
Normal file
563
docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md
Normal 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
|
||||
117
docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md
Normal file
117
docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md
Normal 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)
|
||||
@@ -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"> </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"> </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> </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>
|
||||
|
||||
344
frontend/composables/useEmployeeContract.ts
Normal file
344
frontend/composables/useEmployeeContract.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,76 +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 { createSuspension, updateSuspension } from '~/services/contractSuspensions'
|
||||
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)
|
||||
|
||||
type SuspensionForm = {
|
||||
id: number | null
|
||||
startDate: string
|
||||
endDate: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
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 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 '-'
|
||||
@@ -79,133 +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 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 loadEmployee = async () => {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
const employeeId = Number(idParam)
|
||||
@@ -215,180 +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 submitSuspension = async (index: number) => {
|
||||
const form = suspensionForms.value[index]
|
||||
if (!form || !form.startDate) return
|
||||
|
||||
const periodId = currentActiveContractPeriodId.value
|
||||
if (!periodId) return
|
||||
|
||||
isSuspensionSubmitting.value = true
|
||||
try {
|
||||
if (form.id) {
|
||||
await updateSuspension(form.id, {
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
comment: form.comment || null
|
||||
})
|
||||
} else {
|
||||
await createSuspension({
|
||||
contractPeriodId: periodId,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate || null,
|
||||
comment: form.comment || null
|
||||
})
|
||||
}
|
||||
await loadEmployee()
|
||||
hydrateSuspensionForms()
|
||||
} finally {
|
||||
isSuspensionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addSuspensionForm = () => {
|
||||
suspensionForms.value.push({
|
||||
id: null,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
comment: ''
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -396,49 +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,
|
||||
suspensionForms,
|
||||
isSuspensionSubmitting,
|
||||
submitSuspension,
|
||||
addSuspensionForm,
|
||||
currentActiveContractPeriodId
|
||||
...contract,
|
||||
...leave,
|
||||
...rtt
|
||||
}
|
||||
}
|
||||
|
||||
70
frontend/composables/useEmployeeLeave.ts
Normal file
70
frontend/composables/useEmployeeLeave.ts
Normal 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
|
||||
}
|
||||
}
|
||||
42
frontend/composables/useEmployeeRtt.ts
Normal file
42
frontend/composables/useEmployeeRtt.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<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"
|
||||
@@ -45,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'
|
||||
@@ -97,15 +98,25 @@
|
||||
: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>
|
||||
@@ -122,6 +133,7 @@ const {
|
||||
rttSummary,
|
||||
publicHolidays,
|
||||
showLeaveTab,
|
||||
showRttTab,
|
||||
contractHistory,
|
||||
employeeContractWorkLabel,
|
||||
contractForm,
|
||||
@@ -159,7 +171,9 @@ const {
|
||||
isSuspensionSubmitting,
|
||||
submitSuspension,
|
||||
addSuspensionForm,
|
||||
currentActiveContractPeriodId
|
||||
currentActiveContractPeriodId,
|
||||
isLeaveLoading,
|
||||
isRttLoading
|
||||
} = useEmployeeDetailPage()
|
||||
|
||||
useHead(() => ({
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
54
migrations/Version20260313080007.php
Normal file
54
migrations/Version20260313080007.php
Normal 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)');
|
||||
}
|
||||
}
|
||||
26
migrations/Version20260313092249.php
Normal file
26
migrations/Version20260313092249.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
17
src/Dto/Rtt/WeekRecoveryDetail.php
Normal file
17
src/Dto/Rtt/WeekRecoveryDetail.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,29 +139,40 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, float> YYYY-MM => presence day count (0.5 for half-days)
|
||||
* 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 countPresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
public function countWeekendWorkedDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
SELECT TO_CHAR(work_date, 'YYYY-MM') AS month,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN (morning_from IS NOT NULL OR is_present_morning = true)
|
||||
AND (afternoon_from IS NOT NULL OR is_present_afternoon = true)
|
||||
THEN 1.0
|
||||
WHEN (morning_from IS NOT NULL OR is_present_morning = true)
|
||||
OR (afternoon_from IS NOT NULL OR is_present_afternoon = true)
|
||||
THEN 0.5
|
||||
WHEN total_minutes >= 300 THEN 1.0
|
||||
WHEN total_minutes > 0 THEN 0.5
|
||||
ELSE 0
|
||||
END
|
||||
) AS cnt
|
||||
FROM work_hours
|
||||
WHERE employee_id = :employee
|
||||
AND work_date >= :from
|
||||
AND work_date <= :to
|
||||
AND (morning_from IS NOT NULL OR is_present_morning = true
|
||||
OR afternoon_from IS NOT NULL OR is_present_afternoon = true)
|
||||
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;
|
||||
|
||||
|
||||
@@ -69,18 +69,9 @@ final readonly class LeaveBalanceComputationService
|
||||
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
|
||||
|
||||
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||
$totalBusinessDays = $this->countBusinessDays($from, $to);
|
||||
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
||||
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
|
||||
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
||||
if ([] !== $suspensions) {
|
||||
$totalMonths = $this->countFractionalMonths($from, $to);
|
||||
$suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions);
|
||||
if ($totalMonths > 0) {
|
||||
$ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths);
|
||||
$acquiredDays = $carryDays + $baseAcquiredDays * $ratio + $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);
|
||||
@@ -89,7 +80,9 @@ final readonly class LeaveBalanceComputationService
|
||||
continue;
|
||||
}
|
||||
|
||||
$suspensions = $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to);
|
||||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||
$this->resolveSuspensionsForEmployeePeriod($employee, $from, $to)
|
||||
);
|
||||
$generatedDays = $this->computeAccruedDays(
|
||||
$this->resolveAnnualDays($employee),
|
||||
$this->resolveDaysAccrualPerMonth($employee),
|
||||
@@ -425,55 +418,6 @@ final readonly class LeaveBalanceComputationService
|
||||
return [$takenDays, $takenSaturdays];
|
||||
}
|
||||
|
||||
private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float
|
||||
{
|
||||
$from = $this->normalizeDate($from);
|
||||
$to = $this->normalizeDate($to);
|
||||
$months = 0.0;
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
|
||||
while ($cursor <= $to) {
|
||||
$monthStart = $cursor > $from ? $cursor : $from;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||
$daysInMonth = (int) $cursor->format('t');
|
||||
$months += $coveredDays / $daysInMonth;
|
||||
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ContractSuspension> $suspensions
|
||||
*/
|
||||
private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float
|
||||
{
|
||||
$from = $this->normalizeDate($from);
|
||||
$to = $this->normalizeDate($to);
|
||||
$months = 0.0;
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
|
||||
while ($cursor <= $to) {
|
||||
$monthStart = $cursor > $from ? $cursor : $from;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
$daysInMonth = (int) $cursor->format('t');
|
||||
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
||||
$months += $suspendedDays / $daysInMonth;
|
||||
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ContractSuspension>
|
||||
*/
|
||||
|
||||
@@ -38,6 +38,35 @@ final class SuspensionDaysCalculator
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
|
||||
|
||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
||||
$summary->presenceDaysByMonth = $this->workHourRepository->countPresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
||||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
@@ -178,8 +178,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
|
||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
|
||||
$suspensions = $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to);
|
||||
$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'],
|
||||
@@ -235,16 +237,8 @@ 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 = $this->resolveSuspensionsForPeriod($employee, $from, $to);
|
||||
if ([] !== $suspensions) {
|
||||
$totalMonths = $this->countFractionalMonths($from, $to);
|
||||
$suspendedMonths = $this->countSuspendedFractionalMonths($from, $to, $suspensions);
|
||||
if ($totalMonths > 0) {
|
||||
$ratio = max(0.0, ($totalMonths - $suspendedMonths) / $totalMonths);
|
||||
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'] * $ratio;
|
||||
}
|
||||
}
|
||||
// Suspensions do not impact forfait 218 leave calculation.
|
||||
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
|
||||
$accruingDays = 0.0;
|
||||
$remainingDays = max(0.0, $acquiredDays - $takenDays);
|
||||
$acquiredSaturdays = 0.0;
|
||||
@@ -539,6 +533,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}
|
||||
*/
|
||||
@@ -731,55 +785,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return [$takenDays, $takenSaturdays];
|
||||
}
|
||||
|
||||
private function countFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to): float
|
||||
{
|
||||
$from = $this->normalizeDate($from);
|
||||
$to = $this->normalizeDate($to);
|
||||
$months = 0.0;
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
|
||||
while ($cursor <= $to) {
|
||||
$monthStart = $cursor > $from ? $cursor : $from;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||
$daysInMonth = (int) $cursor->format('t');
|
||||
$months += $coveredDays / $daysInMonth;
|
||||
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ContractSuspension> $suspensions
|
||||
*/
|
||||
private function countSuspendedFractionalMonths(DateTimeImmutable $from, DateTimeImmutable $to, array $suspensions): float
|
||||
{
|
||||
$from = $this->normalizeDate($from);
|
||||
$to = $this->normalizeDate($to);
|
||||
$months = 0.0;
|
||||
$cursor = $from->modify('first day of this month')->setTime(0, 0);
|
||||
|
||||
while ($cursor <= $to) {
|
||||
$monthStart = $cursor > $from ? $cursor : $from;
|
||||
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||
if ($monthEnd > $to) {
|
||||
$monthEnd = $to;
|
||||
}
|
||||
$daysInMonth = (int) $cursor->format('t');
|
||||
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
||||
$months += $suspendedDays / $daysInMonth;
|
||||
|
||||
$cursor = $cursor->modify('first day of next month');
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ContractSuspension>
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,6 +128,40 @@ final class SuspensionDaysCalculatorTest extends TestCase
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user