# 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` (weekKey => totalMinutes). Il faut retourner `array` avec le détail ventilé. - [ ] **Step 1: Créer le DTO `WeekRecoveryDetail`** ```php // src/Dto/Rtt/WeekRecoveryDetail.php `** 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 = { 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