Add design spec for the contract-phase picker on the employee detail page. Lets HR navigate past contract phases (e.g. 39h before a switch to FORFAIT, or a closed CDD) so they can view and settle leftover CP/RTT balances without changing the default behavior for the current contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
Vue contrat (sélecteur de phase) — Design Spec
Objectif
Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon le contrat actuel ET selon ses contrats passés, sans changement de comportement par défaut.
Cas qui motive : un employé passe de 39h à FORFAIT. Tant que le contrat courant est FORFAIT, les soldes CP/RTT accumulés sous l'ancien contrat 39h sont invisibles ou faussés (l'onglet RTT est masqué, la période Congés passe de Juin→Mai à Jan→Déc, les règles d'acquisition appliquent du FORFAIT_218 à toute année consultée). Conséquence : la RH ne peut plus payer les soldes restants.
Le même verrou existe pour toute fin de contrat avec solde de tout compte (notamment fin de CDD) suivie d'un nouveau contrat de type différent.
Principe directeur
Une fois passé en contrat X, on utilise toutes les règles X par défaut. Le sélecteur permet de revenir sur les phases passées pour les consulter et solder leurs reliquats.
Concept de "phase de contrat"
Une phase est un groupe d'EmployeeContractPeriod consécutifs (triés par startDate) partageant la même signature contractuelle = (contract.type, weeklyHours, isDriver). La fusion s'arrête dès qu'une période diffère sur l'un de ces trois axes, même si une période identique apparaît plus tard.
La signature inclut weeklyHours et isDriver parce que :
weeklyHoursdétermine les tranches d'heures supp (25%/50%), le rythme d'acquisition CP (cas 4h), la base contractuelle quotidienne.isDriverchange l'écran (/driver-hoursvs/hours) et les colonnes de WorkHour utilisées pour le calcul RTT.
Exemples :
- CDD 39h → CDI 39h → FORFAIT : 2 phases (
39h,FORFAIT). - CDD 35h → CDI 39h : 2 phases (
35h,39h). - 3 CDD 39h consécutifs sans interruption : 1 phase (
39h). - 39h → INTERIM 4 mois → 39h : 3 phases (les
39hne fusionnent pas à travers l'INTERIM). - CUSTOM 28h → CUSTOM 30h : 2 phases (
weeklyHoursdiffère). - 35h non-driver → 35h driver : 2 phases (
isDriverdiffère).
La règle de groupement vit dans un service backend, pas dans le frontend.
Backend
Nouveau service EmployeeContractPhaseResolver
Localisation : src/Service/Contracts/EmployeeContractPhaseResolver.php.
public function resolvePhases(Employee $employee): array;
Retour : liste ordonnée (plus récente d'abord) de ContractPhase :
| Champ | Type | Description |
|---|---|---|
id |
int |
EmployeeContractPeriod.id de la première période (par date) du groupe — sert d'identifiant stable. |
contractType |
ContractType |
Type partagé par les périodes du groupe. |
weeklyHours |
int |
Heures hebdomadaires (partagées par construction). |
isDriver |
bool |
Driver flag (partagé par construction). |
startDate |
DateTimeImmutable |
startDate de la première période. |
endDate |
?DateTimeImmutable |
endDate de la dernière période, ou null si en cours. |
periodIds |
list<int> |
IDs des périodes composant la phase, par ordre chronologique. |
isCurrent |
bool |
true si la phase couvre la date du jour (= endDate === null ou endDate >= today). |
Exposition API
Nouveau computed field sur Employee (lecture seule, groupe employee:read) :
"contractPhases": [
{ "id": 42, "contractType": "FORFAIT", "weeklyHours": 39, "isDriver": false, "startDate": "2026-05-01", "endDate": null, "isCurrent": true },
{ "id": 17, "contractType": "THIRTY_NINE_HOURS", "weeklyHours": 39, "isDriver": false, "startDate": "2020-06-01", "endDate": "2026-04-30", "isCurrent": false }
]
Le calcul se fait à la sérialisation via un getter virtuel Employee::getContractPhases(): array qui délègue au resolver.
Endpoints impactés
Les endpoints GET /employees/{id}/leave-summary et GET /employees/{id}/rtt-summary acceptent un nouveau paramètre optionnel :
?phaseId=N: id de la phase à consulter.- Si absent → phase courante (= comportement actuel inchangé).
- Si invalide (phase n'appartient pas à l'employé) → 422.
?year=YYYYreste accepté en parallèle et continue de cibler un exercice précis.
Modifications des providers
EmployeeLeaveSummaryProvider :
- Nouvelle méthode
resolveTargetPhase(Employee $e, ?int $phaseId): ContractPhasequi retourne la phase demandée ou la phase courante. resolveLeavePolicy(...)reçoit la phase au lieu de lire$employee->getContract(). Lecontract.typeet leweeklyHoursviennent de la signature de la phase (homogène par construction).resolvePeriodBounds(...): les bornes de l'exercice sont en plus contraintes à[max(periodStart, phase.startDate), min(periodEnd, phase.endDate ?? periodEnd)].resolveYear(...): siphaseIdfourni et pas deyearexplicite, default = dernier exercice intersectant la phase (= année dephase.endDateou année courante si phase en cours).- Si
?yearest fourni hors de la plage des exercices intersectant la phase → clamp silencieux à l'exercice valide le plus proche, pas d'erreur 422 (cohérence avec l'expérience picker frontend). resolveAccrualCalculationEndDate(...)etresolveTakenCalculationEndDate(...): caps additionnels surphase.endDatequand la phase n'est pas la phase courante.resolveFirstComputationYear(...): restreint aux exercices intersectant la phase.
EmployeeRttSummaryProvider :
- Mêmes principes :
?phaseIdcôté API,resolveTargetPhase, bornes d'exercice cappées à la phase,rttStartDateexposé pour borner le sélecteur d'année frontend. - Pour une phase FORFAIT, le tab RTT est masqué frontend, donc l'endpoint n'est pas appelé en pratique. Pas de garde spécifique côté backend, le comportement existant (retour d'un summary potentiellement vide/inutile) suffit.
Paiements de solde
RTT — EmployeeRttPaymentProcessor :
- Garde actuelle "exercice courant uniquement" devient : "exercice courant OU dernier exercice d'une phase clôturée".
- Concrètement, autoriser la création d'un
EmployeeRttPaymentsur l'exercice contenantphase.endDated'une phase non courante. - Les exercices antérieurs au dernier de la phase restent verrouillés (lecture seule).
CP — settlement period-level :
- Le mécanisme existant
EmployeeContractPeriod.paidLeaveSettledClosureDatereste le canal pour solder. Aucun changement de modèle. - Le bouton "Année N-1 payés" (FORFAIT) et "Jours fractionnés" (non-FORFAIT) restent désactivés sur une phase passée — ce ne sont pas des paiements de solde mais des éditions de stock.
Audit
- La création d'
EmployeeRttPaymentest déjà auditée (existant). - La modification de
paidLeaveSettledClosureDateest déjà auditée viaEmployeeContractPeriodManager(existant). - Aucun audit nouveau requis.
Frontend
Picker
- Composant
MalioSelectplacé danspages/employees/[id].vue, dans le header de la fiche, sous le nom de l'employé et au-dessus de la barre d'onglets. - Libellé :
Vue contrat. - Options formatées :
FORFAIT — depuis 01/05/2026 (actuel)39h CDI — 01/06/2020 → 30/04/2026
- Caché si
contractPhases.length <= 1(employé mono-phase, ~majorité des cas). - Sélection en mémoire (état du composable), non persistée entre navigations ou rechargements. Chaque ouverture de fiche démarre sur la phase courante.
Bandeau d'information
Affiché quand selectedPhase.id !== currentPhase.id :
Vous consultez l'historique {contractType} — jusqu'au {endDate}.
Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
Style : bandeau jaune doux (bg-warning-100 border-warning-300), sous le picker, au-dessus des onglets.
Composables
Nouveau useEmployeeContractPhase() :
- État :
selectedPhase,currentPhase(computed depuisemployee.contractPhases). - Computed :
availablePhases,isViewingPastPhase. - API :
setSelectedPhase(phaseId),resetToCurrent(). resetToCurrent()appelé au changement d'employé.
useEmployeeLeave :
- Reçoit
phaseIden paramètre lors des appels àgetEmployeeLeaveSummary/listAbsences. availableLeaveYearsborné aux exercices intersectant la phase sélectionnée.setSelectedPhasecôté parent → reset deselectedLeaveYearet reload.
useEmployeeRtt :
- Idem pour
getEmployeeRttSummary,availableRttYears.
useEmployeeDetailPage :
showRttTabdevient :selectedPhase.contractType !== FORFAIT.- La logique de fallback ("si sur l'onglet RTT et FORFAIT, basculer ailleurs") s'applique aussi quand on bascule de phase 39h vers phase FORFAIT.
Onglets
- Congés : reçoit la phase via le composable. Bouton "Année N-1 payés" / "Jours fractionnés" reste désactivé sur phase passée (idem que sur exercice passé).
- RTT : visibilité driver par la phase. Bouton "+ Payer les RTT" activé uniquement sur le dernier exercice de la phase passée, désactivé sur les exercices antérieurs de la phase.
- Heures, Frais, Formation, Contrat, Calendrier : non impactés.
Format des libellés du picker
Format de la phase : {labelContractType} — {startDateFR} → {endDateFR}, suffixé (actuel) si phase courante.
labelContractType mapping :
FORFAIT→FORFAITTHIRTY_FIVE_HOURS→35hTHIRTY_NINE_HOURS→39hINTERIM→IntérimCUSTOM→CUSTOM ({weeklyHours}h)(les heures hebdo sont homogènes par construction dans une phase)
Suffixe (driver) ajouté quand isDriver=true, ex. 35h CDI (driver) — ....
Migration et impact sur l'existant
- Aucune migration de données. Le concept de phase est calculé à la volée depuis l'historique existant.
- Comportement inchangé pour tout employé avec une seule phase (cas standard).
- Comportement inchangé quand
phaseIdn'est pas fourni → phase courante. - Pas de breaking change API :
contractPhasesest un champ additionnel ;?phaseIdest un paramètre optionnel.
Tests
Unit
EmployeeContractPhaseResolverTest:- Employé mono-période → 1 phase,
isCurrent=true. - Trois périodes même signature consécutives → 1 phase.
- Switch 39h → FORFAIT → 2 phases avec
startDate/endDatecorrectes. - 39h → INTERIM 4 mois → 39h → 3 phases (pas de fusion).
- 35h → 39h → 2 phases (type différent).
- CUSTOM 28h → CUSTOM 30h → 2 phases (
weeklyHoursdiffère). - 35h non-driver → 35h driver → 2 phases (
isDriverdiffère).
- Employé mono-période → 1 phase,
Functional
-
EmployeeLeaveSummaryProvideravecphaseId:- Phase 39h passée →
ruleCode = CDI_CDD_NON_FORFAIT, période Juin→Mai, exercice de transition capé àphase.endDate. - Phase FORFAIT passée →
ruleCode = FORFAIT_218, période Jan→Déc. phaseIdinvalide pour l'employé → 422.?yearhors de la plage de la phase → clamp silencieux à l'exercice intersectant le plus proche.
- Phase 39h passée →
-
EmployeeRttSummaryProvideravecphaseId:- Phase 39h passée → données RTT renvoyées, bornes cappées sur
phase.endDate. ?yearhors de la plage de la phase → clamp silencieux.
- Phase 39h passée → données RTT renvoyées, bornes cappées sur
-
EmployeeRttPaymentProcessor:- Création autorisée sur exercice de fin d'une phase passée.
- Création refusée sur un exercice antérieur d'une phase passée.
Documentation à mettre à jour
Obligatoire par CLAUDE.md :
doc/contract-phase-view.md— nouveau fichier détaillant la fonctionnalité.doc/leave-tab.md— section "Sélecteur de phase" + interaction avec le sélecteur d'année.doc/rtt-tab.md— section "Sélecteur de phase" + règle de visibilité.frontend/data/documentation-content.ts— article niveauadmin.CLAUDE.md— bloc "Vue contrat (sélecteur de phase)" sous Onglet Congés / Onglet RTT.
Hors scope
- Surface d'alerte automatique sur les fiches employés ayant des soldes non payés sur des phases passées (potentiel follow-up).
- Persistance de la sélection du picker entre navigations.
- Picker exposé sur le calendrier global ou tout autre écran que la fiche employé.
- Modification de
WorkHourDayContext(déjà date-driven, pas concerné). - Évolution du mécanisme
paidLeaveSettledClosureDate(canal existant suffisant). - Cas exotiques : phases overlap (interdit par la modélisation actuelle), périodes avec dates incohérentes.
Décisions confirmées avec l'utilisateur
- Picker global en haut de la fiche, pas par onglet.
- Phases groupées par
contract.typeconsécutif. - Sur une phase passée : exercices antérieurs visibles en lecture seule, seul le dernier exercice de la phase ouvre les actions de solde (RTT pay, CP settlement period-level).
- Comportement par défaut (phase courante) strictement inchangé.