Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c317a2a026 | ||
| 8846e83df1 | |||
|
|
ff824f233a | ||
| c4c9dfceab | |||
|
|
ca6597cd38 | ||
| 4a2c3a8eed | |||
|
|
1858817649 | ||
| 99f0f191f4 | |||
| 96617f04bc | |||
|
|
25d961c367 | ||
| 38f09914cb |
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.29'
|
app.version: '0.1.34'
|
||||||
|
|||||||
1550
docs/superpowers/plans/2026-03-12-contract-suspension.md
Normal file
1550
docs/superpowers/plans/2026-03-12-contract-suspension.md
Normal file
File diff suppressed because it is too large
Load Diff
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
|
||||||
187
docs/superpowers/specs/2026-03-12-contract-suspension-design.md
Normal file
187
docs/superpowers/specs/2026-03-12-contract-suspension-design.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Suspension de contrat — Design Spec
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Permettre de suspendre un contrat employé. Une suspension empêche l'acquisition de congés durant la période concernée (prorata). S'applique aux CDI/CDD non forfait et aux forfaits 218.
|
||||||
|
|
||||||
|
## Contraintes
|
||||||
|
|
||||||
|
- Plusieurs suspensions possibles par période de contrat
|
||||||
|
- Pas de suppression de suspension (hors scope)
|
||||||
|
- Règle de calcul : on exclut les jours de suspension jusqu'au dernier mois complet terminé (cohérent avec la règle existante)
|
||||||
|
- Suspension sans date de fin = suspension en cours indéfiniment (exclut les mois jusqu'au dernier mois terminé)
|
||||||
|
- Les suspensions ne doivent pas se chevaucher sur une même période de contrat
|
||||||
|
|
||||||
|
## Modèle de données
|
||||||
|
|
||||||
|
### Nouvelle entité `ContractSuspension`
|
||||||
|
|
||||||
|
Nouvelle table `contract_suspensions` :
|
||||||
|
|
||||||
|
| Colonne | Type | Nullable | Description |
|
||||||
|
|---------|------|----------|-------------|
|
||||||
|
| `id` | SERIAL | non | PK |
|
||||||
|
| `contract_period_id` | INT | non | FK vers `employee_contract_periods`, CASCADE delete |
|
||||||
|
| `start_date` | DATE | non | Début de suspension |
|
||||||
|
| `end_date` | DATE | oui | Fin de suspension (null = en cours) |
|
||||||
|
| `comment` | TEXT | oui | Commentaire libre |
|
||||||
|
| `created_at` | TIMESTAMP | non | Date de création technique |
|
||||||
|
|
||||||
|
Index : `(contract_period_id, start_date)`.
|
||||||
|
|
||||||
|
Relation : `EmployeeContractPeriod` ← OneToMany → `ContractSuspension`.
|
||||||
|
|
||||||
|
## Backend — API
|
||||||
|
|
||||||
|
### Endpoint dédié
|
||||||
|
|
||||||
|
Les suspensions sont gérées via un endpoint dédié plutôt que via les champs transients Employee. Cela évite de complexifier le `EmployeeWriteProcessor` et permet de gérer N suspensions proprement.
|
||||||
|
|
||||||
|
**Nouvel ApiResource `ContractSuspension` :**
|
||||||
|
- `POST /api/contract_suspensions` — créer une suspension (body : `contractPeriod` IRI, `startDate`, `endDate`, `comment`)
|
||||||
|
- `PATCH /api/contract_suspensions/{id}` — modifier une suspension existante
|
||||||
|
- Security : `ROLE_ADMIN`
|
||||||
|
- Pagination désactivée
|
||||||
|
|
||||||
|
**Processor custom `ContractSuspensionWriteProcessor` :**
|
||||||
|
- Résout la période de contrat depuis l'IRI
|
||||||
|
- Validation :
|
||||||
|
- `startDate` requis
|
||||||
|
- `endDate >= startDate` si renseigné
|
||||||
|
- `startDate >= period.startDate`
|
||||||
|
- Pour les CDD/contrats avec date de fin : `startDate` et `endDate` dans les bornes de la période
|
||||||
|
- Pas de chevauchement avec les autres suspensions de la même période
|
||||||
|
- Rejet si la période de contrat est déjà clôturée (date de fin dans le passé)
|
||||||
|
|
||||||
|
### Lecture
|
||||||
|
|
||||||
|
Exposer les suspensions dans la sérialisation de l'Employee :
|
||||||
|
|
||||||
|
- `Employee::getCurrentSuspensions(): array` — retourne les suspensions de la période de contrat courante, groupe `employee:read`
|
||||||
|
- Ajouter les suspensions au `ContractHistoryItem` via `EmployeeContractPeriod::getSuspensions()`
|
||||||
|
|
||||||
|
## Backend — Calcul des congés
|
||||||
|
|
||||||
|
### Deux points d'impact
|
||||||
|
|
||||||
|
Le calcul d'acquisition existe à **deux endroits** qui doivent tous les deux prendre en compte les suspensions :
|
||||||
|
|
||||||
|
1. **`EmployeeLeaveSummaryProvider::computeAccruedDaysFromStart()`** — affichage live des congés en cours d'acquisition
|
||||||
|
2. **`LeaveBalanceComputationService::computeAccruedDays()`** — utilisé par le rollover (`LeaveRolloverCommand`) pour calculer le solde de report
|
||||||
|
|
||||||
|
Les deux méthodes ont la même structure (boucle mois par mois) et doivent être modifiées de la même manière.
|
||||||
|
|
||||||
|
### Modification des méthodes de calcul
|
||||||
|
|
||||||
|
Pour les deux méthodes, ajouter un paramètre optionnel : `array $suspensions = []` (tableau de `{start: DateTimeImmutable, end: ?DateTimeImmutable}`).
|
||||||
|
|
||||||
|
Dans la boucle mois par mois, pour chaque mois :
|
||||||
|
1. Calculer les jours couverts par la période de contrat (existant)
|
||||||
|
2. Pour chaque suspension, calculer le nombre de jours suspendus qui tombent dans ce mois
|
||||||
|
3. Soustraire le total des jours suspendus
|
||||||
|
4. Le ratio du mois = max(0, jours couverts - jours suspendus) / jours dans le mois
|
||||||
|
|
||||||
|
Cela gère automatiquement les suspensions qui commencent/finissent en milieu de mois (prorata).
|
||||||
|
|
||||||
|
Une suspension sans date de fin utilise la date de fin de calcul comme borne (dernier jour du mois précédent, cohérent avec la règle existante).
|
||||||
|
|
||||||
|
**Note :** chaque méthode est appelée deux fois — une pour les jours, une pour les samedis. La soustraction de suspension s'applique aux deux appels.
|
||||||
|
|
||||||
|
### Impact sur les forfaits 218
|
||||||
|
|
||||||
|
Pour les forfaits, les jours acquis en début d'exercice (ex: 34 jours pour 2026) sont réduits au prorata des jours de suspension.
|
||||||
|
|
||||||
|
Calcul : `jours acquis = base × (jours ouvrés effectifs / jours ouvrés totaux de l'exercice)`
|
||||||
|
|
||||||
|
Où `jours ouvrés effectifs = jours ouvrés totaux - jours ouvrés suspendus`.
|
||||||
|
|
||||||
|
Cela impacte `EmployeeLeaveSummaryProvider` dans la branche forfait et `LeaveBalanceComputationService` dans le calcul forfait de `computeDynamicClosingForYear()`.
|
||||||
|
|
||||||
|
### Passage des données de suspension aux méthodes
|
||||||
|
|
||||||
|
- **`EmployeeLeaveSummaryProvider`** : le provider a accès aux périodes de contrat via l'Employee. Il doit résoudre les suspensions de la période couvrant l'exercice et les passer aux méthodes de calcul.
|
||||||
|
- **`LeaveBalanceComputationService`** : le service utilise `$employee->getContractHistory()`. Il doit trouver les suspensions de la période couvrant l'exercice. L'accès au repository `EmployeeContractPeriodRepository` est déjà injecté — ajouter l'accès au repository `ContractSuspensionRepository` ou passer par la relation Doctrine.
|
||||||
|
|
||||||
|
### Impact sur la bascule d'exercice (rollover au 01/06)
|
||||||
|
|
||||||
|
Le rollover (`LeaveRolloverCommand`) appelle `LeaveBalanceComputationService::computeDynamicClosingForYear()` qui appelle `computeAccruedDays()`. En modifiant `computeAccruedDays()` pour accepter et traiter les suspensions, le rollover prendra automatiquement en compte les suspensions. Les jours acquis au rollover reflèteront la déduction.
|
||||||
|
|
||||||
|
**Exemple CDI :** exercice 2027 (juin 2026 - mai 2027), 2 suspensions totalisant 3 mois → au lieu de 25j acquis, l'employé bascule avec ~18.75j (9 mois effectifs × 2.083j/mois).
|
||||||
|
|
||||||
|
### Règles non impactées
|
||||||
|
|
||||||
|
- INTERIM : pas de congés gérés
|
||||||
|
|
||||||
|
## Frontend — UI
|
||||||
|
|
||||||
|
### Bouton et drawer
|
||||||
|
|
||||||
|
Le bouton "Clôturer" devient **"Modifier"**. Il ouvre le drawer existant avec le titre **"Modifier le contrat"**. Le bouton **"+ Ajouter"** (création de nouveau contrat) reste inchangé.
|
||||||
|
|
||||||
|
Le drawer contient 2 onglets :
|
||||||
|
|
||||||
|
**Onglet "Clôturer"** — contenu identique à l'actuel (type contrat, temps de travail, début contrat en readonly, date fin, commentaire, checkbox solde de tout compte).
|
||||||
|
|
||||||
|
**Onglet "Suspendre"** — formulaires empilés :
|
||||||
|
- Pour chaque suspension existante : un formulaire pré-rempli avec les 3 champs (date début, date fin, commentaire) et un bouton **"Modifier"**
|
||||||
|
- En bas : un bouton **"+ Ajouter"** qui ajoute un nouveau formulaire vide avec les 3 champs et un bouton **"Ajouter"**
|
||||||
|
- Chaque formulaire est indépendant (soumission individuelle)
|
||||||
|
|
||||||
|
### Champs par formulaire de suspension
|
||||||
|
|
||||||
|
- Date de début (required, input date)
|
||||||
|
- Date de fin (optionnel, input date)
|
||||||
|
- Commentaire (optionnel, textarea)
|
||||||
|
|
||||||
|
### Données nécessaires côté frontend
|
||||||
|
|
||||||
|
Nouveau type `ContractSuspension` (DTO) :
|
||||||
|
```typescript
|
||||||
|
type ContractSuspension = {
|
||||||
|
id: number
|
||||||
|
startDate: string
|
||||||
|
endDate?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter au type `Employee` (DTO) :
|
||||||
|
- `currentSuspensions?: ContractSuspension[]`
|
||||||
|
|
||||||
|
Ajouter au type `ContractHistoryItem` :
|
||||||
|
- `suspensions?: ContractSuspension[]`
|
||||||
|
|
||||||
|
Nouveau service `frontend/services/contractSuspensions.ts` :
|
||||||
|
- `createSuspension(payload)` — POST
|
||||||
|
- `updateSuspension(id, payload)` — PATCH
|
||||||
|
|
||||||
|
## Exemples de calcul
|
||||||
|
|
||||||
|
### CDI/CDD non forfait
|
||||||
|
|
||||||
|
Contrat CDI démarré le 01/06/2026, exercice 2027 (juin 2026 - mai 2027).
|
||||||
|
Accrual : 25j / 12 mois = 2.083j/mois.
|
||||||
|
|
||||||
|
Sans suspension au 12/03/2027 (9 mois complets : juin-février) :
|
||||||
|
- En cours d'acquisition = 9 × 2.083 = 18.75j
|
||||||
|
|
||||||
|
Avec 2 suspensions (01/01 au 31/01 + 01/03 au 31/03 = 2 mois) au 12/04/2027 (10 mois complets - 2 suspendus = 8 mois effectifs) :
|
||||||
|
- En cours d'acquisition = 8 × 2.083 = 16.67j
|
||||||
|
|
||||||
|
Samedis (5/12 par mois) :
|
||||||
|
- Sans suspension : 9 × 0.417 = 3.75j
|
||||||
|
- Avec 2 suspensions : 8 × 0.417 = 3.33j
|
||||||
|
|
||||||
|
### Forfait 218
|
||||||
|
|
||||||
|
Exercice 2026 (année civile), 34 jours acquis, 252 jours ouvrés dans l'année.
|
||||||
|
Suspension de 2 mois (44 jours ouvrés).
|
||||||
|
|
||||||
|
- Jours ouvrés effectifs = 252 - 44 = 208
|
||||||
|
- Jours acquis = 34 × (208 / 252) = 28.06j
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Suppression d'une suspension
|
||||||
|
- Affichage de la suspension dans l'historique des contrats (les données sont sérialisées mais pas de rendu spécifique dans le tableau historique)
|
||||||
|
- Auto-fermeture des suspensions lors de la clôture du contrat
|
||||||
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)
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="overflow-y-auto p-6" style="max-height: calc(100% - 65px)">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
:disabled="isContractSubmitting || !canCloseCurrentContract"
|
:disabled="isContractSubmitting || !canCloseCurrentContract"
|
||||||
@click="onOpenCloseContractDrawer"
|
@click="onOpenCloseContractDrawer"
|
||||||
>
|
>
|
||||||
Clôturer
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -43,7 +43,30 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
<AppDrawer :model-value="isContractDrawerOpen" title="Modifier le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
||||||
|
<div class="mb-4 flex border-b border-neutral-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pb-2 px-4 border-b-2 font-semibold"
|
||||||
|
:class="drawerTab === 'close'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="drawerTab = 'close'"
|
||||||
|
>
|
||||||
|
Clôturer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pb-2 px-4 border-b-2 font-semibold"
|
||||||
|
:class="drawerTab === 'suspend'
|
||||||
|
? 'border-primary-500 text-primary-500'
|
||||||
|
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||||
|
@click="drawerTab = 'suspend'"
|
||||||
|
>
|
||||||
|
Suspendre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="drawerTab === 'close'">
|
||||||
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
|
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
|
||||||
@@ -128,6 +151,62 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="drawerTab === 'suspend'" class="space-y-6">
|
||||||
|
<div
|
||||||
|
v-for="(form, index) in suspensionForms"
|
||||||
|
:key="form.id ?? `new-${index}`"
|
||||||
|
class="space-y-4 rounded-lg border border-neutral-200 p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700">
|
||||||
|
Date de début <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.startDate"
|
||||||
|
type="date"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700">
|
||||||
|
Date de fin
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.endDate"
|
||||||
|
type="date"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700">
|
||||||
|
Commentaire
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.comment"
|
||||||
|
rows="3"
|
||||||
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="!form.startDate || isSuspensionSubmitting"
|
||||||
|
@click="onSubmitSuspension(index)"
|
||||||
|
>
|
||||||
|
{{ form.id ? 'Modifier' : 'Ajouter' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-md border-2 border-dashed border-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
|
||||||
|
@click="onAddSuspensionForm"
|
||||||
|
>
|
||||||
|
+ Ajouter une suspension
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
|
|
||||||
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
|
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
|
||||||
@@ -195,6 +274,13 @@
|
|||||||
import type { Contract } from '~/services/dto/contract'
|
import type { Contract } from '~/services/dto/contract'
|
||||||
import type { ContractHistoryItem } from '~/services/dto/employee'
|
import type { ContractHistoryItem } from '~/services/dto/employee'
|
||||||
|
|
||||||
|
type SuspensionForm = {
|
||||||
|
id: number | null
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
comment: string
|
||||||
|
}
|
||||||
|
|
||||||
type ContractForm = {
|
type ContractForm = {
|
||||||
contractId: number | ''
|
contractId: number | ''
|
||||||
contractName: string
|
contractName: string
|
||||||
@@ -213,7 +299,7 @@ type CreateContractForm = {
|
|||||||
endDate: string
|
endDate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
contractHistory: ContractHistoryItem[]
|
contractHistory: ContractHistoryItem[]
|
||||||
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
|
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
|
||||||
contractHistoryLabel: (item: ContractHistoryItem) => string
|
contractHistoryLabel: (item: ContractHistoryItem) => string
|
||||||
@@ -245,5 +331,16 @@ defineProps<{
|
|||||||
onUpdateCreateContractDrawerOpen: (open: boolean) => void
|
onUpdateCreateContractDrawerOpen: (open: boolean) => void
|
||||||
onSubmitCloseContract: () => void
|
onSubmitCloseContract: () => void
|
||||||
onSubmitCreateContract: () => void
|
onSubmitCreateContract: () => void
|
||||||
|
suspensionForms: SuspensionForm[]
|
||||||
|
isSuspensionSubmitting: boolean
|
||||||
|
onSubmitSuspension: (index: number) => void
|
||||||
|
onAddSuspensionForm: () => void
|
||||||
|
currentContractPeriodId?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const drawerTab = ref<'close' | 'suspend'>('close')
|
||||||
|
|
||||||
|
watch(() => props.isContractDrawerOpen, (open) => {
|
||||||
|
if (open) drawerTab.value = 'close'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,35 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||||
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
|
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
<p class="col-start-1 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
||||||
<p><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
formatCount(summary?.acquiredDays)
|
||||||
formatCount(summary?.acquiredDays)
|
}} Jours
|
||||||
}} Jours</p>
|
</p>
|
||||||
<p><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
<p class="col-start-2 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Pris :</strong>
|
||||||
{{ formatCount(summary?.remainingDays) }} Jours</p>
|
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
|
||||||
</div>
|
</p>
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
<p class="col-start-3 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
||||||
<p><span class="uppercase font-semibold">Samedi acquis :</span>
|
{{ formatCount(summary?.remainingDays) }} Jours
|
||||||
{{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
|
</p>
|
||||||
<p><span class="uppercase font-semibold">Reste à prendre :</span>
|
<p class="col-start-4 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
|
||||||
{{ formatCount(summary?.remainingSaturdays) }} Jours</p>
|
{{ formatCount(summary?.accruingDays) }} Jours
|
||||||
</div>
|
</p>
|
||||||
<div class="flex flex-col gap-2 jutify-center items-center border-r-4 border-white py-3">
|
<p v-if="!isForfaitRule" class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Samedi acquis :</span>
|
||||||
<p><span class="uppercase font-semibold">Fractionné acquis : </span>{{ formatCount(summary?.fractionedDays) }} Jours</p>
|
{{ formatCount(summary?.acquiredSaturdays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-else class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Année N-1 acquis :</span>
|
||||||
|
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-if="!isForfaitRule" class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
|
||||||
|
{{ formatCount(summary?.takenSaturdays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-if="!isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||||
|
{{ formatCount(summary?.remainingSaturdays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-else class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
|
||||||
|
{{ formatCount(summary?.previousYearTakenDays) }} Jours
|
||||||
|
</p>
|
||||||
|
<p v-if="isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||||
|
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
|
||||||
|
</p>
|
||||||
|
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
|
||||||
|
<div>
|
||||||
|
<span class="uppercase font-semibold">Fractionné acquis : </span>
|
||||||
|
<span>{{ formatCount(summary?.fractionedDays) }} Jours</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
|
class="flex items-center"
|
||||||
@click="openFractionedDrawer"
|
@click="openFractionedDrawer"
|
||||||
>
|
>
|
||||||
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
|
<Icon name="mdi:edit-box" size="24"/>
|
||||||
</div>
|
</button>
|
||||||
<div class="flex flex-col jutify-center gap-2 items-center py-3">
|
|
||||||
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
|
|
||||||
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
||||||
<div class="grid grid-cols-4 gap-10">
|
<div class="grid grid-cols-4 gap-10">
|
||||||
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
|
<div v-for="month in months" :key="month.label"
|
||||||
|
class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
|
||||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
|
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
|
||||||
{{ month.label }}
|
{{ month.label }}
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +73,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence :
|
||||||
|
{{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +139,7 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isFractionedDrawerOpen = ref(false)
|
const isFractionedDrawerOpen = ref(false)
|
||||||
const fractionedForm = reactive({ days: 0 })
|
const fractionedForm = reactive({days: 0})
|
||||||
|
|
||||||
const openFractionedDrawer = () => {
|
const openFractionedDrawer = () => {
|
||||||
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
fractionedForm.days = props.summary?.fractionedDays ?? 0
|
||||||
@@ -150,6 +172,11 @@ const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
|
|||||||
|
|
||||||
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
|
||||||
|
|
||||||
|
const currentYearTakenDays = computed(() => {
|
||||||
|
if (!props.summary) return null
|
||||||
|
return props.summary.takenDays - (props.summary.previousYearTakenDays ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
const displayedYear = computed(() => {
|
const displayedYear = computed(() => {
|
||||||
if (props.summary?.year) return props.summary.year
|
if (props.summary?.year) return props.summary.year
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -259,9 +286,12 @@ const months = computed(() => {
|
|||||||
cells.push(null)
|
cells.push(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const monthKey = `${monthYear}-${String(monthIndex + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
cells
|
cells,
|
||||||
|
monthKey
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -278,15 +308,15 @@ const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) =
|
|||||||
if (day.leave) {
|
if (day.leave) {
|
||||||
const color = day.leave.colors[0] ?? '#222783'
|
const color = day.leave.colors[0] ?? '#222783'
|
||||||
if (day.leave.am && day.leave.pm) {
|
if (day.leave.am && day.leave.pm) {
|
||||||
return { backgroundColor: color }
|
return {backgroundColor: color}
|
||||||
}
|
}
|
||||||
const colorFaded = `${color}60`
|
const colorFaded = `${color}60`
|
||||||
const backgroundImage = day.leave.am
|
const backgroundImage = day.leave.am
|
||||||
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
|
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
|
||||||
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
|
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
|
||||||
return { backgroundImage, backgroundColor: 'transparent' }
|
return {backgroundImage, backgroundColor: 'transparent'}
|
||||||
}
|
}
|
||||||
if (day.isHoliday) return { backgroundColor: 'rgb(179, 229, 252)' }
|
if (day.isHoliday) return {backgroundColor: 'rgb(179, 229, 252)'}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +1,212 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
<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">
|
<!-- Header bar -->
|
||||||
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
<div class="flex items-center justify-between rounded-t-md bg-tertiary-500 px-5 py-4 text-black border border-primary-500">
|
||||||
<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">
|
<div class="flex items-center">
|
||||||
+ Payer les RTT
|
<button
|
||||||
</button>
|
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||||
</div>
|
:disabled="currentMonthIndex === 0"
|
||||||
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
|
@click="currentMonthIndex--"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
|
<Icon name="mdi:chevron-left" size="24"/>
|
||||||
{{ month.label }}
|
</button>
|
||||||
</div>
|
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
|
||||||
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
|
{{ currentMonthLabel }} {{ currentYear }}
|
||||||
<template v-for="week in month.weeks" :key="week.key">
|
</span>
|
||||||
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
|
<button
|
||||||
<span v-if="week.isEmpty"> </span>
|
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||||
<span v-else>Semaine {{ week.weekNumber }}</span>
|
:disabled="currentMonthIndex === 11"
|
||||||
</div>
|
@click="currentMonthIndex++"
|
||||||
<div class="py-[6px] pl-3 border-b border-primary-500">
|
>
|
||||||
<span v-if="week.isEmpty"> </span>
|
<Icon name="mdi:chevron-right" size="24"/>
|
||||||
<span v-else>{{ formatMinutes(week.recoveryMinutes) }}</span>
|
</button>
|
||||||
</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>
|
|
||||||
</div>
|
</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>
|
</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">
|
<form @submit.prevent="onSubmitPayment">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
<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>
|
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-neutral-700">Nombre d'heures</label>
|
<label class="block text-sm font-medium text-neutral-700">Base 25% (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" />
|
<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>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block text-sm font-medium text-neutral-700">Taux</label>
|
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</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">
|
<input
|
||||||
<option value="25">25%</option>
|
v-model.number="paymentForm.bonus50Hours"
|
||||||
<option value="50">50%</option>
|
type="number"
|
||||||
</select>
|
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>
|
||||||
<div class="flex justify-end gap-3">
|
<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
|
||||||
<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>
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
@@ -82,7 +214,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -90,27 +222,27 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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)
|
// --- Month navigation ---
|
||||||
const isEditMode = ref(false)
|
|
||||||
const paymentForm = reactive({ month: 1, hours: 0, rate: '25' as '25' | '50' })
|
|
||||||
|
|
||||||
const monthLabels = [
|
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
|
||||||
'Janvier',
|
|
||||||
'Fevrier',
|
const monthLabels: Record<number, string> = {
|
||||||
'Mars',
|
1: 'JANVIER',
|
||||||
'Avril',
|
2: 'FEVRIER',
|
||||||
'Mai',
|
3: 'MARS',
|
||||||
'Juin',
|
4: 'AVRIL',
|
||||||
'Juillet',
|
5: 'MAI',
|
||||||
'Aout',
|
6: 'JUIN',
|
||||||
'Septembre',
|
7: 'JUILLET',
|
||||||
'Octobre',
|
8: 'AOUT',
|
||||||
'Novembre',
|
9: 'SEPTEMBRE',
|
||||||
'Decembre'
|
10: 'OCTOBRE',
|
||||||
] as const
|
11: 'NOVEMBRE',
|
||||||
|
12: 'DECEMBRE',
|
||||||
|
}
|
||||||
|
|
||||||
const orderedMonthOptions = [
|
const orderedMonthOptions = [
|
||||||
{ value: 6, label: 'Juin' },
|
{ value: 6, label: 'Juin' },
|
||||||
@@ -124,97 +256,152 @@ const orderedMonthOptions = [
|
|||||||
{ value: 2, label: 'Fevrier' },
|
{ value: 2, label: 'Fevrier' },
|
||||||
{ value: 3, label: 'Mars' },
|
{ value: 3, label: 'Mars' },
|
||||||
{ value: 4, label: 'Avril' },
|
{ value: 4, label: 'Avril' },
|
||||||
{ value: 5, label: 'Mai' }
|
{ value: 5, label: 'Mai' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const paymentsByMonth = computed(() => {
|
// Initialize to current month's position in the exercise
|
||||||
const map = new Map<number, { paid25: number; paid50: number }>()
|
const today = new Date()
|
||||||
for (const mp of props.summary?.monthPayments ?? []) {
|
const todayMonth = today.getMonth() + 1
|
||||||
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
const initialIndex = orderedMonths.indexOf(todayMonth as (typeof orderedMonths)[number])
|
||||||
}
|
const currentMonthIndex = ref(initialIndex >= 0 ? initialIndex : 0)
|
||||||
return map
|
|
||||||
|
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
|
// --- Weeks for current month ---
|
||||||
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
|
||||||
|
|
||||||
const months = computed(() => {
|
const weeksForCurrentMonth = computed((): EmployeeRttWeekSummary[] => {
|
||||||
type DisplayWeek = {
|
if (!props.summary) return []
|
||||||
key: string
|
return props.summary.weeks.filter((w) => w.month === currentMonth.value)
|
||||||
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 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 abs = Math.abs(minutes)
|
||||||
const hours = Math.floor(abs / 60)
|
const hours = Math.floor(abs / 60)
|
||||||
const rest = abs % 60
|
const rest = abs % 60
|
||||||
const sign = minutes < 0 ? '-' : ''
|
if (rest === 0) return `${sign}${hours} h`
|
||||||
return `${sign}${hours.toString().padStart(2, '0')}h${rest.toString().padStart(2, '0')}`
|
return `${sign}${hours} h ${rest} m`
|
||||||
}
|
}
|
||||||
|
|
||||||
const openNewPayment = () => {
|
// --- Payment drawer ---
|
||||||
isEditMode.value = false
|
|
||||||
paymentForm.month = 6
|
const isPaymentDrawerOpen = ref(false)
|
||||||
paymentForm.hours = 0
|
const paymentForm = reactive({
|
||||||
paymentForm.rate = '25'
|
month: 6,
|
||||||
isPaymentDrawerOpen.value = true
|
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') => {
|
watch(() => paymentForm.month, (newMonth) => {
|
||||||
isEditMode.value = true
|
prefillFromExistingPayment(newMonth)
|
||||||
paymentForm.month = month
|
})
|
||||||
paymentForm.rate = rate
|
|
||||||
const currentMinutes = rate === '25' ? getMonthPaid25(month) : getMonthPaid50(month)
|
const openPaymentDrawer = () => {
|
||||||
paymentForm.hours = currentMinutes / 60
|
paymentForm.month = currentMonth.value
|
||||||
|
prefillFromExistingPayment(currentMonth.value)
|
||||||
isPaymentDrawerOpen.value = true
|
isPaymentDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmitPayment = () => {
|
const onSubmitPayment = () => {
|
||||||
const minutes = Math.round(paymentForm.hours * 60)
|
emit(
|
||||||
emit('submit-rtt-payment', paymentForm.month, minutes, paymentForm.rate)
|
'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
|
isPaymentDrawerOpen.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</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,65 +1,15 @@
|
|||||||
import type { Contract } from '~/services/dto/contract'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
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 { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { listAbsences } from '~/services/absences'
|
import { getEmployee } from '~/services/employees'
|
||||||
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'
|
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
export const useEmployeeDetailPage = () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
|
||||||
const employee = ref<Employee | null>(null)
|
const employee = ref<Employee | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
|
||||||
const contracts = ref<Contract[]>([])
|
|
||||||
const employeeAbsences = ref<Absence[]>([])
|
|
||||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
|
||||||
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
|
||||||
const publicHolidays = ref<Record<string, string>>({})
|
|
||||||
const isContractDrawerOpen = ref(false)
|
|
||||||
const isContractSubmitting = ref(false)
|
|
||||||
const isCreateContractDrawerOpen = ref(false)
|
|
||||||
const isCreateContractSubmitting = ref(false)
|
|
||||||
|
|
||||||
const contractForm = reactive({
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractName: '',
|
|
||||||
weeklyHours: null as number | null,
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
startDate: '',
|
|
||||||
endDate: '',
|
|
||||||
paidLeaveSettled: false,
|
|
||||||
comment: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const validationTouched = reactive({
|
|
||||||
endDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const createContractForm = reactive({
|
|
||||||
contractId: '' as number | '',
|
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
|
||||||
startDate: '',
|
|
||||||
endDate: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const createValidationTouched = reactive({
|
|
||||||
contractId: false,
|
|
||||||
contractNature: false,
|
|
||||||
startDate: false,
|
|
||||||
endDate: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
|
|
||||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||||
|
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
||||||
const employeeContractWorkLabel = computed(() => {
|
const employeeContractWorkLabel = computed(() => {
|
||||||
const contract = employee.value?.contract
|
const contract = employee.value?.contract
|
||||||
if (!contract) return '-'
|
if (!contract) return '-'
|
||||||
@@ -68,117 +18,6 @@ export const useEmployeeDetailPage = () => {
|
|||||||
return contract.name || '-'
|
return contract.name || '-'
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
|
|
||||||
|
|
||||||
const contractHistoryLabel = (item: ContractHistoryItem) => {
|
|
||||||
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
|
|
||||||
return `${item.weeklyHours} heures`
|
|
||||||
}
|
|
||||||
return item.contractName ?? '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentActiveContractPeriod = computed(() => {
|
|
||||||
const today = getTodayYmd()
|
|
||||||
const history = employee.value?.contractHistory ?? []
|
|
||||||
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCloseCurrentContract = computed(() => {
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!active) return false
|
|
||||||
if (!active.endDate) return true
|
|
||||||
return active.endDate > getTodayYmd()
|
|
||||||
})
|
|
||||||
|
|
||||||
const canCreateContract = computed(() => {
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!active) return true
|
|
||||||
return !!active.endDate
|
|
||||||
})
|
|
||||||
|
|
||||||
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
|
|
||||||
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
|
|
||||||
|
|
||||||
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
|
|
||||||
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
|
|
||||||
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
|
|
||||||
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
|
|
||||||
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
|
|
||||||
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
|
|
||||||
const isCreateContractFormValid = computed(() =>
|
|
||||||
isCreateContractValid.value &&
|
|
||||||
isCreateContractNatureValid.value &&
|
|
||||||
isCreateContractStartDateValid.value &&
|
|
||||||
isCreateContractEndDateValid.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const baseInputClass =
|
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
|
||||||
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
|
|
||||||
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
|
||||||
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
|
||||||
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
|
|
||||||
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
|
|
||||||
const closeContractWorkedHoursLabel = computed(() => {
|
|
||||||
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
|
|
||||||
return contractForm.contractName || '-'
|
|
||||||
})
|
|
||||||
|
|
||||||
const resetContractValidation = () => {
|
|
||||||
validationTouched.endDate = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateContractFormFromCurrent = () => {
|
|
||||||
const current = employee.value
|
|
||||||
const active = currentActiveContractPeriod.value
|
|
||||||
if (!current || !active) return
|
|
||||||
|
|
||||||
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
|
|
||||||
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
|
|
||||||
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
|
|
||||||
contractForm.contractNature = active.contractNature
|
|
||||||
contractForm.startDate = active.startDate
|
|
||||||
contractForm.endDate = getTodayYmd()
|
|
||||||
contractForm.paidLeaveSettled = false
|
|
||||||
contractForm.comment = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCloseContractDrawer = () => {
|
|
||||||
if (!employee.value || !canCloseCurrentContract.value) return
|
|
||||||
hydrateContractFormFromCurrent()
|
|
||||||
resetContractValidation()
|
|
||||||
isContractDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const setContractDrawerOpen = (open: boolean) => {
|
|
||||||
isContractDrawerOpen.value = open
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetCreateValidation = () => {
|
|
||||||
createValidationTouched.contractId = false
|
|
||||||
createValidationTouched.contractNature = false
|
|
||||||
createValidationTouched.startDate = false
|
|
||||||
createValidationTouched.endDate = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const openCreateContractDrawer = () => {
|
|
||||||
if (!employee.value || !canCreateContract.value) return
|
|
||||||
createContractForm.contractId = ''
|
|
||||||
createContractForm.contractNature = 'CDI'
|
|
||||||
createContractForm.endDate = ''
|
|
||||||
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
|
||||||
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
|
||||||
: getTodayYmd()
|
|
||||||
resetCreateValidation()
|
|
||||||
isCreateContractDrawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const setCreateContractDrawerOpen = (open: boolean) => {
|
|
||||||
isCreateContractDrawerOpen.value = open
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadEmployee = async () => {
|
const loadEmployee = async () => {
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
const employeeId = Number(idParam)
|
const employeeId = Number(idParam)
|
||||||
@@ -188,141 +27,42 @@ export const useEmployeeDetailPage = () => {
|
|||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const loadedEmployee = await getEmployee(employeeId)
|
employee.value = await getEmployee(employeeId)
|
||||||
employee.value = loadedEmployee
|
|
||||||
|
|
||||||
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') {
|
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
||||||
activeTab.value = 'contract'
|
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 {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitContractUpdate = async () => {
|
const contract = useEmployeeContract(employee, loadEmployee)
|
||||||
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
|
const leave = useEmployeeLeave(employee, loadEmployee)
|
||||||
|
const rtt = useEmployeeRtt(employee, loadEmployee)
|
||||||
|
|
||||||
validationTouched.endDate = true
|
watch(activeTab, (tab) => {
|
||||||
if (!isContractEndDateValid.value) return
|
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||||
|
leave.loadLeaveData()
|
||||||
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
|
} else if (tab === 'rtt' && !rtt.rttDataLoaded.value && showRttTab.value) {
|
||||||
toast.error({
|
rtt.loadRttData()
|
||||||
title: 'Erreur',
|
|
||||||
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isContractSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await updateEmployee(employee.value.id, {
|
|
||||||
firstName: employee.value.firstName,
|
|
||||||
lastName: employee.value.lastName,
|
|
||||||
siteId: employee.value.site?.id ?? null,
|
|
||||||
contractId: Number(contractForm.contractId),
|
|
||||||
contractEndDate: contractForm.endDate || null,
|
|
||||||
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
|
|
||||||
contractComment: contractForm.comment || null
|
|
||||||
})
|
|
||||||
|
|
||||||
isContractDrawerOpen.value = false
|
|
||||||
await loadEmployee()
|
|
||||||
} finally {
|
|
||||||
isContractSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitCreateContract = async () => {
|
|
||||||
if (!employee.value || isCreateContractSubmitting.value) return
|
|
||||||
|
|
||||||
createValidationTouched.contractId = true
|
|
||||||
createValidationTouched.contractNature = true
|
|
||||||
createValidationTouched.startDate = true
|
|
||||||
createValidationTouched.endDate = true
|
|
||||||
if (!isCreateContractFormValid.value) return
|
|
||||||
|
|
||||||
if (currentActiveContractPeriod.value?.endDate) {
|
|
||||||
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
|
|
||||||
if (createContractForm.startDate < minStartDate) {
|
|
||||||
toast.error({
|
|
||||||
title: 'Erreur',
|
|
||||||
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreateContractSubmitting.value = true
|
|
||||||
try {
|
|
||||||
await updateEmployee(employee.value.id, {
|
|
||||||
firstName: employee.value.firstName,
|
|
||||||
lastName: employee.value.lastName,
|
|
||||||
siteId: employee.value.site?.id ?? null,
|
|
||||||
contractId: Number(createContractForm.contractId),
|
|
||||||
contractNature: createContractForm.contractNature,
|
|
||||||
contractStartDate: createContractForm.startDate,
|
|
||||||
contractEndDate: createContractForm.endDate || null
|
|
||||||
})
|
|
||||||
isCreateContractDrawerOpen.value = false
|
|
||||||
await loadEmployee()
|
|
||||||
} finally {
|
|
||||||
isCreateContractSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitFractionedDays = async (days: number) => {
|
|
||||||
if (!employee.value) return
|
|
||||||
const year = leaveSummary.value?.year ?? undefined
|
|
||||||
await updateFractionedDays(employee.value.id, days, year)
|
|
||||||
await loadEmployee()
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitRttPayment = async (month: number, minutes: number, rate: '25' | '50') => {
|
|
||||||
if (!employee.value) return
|
|
||||||
const year = rttSummary.value?.year ?? undefined
|
|
||||||
await createRttPayment(employee.value.id, month, minutes, rate, year)
|
|
||||||
await loadEmployee()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(showsCreateContractEndDate, (shows) => {
|
|
||||||
if (!shows) {
|
|
||||||
createContractForm.endDate = ''
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
contracts.value = await listContracts()
|
await contract.loadContracts()
|
||||||
await loadEmployee()
|
await loadEmployee()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -330,44 +70,11 @@ export const useEmployeeDetailPage = () => {
|
|||||||
employee,
|
employee,
|
||||||
isLoading,
|
isLoading,
|
||||||
activeTab,
|
activeTab,
|
||||||
contracts,
|
|
||||||
employeeAbsences,
|
|
||||||
leaveSummary,
|
|
||||||
rttSummary,
|
|
||||||
publicHolidays,
|
|
||||||
showLeaveTab,
|
showLeaveTab,
|
||||||
contractHistory,
|
showRttTab,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
contractForm,
|
...contract,
|
||||||
createContractForm,
|
...leave,
|
||||||
isContractDrawerOpen,
|
...rtt
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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.")
|
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hasHolidayInRange(start, end)) {
|
|
||||||
window.alert("Impossible de creer une absence sur un jour ferie.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const overlaps = absences.value.filter((absence) => {
|
const overlaps = absences.value.filter((absence) => {
|
||||||
if (absence.employee?.id !== Number(form.employeeId)) return false
|
if (absence.employee?.id !== Number(form.employeeId)) return false
|
||||||
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex justify-center gap-16 text-2xl font-bold">
|
||||||
<button
|
<button
|
||||||
class="pb-2 border-b-2 flex items-center gap-3"
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
Congé
|
Congé
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="showRttTab"
|
||||||
class="pb-2 border-b-2 flex items-center gap-3"
|
class="pb-2 border-b-2 flex items-center gap-3"
|
||||||
:class="activeTab === 'rtt'
|
:class="activeTab === 'rtt'
|
||||||
? 'border-primary-500 text-primary-500'
|
? 'border-primary-500 text-primary-500'
|
||||||
@@ -91,16 +92,31 @@
|
|||||||
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
|
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
|
||||||
:on-submit-close-contract="submitContractUpdate"
|
:on-submit-close-contract="submitContractUpdate"
|
||||||
:on-submit-create-contract="submitCreateContract"
|
:on-submit-create-contract="submitCreateContract"
|
||||||
|
:suspension-forms="suspensionForms"
|
||||||
|
:is-suspension-submitting="isSuspensionSubmitting"
|
||||||
|
:on-submit-suspension="submitSuspension"
|
||||||
|
:on-add-suspension-form="addSuspensionForm"
|
||||||
|
:current-contract-period-id="currentActiveContractPeriodId"
|
||||||
/>
|
/>
|
||||||
<EmployeesLeaveTab
|
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
|
||||||
v-else-if="showLeaveTab && activeTab === 'leave'"
|
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
class="h-full"
|
Chargement...
|
||||||
:absences="employeeAbsences"
|
</div>
|
||||||
:summary="leaveSummary"
|
<EmployeesLeaveTab
|
||||||
:public-holidays="publicHolidays"
|
v-else
|
||||||
@update-fractioned-days="submitFractionedDays"
|
class="h-full"
|
||||||
/>
|
:absences="employeeAbsences"
|
||||||
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +133,7 @@ const {
|
|||||||
rttSummary,
|
rttSummary,
|
||||||
publicHolidays,
|
publicHolidays,
|
||||||
showLeaveTab,
|
showLeaveTab,
|
||||||
|
showRttTab,
|
||||||
contractHistory,
|
contractHistory,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
contractForm,
|
contractForm,
|
||||||
@@ -149,7 +166,14 @@ const {
|
|||||||
submitContractUpdate,
|
submitContractUpdate,
|
||||||
submitCreateContract,
|
submitCreateContract,
|
||||||
submitFractionedDays,
|
submitFractionedDays,
|
||||||
submitRttPayment
|
submitRttPayment,
|
||||||
|
suspensionForms,
|
||||||
|
isSuspensionSubmitting,
|
||||||
|
submitSuspension,
|
||||||
|
addSuspensionForm,
|
||||||
|
currentActiveContractPeriodId,
|
||||||
|
isLeaveLoading,
|
||||||
|
isRttLoading
|
||||||
} = useEmployeeDetailPage()
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
|
|||||||
38
frontend/services/contractSuspensions.ts
Normal file
38
frontend/services/contractSuspensions.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ContractSuspension } from './dto/employee'
|
||||||
|
|
||||||
|
export const createSuspension = async (payload: {
|
||||||
|
contractPeriodId: number
|
||||||
|
startDate: string
|
||||||
|
endDate?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<ContractSuspension>('/contract_suspensions', {
|
||||||
|
contractPeriodId: payload.contractPeriodId,
|
||||||
|
startDate: payload.startDate,
|
||||||
|
endDate: payload.endDate ?? null,
|
||||||
|
comment: payload.comment ?? null
|
||||||
|
}, {
|
||||||
|
toastSuccessKey: 'Suspension créée',
|
||||||
|
toastErrorKey: 'Erreur lors de la création de la suspension'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateSuspension = async (
|
||||||
|
id: number,
|
||||||
|
payload: {
|
||||||
|
startDate: string
|
||||||
|
endDate?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.patch<ContractSuspension>(`/contract_suspensions/${id}`, {
|
||||||
|
startDate: payload.startDate,
|
||||||
|
endDate: payload.endDate ?? null,
|
||||||
|
comment: payload.comment ?? null
|
||||||
|
}, {
|
||||||
|
toastSuccessKey: 'Suspension modifiée',
|
||||||
|
toastErrorKey: 'Erreur lors de la modification de la suspension'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,5 +10,9 @@ export type EmployeeLeaveSummary = {
|
|||||||
takenSaturdays: number
|
takenSaturdays: number
|
||||||
fractionedDays: number
|
fractionedDays: number
|
||||||
accruingDays: number
|
accruingDays: number
|
||||||
|
previousYearAcquiredDays: number
|
||||||
|
previousYearTakenDays: number
|
||||||
|
previousYearRemainingDays: number
|
||||||
|
presenceDaysByMonth: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,33 @@ export type EmployeeRttWeekSummary = {
|
|||||||
weekNumber: number
|
weekNumber: number
|
||||||
weekStart: string
|
weekStart: string
|
||||||
weekEnd: string
|
weekEnd: string
|
||||||
recoveryMinutes: number
|
overtimeMinutes: number
|
||||||
|
base25Minutes: number
|
||||||
|
bonus25Minutes: number
|
||||||
|
base50Minutes: number
|
||||||
|
bonus50Minutes: number
|
||||||
|
totalMinutes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RttMonthPayment = {
|
export type RttMonthPayment = {
|
||||||
month: number
|
month: number
|
||||||
paidMinutes25: number
|
paidBase25Minutes: number
|
||||||
paidMinutes50: number
|
paidBonus25Minutes: number
|
||||||
|
paidBase50Minutes: number
|
||||||
|
paidBonus50Minutes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmployeeRttSummary = {
|
export type EmployeeRttSummary = {
|
||||||
year: number
|
year: number
|
||||||
|
carryMonth: number
|
||||||
carryFromPreviousYearMinutes: number
|
carryFromPreviousYearMinutes: number
|
||||||
|
carryBase25Minutes: number
|
||||||
|
carryBonus25Minutes: number
|
||||||
|
carryBase50Minutes: number
|
||||||
|
carryBonus50Minutes: number
|
||||||
currentYearRecoveryMinutes: number
|
currentYearRecoveryMinutes: number
|
||||||
totalPaidMinutes: number
|
totalPaidMinutes: number
|
||||||
availableMinutes: number
|
availableMinutes: number
|
||||||
weeks: EmployeeRttWeekSummary[]
|
weeks: EmployeeRttWeekSummary[]
|
||||||
monthPayments: RttMonthPayment[]
|
monthPayments: RttMonthPayment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
import type { Contract } from './contract'
|
import type { Contract } from './contract'
|
||||||
|
|
||||||
|
export type ContractSuspension = {
|
||||||
|
id: number
|
||||||
|
startDate: string
|
||||||
|
endDate?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type ContractHistoryItem = {
|
export type ContractHistoryItem = {
|
||||||
contractId?: number | null
|
contractId?: number | null
|
||||||
contractName?: string | null
|
contractName?: string | null
|
||||||
@@ -9,6 +16,8 @@ export type ContractHistoryItem = {
|
|||||||
startDate: string
|
startDate: string
|
||||||
endDate?: string | null
|
endDate?: string | null
|
||||||
comment?: string | null
|
comment?: string | null
|
||||||
|
periodId?: number | null
|
||||||
|
suspensions?: ContractSuspension[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
@@ -23,4 +32,5 @@ export type Employee = {
|
|||||||
contractHistory?: ContractHistoryItem[]
|
contractHistory?: ContractHistoryItem[]
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
entryDate?: string | null
|
entryDate?: string | null
|
||||||
|
currentSuspensions?: ContractSuspension[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,17 @@ export const getEmployeeRttSummary = async (employeeId: number, year?: number) =
|
|||||||
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
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 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
|
if (year) body.year = year
|
||||||
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||||
}
|
}
|
||||||
|
|||||||
34
migrations/Version20260312140000.php
Normal file
34
migrations/Version20260312140000.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260312140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create contract_suspensions table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE contract_suspensions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
contract_period_id INT NOT NULL REFERENCES employee_contract_periods(id) ON DELETE CASCADE,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE DEFAULT NULL,
|
||||||
|
comment TEXT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||||
|
)');
|
||||||
|
$this->addSql('CREATE INDEX idx_suspension_period_start ON contract_suspensions (contract_period_id, start_date)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE contract_suspensions');
|
||||||
|
}
|
||||||
|
}
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,15 +20,21 @@ use App\State\EmployeeLeaveSummaryProvider;
|
|||||||
)]
|
)]
|
||||||
final class EmployeeLeaveSummary
|
final class EmployeeLeaveSummary
|
||||||
{
|
{
|
||||||
public int $year = 0;
|
public int $year = 0;
|
||||||
public bool $isSupported = false;
|
public bool $isSupported = false;
|
||||||
public string $ruleCode = '';
|
public string $ruleCode = '';
|
||||||
public float $acquiredDays = 0.0;
|
public float $acquiredDays = 0.0;
|
||||||
public float $remainingDays = 0.0;
|
public float $remainingDays = 0.0;
|
||||||
public float $takenDays = 0.0;
|
public float $takenDays = 0.0;
|
||||||
public float $acquiredSaturdays = 0.0;
|
public float $acquiredSaturdays = 0.0;
|
||||||
public float $remainingSaturdays = 0.0;
|
public float $remainingSaturdays = 0.0;
|
||||||
public float $takenSaturdays = 0.0;
|
public float $takenSaturdays = 0.0;
|
||||||
public float $fractionedDays = 0.0;
|
public float $fractionedDays = 0.0;
|
||||||
public float $accruingDays = 0.0;
|
public float $accruingDays = 0.0;
|
||||||
|
public float $previousYearAcquiredDays = 0.0;
|
||||||
|
public float $previousYearTakenDays = 0.0;
|
||||||
|
public float $previousYearRemainingDays = 0.0;
|
||||||
|
|
||||||
|
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
|
||||||
|
public array $presenceDaysByMonth = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use App\State\EmployeeRttPaymentProvider;
|
|||||||
)]
|
)]
|
||||||
final class EmployeeRttPaymentInput
|
final class EmployeeRttPaymentInput
|
||||||
{
|
{
|
||||||
public int $month = 0;
|
public int $month = 0;
|
||||||
public int $minutes = 0;
|
public int $base25Minutes = 0;
|
||||||
public string $rate = '25';
|
public int $bonus25Minutes = 0;
|
||||||
public ?int $year = null;
|
public int $base50Minutes = 0;
|
||||||
|
public int $bonus50Minutes = 0;
|
||||||
|
public ?int $year = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ use App\State\EmployeeRttSummaryProvider;
|
|||||||
final class EmployeeRttSummary
|
final class EmployeeRttSummary
|
||||||
{
|
{
|
||||||
public int $year = 0;
|
public int $year = 0;
|
||||||
|
public int $carryMonth = 5;
|
||||||
public int $carryFromPreviousYearMinutes = 0;
|
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 $currentYearRecoveryMinutes = 0;
|
||||||
public int $availableMinutes = 0;
|
public int $availableMinutes = 0;
|
||||||
public int $totalPaidMinutes = 0;
|
public int $totalPaidMinutes = 0;
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ final class RttRolloverCommand extends Command
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$previousYear = $targetYear - 1;
|
$previousYear = $targetYear - 1;
|
||||||
$carryMinutes = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
$this->logger->error('Error computing carry for employee.', ['employeeId' => $employee->getId(), 'error' => $e->getMessage()]);
|
||||||
++$skipped;
|
++$skipped;
|
||||||
@@ -103,12 +103,15 @@ final class RttRolloverCommand extends Command
|
|||||||
$balance = new EmployeeRttBalance()
|
$balance = new EmployeeRttBalance()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
->setYear($targetYear)
|
->setYear($targetYear)
|
||||||
->setOpeningMinutes($carryMinutes)
|
->setOpeningBase25Minutes($carry->base25Minutes)
|
||||||
|
->setOpeningBonus25Minutes($carry->bonus25Minutes)
|
||||||
|
->setOpeningBase50Minutes($carry->base50Minutes)
|
||||||
|
->setOpeningBonus50Minutes($carry->bonus50Minutes)
|
||||||
->setIsLocked(false)
|
->setIsLocked(false)
|
||||||
;
|
;
|
||||||
|
|
||||||
$this->entityManager->persist($balance);
|
$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;
|
++$created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,9 @@ final class ContractHistoryItem
|
|||||||
public ?string $endDate,
|
public ?string $endDate,
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public ?string $comment = null,
|
public ?string $comment = null,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?int $periodId = null,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public array $suspensions = [],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ final class EmployeeRttWeekSummary
|
|||||||
public int $weekNumber,
|
public int $weekNumber,
|
||||||
public string $weekStart,
|
public string $weekStart,
|
||||||
public string $weekEnd,
|
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 function __construct(
|
||||||
public int $month,
|
public int $month,
|
||||||
public int $paidMinutes25 = 0,
|
public int $paidBase25Minutes = 0,
|
||||||
public int $paidMinutes50 = 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
140
src/Entity/ContractSuspension.php
Normal file
140
src/Entity/ContractSuspension.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Repository\ContractSuspensionRepository;
|
||||||
|
use App\State\ContractSuspensionWriteProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
new Post(processor: ContractSuspensionWriteProcessor::class),
|
||||||
|
new Patch(processor: ContractSuspensionWriteProcessor::class),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['suspension:read']],
|
||||||
|
denormalizationContext: ['groups' => ['suspension:write']],
|
||||||
|
paginationEnabled: false,
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: ContractSuspensionRepository::class)]
|
||||||
|
#[ORM\Table(name: 'contract_suspensions')]
|
||||||
|
#[ORM\Index(columns: ['contract_period_id', 'start_date'], name: 'idx_suspension_period_start')]
|
||||||
|
class ContractSuspension
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['suspension:read', 'employee:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: EmployeeContractPeriod::class, inversedBy: 'suspensions')]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?EmployeeContractPeriod $contractPeriod = null;
|
||||||
|
|
||||||
|
#[Groups(['suspension:write'])]
|
||||||
|
private ?int $contractPeriodId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable')]
|
||||||
|
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
|
private DateTimeImmutable $startDate;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
|
private ?DateTimeImmutable $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
#[Groups(['suspension:read', 'suspension:write', 'employee:read'])]
|
||||||
|
private ?string $comment = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
$this->startDate = new DateTimeImmutable('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContractPeriodId(): ?int
|
||||||
|
{
|
||||||
|
return $this->contractPeriodId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContractPeriodId(?int $contractPeriodId): self
|
||||||
|
{
|
||||||
|
$this->contractPeriodId = $contractPeriodId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContractPeriod(): ?EmployeeContractPeriod
|
||||||
|
{
|
||||||
|
return $this->contractPeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContractPeriod(?EmployeeContractPeriod $contractPeriod): self
|
||||||
|
{
|
||||||
|
$this->contractPeriod = $contractPeriod;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartDate(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartDate(DateTimeImmutable $startDate): self
|
||||||
|
{
|
||||||
|
$this->startDate = $startDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndDate(?DateTimeImmutable $endDate): self
|
||||||
|
{
|
||||||
|
$this->endDate = $endDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComment(): ?string
|
||||||
|
{
|
||||||
|
return $this->comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setComment(?string $comment): self
|
||||||
|
{
|
||||||
|
$this->comment = $comment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -263,6 +263,36 @@ class Employee
|
|||||||
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
|
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: null|int, startDate: string, endDate: null|string, comment: null|string}>
|
||||||
|
*/
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getCurrentSuspensions(): array
|
||||||
|
{
|
||||||
|
$currentPeriod = $this->resolveCurrentContractPeriod();
|
||||||
|
if (null === $currentPeriod) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (ContractSuspension $s): array => [
|
||||||
|
'id' => $s->getId(),
|
||||||
|
'startDate' => $s->getStartDate()->format('Y-m-d'),
|
||||||
|
'endDate' => $s->getEndDate()?->format('Y-m-d'),
|
||||||
|
'comment' => $s->getComment(),
|
||||||
|
],
|
||||||
|
$currentPeriod->getSuspensions()->toArray()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, EmployeeContractPeriod>
|
||||||
|
*/
|
||||||
|
public function getContractPeriods(): Collection
|
||||||
|
{
|
||||||
|
return $this->contractPeriods;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<ContractHistoryItem>
|
* @return list<ContractHistoryItem>
|
||||||
*/
|
*/
|
||||||
@@ -279,6 +309,16 @@ class Employee
|
|||||||
static function (EmployeeContractPeriod $period): ContractHistoryItem {
|
static function (EmployeeContractPeriod $period): ContractHistoryItem {
|
||||||
$contract = $period->getContract();
|
$contract = $period->getContract();
|
||||||
|
|
||||||
|
$suspensionData = array_map(
|
||||||
|
static fn (ContractSuspension $s): array => [
|
||||||
|
'id' => $s->getId(),
|
||||||
|
'startDate' => $s->getStartDate()->format('Y-m-d'),
|
||||||
|
'endDate' => $s->getEndDate()?->format('Y-m-d'),
|
||||||
|
'comment' => $s->getComment(),
|
||||||
|
],
|
||||||
|
$period->getSuspensions()->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
return new ContractHistoryItem(
|
return new ContractHistoryItem(
|
||||||
contractId: $contract?->getId(),
|
contractId: $contract?->getId(),
|
||||||
contractName: $contract?->getName(),
|
contractName: $contract?->getName(),
|
||||||
@@ -287,6 +327,8 @@ class Employee
|
|||||||
startDate: $period->getStartDate()->format('Y-m-d'),
|
startDate: $period->getStartDate()->format('Y-m-d'),
|
||||||
endDate: $period->getEndDate()?->format('Y-m-d'),
|
endDate: $period->getEndDate()?->format('Y-m-d'),
|
||||||
comment: $period->getComment(),
|
comment: $period->getComment(),
|
||||||
|
periodId: $period->getId(),
|
||||||
|
suspensions: $suspensionData,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
$periods
|
$periods
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ namespace App\Entity;
|
|||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Repository\EmployeeContractPeriodRepository;
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
|
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
|
||||||
@@ -43,13 +45,20 @@ class EmployeeContractPeriod
|
|||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
private ?string $comment = null;
|
private ?string $comment = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Collection<int, ContractSuspension>
|
||||||
|
*/
|
||||||
|
#[ORM\OneToMany(mappedBy: 'contractPeriod', targetEntity: ContractSuspension::class, cascade: ['persist', 'remove'])]
|
||||||
|
private Collection $suspensions;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
$this->startDate = new DateTimeImmutable('today');
|
$this->startDate = new DateTimeImmutable('today');
|
||||||
|
$this->suspensions = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -151,4 +160,12 @@ class EmployeeContractPeriod
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, ContractSuspension>
|
||||||
|
*/
|
||||||
|
public function getSuspensions(): Collection
|
||||||
|
{
|
||||||
|
return $this->suspensions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).'])]
|
#[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;
|
private int $year = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 en minutes (solde d ouverture).'])]
|
#[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 $openingMinutes = 0;
|
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).'])]
|
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde est fige (verrouille RH).'])]
|
||||||
private bool $isLocked = false;
|
private bool $isLocked = false;
|
||||||
@@ -74,18 +86,71 @@ class EmployeeRttBalance
|
|||||||
return $this;
|
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;
|
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
|
public function isLocked(): bool
|
||||||
{
|
{
|
||||||
return $this->isLocked;
|
return $this->isLocked;
|
||||||
|
|||||||
@@ -28,11 +28,17 @@ class EmployeeRttPayment
|
|||||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Mois du paiement.'])]
|
||||||
private int $month = 0;
|
private int $month = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'integer', options: ['comment' => 'Duree en minutes.'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
|
||||||
private int $minutes = 0;
|
private int $base25Minutes = 0;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 10, options: ['comment' => 'Taux applique.'])]
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
|
||||||
private string $rate = '';
|
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')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
@@ -88,26 +94,50 @@ class EmployeeRttPayment
|
|||||||
return $this;
|
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;
|
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;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/Repository/ContractSuspensionRepository.php
Normal file
20
src/Repository/ContractSuspensionRepository.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\ContractSuspension;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<ContractSuspension>
|
||||||
|
*/
|
||||||
|
class ContractSuspensionRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ContractSuspension::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,13 +19,12 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
|||||||
parent::__construct($registry, EmployeeRttPayment::class);
|
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([
|
return $this->findOneBy([
|
||||||
'employee' => $employee,
|
'employee' => $employee,
|
||||||
'year' => $year,
|
'year' => $year,
|
||||||
'month' => $month,
|
'month' => $month,
|
||||||
'rate' => $rate,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,59 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
return $qb->getQuery()->getOneOrNullResult();
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count weekend worked days by month.
|
||||||
|
* >= 5h total = 1.0 day, < 5h = 0.5 day.
|
||||||
|
*
|
||||||
|
* @return array<string, float> YYYY-MM => weekend worked day count
|
||||||
|
*/
|
||||||
|
public function countWeekendWorkedDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$sql = <<<'SQL'
|
||||||
|
SELECT TO_CHAR(work_date, 'YYYY-MM') AS month,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN total_minutes >= 300 THEN 1.0
|
||||||
|
WHEN total_minutes > 0 THEN 0.5
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) AS cnt
|
||||||
|
FROM (
|
||||||
|
SELECT work_date,
|
||||||
|
COALESCE(
|
||||||
|
EXTRACT(EPOCH FROM (morning_to::time - morning_from::time)) / 60, 0
|
||||||
|
)
|
||||||
|
+ COALESCE(
|
||||||
|
EXTRACT(EPOCH FROM (afternoon_to::time - afternoon_from::time)) / 60, 0
|
||||||
|
)
|
||||||
|
+ COALESCE(
|
||||||
|
EXTRACT(EPOCH FROM (evening_to::time - evening_from::time)) / 60, 0
|
||||||
|
) AS total_minutes
|
||||||
|
FROM work_hours
|
||||||
|
WHERE employee_id = :employee
|
||||||
|
AND work_date >= :from
|
||||||
|
AND work_date <= :to
|
||||||
|
AND EXTRACT(ISODOW FROM work_date) IN (6, 7)
|
||||||
|
AND (morning_from IS NOT NULL OR afternoon_from IS NOT NULL OR evening_from IS NOT NULL)
|
||||||
|
) sub
|
||||||
|
GROUP BY month
|
||||||
|
SQL;
|
||||||
|
|
||||||
|
$conn = $this->getEntityManager()->getConnection();
|
||||||
|
$rows = $conn->fetchAllAssociative($sql, [
|
||||||
|
'employee' => $employee->getId(),
|
||||||
|
'from' => $from->format('Y-m-d'),
|
||||||
|
'to' => $to->format('Y-m-d'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$result[(string) $row['month']] = (float) $row['cnt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
|
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
|
||||||
{
|
{
|
||||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\Leave;
|
namespace App\Service\Leave;
|
||||||
|
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\ContractSuspension;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Enum\LeaveRuleCode;
|
use App\Enum\LeaveRuleCode;
|
||||||
use App\Repository\AbsenceRepository;
|
use App\Repository\AbsenceRepository;
|
||||||
@@ -29,6 +30,7 @@ final readonly class LeaveBalanceComputationService
|
|||||||
private EmployeeContractPeriodRepository $periodRepository,
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
|
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +69,9 @@ final readonly class LeaveBalanceComputationService
|
|||||||
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
|
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
|
||||||
|
|
||||||
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||||
$acquiredDays = $carryDays + (float) max(0, $this->countBusinessDays($from, $to) - self::FORFAIT_TARGET_WORKED_DAYS) + $fractionedDays;
|
$totalBusinessDays = $this->countBusinessDays($from, $to);
|
||||||
|
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
|
||||||
|
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||||
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
|
[$takenDays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
|
||||||
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
|
$previousRemainingDays = max(0.0, $acquiredDays - $takenDays);
|
||||||
@@ -76,17 +80,22 @@ final readonly class LeaveBalanceComputationService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||||
|
$this->resolveSuspensionsForEmployeePeriod($employee, $from, $to)
|
||||||
|
);
|
||||||
$generatedDays = $this->computeAccruedDays(
|
$generatedDays = $this->computeAccruedDays(
|
||||||
$this->resolveAnnualDays($employee),
|
$this->resolveAnnualDays($employee),
|
||||||
$this->resolveDaysAccrualPerMonth($employee),
|
$this->resolveDaysAccrualPerMonth($employee),
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$to
|
$to,
|
||||||
|
$suspensions
|
||||||
);
|
);
|
||||||
$generatedSaturdays = $this->computeAccruedDays(
|
$generatedSaturdays = $this->computeAccruedDays(
|
||||||
$this->resolveAnnualSaturdays($employee),
|
$this->resolveAnnualSaturdays($employee),
|
||||||
$this->resolveSaturdayAccrualPerMonth($employee),
|
$this->resolveSaturdayAccrualPerMonth($employee),
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$to
|
$to,
|
||||||
|
$suspensions
|
||||||
);
|
);
|
||||||
|
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
|
||||||
@@ -262,7 +271,8 @@ final readonly class LeaveBalanceComputationService
|
|||||||
float $annualCap,
|
float $annualCap,
|
||||||
float $accrualPerMonth,
|
float $accrualPerMonth,
|
||||||
DateTimeImmutable $periodStart,
|
DateTimeImmutable $periodStart,
|
||||||
DateTimeImmutable $periodEnd
|
DateTimeImmutable $periodEnd,
|
||||||
|
array $suspensions = []
|
||||||
): float {
|
): float {
|
||||||
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
|
if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
@@ -280,6 +290,10 @@ final readonly class LeaveBalanceComputationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||||
|
if ([] !== $suspensions) {
|
||||||
|
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
||||||
|
$coveredDays = max(0, $coveredDays - $suspendedDays);
|
||||||
|
}
|
||||||
$daysInMonth = (int) $cursor->format('t');
|
$daysInMonth = (int) $cursor->format('t');
|
||||||
$coveredMonths += $coveredDays / $daysInMonth;
|
$coveredMonths += $coveredDays / $daysInMonth;
|
||||||
|
|
||||||
@@ -404,6 +418,31 @@ final readonly class LeaveBalanceComputationService
|
|||||||
return [$takenDays, $takenSaturdays];
|
return [$takenDays, $takenSaturdays];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ContractSuspension>
|
||||||
|
*/
|
||||||
|
private function resolveSuspensionsForEmployeePeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$suspensions = [];
|
||||||
|
foreach ($employee->getContractPeriods() as $period) {
|
||||||
|
$periodStart = $period->getStartDate();
|
||||||
|
$periodEnd = $period->getEndDate();
|
||||||
|
|
||||||
|
if ($periodStart > $to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($period->getSuspensions() as $suspension) {
|
||||||
|
$suspensions[] = $suspension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $suspensions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{bool, bool}
|
* @return array{bool, bool}
|
||||||
*/
|
*/
|
||||||
|
|||||||
106
src/Service/Leave/SuspensionDaysCalculator.php
Normal file
106
src/Service/Leave/SuspensionDaysCalculator.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Leave;
|
||||||
|
|
||||||
|
use App\Entity\ContractSuspension;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final class SuspensionDaysCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Count calendar days suspended within a month window [monthStart, monthEnd].
|
||||||
|
*
|
||||||
|
* @param list<ContractSuspension> $suspensions
|
||||||
|
*/
|
||||||
|
public function countSuspendedDaysInMonth(
|
||||||
|
DateTimeImmutable $monthStart,
|
||||||
|
DateTimeImmutable $monthEnd,
|
||||||
|
array $suspensions
|
||||||
|
): int {
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach ($suspensions as $suspension) {
|
||||||
|
$sStart = $suspension->getStartDate();
|
||||||
|
$sEnd = $suspension->getEndDate() ?? $monthEnd;
|
||||||
|
|
||||||
|
$overlapStart = $sStart > $monthStart ? $sStart : $monthStart;
|
||||||
|
$overlapEnd = $sEnd < $monthEnd ? $sEnd : $monthEnd;
|
||||||
|
|
||||||
|
if ($overlapStart > $overlapEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total += ((int) $overlapEnd->diff($overlapStart)->format('%a')) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return adjusted suspensions where the first month of each suspension is excluded (grace period).
|
||||||
|
*
|
||||||
|
* @param list<ContractSuspension> $suspensions
|
||||||
|
*
|
||||||
|
* @return list<ContractSuspension>
|
||||||
|
*/
|
||||||
|
public function applyFirstMonthGrace(array $suspensions): array
|
||||||
|
{
|
||||||
|
$adjusted = [];
|
||||||
|
|
||||||
|
foreach ($suspensions as $suspension) {
|
||||||
|
$gracedStart = $suspension->getStartDate()->modify('+1 month');
|
||||||
|
$end = $suspension->getEndDate();
|
||||||
|
|
||||||
|
if ($end instanceof DateTimeImmutable && $gracedStart > $end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$copy = new ContractSuspension();
|
||||||
|
$copy->setStartDate($gracedStart);
|
||||||
|
$copy->setEndDate($end);
|
||||||
|
|
||||||
|
$adjusted[] = $copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count business days (Mon-Fri, excl. public holidays) suspended within a period.
|
||||||
|
*
|
||||||
|
* @param list<ContractSuspension> $suspensions
|
||||||
|
* @param array<string, string> $publicHolidays map of Y-m-d => label
|
||||||
|
*/
|
||||||
|
public function countSuspendedBusinessDays(
|
||||||
|
DateTimeImmutable $periodStart,
|
||||||
|
DateTimeImmutable $periodEnd,
|
||||||
|
array $suspensions,
|
||||||
|
array $publicHolidays
|
||||||
|
): int {
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach ($suspensions as $suspension) {
|
||||||
|
$sStart = $suspension->getStartDate();
|
||||||
|
$sEnd = $suspension->getEndDate() ?? $periodEnd;
|
||||||
|
|
||||||
|
$overlapStart = $sStart > $periodStart ? $sStart : $periodStart;
|
||||||
|
$overlapEnd = $sEnd < $periodEnd ? $sEnd : $periodEnd;
|
||||||
|
|
||||||
|
if ($overlapStart > $overlapEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($cursor = $overlapStart; $cursor <= $overlapEnd; $cursor = $cursor->modify('+1 day')) {
|
||||||
|
$weekDay = (int) $cursor->format('N');
|
||||||
|
$dayKey = $cursor->format('Y-m-d');
|
||||||
|
if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
|
||||||
|
++$total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service\Rtt;
|
namespace App\Service\Rtt;
|
||||||
|
|
||||||
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||||
use App\Dto\WorkHours\WorkMetrics;
|
use App\Dto\WorkHours\WorkMetrics;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
@@ -70,7 +71,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
return $weeks;
|
return $weeks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): int
|
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
|
||||||
{
|
{
|
||||||
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
|
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
|
||||||
$weeks = $this->buildWeeksForExercise($from, $to);
|
$weeks = $this->buildWeeksForExercise($from, $to);
|
||||||
@@ -86,13 +87,25 @@ final readonly class RttRecoveryComputationService
|
|||||||
|
|
||||||
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
|
$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
|
* @param list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
|
||||||
*
|
*
|
||||||
* @return array<string, int>
|
* @return array<string, WeekRecoveryDetail>
|
||||||
*/
|
*/
|
||||||
public function computeRecoveryByWeek(
|
public function computeRecoveryByWeek(
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
@@ -148,13 +161,13 @@ final readonly class RttRecoveryComputationService
|
|||||||
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
|
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
|
||||||
|
|
||||||
if ($effectiveEnd < $effectiveStart) {
|
if ($effectiveEnd < $effectiveStart) {
|
||||||
$results[$weekKey] = 0;
|
$results[$weekKey] = new WeekRecoveryDetail();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
|
if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
|
||||||
$results[$weekKey] = 0;
|
$results[$weekKey] = new WeekRecoveryDetail();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -177,7 +190,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ([] === $weekDays) {
|
if ([] === $weekDays) {
|
||||||
$results[$weekKey] = 0;
|
$results[$weekKey] = new WeekRecoveryDetail();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -191,15 +204,22 @@ final readonly class RttRecoveryComputationService
|
|||||||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||||
? 0
|
? 0
|
||||||
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
||||||
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
|
||||||
? 0
|
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
||||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
|
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
|
||||||
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
||||||
? 0
|
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
|
||||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
|
||||||
$results[$weekKey] = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
$results[$weekKey] = new WeekRecoveryDetail(
|
||||||
? 0
|
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
||||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
base25Minutes: $base25,
|
||||||
|
bonus25Minutes: $bonus25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
bonus50Minutes: $bonus50,
|
||||||
|
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
|
? 0
|
||||||
|
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $results;
|
return $results;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Entity\User;
|
|||||||
use App\Enum\HalfDay;
|
use App\Enum\HalfDay;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DatePeriod;
|
use DatePeriod;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
@@ -22,6 +23,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
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.');
|
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 = [];
|
$segments = [];
|
||||||
foreach ($days as $day) {
|
foreach ($days as $day) {
|
||||||
|
if (isset($publicHolidays[$day->format('Y-m-d')])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
|
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
|
||||||
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
|
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
|
||||||
$isSame = $isFirst && $isLast;
|
$isSame = $isFirst && $isLast;
|
||||||
@@ -246,4 +254,27 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
->setIsValid(false)
|
->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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/State/ContractSuspensionWriteProcessor.php
Normal file
122
src/State/ContractSuspensionWriteProcessor.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\ContractSuspension;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
final readonly class ContractSuspensionWriteProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private ProcessorInterface $persistProcessor,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
mixed $data,
|
||||||
|
Operation $operation,
|
||||||
|
array $uriVariables = [],
|
||||||
|
array $context = []
|
||||||
|
): mixed {
|
||||||
|
if (!$data instanceof ContractSuspension) {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$period = $data->getContractPeriod();
|
||||||
|
|
||||||
|
if (!$period instanceof EmployeeContractPeriod && null !== $data->getContractPeriodId()) {
|
||||||
|
$period = $this->entityManager->find(EmployeeContractPeriod::class, $data->getContractPeriodId());
|
||||||
|
if ($period instanceof EmployeeContractPeriod) {
|
||||||
|
$data->setContractPeriod($period);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$period instanceof EmployeeContractPeriod) {
|
||||||
|
throw new UnprocessableEntityHttpException('contractPeriodId is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate($data, $period);
|
||||||
|
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void
|
||||||
|
{
|
||||||
|
// Compare as Y-m-d strings to avoid timezone issues between Doctrine and API Platform DateTimeImmutable
|
||||||
|
$startDate = $suspension->getStartDate()->format('Y-m-d');
|
||||||
|
$endDate = $suspension->getEndDate()?->format('Y-m-d');
|
||||||
|
$periodStart = $period->getStartDate()->format('Y-m-d');
|
||||||
|
$periodEnd = $period->getEndDate()?->format('Y-m-d');
|
||||||
|
|
||||||
|
if (null !== $periodEnd && $periodEnd < new DateTimeImmutable('today')->format('Y-m-d')) {
|
||||||
|
throw new UnprocessableEntityHttpException('Impossible de suspendre une période de contrat clôturée.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $endDate && $endDate < $startDate) {
|
||||||
|
throw new UnprocessableEntityHttpException('La date de fin doit être postérieure à la date de début.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($startDate < $periodStart) {
|
||||||
|
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer avant le début du contrat.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $periodEnd) {
|
||||||
|
if ($startDate > $periodEnd) {
|
||||||
|
throw new UnprocessableEntityHttpException('La suspension ne peut pas commencer après la fin du contrat.');
|
||||||
|
}
|
||||||
|
if (null !== $endDate && $endDate > $periodEnd) {
|
||||||
|
throw new UnprocessableEntityHttpException('La suspension ne peut pas se terminer après la fin du contrat.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateNoOverlap($suspension, $period);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateNoOverlap(ContractSuspension $suspension, EmployeeContractPeriod $period): void
|
||||||
|
{
|
||||||
|
$start = $suspension->getStartDate()->format('Y-m-d');
|
||||||
|
$end = $suspension->getEndDate()?->format('Y-m-d');
|
||||||
|
|
||||||
|
foreach ($period->getSuspensions() as $existing) {
|
||||||
|
if ($existing->getId() === $suspension->getId() && null !== $suspension->getId()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingStart = $existing->getStartDate()->format('Y-m-d');
|
||||||
|
$existingEnd = $existing->getEndDate()?->format('Y-m-d');
|
||||||
|
|
||||||
|
if (null === $end && null === $existingEnd) {
|
||||||
|
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $end) {
|
||||||
|
if ($start <= $existingEnd) {
|
||||||
|
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $existingEnd) {
|
||||||
|
if ($existingStart <= $end) {
|
||||||
|
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($start <= $existingEnd && $end >= $existingStart) {
|
||||||
|
throw new UnprocessableEntityHttpException('Les suspensions ne peuvent pas se chevaucher.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\EmployeeLeaveSummary;
|
use App\ApiResource\EmployeeLeaveSummary;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\ContractSuspension;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
@@ -17,8 +18,10 @@ use App\Repository\AbsenceRepository;
|
|||||||
use App\Repository\EmployeeContractPeriodRepository;
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
use App\Repository\EmployeeLeaveBalanceRepository;
|
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
use App\Service\Leave\LeaveBalanceComputationService;
|
use App\Service\Leave\LeaveBalanceComputationService;
|
||||||
|
use App\Service\Leave\SuspensionDaysCalculator;
|
||||||
use App\Service\PublicHolidayServiceInterface;
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -50,6 +53,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||||
private LeaveBalanceComputationService $leaveBalanceComputationService,
|
private LeaveBalanceComputationService $leaveBalanceComputationService,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
|
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||||||
@@ -86,16 +91,22 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
|
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
|
||||||
|
|
||||||
$summary->isSupported = true;
|
$summary->isSupported = true;
|
||||||
$summary->ruleCode = $yearSummary['ruleCode'];
|
$summary->ruleCode = $yearSummary['ruleCode'];
|
||||||
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
|
$summary->acquiredDays = $yearSummary['acquiredDays'] + $fractionedDays;
|
||||||
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
|
$summary->acquiredSaturdays = $yearSummary['acquiredSaturdays'];
|
||||||
$summary->fractionedDays = $fractionedDays;
|
$summary->fractionedDays = $fractionedDays;
|
||||||
$summary->accruingDays = $yearSummary['accruingDays'];
|
$summary->accruingDays = $yearSummary['accruingDays'];
|
||||||
$summary->takenDays = $yearSummary['takenDays'];
|
$summary->takenDays = $yearSummary['takenDays'];
|
||||||
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
|
$summary->takenSaturdays = $yearSummary['takenSaturdays'];
|
||||||
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
|
$summary->remainingDays = $yearSummary['remainingDays'] + $fractionedDays;
|
||||||
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
|
$summary->remainingSaturdays = $yearSummary['remainingSaturdays'];
|
||||||
|
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
|
||||||
|
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
|
||||||
|
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||||||
|
|
||||||
|
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
||||||
|
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -109,7 +120,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* takenDays: float,
|
* takenDays: float,
|
||||||
* takenSaturdays: float,
|
* takenSaturdays: float,
|
||||||
* remainingDays: float,
|
* remainingDays: float,
|
||||||
* remainingSaturdays: float
|
* remainingSaturdays: float,
|
||||||
|
* previousYearAcquiredDays: float,
|
||||||
|
* previousYearTakenDays: float,
|
||||||
|
* previousYearRemainingDays: float
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private function computeYearSummary(Employee $employee, int $targetYear): ?array
|
private function computeYearSummary(Employee $employee, int $targetYear): ?array
|
||||||
@@ -170,12 +184,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
||||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
|
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
|
||||||
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||||
|
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||||||
|
);
|
||||||
|
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
||||||
? $this->computeAccruedDaysFromStart(
|
? $this->computeAccruedDaysFromStart(
|
||||||
$leavePolicy['acquiredDays'],
|
$leavePolicy['acquiredDays'],
|
||||||
$leavePolicy['accrualPerMonth'],
|
$leavePolicy['accrualPerMonth'],
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$accrualCalculationEnd
|
$accrualCalculationEnd,
|
||||||
|
$suspensions
|
||||||
)
|
)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
||||||
@@ -183,7 +201,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$leavePolicy['acquiredSaturdays'],
|
$leavePolicy['acquiredSaturdays'],
|
||||||
$leavePolicy['saturdayAccrualPerMonth'],
|
$leavePolicy['saturdayAccrualPerMonth'],
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$accrualCalculationEnd
|
$accrualCalculationEnd,
|
||||||
|
$suspensions
|
||||||
)
|
)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||||
@@ -201,6 +220,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$takenDays += $openingBalance->getTakenDays();
|
$takenDays += $openingBalance->getTakenDays();
|
||||||
$takenSaturdays += $openingBalance->getTakenSaturdays();
|
$takenSaturdays += $openingBalance->getTakenSaturdays();
|
||||||
}
|
}
|
||||||
|
$previousYearAcquired = 0.0;
|
||||||
|
$previousYearTaken = 0.0;
|
||||||
|
$previousYearRemaining = 0.0;
|
||||||
|
|
||||||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
|
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
|
||||||
$availableAcquired = max(0.0, $carryDays);
|
$availableAcquired = max(0.0, $carryDays);
|
||||||
$takenFromAcquired = min($availableAcquired, $takenDays);
|
$takenFromAcquired = min($availableAcquired, $takenDays);
|
||||||
@@ -224,26 +247,38 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
|
$previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
|
||||||
} else {
|
} else {
|
||||||
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
|
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
|
||||||
$acquiredDays = $carryDays + $leavePolicy['acquiredDays'];
|
// Suspensions do not impact forfait 218 leave calculation.
|
||||||
|
// Taken days are first deducted from N-1 carry, then from current year.
|
||||||
|
$previousYearAcquired = $carryDays;
|
||||||
|
$takenFromPrevious = min(max(0.0, $previousYearAcquired), $takenDays);
|
||||||
|
$previousYearTaken = $takenFromPrevious;
|
||||||
|
$takenFromCurrent = $takenDays - $takenFromPrevious;
|
||||||
|
|
||||||
|
$previousYearRemaining = max(0.0, $previousYearAcquired - $takenFromPrevious);
|
||||||
|
|
||||||
|
$acquiredDays = $leavePolicy['acquiredDays'];
|
||||||
$accruingDays = 0.0;
|
$accruingDays = 0.0;
|
||||||
$remainingDays = max(0.0, $acquiredDays - $takenDays);
|
$remainingDays = max(0.0, $acquiredDays - $takenFromCurrent);
|
||||||
$acquiredSaturdays = 0.0;
|
$acquiredSaturdays = 0.0;
|
||||||
$remainingSaturdays = 0.0;
|
$remainingSaturdays = 0.0;
|
||||||
|
|
||||||
$previousRemainingDays = $remainingDays;
|
$previousRemainingDays = $previousYearRemaining + $remainingDays;
|
||||||
$previousRemainingSaturdays = 0.0;
|
$previousRemainingSaturdays = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($year === $targetYear) {
|
if ($year === $targetYear) {
|
||||||
$targetSummary = [
|
$targetSummary = [
|
||||||
'ruleCode' => $leavePolicy['ruleCode'],
|
'ruleCode' => $leavePolicy['ruleCode'],
|
||||||
'acquiredDays' => $acquiredDays,
|
'acquiredDays' => $acquiredDays,
|
||||||
'acquiredSaturdays' => $acquiredSaturdays,
|
'acquiredSaturdays' => $acquiredSaturdays,
|
||||||
'accruingDays' => $accruingDays,
|
'accruingDays' => $accruingDays,
|
||||||
'takenDays' => $takenDays,
|
'takenDays' => $takenDays,
|
||||||
'takenSaturdays' => $takenSaturdays,
|
'takenSaturdays' => $takenSaturdays,
|
||||||
'remainingDays' => $remainingDays,
|
'remainingDays' => $remainingDays,
|
||||||
'remainingSaturdays' => $remainingSaturdays,
|
'remainingSaturdays' => $remainingSaturdays,
|
||||||
|
'previousYearAcquiredDays' => $previousYearAcquired,
|
||||||
|
'previousYearTakenDays' => $previousYearTaken,
|
||||||
|
'previousYearRemainingDays' => $previousYearRemaining,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +369,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
float $acquiredDays,
|
float $acquiredDays,
|
||||||
float $accrualPerMonth,
|
float $accrualPerMonth,
|
||||||
DateTimeImmutable $periodStart,
|
DateTimeImmutable $periodStart,
|
||||||
?DateTimeImmutable $periodEnd
|
?DateTimeImmutable $periodEnd,
|
||||||
|
array $suspensions = []
|
||||||
): float {
|
): float {
|
||||||
if ($accrualPerMonth <= 0.0) {
|
if ($accrualPerMonth <= 0.0) {
|
||||||
return $acquiredDays;
|
return $acquiredDays;
|
||||||
@@ -356,6 +392,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||||
|
if ([] !== $suspensions) {
|
||||||
|
$suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
|
||||||
|
$coveredDays = max(0, $coveredDays - $suspendedDays);
|
||||||
|
}
|
||||||
$daysInMonth = (int) $cursor->format('t');
|
$daysInMonth = (int) $cursor->format('t');
|
||||||
$coveredMonths += $coveredDays / $daysInMonth;
|
$coveredMonths += $coveredDays / $daysInMonth;
|
||||||
|
|
||||||
@@ -514,6 +554,66 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $map;
|
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}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
@@ -706,6 +806,31 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return [$takenDays, $takenSaturdays];
|
return [$takenDays, $takenSaturdays];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ContractSuspension>
|
||||||
|
*/
|
||||||
|
private function resolveSuspensionsForPeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$suspensions = [];
|
||||||
|
foreach ($employee->getContractPeriods() as $period) {
|
||||||
|
$periodStart = $period->getStartDate();
|
||||||
|
$periodEnd = $period->getEndDate();
|
||||||
|
|
||||||
|
if ($periodStart > $to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($period->getSuspensions() as $suspension) {
|
||||||
|
$suspensions[] = $suspension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $suspensions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{bool, bool}
|
* @return array{bool, bool}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
use function in_array;
|
|
||||||
|
|
||||||
final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -42,32 +40,27 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
throw new NotFoundHttpException('Employee not found.');
|
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) {
|
if ($data->month < 1 || $data->month > 12) {
|
||||||
throw new UnprocessableEntityHttpException('month must be between 1 and 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();
|
$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) {
|
if (null === $payment) {
|
||||||
$payment = new EmployeeRttPayment();
|
$payment = new EmployeeRttPayment();
|
||||||
$payment->setEmployee($employee);
|
$payment->setEmployee($employee);
|
||||||
$payment->setYear($year);
|
$payment->setYear($year);
|
||||||
$payment->setMonth($data->month);
|
$payment->setMonth($data->month);
|
||||||
$payment->setRate($data->rate);
|
|
||||||
$this->entityManager->persist($payment);
|
$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();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$data->year = $year;
|
$data->year = $year;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProviderInterface;
|
|||||||
use App\ApiResource\EmployeeRttSummary;
|
use App\ApiResource\EmployeeRttSummary;
|
||||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
use App\Dto\Rtt\RttMonthPayment;
|
use App\Dto\Rtt\RttMonthPayment;
|
||||||
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
@@ -76,22 +77,36 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$limitDate = $periodFrom->modify('-1 day');
|
$limitDate = $periodFrom->modify('-1 day');
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||||
$carryMinutes = $this->resolveCarryMinutes($employee, $year);
|
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
||||||
|
|
||||||
$summary = new EmployeeRttSummary();
|
$summary = new EmployeeRttSummary();
|
||||||
$summary->year = $year;
|
$summary->year = $year;
|
||||||
$summary->carryFromPreviousYearMinutes = $carryMinutes;
|
$summary->carryMonth = $carryMonth;
|
||||||
$summary->currentYearRecoveryMinutes = array_sum($currentByWeekStart);
|
$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->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
||||||
$summary->weeks = array_map(
|
$summary->weeks = array_map(
|
||||||
static fn (array $week) => new EmployeeRttWeekSummary(
|
static function (array $week) use ($currentByWeekStart) {
|
||||||
month: (int) $week['month'],
|
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
|
||||||
weekNumber: (int) $week['weekNumber'],
|
|
||||||
weekStart: $week['start']->format('Y-m-d'),
|
return new EmployeeRttWeekSummary(
|
||||||
weekEnd: $week['end']->format('Y-m-d'),
|
month: (int) $week['month'],
|
||||||
recoveryMinutes: (int) ($currentByWeekStart[$week['start']->format('Y-m-d')] ?? 0),
|
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
|
$weekRanges
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,21 +116,20 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
foreach ($payments as $payment) {
|
foreach ($payments as $payment) {
|
||||||
$m = $payment->getMonth();
|
$m = $payment->getMonth();
|
||||||
if (!isset($monthBuckets[$m])) {
|
if (!isset($monthBuckets[$m])) {
|
||||||
$monthBuckets[$m] = ['paidMinutes25' => 0, 'paidMinutes50' => 0];
|
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
|
||||||
}
|
|
||||||
if ('25' === $payment->getRate()) {
|
|
||||||
$monthBuckets[$m]['paidMinutes25'] += $payment->getMinutes();
|
|
||||||
} else {
|
|
||||||
$monthBuckets[$m]['paidMinutes50'] += $payment->getMinutes();
|
|
||||||
}
|
}
|
||||||
|
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
|
||||||
|
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
|
||||||
|
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
|
||||||
|
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
$monthPayments = [];
|
$monthPayments = [];
|
||||||
$totalPaidMinutes = 0;
|
$totalPaidMinutes = 0;
|
||||||
|
|
||||||
foreach ($monthBuckets as $m => $bucket) {
|
foreach ($monthBuckets as $m => $bucket) {
|
||||||
$monthPayments[] = new RttMonthPayment($m, $bucket['paidMinutes25'], $bucket['paidMinutes50']);
|
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
|
||||||
$totalPaidMinutes += $bucket['paidMinutes25'] + $bucket['paidMinutes50'];
|
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$summary->totalPaidMinutes = $totalPaidMinutes;
|
$summary->totalPaidMinutes = $totalPaidMinutes;
|
||||||
@@ -125,14 +139,29 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
return $summary;
|
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);
|
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
|
||||||
if (null !== $balance) {
|
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
|
private function resolveYear(): int
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Service\Leave;
|
namespace App\Tests\Service\Leave;
|
||||||
|
|
||||||
use App\Service\Leave\LeaveBalanceComputationService;
|
use App\Service\Leave\LeaveBalanceComputationService;
|
||||||
|
use App\Service\Leave\SuspensionDaysCalculator;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
@@ -16,7 +17,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function testComputeAccruedDaysProratesPartialFirstMonth(): void
|
public function testComputeAccruedDaysProratesPartialFirstMonth(): void
|
||||||
{
|
{
|
||||||
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
|
$service = $this->createServiceWithoutConstructor();
|
||||||
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
||||||
|
|
||||||
$result = $method->invoke(
|
$result = $method->invoke(
|
||||||
@@ -32,7 +33,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
|
|||||||
|
|
||||||
public function testComputeAccruedDaysTotalMatchesAlainCase(): void
|
public function testComputeAccruedDaysTotalMatchesAlainCase(): void
|
||||||
{
|
{
|
||||||
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
|
$service = $this->createServiceWithoutConstructor();
|
||||||
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
||||||
|
|
||||||
$days = $method->invoke(
|
$days = $method->invoke(
|
||||||
@@ -55,7 +56,7 @@ final class LeaveBalanceComputationServiceTest extends TestCase
|
|||||||
|
|
||||||
public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void
|
public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void
|
||||||
{
|
{
|
||||||
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
|
$service = $this->createServiceWithoutConstructor();
|
||||||
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
||||||
|
|
||||||
$result = $method->invoke(
|
$result = $method->invoke(
|
||||||
@@ -68,4 +69,15 @@ final class LeaveBalanceComputationServiceTest extends TestCase
|
|||||||
|
|
||||||
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createServiceWithoutConstructor(): LeaveBalanceComputationService
|
||||||
|
{
|
||||||
|
$rc = new ReflectionClass(LeaveBalanceComputationService::class);
|
||||||
|
$service = $rc->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$prop = $rc->getProperty('suspensionDaysCalculator');
|
||||||
|
$prop->setValue($service, new SuspensionDaysCalculator());
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
tests/Service/Leave/SuspensionDaysCalculatorTest.php
Normal file
175
tests/Service/Leave/SuspensionDaysCalculatorTest.php
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Leave;
|
||||||
|
|
||||||
|
use App\Entity\ContractSuspension;
|
||||||
|
use App\Service\Leave\SuspensionDaysCalculator;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SuspensionDaysCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testNoSuspensionsReturnsZero(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$result = $calc->countSuspendedDaysInMonth(
|
||||||
|
new DateTimeImmutable('2026-03-01'),
|
||||||
|
new DateTimeImmutable('2026-03-31'),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(0, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullMonthSuspension(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-03-01', '2026-03-31');
|
||||||
|
|
||||||
|
$result = $calc->countSuspendedDaysInMonth(
|
||||||
|
new DateTimeImmutable('2026-03-01'),
|
||||||
|
new DateTimeImmutable('2026-03-31'),
|
||||||
|
[$suspension]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(31, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartialMonthSuspension(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-03-10', '2026-03-20');
|
||||||
|
|
||||||
|
$result = $calc->countSuspendedDaysInMonth(
|
||||||
|
new DateTimeImmutable('2026-03-01'),
|
||||||
|
new DateTimeImmutable('2026-03-31'),
|
||||||
|
[$suspension]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(11, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSuspensionSpanningMultipleMonths(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-02-15', '2026-04-10');
|
||||||
|
|
||||||
|
// March fully covered
|
||||||
|
$result = $calc->countSuspendedDaysInMonth(
|
||||||
|
new DateTimeImmutable('2026-03-01'),
|
||||||
|
new DateTimeImmutable('2026-03-31'),
|
||||||
|
[$suspension]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(31, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSuspensionWithoutEndDate(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-03-15', null);
|
||||||
|
|
||||||
|
$result = $calc->countSuspendedDaysInMonth(
|
||||||
|
new DateTimeImmutable('2026-03-01'),
|
||||||
|
new DateTimeImmutable('2026-03-31'),
|
||||||
|
[$suspension]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(17, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultipleSuspensionsInSameMonth(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$s1 = $this->buildSuspension('2026-03-01', '2026-03-10');
|
||||||
|
$s2 = $this->buildSuspension('2026-03-20', '2026-03-25');
|
||||||
|
|
||||||
|
$result = $calc->countSuspendedDaysInMonth(
|
||||||
|
new DateTimeImmutable('2026-03-01'),
|
||||||
|
new DateTimeImmutable('2026-03-31'),
|
||||||
|
[$s1, $s2]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(16, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSuspensionOutsideMonthReturnsZero(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-01-01', '2026-01-31');
|
||||||
|
|
||||||
|
$result = $calc->countSuspendedDaysInMonth(
|
||||||
|
new DateTimeImmutable('2026-03-01'),
|
||||||
|
new DateTimeImmutable('2026-03-31'),
|
||||||
|
[$suspension]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(0, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountSuspendedBusinessDays(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
// March 2-6, 2026 = Mon-Fri = 5 business days
|
||||||
|
$suspension = $this->buildSuspension('2026-03-02', '2026-03-06');
|
||||||
|
|
||||||
|
$result = $calc->countSuspendedBusinessDays(
|
||||||
|
new DateTimeImmutable('2026-01-01'),
|
||||||
|
new DateTimeImmutable('2026-12-31'),
|
||||||
|
[$suspension],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(5, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFirstMonthGraceShiftsStartByOneMonth(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-03-15', '2026-06-30');
|
||||||
|
|
||||||
|
$result = $calc->applyFirstMonthGrace([$suspension]);
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertEquals(new DateTimeImmutable('2026-04-15'), $result[0]->getStartDate());
|
||||||
|
self::assertEquals(new DateTimeImmutable('2026-06-30'), $result[0]->getEndDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFirstMonthGraceRemovesSuspensionShorterThanOneMonth(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-03-10', '2026-03-25');
|
||||||
|
|
||||||
|
$result = $calc->applyFirstMonthGrace([$suspension]);
|
||||||
|
|
||||||
|
self::assertCount(0, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFirstMonthGraceOpenEndedSuspension(): void
|
||||||
|
{
|
||||||
|
$calc = new SuspensionDaysCalculator();
|
||||||
|
$suspension = $this->buildSuspension('2026-03-01', null);
|
||||||
|
|
||||||
|
$result = $calc->applyFirstMonthGrace([$suspension]);
|
||||||
|
|
||||||
|
self::assertCount(1, $result);
|
||||||
|
self::assertEquals(new DateTimeImmutable('2026-04-01'), $result[0]->getStartDate());
|
||||||
|
self::assertNull($result[0]->getEndDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildSuspension(string $start, ?string $end): ContractSuspension
|
||||||
|
{
|
||||||
|
$s = new ContractSuspension();
|
||||||
|
$s->setStartDate(new DateTimeImmutable($start));
|
||||||
|
if (null !== $end) {
|
||||||
|
$s->setEndDate(new DateTimeImmutable($end));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ use App\Entity\User;
|
|||||||
use App\Enum\HalfDay;
|
use App\Enum\HalfDay;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use App\State\AbsenceWriteProcessor;
|
use App\State\AbsenceWriteProcessor;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -35,7 +36,7 @@ final class AbsenceWriteProcessorTest extends TestCase
|
|||||||
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
|
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||||
$security = $this->createAdminSecurityStub();
|
$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);
|
$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);
|
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||||
$security = $this->createAdminSecurityStub();
|
$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);
|
$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);
|
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||||
$security = $this->createAdminSecurityStub();
|
$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);
|
$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);
|
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||||
$security = $this->createAdminSecurityStub();
|
$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);
|
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
|
||||||
|
|
||||||
@@ -140,4 +141,12 @@ final class AbsenceWriteProcessorTest extends TestCase
|
|||||||
|
|
||||||
return $security;
|
return $security;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createEmptyHolidayServiceStub(): PublicHolidayServiceInterface
|
||||||
|
{
|
||||||
|
$service = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$service->method('getHolidaysDayByYears')->willReturn([]);
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
132
tests/State/ContractSuspensionWriteProcessorTest.php
Normal file
132
tests/State/ContractSuspensionWriteProcessorTest.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\ContractSuspension;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\State\ContractSuspensionWriteProcessor;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ContractSuspensionWriteProcessorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPersistsValidSuspension(): void
|
||||||
|
{
|
||||||
|
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
|
||||||
|
$suspension = new ContractSuspension();
|
||||||
|
$suspension->setContractPeriod($period);
|
||||||
|
$suspension->setStartDate(new DateTimeImmutable('2026-03-01'));
|
||||||
|
$suspension->setEndDate(new DateTimeImmutable('2026-04-30'));
|
||||||
|
$suspension->setComment('Congé sans solde');
|
||||||
|
|
||||||
|
$persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||||
|
$persistProcessor->expects(self::once())->method('process')->willReturn($suspension);
|
||||||
|
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||||
|
$result = $processor->process($suspension, new Post());
|
||||||
|
|
||||||
|
self::assertSame($suspension, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsEndDateBeforeStartDate(): void
|
||||||
|
{
|
||||||
|
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
|
||||||
|
$suspension = new ContractSuspension();
|
||||||
|
$suspension->setContractPeriod($period);
|
||||||
|
$suspension->setStartDate(new DateTimeImmutable('2026-05-01'));
|
||||||
|
$suspension->setEndDate(new DateTimeImmutable('2026-03-01'));
|
||||||
|
|
||||||
|
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$processor->process($suspension, new Post());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsStartDateBeforePeriodStart(): void
|
||||||
|
{
|
||||||
|
$period = $this->buildPeriodWithId(1, '2026-06-01', null);
|
||||||
|
$suspension = new ContractSuspension();
|
||||||
|
$suspension->setContractPeriod($period);
|
||||||
|
$suspension->setStartDate(new DateTimeImmutable('2026-01-01'));
|
||||||
|
|
||||||
|
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$processor->process($suspension, new Post());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsOverlappingSuspension(): void
|
||||||
|
{
|
||||||
|
$period = $this->buildPeriodWithId(1, '2026-01-01', null);
|
||||||
|
|
||||||
|
$existing = new ContractSuspension();
|
||||||
|
$existing->setContractPeriod($period);
|
||||||
|
$existing->setStartDate(new DateTimeImmutable('2026-03-01'));
|
||||||
|
$existing->setEndDate(new DateTimeImmutable('2026-04-30'));
|
||||||
|
$period->getSuspensions()->add($existing);
|
||||||
|
|
||||||
|
$suspension = new ContractSuspension();
|
||||||
|
$suspension->setContractPeriod($period);
|
||||||
|
$suspension->setStartDate(new DateTimeImmutable('2026-04-01'));
|
||||||
|
$suspension->setEndDate(new DateTimeImmutable('2026-05-31'));
|
||||||
|
|
||||||
|
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$processor->process($suspension, new Post());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsClosedPeriod(): void
|
||||||
|
{
|
||||||
|
$period = $this->buildPeriodWithId(1, '2025-01-01', '2025-12-31');
|
||||||
|
$suspension = new ContractSuspension();
|
||||||
|
$suspension->setContractPeriod($period);
|
||||||
|
$suspension->setStartDate(new DateTimeImmutable('2025-06-01'));
|
||||||
|
$suspension->setEndDate(new DateTimeImmutable('2025-07-31'));
|
||||||
|
|
||||||
|
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$processor->process($suspension, new Post());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPeriodWithId(int $id, string $start, ?string $end): EmployeeContractPeriod
|
||||||
|
{
|
||||||
|
$period = new EmployeeContractPeriod();
|
||||||
|
$period->setStartDate(new DateTimeImmutable($start));
|
||||||
|
if (null !== $end) {
|
||||||
|
$period->setEndDate(new DateTimeImmutable($end));
|
||||||
|
}
|
||||||
|
$period->setContractNature(ContractNature::CDI);
|
||||||
|
|
||||||
|
$ref = new ReflectionProperty($period, 'id');
|
||||||
|
$ref->setValue($period, $id);
|
||||||
|
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user