feat : Ajout du système de RTT sur la page employé avec le repport annuel des heures
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s

This commit is contained in:
2026-03-13 10:26:33 +01:00
parent 1858817649
commit 4a2c3a8eed
29 changed files with 1595 additions and 391 deletions

View File

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