Files
SIRH/docs/superpowers/plans/2026-06-01-day-view-per-date-tracking-mode.md
T
tristan a41bd632cf
Auto Tag Develop / tag (push) Successful in 11s
Retour RH: vue jour par date, RTT mi-semaine, récap salaire & exports, panier de nuit (#21)
## Correctifs RH (branche fix/retour-rh)

### Vue Jour (Heures)
- Mode saisie/présence, libellé de contrat et sauvegarde résolus **à la date affichée** (et non au contrat courant). Corrige les salariés passés 39h/35h → Forfait.

### RTT — heures supplémentaires
- Proratisation du **plafond 25%/50%** pour les embauches en milieu de semaine (la bande +25% se décale au lieu de rester bloquée à 43h). Témoin Dylan : 4h à 25% + 3h à 50%.

### Récap salaire (PDF mensuel)
- Forfait : congés imputés **N-1** non affichés et comptés en présence.
- Colonne « Heures payés » **scindée 25% / 50%** (en-tête fusionné).
- **Exclusion des salariés sans contrat** sur le mois (ex. Marine, contrat terminé).

### Exports heures annuelles (par salarié + tous)
- **Tous les jours sous contrat** affichés, même vides/non saisis (corrige les lignes manquantes).
- Samedis/dimanches en **gris plus foncé**.

### Panier de nuit
- **Ne s'applique pas aux conducteurs** (vue semaine + récap salaire).

## Tests
- 11 tests ajoutés. Suite verte hors un test legacy pré-existant dépendant de la date (`EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting`, non modifié par cette branche).

## À noter (hors scope)
- L'export heures annuelles *tous salariés* peut dépasser `memory_limit=256M` (Dompdf) — limitation **pré-existante**, non corrigée ici.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #21
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-02 06:26:40 +00:00

13 KiB

Vue Jour — contrat résolu à la date affichée — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Faire que l'écran Heures — vue Jour affiche/sauvegarde la saisie d'heures (TIME) vs cases de présence (PRESENCE) et le libellé de contrat selon le contrat valable à la date affichée, et non le contrat courant de l'employé.

Architecture: Le provider backend WorkHourDayContextProvider résout déjà le contrat à la date demandée. On expose 4 champs de contrat supplémentaires sur la ligne du jour (DayContextRow), on les reflète dans le DTO TS, puis le composable useHoursPage lit ces champs par date (fallback sur employee.contract si pas de ligne du jour). handleSave en hérite automatiquement.

Tech Stack: Symfony / API Platform (PHP 8.4, PHPUnit) côté backend ; Nuxt 4 / Vue 3 / TypeScript côté frontend.

Spec : docs/superpowers/specs/2026-06-01-day-view-per-date-tracking-mode-design.md


File Structure

  • src/Dto/WorkHours/DayContextRow.php — DTO ligne du jour : +4 champs.
  • src/State/WorkHourDayContextProvider.php — peuple les 4 champs depuis le contrat du jour.
  • tests/State/WorkHourDayContextProviderTest.php — test de résolution par date.
  • frontend/services/dto/work-hour.ts — type WorkHourDayContextRow : +4 champs.
  • frontend/composables/useHoursPage.ts — helpers résolus par date.
  • doc/ + frontend/data/documentation-content.ts + CLAUDE.md — documentation.

Task 1: Backend — exposer le contrat du jour sur DayContextRow

Files:

  • Modify: src/Dto/WorkHours/DayContextRow.php

  • Modify: src/State/WorkHourDayContextProvider.php:60-71

  • Test: tests/State/WorkHourDayContextProviderTest.php

  • Step 1: Écrire le test en échec

Ajouter cette méthode dans tests/State/WorkHourDayContextProviderTest.php (après testBuildsRowsWithAbsenceCredits). Elle vérifie que la ligne porte le contrat à la date demandée pour un employé 39h→Forfait. On remplace le resolver stub par un callback dépendant de la date.

public function testRowCarriesContractAtRequestedDate(): void
{
    $user = new User();

    $timeContract = new Contract()
        ->setName('Contrat')
        ->setTrackingMode(Contract::TRACKING_TIME)
        ->setWeeklyHours(39)
    ;
    $forfaitContract = new Contract()
        ->setName('Forfait')
        ->setTrackingMode(Contract::TRACKING_PRESENCE)
        ->setWeeklyHours(null)
    ;
    $employee = new Employee()
        ->setFirstName('Jean')
        ->setLastName('Test')
        ->setContract($forfaitContract)
    ;
    $this->setEntityId($employee, 1);

    // Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date.
    $resolver = $this->createStub(EmployeeContractResolver::class);
    $resolver->method('resolveForEmployeeAndDate')->willReturnCallback(
        static fn (Employee $e, \DateTimeImmutable $d): ?Contract =>
            $d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract
    );
    $resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI);

    $this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
    $this->security->method('getUser')->willReturn($user);
    $this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
    $this->absenceRepository->method('findByDateAndEmployees')->willReturn([]);

    $provider = new WorkHourDayContextProvider(
        $this->security,
        $this->requestStack,
        $this->employeeRepository,
        $this->absenceRepository,
        $this->formationRepository,
        $resolver,
        new AbsenceSegmentsResolver(),
        new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()),
        $this->buildHolidayResolver(),
    );

    $row = $provider->provide(new Get())->rows[0];

    self::assertSame('TIME', $row['trackingMode']);
    self::assertSame(39, $row['weeklyHours']);
    self::assertSame('39H', $row['contractType']);
    self::assertSame('Contrat', $row['contractName']);
}
  • Step 2: Lancer le test, vérifier l'échec

Run: make test Expected: FAIL — Undefined array key "trackingMode" (le DTO ne porte pas encore le champ).

  • Step 3: Ajouter les 4 champs au DTO

Dans src/Dto/WorkHours/DayContextRow.php, ajouter au constructeur après public ?string $contractNature = null, :

        public ?string $trackingMode = null,
        public ?int $weeklyHours = null,
        public ?string $contractType = null,
        public ?string $contractName = null,

Mettre à jour le PHPDoc de toArray() (ajouter les 4 clés après contractNature:?string) :

     *     contractNature:?string,
     *     trackingMode:?string,
     *     weeklyHours:?int,
     *     contractType:?string,
     *     contractName:?string

Et le corps de toArray(), après 'contractNature' => $this->contractNature, :

            'trackingMode'          => $this->trackingMode,
            'weeklyHours'           => $this->weeklyHours,
            'contractType'          => $this->contractType,
            'contractName'          => $this->contractName,
  • Step 4: Peupler les champs dans le provider

Dans src/State/WorkHourDayContextProvider.php, dans l'appel new DayContextRow(...) (lignes 65-71), ajouter après contractNature: $contractNature, :

                trackingMode: $contract?->getTrackingMode(),
                weeklyHours: $contract?->getWeeklyHours(),
                contractType: $contract?->getType()->value,
                contractName: $contract?->getName(),
  • Step 5: Lancer le test, vérifier le succès

Run: make test Expected: PASS (le nouveau test et les 150 autres ; le test legacy EmployeeRttSummaryProviderTest::testNoQueryParamsKeepsLegacyYearDefaulting peut rester rouge — pré-existant, dépendant de la date, hors périmètre).

  • Step 6: Commit
git add src/Dto/WorkHours/DayContextRow.php src/State/WorkHourDayContextProvider.php tests/State/WorkHourDayContextProviderTest.php
git commit -m "[#SIRH] Vue jour: exposer le contrat du jour sur DayContextRow"

Task 2: Frontend — lire le contrat par date dans la vue Jour

Files:

  • Modify: frontend/services/dto/work-hour.ts:103-118

  • Modify: frontend/composables/useHoursPage.ts

  • Step 1: Refléter les 4 champs dans le DTO TS

Dans frontend/services/dto/work-hour.ts, type WorkHourDayContextRow, ajouter après contractNature?: ... (ligne 117) :

  trackingMode?: TrackingMode | null
  weeklyHours?: number | null
  contractType?: string | null
  contractName?: string | null

(TrackingMode est déjà importé dans ce fichier — utilisé ligne 73.)

  • Step 2: Ajouter un résolveur de contrat par date dans le composable

Dans frontend/composables/useHoursPage.ts, juste avant const isPresenceTracking (ligne 353), insérer :

  // Résout le contrat à la date affichée (ligne du jour), avec repli sur le contrat courant.
  const resolveDayContract = (employee: Employee) => {
    const dayRow = dayContextByEmployeeId.value.get(employee.id)
    if (dayRow?.hasContractAtDate) {
      return {
        trackingMode: dayRow.trackingMode ?? null,
        weeklyHours: dayRow.weeklyHours ?? null,
        type: dayRow.contractType ?? null,
        name: dayRow.contractName ?? ''
      }
    }
    return {
      trackingMode: employee.contract?.trackingMode ?? null,
      weeklyHours: employee.contract?.weeklyHours ?? null,
      type: employee.contract?.type ?? null,
      name: employee.contract?.name ?? ''
    }
  }
  • Step 3: Brancher les helpers sur le contrat du jour

Toujours dans frontend/composables/useHoursPage.ts, remplacer les définitions actuelles (lignes 353-358 et 367-377).

Remplacer :

  const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
  const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
  const is4hContract = (employeeId: number) => {
    const employee = employees.value.find((e) => e.id === employeeId)
    return employee?.contract?.weeklyHours === 4
  }

par :

  const isPresenceTracking = (employee: Employee) => resolveDayContract(employee).trackingMode === TRACKING_MODES.PRESENCE
  const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
  const is4hContract = (employeeId: number) => {
    const employee = employees.value.find((e) => e.id === employeeId)
    return employee ? resolveDayContract(employee).weeklyHours === 4 : false
  }

Remplacer :

  const contractLabel = (employee: Employee) => {
    const contract = employee.contract
    if (!contract) return '-'
    if (contract.type === CONTRACT_TYPES.INTERIM) {
      return contract.name
    }
    if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
      return `${contract.weeklyHours}h`
    }
    return contract.name
  }

par :

  const contractLabel = (employee: Employee) => {
    const contract = resolveDayContract(employee)
    if (!contract.type && !contract.name) return '-'
    if (contract.type === CONTRACT_TYPES.INTERIM) {
      return contract.name
    }
    if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
      return `${contract.weeklyHours}h`
    }
    return contract.name
  }
  • Step 4: Vérification statique de lecture

Relire le diff : resolveDayContract est défini après dayContextByEmployeeId (ligne 201) et employees — donc disponible. isPresenceTracking reste de signature (employee: Employee) ⇒ aucun appelant à modifier (HoursDayView.vue, handleSave ligne 1073 inchangés). CONTRACT_TYPES/TRACKING_MODES déjà importés (ligne 8).

  • Step 5: Commit
git add frontend/services/dto/work-hour.ts frontend/composables/useHoursPage.ts
git commit -m "[#SIRH] Vue jour: saisie/présence et libellé résolus à la date affichée"

Task 3: Documentation

Files:

  • Modify: doc/functional-rules.md

  • Modify: frontend/data/documentation-content.ts

  • Modify: CLAUDE.md

  • Step 1: Doc fonctionnelle

Il n'existe pas de doc dédiée « Heures » dans doc/ ; ajouter le paragraphe suivant dans doc/functional-rules.md (section traitant des écrans Heures / du contrat, ou en fin de fichier sous un titre ## Vue Jour — contrat à la date affichée) :

Vue Jour (Heures) — contrat à la date affichée : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs trackingMode/weeklyHours/contractType/contractName portés par WorkHourDayContext), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait.

  • Step 2: Doc in-app

Dans frontend/data/documentation-content.ts, repérer la section/article de l'écran « Heures » (grep -n "Heures" frontend/data/documentation-content.ts) et ajouter un bloc texte :

Sur la vue Jour, l'affichage (saisie d'heures ou présence) et le libellé de contrat correspondent au contrat de l'employé à la date consultée. Si un salarié a changé de type de contrat (ex. passage en forfait), les jours antérieurs restent affichés selon l'ancien contrat.

  • Step 3: CLAUDE.md

Dans CLAUDE.md, section « Écrans Heures / Heures Conducteurs (vue jour) », compléter la puce existante sur contractNature par une phrase :

Idem pour le mode de suivi et le libellé de contrat sur la vue Jour : résolus à la date filtrée via WorkHourDayContext (trackingMode/weeklyHours/contractType/contractName, peuplés depuis EmployeeContractResolver::resolveForEmployeeAndDate), pas via employee.contract (résolu à aujourd'hui). Le composable lit resolveDayContract() (useHoursPage.ts), ce qui pilote aussi handleSave (heures vs présence par date).

  • Step 4: Commit
git add doc/ frontend/data/documentation-content.ts CLAUDE.md
git commit -m "docs: vue jour contrat à la date affichée (doc + in-app + CLAUDE.md)"

Vérification finale (manuelle, par l'utilisateur)

  • Sur la fiche d'un salarié passé 39h/35h → Forfait, écran Heures vue Jour :
    • naviguer une date avant la bascule → champs de saisie d'heures, libellé 39h/35h ;
    • naviguer une date après la bascule → cases de présence, libellé Forfait ;
    • éditer puis enregistrer une date avant la bascule → les heures sont conservées (pas de flags présence).