20 KiB
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
// 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
computeRecoveryByWeekpour retournerarray<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 brutesweeklyOvertime25Minutes= bonus 25% = round(min(worked, 43*60) - overtime25Start) * 0.25weeklyOvertime50Minutes= 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
overtime25Startetmin(worked, 43*60)= c'estmax(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 :
$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
computeTotalRecoveryForExercisepour retourner unWeekRecoveryDetailagré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.
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
openingMinutespar 4 champs dansEmployeeRttBalance
Remplacer la propriété $openingMinutes par :
#[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 :
$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 :
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
EmployeeRttSummarypour le frontend -
Step 4: Ajouter les champs carry dans
EmployeeRttSummary
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+ratepar 4 champs dans l'entité
Remplacer les propriétés $minutes et $rate par :
#[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) :
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
minutesetrate
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 :
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 :
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 :
$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 :
$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 :
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
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 :
$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
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
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 :
- En-tête avec navigation mensuelle (flèches
<>) et "RTT À LA DATE DU JOUR : X heure" - Tableau 7 colonnes : Semaine | Heure | Base | 25% | Base | 50% | Total
- Si mois de juin (premier mois de l'exercice) et carry > 0 : ligne "Report" avec les 4 valeurs carry (colonne Heure = "-")
- 5 lignes semaines (padding si < 5)
- Ligne Total (somme par colonne, incluant le report si présent)
- Ligne Payé (valeurs négatives, "-" pour colonne Heure)
- Ligne Reste (Total - |Payé|, "-" pour colonne Heure)
- Bouton "+ Payer les RRT"
- Drawer de paiement avec 5 champs
Script setup :
currentMonthIndex: ref (0-11) pour la navigation dansorderedMonthIndexes(toujours [5,6,7,8,9,10,11,0,1,2,3,4] = juin à mai)- Initialiser
currentMonthIndexau mois courant dans l'exercice currentMonth: computed qui retourne le numéro de mois (1-12) basé sur l'indexweeksForMonth: computed filtrant les semaines du summary pour le mois courant, paddé à 5monthPayment: computed trouvant le paiement du mois danssummary.monthPayments- Totaux par colonne : computed sommant les semaines
formatMinutes: existant, réutiliser (formatXhouXh Ym)- Navigation :
prevMonth/nextMonthmodifiantcurrentMonthIndexavec 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-paymentavec 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
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