docs(spec) : contract phase view selector design
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>
This commit is contained in:
230
docs/superpowers/specs/2026-05-19-contract-phase-view-design.md
Normal file
230
docs/superpowers/specs/2026-05-19-contract-phase-view-design.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# 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 :
|
||||||
|
- `weeklyHours` détermine les tranches d'heures supp (25%/50%), le rythme d'acquisition CP (cas 4h), la base contractuelle quotidienne.
|
||||||
|
- `isDriver` change l'écran (`/driver-hours` vs `/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 `39h` ne fusionnent pas à travers l'`INTERIM`).
|
||||||
|
- CUSTOM 28h → CUSTOM 30h : 2 phases (`weeklyHours` diffère).
|
||||||
|
- 35h non-driver → 35h driver : 2 phases (`isDriver` diffè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`.
|
||||||
|
|
||||||
|
```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`) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
"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=YYYY` reste accepté en parallèle et continue de cibler un exercice précis.
|
||||||
|
|
||||||
|
### Modifications des providers
|
||||||
|
|
||||||
|
**`EmployeeLeaveSummaryProvider`** :
|
||||||
|
- Nouvelle méthode `resolveTargetPhase(Employee $e, ?int $phaseId): ContractPhase` qui retourne la phase demandée ou la phase courante.
|
||||||
|
- `resolveLeavePolicy(...)` reçoit la phase au lieu de lire `$employee->getContract()`. Le `contract.type` et le `weeklyHours` viennent 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(...)` : si `phaseId` fourni et pas de `year` explicite, default = dernier exercice intersectant la phase (= année de `phase.endDate` ou année courante si phase en cours).
|
||||||
|
- Si `?year` est 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(...)` et `resolveTakenCalculationEndDate(...)` : caps additionnels sur `phase.endDate` quand la phase n'est pas la phase courante.
|
||||||
|
- `resolveFirstComputationYear(...)` : restreint aux exercices intersectant la phase.
|
||||||
|
|
||||||
|
**`EmployeeRttSummaryProvider`** :
|
||||||
|
- Mêmes principes : `?phaseId` côté API, `resolveTargetPhase`, bornes d'exercice cappées à la phase, `rttStartDate` exposé 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 `EmployeeRttPayment` sur l'exercice contenant `phase.endDate` d'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.paidLeaveSettledClosureDate` reste 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'`EmployeeRttPayment` est déjà auditée (existant).
|
||||||
|
- La modification de `paidLeaveSettledClosureDate` est déjà auditée via `EmployeeContractPeriodManager` (existant).
|
||||||
|
- Aucun audit nouveau requis.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Picker
|
||||||
|
|
||||||
|
- Composant `MalioSelect` placé dans `pages/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 depuis `employee.contractPhases`).
|
||||||
|
- Computed : `availablePhases`, `isViewingPastPhase`.
|
||||||
|
- API : `setSelectedPhase(phaseId)`, `resetToCurrent()`.
|
||||||
|
- `resetToCurrent()` appelé au changement d'employé.
|
||||||
|
|
||||||
|
**`useEmployeeLeave`** :
|
||||||
|
- Reçoit `phaseId` en paramètre lors des appels à `getEmployeeLeaveSummary` / `listAbsences`.
|
||||||
|
- `availableLeaveYears` borné aux exercices intersectant la phase sélectionnée.
|
||||||
|
- `setSelectedPhase` côté parent → reset de `selectedLeaveYear` et reload.
|
||||||
|
|
||||||
|
**`useEmployeeRtt`** :
|
||||||
|
- Idem pour `getEmployeeRttSummary`, `availableRttYears`.
|
||||||
|
|
||||||
|
**`useEmployeeDetailPage`** :
|
||||||
|
- `showRttTab` devient : `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` → `FORFAIT`
|
||||||
|
- `THIRTY_FIVE_HOURS` → `35h`
|
||||||
|
- `THIRTY_NINE_HOURS` → `39h`
|
||||||
|
- `INTERIM` → `Intérim`
|
||||||
|
- `CUSTOM` → `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 `phaseId` n'est pas fourni → phase courante.
|
||||||
|
- Pas de breaking change API : `contractPhases` est un champ additionnel ; `?phaseId` est 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`/`endDate` correctes.
|
||||||
|
- 39h → INTERIM 4 mois → 39h → 3 phases (pas de fusion).
|
||||||
|
- 35h → 39h → 2 phases (type différent).
|
||||||
|
- CUSTOM 28h → CUSTOM 30h → 2 phases (`weeklyHours` diffère).
|
||||||
|
- 35h non-driver → 35h driver → 2 phases (`isDriver` diffère).
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
- `EmployeeLeaveSummaryProvider` avec `phaseId` :
|
||||||
|
- 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.
|
||||||
|
- `phaseId` invalide pour l'employé → 422.
|
||||||
|
- `?year` hors de la plage de la phase → clamp silencieux à l'exercice intersectant le plus proche.
|
||||||
|
|
||||||
|
- `EmployeeRttSummaryProvider` avec `phaseId` :
|
||||||
|
- Phase 39h passée → données RTT renvoyées, bornes cappées sur `phase.endDate`.
|
||||||
|
- `?year` hors de la plage de la phase → clamp silencieux.
|
||||||
|
|
||||||
|
- `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 niveau `admin`.
|
||||||
|
- `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.type` consé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é.
|
||||||
Reference in New Issue
Block a user