Files
SIRH/CLAUDE.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

181 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SIRH
## Mandatory Rules
- Any functional change MUST update `doc/` in the same intervention
- Any functional change MUST update the in-app documentation (`frontend/data/documentation-content.ts`) in the same intervention
- At the end of every feature addition or functional modification, update this CLAUDE.md to reflect new patterns, rules, or conventions introduced
## Commands
- `make start` — start Docker stack
- `make test` — run backend tests (PHPUnit)
- `make dev-nuxt` — dev frontend
- `cd frontend && npm run build` — build frontend
- `php bin/console cache:clear && php bin/console cache:warmup` — clear cache after deploy
## Stack
- Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
## Project Structure
- `src/` — Symfony domain, API resources, state providers/processors, services
- `frontend/` — Nuxt app (pages, components, composables, services)
- `migrations/` — Doctrine migrations (always include working `down()`)
- `doc/` — functional rules and business documentation
## Functional Rules
- Reference: `doc/functional-rules.md` (mandatory reading before any business logic change)
- Complementary: `doc/leave-rollover.md`, `doc/rtt-rollover.md`
## Domain Model
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo 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). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date).
- **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin.
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin.
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
- **Panier de nuit (PN) — conducteurs exclus** : le panier de nuit (règle nuit > jour OU nuit ≥ 4h) **ne s'applique qu'aux non-conducteurs**. Un jour conducteur ne crédite jamais de PN, ni sur la vue semaine (`WorkHourWeeklySummaryProvider`, garde `!$isDateDriver`) ni sur le récap salaire (`SalaryRecapPrintProvider`, bloc `if ($isDriver)` sans incrément). Les conducteurs ont leurs propres primes (PDJ/repas/nuitée).
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
- Création/édition d'absence **autorisée** sur un férié (bouton Modifier visible). En présence d'absence, le crédit d'heures suit `absence.type.countAsWorkedHours` (WorkedHoursCreditPolicy), pas le crédit virtuel férié.
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
## Commentaires de semaine
- Entité `EmployeeWeekComment` : commentaire libre par employé et semaine ISO (unique `(employee_id, week_start_date)`). `week_start_date` = lundi.
- CRUD `/employee_week_comments` `ROLE_ADMIN`. Write processor audite via `AuditLogger`.
- Picto bulle vue semaine (HoursWeekView + DriverHoursWeekView) : fond bleu/rouge. Intégré dans `WeeklySummaryRow.comment/commentId`.
- Doc : `doc/week-comments.md`.
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
- Any real modification resets both `isSiteValid=false` and `isValid=false`
- No-op saves preserve existing validations
## Overtime Rules
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
- **Ancre de semaine (type de contrat)** : le type/nature de contrat d'une semaine RTT est résolu sur le **premier jour contracté** de la semaine, pas sur le lundi (`RttRecoveryComputationService::resolveWeekAnchorDate`). Sinon une semaine d'embauche en milieu de semaine (lundi hors contrat) serait classée CUSTOM → bonus 25%/50% désactivés à tort. Ex. CDD 39h embauché le jeudi : la semaine reste 39h, le seuil 25% est proraté aux jours contractés (`computeWeeklyOvertime25StartMinutes`), donc les heures au-delà ouvrent bien le +25%.
- **Plafond 25%/50% proraté (mi-semaine)** : le plafond séparant 25% et 50% n'est **pas** codé en dur à 43h mais vaut `seuil_départ_proraté + largeur_bande_25%` (`RttRecoveryComputationService::{resolveOvertime25BandWidthMinutes, computeOvertimeBaseMinutes}`). Largeur = 43h base (4h pour un 39h, 8h pour un 35h). Pour une semaine pleine le plafond redonne 43h (aucune régression) ; pour une embauche mi-semaine il se décale avec le départ, ouvrant la tranche 50%. Témoin Dylan (CDD 39h embauché jeudi, 22h) : 4h à 25% + 3h à 50%. **Hors périmètre** : l'écran Heures (`WorkHourWeeklySummaryProvider`) n'a pas cette proratisation (calcul dupliqué, laissé tel quel par décision métier).
- INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
- **Récap salaire (export PDF mensuel)** : même règle appliquée dans `SalaryRecapPrintProvider` — un congé forfait imputé N-1 n'est ni affiché en colonne congés ni soustrait, et compte comme jour de présence. Le budget N-1 vient de `EmployeeLeaveSummaryProvider::resolvePreviousYearTakenDays()` (méthode publique mutualisée, qui reproduit `provide()` : phase courante + recalcul jours payés, donc **même budget que la fiche employé**). Comme l'export est mensuel, les congés sont chargés depuis le 1er janvier (`findForPrint(yearStart, to)`) et le budget consommé chronologiquement par `splitForfaitCongesByN1()`. Non-forfait ou budget N-1 = 0 → `countAbsencesByCode(['C'])` inchangé.
- **Colonne « Heures payés » scindée 25 %/50 %** : en-tête fusionné (`colspan=2`) + deux sous-colonnes `25%`/`50%` dans le template `salary-recap/print.html.twig`. Données : `paid25Hours` = `base25Minutes`, `paid50Hours` = `base50Minutes` (bases seules, **hors bonus** — total inchangé vs l'ancienne colonne unique). `buildRttPaymentMap` renvoie `['m25','m50']` par employé. Le tableau a désormais 20 colonnes (`colspan` des lignes site/vide ajusté).
- **Jours de présence — borne début de contrat** : `presenceDaysByMonth`/`presenceDaysToToday` sont calculés à partir de `resolveEarliestContractStartWithinRange` (début de contrat dans l'exercice), pas du début d'exercice brut. Évite de compter comme « présents » les jours ouvrés antérieurs à l'embauche pour une entrée en cours d'exercice (ex. CDD : Dylan passait de 43,5 à 246 sans la borne). Sans effet pour un employé présent depuis avant l'exercice ni pour le forfait (déjà capé au début de phase).
## Onglet Congés (fiche employé)
- Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée.
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice **suivant** (exercice courant + 1 sur une phase ouverte ; exercice de fin de phase si clôturée) jusqu'à `max(floor_contrat, floor_data_start_date)``floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
- Changement d'année → recharge complète de l'onglet via `useEmployeeLeave.setSelectedLeaveYear(year)` (reload de `getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`). Backend : filtre `?year=YYYY` validé 2000-2100, et `EmployeeLeaveSummary` expose `dataStartDate` (env `RTT_START_DATE`, injecté via `services.yaml`).
- Sur un exercice **autre que l'exercice courant** (`selectedYear !== currentYear`, passé ou futur), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report (ni d'édition anticipée sur un exercice futur).
- Doc : `doc/leave-tab.md`.
## Onglet RTT (fiche employé)
- Tableau hebdomadaire (`frontend/components/employees/RttTab.vue`) — exercice fixe Juin(N-1)→Mai(N). Onglet **masqué pour les FORFAIT** (`showRttTab`).
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Double plancher `max(floor_contrat, floor_rttStartDate)`. Borne haute = exercice courant : **contrairement à l'onglet Congés, le RTT ne propose PAS l'exercice suivant** (consulter un exercice RTT à venir — heures non saisies, rien à payer — n'a pas de sens ; cf. `availableRttYears`). Format unique : `Juin 2025 → Mai 2026`.
- Changement d'année → recharge via `useEmployeeRtt.setSelectedRttYear(year)` (`getEmployeeRttSummary?year=YYYY`). `EmployeeRttSummary.rttStartDate` est déjà exposé (champ existant) — il sert à la fois au floor du sélecteur et au masquage des lignes Report avant la mise en service.
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
- Doc : `doc/rtt-tab.md`.
## Vue contrat (sélecteur de phase)
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
- **Filtre `RTT_START_DATE`** : les phases dont `endDate < RTT_START_DATE` sont masquées (aucune donnée logiciel avant la mise en service). Le resolver reçoit la date via DI (`services.yaml`) ; `Employee::getContractPhases()` lit `$_SERVER['RTT_START_DATE']` pour instancier le resolver côté entité.
- Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante.
- Sélectionner une phase passée :
- Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur `phase.endDate`. **Cap `from` au `phase.startDate` uniquement pour FORFAIT** (sémantique année civile). Pour le non-forfait, l'exercice CP reste annuel et continu à travers les changements d'heures (35h→39h, etc.) — seul `resolveEffectivePeriodStart` clampe sur la date d'entrée en contrat des nouveaux embauchés.
- **Entrée FORFAIT en cours d'année** (année d'entrée only) : l'exercice d'entrée crédite `repos_proratisés + CP_reportés` au lieu de `max(0, businessDays218)`=0. Repos année = `jours_ouvrés_année 218 25`, proratisés par jours ouvrés. CP reportés = solde de la phase non-forfait précédente (jours ouvrés nets + samedis bruts ; un samedi posé ne réduit PAS le report ; fractionnés exclus). Nouvel embauché = repos seuls. Années pleines suivantes + forfait démarrant le 01/01 = calcul 218 inchangé (→34). Services : `EmployeeLeaveSummaryProvider::{resolveLeavePolicy (branche FORFAIT), isForfaitEntryYear, computeProratedForfaitRepoDays, resolveCarriedCpFromPriorPhase}`. Témoin Grégory : ≈13.
- **Header fiche employé (jours à travailler / présence / restant)** : `EmployeeLeaveSummary.forfaitWorkTargetDays` = `jours_ouvrés_période acquiredDays` (218 année pleine = 25234 ; entrée = 16813 ≈ **155**). Le header (`useEmployeeDetailPage.employeeContractWorkLabel` + `forfaitRemainingDaysLabel`) affiche `Forfait - {target} jours ({presenceDaysToToday} présence · {targetprésence} restants)`. Avant : `218` codé en dur → faux pour une entrée en cours d'année. Témoin Grégory : `155 jours (11 présence · 144 restants)`. **Non-forfait** : le header affiche les jours de présence seuls via `nonForfaitPresenceLabel` (`{weeklyHours} heures ({presenceDaysToToday} présence)`, ex. `39 heures (43,5 présence)`). Le récap congés est désormais chargé en eager pour tout employé avec onglet Congés (pas seulement le forfait) afin d'alimenter ce libellé.
- Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. Tableau hebdo affiché sur l'exercice complet (Juin→Mai) ; `periodFrom` non capé sur `phase.startDate` (les semaines avant embauche ou hors phase apparaissent à 0). `periodTo`/`limitDate` capés sur `phase.endDate` pour les phases clôturées. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`.
- Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée.
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
- CP : solder via `EmployeeContractPeriod.paidLeaveSettledClosureDate` (mécanisme existant). RTT : créer un `EmployeeRttPayment` sur le dernier exercice de la phase.
- "Exercise year for date" mutualisé dans `App\Service\Exercise\ExerciseYearResolver` (forfait = année civile, non-forfait = Juin N-1 → Mai N).
- Doc complète : `doc/contract-phase-view.md`.
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 14j)`. Pas de gate `isValid`.
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
- Pas d'export PDF depuis cet écran
- Doc détaillée : `doc/leave-recap-screen.md`
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
## Formations
- Onglet "Formation" sur la fiche employé (admin uniquement)
- Champs : date début, date fin, justificatif PDF optionnel, commentaire
- Validation: dates obligatoires, `endDate >= startDate`, fichier PDF uniquement
- Justificatif stocké dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf` (année/mois = startDate)
- Suppression et remplacement du justificatif nettoient l'ancien fichier disque
- Tri tableau par `startDate DESC`
- Affichage écran Heures (jour) : pill "Formation" (indigo) dans la colonne Absence. Quand une formation existe, le bouton "Modifier" de la colonne Absence est masqué (lockdown complet du jour pour la gestion d'absence)
- Affichage Calendrier : cellule "F" (indigo) si formation seule, ou icône école en coin si formation + absence. Cellules avec formation non cliquables. Légende dédiée. PDF export : code "F" indigo ou astérisque à côté du code d'absence
- Le CRUD formation est exclusivement géré depuis la fiche employé > onglet Formation
## Frontend Patterns
### Table styling (standard across all pages)
- Header: `grid border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`
- Body wrapper: `border-x border-b border-primary-500 rounded-b-md`
- Rows: `grid items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500`
- Page wrapper for scroll: `h-full flex flex-col overflow-hidden`, table container: `min-h-0 overflow-auto rounded-md bg-white`
### Drawer buttons (AppDrawer)
- Edit mode: `grid grid-cols-2 gap-3` → Supprimer (red, left) + Modifier (primary, right)
- Create mode: centered `+ Ajouter` button, w-[200px]
- Exception: Users drawer has NO delete button
- All "Ajouter" buttons across the app use "+" prefix
### API Platform (backend)
- Custom operations use Processor (write) / Provider (read)
- File uploads: `deserialize: false` on Post, access file via RequestStack
- Upload dir: `%kernel.project_dir%/var/uploads`
## Audit Logging
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
- Documentation: `doc/audit-logging.md`
## Backend Conventions
- Prefer explicit DTOs over associative arrays
- Business rules in backend (providers/processors/services), frontend is display/interaction only
- Keep backend PHP DTOs aligned with frontend TS DTOs (`frontend/services/dto/*`)
- Update unit tests when constructor/service signatures change
## In-App Documentation
- Content: `frontend/data/documentation-content.ts` — structured TypeScript data with all user-facing documentation
- Types: `frontend/types/documentation.ts` — DocSection, DocArticle, DocBlock
- Composable: `frontend/composables/useDocumentation.ts` — role-based filtering (employee < site_manager < admin)
- Components: `frontend/components/documentation/` — DocumentationPage, DocumentationSection, DocumentationArticle
- Page: `frontend/pages/documentation.vue`
- 3 access levels: `employee` (ROLE_SELF), `site_manager` (ROLE_USER), `admin` (ROLE_ADMIN) — cumulative (admin sees everything)
- Each section/article has a `requiredLevel` that controls visibility
- When adding or modifying a feature, update the corresponding section in `documentation-content.ts`
## Language
- UI is in French
- User communicates in French
- Code (variables, comments) in English