19 KiB
19 KiB
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 stackmake test— run backend tests (PHPUnit)make dev-nuxt— dev frontendcd frontend && npm run build— build frontendphp 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']dansnuxt.config.ts). Composants auto-importés avec préfixeMalio*(ex.MalioSelectCheckbox,MalioInputText…). Doc d'usage dansnode_modules/@malio/layer-ui/COMPONENTS.md. Tokens Tailwindm-*(primary/muted/danger/success/…) et variables CSS--m-*fournies par la couche.
Project Structure
src/— Symfony domain, API resources, state providers/processors, servicesfrontend/— Nuxt app (pages, components, composables, services)migrations/— Doctrine migrations (always include workingdown())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 (
InterimAgencyentity, tableinterim_agencies): optionnelle surEmployeeContractPeriodquand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seuleGET /interim_agencies. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat. - Employee contract history:
employee_contract_periods, resolved byEmployeeContractResolver - É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é parEmployeeContractResolver::resolveNatureForEmployeeAndDate), pas viaEmployee.currentContractNature(qui est résolu à aujourd'hui). - É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 dansvisibleEmployees(pages/calendar.vue). - 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é parHolidayVirtualHoursResolver(crédit férié) etWorkedHoursCreditPolicy(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=trueonEmployeeContractPeriod): separate screen/driver-hours, usesdayHoursMinutes/nightHoursMinutes+ meal/overnight flags onWorkHour
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
#b3e5fcavec icônemdi:calendar-stardans 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é endayHoursMinutes. 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 lecountAsWorkedHoursdu 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_commentsROLE_ADMIN. Write processor audite viaAuditLogger. - 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=falseandisValid=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%. - INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses
dayHoursMinutes + nightHoursMinutes + workshopHoursMinutesinstead 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 (
presenceDaysByMonthetpresenceDaysToToday). Implémenté dansEmployeeLeaveSummaryProvider::computePresenceDaysByMonthvia un budget N-1 (=previousYearTakenDays) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé. - Jours de présence — borne début de contrat :
presenceDaysByMonth/presenceDaysToTodaysont calculés à partir deresolveEarliestContractStartWithinRange(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 courant jusqu'à
max(floor_contrat, floor_data_start_date)—floor_contrat= premier exercice avec contrat ouvert (employee.contractHistory[].startDate) ;floor_data_start_date= exercice contenantRTT_START_DATE(env, ex.2026-02-23→ exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format :2026pour FORFAIT,Juin 2025 → Mai 2026sinon. - Changement d'année → recharge complète de l'onglet via
useEmployeeLeave.setSelectedLeaveYear(year)(reload degetEmployeeLeaveSummary?year=YYYY+listAbsences+listPublicHolidays). Backend : filtre?year=YYYYvalidé 2000-2100, etEmployeeLeaveSummaryexposedataStartDate(envRTT_START_DATE, injecté viaservices.yaml). - Sur un exercice passé (
selectedYear !== currentYear), 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. - 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. Même mécanique que l'onglet Congés (double plancher) :
max(floor_contrat, floor_rttStartDate). Format unique :Juin 2025 → Mai 2026. - Changement d'année → recharge via
useEmployeeRtt.setSelectedRttYear(year)(getEmployeeRttSummary?year=YYYY).EmployeeRttSummary.rttStartDateest 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 contraten haut de la fiche employé (pages/employees/[id].vue). Caché si l'employé n'a qu'une phase. - Phase = groupe d'
EmployeeContractPeriodconsécutifs partageant la signature(contract.type, weeklyHours, isDriver). Résolu parApp\Service\Contracts\EmployeeContractPhaseResolver. - Filtre
RTT_START_DATE: les phases dontendDate < RTT_START_DATEsont 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). EndpointsGET /employees/{id}/leave-summaryetGET /employees/{id}/rtt-summaryacceptent?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. Capfromauphase.startDateuniquement 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.) — seulresolveEffectivePeriodStartclampe 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ésau lieu demax(0, businessDays−218)=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 = 252−34 ; entrée = 168−13 ≈ 155). Le header (useEmployeeDetailPage.employeeContractWorkLabel+forfaitRemainingDaysLabel) afficheForfait - {target} jours ({presenceDaysToToday} présence · {target−présence} restants). Avant :218codé 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 vianonForfaitPresenceLabel({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) ;periodFromnon capé surphase.startDate(les semaines avant embauche ou hors phase apparaissent à 0).periodTo/limitDatecapés surphase.endDatepour les phases clôturées.+ Payer les RTTactif uniquement sur l'exercice contenantphase.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.
- Onglet Congés : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur
- 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 unEmployeeRttPaymentsur 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 flagUser.hasLeaveRecapAccess(défautfalse) — 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 gateisValid. - Helper :
App\Util\LeaveRecapCutoff::resolveCutoff() - Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé :
LeaveRecapRowBuilderconsommé parLeaveRecapPrintProvider(as-of today) etEmployeeLeaveRecapProvider(as-of cutoff) EmployeeLeaveSummaryProvider::computeYearSummary()accepte un?DateTimeImmutable $asOfDatequi 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 > 0ouamount > 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
+ Ajouterbutton, 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: falseon 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
AuditLoggerand log create/update/delete actions AuditLogger::log()persists without flushing — the processor'sflush()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
requiredLevelthat 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