Files
SIRH/docs/superpowers/plans/2026-03-13-rtt-tab-redesign.md
tristan 4a2c3a8eed
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
feat : Ajout du système de RTT sur la page employé avec le repport annuel des heures
2026-03-13 10:26:33 +01:00

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 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 :

$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.

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 :

#[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 EmployeeRttSummary pour 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 + rate par 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 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 :

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 :

  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
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