Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0333270089 | |||
| 832751d1ed | |||
| c119db0b02 | |||
| a52c35e082 | |||
| a1af125c78 | |||
| 8e59e9fd6a | |||
| 4d4bdba914 | |||
| 74abecbe03 | |||
| 5d2b5d1c54 | |||
| c8e7f80c72 | |||
| c298f66993 | |||
| 7187989003 | |||
| 4b22270c60 | |||
| acbf1ccecb | |||
| 036399846b | |||
| 0a9b26d31e | |||
| 7dc73f37ac | |||
| dc02316d8b | |||
| e89a1fd7cf | |||
| 327c10fda4 | |||
| 6ba70c36e9 | |||
| ef15d96d2a | |||
| ceba1121f0 | |||
| b5bd4db5f1 | |||
| 49ad6306ea | |||
| 9d2e70f81e | |||
| 370bbb491f | |||
| f0387233e4 | |||
| 081d92b9f4 | |||
| 143278a368 | |||
| 2802f9524c | |||
| 589018064b |
@@ -40,7 +40,7 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
|
|||||||
RTT_START_DATE=2026-02-23
|
RTT_START_DATE=2026-02-23
|
||||||
# Comma-separated list of public holiday labels to exclude from the government API response
|
# Comma-separated list of public holiday labels to exclude from the government API response
|
||||||
# (typically the "journée de solidarité" worked in many companies)
|
# (typically the "journée de solidarité" worked in many companies)
|
||||||
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
|
EXCLUDED_PUBLIC_HOLIDAYS="null"
|
||||||
###< app ###
|
###< app ###
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> nelmio/cors-bundle ###
|
||||||
|
|||||||
@@ -33,9 +33,11 @@
|
|||||||
- Contract nature (per period): CDI, CDD, INTERIM
|
- 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.
|
- **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`
|
- 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).
|
- **É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). **Jours travaillés (CUSTOM)** : le libellé sous le nom affiche en suffixe les jours du planning `workDaysHours` au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE`). Exposé via `WorkHourDayContext.workDaysHours` (peuplé par `EmployeeContractResolver::resolveWorkDaysMinutesForEmployeeAndDate`, à la date filtrée), formaté front par `formatWorkedDaysShort` (`utils/contract.ts`) et accédé via `getRowWorkedDaysLabel` (`useHoursPage.ts`). Affiché **uniquement écran Heures** (`HoursDayView.vue`, mobile + desktop) ; naturellement limité aux CUSTOM (seuls eux ont `workDaysHours` → null sinon, rien affiché). Pas sur Heures Conducteurs (pas de planning workDaysHours).
|
||||||
- **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.
|
- **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.
|
||||||
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_ADMIN`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="isAdmin && viewMode === 'day'"`, masqué en vue Semaine). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés (lignes vides incluses). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`.
|
- **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`.
|
||||||
|
- **Calendrier des jours validés (vue Jour)** (`WorkHourValidationStatusProvider`, ressource `WorkHourValidationStatus`, endpoint `GET /work-hours/validation-status?from=&to=[&driver=1]`, `ROLE_USER`) : en **vue Jour**, sur les **deux écrans** (Heures **et** Heures Conducteurs), le sélecteur de date est un `MalioDate` (layer `@malio/layer-ui >= 1.7.x` : prop `markedDates` + event `@month-change`) qui peint **en vert** (`markedDates` → `'success'`) les jours **entièrement validés**. **Définition** : un jour est vert ssi il porte ≥1 ligne `WorkHour` du scope ce jour-là **et** aucune n'est `isValid=false` — on se base sur la **seule** colonne `is_valid` (validation admin ; `isSiteValid` ignoré). Jour **sans aucune ligne** → neutre (jamais vert). **Périmètre complet** via `EmployeeRepository::findScoped` (admin = tous sites, chef de site = ses sites), **indépendant du filtre sites** de l'écran. **Scope conducteur inversé** par `?driver=1` : écran Heures → non-conducteurs (défaut), écran Heures Conducteurs → conducteurs (résolu par date via `EmployeeContractResolver::resolveIsDriverForEmployeeAndDate`, mémoïsé ; garde `if ($isDriver !== $driverOnly) continue`). Provider : une requête `WorkHourReadRepositoryInterface::findByDateRangeAndEmployees`, agrégation par jour (`total`/`pending`), plage bornée à 366 j. **Chargement à la volée par mois** (jamais préchargé) : `@month-change {month,year}` (à l'ouverture + nav) → fetch de la **grille visible** (lundi avant le 1er → dimanche après le dernier) → cache `validatedDaysByMonth` (`useHoursPage` / `useDriverHoursPage`, ce dernier passe `{ driver: true }` au service) → `markedDates` réactif. **Rafraîchissement** du mois en cache (`reloadValidationMonth`) après `toggleValidation`/`toggleValidationBulk`/`handleSave`/`refreshAfterAbsenceChange` (pas la validation site). La **vue Semaine** utilise un `MalioDateWeek` (sélecteur de semaine, v-model ISO week `YYYY-Www`, sans coloration). Le **stepper ‹ › du mode jour est remplacé** par le `MalioDate` (raccourcis Hier/Aujourd'hui/Demain conservés) ; `PeriodStepperPicker` reste un fallback de la vue Jour quand `showValidationCalendar` est absent (aucun appelant actuel). Activation par écran via la prop `showValidationCalendar` de `HoursToolbar` (les deux pages la passent à `true` + `markedDates` + `@month-change`). Alignement vertical de la ligne via `lg:items-center` (les champs Malio font `h-12` vs `h-10` des boutons). Doc complète : `doc/hours-validated-days.md`.
|
||||||
|
- **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`.
|
||||||
- **É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.
|
- **É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`.
|
- **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: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||||
@@ -62,18 +64,21 @@
|
|||||||
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
||||||
- Any real modification resets both `isSiteValid=false` and `isValid=false`
|
- Any real modification resets both `isSiteValid=false` and `isValid=false`
|
||||||
- No-op saves preserve existing validations
|
- No-op saves preserve existing validations
|
||||||
|
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : pas de verrou optimiste backend — l'édition explicite d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne. Doc : `doc/hours-save-dirty-tracking.md`.
|
||||||
|
|
||||||
## Overtime Rules
|
## Overtime Rules
|
||||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||||
- Contracts >= 39h: +25% from 39h 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
|
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1.
|
||||||
|
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50 qui restent à 0). Net = exactement −prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. **Garde : uniquement si le salarié travaille le lundi** (`workDaysHours[lundi] > 0`, i.e. `expectedMinutes > 0`) ; un temps partiel ne travaillant jamais le lundi (ex. Nadia, Mar+Ven) **ne porte aucun déficit** (sinon `(0 − 0) − prorata` lui facturerait à tort le prorata). Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH y pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
|
||||||
- **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%.
|
- **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).
|
- **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
|
- INTERIM: no overtime bonuses, no recovery time
|
||||||
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
|
- 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 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é.
|
- **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é.
|
- **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'])`.
|
||||||
|
- **Congé posé un dimanche — jamais décompté** : un congé `C` tombant un **dimanche** n'est compté comme congé pris **nulle part** (récap congés, rollover, jours de présence l'ignoraient déjà ; le **récap salaire** est désormais aligné via une garde dans `SalaryRecapPrintProvider::countAbsencesByCode` : `'C' === code && 7 === N → continue`). Objectif RH : poser une période à cheval sur un week-end (ex. jeu→mar) sans « perdre » le dimanche. **Correctif au comptage** (pas à la création) : les lignes d'absence du dimanche **restent créées et stockées** (`AbsenceWriteProcessor::expandAbsenceRange` inchangé), donc l'existant cesse de compter sans migration, et le **calendrier + impression PDF des absences continuent d'afficher** le dimanche (volonté RH). Périmètre strict : code `C` uniquement (maladie/AT comptés normalement) ; le **samedi** garde son budget dédié (`takenSaturdays`). `splitForfaitCongesByN1` sautait déjà le week-end.
|
||||||
- **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é).
|
- **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).
|
- **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).
|
||||||
|
|
||||||
@@ -106,6 +111,33 @@
|
|||||||
- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord.
|
- **Verrou** : si le report de l'exercice courant est `is_locked`, le paiement rétroactif est **refusé** (`assertReportNotLocked`) — la RH doit déverrouiller d'abord.
|
||||||
- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus).
|
- Portée limitée à N-1 (chaîne de recalcul = 1 étape). Si la ligne courante n'existe pas encore, le fallback provider couvre l'affichage (cf. ci-dessus).
|
||||||
|
|
||||||
|
## Contingent heures supplémentaires payées
|
||||||
|
- Suivi par **année civile** (Janv–Déc) des heures supp payées vs plafond légal (350 h
|
||||||
|
chauffeur / 220 h autres), non-forfait uniquement.
|
||||||
|
- **Heures payées** = `base25 + base50` (hors bonus) **+ heures structurelles**. **Mapping** :
|
||||||
|
paiements RTT stockés par exercice → `annéeCivile = mois ≥ 6 ? exercice − 1 : exercice` ;
|
||||||
|
année civile Y = exercice Y (mois 1–5) + exercice Y+1 (mois 6–12). Cœur partagé pur
|
||||||
|
`OvertimePaidContingentCalculator`.
|
||||||
|
- **Heures structurelles** : les heures contractuelles au-delà de 35h (durée légale) sont des
|
||||||
|
heures supp payées chaque mois, hors paiements RTT (la référence d'un 39h est 39h). Ajoutées
|
||||||
|
au contingent : `(weeklyHours − 35) × 52/12` h/mois = `(weeklyHours − 35) × 260` min (39h →
|
||||||
|
1040 min = 17,33 h/mois). Généralisé à tout contrat non-forfait/non-intérim `weeklyHours > 35`
|
||||||
|
(custom 40h → 21,67 h/mois) ; **proratisé** aux jours sous contrat dans le mois (itère
|
||||||
|
`employee.contractPeriods`). Cœur partagé `StructuralOvertimeContingentCalculator`
|
||||||
|
(`monthlyStructuralMinutes`/`totalStructuralMinutes`), branché sur l'encart fiche
|
||||||
|
(`EmployeeOvertimeContingentProvider`) **et** l'export (`OvertimeContingentExportBuilder`).
|
||||||
|
- **Plafond** résolu sur `isDriver` du **contrat courant**.
|
||||||
|
- **Fiche employé** : encart header `Total H.payés {année} : X h / plafond h` (année civile
|
||||||
|
courante, rouge si dépassement), via `GET /employees/{id}/overtime-contingent`. Encart
|
||||||
|
volontairement indépendant de la phase sélectionnée (toujours l'année civile courante).
|
||||||
|
- **Export PDF** (`GET /overtime-contingent/print?year=&siteIds=`, `ROLE_USER`,
|
||||||
|
`findScoped`) : groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`,
|
||||||
|
colonnes Janv–Déc + `Total payé / payable`. Drawer liste employés : sélecteur année +
|
||||||
|
sites (vide = périmètre complet). Exclut les FORFAIT (contrat courant).
|
||||||
|
- ⚠️ Bug latent consigné : `SalaryRecapPrintProvider` rattache mal les paiements RTT des mois
|
||||||
|
Juin–Déc (requête par année civile sur un stockage par exercice). Hors périmètre.
|
||||||
|
- Doc : `doc/overtime-contingent.md`.
|
||||||
|
|
||||||
## Vue contrat (sélecteur de phase)
|
## 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.
|
- 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`.
|
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
||||||
@@ -125,7 +157,7 @@
|
|||||||
## Récap. congés (écran)
|
## 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.
|
- 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
|
- 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`.
|
- Cutoff temporel : fin de la semaine S-1 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante − 7j)`. Pas de gate `isValid`.
|
||||||
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
|
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
|
||||||
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
|
- 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)
|
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
|
||||||
@@ -157,11 +189,15 @@
|
|||||||
- 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`
|
- 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`
|
- 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)
|
### Drawers (MalioDrawer)
|
||||||
- Edit mode: `grid grid-cols-2 gap-3` → Supprimer (red, left) + Modifier (primary, right)
|
- **Tous les drawers utilisent `MalioDrawer`** (couche Malio, auto-importé). L'ancien composant custom `AppDrawer` a été supprimé — ne pas le réintroduire.
|
||||||
- Create mode: centered `+ Ajouter` button, w-[200px]
|
- **Titre via le slot `#header`** (MalioDrawer n'a PAS de prop `title`) : `<template #header><h2 class="text-[32px] font-semibold text-primary-500">…</h2></template>`.
|
||||||
- Exception: Users drawer has NO delete button
|
- `v-model` = ouverture ; bouton de fermeture + clic overlay/Échap gérés par MalioDrawer (`showClose`/`dismissable`/`closeOnEscape` défaut `true`). Largeur `max-w-md`.
|
||||||
- All "Ajouter" buttons across the app use "+" prefix
|
- **Boutons d'action = `MalioButton`** (dans le slot par défaut ; plus de `<button>` natif). `MalioButton` rend un `type="button"` (ne soumet pas) → câbler `@click="<handler de submit>"` (= la fonction du `@submit.prevent` du form, conservé pour la touche Entrée). Delete → `variant="danger"`, annuler → `variant="tertiary"`.
|
||||||
|
- **Deux boutons côte à côte partagent l'espace** : `<div class="grid grid-cols-2 gap-3 pt-2">` + chaque `MalioButton` en `button-class="w-full"` (edit : Supprimer/Annuler à gauche + Modifier/Enregistrer à droite). Cas conditionnel (un des deux en `v-if`) : `flex gap-3` + `button-class="flex-1"` (1 visible → pleine largeur, 2 → moitiés).
|
||||||
|
- **Un seul bouton** : centré `flex justify-center pt-2` (largeur `w-[200px]` ou défaut). Bouton de création : `label="Ajouter…"` + `icon-name="mdi:plus"` (plus de préfixe texte « + »).
|
||||||
|
- Exception: Users drawer has NO delete button.
|
||||||
|
- NB : quelques `MalioButton` historiques soumettent encore via `type="submit"` (passthrough d'attribut) au lieu de `@click` (sites/users/absence-types) — fonctionnel, à aligner sur `@click` à l'occasion.
|
||||||
|
|
||||||
### API Platform (backend)
|
### API Platform (backend)
|
||||||
- Custom operations use Processor (write) / Provider (read)
|
- Custom operations use Processor (write) / Provider (read)
|
||||||
@@ -172,6 +208,8 @@
|
|||||||
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
|
- 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
|
- `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)
|
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
|
||||||
|
- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. **CORS** : `X-Device-Id` doit rester dans `nelmio_cors.allow_headers` (front/API cross-origin → préflight, sinon le navigateur bloque toutes les requêtes). Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`.
|
||||||
|
- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `employee` (LIKE nom/prénom de l'employé affecté, via join), `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres du drawer = champs texte (recherche libre), période en `MalioDateRange`, type/action en cases à cocher. Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères.
|
||||||
- Documentation: `doc/audit-logging.md`
|
- Documentation: `doc/audit-logging.md`
|
||||||
|
|
||||||
## Backend Conventions
|
## Backend Conventions
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ framework:
|
|||||||
# Note that the session will be started ONLY if you read or write from it.
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
session: true
|
session: true
|
||||||
|
|
||||||
|
# Trusted proxies — REQUIRED for a correct client IP in the activity log
|
||||||
|
# when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB).
|
||||||
|
# Without this, Request::getClientIp() returns the PROXY ip, not the client's.
|
||||||
|
# Uncomment and set to the proxy network/CIDR of your deployment, e.g.:
|
||||||
|
# trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
|
||||||
|
# trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
|
||||||
|
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
|
||||||
|
|
||||||
#esi: true
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ nelmio_cors:
|
|||||||
origin_regex: true
|
origin_regex: true
|
||||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
allow_headers: ['Content-Type', 'Authorization']
|
allow_headers: ['Content-Type', 'Authorization', 'X-Device-Id']
|
||||||
allow_credentials: true
|
allow_credentials: true
|
||||||
expose_headers: ['Link', 'Content-Disposition']
|
expose_headers: ['Link', 'Content-Disposition']
|
||||||
max_age: 3600
|
max_age: 3600
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
$dataStartDate: '%env(RTT_START_DATE)%'
|
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||||
|
App\Repository\Contract\AuditLogReadRepositoryInterface: '@App\Repository\AuditLogRepository'
|
||||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||||
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
|
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.109'
|
app.version: '0.1.124'
|
||||||
|
|||||||
+17
-5
@@ -40,16 +40,28 @@ Chaque entrée contient :
|
|||||||
- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs
|
- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs
|
||||||
- **affectedDate** : date de travail ou début d'absence (pour filtrage par période)
|
- **affectedDate** : date de travail ou début d'absence (pour filtrage par période)
|
||||||
- **createdAt** : horodatage de l'action
|
- **createdAt** : horodatage de l'action
|
||||||
|
- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy
|
||||||
|
- `userAgent` : User-Agent brut de la requête
|
||||||
|
- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser`
|
||||||
|
- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes.
|
||||||
|
|
||||||
|
Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`.
|
||||||
|
|
||||||
|
> ⚠️ **CORS** : le front et l'API sont sur des origines distinctes ; le header `X-Device-Id` ajouté à chaque requête déclenche un préflight CORS. Il **doit** figurer dans `nelmio_cors.allow_headers` (`config/packages/nelmio_cors.yaml`), sinon le navigateur bloque toutes les requêtes API.
|
||||||
|
|
||||||
## Filtres disponibles
|
## Filtres disponibles
|
||||||
|
|
||||||
- Par employé
|
- Par employé (affecté) — champ texte, recherche partielle sur nom/prénom (insensible à la casse)
|
||||||
- Par plage de dates (date affectée)
|
- Par période (date affectée) — sélecteur de plage
|
||||||
- Par type d'entité
|
- Par type(s) d'entité (multi-sélection)
|
||||||
|
- Par action(s) (multi-sélection)
|
||||||
|
- Par utilisateur / compte — champ texte, recherche partielle (insensible à la casse)
|
||||||
|
- Par IP (recherche partielle)
|
||||||
|
- Par appareil (recherche partielle sur le libellé ou le device id)
|
||||||
|
|
||||||
## Pagination
|
Pagination : `perPage` (10 / 25 / 50 / 100, défaut 10) + `page`.
|
||||||
|
|
||||||
Les résultats sont paginés par 50 entrées. L'API retourne `{items, total, page, perPage}` et accepte un query param `page`.
|
L'écran utilise un `MalioDataTable`, un **drawer de filtre** (bouton « Filtrer » avec compteur de filtres actifs, état brouillon/appliqué, Réinitialiser/Appliquer) et un **drawer de détail** ouvert au clic sur une ligne (méta + contexte technique IP/appareil/User-Agent/device id + diff lisible des changements).
|
||||||
|
|
||||||
## Convention
|
## Convention
|
||||||
|
|
||||||
|
|||||||
+41
-5
@@ -61,6 +61,7 @@ Documents complementaires:
|
|||||||
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
|
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
|
||||||
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
|
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
|
||||||
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
|
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
|
||||||
|
- **Jours travaillés (contrats CUSTOM)** : pour un contrat CUSTOM (planning `workDaysHours` renseigné), les jours effectivement travaillés sont affichés en suffixe du libellé `Site — Nature`, au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE` pour un temps partiel travaillant lundi et jeudi). Résolu à la date filtrée. Les contrats 35h/39h/Forfait/Intérim n'ont pas de planning → aucun suffixe. Écran Heures uniquement (pas Heures Conducteurs).
|
||||||
- **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`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), 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. La vue Semaine était déjà résolue par date.
|
- **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`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), 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. La vue Semaine était déjà résolue par date.
|
||||||
- **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu.
|
- **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu.
|
||||||
- **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants.
|
- **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants.
|
||||||
@@ -140,7 +141,24 @@ Documents complementaires:
|
|||||||
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
|
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
|
||||||
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
|
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
|
||||||
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
|
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
|
||||||
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
|
- le déficit (travail < contrat) réduit le cumul RTT 1:1 (peut devenir négatif, reporté N+1)
|
||||||
|
|
||||||
|
### Jour de solidarité (contrats CUSTOM < 35h)
|
||||||
|
|
||||||
|
Le Lundi de Pentecôte (jour de solidarité) impose une contribution proratisée aux temps
|
||||||
|
partiels < 35h. La RH pose un RTT sur ce jour pour tous les salariés ; pour les contrats
|
||||||
|
standard (35h/39h) cela draine ~7h du cumul RTT (comportement inchangé). Pour les CUSTOM
|
||||||
|
< 35h, poser un RTT entier n'a pas de sens : le logiciel **neutralise** le jour (quel que
|
||||||
|
soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebdo`
|
||||||
|
(= 12 min par heure hebdo : 4h → 48 min, 28h → 5h36). Ce déficit réduit le cumul RTT
|
||||||
|
(peut le rendre négatif, reporté à l'exercice suivant) et se cumule avec les autres
|
||||||
|
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
|
||||||
|
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
|
||||||
|
|
||||||
|
Le déficit ne s'applique **que si le salarié travaille le lundi** (jour de solidarité
|
||||||
|
planifié au contrat, `workDaysHours[lundi] > 0`). Un temps partiel ne travaillant jamais
|
||||||
|
le lundi (ex. Mar+Ven) n'est pas concerné : aucun déficit n'est imputé.
|
||||||
|
|
||||||
- Nature `INTERIM`:
|
- Nature `INTERIM`:
|
||||||
- pas de bonus 25%
|
- pas de bonus 25%
|
||||||
- pas de bonus 50%
|
- pas de bonus 50%
|
||||||
@@ -261,8 +279,9 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
|||||||
- pas de samedi (`0`)
|
- pas de samedi (`0`)
|
||||||
- pas de jours en cours d'acquisition (`0`)
|
- pas de jours en cours d'acquisition (`0`)
|
||||||
- fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
|
- fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
|
||||||
|
- **dimanche jamais décompté** : un congé `C` posé un dimanche n'est **jamais** compté comme congé pris, où que ce soit (récap congés, rollover, jours de présence, et **récap salaire**). Permet de poser une période à cheval sur un week-end (ex. jeu→mar) sans « perdre » le dimanche. Ne concerne que le code `C` (maladie/AT inchangés) ; le samedi conserve son budget dédié. **Le calendrier et son impression PDF continuent d'afficher** la ligne du dimanche (la ligne d'absence existe en base, choix RH).
|
||||||
- pour `CDI`/`CDD` non forfait:
|
- pour `CDI`/`CDD` non forfait:
|
||||||
- pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées
|
- pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées (dimanche exclu, samedi compté à part)
|
||||||
- samedi pris: absences `C` posées le samedi (demi-journée incluse)
|
- samedi pris: absences `C` posées le samedi (demi-journée incluse)
|
||||||
- restants = acquis - pris (borné à 0)
|
- restants = acquis - pris (borné à 0)
|
||||||
- pour `FORFAIT`:
|
- pour `FORFAIT`:
|
||||||
@@ -361,14 +380,31 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
|||||||
- `ROLE_ADMIN` : tous les employés
|
- `ROLE_ADMIN` : tous les employés
|
||||||
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
||||||
- `ROLE_SELF` : uniquement son employé lié
|
- `ROLE_SELF` : uniquement son employé lié
|
||||||
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
|
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-1 (dimanche 23:59:59)
|
||||||
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 14 jours)`
|
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 7 jours)`
|
||||||
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
|
- Exemple : mardi 14/04/2026 (S16) → dimanche 12/04/2026 (fin S15)
|
||||||
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
||||||
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
||||||
- Colonnes identiques au PDF (voir §10)
|
- Colonnes identiques au PDF (voir §10)
|
||||||
- Détails techniques : voir `doc/leave-recap-screen.md`
|
- Détails techniques : voir `doc/leave-recap-screen.md`
|
||||||
|
|
||||||
|
## Export Contingent heures de nuit
|
||||||
|
|
||||||
|
- Accès : drawer « Export » de la liste employés, type « Contingent H.nuit ».
|
||||||
|
Endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`.
|
||||||
|
- Périmètre : `EmployeeRepository::findScoped($user)` (admin → tous, chef de
|
||||||
|
site → ses sites). Employés ayant ≥ 1 contrat sur l'année civile uniquement.
|
||||||
|
- PDF A4 **paysage** : lignes = employés (groupés par site, triés displayOrder
|
||||||
|
puis nom/prénom), colonnes = 12 mois (Janv→Déc), chaque mois avec 2 sous-
|
||||||
|
colonnes « H.nuit » et « N.jours ».
|
||||||
|
- Heures de nuit : minutes travaillées dans la fenêtre **21h→6h**
|
||||||
|
(`NightHoursCalculator`, identique au reste de l'app). Conducteurs inclus :
|
||||||
|
champ manuel `WorkHour.nightHoursMinutes`.
|
||||||
|
- « N.jours » : un jour compte 1 dès que ses minutes de nuit ≥ 240 (4h).
|
||||||
|
- Aucun crédit absence/férié : seules les heures réellement travaillées comptent.
|
||||||
|
- Services : `App\State\NightHoursContingentPrintProvider` +
|
||||||
|
`App\Service\WorkHours\NightContingentExportBuilder`.
|
||||||
|
|
||||||
## 11) Récapitulatif Salaire (PDF mensuel)
|
## 11) Récapitulatif Salaire (PDF mensuel)
|
||||||
|
|
||||||
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
||||||
|
|||||||
+15
-7
@@ -1,17 +1,24 @@
|
|||||||
# Export PDF des heures — vue Jour
|
# Export PDF des heures — vue Jour
|
||||||
|
|
||||||
Bouton **Exporter** à droite du titre « Heures », visible **uniquement pour les
|
Bouton **Exporter** à droite du titre « Heures », visible pour les **administrateurs**
|
||||||
administrateurs** (`ROLE_ADMIN`) et **uniquement en vue Jour** (masqué en vue Semaine).
|
(`ROLE_ADMIN`) **et les chefs de site** (`ROLE_USER`), **uniquement en vue Jour** (masqué
|
||||||
|
en vue Semaine, masqué pour les profils employé `ROLE_SELF`).
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
- **Administrateur** : peut exporter tous les sites.
|
||||||
|
- **Chef de site** : ne voit dans le drawer que **ses sites** et ne peut exporter que
|
||||||
|
ceux-ci. Le périmètre est appliqué côté backend (`EmployeeRepository::findScoped`) — un
|
||||||
|
`siteIds` forcé hors de son périmètre est ignoré, aucune donnée d'un autre site ne fuit.
|
||||||
|
|
||||||
## Comportement
|
## Comportement
|
||||||
- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à
|
- Ouvre un drawer : un champ **date** (préremplit la date affichée) et des **cases à
|
||||||
cocher des sites** (présélectionnées sur le filtre courant).
|
cocher des sites** (limitées au périmètre de l'utilisateur).
|
||||||
- Génère un **PDF A4 portrait** d'une seule journée, **regroupé par site**.
|
- Génère un **PDF A4 portrait** d'une seule journée, **regroupé par site**.
|
||||||
|
|
||||||
## Données
|
## Données
|
||||||
- Mêmes employés que la vue Jour : **non-conducteurs**, **sous contrat** à la date
|
- Mêmes employés que la vue Jour : **non-conducteurs**, **sous contrat** à la date
|
||||||
choisie, des sites cochés. Les employés sous contrat sans saisie apparaissent (lignes
|
choisie, des sites cochés et **dans le périmètre de l'utilisateur**. Les employés sous
|
||||||
vides).
|
contrat sans saisie apparaissent (lignes vides).
|
||||||
- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi ·
|
- Colonnes : Nom · Statut · Début matin · Fin matin · Début après-midi · Fin après-midi ·
|
||||||
Début soir · Fin soir · Jour · Nuit · **Total** (en gras). **Pas de colonne « Valider ».**
|
Début soir · Fin soir · Jour · Nuit · **Total** (en gras). **Pas de colonne « Valider ».**
|
||||||
- Colonne **Statut** : affiche le **code** du type d'absence (ex. `AT`), pas le libellé,
|
- Colonne **Statut** : affiche le **code** du type d'absence (ex. `AT`), pas le libellé,
|
||||||
@@ -23,8 +30,9 @@ administrateurs** (`ROLE_ADMIN`) et **uniquement en vue Jour** (masqué en vue S
|
|||||||
de couleur contenant le code et le libellé du type à droite. Triée par code, dédupliquée.
|
de couleur contenant le code et le libellé du type à droite. Triée par code, dédupliquée.
|
||||||
|
|
||||||
## Technique
|
## Technique
|
||||||
- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_ADMIN`).
|
- Endpoint : `GET /work-hours/day-export?workDate=YYYY-MM-DD&siteIds=1,2,3` (`ROLE_USER`).
|
||||||
- Provider : `App\State\WorkHourDayExportProvider`.
|
- Provider : `App\State\WorkHourDayExportProvider` — résout le périmètre via
|
||||||
|
`EmployeeRepository::findScoped($user)` (admin → tous, chef de site → ses sites).
|
||||||
- Calcul des cellules : `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source
|
- Calcul des cellules : `YearlyHoursExportBuilder::buildDayRowsForEmployees` (source
|
||||||
unique de vérité, partagée avec les exports annuels).
|
unique de vérité, partagée avec les exports annuels).
|
||||||
- Gabarit : `templates/work-hour-day-export/print.html.twig`.
|
- Gabarit : `templates/work-hour-day-export/print.html.twig`.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Enregistrement des heures — envoi des seules lignes modifiées
|
||||||
|
|
||||||
|
## Problème corrigé (perte de données par écrasement « à l'aveugle »)
|
||||||
|
|
||||||
|
L'écran **Heures** (et **Heures Conducteurs**) présente une grille d'une journée avec
|
||||||
|
**tous** les employés du périmètre. L'enregistrement (`POST /work-hours/bulk-upsert`,
|
||||||
|
`WorkHourBulkUpsertProcessor`) a une sémantique **upsert par (employé, date)** où une
|
||||||
|
**entrée vide supprime** la ligne existante (« une ligne vide supprime l'enregistrement »).
|
||||||
|
|
||||||
|
Avant correctif, `handleSave` (front) envoyait une entrée pour **chaque** employé visible non
|
||||||
|
verrouillé, à partir de l'état en mémoire de la grille. Conséquence en cas de **concurrence** :
|
||||||
|
|
||||||
|
1. Un admin ouvre l'écran ; la ligne d'un salarié (ex. utilisateur `ROLE_SELF`) est vide.
|
||||||
|
2. Ce salarié saisit ses heures dans sa propre session → ligne créée en BDD, **non validée**
|
||||||
|
(donc non verrouillée).
|
||||||
|
3. L'admin, sur sa grille **périmée**, saisit les heures d'**autres** employés et enregistre.
|
||||||
|
4. Le payload contient une entrée **vide** pour le salarié (état périmé). Le backend relit la
|
||||||
|
BDD (ligne désormais remplie), constate « entrée vide ≠ existant » → **supprime** la ligne
|
||||||
|
fraîchement saisie. Perte de données.
|
||||||
|
|
||||||
|
## Correctif (suivi des lignes modifiées côté front)
|
||||||
|
|
||||||
|
`hydrateRows` capture désormais un **instantané** des lignes telles que chargées depuis le
|
||||||
|
serveur (`loadedRows`, clone indépendant de `rows`). À l'enregistrement, `handleSave` ne
|
||||||
|
transmet **que les lignes dont l'état courant diffère de l'instantané chargé** :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const entries = candidates
|
||||||
|
.map((employee) => ({
|
||||||
|
current: buildEntry(employee, rows.value[employee.id] ?? emptyRow()),
|
||||||
|
original: buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow()),
|
||||||
|
}))
|
||||||
|
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||||
|
.map(({ current }) => current)
|
||||||
|
```
|
||||||
|
|
||||||
|
Conséquences :
|
||||||
|
|
||||||
|
- Une ligne **intouchée** n'est jamais transmise → jamais supprimée, même si un autre
|
||||||
|
utilisateur l'a saisie/modifiée entre-temps. **C'est le correctif du bug.**
|
||||||
|
- Une ligne **vidée volontairement** par l'utilisateur diffère de l'instantané → transmise
|
||||||
|
vide → supprimée (comportement métier conservé).
|
||||||
|
- Une ligne **remplie** diffère → transmise → créée/mise à jour.
|
||||||
|
|
||||||
|
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
|
||||||
|
`frontend/composables/useDriverHoursPage.ts` (conducteurs).
|
||||||
|
|
||||||
|
## Limite connue (hors périmètre de ce correctif)
|
||||||
|
|
||||||
|
Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une
|
||||||
|
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie
|
||||||
|
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste**
|
||||||
|
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune
|
||||||
|
détection de conflit concurrent (pas de version, pas d'horodatage comparé).
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Calendrier des jours validés (écran Heures — vue Jour)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Sur l'écran **Heures**, en **vue Jour**, le sélecteur de date est un calendrier
|
||||||
|
(composant `MalioDate` du layer `@malio/layer-ui`) qui **peint en vert** les jours
|
||||||
|
entièrement validés par un admin. La RH repère ainsi d'un coup d'œil les jours où
|
||||||
|
il reste de la validation à faire.
|
||||||
|
|
||||||
|
## Définition « jour validé » (vert)
|
||||||
|
Un jour est vert ssi, dans le **périmètre complet** de l'utilisateur :
|
||||||
|
- il porte **au moins une ligne `WorkHour`** dans le scope ciblé ce jour-là, **et**
|
||||||
|
- **aucune** de ces lignes n'est en attente de validation (`isValid = false`).
|
||||||
|
|
||||||
|
La même mécanique sert les **deux écrans**, avec un scope opposé : écran **Heures** →
|
||||||
|
non-conducteurs (défaut) ; écran **Heures Conducteurs** → conducteurs (`?driver=1`).
|
||||||
|
|
||||||
|
Conséquences :
|
||||||
|
- Un jour **sans aucune ligne** (rien saisi, ex. week-end, jour futur) reste **neutre**
|
||||||
|
(jamais vert) — « rien fait » n'est pas « tout validé ».
|
||||||
|
- On se base sur la **seule** colonne `work_hours.is_valid` (validation admin/RH).
|
||||||
|
`isSiteValid` (chef de site) n'entre pas en compte → modifier une validation site
|
||||||
|
ne change pas la couleur.
|
||||||
|
- **Scope conducteur** : écran Heures → conducteurs exclus ; écran Heures Conducteurs →
|
||||||
|
seuls les conducteurs (le filtre est inversé via `?driver=1`).
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
- `ROLE_ADMIN` → tous les employés / tous les sites.
|
||||||
|
- Chef de site → ses sites uniquement.
|
||||||
|
- Le **filtre sites de l'écran est volontairement ignoré** : le vert reflète tout le
|
||||||
|
périmètre (objectif : repérer le moindre jour incomplet, où qu'il soit). Changer le
|
||||||
|
filtre sites de la vue Jour ne recalcule pas le calendrier.
|
||||||
|
|
||||||
|
## Chargement des données
|
||||||
|
- Endpoint : `GET /work-hours/validation-status?from=YYYY-MM-DD&to=YYYY-MM-DD[&driver=1]`
|
||||||
|
(`ROLE_USER`). Réponse : `{ from, to, validatedDays: string[] }` (dates `Y-m-d`).
|
||||||
|
- Provider : `App\State\WorkHourValidationStatusProvider`
|
||||||
|
(ressource `App\ApiResource\WorkHourValidationStatus`).
|
||||||
|
- `EmployeeRepository::findScoped($user)` pour le périmètre (ignore tout `siteIds`).
|
||||||
|
- Une requête `WorkHourReadRepositoryInterface::findByDateRangeAndEmployees`.
|
||||||
|
- Filtrage conducteur **par date** via `EmployeeContractResolver::resolveIsDriverForEmployeeAndDate`
|
||||||
|
(mémoïsé par couple employé/jour) : `if ($isDriver !== $driverOnly) continue;`
|
||||||
|
(`driverOnly` = `?driver=1`).
|
||||||
|
- Agrégation par jour : vert ⇔ `total > 0` (lignes du scope) et `pending = 0`
|
||||||
|
(aucune `isValid=false`).
|
||||||
|
- Garde-fou : plage bornée à 366 jours.
|
||||||
|
- Le chargement est **à la volée par mois affiché** (jamais préchargé sur plusieurs
|
||||||
|
années) : `MalioDate` émet `@month-change { month, year }` à l'ouverture du popover et
|
||||||
|
à chaque navigation ; le front fetch la **grille visible** (lundi avant le 1er →
|
||||||
|
dimanche après le dernier jour, pour colorer aussi les jours débordants) et met le
|
||||||
|
résultat en cache par mois (`useHoursPage` / `useDriverHoursPage` → `validatedDaysByMonth` ;
|
||||||
|
ce dernier appelle le service avec `{ driver: true }`). La prop réactive `markedDates`
|
||||||
|
(ISO → `'success'`) recolore la grille.
|
||||||
|
|
||||||
|
## Rafraîchissement
|
||||||
|
Toute action qui touche la validation d'un jour recharge le mois concerné s'il est en
|
||||||
|
cache (`reloadValidationMonth`), donc le calendrier se recolore aussitôt :
|
||||||
|
- validation admin d'une ligne (`toggleValidation`) ou en masse (`toggleValidationBulk`) ;
|
||||||
|
- sauvegarde d'heures (`handleSave`) — toute modification réelle remet `isValid=false` ;
|
||||||
|
- création / suppression d'absence (`refreshAfterAbsenceChange`).
|
||||||
|
La validation **site** ne déclenche pas de rechargement (sans effet sur le vert).
|
||||||
|
|
||||||
|
## Périmètre d'affichage
|
||||||
|
- **Vue Jour uniquement** : le vert (calendrier `MalioDate` + `markedDates`) est à la maille
|
||||||
|
jour, sur les **deux écrans** (Heures et Heures Conducteurs, via `showValidationCalendar`).
|
||||||
|
La **vue Semaine** utilise un `MalioDateWeek` (sélecteur de semaine, sans coloration).
|
||||||
|
Le `PeriodStepperPicker` ne subsiste que comme fallback de la vue Jour quand
|
||||||
|
`showValidationCalendar` est absent (aucun appelant actuel, conservé par flexibilité).
|
||||||
|
- Précédence d'affichage dans la grille (côté layer) : sélection (fond plein primary) >
|
||||||
|
variante marquée ; le **jour courant** (`today`) garde sa bordure **et** reçoit le fond
|
||||||
|
vert s'il est validé.
|
||||||
|
|
||||||
|
## Dépendance layer
|
||||||
|
Nécessite `@malio/layer-ui >= 1.7.x` : prop `markedDates`
|
||||||
|
(`Record<"YYYY-MM-DD", 'success' | 'danger'>`) + event `month-change` sur `MalioDate`
|
||||||
|
(ticket Malio UI MUI-45).
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
## Objet
|
## Objet
|
||||||
|
|
||||||
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
|
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-1).
|
||||||
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
|
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
|
||||||
|
|
||||||
## Cutoff
|
## Cutoff
|
||||||
|
|
||||||
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante − 14 jours)`.
|
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante − 7 jours)`.
|
||||||
|
|
||||||
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
|
Exemple : mardi 14/04/2026 (S16) → **dimanche 12/04/2026 23:59:59** (fin S15).
|
||||||
|
|
||||||
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
|
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
|
||||||
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
|
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Contingent d'heures supplémentaires payées
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Suivre, par année civile (Janv–Déc), les heures supplémentaires payées de chaque employé
|
||||||
|
non-forfait (chauffeurs inclus) face au plafond légal annuel.
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
- **Heures payées** = `base25 + base50` (en minutes), hors majoration (bonus), **+ heures
|
||||||
|
structurelles** (voir ci-dessous).
|
||||||
|
- **Plafond** : 350 h pour les chauffeurs (contrat courant `isDriver`), 220 h sinon.
|
||||||
|
- **Périmètre** : non-forfait uniquement (FORFAIT exclus, ni RTT ni heures supp payées).
|
||||||
|
|
||||||
|
## Heures supplémentaires structurelles
|
||||||
|
Les heures contractuelles **au-delà de 35h** (durée légale) sont des heures supplémentaires
|
||||||
|
payées **chaque mois**, qui ne transitent pas par les paiements RTT (la référence d'un 39h est
|
||||||
|
39h, pas 35h) mais comptent dans le contingent légal.
|
||||||
|
|
||||||
|
- Montant mensuel plein = `(weeklyHours − 35) × 52/12` h = `(weeklyHours − 35) × 260` min.
|
||||||
|
Pour un 39h : `4 × 260 = 1040` min = **17,33 h/mois**.
|
||||||
|
- **Généralisé** à tout contrat non-forfait/non-intérim dont `weeklyHours > 35` (ex. custom
|
||||||
|
40h → 21,67 h/mois). Contrats ≤ 35h, FORFAIT, INTERIM → 0.
|
||||||
|
- **Proratisé** au nombre de jours réellement sous contrat dans le mois (entrée/sortie en cours
|
||||||
|
de mois). Itère les périodes de contrat (`employee.contractPeriods`), pas de requête jour/jour.
|
||||||
|
- Cœur partagé : `App\Service\WorkHours\StructuralOvertimeContingentCalculator`
|
||||||
|
(`monthlyStructuralMinutes` / `totalStructuralMinutes`). Ajouté au total des paiements RTT
|
||||||
|
côté provider (encart fiche) **et** export builder (PDF).
|
||||||
|
|
||||||
|
## Mapping exercice → année civile
|
||||||
|
Les paiements RTT (`EmployeeRttPayment`) sont stockés par **exercice** (`year` = Juin N-1 →
|
||||||
|
Mai N) + `month` (1–12). L'année civile d'un paiement :
|
||||||
|
|
||||||
|
annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear
|
||||||
|
|
||||||
|
Donc l'année civile **Y** agrège : exercice `Y` (mois 1–5) + exercice `Y+1` (mois 6–12).
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
- Cœur partagé : `App\Service\WorkHours\OvertimePaidContingentCalculator` (pur).
|
||||||
|
- Repo : `EmployeeRttPaymentRepository::findByEmployeesAndYears`.
|
||||||
|
- Fiche employé : `GET /employees/{id}/overtime-contingent?year=YYYY` → encart header
|
||||||
|
(`Total H.payés {année} : X h / plafond h`, rouge si dépassement, année civile courante).
|
||||||
|
- Export PDF : `GET /overtime-contingent/print?year=&siteIds=` (`ROLE_USER`, périmètre
|
||||||
|
`findScoped`), groupé par site (`displayOrder`), tri `displayOrder → nom → prénom`,
|
||||||
|
colonnes Janv–Déc + colonne `Total payé / payable`. Builder
|
||||||
|
`OvertimeContingentExportBuilder`, template `overtime-contingent/print.html.twig`.
|
||||||
|
|
||||||
|
## Hors périmètre / connu
|
||||||
|
- Bug latent récap salaire : `SalaryRecapPrintProvider` requête `findByYearAndMonth` avec
|
||||||
|
l'année civile alors que le stockage est par exercice (mauvais rattachement des paiements
|
||||||
|
des mois Juin–Déc sur le récap mensuel). À corriger séparément.
|
||||||
@@ -96,6 +96,12 @@ Traitement par employe:
|
|||||||
|
|
||||||
> Regle clef : le report d'un exercice a l'autre reprend exactement le **disponible** affiche sur l'onglet RTT (cf. `EmployeeRttSummaryProvider`). Le report deja present au debut de l'exercice precedent n'est jamais perdu, et les heures deja payees ne sont pas re-creditees. Service mutualise : `App\Service\Rtt\RttClosingBalanceService`.
|
> Regle clef : le report d'un exercice a l'autre reprend exactement le **disponible** affiche sur l'onglet RTT (cf. `EmployeeRttSummaryProvider`). Le report deja present au debut de l'exercice precedent n'est jamais perdu, et les heures deja payees ne sont pas re-creditees. Service mutualise : `App\Service\Rtt\RttClosingBalanceService`.
|
||||||
|
|
||||||
|
> Contrats CUSTOM : le solde de clôture intègre désormais les **déficits** hebdomadaires
|
||||||
|
> (semaines travaillées sous les heures contractuelles), via `RttClosingBalanceService::fold`
|
||||||
|
> qui gère les totaux négatifs. La clôture (donc le report d'ouverture N+1) peut être négative.
|
||||||
|
> Après une mise à jour de cette règle, rejouer `app:rtt:rollover --force --recompute` pour
|
||||||
|
> recalculer les lignes `employee_rtt_balances` non verrouillées calculées avec l'ancienne règle.
|
||||||
|
|
||||||
> Bug historique corrige : la version initiale ne reportait que `acquis N-1` (ni report d'ouverture, ni deduction des paiements), ce qui faisait disparaitre le solde de depart. Pour corriger des lignes deja creees a tort, relancer avec `--force --recompute`.
|
> Bug historique corrige : la version initiale ne reportait que `acquis N-1` (ni report d'ouverture, ni deduction des paiements), ce qui faisait disparaitre le solde de depart. Pour corriger des lignes deja creees a tort, relancer avec `--force --recompute`.
|
||||||
|
|
||||||
## 7) Donnees a fournir au go-live
|
## 7) Donnees a fournir au go-live
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `us
|
|||||||
|
|
||||||
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
||||||
|
|
||||||
|
## Règle de calcul — contrats CUSTOM (4h, 25h…)
|
||||||
|
|
||||||
|
Pour un contrat CUSTOM, la récupération est **plate** (1h sup = 1h récup, sans bonus 25 %/50 %).
|
||||||
|
Depuis 2026-06, une semaine **travaillée sous les heures contractuelles** produit un **déficit
|
||||||
|
signé** dans la colonne « Heure » qui **réduit le « Total » et le « Cumul »** (1h manquante =
|
||||||
|
-1h). Les colonnes Base/25 %/50 % restent à **0** (pas de tranches pour ces contrats). Le cumul
|
||||||
|
peut devenir négatif ; il est reporté à l'exercice suivant.
|
||||||
|
|
||||||
|
Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
|
||||||
|
`EmployeeRttSummaryProvider::applyDeficitCascade` les exclut du drainage des tranches 25/50.
|
||||||
|
|
||||||
|
#### Jour de solidarité (CUSTOM < 35h)
|
||||||
|
|
||||||
|
Sur la semaine du Lundi de Pentecôte, un contrat CUSTOM < 35h porte un déficit
|
||||||
|
forfaitaire de `7/35 × heuresHebdo` (12 min/h hebdo, ex. 4h → −0h48) dans les colonnes
|
||||||
|
Heure / Total / Cumul (25 %/50 % restent à 0). Le montant est fixe et inconditionnel :
|
||||||
|
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Un salarié qui
|
||||||
|
ne travaille pas le lundi (lundi non planifié au contrat) n'est pas concerné : aucun
|
||||||
|
déficit. Les contrats 35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul
|
||||||
|
normalement).
|
||||||
|
|
||||||
## Sélecteur d'année
|
## Sélecteur d'année
|
||||||
|
|
||||||
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
# RTT — Déficit pris en compte pour les contrats CUSTOM — 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:** Pour les contrats CUSTOM (4h, 25h…), une semaine travaillée sous les heures contractuelles doit réduire le cumul RTT (déficit compté), avec un affichage propre (colonnes 25%/50% à 0).
|
||||||
|
|
||||||
|
**Architecture:** On retire l'écrêtage `max(0, …)` du total hebdo CUSTOM dans `RttRecoveryComputationService` (le déficit signé circule dans `totalMinutes`) et on marque ces semaines `isFlatRecovery = true`. Ce drapeau désactive la cascade de drainage 25/50 dans `EmployeeRttSummaryProvider`, de sorte que le déficit n'impacte que les colonnes Heure/Total/Cumul. Le `RttClosingBalanceService::fold` gère déjà les totaux négatifs (report N+1 cohérent).
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony, API Platform, Doctrine ORM, PHPUnit. Frontend Nuxt/Vue (aucun changement de code, docs uniquement).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-09-rtt-custom-deficit-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Ajouter le drapeau `isFlatRecovery` aux DTOs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Dto/Rtt/WeekRecoveryDetail.php`
|
||||||
|
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
|
||||||
|
|
||||||
|
Ces deux DTOs sont de simples porteurs de données ; ils sont couverts par les tests des tâches 3 et 4. Pas de test dédié.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ajouter le champ à `WeekRecoveryDetail`**
|
||||||
|
|
||||||
|
Dans `src/Dto/Rtt/WeekRecoveryDetail.php`, ajouter un dernier paramètre au constructeur (après `$dailyMinutes`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $dailyMinutes date (Y-m-d) => worked minutes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
|
public array $dailyMinutes = [],
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Ajouter le champ à `EmployeeRttWeekSummary`**
|
||||||
|
|
||||||
|
Dans `src/Dto/Rtt/EmployeeRttWeekSummary.php`, ajouter un dernier paramètre au constructeur (après `$cumulativeBalanceMinutes`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $weekNumber,
|
||||||
|
public string $weekStart,
|
||||||
|
public string $weekEnd,
|
||||||
|
public int $overtimeMinutes = 0,
|
||||||
|
public int $base25Minutes = 0,
|
||||||
|
public int $bonus25Minutes = 0,
|
||||||
|
public int $base50Minutes = 0,
|
||||||
|
public int $bonus50Minutes = 0,
|
||||||
|
public int $totalMinutes = 0,
|
||||||
|
public int $cumulativeBalanceMinutes = 0,
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
|
) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier que rien n'est cassé (DTO à valeur par défaut)**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (aucun appel existant ne casse — le nouveau paramètre a une valeur par défaut).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Dto/Rtt/WeekRecoveryDetail.php src/Dto/Rtt/EmployeeRttWeekSummary.php
|
||||||
|
git commit -m "feat(rtt): add isFlatRecovery flag to recovery DTOs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Test de clôture — déficit CUSTOM diminue le report (aucun code à changer)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `tests/Service/Rtt/RttClosingBalanceServiceTest.php`
|
||||||
|
|
||||||
|
`RttClosingBalanceService::fold` gère déjà les `totalMinutes` négatifs. On ajoute un test explicite « déficit CUSTOM » pour verrouiller le comportement.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Écrire le test**
|
||||||
|
|
||||||
|
Ajouter cette méthode dans `tests/Service/Rtt/RttClosingBalanceServiceTest.php` (après `testCustomRecoveryWithoutBucketsStillCountsInTotal`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testCustomDeficitWeekReducesClosingBalance(): void
|
||||||
|
{
|
||||||
|
// CUSTOM (4h) : une semaine de récup +3h puis une semaine déficitaire -1h
|
||||||
|
// (toutes deux sans tranches 25/50). Le déficit doit réduire la clôture.
|
||||||
|
$recovery = new WeekRecoveryDetail(totalMinutes: 180, isFlatRecovery: true); // +3h
|
||||||
|
$deficit = new WeekRecoveryDetail(totalMinutes: -60, isFlatRecovery: true); // -1h
|
||||||
|
|
||||||
|
$closing = $this->service()->fold(new WeekRecoveryDetail(), [$recovery, $deficit], $this->payments());
|
||||||
|
|
||||||
|
// 3h - 1h = 2h reportées, et la somme des buckets égale toujours le total.
|
||||||
|
self::assertSame(120, $closing->totalMinutes);
|
||||||
|
self::assertSame(
|
||||||
|
120,
|
||||||
|
$closing->base25Minutes + $closing->bonus25Minutes + $closing->base50Minutes + $closing->bonus50Minutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer le test**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttClosingBalanceServiceTest.php --filter testCustomDeficitWeekReducesClosingBalance'`
|
||||||
|
Expected: PASS (le `fold` gère déjà les totaux négatifs).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/Service/Rtt/RttClosingBalanceServiceTest.php
|
||||||
|
git commit -m "test(rtt): custom deficit week reduces closing balance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `RttRecoveryComputationService` — récup plate signée pour CUSTOM
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php` (`computeRecoveryByWeek` lignes ~243-270, + nouvelle méthode privée)
|
||||||
|
- Test: `tests/Service/Rtt/RttRecoveryComputationServiceTest.php`
|
||||||
|
|
||||||
|
On extrait la construction du `WeekRecoveryDetail` dans une méthode pure `buildWeekRecoveryDetail`, testable par réflexion (style existant du fichier de test), et on y applique le changement CUSTOM.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Écrire les tests (méthode pure)**
|
||||||
|
|
||||||
|
Ajouter dans `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` (le fichier instancie déjà le service via `newInstanceWithoutConstructor` et possède le helper `invokePrivate`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testBuildWeekDetailCustomDeficitKeepsSignedTotalAndFlatFlag(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
// CUSTOM, semaine sous les heures : overtime -120 (worked 2h sur réf 4h).
|
||||||
|
$detail = $this->invokePrivate(
|
||||||
|
$service,
|
||||||
|
'buildWeekRecoveryDetail',
|
||||||
|
false, // isPresence
|
||||||
|
false, // disableBonuses
|
||||||
|
true, // isCustom
|
||||||
|
-120, // overtimeTotalMinutes
|
||||||
|
0, // rawBase25
|
||||||
|
0, // rawBase50
|
||||||
|
[], // dailyMinutes
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(-120, $detail->totalMinutes);
|
||||||
|
self::assertTrue($detail->isFlatRecovery);
|
||||||
|
self::assertSame(0, $detail->base25Minutes);
|
||||||
|
self::assertSame(0, $detail->base50Minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildWeekDetailCustomPositiveIsFlatOneToOne(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, true, 180, 0, 0, []);
|
||||||
|
|
||||||
|
self::assertSame(180, $detail->totalMinutes); // 1h = 1h
|
||||||
|
self::assertTrue($detail->isFlatRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildWeekDetailStandardKeepsBucketsAndBonuses(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
// 39h : overtime 300, base25 240, base50 60.
|
||||||
|
$detail = $this->invokePrivate($service, 'buildWeekRecoveryDetail', false, false, false, 300, 240, 60, []);
|
||||||
|
|
||||||
|
self::assertFalse($detail->isFlatRecovery);
|
||||||
|
self::assertSame(240, $detail->base25Minutes);
|
||||||
|
self::assertSame(60, $detail->bonus25Minutes); // round(240 * 0.25)
|
||||||
|
self::assertSame(60, $detail->base50Minutes);
|
||||||
|
self::assertSame(30, $detail->bonus50Minutes); // round(60 * 0.5)
|
||||||
|
self::assertSame(300 + 60 + 30, $detail->totalMinutes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttRecoveryComputationServiceTest.php --filter testBuildWeekDetail'`
|
||||||
|
Expected: FAIL — `buildWeekRecoveryDetail` n'existe pas encore (ReflectionException / method does not exist).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Ajouter la méthode privée `buildWeekRecoveryDetail`**
|
||||||
|
|
||||||
|
Dans `src/Service/Rtt/RttRecoveryComputationService.php`, ajouter cette méthode (par ex. juste après `computeRecoveryByWeek`, avant `computeMetrics`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
|
||||||
|
* des bandes d'heures sup brutes.
|
||||||
|
*
|
||||||
|
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
|
||||||
|
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
|
||||||
|
* le total, donc une semaine travaillée sous les heures contractuelles produit un
|
||||||
|
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
|
||||||
|
* provider ne draine pas les tranches 25/50.
|
||||||
|
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $dailyMinutes
|
||||||
|
*/
|
||||||
|
private function buildWeekRecoveryDetail(
|
||||||
|
bool $isPresence,
|
||||||
|
bool $disableBonuses,
|
||||||
|
bool $isCustom,
|
||||||
|
int $overtimeTotalMinutes,
|
||||||
|
int $rawBase25,
|
||||||
|
int $rawBase50,
|
||||||
|
array $dailyMinutes,
|
||||||
|
): WeekRecoveryDetail {
|
||||||
|
$noBands = $isPresence || $disableBonuses || $isCustom;
|
||||||
|
|
||||||
|
$base25 = $noBands ? 0 : $rawBase25;
|
||||||
|
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
|
||||||
|
$base50 = $noBands ? 0 : $rawBase50;
|
||||||
|
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
|
||||||
|
|
||||||
|
if ($isPresence || $disableBonuses) {
|
||||||
|
$totalMinutes = 0;
|
||||||
|
} elseif ($isCustom) {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
|
||||||
|
} else {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
overtimeMinutes: $overtimeTotalMinutes,
|
||||||
|
base25Minutes: $base25,
|
||||||
|
bonus25Minutes: $bonus25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
bonus50Minutes: $bonus50,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
dailyMinutes: $dailyMinutes,
|
||||||
|
isFlatRecovery: $isCustom,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Brancher l'appelant sur la nouvelle méthode**
|
||||||
|
|
||||||
|
Dans `computeRecoveryByWeek`, remplacer le bloc existant (depuis `[$rawBase25, $rawBase50] = …` jusqu'à la fin du `new WeekRecoveryDetail(...)` qui assigne `$results[$weekKey]`) par :
|
||||||
|
|
||||||
|
```php
|
||||||
|
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||||
|
|
||||||
|
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||||
|
$isWeekPresenceTracking,
|
||||||
|
$disableOvertimeBonuses,
|
||||||
|
$isCustomContract,
|
||||||
|
$weeklyOvertimeTotalMinutes,
|
||||||
|
$rawBase25,
|
||||||
|
$rawBase50,
|
||||||
|
$dailyWorkedMinutes,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Conserver les lignes précédentes qui calculent `$weeklyOvertimeTotalMinutes`, `$overtime25StartMinutes`, `$overtime50StartMinutes`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Lancer les tests pour vérifier qu'ils passent**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/Service/Rtt/RttRecoveryComputationServiceTest.php'`
|
||||||
|
Expected: PASS (nouveaux tests + tests existants des helpers).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Rtt/RttRecoveryComputationService.php tests/Service/Rtt/RttRecoveryComputationServiceTest.php
|
||||||
|
git commit -m "feat(rtt): custom contract deficit counts as signed recovery (1h=1h, no bands)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `EmployeeRttSummaryProvider` — sauter la cascade pour les semaines plates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/State/EmployeeRttSummaryProvider.php` (cascade lignes ~145-174 → méthode extraite ; `buildWeekSummaries` lignes ~385-396 et ~425-436)
|
||||||
|
- Test: `tests/State/EmployeeRttSummaryProviderTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Écrire les tests (méthode pure `applyDeficitCascade`)**
|
||||||
|
|
||||||
|
Ajouter dans `tests/State/EmployeeRttSummaryProviderTest.php`. Le fichier importe déjà `EmployeeRttSummaryProvider`, `ReflectionClass`, et possède `invokePrivate` / `buildProvider`. Ajouter d'abord ce petit helper de fabrication en bas de la classe (si un helper équivalent n'existe pas déjà) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function weekSummary(int $totalMinutes, bool $isFlat, int $base25 = 0, int $base50 = 0): \App\Dto\Rtt\EmployeeRttWeekSummary
|
||||||
|
{
|
||||||
|
return new \App\Dto\Rtt\EmployeeRttWeekSummary(
|
||||||
|
month: 6,
|
||||||
|
weekNumber: 1,
|
||||||
|
weekStart: '2026-06-01',
|
||||||
|
weekEnd: '2026-06-07',
|
||||||
|
overtimeMinutes: $totalMinutes,
|
||||||
|
base25Minutes: $base25,
|
||||||
|
base50Minutes: $base50,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
isFlatRecovery: $isFlat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis les tests :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testFlatDeficitWeekIsNotDrainedFromTiers(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
// Semaine CUSTOM déficitaire (-120), aucune tranche accumulée.
|
||||||
|
$weeks = [$this->weekSummary(-120, true)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0);
|
||||||
|
|
||||||
|
// Buckets restent à 0 ; le total négatif est conservé (le cumul est calculé ailleurs).
|
||||||
|
self::assertSame(0, $result[0]->base25Minutes);
|
||||||
|
self::assertSame(0, $result[0]->base50Minutes);
|
||||||
|
self::assertSame(-120, $result[0]->totalMinutes);
|
||||||
|
self::assertTrue($result[0]->isFlatRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStandardDeficitWeekDrainsFiftyThenTwentyFive(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
// Semaine 35h/39h déficitaire (-100), avec 60 en 50% et 120 en 25% accumulés.
|
||||||
|
$weeks = [$this->weekSummary(-100, false)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 120, 60);
|
||||||
|
|
||||||
|
self::assertSame(-60, $result[0]->base50Minutes); // 60 drainés du 50%
|
||||||
|
self::assertSame(-40, $result[0]->base25Minutes); // 40 restants drainés du 25%
|
||||||
|
self::assertSame(-100, $result[0]->totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFlatPositiveWeekIsUntouched(): void
|
||||||
|
{
|
||||||
|
$provider = $this->buildProvider([]);
|
||||||
|
|
||||||
|
$weeks = [$this->weekSummary(180, true)];
|
||||||
|
$result = $this->invokePrivate($provider, 'applyDeficitCascade', $weeks, 0, 0);
|
||||||
|
|
||||||
|
self::assertSame(180, $result[0]->totalMinutes);
|
||||||
|
self::assertSame(0, $result[0]->base25Minutes);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NB : si `invokePrivate` n'accepte pas d'arguments variadiques dans ce fichier, vérifier sa signature en haut du fichier de test et adapter (l'autre fichier de test du dépôt l'utilise déjà avec des arguments).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer les tests pour vérifier qu'ils échouent**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/State/EmployeeRttSummaryProviderTest.php --filter Deficit'`
|
||||||
|
Expected: FAIL — `applyDeficitCascade` n'existe pas encore.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extraire la cascade en méthode privée**
|
||||||
|
|
||||||
|
Dans `src/State/EmployeeRttSummaryProvider.php`, ajouter cette méthode privée (par ex. juste avant `buildWeekSummaries`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Distribue les semaines déficitaires sur les tranches 25/50 accumulées (50 % d'abord,
|
||||||
|
* puis 25 %), en réécrivant les buckets affichés de chaque semaine déficitaire avec les
|
||||||
|
* montants négatifs drainés.
|
||||||
|
*
|
||||||
|
* Les semaines à récupération plate (CUSTOM 1h = 1h) sont ignorées : elles n'ont pas de
|
||||||
|
* tranches 25/50, donc leur déficit ne réduit que le cumul courant (calculé ensuite à
|
||||||
|
* partir de totalMinutes) et les colonnes 25/50 restent à 0.
|
||||||
|
*
|
||||||
|
* @param list<EmployeeRttWeekSummary> $weeks
|
||||||
|
*
|
||||||
|
* @return list<EmployeeRttWeekSummary>
|
||||||
|
*/
|
||||||
|
private function applyDeficitCascade(array $weeks, int $cumulative25, int $cumulative50): array
|
||||||
|
{
|
||||||
|
foreach ($weeks as $i => $week) {
|
||||||
|
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||||
|
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
||||||
|
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deficit = -$week->totalMinutes;
|
||||||
|
$from50 = min($deficit, max(0, $cumulative50));
|
||||||
|
$from25 = $deficit - $from50;
|
||||||
|
|
||||||
|
$cumulative50 -= $from50;
|
||||||
|
$cumulative25 -= $from25;
|
||||||
|
|
||||||
|
$weeks[$i] = new EmployeeRttWeekSummary(
|
||||||
|
month: $week->month,
|
||||||
|
weekNumber: $week->weekNumber,
|
||||||
|
weekStart: $week->weekStart,
|
||||||
|
weekEnd: $week->weekEnd,
|
||||||
|
overtimeMinutes: $week->overtimeMinutes,
|
||||||
|
base25Minutes: $from25 > 0 ? -$from25 : 0,
|
||||||
|
bonus25Minutes: 0,
|
||||||
|
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
||||||
|
bonus50Minutes: 0,
|
||||||
|
totalMinutes: $week->totalMinutes,
|
||||||
|
isFlatRecovery: $week->isFlatRecovery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $weeks;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Brancher `provide()` sur la méthode extraite**
|
||||||
|
|
||||||
|
Dans `provide()`, remplacer le bloc commentaire + boucle (depuis `// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)` et la déclaration de `$cumulative50`/`$cumulative25` jusqu'à la fin du `foreach ($summary->weeks as $i => $week) { … }`) par :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%).
|
||||||
|
// Flat-recovery (CUSTOM) weeks are skipped — their deficit only reduces the running cumul.
|
||||||
|
$summary->weeks = $this->applyDeficitCascade(
|
||||||
|
$summary->weeks,
|
||||||
|
$carry->base25Minutes + $carry->bonus25Minutes,
|
||||||
|
$carry->base50Minutes + $carry->bonus50Minutes,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Propager `isFlatRecovery` dans `buildWeekSummaries`**
|
||||||
|
|
||||||
|
Dans `buildWeekSummaries`, ajouter `isFlatRecovery: $detail->isFlatRecovery,` comme dernier argument des DEUX appels `new EmployeeRttWeekSummary(...)` :
|
||||||
|
- le cas mono-mois (`if ($startMonth === $endMonth)`, après `totalMinutes: $detail->totalMinutes,`)
|
||||||
|
- le cas semaine à cheval (boucle `foreach ([$startMonth, $endMonth] …`, après `totalMinutes: (int) round($detail->totalMinutes * $ratio),`)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Lancer les tests**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && vendor/bin/phpunit tests/State/EmployeeRttSummaryProviderTest.php'`
|
||||||
|
Expected: PASS (nouveaux tests + tests existants du provider).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/State/EmployeeRttSummaryProvider.php tests/State/EmployeeRttSummaryProviderTest.php
|
||||||
|
git commit -m "feat(rtt): skip 25/50 deficit cascade for flat (custom) recovery weeks"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `DumpVerificationSnapshotCommand` — refléter le drapeau
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Command/DumpVerificationSnapshotCommand.php` (`distributeDeficits` ligne ~689 ; build des week summaries lignes ~628-639 et ~664-675)
|
||||||
|
|
||||||
|
Ce command duplique la logique du provider pour produire les snapshots de vérification. Sans mise à jour, les snapshots « after » seraient faux pour les semaines CUSTOM.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Propager `isFlatRecovery` dans les week summaries dupliquées**
|
||||||
|
|
||||||
|
Ajouter `isFlatRecovery: $detail->isFlatRecovery,` comme dernier argument des deux appels `new EmployeeRttWeekSummary(...)` (cas mono-mois ligne ~638 après `totalMinutes: $detail->totalMinutes,`, et cas à cheval ligne ~674 après `totalMinutes: (int) round($detail->totalMinutes * $ratio),`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Sauter la cascade pour les semaines plates dans `distributeDeficits`**
|
||||||
|
|
||||||
|
Modifier la condition de la boucle :
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||||
|
```
|
||||||
|
|
||||||
|
et ajouter `isFlatRecovery: $week->isFlatRecovery,` comme dernier argument du `new EmployeeRttWeekSummary(...)` de reconstruction (après `totalMinutes: $week->totalMinutes,`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier la compilation (lint)**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm sh -c 'cd /var/www/html && php -l src/Command/DumpVerificationSnapshotCommand.php'`
|
||||||
|
Expected: `No syntax errors detected`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Command/DumpVerificationSnapshotCommand.php
|
||||||
|
git commit -m "chore(rtt): mirror flat-recovery cascade skip in verification snapshot command"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Documentation (obligatoire — même intervention)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md` (ligne ~68)
|
||||||
|
- Modify: `frontend/data/documentation-content.ts` (lignes ~367-368 et section RTT ~520)
|
||||||
|
- Modify: `doc/rtt-tab.md`
|
||||||
|
- Modify: `doc/rtt-rollover.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: `CLAUDE.md`**
|
||||||
|
|
||||||
|
Remplacer la ligne :
|
||||||
|
|
||||||
|
```
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```
|
||||||
|
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery). **Le déficit (heures travaillées < heures contractuelles) réduit le cumul RTT 1:1** (peut devenir négatif, reporté à l'exercice suivant). Implémenté via `WeekRecoveryDetail::isFlatRecovery` / `EmployeeRttWeekSummary::isFlatRecovery` : ces semaines portent leur récup/déficit signé dans `totalMinutes` (`RttRecoveryComputationService::buildWeekRecoveryDetail`) et `EmployeeRttSummaryProvider::applyDeficitCascade` **ne draine pas** les tranches 25/50 pour elles (colonnes 25%/50% restent à 0). Le `RttClosingBalanceService::fold` reporte le déficit en N+1.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: In-app docs — règles d'heures sup (ligne ~367)**
|
||||||
|
|
||||||
|
Dans `frontend/data/documentation-content.ts`, remplacer le contenu de la liste ligne ~367 :
|
||||||
|
|
||||||
|
```
|
||||||
|
Contrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```
|
||||||
|
Contrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus. Une semaine sous les heures contractuelles réduit le cumul RTT (1h manquante = -1h), sans passer par les tranches 25/50
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: In-app docs — note déficit (ligne ~368)**
|
||||||
|
|
||||||
|
Remplacer le contenu de la note ligne ~368 :
|
||||||
|
|
||||||
|
```
|
||||||
|
En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%.
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```
|
||||||
|
En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT. Pour un 35h/39h, il est puisé d'abord dans les heures à 50%, puis à 25%. Pour un contrat CUSTOM (4h, etc.), il réduit directement le cumul (pas de tranches 25/50) ; le cumul peut devenir négatif et est reporté à l'exercice suivant.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: In-app docs — section RTT « Compteurs » (ajout d'une note)**
|
||||||
|
|
||||||
|
Dans l'article `rtt-compteurs` (id `'rtt-compteurs'`, vers la ligne ~520), ajouter une note à la fin du tableau `blocks` (après le dernier `paragraph`) :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{ type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: `doc/rtt-tab.md` — ajouter une sous-section règle CUSTOM**
|
||||||
|
|
||||||
|
Après la section « Période affichée » (avant « Sélecteur d'année »), insérer :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Règle de calcul — contrats CUSTOM (4h, 25h…)
|
||||||
|
|
||||||
|
Pour un contrat CUSTOM, la récupération est **plate** (1h sup = 1h récup, sans bonus 25 %/50 %).
|
||||||
|
Depuis 2026-06, une semaine **travaillée sous les heures contractuelles** produit un **déficit
|
||||||
|
signé** dans la colonne « Heure » qui **réduit le « Total » et le « Cumul »** (1h manquante =
|
||||||
|
-1h). Les colonnes Base/25 %/50 % restent à **0** (pas de tranches pour ces contrats). Le cumul
|
||||||
|
peut devenir négatif ; il est reporté à l'exercice suivant.
|
||||||
|
|
||||||
|
Techniquement : `WeekRecoveryDetail::isFlatRecovery` marque ces semaines ;
|
||||||
|
`EmployeeRttSummaryProvider::applyDeficitCascade` les exclut du drainage des tranches 25/50.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: `doc/rtt-rollover.md` — préciser que le déficit CUSTOM est reporté**
|
||||||
|
|
||||||
|
Sous le point 3 (« calculer le solde de clôture… ») / la « Règle clef » (ligne ~97), ajouter :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
> Contrats CUSTOM : le solde de clôture intègre désormais les **déficits** hebdomadaires
|
||||||
|
> (semaines travaillées sous les heures contractuelles), via `RttClosingBalanceService::fold`
|
||||||
|
> qui gère les totaux négatifs. La clôture (donc le report d'ouverture N+1) peut être négative.
|
||||||
|
> Après une mise à jour de cette règle, rejouer `app:rtt:rollover --force --recompute` pour
|
||||||
|
> recalculer les lignes `employee_rtt_balances` non verrouillées calculées avec l'ancienne règle.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build frontend (vérifier que le TS compile)**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm true` (no-op) puis localement : ne PAS lancer `npm run build` (préférence utilisateur). Vérifier visuellement que les chaînes ajoutées échappent bien les apostrophes (`\'`).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md frontend/data/documentation-content.ts doc/rtt-tab.md doc/rtt-rollover.md
|
||||||
|
git commit -m "docs(rtt): custom contract deficit now reduces the balance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Vérification métier sur données prod + suite complète
|
||||||
|
|
||||||
|
**Files:** aucun (vérification).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Lancer toute la suite de tests**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (aucune régression).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier Ewa / Nadia via le snapshot de vérification (ou requête API)**
|
||||||
|
|
||||||
|
Générer un snapshot « after » et confirmer que pour Ewa (id 31, exercice 2027) la semaine 23 affiche : Heure −2h, Total −2h, Cumul −2h, colonnes 25/50 = 0 ; et que Nadia (id 22) reste cohérente.
|
||||||
|
|
||||||
|
Run (exemple, adapter à la signature réelle du command) :
|
||||||
|
`docker exec php-sirh-fpm sh -c 'cd /var/www/html && php bin/console app:dump-verification-snapshot --help'`
|
||||||
|
|
||||||
|
Comparer `docs/verifications/` (before) et le nouveau snapshot.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Note de déploiement**
|
||||||
|
|
||||||
|
Consigner dans la PR : après déploiement, exécuter
|
||||||
|
`php bin/console app:rtt:rollover --force --recompute`
|
||||||
|
pour rafraîchir les reports stockés (lignes non verrouillées) calculés avec l'ancienne règle (déficit = 0).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage** : (1) déficit signé CUSTOM → Task 3 ; (2) cumul réduit + cascade non drainée → Task 4 ; (3) report N+1 → Task 2 (fold déjà OK) + note rollover Task 6/7 ; (4) affichage propre (frontend inchangé) → couvert par buckets 0 ; (5) command de vérification → Task 5 ; (6) docs → Task 6. ✓
|
||||||
|
- **Placeholders** : aucun — code complet à chaque étape.
|
||||||
|
- **Cohérence des types** : `isFlatRecovery` (bool) ajouté de façon identique aux 2 DTOs ; `buildWeekRecoveryDetail` et `applyDeficitCascade` ont des signatures fixes utilisées de manière cohérente entre tâches et tests. ✓
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,522 @@
|
|||||||
|
# RTT — Déficit jour de solidarité (CUSTOM < 35h) — 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:** Sur le Lundi de Pentecôte, retrancher au cumul RTT des contrats CUSTOM < 35h un déficit forfaitaire de `7/35 × heuresHebdo` (= 12 min/heure hebdo), net et inconditionnel, sans rien changer aux autres contrats.
|
||||||
|
|
||||||
|
**Architecture :** Un service pur `SolidarityDayResolver` calcule le Lundi de Pentecôte par computus (Pâques + 50 j). `RttRecoveryComputationService::computeRecoveryByWeek` (calcul partagé : onglet RTT, clôture/rollover, commande de vérification) neutralise le jour de solidarité pour les CUSTOM < 35h et applique le prorata, en le faisant transiter par `totalMinutes` via le mécanisme `isFlatRecovery` existant (reporté en N+1, ne draine pas les tranches 25/50).
|
||||||
|
|
||||||
|
**Tech Stack :** PHP 8.4, Symfony, PHPUnit. Tests purs via `ReflectionClass::newInstanceWithoutConstructor` (pattern existant dans `RttRecoveryComputationServiceTest`).
|
||||||
|
|
||||||
|
**Spec :** `docs/superpowers/specs/2026-06-11-rtt-solidarity-day-deficit-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Create** `src/Service/Rtt/SolidarityDayResolver.php` — service pur, computus de Pâques + Lundi de Pentecôte. Responsabilité unique : donner la date du jour de solidarité d'une année.
|
||||||
|
- **Create** `tests/Service/Rtt/SolidarityDayResolverTest.php` — tests des dates 2024/2025/2026.
|
||||||
|
- **Modify** `src/Service/Rtt/RttRecoveryComputationService.php` — injecter `SolidarityDayResolver` ; ajouter `resolveSolidarityDatesInRange()` + `computeSolidarityDeficitAdjustment()` ; appliquer dans `computeRecoveryByWeek()`.
|
||||||
|
- **Modify** `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` — tests réflexion de `computeSolidarityDeficitAdjustment()`.
|
||||||
|
- **Modify (docs)** `CLAUDE.md`, `frontend/data/documentation-content.ts`, `doc/rtt-tab.md`, `doc/functional-rules.md`.
|
||||||
|
- **Inchangé** : `config/services.yaml` (autowiring : `SolidarityDayResolver` est un service autowireable, et `RttRecoveryComputationService` n'override que `$rttStartDate` — les autres args s'autowirent), `DumpVerificationSnapshotCommand.php` (consomme `WeekRecoveryDetail.totalMinutes`, hérite du déficit), `RttTab.vue`, migrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: SolidarityDayResolver (computus Pâques + Pentecôte)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Rtt/SolidarityDayResolver.php`
|
||||||
|
- Test: `tests/Service/Rtt/SolidarityDayResolverTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/Service/Rtt/SolidarityDayResolverTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Rtt;
|
||||||
|
|
||||||
|
use App\Service\Rtt\SolidarityDayResolver;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class SolidarityDayResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Lundi de Pentecôte = dimanche de Pâques + 50 jours.
|
||||||
|
* 2024 : Pâques 31/03 → 20/05 ; 2025 : Pâques 20/04 → 09/06 ; 2026 : Pâques 05/04 → 25/05.
|
||||||
|
*
|
||||||
|
* @dataProvider pentecostCases
|
||||||
|
*/
|
||||||
|
public function testPentecostMonday(int $year, string $expected): void
|
||||||
|
{
|
||||||
|
$resolver = new SolidarityDayResolver();
|
||||||
|
|
||||||
|
self::assertSame($expected, $resolver->pentecostMonday($year)->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{int, string}>
|
||||||
|
*/
|
||||||
|
public static function pentecostCases(): iterable
|
||||||
|
{
|
||||||
|
yield '2024' => [2024, '2024-05-20'];
|
||||||
|
yield '2025' => [2025, '2025-06-09'];
|
||||||
|
yield '2026' => [2026, '2026-05-25'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test` (or `docker exec php-sirh-fpm php bin/phpunit tests/Service/Rtt/SolidarityDayResolverTest.php`)
|
||||||
|
Expected: FAIL — `Class "App\Service\Rtt\SolidarityDayResolver" not found`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the implementation**
|
||||||
|
|
||||||
|
Create `src/Service/Rtt/SolidarityDayResolver.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Rtt;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le jour de solidarité (Lundi de Pentecôte) d'une année.
|
||||||
|
*
|
||||||
|
* Pur et déterministe : Pâques via l'algorithme de Meeus/Jones/Butcher (calendrier
|
||||||
|
* grégorien), sans dépendance à l'extension calendar ni au réseau. Lundi de Pentecôte
|
||||||
|
* = dimanche de Pâques + 50 jours.
|
||||||
|
*/
|
||||||
|
final class SolidarityDayResolver
|
||||||
|
{
|
||||||
|
public function pentecostMonday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->easterSunday($year)->modify('+50 days');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function easterSunday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$a = $year % 19;
|
||||||
|
$b = intdiv($year, 100);
|
||||||
|
$c = $year % 100;
|
||||||
|
$d = intdiv($b, 4);
|
||||||
|
$e = $b % 4;
|
||||||
|
$f = intdiv($b + 8, 25);
|
||||||
|
$g = intdiv($b - $f + 1, 3);
|
||||||
|
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||||
|
$i = intdiv($c, 4);
|
||||||
|
$k = $c % 4;
|
||||||
|
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||||
|
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||||
|
|
||||||
|
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||||
|
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||||
|
|
||||||
|
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (3 nouveaux tests verts, le reste de la suite inchangé).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Rtt/SolidarityDayResolver.php tests/Service/Rtt/SolidarityDayResolverTest.php
|
||||||
|
git commit -m "feat(rtt) : add SolidarityDayResolver (Pentecost Monday via computus)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Déficit solidarité dans RttRecoveryComputationService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php`
|
||||||
|
- Test: `tests/Service/Rtt/RttRecoveryComputationServiceTest.php`
|
||||||
|
|
||||||
|
Le helper `computeSolidarityDeficitAdjustment()` est **pur** (n'utilise que `ContractType::resolve` et les getters de `Contract`) → testable via `newInstanceWithoutConstructor` comme les autres helpers du fichier. Il renvoie le **delta** à ajouter à `weeklyOvertimeTotalMinutes`.
|
||||||
|
|
||||||
|
Rappel arithmétique (Ewa, 4h, lundi, `expected = workDaysHours[lundi] = 120`, `prorata = round(4×12) = 48`) :
|
||||||
|
- RTT posé / jour vide (`worked = 0`) → delta `(120−0)−48 = +72` ; appliqué au naturel `−120` ⇒ semaine **−48 min**.
|
||||||
|
- travaillé normalement (`worked = 120`) → delta `(120−120)−48 = −48` ; naturel `0` ⇒ **−48 min**.
|
||||||
|
- travaillé en plus (`worked = 240`) → delta `(120−240)−48 = −168` ; naturel `+120` ⇒ **−48 min**.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test (pure helper)**
|
||||||
|
|
||||||
|
Add these methods to `tests/Service/Rtt/RttRecoveryComputationServiceTest.php` (before the `invokePrivate` helper). Note `use` additions at top: `use App\Enum\TrackingMode;` (already imports `App\Entity\Contract`).
|
||||||
|
|
||||||
|
```php
|
||||||
|
private static function customContract(int $weeklyHours): Contract
|
||||||
|
{
|
||||||
|
return new Contract()
|
||||||
|
->setName('Temps partiel')
|
||||||
|
->setTrackingMode(TrackingMode::TIME)
|
||||||
|
->setWeeklyHours($weeklyHours)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité non travaillé (RTT posé ou vide) : delta = (attendu − 0) − prorata.
|
||||||
|
* attendu lundi = workDaysHours = 120 ; prorata = round(4×12) = 48 ; delta = 120 − 48 = 72.
|
||||||
|
* (Combiné au naturel −120 de la semaine, donne −48 min.)
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomNotWorkedNeutralisesToProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate(
|
||||||
|
$service,
|
||||||
|
'computeSolidarityDeficitAdjustment',
|
||||||
|
self::customContract(4),
|
||||||
|
120, // expectedMinutes (workDaysHours du lundi)
|
||||||
|
0, // workedMinutes (RTT posé / vide)
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(72, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité travaillé normalement (120) : delta = (120 − 120) − 48 = −48.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomWorkedNormallyChargesProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 120);
|
||||||
|
|
||||||
|
self::assertSame(-48, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 4h, jour de solidarité travaillé en plus (240) : delta = (120 − 240) − 48 = −168.
|
||||||
|
* Le surplus du jour de solidarité n'est PAS crédité (jour neutralisé, net forcé à −prorata).
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustomWorkedExtraStillNetsProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(4), 120, 240);
|
||||||
|
|
||||||
|
self::assertSame(-168, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM 28h : prorata = round(28×12) = 336 (5h36). worked 0, expected 336 → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustom28hUsesProrata(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(28), 336, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CUSTOM ≥ 35h (36h) : hors périmètre → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentCustom36hOutOfScope(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', self::customContract(36), 999, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 35h : type H35 (pas CUSTOM) → delta 0 (comportement inchangé, RTT posé fait foi).
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustment35hOutOfScope(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
$contract = new Contract()->setName('35h')->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', $contract, 420, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aucun contrat ce jour-là (salarié parti / pas encore embauché) → delta 0.
|
||||||
|
*/
|
||||||
|
public function testSolidarityAdjustmentNoContractIsZero(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
$delta = $this->invokePrivate($service, 'computeSolidarityDeficitAdjustment', null, 0, 0);
|
||||||
|
|
||||||
|
self::assertSame(0, $delta);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: FAIL — `computeSolidarityDeficitAdjustment` n'existe pas (réflexion : `Method ... does not exist`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the constructor dependency**
|
||||||
|
|
||||||
|
In `src/Service/Rtt/RttRecoveryComputationService.php`, add `SolidarityDayResolver` to the constructor (BEFORE the defaulted `$rttStartDate`, sinon erreur « param non-défaut après défaut »). `SolidarityDayResolver` est dans le même namespace `App\Service\Rtt` → aucun `use` à ajouter.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function __construct(
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private AbsenceRepository $absenceRepository,
|
||||||
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private SolidarityDayResolver $solidarityDayResolver,
|
||||||
|
string $rttStartDate = '',
|
||||||
|
) {
|
||||||
|
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the two private methods**
|
||||||
|
|
||||||
|
In the same file, add these methods (e.g. just after `resolveWeekAnchorDate`, alongside the other private helpers):
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
|
||||||
|
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
|
||||||
|
*
|
||||||
|
* @return list<string> dates au format 'Y-m-d'
|
||||||
|
*/
|
||||||
|
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
$firstYear = (int) $from->format('Y');
|
||||||
|
$lastYear = (int) $to->format('Y');
|
||||||
|
|
||||||
|
for ($year = $firstYear; $year <= $lastYear; ++$year) {
|
||||||
|
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
|
||||||
|
if ($candidate >= $from && $candidate <= $to) {
|
||||||
|
$dates[] = $candidate->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
|
||||||
|
*
|
||||||
|
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
|
||||||
|
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
|
||||||
|
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
|
||||||
|
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
|
||||||
|
* semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à
|
||||||
|
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
|
||||||
|
*/
|
||||||
|
private function computeSolidarityDeficitAdjustment(
|
||||||
|
?Contract $contractAtSolidarity,
|
||||||
|
int $expectedMinutes,
|
||||||
|
int $workedMinutes,
|
||||||
|
): int {
|
||||||
|
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
|
||||||
|
$type = ContractType::resolve(
|
||||||
|
$contractAtSolidarity?->getName(),
|
||||||
|
$contractAtSolidarity?->getTrackingMode(),
|
||||||
|
$weeklyHours,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prorata = (int) round($weeklyHours * 12);
|
||||||
|
|
||||||
|
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Wire it into `computeRecoveryByWeek`**
|
||||||
|
|
||||||
|
(a) Just before the weeks loop, after `$results = [];` (≈ line 165), resolve the solidarity dates once:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$results = [];
|
||||||
|
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Inside the week loop, immediately after `$weeklyOvertimeTotalMinutes = ...` is computed (the `$isWeekPresenceTracking ? 0 : $weeklyTotalMinutes - $overtimeReferenceMinutes;` assignment, ≈ line 243-245) and BEFORE the `[$rawBase25, $rawBase50] = ...` line, insert:
|
||||||
|
|
||||||
|
```php
|
||||||
|
foreach ($solidarityDates as $solidarityDate) {
|
||||||
|
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
|
||||||
|
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
|
||||||
|
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
|
||||||
|
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
|
||||||
|
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
|
||||||
|
// c'est ce qui rend la neutralisation correcte (cf. spec).
|
||||||
|
$solidarityExpected = $this->dailyReferenceResolver->resolve(
|
||||||
|
$contractAtSolidarity?->getWeeklyHours(),
|
||||||
|
$solidarityIsoDay,
|
||||||
|
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
|
||||||
|
$contractAtSolidarity,
|
||||||
|
$solidarityExpected,
|
||||||
|
$dailyWorkedMinutes[$solidarityDate],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS — les 7 nouveaux tests verts, toute la suite verte (le pre-commit relance aussi la suite complète).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify the service container still builds (autowiring)**
|
||||||
|
|
||||||
|
Run: `docker exec php-sirh-fpm php bin/console debug:container App\\Service\\Rtt\\RttRecoveryComputationService 2>&1 | tail -20`
|
||||||
|
Expected: le service est listé, sans erreur d'argument non résolu (`SolidarityDayResolver` autowiré). Si erreur d'autowiring : ajouter explicitement l'argument dans `config/services.yaml` sous `RttRecoveryComputationService` — mais normalement inutile.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Rtt/RttRecoveryComputationService.php tests/Service/Rtt/RttRecoveryComputationServiceTest.php
|
||||||
|
git commit -m "feat(rtt) : solidarity-day deficit for CUSTOM <35h contracts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Vérification sur données de production
|
||||||
|
|
||||||
|
**Files:** aucun fichier de code. Génère des snapshots « after » et compare.
|
||||||
|
|
||||||
|
Contexte : le workflow before/after existe déjà (`docs/verifications/` = avant, `docs/verifications-after/` = après). La commande `app:verification:snapshot` rend la vue onglet RTT par mois.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the "after" snapshot for the witnesses + a control**
|
||||||
|
|
||||||
|
Ewa (id 31, CUSTOM 4h), Nadia (id 22, CUSTOM 4h), et un témoin 35h ou 39h (choisir un id présent — vérifier en base) pour prouver la non-régression.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec php-sirh-fpm php bin/console app:verification:snapshot 31 22 --rtt-year=2026 --output-dir=docs/verifications-after
|
||||||
|
```
|
||||||
|
Expected: génère les fichiers Markdown sans erreur.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inspect Ewa's solidarity week (S22, semaine du 25/05/2026)**
|
||||||
|
|
||||||
|
Ouvrir le snapshot d'Ewa (`docs/verifications-after/…`) et vérifier :
|
||||||
|
- Semaine du 2026-05-25 : **Heure** et **Total** = −0h48 (−48 min), **Cumul** réduit de 48 min.
|
||||||
|
- Colonnes 25 % / 50 % = 0 sur cette semaine.
|
||||||
|
- La semaine du 2026-06-01 (lundi 1er juin) conserve son −2h existant, distinct.
|
||||||
|
|
||||||
|
Si l'écart ne vaut pas −48 min : NE PAS « ajuster jusqu'à ce que ça passe ». Relire `computeSolidarityDeficitAdjustment` et la valeur `expectedMinutes` (doit valoir `workDaysHours[lundi]`, ex. 120) — un écart signale un bug réel (utiliser systematic-debugging).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm non-regression for a standard contract**
|
||||||
|
|
||||||
|
Snapshot d'un employé 35h/39h ayant un RTT posé sur le 25/05/2026 : la semaine doit être **inchangée** vs `docs/verifications/` (le déficit solidarité ne s'applique pas, le RTT posé garde son effet).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the after-snapshots (regression baseline)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/verifications-after
|
||||||
|
git commit -m "test(rtt) : after-snapshot proving solidarity deficit on Ewa/Nadia S22"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Documentation (règle projet obligatoire)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
- Modify: `doc/functional-rules.md`
|
||||||
|
- Modify: `doc/rtt-tab.md`
|
||||||
|
- Modify: `frontend/data/documentation-content.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: CLAUDE.md — section Overtime Rules**
|
||||||
|
|
||||||
|
Sous la puce CUSTOM existante (« CUSTOM contracts … Le déficit … réduit le cumul RTT 1:1 »), ajouter une sous-puce :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Jour de solidarité (Lundi de Pentecôte) — CUSTOM < 35h** : le jour est neutralisé et chargé d'un déficit forfaitaire `7/35 × weeklyHours` = **12 min par heure hebdo** (4h→48 min, 25h→5h, 28h→5h36), retranché du cumul RTT (signé, reporté N+1, ne draine pas les tranches 25/50). Net = exactement −prorata quel que soit ce qui est posé ce jour-là (RTT, heures, vide) → pas de double comptage avec le RTT que la RH pose aussi sur ce jour. Hors périmètre : 35h/39h/Forfait/Intérim et CUSTOM ≥ 35h (inchangés ; la RH pose un RTT qui draine ~7h). Date via `App\Service\Rtt\SolidarityDayResolver` (computus, indépendant d'`EXCLUDED_PUBLIC_HOLIDAYS`). Appliqué dans `RttRecoveryComputationService::{resolveSolidarityDatesInRange, computeSolidarityDeficitAdjustment}`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: doc/functional-rules.md**
|
||||||
|
|
||||||
|
Dans la section RTT / heures supplémentaires (près des règles CUSTOM), ajouter un paragraphe :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Jour de solidarité (contrats CUSTOM < 35h)
|
||||||
|
|
||||||
|
Le Lundi de Pentecôte (jour de solidarité) impose une contribution proratisée aux temps
|
||||||
|
partiels < 35h. La RH pose un RTT sur ce jour pour tous les salariés ; pour les contrats
|
||||||
|
standard (35h/39h) cela draine ~7h du cumul RTT (comportement inchangé). Pour les CUSTOM
|
||||||
|
< 35h, poser un RTT entier n'a pas de sens : le logiciel **neutralise** le jour (quel que
|
||||||
|
soit ce qui y est saisi) et applique un déficit forfaitaire `7/35 × heuresHebdo`
|
||||||
|
(= 12 min par heure hebdo : 4h → 48 min, 28h → 5h36). Ce déficit réduit le cumul RTT
|
||||||
|
(peut le rendre négatif, reporté à l'exercice suivant) et se cumule avec les autres
|
||||||
|
déficits/surplus de la semaine. Date calculée par computus (Pâques + 50 jours),
|
||||||
|
indépendante de la liste `EXCLUDED_PUBLIC_HOLIDAYS`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: doc/rtt-tab.md**
|
||||||
|
|
||||||
|
Dans la section « Règle de calcul — contrats CUSTOM », ajouter un sous-bloc :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
#### Jour de solidarité (CUSTOM < 35h)
|
||||||
|
|
||||||
|
Sur la semaine du Lundi de Pentecôte, un contrat CUSTOM < 35h porte un déficit
|
||||||
|
forfaitaire de `7/35 × heuresHebdo` (12 min/h hebdo, ex. 4h → −0h48) dans les colonnes
|
||||||
|
Heure / Total / Cumul (25 %/50 % restent à 0). Le montant est fixe et inconditionnel :
|
||||||
|
il ne dépend pas des heures saisies ni du RTT que la RH pose ce jour-là. Les contrats
|
||||||
|
35h/39h ne sont pas concernés ici (leur RTT posé draine le cumul normalement).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: frontend/data/documentation-content.ts**
|
||||||
|
|
||||||
|
Repérer l'article RTT pour les contrats partiels / CUSTOM (recherche `CUSTOM` ou `rtt-compteurs`). Ajouter un bloc de texte (échapper les apostrophes `\'`) décrivant la règle :
|
||||||
|
|
||||||
|
```
|
||||||
|
Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un
|
||||||
|
déficit fixe proportionnel (7/35 des heures hebdo, soit 12 minutes par heure
|
||||||
|
hebdomadaire : 4h → 48 min). Ce déficit réduit le cumul RTT, peu importe ce qui est saisi
|
||||||
|
ce jour-là.
|
||||||
|
```
|
||||||
|
|
||||||
|
Respecter la structure `DocBlock` existante (même type de bloc que les paragraphes voisins).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the suite and commit**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (les docs ne cassent rien ; la suite reste verte).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md doc/functional-rules.md doc/rtt-tab.md frontend/data/documentation-content.ts
|
||||||
|
git commit -m "docs(rtt) : document solidarity-day deficit for CUSTOM <35h"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (rempli pendant la rédaction)
|
||||||
|
|
||||||
|
- **Couverture spec** : SolidarityDayResolver (T1) ✓ ; injection + neutralisation + prorata dans computeRecoveryByWeek (T2) ✓ ; périmètre CUSTOM < 35h + garde ≥ 35h (T2, `computeSolidarityDeficitAdjustment`) ✓ ; robustesse limitDate/rttStartDate via `isset($dailyWorkedMinutes)` (T2 step 5) ✓ ; contrat lu au jour de solidarité (T2 step 5) ✓ ; propagation clôture/rollover/snapshot via totalMinutes (inchangé, vérifié T3) ✓ ; cas limites (T2 tests + T3) ✓ ; docs (T4) ✓.
|
||||||
|
- **Pas de placeholder** : tout le code est fourni.
|
||||||
|
- **Cohérence des noms** : `pentecostMonday`, `resolveSolidarityDatesInRange`, `computeSolidarityDeficitAdjustment`, `$solidarityDayResolver`, `$dailyReferenceResolver`, `$workDaysByDate`, `$employeeId`, `$dailyWorkedMinutes`, `$employeeContractsByDate` — alignés avec le code existant de `computeRecoveryByWeek`.
|
||||||
@@ -0,0 +1,904 @@
|
|||||||
|
# Contexte forensique dans le journal d'activité — 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:** Capter automatiquement IP, libellé appareil, User-Agent brut et identifiant d'appareil persistant sur chaque entrée du journal d'activité, et les exposer en API lecture, pour différencier les intervenants derrière un compte partagé (ex. « Usine »).
|
||||||
|
|
||||||
|
**Architecture:** Point de capture unique côté back (`AuditLogger::log()` + `RequestStack`) → aucun processor modifié. 4 colonnes nullable ajoutées à `audit_logs`. Un service `UserAgentParser` dérive un libellé lisible. Côté front, un device ID persistant (`localStorage`) est envoyé en header `X-Device-Id` sur toutes les requêtes API.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 7 + API Platform + Doctrine (PostgreSQL) ; Nuxt 4 + Vue 3 + TypeScript (ofetch).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Backend = source de vérité ; le front ne fait qu'envoyer le header et afficher. (CLAUDE.md)
|
||||||
|
- Toute écriture d'audit DOIT passer par `AuditLogger` — ne pas dupliquer la capture ailleurs. (CLAUDE.md, doc/audit-logging.md)
|
||||||
|
- Migrations Doctrine : toujours un `down()` fonctionnel. PostgreSQL. (CLAUDE.md)
|
||||||
|
- DTO PHP ↔ DTO TS alignés. (CLAUDE.md)
|
||||||
|
- Tout changement fonctionnel met à jour `doc/` + `frontend/data/documentation-content.ts` + `CLAUDE.md` dans la même intervention. (CLAUDE.md — règles obligatoires)
|
||||||
|
- **Ne PAS lancer `npm run build`** sauf demande explicite. (mémoire feedback utilisateur)
|
||||||
|
- Code (variables, commentaires) en anglais ; UI/libellés en français.
|
||||||
|
- Format de message de commit imposé par le hook : `<type> : <message>` (espace AVANT le `:`). Types : build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.
|
||||||
|
- Lancer un test ciblé : `make test FILES=<chemin>`. Conteneur PHP : `php-sirh-fpm`. Le pre-commit hook lance déjà tout PHPUnit + php-cs-fixer.
|
||||||
|
- **Hors périmètre (étape suivante, ne PAS toucher) :** l'écran `frontend/pages/audit-logs.vue` (affichage des nouvelles colonnes, filtre par appareil). On se contente d'exposer les champs dans l'API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
- `src/Service/UserAgentParser.php` — *créer*. Parse un User-Agent en libellé court `Type · OS · Navigateur`.
|
||||||
|
- `tests/Service/UserAgentParserTest.php` — *créer*.
|
||||||
|
- `src/Entity/AuditLog.php` — *modifier*. +4 propriétés + accesseurs.
|
||||||
|
- `migrations/Version20260624120000.php` — *créer*. +4 colonnes nullable sur `audit_logs`.
|
||||||
|
- `src/Service/AuditLogger.php` — *modifier*. Injecte `RequestStack` + `UserAgentParser`, peuple les 4 champs.
|
||||||
|
- `tests/Service/AuditLoggerTest.php` — *créer*.
|
||||||
|
- `src/State/AuditLogProvider.php` — *modifier*. Expose les 4 champs dans le JSON.
|
||||||
|
- `tests/State/AuditLogProviderTest.php` — *créer*.
|
||||||
|
- `config/packages/framework.yaml` — *modifier*. Bloc `trusted_proxies` documenté (commenté).
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
- `frontend/composables/useDeviceId.ts` — *créer*. Device ID persistant.
|
||||||
|
- `frontend/composables/useApi.ts` — *modifier*. Injecte le header `X-Device-Id` (intercepteur `onRequest`).
|
||||||
|
- `frontend/services/dto/audit-log.ts` — *modifier*. +4 champs optionnels.
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- `doc/audit-logging.md`, `frontend/data/documentation-content.ts`, `CLAUDE.md` — *modifier*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Service `UserAgentParser`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/UserAgentParser.php`
|
||||||
|
- Test: `tests/Service/UserAgentParserTest.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: rien.
|
||||||
|
- Produces: `UserAgentParser::parse(?string $userAgent): ?string` → libellé `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`), ou `null` si UA vide/null. Consommé par Task 3.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/Service/UserAgentParserTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Service\UserAgentParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class UserAgentParserTest extends TestCase
|
||||||
|
{
|
||||||
|
private UserAgentParser $parser;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->parser = new UserAgentParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullAndEmptyReturnNull(): void
|
||||||
|
{
|
||||||
|
self::assertNull($this->parser->parse(null));
|
||||||
|
self::assertNull($this->parser->parse(''));
|
||||||
|
self::assertNull($this->parser->parse(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChromeOnWindows(): void
|
||||||
|
{
|
||||||
|
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
self::assertSame('Ordinateur · Windows · Chrome', $this->parser->parse($ua));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEdgeBeatsChrome(): void
|
||||||
|
{
|
||||||
|
$ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0';
|
||||||
|
self::assertSame('Ordinateur · Windows · Edge', $this->parser->parse($ua));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSafariOnIphoneIsMobileIos(): void
|
||||||
|
{
|
||||||
|
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||||
|
self::assertSame('Mobile · iOS · Safari', $this->parser->parse($ua));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChromeOnAndroid(): void
|
||||||
|
{
|
||||||
|
$ua = 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
||||||
|
self::assertSame('Mobile · Android · Chrome', $this->parser->parse($ua));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFirefoxOnLinux(): void
|
||||||
|
{
|
||||||
|
$ua = 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0';
|
||||||
|
self::assertSame('Ordinateur · Linux · Firefox', $this->parser->parse($ua));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSafariOnMac(): void
|
||||||
|
{
|
||||||
|
$ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15';
|
||||||
|
self::assertSame('Ordinateur · macOS · Safari', $this->parser->parse($ua));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIpadIsTablet(): void
|
||||||
|
{
|
||||||
|
$ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||||
|
self::assertSame('Tablette · iOS · Safari', $this->parser->parse($ua));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownUaFallsBack(): void
|
||||||
|
{
|
||||||
|
self::assertSame('Ordinateur · Autre · Autre', $this->parser->parse('SomeRandomBot/1.0'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test FILES=tests/Service/UserAgentParserTest.php`
|
||||||
|
Expected: FAIL — `Class "App\Service\UserAgentParser" not found`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `src/Service/UserAgentParser.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
|
||||||
|
* User-Agent string, used to add forensic context to audit log entries.
|
||||||
|
* Heuristic on purpose — enough to tell a phone from a desktop and identify
|
||||||
|
* OS/browser families on shared accounts.
|
||||||
|
*/
|
||||||
|
class UserAgentParser
|
||||||
|
{
|
||||||
|
public function parse(?string $userAgent): ?string
|
||||||
|
{
|
||||||
|
if (null === $userAgent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ua = trim($userAgent);
|
||||||
|
if ('' === $ua) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', [
|
||||||
|
$this->detectType($ua),
|
||||||
|
$this->detectOs($ua),
|
||||||
|
$this->detectBrowser($ua),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectType(string $ua): string
|
||||||
|
{
|
||||||
|
if (1 === preg_match('/iPad|Tablet/i', $ua)) {
|
||||||
|
return 'Tablette';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) {
|
||||||
|
return 'Mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Ordinateur';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectOs(string $ua): string
|
||||||
|
{
|
||||||
|
// Order matters: iOS before macOS (iOS UAs contain "Mac OS X"),
|
||||||
|
// Android before Linux (Android UAs contain "Linux").
|
||||||
|
return match (true) {
|
||||||
|
1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS',
|
||||||
|
1 === preg_match('/Android/i', $ua) => 'Android',
|
||||||
|
1 === preg_match('/Windows/i', $ua) => 'Windows',
|
||||||
|
1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS',
|
||||||
|
1 === preg_match('/Linux/i', $ua) => 'Linux',
|
||||||
|
default => 'Autre',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectBrowser(string $ua): string
|
||||||
|
{
|
||||||
|
// Order matters: Edge/Opera contain "Chrome" and "Safari";
|
||||||
|
// Chrome contains "Safari". Match the most specific first.
|
||||||
|
return match (true) {
|
||||||
|
1 === preg_match('/Edg/i', $ua) => 'Edge',
|
||||||
|
1 === preg_match('/OPR|Opera/i', $ua) => 'Opera',
|
||||||
|
1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox',
|
||||||
|
1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome',
|
||||||
|
1 === preg_match('/Safari/i', $ua) => 'Safari',
|
||||||
|
default => 'Autre',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test FILES=tests/Service/UserAgentParserTest.php`
|
||||||
|
Expected: PASS (8 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/UserAgentParser.php tests/Service/UserAgentParserTest.php
|
||||||
|
git commit -m "feat(audit) : ajoute UserAgentParser (libellé appareil lisible)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Colonnes `audit_logs` + entité
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/AuditLog.php`
|
||||||
|
- Create: `migrations/Version20260624120000.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (sur `AuditLog`) : `getIpAddress(): ?string` / `setIpAddress(?string): self` ; `getUserAgent(): ?string` / `setUserAgent(?string): self` ; `getDeviceLabel(): ?string` / `setDeviceLabel(?string): self` ; `getDeviceId(): ?string` / `setDeviceId(?string): self`. Consommés par Task 3 et Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the 4 mapped properties to the entity**
|
||||||
|
|
||||||
|
In `src/Entity/AuditLog.php`, after the `affectedDate` property block (currently ends line 47, before `createdAt` declared line 49–50), insert:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(type: 'string', length: 45, nullable: true)]
|
||||||
|
private ?string $ipAddress = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $userAgent = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $deviceLabel = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||||
|
private ?string $deviceId = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the accessors**
|
||||||
|
|
||||||
|
In `src/Entity/AuditLog.php`, after `setAffectedDate()` (ends line 156) and before `getCreatedAt()` (line 158), insert:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIpAddress(?string $ipAddress): self
|
||||||
|
{
|
||||||
|
$this->ipAddress = $ipAddress;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUserAgent(?string $userAgent): self
|
||||||
|
{
|
||||||
|
$this->userAgent = $userAgent;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeviceLabel(?string $deviceLabel): self
|
||||||
|
{
|
||||||
|
$this->deviceLabel = $deviceLabel;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceId(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeviceId(?string $deviceId): self
|
||||||
|
{
|
||||||
|
$this->deviceId = $deviceId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the migration**
|
||||||
|
|
||||||
|
Create `migrations/Version20260624120000.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260624120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add forensic context columns (ip, user agent, device label, device id) to audit_logs';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Apply the migration and verify the mapping**
|
||||||
|
|
||||||
|
Run: `make migration-migrate`
|
||||||
|
Expected: migration `Version20260624120000` applied, no error.
|
||||||
|
|
||||||
|
Then verify the Doctrine mapping matches the DB:
|
||||||
|
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console doctrine:schema:validate`
|
||||||
|
Expected: `[OK] The mapping files are correct.` (the "database is in sync" line must also be OK for `audit_logs`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/AuditLog.php migrations/Version20260624120000.php
|
||||||
|
git commit -m "feat(audit) : colonnes contexte forensique sur audit_logs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Capture du contexte dans `AuditLogger`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Service/AuditLogger.php`
|
||||||
|
- Create: `tests/Service/AuditLoggerTest.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `UserAgentParser::parse()` (Task 1) ; setters de `AuditLog` (Task 2) ; `Symfony\Component\HttpFoundation\RequestStack`.
|
||||||
|
- Produces: signature publique de `AuditLogger::log()` **inchangée** (la capture est interne, automatique). Le constructeur gagne 2 dépendances autowirées.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/Service/AuditLoggerTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use App\Service\AuditLogger;
|
||||||
|
use App\Service\UserAgentParser;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class AuditLoggerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCapturesRequestContext(): void
|
||||||
|
{
|
||||||
|
$persisted = null;
|
||||||
|
$em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn(null); // -> username "system"
|
||||||
|
|
||||||
|
$request = Request::create('/api/work_hours', 'POST');
|
||||||
|
$request->server->set('REMOTE_ADDR', '203.0.113.7');
|
||||||
|
$request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||||
|
$request->headers->set('X-Device-Id', 'device-abc');
|
||||||
|
|
||||||
|
$stack = new RequestStack();
|
||||||
|
$stack->push($request);
|
||||||
|
|
||||||
|
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
|
||||||
|
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||||
|
|
||||||
|
self::assertInstanceOf(AuditLog::class, $persisted);
|
||||||
|
self::assertSame('203.0.113.7', $persisted->getIpAddress());
|
||||||
|
self::assertSame('device-abc', $persisted->getDeviceId());
|
||||||
|
self::assertSame('Ordinateur · Windows · Chrome', $persisted->getDeviceLabel());
|
||||||
|
self::assertNotNull($persisted->getUserAgent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTruncatesOverlongDeviceId(): void
|
||||||
|
{
|
||||||
|
$persisted = null;
|
||||||
|
$em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn(null);
|
||||||
|
|
||||||
|
$request = Request::create('/api/work_hours', 'POST');
|
||||||
|
$request->headers->set('X-Device-Id', str_repeat('x', 200));
|
||||||
|
$stack = new RequestStack();
|
||||||
|
$stack->push($request);
|
||||||
|
|
||||||
|
$logger = new AuditLogger($em, $security, $stack, new UserAgentParser());
|
||||||
|
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||||
|
|
||||||
|
self::assertSame(64, mb_strlen((string) $persisted->getDeviceId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRequestLeavesContextNull(): void
|
||||||
|
{
|
||||||
|
$persisted = null;
|
||||||
|
$em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn(null);
|
||||||
|
|
||||||
|
$logger = new AuditLogger($em, $security, new RequestStack(), new UserAgentParser());
|
||||||
|
$logger->log(null, 'create', 'work_hour', 1, 'desc');
|
||||||
|
|
||||||
|
self::assertNull($persisted->getIpAddress());
|
||||||
|
self::assertNull($persisted->getUserAgent());
|
||||||
|
self::assertNull($persisted->getDeviceLabel());
|
||||||
|
self::assertNull($persisted->getDeviceId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test FILES=tests/Service/AuditLoggerTest.php`
|
||||||
|
Expected: FAIL — `AuditLogger::__construct()` expects 2 args (too few given), or `getIpAddress()` undefined if run before Task 2.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the service**
|
||||||
|
|
||||||
|
Replace the full contents of `src/Service/AuditLogger.php` with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
readonly class AuditLogger
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private Security $security,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private UserAgentParser $userAgentParser,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function log(
|
||||||
|
?Employee $employee,
|
||||||
|
string $action,
|
||||||
|
string $entityType,
|
||||||
|
?int $entityId,
|
||||||
|
string $description,
|
||||||
|
?array $changes = null,
|
||||||
|
?DateTimeImmutable $affectedDate = null,
|
||||||
|
): void {
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
$username = $user instanceof User ? $user->getUsername() : 'system';
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$ipAddress = null;
|
||||||
|
$userAgent = null;
|
||||||
|
$deviceId = null;
|
||||||
|
|
||||||
|
if (null !== $request) {
|
||||||
|
$ipAddress = $request->getClientIp();
|
||||||
|
$userAgent = $request->headers->get('User-Agent');
|
||||||
|
$deviceId = $request->headers->get('X-Device-Id');
|
||||||
|
// The device id comes from an untrusted client header; cap it to the column width.
|
||||||
|
if (null !== $deviceId) {
|
||||||
|
$deviceId = mb_substr($deviceId, 0, 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLog = new AuditLog();
|
||||||
|
$auditLog
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setUsername($username)
|
||||||
|
->setAction($action)
|
||||||
|
->setEntityType($entityType)
|
||||||
|
->setEntityId($entityId)
|
||||||
|
->setDescription($description)
|
||||||
|
->setChanges($changes)
|
||||||
|
->setAffectedDate($affectedDate)
|
||||||
|
->setIpAddress($ipAddress)
|
||||||
|
->setUserAgent($userAgent)
|
||||||
|
->setDeviceLabel($this->userAgentParser->parse($userAgent))
|
||||||
|
->setDeviceId($deviceId)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($auditLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test FILES=tests/Service/AuditLoggerTest.php`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full backend suite (no regression)**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: OK — all tests green (existing processors that use `AuditLogger` are autowired, so the 2 new constructor args resolve automatically).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/AuditLogger.php tests/Service/AuditLoggerTest.php
|
||||||
|
git commit -m "feat(audit) : capture IP/appareil/user-agent dans AuditLogger"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Exposition des champs dans l'API lecture
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/State/AuditLogProvider.php:53-64` (le tableau `$items[]`)
|
||||||
|
- Modify: `frontend/services/dto/audit-log.ts`
|
||||||
|
- Create: `tests/State/AuditLogProviderTest.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: getters de `AuditLog` (Task 2).
|
||||||
|
- Produces: chaque item JSON du endpoint `GET /audit-logs` porte désormais `ipAddress`, `userAgent`, `deviceLabel`, `deviceId` (string|null). Le DTO TS `AuditLog` gagne ces 4 champs optionnels.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/State/AuditLogProviderTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use App\State\AuditLogProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class AuditLogProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testProvideExposesForensicFields(): void
|
||||||
|
{
|
||||||
|
$log = (new AuditLog())
|
||||||
|
->setUsername('usine')
|
||||||
|
->setAction('create')
|
||||||
|
->setEntityType('work_hour')
|
||||||
|
->setDescription('desc')
|
||||||
|
->setIpAddress('203.0.113.7')
|
||||||
|
->setUserAgent('UA-string')
|
||||||
|
->setDeviceLabel('Mobile · Android · Chrome')
|
||||||
|
->setDeviceId('device-abc')
|
||||||
|
;
|
||||||
|
|
||||||
|
$repo = $this->createMock(AuditLogRepository::class);
|
||||||
|
$repo->method('countByFilters')->willReturn(1);
|
||||||
|
$repo->method('findByFilters')->willReturn([$log]);
|
||||||
|
|
||||||
|
$stack = new RequestStack();
|
||||||
|
$stack->push(Request::create('/api/audit-logs', 'GET'));
|
||||||
|
|
||||||
|
$provider = new AuditLogProvider($stack, $repo);
|
||||||
|
$response = $provider->provide($this->createMock(Operation::class));
|
||||||
|
|
||||||
|
$data = json_decode((string) $response->getContent(), true);
|
||||||
|
$item = $data['items'][0];
|
||||||
|
|
||||||
|
self::assertSame('203.0.113.7', $item['ipAddress']);
|
||||||
|
self::assertSame('UA-string', $item['userAgent']);
|
||||||
|
self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']);
|
||||||
|
self::assertSame('device-abc', $item['deviceId']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test FILES=tests/State/AuditLogProviderTest.php`
|
||||||
|
Expected: FAIL — `Undefined array key "ipAddress"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the fields to the provider output**
|
||||||
|
|
||||||
|
In `src/State/AuditLogProvider.php`, in the `$items[] = [ ... ]` block, add the 4 keys after `'affectedDate' => ...` and before `'createdAt' => ...`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
|
||||||
|
'ipAddress' => $log->getIpAddress(),
|
||||||
|
'userAgent' => $log->getUserAgent(),
|
||||||
|
'deviceLabel' => $log->getDeviceLabel(),
|
||||||
|
'deviceId' => $log->getDeviceId(),
|
||||||
|
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test FILES=tests/State/AuditLogProviderTest.php`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Align the frontend DTO**
|
||||||
|
|
||||||
|
Replace the full contents of `frontend/services/dto/audit-log.ts` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type AuditLog = {
|
||||||
|
id: number
|
||||||
|
employeeName: string | null
|
||||||
|
employeeId: number | null
|
||||||
|
username: string
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
description: string
|
||||||
|
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
|
||||||
|
affectedDate: string | null
|
||||||
|
ipAddress: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
deviceLabel: string | null
|
||||||
|
deviceId: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/State/AuditLogProvider.php tests/State/AuditLogProviderTest.php frontend/services/dto/audit-log.ts
|
||||||
|
git commit -m "feat(audit) : expose le contexte forensique dans l'API lecture"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Device ID persistant côté front
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/composables/useDeviceId.ts`
|
||||||
|
- Modify: `frontend/composables/useApi.ts` (intercepteur `onRequest` dans `$fetch.create`, lignes 79-170)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: rien.
|
||||||
|
- Produces: `useDeviceId(): string | null` (auto-importé Nuxt). Renvoie l'UUID stocké dans `localStorage['sirh-device-id']` (créé si absent), ou `null` côté serveur (SSR). `useApi` ajoute le header `X-Device-Id` sur toutes les requêtes API quand l'ID est disponible.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the composable**
|
||||||
|
|
||||||
|
Create `frontend/composables/useDeviceId.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Stable per-device identifier used to add forensic context to audit logs.
|
||||||
|
// Persisted in localStorage so the same browser/device reuses it across sessions.
|
||||||
|
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
|
||||||
|
// user of the same browser shares one id (intended: it distinguishes devices).
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sirh-device-id'
|
||||||
|
let cached: string | null = null
|
||||||
|
|
||||||
|
export const useDeviceId = (): string | null => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID()
|
||||||
|
localStorage.setItem(STORAGE_KEY, id)
|
||||||
|
}
|
||||||
|
cached = id
|
||||||
|
return id
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode, disabled) — degrade gracefully.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inject the header in the shared fetch client**
|
||||||
|
|
||||||
|
In `frontend/composables/useApi.ts`, the client is created at line 79 with `$fetch.create({ baseURL, retry: 0, credentials: 'include', onResponse(...) {...}, onResponseError(...) {...} })`. Add an `onRequest` interceptor as the first option inside that object (right after `credentials: 'include',`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const client = $fetch.create({
|
||||||
|
baseURL,
|
||||||
|
retry: 0,
|
||||||
|
credentials: 'include',
|
||||||
|
onRequest({ options }) {
|
||||||
|
const deviceId = useDeviceId()
|
||||||
|
if (deviceId) {
|
||||||
|
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||||
|
headers.set('X-Device-Id', deviceId)
|
||||||
|
options.headers = headers
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onResponse({ options, response }) {
|
||||||
|
```
|
||||||
|
|
||||||
|
This covers every call — both `request()` (GET/POST/PUT/PATCH/DELETE) and the `getBlob` path (`client.raw`), since both go through this single client.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify (no build — review only)**
|
||||||
|
|
||||||
|
Do NOT run `npm run build` (project rule). Verify by re-reading the diff:
|
||||||
|
- `useDeviceId.ts` returns `null` on server (`import.meta.client` guard) and never throws.
|
||||||
|
- In `useApi.ts`, `onRequest` is a sibling key of `onResponse` inside `$fetch.create({...})`, the braces/commas are balanced, and `options.headers` is reassigned to the merged `Headers`.
|
||||||
|
|
||||||
|
Expected: header `X-Device-Id` will be present on all `/api/*` requests once running.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/composables/useDeviceId.ts frontend/composables/useApi.ts
|
||||||
|
git commit -m "feat(audit) : envoie un device id persistant sur les requêtes API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Config `trusted_proxies` documentée
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/packages/framework.yaml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: rien. Produces: rien (config commentée, comportement inchangé tant que non activée).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the documented (commented) block**
|
||||||
|
|
||||||
|
In `config/packages/framework.yaml`, inside the top-level `framework:` block (after `session: true`, before the `#esi: true` line), insert:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Trusted proxies — REQUIRED for a correct client IP in the activity log
|
||||||
|
# when SIRH runs behind a reverse proxy (nginx / traefik / cloud LB).
|
||||||
|
# Without this, Request::getClientIp() returns the PROXY ip, not the client's.
|
||||||
|
# Uncomment and set to the proxy network/CIDR of your deployment, e.g.:
|
||||||
|
# trusted_proxies: '127.0.0.1,REMOTE_ADDR,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'
|
||||||
|
# trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
|
||||||
|
# trusted_proxies: '%env(TRUSTED_PROXIES)%'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify config still loads**
|
||||||
|
|
||||||
|
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console cache:clear`
|
||||||
|
Expected: cache cleared, no YAML/config error.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/packages/framework.yaml
|
||||||
|
git commit -m "docs(audit) : documente trusted_proxies pour l'IP du journal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Documentation (règles obligatoires)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `doc/audit-logging.md`
|
||||||
|
- Modify: `frontend/data/documentation-content.ts`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
**Interfaces:** N/A.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `doc/audit-logging.md`**
|
||||||
|
|
||||||
|
In the "Données stockées par entrée" section, add the 4 new fields and a note. Append these lines to that list (after `affectedDate` / `createdAt`):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `ipAddress` : IP source de la requête (`Request::getClientIp()`) — nécessite `framework.trusted_proxies` derrière un reverse proxy, sinon IP du proxy
|
||||||
|
- `userAgent` : User-Agent brut de la requête
|
||||||
|
- `deviceLabel` : libellé lisible dérivé du User-Agent (`Type · OS · Navigateur`, ex. `Mobile · Android · Chrome`), via `App\Service\UserAgentParser`
|
||||||
|
- `deviceId` : identifiant d'appareil persistant envoyé par le front (header `X-Device-Id`, stocké en `localStorage['sirh-device-id']`). Distingue les **appareils** derrière un compte partagé (ex. « Usine »), pas les personnes.
|
||||||
|
|
||||||
|
Capture : automatique et centralisée dans `AuditLogger::log()` (via `RequestStack`) — aucun processor à modifier. En contexte CLI/cron (pas de requête), ces 4 champs restent `null`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the in-app documentation**
|
||||||
|
|
||||||
|
In `frontend/data/documentation-content.ts`, locate the audit-log / "Journal des actions" article (admin level). Add to its content a block explaining the new forensic columns. Add this block to that article's `blocks` array (follow the existing `DocBlock` shape used in the file — typically `{ type: 'paragraph', text: '...' }` and `{ type: 'list', items: [...] }`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ type: 'paragraph', text: "Chaque entrée du journal enregistre aussi un contexte technique automatique pour distinguer les intervenants sur un compte partagé (ex. « Usine ») :" },
|
||||||
|
{
|
||||||
|
type: 'list',
|
||||||
|
items: [
|
||||||
|
"Adresse IP de la connexion",
|
||||||
|
"Appareil / système / navigateur (ex. « Mobile · Android · Chrome »)",
|
||||||
|
"Identifiant d'appareil : un même appareil garde le même identifiant entre les sessions (distingue les appareils, pas les personnes)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
(If the exact `DocBlock` field names differ — check `frontend/types/documentation.ts` — adapt the keys to match; keep the French copy.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `CLAUDE.md`**
|
||||||
|
|
||||||
|
In `CLAUDE.md`, in the `## Audit Logging` section, add a bullet:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Contexte forensique automatique** : chaque entrée capte aussi `ipAddress`, `userAgent` (brut), `deviceLabel` (libellé lisible via `App\Service\UserAgentParser`) et `deviceId` (header `X-Device-Id`, device id persistant `localStorage['sirh-device-id']` envoyé par le front depuis `useApi`/`useDeviceId`). Capture centralisée dans `AuditLogger::log()` via `RequestStack` (null en contexte CLI). But : distinguer les appareils derrière un compte partagé (ex. « Usine »). IP fiable derrière proxy → activer `framework.trusted_proxies`. Affichage écran (`audit-logs.vue`) non couvert (refonte séparée). Doc : `doc/audit-logging.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify docs reference real symbols**
|
||||||
|
|
||||||
|
Run: `grep -rn "UserAgentParser\|X-Device-Id\|sirh-device-id" doc/audit-logging.md CLAUDE.md src/ frontend/composables/`
|
||||||
|
Expected: references resolve to the files created in Tasks 1, 3, 5 (no typos).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add doc/audit-logging.md frontend/data/documentation-content.ts CLAUDE.md
|
||||||
|
git commit -m "docs(audit) : documente le contexte forensique du journal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (auteur du plan)
|
||||||
|
|
||||||
|
**Spec coverage :**
|
||||||
|
- Capture 4 signaux via point unique → Task 3 ✓
|
||||||
|
- 4 colonnes nullable + migration `down()` → Task 2 ✓
|
||||||
|
- `UserAgentParser` maison → Task 1 ✓
|
||||||
|
- Device id front (localStorage) + header sur toutes requêtes → Task 5 ✓
|
||||||
|
- Exposition API lecture + DTO TS aligné → Task 4 ✓
|
||||||
|
- `trusted_proxies` documenté/conservateur → Task 6 ✓
|
||||||
|
- Docs (doc + in-app + CLAUDE.md) + tests → Tasks 1,3,4,7 ✓
|
||||||
|
- Écran `audit-logs.vue` explicitement hors périmètre → respecté (aucune tâche ne le touche) ✓
|
||||||
|
|
||||||
|
**Placeholder scan :** aucun TBD/TODO ; tout le code est fourni. La seule souplesse explicite : Task 7 Step 2 demande d'adapter aux noms de champs réels de `DocBlock` (avec instruction de vérifier `frontend/types/documentation.ts`).
|
||||||
|
|
||||||
|
**Type consistency :** getters/setters de Task 2 (`getIpAddress`/`getUserAgent`/`getDeviceLabel`/`getDeviceId`) réutilisés à l'identique dans Tasks 3 et 4. Clés JSON (`ipAddress`/`userAgent`/`deviceLabel`/`deviceId`) identiques entre provider (Task 4 Step 3), test (Task 4 Step 1) et DTO TS (Task 4 Step 5). Header `X-Device-Id` et clé `localStorage` `sirh-device-id` cohérents entre Task 3 (lecture back), Task 5 (écriture front) et docs.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
|||||||
|
# Déficit RTT pris en compte pour les contrats CUSTOM (4h, etc.)
|
||||||
|
|
||||||
|
Date : 2026-06-09
|
||||||
|
Branche : `feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h`
|
||||||
|
|
||||||
|
## Contexte & problème
|
||||||
|
|
||||||
|
Les salariées avec un contrat 4h (type CUSTOM : `weeklyHours` ≠ 35/39, non INTERIM/FORFAIT,
|
||||||
|
mode TIME) voient, dans l'onglet RTT, des semaines travaillées **en dessous** de leurs heures
|
||||||
|
contractuelles afficher un déficit dans la colonne « Heure » (ex. Ewa S23 : −2h) **sans aucun
|
||||||
|
effet** : « Total » = 0 et « Cumul » = 0.
|
||||||
|
|
||||||
|
Cause : `RttRecoveryComputationService::computeRecoveryByWeek` écrête le total hebdo des CUSTOM
|
||||||
|
avec `totalMinutes = max(0, $weeklyOvertimeTotalMinutes)`. Le déficit est donc supprimé. C'est
|
||||||
|
le comportement métier documenté jusqu'ici (« CUSTOM : le déficit n'impacte pas le solde »).
|
||||||
|
|
||||||
|
Décision métier (validée avec le client) : **le déficit doit être pris en compte et réduire le
|
||||||
|
cumul**, comme pour les 35h/39h, avec un affichage propre dans l'onglet RTT.
|
||||||
|
|
||||||
|
## Décisions validées
|
||||||
|
|
||||||
|
1. **Le cumul peut devenir négatif** (identique aux 35h/39h, comportement déjà assumé dans le
|
||||||
|
code — cf. `RttClosingBalanceService::fold` ligne 98 « leftover may push the balance
|
||||||
|
negative, as on screen »). Le négatif est reporté à l'exercice suivant.
|
||||||
|
2. **Déficit visible** en colonnes « Heure » + « Total » + « Cumul ». Les colonnes 25%/50%
|
||||||
|
restent à **0** pour un contrat CUSTOM (un 4h n'a pas de bonus, donc pas de tranches).
|
||||||
|
|
||||||
|
## Principe technique
|
||||||
|
|
||||||
|
Retirer l'écrêtage `max(0, …)` pour les semaines CUSTOM : le déficit (négatif) circule dans
|
||||||
|
`WeekRecoveryDetail::totalMinutes` et réduit le cumul. La seule spécificité CUSTOM reste :
|
||||||
|
récupération 1h = 1h, sans bonus 25/50.
|
||||||
|
|
||||||
|
### Le point délicat : ne pas drainer les tranches 25/50
|
||||||
|
|
||||||
|
Pour un 35h/39h, une semaine déficitaire **draine** les tranches 25/50 accumulées via la cascade
|
||||||
|
de `EmployeeRttSummaryProvider` (lignes 149-174) et de `RttClosingBalanceService::fold` (lignes
|
||||||
|
92-99), ce qui affiche des valeurs négatives en « Total 25% / 50% ».
|
||||||
|
|
||||||
|
Pour un CUSTOM, la récup n'est jamais bucketisée (elle vit uniquement dans `totalMinutes`). La
|
||||||
|
cascade ne doit donc **pas** s'appliquer aux semaines CUSTOM, sinon le déficit apparaîtrait en
|
||||||
|
négatif dans « Total 25% » (affichage sale, et incohérent avec les récups positives qui, elles,
|
||||||
|
n'y figurent pas).
|
||||||
|
|
||||||
|
Solution : un drapeau **`isFlatRecovery`** (récupération plate 1:1, sans tranches) porté par la
|
||||||
|
semaine, qui désactive la cascade dans le provider.
|
||||||
|
|
||||||
|
## Changements
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **`src/Dto/Rtt/WeekRecoveryDetail.php`** : ajout `public bool $isFlatRecovery = false`.
|
||||||
|
2. **`src/Dto/Rtt/EmployeeRttWeekSummary.php`** : ajout `public bool $isFlatRecovery = false`.
|
||||||
|
3. **`src/Service/Rtt/RttRecoveryComputationService.php`**
|
||||||
|
(`computeRecoveryByWeek`, branche `$isCustomContract`) :
|
||||||
|
- `totalMinutes = $weeklyOvertimeTotalMinutes` (signé — plus de `max(0, …)`).
|
||||||
|
- `isFlatRecovery: true` sur le `WeekRecoveryDetail` retourné.
|
||||||
|
- Les buckets `base25/bonus25/base50/bonus50` restent à 0 (inchangé).
|
||||||
|
- Cas non-CUSTOM et PRESENCE/INTERIM inchangés (`isFlatRecovery` reste `false`).
|
||||||
|
4. **`src/State/EmployeeRttSummaryProvider.php`**
|
||||||
|
- `buildWeekSummaries` : propager `isFlatRecovery` du `WeekRecoveryDetail` vers
|
||||||
|
l'`EmployeeRttWeekSummary` — dans le cas mono-mois **et** dans le cas semaine à cheval sur
|
||||||
|
deux mois (les deux instances héritent du drapeau).
|
||||||
|
- Cascade déficit (ligne 150) : condition devient
|
||||||
|
`if ($week->totalMinutes >= 0 || $week->isFlatRecovery)`. Pour une semaine CUSTOM
|
||||||
|
déficitaire, on ne draine pas : les buckets restent 0, `cumulativeBalanceMinutes`
|
||||||
|
(déjà basé sur `totalMinutes`, ligne 197) intègre le déficit.
|
||||||
|
- Dans la reconstruction de la branche `else` (semaine déficitaire normale), conserver
|
||||||
|
explicitement `isFlatRecovery: $week->isFlatRecovery` (toujours `false` à ce point, mais
|
||||||
|
explicite pour la clarté).
|
||||||
|
5. **`src/Command/DumpVerificationSnapshotCommand.php`** : ce command duplique la cascade du
|
||||||
|
provider (lignes ~695-716). Mettre à jour la condition de cascade pour respecter
|
||||||
|
`isFlatRecovery`, et propager le drapeau dans sa reconstruction des week summaries, afin que
|
||||||
|
les snapshots before/after restent fidèles à l'app.
|
||||||
|
|
||||||
|
### Aucun changement
|
||||||
|
|
||||||
|
- **`src/Service/Rtt/RttClosingBalanceService.php`** : `fold` gère déjà `totalMinutes` négatif
|
||||||
|
(branche déficit lignes 92-99) et le remainder CUSTOM (lignes 83-87). Le report N+1 intègre
|
||||||
|
donc automatiquement le déficit. Pas de modification.
|
||||||
|
- **`frontend/components/employees/RttTab.vue`** : aucun. Les sous-colonnes Base/25%/50% sont
|
||||||
|
déjà écrêtées à 0 sur les semaines déficitaires (`totalMinutes >= 0 ? … : 0`). Avec buckets =
|
||||||
|
0 côté back, « Total 25%/50% » = 0, et « Heure »/« Total »/« Cumul » affichent le déficit.
|
||||||
|
- **Pas de migration** : aucun changement de schéma.
|
||||||
|
|
||||||
|
## Effets de bord (assumés, cohérents)
|
||||||
|
|
||||||
|
- **Récap congés** (`LeaveRecapRowBuilder::…` via `computeTotalRecoveryForExercise`) : la valeur
|
||||||
|
RTT reflètera aussi les déficits et peut devenir négative. Cohérent avec la décision.
|
||||||
|
- **Rollover / report** : la clôture d'exercice (`computeClosingBalance`) intègre désormais les
|
||||||
|
déficits. Les lignes `employee_rtt_balances` déjà stockées (calculées avec l'ancienne logique,
|
||||||
|
déficit = 0) doivent être rafraîchies après déploiement :
|
||||||
|
`php bin/console app:rtt:rollover --force --recompute` (ne touche pas les lignes
|
||||||
|
`is_locked`). Ex. Ewa : clôture 2026 passe de 0 à −2h, donc report d'ouverture 2027 = −2h.
|
||||||
|
- **Carry / Report row** : pour un CUSTOM, le report reste stocké/affiché dans la tranche
|
||||||
|
`base25` (convention pré-existante du `fold`, remainder parking) — comportement inchangé, hors
|
||||||
|
périmètre.
|
||||||
|
|
||||||
|
## Tests (TDD)
|
||||||
|
|
||||||
|
- **`tests/Service/Rtt/RttClosingBalanceServiceTest.php`** : nouveau cas — un `WeekRecoveryDetail`
|
||||||
|
CUSTOM **déficitaire** (`totalMinutes` négatif) diminue bien la clôture (somme = report +
|
||||||
|
Σ semaines − payés, négatif inclus).
|
||||||
|
- **`tests/State/EmployeeRttSummaryProviderTest.php`** : semaine CUSTOM déficitaire
|
||||||
|
(`isFlatRecovery = true`, `totalMinutes < 0`) → buckets 25/50 restent 0, `cumulativeBalance`
|
||||||
|
réduit du déficit (pas de drainage des tranches). Vérifier aussi qu'une semaine 35h/39h
|
||||||
|
déficitaire continue de drainer (non-régression).
|
||||||
|
- **`tests/Service/Rtt/RttRecoveryComputationServiceTest.php`** : si réalisable en intégration —
|
||||||
|
contrat CUSTOM, semaine travaillée sous les heures → `totalMinutes` négatif et
|
||||||
|
`isFlatRecovery = true`.
|
||||||
|
|
||||||
|
## Documentation (obligatoire, même intervention)
|
||||||
|
|
||||||
|
- `doc/rtt-rollover.md` et/ou `doc/rtt-tab.md` : mettre à jour la règle CUSTOM (déficit
|
||||||
|
désormais compté).
|
||||||
|
- `frontend/data/documentation-content.ts` : section RTT — déficit des contrats CUSTOM.
|
||||||
|
- `CLAUDE.md` : section « Overtime Rules » — corriger « deficit doesn't impact balance » pour les
|
||||||
|
CUSTOM ; documenter `isFlatRecovery`.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Export « Contingent H.nuit »
|
||||||
|
|
||||||
|
Date : 2026-06-11
|
||||||
|
Statut : validé (design)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ajouter un nouvel export PDF sur la **liste des employés** : un tableau du
|
||||||
|
contingent d'heures de nuit, employés en lignes, mois en colonnes. Chaque mois
|
||||||
|
porte deux sous-colonnes : **Total H.nuit** (heures travaillées de nuit) et
|
||||||
|
**Total N.jours** (nombre de nuits où ≥ 4h ont été travaillées de nuit).
|
||||||
|
|
||||||
|
## Décisions cadrées
|
||||||
|
|
||||||
|
- **Période** : année civile Janvier → Décembre, choisie via un sélecteur d'année
|
||||||
|
dans le drawer (réutilise `exportYearOptions`).
|
||||||
|
- **Fenêtre de nuit** : 21h → 6h — on **réutilise le calcul existant** de l'app
|
||||||
|
(constante `[0,360]` + `[1260,1440]` minutes, projection J+1 pour les shifts
|
||||||
|
traversant minuit). NB : la demande initiale mentionnait 21h-5h ; arbitré sur
|
||||||
|
21h→6h pour rester cohérent avec le reste de l'application.
|
||||||
|
- **Règle « 1 jour »** : un jour compte 1 dans « N.jours » dès que les minutes de
|
||||||
|
nuit du jour ≥ 240 (4h).
|
||||||
|
- **Conducteurs** : inclus. Leurs minutes de nuit = champ manuel
|
||||||
|
`WorkHour.nightHoursMinutes` (pas de fenêtre horaire — total saisi). La règle
|
||||||
|
≥ 240 min = 1 jour s'applique aussi.
|
||||||
|
- **Non-conducteurs** : minutes de nuit calculées depuis les plages
|
||||||
|
matin/après-midi/soir via la fenêtre 21h→6h.
|
||||||
|
- **Pas de crédit** absence/férié : on ne compte que les heures de nuit
|
||||||
|
réellement travaillées (pas de crédit virtuel férié ni crédit absence).
|
||||||
|
- **Pas de colonne « Total annuel »** (hors périmètre pour l'instant).
|
||||||
|
- **Mise en page** : A4 **paysage**.
|
||||||
|
- **Groupement / tri** : par site, identique au day-export et au calendrier —
|
||||||
|
sites triés par `displayOrder` puis nom ; employés triés par `displayOrder`,
|
||||||
|
puis nom, puis prénom.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
`frontend/pages/employees/index.vue` (drawer Export existant) :
|
||||||
|
- Ajouter une option `{ label: 'Contingent H.nuit', value: 'night-contingent' }`
|
||||||
|
dans `exportTypeOptions`.
|
||||||
|
- Quand `exportChoice === 'night-contingent'` : afficher le sélecteur d'année
|
||||||
|
(réutiliser le `MalioSelect` année déjà utilisé par `yearly-hours`).
|
||||||
|
- `isExportValid` : valide si une année est choisie.
|
||||||
|
- `handleExportValidate` : `await printPdf('/night-hours-contingent/print?year=' + exportYear)`.
|
||||||
|
|
||||||
|
Aucun sélecteur de site (cohérent avec les autres exports de ce drawer — le
|
||||||
|
périmètre vient du back via `findScoped`).
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**Endpoint** : `GET /night-hours-contingent/print?year=YYYY`
|
||||||
|
- Operation API Platform custom (Provider, output PDF `Response`), `ROLE_USER`.
|
||||||
|
- `year` : entier validé 2000-2100, défaut = année courante.
|
||||||
|
|
||||||
|
**Provider** `App\State\NightHoursContingentPrintProvider`
|
||||||
|
1. Auth + parse `year`.
|
||||||
|
2. `employees = employeeRepository->findScoped($user)` (périmètre admin / chef de site).
|
||||||
|
3. Garder les employés ayant ≥ 1 période de contrat intersectant
|
||||||
|
`[YYYY-01-01 ; YYYY-12-31]` (helper `hasContractInRange`, même esprit que
|
||||||
|
`AbsencePrintProvider`/`SalaryRecapPrintProvider`).
|
||||||
|
4. Grouper par site ; trier sites par `displayOrder` puis nom ; trier employés
|
||||||
|
intra-site par `displayOrder`, nom, prénom.
|
||||||
|
5. Construire les lignes via `NightContingentExportBuilder`.
|
||||||
|
6. Rendre `templates/night-hours-contingent/print.html.twig` → Dompdf (paysage).
|
||||||
|
|
||||||
|
**Builder** `App\Service\WorkHours\NightContingentExportBuilder`
|
||||||
|
- `buildRows(list<Employee> $employees, int $year): list<NightContingentRow>`
|
||||||
|
- Pour chaque employé : charge ses `WorkHour` de l'année (1 requête par lot ou
|
||||||
|
par employé selon le repo existant), répartit par mois (1..12).
|
||||||
|
- Par jour : `nightMinutes = NightHoursCalculator::nightMinutesForWorkHour($wh, $isDriverThatDay)`.
|
||||||
|
- Driver ce jour-là → `nightHoursMinutes ?? 0`.
|
||||||
|
- Non-driver → somme des `nightIntervalMinutes` sur les 3 plages.
|
||||||
|
- Agrégats par mois : `nightMinutesTotal += nightMinutes` ;
|
||||||
|
`if (nightMinutes >= 240) nightDays += 1`.
|
||||||
|
- Retour DTO : `{ employeeId, employeeName, isDriver, months: [{nightMinutes, nightDays} × 12] }`.
|
||||||
|
|
||||||
|
> Le statut driver est résolu **par date** (un employé peut changer de nature de
|
||||||
|
> contrat dans l'année), via le mécanisme existant (`EmployeeContractResolver` /
|
||||||
|
> période de contrat couvrant la date du `WorkHour`). On suit l'approche déjà en
|
||||||
|
> place dans les providers heures pour résoudre `isDriver` à la date.
|
||||||
|
|
||||||
|
**Service partagé** `App\Service\WorkHours\NightHoursCalculator`
|
||||||
|
- Extrait la logique 21h→6h aujourd'hui **dupliquée** dans
|
||||||
|
`WorkHourWeeklySummaryProvider::nightIntervalMinutes/computeMetrics` et
|
||||||
|
`YearlyHoursExportBuilder::nightIntervalMinutes/computeMetrics`.
|
||||||
|
- Méthodes :
|
||||||
|
- `nightIntervalMinutes(?string $from, ?string $to): int` (fenêtres
|
||||||
|
`[0,360]` + `[1260,1440]`, projection J+1).
|
||||||
|
- `nightMinutesFromRanges(WorkHour $wh): int` (somme sur les 3 plages).
|
||||||
|
- `nightMinutesForWorkHour(WorkHour $wh, bool $isDriver): int`
|
||||||
|
(driver → `nightHoursMinutes ?? 0`, sinon `nightMinutesFromRanges`).
|
||||||
|
- `WorkHourWeeklySummaryProvider` et `YearlyHoursExportBuilder` délèguent à ce
|
||||||
|
service pour la partie nuit (résultats identiques garantis ; les tests
|
||||||
|
existants couvrent la non-régression).
|
||||||
|
|
||||||
|
### Template
|
||||||
|
|
||||||
|
`templates/night-hours-contingent/print.html.twig`
|
||||||
|
- A4 paysage.
|
||||||
|
- En-tête : « Contingent heures de nuit — {{ year }} », date d'export.
|
||||||
|
- Colonnes : `Nom` + 12 groupes de mois, chacun deux sous-colonnes
|
||||||
|
`H.nuit` / `N.jours`.
|
||||||
|
- Lignes d'en-tête de site colorées (couleur site), comme le day-export.
|
||||||
|
- `H.nuit` formaté `12h30` (helper minutes → HH h MM), `N.jours` entier.
|
||||||
|
- Mois sans données → `0h00` / `0` (ou vide selon rendu — défaut 0).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `NightHoursCalculatorTest` : fenêtre 21h→6h, shift traversant minuit
|
||||||
|
(21:00→05:00 = 8h nuit), plage de jour pur (0 nuit), plages nulles.
|
||||||
|
- `NightContingentExportBuilderTest` : agrégation mensuelle, règle ≥4h=1 jour
|
||||||
|
(3h59 → 0 jour, 4h → 1 jour), conducteur via `nightHoursMinutes`, employé
|
||||||
|
multi-mois.
|
||||||
|
- Non-régression : `make test` (les tests existants de
|
||||||
|
`WorkHourWeeklySummaryProvider` / `YearlyHoursExportBuilder` valident le
|
||||||
|
refactor du service partagé).
|
||||||
|
|
||||||
|
## Documentation à mettre à jour (même intervention)
|
||||||
|
|
||||||
|
- `doc/functional-rules.md` : nouvelle section export contingent nuit.
|
||||||
|
- `CLAUDE.md` : entrée sous une rubrique export.
|
||||||
|
- `frontend/data/documentation-content.ts` : article utilisateur (niveau admin/
|
||||||
|
chef de site).
|
||||||
|
|
||||||
|
## Hors périmètre
|
||||||
|
|
||||||
|
- Colonne total annuel.
|
||||||
|
- Sélecteur de site dans le drawer.
|
||||||
|
- Export depuis un autre écran que la liste employés.
|
||||||
|
- Changement de la fenêtre de nuit ailleurs dans l'app (reste 21h→6h partout).
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Contingent d'heures supplémentaires payées — Design
|
||||||
|
|
||||||
|
Date : 2026-06-11
|
||||||
|
Statut : validé (brainstorming)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
La RH a besoin de suivre, **par année civile (Janvier→Décembre)**, le volume d'heures
|
||||||
|
supplémentaires payées à chaque employé non-forfait (chauffeurs inclus), rapporté au
|
||||||
|
plafond réglementaire annuel (le « contingent ») :
|
||||||
|
|
||||||
|
- **350 h** pour les chauffeurs (conducteurs),
|
||||||
|
- **220 h** pour les autres non-forfait.
|
||||||
|
|
||||||
|
Deux livrables :
|
||||||
|
|
||||||
|
1. **Fiche employé** — un encart dans le header affichant `Contingent {année} : X h / plafond h`.
|
||||||
|
2. **Écran liste employés** — un export PDF supplémentaire : par employé, les heures payées
|
||||||
|
de chaque mois + une colonne finale « Total payé / Total payable », groupé par site.
|
||||||
|
|
||||||
|
## Règles métier (validées)
|
||||||
|
|
||||||
|
- **Heures payées** = `base25Minutes + base50Minutes` (en minutes), **hors majoration
|
||||||
|
(bonus)**. Cohérent avec la colonne « Heures payés » du récap salaire, déjà définie hors
|
||||||
|
bonus.
|
||||||
|
- **Période = vraie année civile (Janv–Déc).** Les paiements RTT (`EmployeeRttPayment`)
|
||||||
|
sont stockés par **exercice** (`year` = année d'exercice Juin N-1 → Mai N) + `month`
|
||||||
|
(1–12). L'année civile d'un paiement se reconstitue avec la même formule que
|
||||||
|
`RttTab.vue:392` :
|
||||||
|
|
||||||
|
```
|
||||||
|
annéeCivile = month >= 6 ? exerciseYear - 1 : exerciseYear
|
||||||
|
```
|
||||||
|
|
||||||
|
Donc l'année civile **Y** agrège :
|
||||||
|
- exercice `Y`, mois 1–5 (Janv–Mai Y),
|
||||||
|
- exercice `Y+1`, mois 6–12 (Juin–Déc Y).
|
||||||
|
|
||||||
|
- **Plafond** : `isDriver` du **contrat courant** → 350 h, sinon → 220 h.
|
||||||
|
- **Périmètre** : non-forfait uniquement. Les FORFAIT sont exclus (pas d'heures supp
|
||||||
|
payées ; onglet RTT déjà masqué pour eux).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Cœur partagé — `App\Service\WorkHours\OvertimePaidContingentCalculator`
|
||||||
|
|
||||||
|
Source de vérité unique, consommée par l'endpoint fiche employé ET le builder PDF.
|
||||||
|
|
||||||
|
```php
|
||||||
|
final readonly class OvertimePaidContingentCalculator
|
||||||
|
{
|
||||||
|
public const int CAP_HOURS_DRIVER = 350;
|
||||||
|
public const int CAP_HOURS_DEFAULT = 220;
|
||||||
|
|
||||||
|
// Heures payées (base25+base50) ventilées par mois civil 1..12 pour l'année civile.
|
||||||
|
public function monthlyBaseMinutes(Employee $employee, int $civilYear): array; // <int,int> 1..12
|
||||||
|
|
||||||
|
// Somme des 12 mois.
|
||||||
|
public function totalBaseMinutes(Employee $employee, int $civilYear): int;
|
||||||
|
|
||||||
|
// 350 si conducteur (contrat courant isDriver), sinon 220.
|
||||||
|
public function capHours(Employee $employee): int;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Calcul de `monthlyBaseMinutes` :
|
||||||
|
1. Récupérer les paiements des exercices `civilYear` et `civilYear+1` (fetch groupé).
|
||||||
|
2. Pour chaque paiement, calculer son année civile via la formule ci-dessus ; ne garder que
|
||||||
|
ceux dont l'année civile == `civilYear`.
|
||||||
|
3. Bucketiser par `month`, sommer `base25Minutes + base50Minutes`.
|
||||||
|
|
||||||
|
Statut conducteur : résolu via le contrat courant de l'employé (cohérent avec le choix
|
||||||
|
« contrat courant » pour le plafond). Réutiliser le mécanisme existant
|
||||||
|
(`employee.currentContract` / `EmployeeContractResolver`).
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
|
||||||
|
Ajout à `EmployeeRttPaymentRepository` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Fetch groupé pour le PDF (évite N+1 sur N employés).
|
||||||
|
public function findByEmployeesAndYears(array $employees, array $years): array;
|
||||||
|
```
|
||||||
|
|
||||||
|
Le calculator pour un seul employé peut réutiliser `findByEmployeeAndYear()` (existant) deux
|
||||||
|
fois (exercices `civilYear` et `civilYear+1`).
|
||||||
|
|
||||||
|
## Partie A — Encart fiche employé (header)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- ApiResource `EmployeeOvertimeContingentOutput` + opération
|
||||||
|
`GET /employees/{id}/overtime-contingent?year=YYYY` (`ROLE_ADMIN`).
|
||||||
|
- Défaut `year` = année civile courante. Validation 2000–2100.
|
||||||
|
- Provider : retourne `{ year, paidMinutes, capHours, isDriver }`.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Service + composable : fetch sur la fiche employé **uniquement pour les non-forfait**
|
||||||
|
(même condition que l'affichage de l'onglet RTT).
|
||||||
|
- Affichage : ligne texte dans le header, sous le libellé contrat
|
||||||
|
(`useEmployeeDetailPage` / header de `pages/employees/[id].vue`), au format :
|
||||||
|
|
||||||
|
```
|
||||||
|
Contingent 2026 : 142 h / 220 h
|
||||||
|
```
|
||||||
|
|
||||||
|
Passe en **rouge** (`text-m-danger` / classe danger) si `paidMinutes > capHours*60`.
|
||||||
|
- **Année civile courante uniquement, pas de sélecteur** dans le header. L'historique se
|
||||||
|
consulte via le PDF.
|
||||||
|
|
||||||
|
## Partie B — Export PDF (écran liste employés)
|
||||||
|
|
||||||
|
Calque exact de l'export contingent heures de nuit (`night-hours-contingent`).
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- ApiResource `OvertimeContingentPrint` → `GET /overtime-contingent/print?year=&siteIds=`
|
||||||
|
(`ROLE_USER`).
|
||||||
|
- Provider `OvertimeContingentPrintProvider` :
|
||||||
|
- Périmètre via `EmployeeRepository::findScoped($user)` (admin → tous, chef de site → ses
|
||||||
|
sites). `siteIds` hors périmètre ignoré.
|
||||||
|
- **Exclut les FORFAIT** (contrat courant) en plus du filtre `hasContractInRange` sur
|
||||||
|
l'année.
|
||||||
|
- Groupe par site (`displayOrder`), tri intra-site `displayOrder → nom → prénom`
|
||||||
|
(identique au calendrier / aux autres exports).
|
||||||
|
- Builder `OvertimeContingentExportBuilder::buildRows($employees, $year)` :
|
||||||
|
- utilise `OvertimePaidContingentCalculator` (fetch groupé via `findByEmployeesAndYears`),
|
||||||
|
- retourne par employé : `months[1..12]` (minutes base payées), `totalMinutes`, `capHours`.
|
||||||
|
- DTO `App\Dto\WorkHours\OvertimeContingentRow`.
|
||||||
|
|
||||||
|
### Template
|
||||||
|
- `templates/overtime-contingent/print.html.twig` — **A4 paysage**.
|
||||||
|
- Colonnes : Nom employé · Janv … Déc (heures payées du mois, format `XhYY` ou `—` si 0) ·
|
||||||
|
**Total : `total payé h / plafond h`** (ex. `142 h / 220 h`).
|
||||||
|
- Total en gras ; cellule total en rouge si dépassement.
|
||||||
|
- En-têtes de site colorées (comme night-contingent).
|
||||||
|
|
||||||
|
### Frontend (drawer existant `pages/employees/index.vue`)
|
||||||
|
- Ajouter le choix `overtime-contingent` à `exportTypeOptions`
|
||||||
|
(libellé ex. « Contingent H.supp. »).
|
||||||
|
- Bloc de formulaire dédié : sélecteur **Année** (`exportYearOptions`) + sélecteur **Sites**
|
||||||
|
multi-sélection (tags, calqué sur le drawer d'export jour ; valeurs = sites visibles).
|
||||||
|
- `isExportValid` : `exportYear > 0` (sites optionnels — vide = tous les sites du périmètre).
|
||||||
|
- `handleExportValidate` : `printPdf('/overtime-contingent/print?year=${exportYear}${siteIdsParam}')`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `OvertimePaidContingentCalculatorTest` :
|
||||||
|
- mapping année civile (paiement exercice 2027 mois 9 → compté en 2026),
|
||||||
|
- frontière mois 5/6 (mai = exercice, juin = exercice-1),
|
||||||
|
- somme `base25+base50` hors bonus,
|
||||||
|
- plafond 350 (driver) vs 220.
|
||||||
|
- `OvertimeContingentExportBuilderTest` : ventilation mensuelle + total + plafond par
|
||||||
|
employé, fetch groupé.
|
||||||
|
- Test provider : exclusion forfait, périmètre `findScoped`, tri/groupement par site.
|
||||||
|
|
||||||
|
## Documentation à mettre à jour (règle projet obligatoire)
|
||||||
|
|
||||||
|
- `doc/overtime-contingent.md` (nouveau) — règles + mapping civil/exercice.
|
||||||
|
- `CLAUDE.md` — section dédiée (cœur partagé, mapping, plafonds, périmètre).
|
||||||
|
- `frontend/data/documentation-content.ts` — section utilisateur (admin) décrivant l'encart
|
||||||
|
et l'export.
|
||||||
|
|
||||||
|
## Hors périmètre (consigné pour plus tard)
|
||||||
|
|
||||||
|
- **Bug latent du récap salaire** : `SalaryRecapPrintProvider:86` requête
|
||||||
|
`findByYearAndMonth(annéeCivile, mois)` alors que les paiements sont stockés par exercice.
|
||||||
|
Pour les mois Juin–Déc, un paiement RTT est donc probablement mal rattaché sur le récap
|
||||||
|
mensuel. À corriger dans une intervention séparée.
|
||||||
|
- Plafonds 350/220 en constantes nommées dans le calculator ; passage en config/env
|
||||||
|
envisageable ultérieurement.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Design — Déficit « jour de solidarité » pour les contrats CUSTOM < 35h
|
||||||
|
|
||||||
|
Date : 2026-06-11
|
||||||
|
Branche : `feature/SIRH-36-corriger-le-calcule-des-rtt-des-contrat-4h`
|
||||||
|
Statut : validé (brainstorming)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Le jour de solidarité (Lundi de Pentecôte) impose à chaque salarié une contribution
|
||||||
|
de travail non rémunérée : 7h/an pour un temps plein (35h), proratisée pour les temps
|
||||||
|
partiels. La RH matérialise cette contribution en **posant une absence de type RTT**
|
||||||
|
sur le Lundi de Pentecôte pour **tous** les salariés, y compris les contrats < 35h.
|
||||||
|
|
||||||
|
Pour les contrats standard (35h/39h), poser un RTT d'une journée draine ~7h du cumul RTT
|
||||||
|
accumulé — ce qui correspond exactement à l'obligation. Ce comportement **fonctionne
|
||||||
|
déjà et ne doit pas changer**.
|
||||||
|
|
||||||
|
Pour les contrats **CUSTOM < 35h** (ex. 4h, 25h, 28h — `weeklyHours ≠ 35 et ≠ 39`,
|
||||||
|
mode TIME), poser une absence RTT (type `R`, `countAsWorkedHours = false`) produit un
|
||||||
|
déficit égal au **créneau travaillé du jour** (ex. Ewa, 4h, travaille 2h le lundi →
|
||||||
|
−2h), et non au prorata légal attendu (`7/35 × 4h = 48 min`). Le montant naturel dépend
|
||||||
|
du planning du jour, pas de l'obligation. C'est le bug à corriger.
|
||||||
|
|
||||||
|
## Règle métier validée
|
||||||
|
|
||||||
|
- **Périmètre** : contrats **CUSTOM avec `weeklyHours < 35`** uniquement. 35h, 39h,
|
||||||
|
Forfait, Intérim, et CUSTOM ≥ 35h : aucun changement.
|
||||||
|
- **Date** : Lundi de Pentecôte (= Pâques + 50 jours), calculé par computus, indépendant
|
||||||
|
de l'env `EXCLUDED_PUBLIC_HOLIDAYS` (qui n'est plus la source de vérité).
|
||||||
|
- **Montant** : `prorata = round(weeklyHours × 12)` minutes (7h/35h × 60 = 12 min par
|
||||||
|
heure hebdo). Ex. 4h → 48 min, 25h → 5h00, 28h → 5h36.
|
||||||
|
- **Net forfaitaire et inconditionnel** : au net, le jour de solidarité vaut **exactement
|
||||||
|
`−prorata`** dans le cumul RTT, quel que soit ce qui est posé ce jour-là (absence RTT,
|
||||||
|
heures travaillées, ou rien). On **neutralise** l'effet naturel du jour puis on applique
|
||||||
|
le forfait. Garantit l'absence de double comptage avec le RTT posé par la RH, et reste
|
||||||
|
correct même si la RH oublie de poser le RTT.
|
||||||
|
- **Cumul** : le déficit se cumule avec tout autre déficit/surplus de la même semaine,
|
||||||
|
réduit le cumul RTT (peut le rendre négatif), et est reporté à l'exercice suivant.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Point d'injection unique
|
||||||
|
|
||||||
|
Tout passe par `App\Service\Rtt\RttRecoveryComputationService::computeRecoveryByWeek`,
|
||||||
|
le calcul **partagé** consommé par :
|
||||||
|
- `EmployeeRttSummaryProvider` (onglet RTT),
|
||||||
|
- `computeTotalRecoveryForExercise` → `RttClosingBalanceService` (clôture / rollover),
|
||||||
|
- `DumpVerificationSnapshotCommand` (commande de vérification).
|
||||||
|
|
||||||
|
En posant le déficit dans `totalMinutes` / `overtimeMinutes` à cet endroit, il se propage
|
||||||
|
partout sans duplication. Le drapeau `isFlatRecovery` (déjà existant pour les CUSTOM)
|
||||||
|
reste `true` → le provider ne draine pas les tranches 25/50 et le fold reporte le déficit
|
||||||
|
en N+1.
|
||||||
|
|
||||||
|
### Nouveau service pur : `SolidarityDayResolver`
|
||||||
|
|
||||||
|
```
|
||||||
|
final class SolidarityDayResolver
|
||||||
|
{
|
||||||
|
// Lundi de Pentecôte = dimanche de Pâques + 50 jours.
|
||||||
|
public function pentecostMonday(int $year): DateTimeImmutable;
|
||||||
|
|
||||||
|
// Easter via l'algorithme de Meeus/Jones/Butcher (calendrier grégorien),
|
||||||
|
// sans dépendance à l'extension calendar PHP.
|
||||||
|
private function easterSunday(int $year): DateTimeImmutable;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pur, déterministe, aucune dépendance réseau (le chemin de calcul RTT n'a aujourd'hui
|
||||||
|
aucune dépendance HTTP — on le préserve). Trivial à tester unitairement.
|
||||||
|
|
||||||
|
### Modification de `computeRecoveryByWeek`
|
||||||
|
|
||||||
|
Le service reçoit `SolidarityDayResolver` par injection. Avant la boucle des semaines, on
|
||||||
|
résout les Lundi de Pentecôte des années civiles couvertes par `[periodFrom, periodTo]`
|
||||||
|
(exercice Juin N-1 → Mai N → années N-1 et N) et on retient ceux dans la fenêtre.
|
||||||
|
|
||||||
|
Dans la boucle, après le calcul de `weeklyOvertimeTotalMinutes` et **uniquement** quand
|
||||||
|
un jour de solidarité `S` tombe dans la semaine **et** a été inclus dans le sommage
|
||||||
|
(`isset($dailyWorkedMinutes[S])`, donc `S ≤ limitDate` et `S ≥ rttStartDate`) :
|
||||||
|
|
||||||
|
```
|
||||||
|
$contractAtS = $employeeContractsByDate[$S] ?? null;
|
||||||
|
$weeklyHours = $contractAtS?->getWeeklyHours();
|
||||||
|
$typeAtS = ContractType::resolve($contractAtS?->getName(), $contractAtS?->getTrackingMode(), $weeklyHours);
|
||||||
|
|
||||||
|
if (ContractType::CUSTOM === $typeAtS && null !== $weeklyHours && $weeklyHours < 35) {
|
||||||
|
$isoDayS = (int) (new DateTimeImmutable($S))->format('N');
|
||||||
|
$workDaysForS = $workDaysByDate[$employeeId][$S] ?? null; // {iso_day: minutes}
|
||||||
|
// Heures contractuelles RÉELLES du jour (planning workDaysHours), PAS la
|
||||||
|
// répartition uniforme weeklyHours/5 — c'est ce qui rend le net = -prorata.
|
||||||
|
$expectedS = $this->dailyReferenceResolver->resolve($weeklyHours, $isoDayS, $workDaysForS);
|
||||||
|
$workedS = $dailyWorkedMinutes[$S]; // déjà calculé dans la boucle des jours
|
||||||
|
$prorata = (int) round($weeklyHours * 12);
|
||||||
|
|
||||||
|
// 1) faire compter le jour comme s'il était travaillé normalement (annule la
|
||||||
|
// valeur réelle du jour, quelle qu'elle soit : RTT posé, heures, vide, crédit
|
||||||
|
// férié virtuel) ; 2) appliquer le forfait solidarité.
|
||||||
|
$weeklyOvertimeTotalMinutes += ($expectedS - $workedS) - $prorata;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis `buildWeekRecoveryDetail(...)` est appelé tel quel : pour un CUSTOM,
|
||||||
|
`totalMinutes = overtimeMinutes = weeklyOvertimeTotalMinutes` (signé), bandes 25/50 = 0,
|
||||||
|
`isFlatRecovery = true`.
|
||||||
|
|
||||||
|
> **Pourquoi `workDaysHours` et pas la référence hebdo CUSTOM** : la référence CUSTOM
|
||||||
|
> (`computeWeeklyCustomReferenceMinutes`) répartit `weeklyHours` uniformément sur les 5
|
||||||
|
> jours ouvrés (`weeklyHours/5`), sans tenir compte du planning réel. Neutraliser le jour
|
||||||
|
> avec cette valeur uniforme (48 min pour le lundi d'Ewa) laisserait le manque des autres
|
||||||
|
> jours → −2h au lieu de −48 min. En neutralisant avec l'attendu RÉEL du jour
|
||||||
|
> (`workDaysHours[lundi] = 120 min`), le terme `(attendu − travaillé)` ramène la semaine à
|
||||||
|
> son net « normal » (0 pour une semaine pleine), puis le forfait applique exactement
|
||||||
|
> −prorata. `DailyReferenceMinutesResolver::resolve(weeklyHours, isoDay, workDaysMinutes)`
|
||||||
|
> renvoie déjà cet attendu réel quand `workDaysMinutes` est fourni (obligatoire pour tout
|
||||||
|
> CUSTOM < 35h). Fallback uniforme si absent.
|
||||||
|
>
|
||||||
|
> **Robustesse `EXCLUDED` / férié** : `(attendu − travaillé)` annule n'importe quelle
|
||||||
|
> valeur de `$workedS`, y compris un éventuel crédit férié virtuel si le Lundi de Pentecôte
|
||||||
|
> cessait d'être exclu. Le résultat ne dépend donc pas de l'état d'`EXCLUDED_PUBLIC_HOLIDAYS`.
|
||||||
|
|
||||||
|
## Cas limites
|
||||||
|
|
||||||
|
| Cas | Comportement |
|
||||||
|
|-----|--------------|
|
||||||
|
| Jour de solidarité futur (`> limitDate`) | Pas de déficit (semaine/jour non sommés). Appliqué une fois le jour passé. |
|
||||||
|
| Jour de solidarité avant `rttStartDate` | Pas de déficit (semaine zéro-ée en amont). |
|
||||||
|
| Changement de contrat dans la semaine | Contrat lu **au jour de solidarité**, pas à l'ancre de semaine. |
|
||||||
|
| Salarié non contracté ce jour-là | `contractAtS = null` → pas de déficit. |
|
||||||
|
| Salarié CUSTOM < 35h ne travaillant pas le lundi (ex. Mar+Ven) | `expectedMinutes = workDaysHours[lundi] = 0` → pas de déficit (garde `0 === $expectedMinutes`). |
|
||||||
|
| CUSTOM ≥ 35h (36–38h) | Hors périmètre → pas de déficit. |
|
||||||
|
| 35h/39h avec RTT posé | Inchangé (drainage ~7h via la cascade existante). |
|
||||||
|
| Autre déficit/surplus la même semaine | Le forfait s'y cumule. |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### `SolidarityDayResolverTest`
|
||||||
|
- Pentecôte 2024 = 20 mai 2024 ; 2025 = 9 juin 2025 ; 2026 = 25 mai 2026.
|
||||||
|
- (optionnel) Pâques pivot : 2025 = 20 avril.
|
||||||
|
|
||||||
|
### `RttRecoveryComputationServiceTest` (ajouts)
|
||||||
|
- CUSTOM 4h, RTT posé sur le jour de solidarité → semaine = −48 min, `isFlatRecovery = true`,
|
||||||
|
base/bonus 25/50 = 0.
|
||||||
|
- CUSTOM 4h, heures travaillées ce jour-là → semaine = −48 min (net forcé).
|
||||||
|
- CUSTOM 4h, rien de posé → semaine = −48 min.
|
||||||
|
- CUSTOM 4h avec un autre jour vide la même semaine → −48 min + l'autre déficit (cumul).
|
||||||
|
- CUSTOM 36h → 0 (hors périmètre).
|
||||||
|
- 35h avec RTT posé sur le jour de solidarité → inchangé (déficit plein, drainage tranches).
|
||||||
|
- Jour de solidarité au-delà de `limitDate` → 0.
|
||||||
|
- `computeTotalRecoveryForExercise` : le déficit solidarité se retrouve dans le total
|
||||||
|
d'exercice (→ clôture/report N+1).
|
||||||
|
|
||||||
|
### Vérification données prod
|
||||||
|
- Ewa (id 31, 4h, Lun+Jeu) : semaine du 25 mai 2026 (S22) = −48 min ; le −2h de la S23
|
||||||
|
(lundi 1er juin non saisi) reste distinct et inchangé.
|
||||||
|
|
||||||
|
## Hors scope / inchangé
|
||||||
|
|
||||||
|
- Front `RttTab.vue` : déjà propre (clamp des sous-colonnes 25/50 à 0 pour les semaines
|
||||||
|
déficitaires) → aucun changement.
|
||||||
|
- Migrations : aucune.
|
||||||
|
- `EXCLUDED_PUBLIC_HOLIDAYS`, `HolidayVirtualHoursResolver`, traitement des autres fériés :
|
||||||
|
inchangés.
|
||||||
|
- Comportement des contrats 35h/39h/Forfait/Intérim sur le jour de solidarité : inchangé.
|
||||||
|
|
||||||
|
## Documentation à mettre à jour (règle projet)
|
||||||
|
|
||||||
|
- `CLAUDE.md` — section Overtime Rules / contrats CUSTOM : ajouter la règle du jour de
|
||||||
|
solidarité (prorata 12 min/h, net forcé, périmètre < 35h).
|
||||||
|
- `frontend/data/documentation-content.ts` — doc in-app RTT.
|
||||||
|
- `doc/rtt-tab.md` et/ou `doc/functional-rules.md` — règle métier détaillée.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Contexte forensique dans le journal d'activité
|
||||||
|
|
||||||
|
Date : 2026-06-24
|
||||||
|
Branche : `feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite`
|
||||||
|
|
||||||
|
## Problème
|
||||||
|
|
||||||
|
Le journal d'activité (`audit_logs`) ne stocke comme « qui » que le `username`. Or
|
||||||
|
certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »). Sous
|
||||||
|
un compte partagé, toutes les actions apparaissent sous le même nom → impossible de
|
||||||
|
distinguer les intervenants en cas de litige. Les utilisateurs se connectent aussi
|
||||||
|
depuis des **smartphones**.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Ajouter du **contexte forensique automatique** à chaque entrée du journal, sans rien
|
||||||
|
demander à l'utilisateur. But : disposer d'assez d'indices techniques pour enquêter
|
||||||
|
(IP, type d'appareil/OS/navigateur, identifiant d'appareil stable, User-Agent brut) et
|
||||||
|
distinguer les **appareils** derrière un compte partagé.
|
||||||
|
|
||||||
|
Non-objectif (volontairement exclu) : identification explicite de la personne physique
|
||||||
|
(liste de noms, PIN…). Écarté par l'utilisateur — on reste sur du signal automatique.
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
|
||||||
|
**Inclus :**
|
||||||
|
- Capture automatique de 4 signaux à chaque écriture d'audit.
|
||||||
|
- 4 nouvelles colonnes nullable sur `audit_logs` + migration (avec `down()`).
|
||||||
|
- Service `UserAgentParser` (libellé appareil lisible, sans dépendance externe).
|
||||||
|
- Front : identifiant d'appareil persistant (`localStorage`) envoyé en header sur toutes
|
||||||
|
les requêtes API.
|
||||||
|
- Exposition des 4 champs dans l'API de lecture du journal (`AuditLogResource` / provider)
|
||||||
|
pour que la future refonte d'écran les ait à disposition.
|
||||||
|
- Config `framework.trusted_proxies` documentée (conservatrice, à activer selon l'infra).
|
||||||
|
- Docs (`doc/audit-logging.md`, `documentation-content.ts`, `CLAUDE.md`) + tests unitaires.
|
||||||
|
|
||||||
|
**Exclu (étape suivante) :**
|
||||||
|
- Refonte de l'écran `frontend/pages/audit-logs.vue` (affichage des nouvelles colonnes,
|
||||||
|
filtre par appareil). L'utilisateur prévoit de revoir cet écran séparément. On se
|
||||||
|
contente d'exposer les données via l'API ; aucune modif du composant Vue dans ce lot.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Capture — un seul point d'entrée
|
||||||
|
|
||||||
|
Toutes les écritures d'audit passent par `AuditLogger::log()` (`src/Service/AuditLogger.php`).
|
||||||
|
On y injecte `RequestStack`. À chaque `log()`, on lit la requête courante et on renseigne
|
||||||
|
les 4 champs sur l'entité `AuditLog` avant persistance. **Aucun processor à modifier.**
|
||||||
|
|
||||||
|
Extraction depuis la requête :
|
||||||
|
- `ip_address` ← `Request::getClientIp()`
|
||||||
|
- `user_agent` ← header `User-Agent` (brut)
|
||||||
|
- `device_label` ← `UserAgentParser::parse(userAgent)`
|
||||||
|
- `device_id` ← header `X-Device-Id`
|
||||||
|
|
||||||
|
Si aucune requête courante (ex. commande CLI / cron), les 4 champs restent `null`
|
||||||
|
(comportement « system » déjà existant pour le username).
|
||||||
|
|
||||||
|
### Modèle de données — `audit_logs`
|
||||||
|
|
||||||
|
Ajout de 4 colonnes **nullable** (pas de backfill, l'existant reste valide) :
|
||||||
|
|
||||||
|
| Colonne | Type | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| `ip_address` | VARCHAR(45) | IP source. 45 = longueur max IPv6 (avec mapping IPv4). |
|
||||||
|
| `user_agent` | TEXT | User-Agent brut, stocké tel quel. |
|
||||||
|
| `device_label` | VARCHAR(255) | Libellé lisible, ex. `Mobile · Android · Chrome`. |
|
||||||
|
| `device_id` | VARCHAR(64) | UUID persistant fourni par le front. |
|
||||||
|
|
||||||
|
Migration Doctrine avec `down()` supprimant les 4 colonnes. Pas de nouvel index dans ce
|
||||||
|
lot (le filtre par appareil étant reporté à la refonte d'écran).
|
||||||
|
|
||||||
|
### Service `UserAgentParser`
|
||||||
|
|
||||||
|
Nouveau service `src/Service/UserAgentParser.php`, maison, sans dépendance.
|
||||||
|
`parse(?string $userAgent): ?string` → libellé court composé de :
|
||||||
|
- **Type** : Mobile / Tablette / Ordinateur (détecté sur tokens `Mobile`, `Tablet`, `iPad`…).
|
||||||
|
- **OS** : Android / iOS / Windows / macOS / Linux / autre.
|
||||||
|
- **Navigateur** : Chrome / Safari / Firefox / Edge / autre (ordre de test important :
|
||||||
|
Edge avant Chrome, Chrome avant Safari, car les UA s'imbriquent).
|
||||||
|
|
||||||
|
Format : `Type · OS · Navigateur` (ex. `Ordinateur · Windows · Firefox`). Retourne `null`
|
||||||
|
si User-Agent vide. Heuristique volontairement simple et lisible ; suffisant pour
|
||||||
|
distinguer mobile/poste et familles d'OS. (Alternative écartée : librairie
|
||||||
|
`matomo/device-detector` — plus précise mais lourde et non nécessaire ici.)
|
||||||
|
|
||||||
|
### Front — identifiant d'appareil persistant
|
||||||
|
|
||||||
|
- Composable `frontend/composables/useDeviceId.ts` : côté client uniquement, lit
|
||||||
|
`localStorage['sirh-device-id']` ; si absent, génère `crypto.randomUUID()` et le persiste.
|
||||||
|
Retourne l'ID (ou `null` côté serveur en SSR).
|
||||||
|
- `frontend/composables/useApi.ts`, fonction `request()` (point unique de construction des
|
||||||
|
headers) : `headers.set('X-Device-Id', deviceId)` quand l'ID est disponible. Appliqué à
|
||||||
|
toutes les méthodes (GET/POST/PUT/PATCH/DELETE).
|
||||||
|
- Note : l'auth est par cookie JWT (`credentials: 'include'`), donc le device ID n'est pas
|
||||||
|
lié à l'auth — `localStorage` est ici un usage non sensible, acceptable.
|
||||||
|
- **Limite assumée** : l'ID est par navigateur/appareil, pas par personne. Sur un poste
|
||||||
|
partagé (même navigateur), l'ID est identique pour tous → distingue les appareils, pas
|
||||||
|
les humains. Cohérent avec l'objectif forensique.
|
||||||
|
|
||||||
|
### API de lecture
|
||||||
|
|
||||||
|
Exposer `ipAddress`, `userAgent`, `deviceLabel`, `deviceId` dans la sortie de lecture du
|
||||||
|
journal (`src/ApiResource/AuditLogResource.php` + sérialisation dans `AuditLogProvider`),
|
||||||
|
ainsi que dans le DTO front `frontend/services/dto/audit-log.ts`. Aucune modif du
|
||||||
|
composant `audit-logs.vue` (refonte ultérieure). Objectif : les données sont prêtes à
|
||||||
|
être affichées par la future refonte.
|
||||||
|
|
||||||
|
### Trusted proxies (IP fiable)
|
||||||
|
|
||||||
|
`framework.trusted_proxies` n'est pas configuré aujourd'hui. Derrière un reverse proxy
|
||||||
|
(nginx/traefik), `getClientIp()` renvoie l'IP du proxy. Architecture de déploiement non
|
||||||
|
confirmée → on prévoit dans `config/packages/framework.yaml` une entrée **commentée et
|
||||||
|
documentée** (avec exemple `trusted_proxies` réseau privé / loopback + `trusted_headers`),
|
||||||
|
à activer selon l'infra. En attendant, l'IP est stockée telle que renvoyée par Symfony.
|
||||||
|
|
||||||
|
## Stratégie de test
|
||||||
|
|
||||||
|
- `tests/.../UserAgentParserTest` : table de User-Agents réels (Chrome desktop, Safari
|
||||||
|
iPhone, Chrome Android, Firefox, Edge, UA vide/null) → libellés attendus.
|
||||||
|
- `tests/.../AuditLoggerTest` : avec un `RequestStack` peuplé d'une `Request` factice
|
||||||
|
(IP, headers User-Agent + X-Device-Id), vérifier que l'`AuditLog` persisté porte bien les
|
||||||
|
4 champs ; et qu'avec une `RequestStack` vide (contexte CLI), les 4 champs sont `null`.
|
||||||
|
|
||||||
|
## Documentation à mettre à jour (règles obligatoires CLAUDE.md)
|
||||||
|
|
||||||
|
- `doc/audit-logging.md` : section « Données stockées par entrée » + nouveaux champs +
|
||||||
|
note sur le device ID front et le caveat trusted proxies.
|
||||||
|
- `frontend/data/documentation-content.ts` : doc in-app (niveau admin) du journal.
|
||||||
|
- `CLAUDE.md` : section Audit Logging — mentionner les 4 nouveaux signaux et le point de
|
||||||
|
capture unique (`AuditLogger` + `RequestStack`).
|
||||||
|
|
||||||
|
## Risques / limites
|
||||||
|
|
||||||
|
- Device ID = par appareil, pas par humain (cf. ci-dessus).
|
||||||
|
- IP peu utile derrière proxy tant que `trusted_proxies` n'est pas activé.
|
||||||
|
- Plusieurs personnes dans la même usine sortent souvent sur la même IP publique et
|
||||||
|
peuvent avoir le même modèle de téléphone → les signaux se recoupent ; ce lot fournit
|
||||||
|
des indices, pas une preuve d'identité.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Refonte de l'écran Journal d'activité (MalioDataTable + drawer de filtre)
|
||||||
|
|
||||||
|
Date : 2026-06-24
|
||||||
|
Branche : `feature/SIRH-41-ajouter-plus-d-info-dans-le-journal-d-activite`
|
||||||
|
|
||||||
|
## Problème / objectif
|
||||||
|
|
||||||
|
L'écran `frontend/pages/audit-logs.vue` (journal d'activité, `ROLE_SUPER_ADMIN`) est aujourd'hui
|
||||||
|
fait main : `<select>`/`<input>` natifs, tableau en grille CSS, lignes dépliables affichant le diff
|
||||||
|
JSON brut, pagination « précédent/suivant » figée à 50/page. Il faut le **moderniser** :
|
||||||
|
|
||||||
|
1. Passer le tableau en **`MalioDataTable`** (1er usage dans SIRH).
|
||||||
|
2. Mettre les filtres dans un **drawer**, sur le **même principe que STARSEED** (les écrans de liste
|
||||||
|
`modules/.../pages/.../index.vue` : `MalioDrawer` + `MalioAccordion`, état brouillon/appliqué,
|
||||||
|
footer Réinitialiser/Appliquer, badge de compteur de filtres actifs).
|
||||||
|
3. Passer **tous** les composants de l'écran en composants **Malio** quand l'équivalent existe.
|
||||||
|
4. Exploiter les nouvelles données forensiques (IP, appareil, User-Agent, device id) déjà captées
|
||||||
|
par le backend.
|
||||||
|
|
||||||
|
## Référence de pattern
|
||||||
|
|
||||||
|
- STARSEED, écran canonique : `/home/m-tristan/workspace/Starseed/frontend/modules/commercial/pages/clients/index.vue`
|
||||||
|
(drawer de filtre, `MalioAccordion`, brouillon→appliqué, `MalioDataTable`, badge compteur).
|
||||||
|
- Adaptations SIRH : **libellés en français en dur** (convention des drawers SIRH existants —
|
||||||
|
`employees/index.vue`, `sites.vue` — pas d'i18n comme STARSEED) ; **filtres non persistés en URL**
|
||||||
|
(comme STARSEED et l'écran actuel).
|
||||||
|
- Malio `@malio/layer-ui` 1.7.15 (doc `node_modules/@malio/layer-ui/COMPONENTS.md`).
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
|
||||||
|
**Inclus :** refonte complète de `audit-logs.vue` (tableau, filtres, détail) + évolutions backend
|
||||||
|
nécessaires (perPage + nouveaux filtres) + DTO TS + docs.
|
||||||
|
|
||||||
|
**Exclus :** toute autre page ; l'audit reste `ROLE_SUPER_ADMIN` ; pas de doc in-app (outil caché,
|
||||||
|
aucun article existant — décision déjà prise au lot précédent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Tableau — `MalioDataTable`
|
||||||
|
|
||||||
|
API (1.7.15) : `:columns` (`{key,label}[]`), `:items`, `:total-items`, `v-model:page`,
|
||||||
|
`v-model:per-page`, `:per-page-options`, `row-clickable`, événements `row-click` /
|
||||||
|
`update:page` / `update:per-page`, slots `#cell-{key}` et `#empty`.
|
||||||
|
|
||||||
|
Colonnes :
|
||||||
|
|
||||||
|
| key | label | rendu |
|
||||||
|
|---|---|---|
|
||||||
|
| `createdAt` | Date action | `JJ/MM/AAAA HH:MM` (déjà formaté par le provider) |
|
||||||
|
| `username` | Utilisateur | texte brut |
|
||||||
|
| `action` | Action | badge couleur via `#cell-action` (create=vert, update=bleu, delete=rouge, validate=violet, site_validate=indigo, défaut=neutre) |
|
||||||
|
| `entityType` | Type | libellé FR via `#cell-entityType` (work_hour→Heures, absence→Absence, employee→Employé, contract_suspension→Suspension, rtt_payment→RTT, fractioned_days→Fract., paid_leave_days→Congés payés, week_comment→Commentaire) |
|
||||||
|
| `employeeName` | Employé | nom ou `—` |
|
||||||
|
| `deviceLabel` | Appareil | `deviceLabel` ou `—` |
|
||||||
|
| `description` | Description | tronqué (`truncate` + `title`) via `#cell-description` |
|
||||||
|
|
||||||
|
- `:per-page-options="[25, 50, 100]"`, `perPage` par défaut 50.
|
||||||
|
- `@row-click` → ouvre le drawer de détail avec la ligne cliquée.
|
||||||
|
- `:items` = directement les `AuditLog` de la page courante (le DTO porte déjà toutes les clés ;
|
||||||
|
les `key` de colonnes correspondent aux champs).
|
||||||
|
|
||||||
|
## B. Drawer de détail (clic ligne)
|
||||||
|
|
||||||
|
`MalioDrawer` (droite, `drawer-class="max-w-xl"`), titre `#header` = « Détail de l'action ».
|
||||||
|
Contenu (lecture seule, sections) :
|
||||||
|
|
||||||
|
- **Méta** : Utilisateur, Employé, Date action, Date affectée, Action (badge), Type (libellé).
|
||||||
|
- **Contexte technique** : IP (`ipAddress`), Appareil (`deviceLabel`), User-Agent brut
|
||||||
|
(`userAgent`, en `break-all`/petite police), Device id (`deviceId`). Champs nuls → `—`.
|
||||||
|
- **Changements** : si `changes` non nul, rendu lisible — pour chaque clé présente dans
|
||||||
|
`old`/`new`, une ligne `clé : ancienne → nouvelle` (au lieu du double bloc JSON brut actuel).
|
||||||
|
Helper front `formatChanges(changes)` qui fusionne les clés de `old` et `new`. Si `changes` nul →
|
||||||
|
« Aucun détail de modification ».
|
||||||
|
|
||||||
|
État : `selectedLog: AuditLog | null` + `detailOpen: boolean`. Fermeture standard MalioDrawer.
|
||||||
|
|
||||||
|
## C. Drawer de filtre (principe STARSEED)
|
||||||
|
|
||||||
|
Bouton **« Filtrer »** (`MalioButton variant="tertiary" icon-name="mdi:tune"`) dans la barre de titre ;
|
||||||
|
son label porte le **compteur de filtres actifs** (`Filtrer (N)` si N>0).
|
||||||
|
|
||||||
|
`MalioDrawer` (`drawer-class="max-w-[450px]"`, `body-class="p-0"`,
|
||||||
|
`footer-class="justify-between border-t border-black p-6"`), titre `#header` = « Filtres ».
|
||||||
|
Corps en `MalioAccordion` (un `MalioAccordionItem` par section) :
|
||||||
|
|
||||||
|
| Section | Composant | Champ filtre |
|
||||||
|
|---|---|---|
|
||||||
|
| Période | `MalioDateRange` (`v-model` = `{start,end}` ISO) | `from`/`to` sur `affectedDate` (sémantique actuelle conservée) |
|
||||||
|
| Employé | `MalioSelect` (options = employés chargés au mount) | `employeeId` (valeur unique) |
|
||||||
|
| Type d'entité | liste de `MalioCheckbox` (multi) | `entityType[]` |
|
||||||
|
| Action | liste de `MalioCheckbox` (multi) | `action[]` |
|
||||||
|
| Utilisateur / compte | `MalioInputText` (`icon mdi:magnify`) | `username` (ILIKE partiel) |
|
||||||
|
| IP | `MalioInputText` | `ip` (ILIKE partiel) |
|
||||||
|
| Appareil | `MalioInputText` | `device` (ILIKE partiel sur `device_label` OU `device_id`) |
|
||||||
|
|
||||||
|
Footer : `MalioButton variant="tertiary"` **Réinitialiser** (gauche) + `MalioButton variant="primary"`
|
||||||
|
**Appliquer** (droite).
|
||||||
|
|
||||||
|
**État brouillon → appliqué** (pattern STARSEED) :
|
||||||
|
- `draft*` refs (éditées dans le drawer) et `applied*` refs (pilotent le fetch).
|
||||||
|
- `openFilters()` : copie `applied*` → `draft*` puis ouvre.
|
||||||
|
- `applyFilters()` : copie `draft*` → `applied*`, remet `page=1`, refetch, ferme le drawer.
|
||||||
|
- `resetFilters()` : vide `draft*` **et** `applied*`, remet `page=1`, refetch, **laisse le drawer ouvert**.
|
||||||
|
- `activeFilterCount` (computed sur `applied*`) → badge bouton.
|
||||||
|
- Helpers `toggle(arrayRef, value, selected)` pour les multi-select.
|
||||||
|
- Options Type d'entité / Action = listes statiques (mêmes codes que le provider) ; options Employé
|
||||||
|
chargées une fois au `onMounted` (réutiliser le chargement employés déjà fait par l'écran actuel).
|
||||||
|
|
||||||
|
## D. Composable `useAuditLogsList`
|
||||||
|
|
||||||
|
Composable **spécifique à l'écran** (`frontend/composables/useAuditLogsList.ts`) — pas de
|
||||||
|
`usePaginatedList` générique (un seul consommateur → YAGNI). Expose :
|
||||||
|
|
||||||
|
- état : `items`, `total`, `page`, `perPage`, `loading`, les `draft*`/`applied*`, `activeFilterCount`,
|
||||||
|
`employeeOptions`.
|
||||||
|
- actions : `load()` (fetch avec filtres appliqués + page/perPage), `goToPage(n)`, `setPerPage(n)`,
|
||||||
|
`openFilters()`, `applyFilters()`, `resetFilters()`, `loadEmployeeOptions()`.
|
||||||
|
- `load()` doit ignorer les réponses périmées (garde anti-race : compteur de requête, on jette
|
||||||
|
les réponses dont l'index n'est pas le dernier émis).
|
||||||
|
|
||||||
|
La page `audit-logs.vue` se réduit à : barre de titre (titre + bouton Filtrer), `MalioDataTable`,
|
||||||
|
drawer filtre, drawer détail — toute la logique vit dans le composable.
|
||||||
|
|
||||||
|
## E. Backend
|
||||||
|
|
||||||
|
### `frontend/services/dto/audit-log.ts` (`AuditLogFilters`)
|
||||||
|
Étendre :
|
||||||
|
```ts
|
||||||
|
export type AuditLogFilters = {
|
||||||
|
employeeId?: number
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
entityType?: string[]
|
||||||
|
action?: string[]
|
||||||
|
username?: string
|
||||||
|
ip?: string
|
||||||
|
device?: string
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`fetchAuditLogs` sérialise les tableaux en `entityType[]`/`action[]` (syntaxe PHP) et n'inclut que
|
||||||
|
les filtres non vides.
|
||||||
|
|
||||||
|
### `src/ApiResource/AuditLogResource.php`
|
||||||
|
Ajouter les `QueryParameter` : `perPage`, `username`, `ip`, `device`, `action` (`entityType` existe
|
||||||
|
déjà). (Les `QueryParameter` sont surtout documentaires : le provider lit `$request->query`.)
|
||||||
|
|
||||||
|
### `src/State/AuditLogProvider.php`
|
||||||
|
- Lire `perPage` (défaut 50, clampé à un ensemble autorisé `[25,50,100]`, fallback 50 ; borne dure).
|
||||||
|
- Lire `username`, `ip`, `device` (chaînes, `null` si vide).
|
||||||
|
- Lire `entityType` et `action` en **tableaux** (`$request->query->all('entityType')` /
|
||||||
|
`->all('action')`), `null`/`[]` si absent. Conserver la rétro-compat : si `entityType` arrive en
|
||||||
|
scalaire, le normaliser en tableau à un élément.
|
||||||
|
- Passer le tout au repository ; `perPage` remplace la constante `PER_PAGE`. La réponse renvoie
|
||||||
|
`perPage` réel.
|
||||||
|
|
||||||
|
### `src/Repository/Contract/AuditLogReadRepositoryInterface.php` + `AuditLogRepository.php`
|
||||||
|
Faire évoluer `findByFilters` / `countByFilters` :
|
||||||
|
```php
|
||||||
|
findByFilters(
|
||||||
|
?int $employeeId,
|
||||||
|
?DateTimeImmutable $from,
|
||||||
|
?DateTimeImmutable $to,
|
||||||
|
?array $entityTypes, // list<string>|null
|
||||||
|
?array $actions, // list<string>|null
|
||||||
|
?string $username,
|
||||||
|
?string $ip,
|
||||||
|
?string $device,
|
||||||
|
int $limit,
|
||||||
|
int $offset,
|
||||||
|
): array
|
||||||
|
countByFilters(... mêmes filtres ...): int
|
||||||
|
```
|
||||||
|
Clauses : `employeeId` =, dates BETWEEN sur `affectedDate` (inchangé), `entityTypes`/`actions`
|
||||||
|
`IN (:...)` si non vides, `username`/`ip` `ILIKE %v%` (paramètre échappé), `device` →
|
||||||
|
`(device_label ILIKE :d OR device_id ILIKE :d)`. Tri inchangé (`createdAt DESC`).
|
||||||
|
Mutualiser la construction des critères entre les deux méthodes (méthode privée
|
||||||
|
`applyFilters(QueryBuilder, ...)`) pour rester DRY.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Backend : `AuditLogProviderTest` étendu — vérifier que `perPage`, `username`, `ip`, `device`,
|
||||||
|
`entityType[]`, `action[]` sont lus et transmis au repository (repo stubbé, on asserte les
|
||||||
|
arguments via un spy), et que `perPage` hors liste retombe sur 50.
|
||||||
|
- Backend : test repository des nouvelles clauses si un test repository existe ; sinon couvrir via le
|
||||||
|
provider (le repo réel n'est pas unit-testé aujourd'hui — ne pas introduire d'intégration DB).
|
||||||
|
- Front : pas de test auto (convention SIRH, pas de build) — revue de diff. Le composable
|
||||||
|
`useAuditLogsList` reste pur/réactif et testable manuellement.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- `doc/audit-logging.md` : section « Filtres disponibles » mise à jour (employé, période, type[],
|
||||||
|
action[], utilisateur, IP, appareil ; pagination perPage) + mention du drawer et du drawer de
|
||||||
|
détail.
|
||||||
|
- `CLAUDE.md` : compléter la puce « Contexte forensique » / journal pour noter l'écran refondu
|
||||||
|
(`MalioDataTable`, drawer de filtre façon STARSEED, drawer de détail, filtres back
|
||||||
|
username/ip/device/action[]/entityType[]/perPage).
|
||||||
|
|
||||||
|
## Risques / notes
|
||||||
|
|
||||||
|
- 1er `MalioDataTable` de SIRH : valider le rendu (le composant gère sa propre pagination/markup ;
|
||||||
|
ne pas réappliquer le gabarit grille maison du CLAUDE.md à ce tableau).
|
||||||
|
- `MalioDateRange` filtre `affectedDate` (cohérent avec l'existant) ; ne pas confondre avec
|
||||||
|
`createdAt` (date d'action affichée en colonne).
|
||||||
|
- Évolution de signature de `AuditLogReadRepositoryInterface` : mettre à jour l'implémentation et le
|
||||||
|
provider dans le même lot (ils sont les seuls consommateurs).
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
|
<MalioDrawer v-model="drawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Nouvelle absence</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
|
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
|
||||||
:options="employeeOptions"
|
:options="employeeOptions"
|
||||||
label="Employé *"
|
label="Employé *"
|
||||||
@@ -12,7 +15,7 @@
|
|||||||
@update:model-value="onEmployeeChange"
|
@update:model-value="onEmployeeChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
|
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
|
||||||
:options="typeOptions"
|
:options="typeOptions"
|
||||||
label="Type d'absence *"
|
label="Type d'absence *"
|
||||||
@@ -24,16 +27,16 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="start-date">Début</label>
|
<label class="text-md font-semibold text-neutral-700">Début</label>
|
||||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
<div class="mt-2 space-y-2">
|
||||||
<input
|
<MalioDate
|
||||||
id="start-date"
|
|
||||||
v-model="absenceForm.startDate"
|
v-model="absenceForm.startDate"
|
||||||
type="date"
|
:clearable="false"
|
||||||
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
|
:reserve-message-space="false"
|
||||||
:disabled="props.lockDates"
|
:disabled="props.lockDates"
|
||||||
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="absenceForm.startHalf"
|
:model-value="absenceForm.startHalf"
|
||||||
:options="halfDayOptions"
|
:options="halfDayOptions"
|
||||||
min-width=""
|
min-width=""
|
||||||
@@ -42,16 +45,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="end-date">Fin</label>
|
<label class="text-md font-semibold text-neutral-700">Fin</label>
|
||||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
<div class="mt-2 space-y-2">
|
||||||
<input
|
<MalioDate
|
||||||
id="end-date"
|
|
||||||
v-model="absenceForm.endDate"
|
v-model="absenceForm.endDate"
|
||||||
type="date"
|
:clearable="false"
|
||||||
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
|
:reserve-message-space="false"
|
||||||
:disabled="props.lockDates"
|
:disabled="props.lockDates"
|
||||||
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="absenceForm.endHalf"
|
:model-value="absenceForm.endHalf"
|
||||||
:options="halfDayOptions"
|
:options="halfDayOptions"
|
||||||
min-width=""
|
min-width=""
|
||||||
@@ -72,31 +75,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editingAbsence" class="grid grid-cols-2 gap-3 pt-2">
|
<div v-if="editingAbsence" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Supprimer"
|
||||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
variant="danger"
|
||||||
|
button-class="w-full"
|
||||||
@click="handleDelete"
|
@click="handleDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
<MalioButton
|
||||||
</button>
|
label="Modifier"
|
||||||
<button
|
variant="primary"
|
||||||
type="submit"
|
button-class="w-full"
|
||||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
:disabled="props.isSubmitting || !isFormValid"
|
||||||
:class="submitButtonClass"
|
@click="handleSubmit"
|
||||||
>
|
/>
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center pt-2">
|
<div v-else class="flex justify-center pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
type="submit"
|
|
||||||
label="Valider"
|
label="Valider"
|
||||||
button-class="w-[200px]"
|
button-class="w-[200px]"
|
||||||
:disabled="props.isSubmitting || !isFormValid"
|
:disabled="props.isSubmitting || !isFormValid"
|
||||||
|
@click="handleSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -106,7 +108,6 @@ import type { AbsenceType } from '~/services/dto/absence-type'
|
|||||||
import type { Absence } from '~/services/dto/absence'
|
import type { Absence } from '~/services/dto/absence'
|
||||||
import type { HalfDay } from '~/services/dto/half-day'
|
import type { HalfDay } from '~/services/dto/half-day'
|
||||||
import { HALF_DAYS } from '~/services/dto/half-day'
|
import { HALF_DAYS } from '~/services/dto/half-day'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -159,13 +160,6 @@ const showTypeError = computed(
|
|||||||
() => validationTouched.type && !isTypeValid.value
|
() => validationTouched.type && !isTypeValid.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
|
||||||
if (props.isSubmitting || !isFormValid.value) {
|
|
||||||
return 'opacity-50 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const employeeOptions = computed(() =>
|
const employeeOptions = computed(() =>
|
||||||
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
|
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
|
||||||
)
|
)
|
||||||
@@ -174,9 +168,6 @@ const typeOptions = computed(() =>
|
|||||||
)
|
)
|
||||||
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
|
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
|
||||||
|
|
||||||
const dateInputBaseClass =
|
|
||||||
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
|
|
||||||
|
|
||||||
const onEmployeeChange = (value: string | number | null) => {
|
const onEmployeeChange = (value: string | number | null) => {
|
||||||
absenceForm.value.employeeId = value === null ? '' : Number(value)
|
absenceForm.value.employeeId = value === null ? '' : Number(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="drawerOpen" title="Imprimer les absences">
|
<MalioDrawer v-model="drawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Imprimer les absences</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<MalioDate
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="print-from">
|
|
||||||
Date de début <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="print-from"
|
|
||||||
v-model="printForm.from"
|
v-model="printForm.from"
|
||||||
type="date"
|
label="Date de début"
|
||||||
:class="fromFieldClass"
|
required
|
||||||
|
:clearable="false"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
:error="showFromError ? 'La date de début est obligatoire.' : ''"
|
||||||
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<p v-if="showFromError" class="mt-1 text-sm text-red-600">
|
|
||||||
La date de début est obligatoire.
|
<MalioDate
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700" for="print-to">
|
|
||||||
Date de fin <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="print-to"
|
|
||||||
v-model="printForm.to"
|
v-model="printForm.to"
|
||||||
type="date"
|
label="Date de fin"
|
||||||
:class="toFieldClass"
|
required
|
||||||
|
:clearable="false"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
:error="showToError ? 'La date de fin est obligatoire.' : ''"
|
||||||
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<p v-if="showToError" class="mt-1 text-sm text-red-600">
|
|
||||||
La date de fin est obligatoire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-md font-semibold text-neutral-700">
|
<p class="text-md font-semibold text-neutral-700">
|
||||||
@@ -97,21 +89,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Imprimer"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
variant="primary"
|
||||||
:class="submitButtonClass"
|
:button-class="submitButtonClass"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Imprimer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, toRef, watch } from 'vue'
|
import { computed, reactive, toRef, watch } from 'vue'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
type SiteOption = {
|
type SiteOption = {
|
||||||
id: number
|
id: number
|
||||||
@@ -190,21 +180,6 @@ const showSitesError = computed(() => validationTouched.sites && !isSitesValid.v
|
|||||||
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
|
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
|
||||||
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
|
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
|
||||||
|
|
||||||
const baseInputClass =
|
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
|
||||||
const fromFieldClass = computed(() => {
|
|
||||||
if (showFromError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
const toFieldClass = computed(() => {
|
|
||||||
if (showToError.value) {
|
|
||||||
return `${baseInputClass} border-red-500`
|
|
||||||
}
|
|
||||||
return `${baseInputClass} border-neutral-300`
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
const submitButtonClass = computed(() => {
|
||||||
if (!isFormValid.value) {
|
if (!isFormValid.value) {
|
||||||
return 'opacity-50 cursor-not-allowed'
|
return 'opacity-50 cursor-not-allowed'
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="modelValue" class="fixed inset-0 z-[60]">
|
|
||||||
<Transition name="drawer-backdrop">
|
|
||||||
<div class="absolute inset-0 bg-black/40" @click="close" />
|
|
||||||
</Transition>
|
|
||||||
<Transition name="drawer-panel">
|
|
||||||
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl flex flex-col">
|
|
||||||
<div class="shrink-0 flex items-center justify-between px-[20px] pt-8 pb-8">
|
|
||||||
<h2 class="text-[32px] font-semibold text-primary-500">
|
|
||||||
{{ title }}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:close" size="24"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{ modelValue: boolean; title?: string }>()
|
|
||||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
|
||||||
|
|
||||||
const close = () => emit('update:modelValue', false)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.drawer-backdrop-enter-active,
|
|
||||||
.drawer-backdrop-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-backdrop-enter-from,
|
|
||||||
.drawer-backdrop-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-panel-enter-active,
|
|
||||||
.drawer-panel-leave-active {
|
|
||||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-panel-enter-from,
|
|
||||||
.drawer-panel-leave-to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="drawerOpen" title="Export heures (tous les employés)">
|
<MalioDrawer v-model="drawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Export heures (tous les employés)</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-year">
|
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-year">
|
||||||
@@ -29,26 +32,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="isLoading ? 'Génération en cours...' : 'Imprimer'"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
button-class="w-[200px]"
|
||||||
:disabled="isLoading || selectedMonth === ''"
|
:disabled="isLoading || selectedMonth === ''"
|
||||||
>
|
@click="handleSubmit"
|
||||||
<template v-if="isLoading">
|
/>
|
||||||
Génération en cours...
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
Imprimer
|
|
||||||
</template>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="drawerOpen" title="Export heures">
|
<MalioDrawer v-model="drawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Export heures</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
|
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
|
||||||
@@ -29,20 +32,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Imprimer"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
button-class="w-[200px]"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Imprimer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="drawerOpen" title="Récapitulatif Salaire">
|
<MalioDrawer v-model="drawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Récapitulatif Salaire</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="salary-recap-month">
|
<label class="text-md font-semibold text-neutral-700" for="salary-recap-month">
|
||||||
@@ -17,21 +20,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Imprimer"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
button-class="w-[200px]"
|
||||||
:class="submitButtonClass"
|
@click="handleSubmit"
|
||||||
>
|
/>
|
||||||
Imprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -63,13 +63,6 @@ const monthFieldClass = computed(() => {
|
|||||||
return `${baseInputClass} border-neutral-300`
|
return `${baseInputClass} border-neutral-300`
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
|
||||||
if (!isMonthValid.value) {
|
|
||||||
return 'opacity-50 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
validationTouched.value = true
|
validationTouched.value = true
|
||||||
if (!isMonthValid.value) return
|
if (!isMonthValid.value) return
|
||||||
|
|||||||
@@ -33,7 +33,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification prime' : 'Nouvelle prime'">
|
<MalioDrawer v-model="isDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">{{ isEditing ? 'Modification prime' : 'Nouvelle prime' }}</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="bonus-month">
|
<label class="text-md font-semibold text-neutral-700" for="bonus-month">
|
||||||
@@ -75,38 +78,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Supprimer"
|
||||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
variant="danger"
|
||||||
|
button-class="w-full"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
<MalioButton
|
||||||
</button>
|
label="Modifier"
|
||||||
<button
|
variant="primary"
|
||||||
type="submit"
|
button-class="w-full"
|
||||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
Modifier
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center pt-2">
|
<div v-else class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Ajouter"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[200px]"
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
+ Ajouter
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Bonus } from '~/services/dto/bonus'
|
import type { Bonus } from '~/services/dto/bonus'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
bonuses: Bonus[]
|
bonuses: Bonus[]
|
||||||
|
|||||||
@@ -43,7 +43,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer :model-value="isContractDrawerOpen" title="Modifier le contrat" @update:model-value="onUpdateContractDrawerOpen">
|
<MalioDrawer :model-value="isContractDrawerOpen" @update:model-value="onUpdateContractDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Modifier le contrat</h2>
|
||||||
|
</template>
|
||||||
<div class="mb-4 flex border-b border-neutral-200">
|
<div class="mb-4 flex border-b border-neutral-200">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -141,13 +144,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Modifier"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-[200px]"
|
||||||
:disabled="isContractSubmitting || !isContractEndDateValid"
|
:disabled="isContractSubmitting || !isContractEndDateValid"
|
||||||
>
|
@click="onSubmitCloseContract"
|
||||||
Modifier
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,27 +190,29 @@
|
|||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
:label="form.id ? 'Modifier' : 'Ajouter'"
|
||||||
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
|
button-class="w-full"
|
||||||
:disabled="!form.startDate || isSuspensionSubmitting"
|
:disabled="!form.startDate || isSuspensionSubmitting"
|
||||||
@click="onSubmitSuspension(index)"
|
@click="onSubmitSuspension(index)"
|
||||||
>
|
/>
|
||||||
{{ form.id ? 'Modifier' : 'Ajouter' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Ajouter une suspension"
|
||||||
class="w-full rounded-md border-2 border-dashed border-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-full"
|
||||||
@click="onAddSuspensionForm"
|
@click="onAddSuspensionForm"
|
||||||
>
|
/>
|
||||||
+ Ajouter une suspension
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
|
|
||||||
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
|
<MalioDrawer :model-value="isCreateContractDrawerOpen" @update:model-value="onUpdateCreateContractDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Ajouter un contrat</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
|
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
|
||||||
@@ -282,16 +286,17 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-center">
|
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-center">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Ajouter"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-[200px]"
|
||||||
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
|
||||||
>
|
@click="onSubmitCreateContract"
|
||||||
+ Ajouter
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer v-model="isDrawerOpen" title="Formation">
|
<MalioDrawer v-model="isDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Formation</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="formation-start-date">
|
<label class="text-md font-semibold text-neutral-700" for="formation-start-date">
|
||||||
@@ -107,39 +110,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Supprimer"
|
||||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
variant="danger"
|
||||||
|
button-class="w-full"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
<MalioButton
|
||||||
</button>
|
label="Modifier"
|
||||||
<button
|
variant="primary"
|
||||||
type="submit"
|
button-class="w-full"
|
||||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
Modifier
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center pt-2">
|
<div v-else class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Ajouter"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[200px]"
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
+ Ajouter
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {Formation} from '~/services/dto/formation'
|
import type {Formation} from '~/services/dto/formation'
|
||||||
import {getFormationJustificatifUrl} from '~/services/formations'
|
import {getFormationJustificatifUrl} from '~/services/formations'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
formations: Formation[]
|
formations: Formation[]
|
||||||
|
|||||||
@@ -111,7 +111,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
<MalioDrawer v-model="isFractionedDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Jours fractionnés</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="fractioned-days">
|
<label class="text-md font-semibold text-neutral-700" for="fractioned-days">
|
||||||
@@ -127,24 +130,25 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Annuler"
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
variant="tertiary"
|
||||||
|
button-class="w-full"
|
||||||
@click="isFractionedDrawerOpen = false"
|
@click="isFractionedDrawerOpen = false"
|
||||||
>
|
/>
|
||||||
Annuler
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-full"
|
||||||
type="submit"
|
@click="handleSubmitFractioned"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
/>
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
<AppDrawer v-model="isPaidLeaveDrawerOpen" title="Congés N-1 payés">
|
<MalioDrawer v-model="isPaidLeaveDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Congés N-1 payés</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmitPaidLeave">
|
<form class="space-y-4" @submit.prevent="handleSubmitPaidLeave">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="paid-leave-days">
|
<label class="text-md font-semibold text-neutral-700" for="paid-leave-days">
|
||||||
@@ -160,23 +164,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Annuler"
|
||||||
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
variant="tertiary"
|
||||||
|
button-class="w-full"
|
||||||
@click="isPaidLeaveDrawerOpen = false"
|
@click="isPaidLeaveDrawerOpen = false"
|
||||||
>
|
/>
|
||||||
Annuler
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-full"
|
||||||
type="submit"
|
@click="handleSubmitPaidLeave"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
/>
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -184,7 +186,6 @@
|
|||||||
import type {Absence} from '~/services/dto/absence'
|
import type {Absence} from '~/services/dto/absence'
|
||||||
import type {EmployeeLeaveSummary} from '~/services/dto/employee-leave-summary'
|
import type {EmployeeLeaveSummary} from '~/services/dto/employee-leave-summary'
|
||||||
import {normalizeDate, toYmd} from '~/utils/date'
|
import {normalizeDate, toYmd} from '~/utils/date'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
type DayLeaveState = {
|
type DayLeaveState = {
|
||||||
am: boolean
|
am: boolean
|
||||||
|
|||||||
@@ -64,7 +64,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<AppDrawer v-model="isDrawerOpen" title="Frais">
|
<MalioDrawer v-model="isDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Frais</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="mileage-month">
|
<label class="text-md font-semibold text-neutral-700" for="mileage-month">
|
||||||
@@ -157,39 +160,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Supprimer"
|
||||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
variant="danger"
|
||||||
|
button-class="w-full"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
<MalioButton
|
||||||
</button>
|
label="Modifier"
|
||||||
<button
|
variant="primary"
|
||||||
type="submit"
|
button-class="w-full"
|
||||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
Modifier
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center pt-2">
|
<div v-else class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Ajouter"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[200px]"
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
+ Ajouter
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {MileageAllowance} from '~/services/dto/mileage-allowance'
|
import type {MileageAllowance} from '~/services/dto/mileage-allowance'
|
||||||
import {getKmReceiptUrl, getAmountReceiptUrl} from '~/services/mileage-allowances'
|
import {getKmReceiptUrl, getAmountReceiptUrl} from '~/services/mileage-allowances'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
allowances: MileageAllowance[]
|
allowances: MileageAllowance[]
|
||||||
|
|||||||
@@ -31,7 +31,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer v-model="isDrawerOpen" :title="isEditing ? 'Modification observation' : 'Nouvelle observation'">
|
<MalioDrawer v-model="isDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">{{ isEditing ? 'Modification observation' : 'Nouvelle observation' }}</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="observation-month">
|
<label class="text-md font-semibold text-neutral-700" for="observation-month">
|
||||||
@@ -59,38 +62,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Supprimer"
|
||||||
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
|
variant="danger"
|
||||||
|
button-class="w-full"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
<MalioButton
|
||||||
</button>
|
label="Modifier"
|
||||||
<button
|
variant="primary"
|
||||||
type="submit"
|
button-class="w-full"
|
||||||
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
Modifier
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center pt-2">
|
<div v-else class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Ajouter"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[200px]"
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid"
|
||||||
>
|
@click="onSubmit"
|
||||||
+ Ajouter
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Observation } from '~/services/dto/observation'
|
import type { Observation } from '~/services/dto/observation'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
observations: Observation[]
|
observations: Observation[]
|
||||||
|
|||||||
@@ -203,7 +203,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Drawer -->
|
<!-- Payment Drawer -->
|
||||||
<AppDrawer v-model="isPaymentDrawerOpen" title="Payer des RTT">
|
<MalioDrawer v-model="isPaymentDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Payer des RTT</h2>
|
||||||
|
</template>
|
||||||
<form @submit.prevent="onSubmitPayment">
|
<form @submit.prevent="onSubmitPayment">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
<label class="block text-sm font-medium text-neutral-700">Mois</label>
|
||||||
@@ -254,30 +257,27 @@
|
|||||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Annuler"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
button-class="w-full"
|
||||||
@click="isPaymentDrawerOpen = false"
|
@click="isPaymentDrawerOpen = false"
|
||||||
>
|
/>
|
||||||
Annuler
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-full"
|
||||||
type="submit"
|
@click="onSubmitPayment"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600"
|
/>
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
import type { ContractPhase } from '~/services/dto/contract-phase'
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
|
|
||||||
type RttYearOption = {
|
type RttYearOption = {
|
||||||
value: number
|
value: number
|
||||||
|
|||||||
@@ -1,55 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="drawerOpen" title="Export des heures">
|
<MalioDrawer v-model="drawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Export des heures</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<MalioDate
|
||||||
<label class="text-md font-semibold text-neutral-700" for="hours-export-date">
|
|
||||||
Date <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="hours-export-date"
|
|
||||||
v-model="selectedDate"
|
v-model="selectedDate"
|
||||||
type="date"
|
label="Date"
|
||||||
class="mt-2 w-full rounded-md border border-black px-3 py-2 text-md text-neutral-900"
|
required
|
||||||
>
|
:clearable="false"
|
||||||
</div>
|
:reserve-message-space="false"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700">
|
|
||||||
Sites <span class="text-red-600">*</span>
|
|
||||||
</label>
|
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox
|
||||||
v-model="selectedSites"
|
v-model="selectedSites"
|
||||||
:options="siteOptions"
|
:options="siteOptions"
|
||||||
groupClass="w-full mt-2"
|
|
||||||
label="Sites"
|
label="Sites"
|
||||||
|
required
|
||||||
|
:reserve-message-space="false"
|
||||||
|
groupClass="w-full"
|
||||||
display-select-all
|
display-select-all
|
||||||
|
display-tag
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="isLoading ? 'Génération en cours...' : 'Exporter'"
|
||||||
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
button-class="w-[200px]"
|
||||||
:disabled="isLoading || !selectedDate || selectedSites.length === 0"
|
:disabled="isLoading || !selectedDate || selectedSites.length === 0"
|
||||||
>
|
@click="handleSubmit"
|
||||||
<template v-if="isLoading">Génération en cours...</template>
|
/>
|
||||||
<template v-else>Exporter</template>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
initialDate: string
|
initialDate: string
|
||||||
initialSiteIds: number[]
|
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -64,7 +58,7 @@ const drawerOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedDate = ref(props.initialDate)
|
const selectedDate = ref(props.initialDate)
|
||||||
const selectedSites = ref<number[]>([...props.initialSiteIds])
|
const selectedSites = ref<number[]>([])
|
||||||
|
|
||||||
const siteOptions = computed(() =>
|
const siteOptions = computed(() =>
|
||||||
props.sites.map((site) => ({ label: site.name, value: site.id }))
|
props.sites.map((site) => ({ label: site.name, value: site.id }))
|
||||||
@@ -80,7 +74,7 @@ watch(
|
|||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
selectedDate.value = props.initialDate
|
selectedDate.value = props.initialDate
|
||||||
selectedSites.value = [...props.initialSiteIds]
|
selectedSites.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
|
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-500 truncate">
|
<p class="text-sm text-neutral-500 truncate">
|
||||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span><span v-if="getRowWorkedDaysLabel(employee.id)"> — {{ getRowWorkedDaysLabel(employee.id) }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span><span v-if="getRowWorkedDaysLabel(employee.id)"> — {{ getRowWorkedDaysLabel(employee.id) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
@@ -406,6 +406,7 @@ const props = defineProps<{
|
|||||||
hasRowFormation: (employeeId: number) => boolean
|
hasRowFormation: (employeeId: number) => boolean
|
||||||
getRowFormationLabel: (employeeId: number) => string
|
getRowFormationLabel: (employeeId: number) => string
|
||||||
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
||||||
|
getRowWorkedDaysLabel: (employeeId: number) => string | null
|
||||||
getRowUpdatedAt: (employeeId: number) => string
|
getRowUpdatedAt: (employeeId: number) => string
|
||||||
getPresenceDayValue: (employeeId: number) => string
|
getPresenceDayValue: (employeeId: number) => string
|
||||||
onAbsenceClick: (employeeId: number) => void
|
onAbsenceClick: (employeeId: number) => void
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Desktop: filters row -->
|
<!-- Desktop: filters row -->
|
||||||
<div class="hidden lg:flex lg:items-center lg:gap-4">
|
<div class="hidden lg:flex lg:items-center lg:gap-4">
|
||||||
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
|
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox :reserve-message-space="false"
|
||||||
v-model="selectedSiteIds"
|
v-model="selectedSiteIds"
|
||||||
:options="siteOptions"
|
:options="siteOptions"
|
||||||
groupClass="w-80"
|
groupClass="w-80"
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
display-select-all
|
display-select-all
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isAdmin" class="w-80">
|
<div v-if="isAdmin" class="w-96">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="employeeFilter"
|
v-model="employeeFilter"
|
||||||
label="Recherche d'un employé"
|
label="Recherche d'un employé"
|
||||||
icon-name="mdi:magnify"
|
icon-name="mdi:magnify"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<!-- Mobile: search + filter button -->
|
<!-- Mobile: search + filter button -->
|
||||||
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
|
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="employeeFilter"
|
v-model="employeeFilter"
|
||||||
label="Recherche d'un employé"
|
label="Recherche d'un employé"
|
||||||
icon-name="mdi:magnify"
|
icon-name="mdi:magnify"
|
||||||
@@ -39,12 +39,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile filters drawer -->
|
<!-- Mobile filters drawer -->
|
||||||
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
|
<MalioDrawer v-model="filtersDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Filtres</h2>
|
||||||
|
</template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div v-if="sites.length > 0 && isAdmin">
|
<div v-if="sites.length > 0 && isAdmin">
|
||||||
<label class="text-md font-semibold text-neutral-700">Sites</label>
|
<label class="text-md font-semibold text-neutral-700">Sites</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox :reserve-message-space="false"
|
||||||
v-model="selectedSiteIds"
|
v-model="selectedSiteIds"
|
||||||
:options="siteOptions"
|
:options="siteOptions"
|
||||||
groupClass="w-80"
|
groupClass="w-80"
|
||||||
@@ -77,11 +80,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
|
|
||||||
<!-- Date navigation -->
|
<!-- Date navigation -->
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
|
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
|
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center lg:gap-4">
|
||||||
<div
|
<div
|
||||||
v-if="viewMode === 'day'"
|
v-if="viewMode === 'day'"
|
||||||
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||||
@@ -142,10 +145,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vue Jour (opt-in) : calendrier Malio avec jours validés en vert (markedDates). -->
|
||||||
|
<MalioDate
|
||||||
|
v-if="viewMode === 'day' && showValidationCalendar"
|
||||||
|
:model-value="selectedDate"
|
||||||
|
:clearable="false"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
:marked-dates="markedDates"
|
||||||
|
group-class="w-full lg:w-96"
|
||||||
|
label="Date"
|
||||||
|
@update:model-value="onDatePicked"
|
||||||
|
@month-change="(payload) => emit('month-change', payload)"
|
||||||
|
/>
|
||||||
|
<!-- Vue Semaine : sélecteur de semaine Malio. -->
|
||||||
|
<MalioDateWeek
|
||||||
|
v-else-if="viewMode === 'week'"
|
||||||
|
:model-value="pickerValue"
|
||||||
|
:clearable="false"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
group-class="w-full lg:w-96"
|
||||||
|
label="Semaine"
|
||||||
|
@update:model-value="onWeekPicked"
|
||||||
|
/>
|
||||||
<PeriodStepperPicker
|
<PeriodStepperPicker
|
||||||
|
v-else
|
||||||
width-class="w-full lg:w-[320px]"
|
width-class="w-full lg:w-[320px]"
|
||||||
:label="formattedSelectedDate"
|
:label="formattedSelectedDate"
|
||||||
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
picker-type="date"
|
||||||
:picker-value="pickerValue"
|
:picker-value="pickerValue"
|
||||||
prev-aria-label="Période précédente"
|
prev-aria-label="Période précédente"
|
||||||
next-aria-label="Période suivante"
|
next-aria-label="Période suivante"
|
||||||
@@ -195,7 +221,6 @@
|
|||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||||
import AppDrawer from '~/components/AppDrawer.vue'
|
|
||||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||||
|
|
||||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||||
@@ -208,6 +233,10 @@ const props = defineProps<{
|
|||||||
sites: Site[]
|
sites: Site[]
|
||||||
absenceTypes: AbsenceType[]
|
absenceTypes: AbsenceType[]
|
||||||
formattedSelectedDate: string
|
formattedSelectedDate: string
|
||||||
|
// Calendrier des jours validés (vert) : opt-in, réservé à l'écran Heures.
|
||||||
|
// L'écran Heures Conducteurs ne le passe pas → garde le PeriodStepperPicker.
|
||||||
|
showValidationCalendar?: boolean
|
||||||
|
markedDates?: Record<string, 'success' | 'danger'>
|
||||||
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
||||||
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||||
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||||
@@ -223,6 +252,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'set-this-week'): void
|
(e: 'set-this-week'): void
|
||||||
(e: 'set-next-week'): void
|
(e: 'set-next-week'): void
|
||||||
(e: 'shift-date', value: number): void
|
(e: 'shift-date', value: number): void
|
||||||
|
(e: 'month-change', value: { month: number; year: number }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const filtersDrawerOpen = ref(false)
|
const filtersDrawerOpen = ref(false)
|
||||||
@@ -252,4 +282,20 @@ const onPickerValue = (value: string) => {
|
|||||||
|
|
||||||
selectedDate.value = value
|
selectedDate.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sélection d'un jour dans le calendrier MalioDate (vue Jour). `clearable=false`
|
||||||
|
// → pas de null en pratique, mais on garde la garde par sécurité.
|
||||||
|
const onDatePicked = (value: string | null) => {
|
||||||
|
if (!value) return
|
||||||
|
selectedDate.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélection d'une semaine dans MalioDateWeek (vue Semaine) : v-model au format ISO
|
||||||
|
// week (YYYY-Www) → on repositionne selectedDate sur le lundi de cette semaine.
|
||||||
|
const onWeekPicked = (value: string | null) => {
|
||||||
|
if (!value) return
|
||||||
|
const ymd = weekInputValueToYmd(value)
|
||||||
|
if (!ymd) return
|
||||||
|
selectedDate.value = ymd
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer v-model="drawerOpen" title="Commentaire">
|
<MalioDrawer v-model="drawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Commentaire</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="onSave">
|
<form class="space-y-4" @submit.prevent="onSave">
|
||||||
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
|
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
|
||||||
<div class="text-md font-semibold text-neutral-700">{{ formatWeekRange }}</div>
|
<div class="text-md font-semibold text-neutral-700">{{ formatWeekRange }}</div>
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea :reserve-message-space="false"
|
||||||
v-model="content"
|
v-model="content"
|
||||||
label="Commentaire"
|
label="Commentaire"
|
||||||
:size="8"
|
:size="8"
|
||||||
@@ -11,17 +14,18 @@
|
|||||||
:show-counter="true"
|
:show-counter="true"
|
||||||
resize="vertical"
|
resize="vertical"
|
||||||
/>
|
/>
|
||||||
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-between gap-3">
|
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex gap-3">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="commentId"
|
v-if="commentId"
|
||||||
label="Supprimer"
|
label="Supprimer"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
button-class="flex-1"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Enregistrer"
|
label="Enregistrer"
|
||||||
button-class="ml-auto"
|
button-class="flex-1"
|
||||||
:disabled="isSubmitting || !canSubmit"
|
:disabled="isSubmitting || !canSubmit"
|
||||||
@click="onSave"
|
@click="onSave"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ export const useApi = (): ApiClient => {
|
|||||||
baseURL,
|
baseURL,
|
||||||
retry: 0,
|
retry: 0,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
onRequest({ options }) {
|
||||||
|
const deviceId = useDeviceId()
|
||||||
|
if (deviceId) {
|
||||||
|
const headers = new Headers(options.headers as HeadersInit | undefined)
|
||||||
|
headers.set('X-Device-Id', deviceId)
|
||||||
|
options.headers = headers
|
||||||
|
}
|
||||||
|
},
|
||||||
onResponse({ options, response }) {
|
onResponse({ options, response }) {
|
||||||
const apiOptions = options as ApiFetchOptions<'json'>
|
const apiOptions = options as ApiFetchOptions<'json'>
|
||||||
if (apiOptions?.toast === false) {
|
if (apiOptions?.toast === false) {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { AuditLog } from '~/services/dto/audit-log'
|
||||||
|
import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs'
|
||||||
|
|
||||||
|
type Range = { start: string, end: string } | null
|
||||||
|
|
||||||
|
export const useAuditLogsList = () => {
|
||||||
|
const items = ref<AuditLog[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const loading = ref(false)
|
||||||
|
const filterOpen = ref(false)
|
||||||
|
|
||||||
|
// Applied filters (drive the fetch)
|
||||||
|
const appliedEmployee = ref('')
|
||||||
|
const appliedRange = ref<Range>(null)
|
||||||
|
const appliedEntityTypes = ref<string[]>([])
|
||||||
|
const appliedActions = ref<string[]>([])
|
||||||
|
const appliedUsername = ref('')
|
||||||
|
const appliedIp = ref('')
|
||||||
|
const appliedDevice = ref('')
|
||||||
|
|
||||||
|
// Draft filters (edited inside the drawer)
|
||||||
|
const draftEmployee = ref('')
|
||||||
|
const draftRange = ref<Range>(null)
|
||||||
|
const draftEntityTypes = ref<string[]>([])
|
||||||
|
const draftActions = ref<string[]>([])
|
||||||
|
const draftUsername = ref('')
|
||||||
|
const draftIp = ref('')
|
||||||
|
const draftDevice = ref('')
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let n = 0
|
||||||
|
if (appliedEmployee.value.trim() !== '') n++
|
||||||
|
if (appliedRange.value?.start || appliedRange.value?.end) n++
|
||||||
|
if (appliedEntityTypes.value.length > 0) n++
|
||||||
|
if (appliedActions.value.length > 0) n++
|
||||||
|
if (appliedUsername.value.trim() !== '') n++
|
||||||
|
if (appliedIp.value.trim() !== '') n++
|
||||||
|
if (appliedDevice.value.trim() !== '') n++
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildFilters = (): AuditLogFilters => ({
|
||||||
|
employee: appliedEmployee.value.trim() || undefined,
|
||||||
|
from: appliedRange.value?.start || undefined,
|
||||||
|
to: appliedRange.value?.end || undefined,
|
||||||
|
entityType: appliedEntityTypes.value.length > 0 ? [...appliedEntityTypes.value] : undefined,
|
||||||
|
action: appliedActions.value.length > 0 ? [...appliedActions.value] : undefined,
|
||||||
|
username: appliedUsername.value.trim() || undefined,
|
||||||
|
ip: appliedIp.value.trim() || undefined,
|
||||||
|
device: appliedDevice.value.trim() || undefined,
|
||||||
|
page: page.value,
|
||||||
|
perPage: perPage.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Race guard: only the latest request may commit its result.
|
||||||
|
let requestSeq = 0
|
||||||
|
const load = async () => {
|
||||||
|
const seq = ++requestSeq
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAuditLogs(buildFilters())
|
||||||
|
if (seq !== requestSeq) return
|
||||||
|
items.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
page.value = result.page
|
||||||
|
perPage.value = result.perPage
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToPage = (n: number) => {
|
||||||
|
page.value = n
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPerPage = (n: number) => {
|
||||||
|
perPage.value = n
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFilters = () => {
|
||||||
|
draftEmployee.value = appliedEmployee.value
|
||||||
|
draftRange.value = appliedRange.value ? { ...appliedRange.value } : null
|
||||||
|
draftEntityTypes.value = [...appliedEntityTypes.value]
|
||||||
|
draftActions.value = [...appliedActions.value]
|
||||||
|
draftUsername.value = appliedUsername.value
|
||||||
|
draftIp.value = appliedIp.value
|
||||||
|
draftDevice.value = appliedDevice.value
|
||||||
|
filterOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
appliedEmployee.value = draftEmployee.value
|
||||||
|
appliedRange.value = draftRange.value ? { ...draftRange.value } : null
|
||||||
|
appliedEntityTypes.value = [...draftEntityTypes.value]
|
||||||
|
appliedActions.value = [...draftActions.value]
|
||||||
|
appliedUsername.value = draftUsername.value
|
||||||
|
appliedIp.value = draftIp.value
|
||||||
|
appliedDevice.value = draftDevice.value
|
||||||
|
page.value = 1
|
||||||
|
filterOpen.value = false
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
draftEmployee.value = ''
|
||||||
|
draftRange.value = null
|
||||||
|
draftEntityTypes.value = []
|
||||||
|
draftActions.value = []
|
||||||
|
draftUsername.value = ''
|
||||||
|
draftIp.value = ''
|
||||||
|
draftDevice.value = ''
|
||||||
|
appliedEmployee.value = ''
|
||||||
|
appliedRange.value = null
|
||||||
|
appliedEntityTypes.value = []
|
||||||
|
appliedActions.value = []
|
||||||
|
appliedUsername.value = ''
|
||||||
|
appliedIp.value = ''
|
||||||
|
appliedDevice.value = ''
|
||||||
|
page.value = 1
|
||||||
|
load() // drawer stays open
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (arr: typeof draftEntityTypes, value: string, selected: boolean) => {
|
||||||
|
arr.value = selected ? [...arr.value, value] : arr.value.filter(v => v !== value)
|
||||||
|
}
|
||||||
|
const toggleEntityType = (value: string, selected: boolean) => toggle(draftEntityTypes, value, selected)
|
||||||
|
const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items, total, page, perPage, loading, filterOpen, activeFilterCount,
|
||||||
|
draftEmployee, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice,
|
||||||
|
init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Stable per-device identifier used to add forensic context to audit logs.
|
||||||
|
// Persisted in localStorage so the same browser/device reuses it across sessions.
|
||||||
|
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
|
||||||
|
// user of the same browser shares one id (intended: it distinguishes devices).
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sirh-device-id'
|
||||||
|
let cached: string | null = null
|
||||||
|
|
||||||
|
export const useDeviceId = (): string | null => {
|
||||||
|
if (!import.meta.client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID()
|
||||||
|
localStorage.setItem(STORAGE_KEY, id)
|
||||||
|
}
|
||||||
|
cached = id
|
||||||
|
return id
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode, disabled) — degrade gracefully.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
bulkUpdateWorkHourValidation,
|
bulkUpdateWorkHourValidation,
|
||||||
bulkUpsertWorkHours,
|
bulkUpsertWorkHours,
|
||||||
getWorkHourDayContext,
|
getWorkHourDayContext,
|
||||||
|
getWorkHourValidationStatus,
|
||||||
getWeeklyWorkHourSummary,
|
getWeeklyWorkHourSummary,
|
||||||
listWorkHoursByDate,
|
listWorkHoursByDate,
|
||||||
updateWorkHourSiteValidation,
|
updateWorkHourSiteValidation,
|
||||||
@@ -28,7 +29,8 @@ import {
|
|||||||
getWeekStartDate,
|
getWeekStartDate,
|
||||||
getTodayYmd,
|
getTodayYmd,
|
||||||
parseYmd,
|
parseYmd,
|
||||||
shiftYmd
|
shiftYmd,
|
||||||
|
toYmd
|
||||||
} from '~/utils/date'
|
} from '~/utils/date'
|
||||||
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
||||||
|
|
||||||
@@ -46,6 +48,11 @@ export const useDriverHoursPage = () => {
|
|||||||
const selectedSiteIds = ref<number[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
const rows = ref<Record<number, DriverHourRow>>({})
|
const rows = ref<Record<number, DriverHourRow>>({})
|
||||||
|
// Instantané des lignes telles que chargées depuis le serveur (clé = employeeId).
|
||||||
|
// Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées, afin de ne jamais
|
||||||
|
// écraser/supprimer une ligne saisie entre-temps par un autre utilisateur (enregistrement
|
||||||
|
// « à l'aveugle » d'une grille périmée).
|
||||||
|
const loadedRows = ref<Record<number, DriverHourRow>>({})
|
||||||
const dayContext = ref<WorkHourDayContext | null>(null)
|
const dayContext = ref<WorkHourDayContext | null>(null)
|
||||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||||
const absenceTypes = ref<AbsenceType[]>([])
|
const absenceTypes = ref<AbsenceType[]>([])
|
||||||
@@ -68,6 +75,9 @@ export const useDriverHoursPage = () => {
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const validatingRowIds = ref<number[]>([])
|
const validatingRowIds = ref<number[]>([])
|
||||||
const siteValidatingRowIds = ref<number[]>([])
|
const siteValidatingRowIds = ref<number[]>([])
|
||||||
|
// Jours entièrement validés (conducteurs) par mois civil, pour le calendrier de
|
||||||
|
// la vue Jour. Clé 'YYYY-MM' → dates Y-m-d. Chargé à la volée sur @month-change.
|
||||||
|
const validatedDaysByMonth = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
const dayGridCols = computed(() => {
|
const dayGridCols = computed(() => {
|
||||||
const metricCol = '0.4fr'
|
const metricCol = '0.4fr'
|
||||||
@@ -453,6 +463,10 @@ export const useDriverHoursPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows.value = nextRows
|
rows.value = nextRows
|
||||||
|
// Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci.
|
||||||
|
loadedRows.value = Object.fromEntries(
|
||||||
|
Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAbsenceTypes = async () => {
|
const loadAbsenceTypes = async () => {
|
||||||
@@ -519,11 +533,12 @@ export const useDriverHoursPage = () => {
|
|||||||
const refreshAfterAbsenceChange = async () => {
|
const refreshAfterAbsenceChange = async () => {
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
weeklySummary.value = null
|
weeklySummary.value = null
|
||||||
await Promise.all([loadDayContext(), loadAbsences()])
|
await Promise.all([loadDayContext(), loadAbsences()])
|
||||||
}
|
}
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
|
}
|
||||||
|
|
||||||
const submitAbsence = async () => {
|
const submitAbsence = async () => {
|
||||||
const form = absenceForm.value
|
const form = absenceForm.value
|
||||||
@@ -626,6 +641,7 @@ export const useDriverHoursPage = () => {
|
|||||||
try {
|
try {
|
||||||
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
||||||
updatedRow.isValid = checked
|
updatedRow.isValid = checked
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
} finally {
|
} finally {
|
||||||
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
}
|
}
|
||||||
@@ -708,6 +724,7 @@ export const useDriverHoursPage = () => {
|
|||||||
}, { toast: false })
|
}, { toast: false })
|
||||||
|
|
||||||
await loadWorkHours()
|
await loadWorkHours()
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
|
|
||||||
if (result.updated === 0) {
|
if (result.updated === 0) {
|
||||||
toast.error({ title: 'Erreur', message: 'Aucune ligne mise à jour.' })
|
toast.error({ title: 'Erreur', message: 'Aucune ligne mise à jour.' })
|
||||||
@@ -825,6 +842,45 @@ export const useDriverHoursPage = () => {
|
|||||||
dayContext.value = await getWorkHourDayContext(selectedDate.value)
|
dayContext.value = await getWorkHourDayContext(selectedDate.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Calendrier vue Jour : jours validés en vert (scope conducteurs) ---------
|
||||||
|
const monthKey = (year: number, monthIndex: number) => `${year}-${String(monthIndex + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const markedDates = computed<Record<string, 'success'>>(() => {
|
||||||
|
const map: Record<string, 'success'> = {}
|
||||||
|
for (const days of Object.values(validatedDaysByMonth.value)) {
|
||||||
|
for (const day of days) map[day] = 'success'
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// Plage = grille visible complète (lundi avant le 1er → dimanche après le dernier).
|
||||||
|
// driver:true → l'endpoint ne considère que les conducteurs.
|
||||||
|
const loadValidationMonth = async (monthIndex: number, year: number, options: { force?: boolean } = {}) => {
|
||||||
|
const key = monthKey(year, monthIndex)
|
||||||
|
if (!options.force && validatedDaysByMonth.value[key]) return
|
||||||
|
|
||||||
|
const gridStart = getWeekStartDate(new Date(year, monthIndex, 1))
|
||||||
|
const gridEnd = getWeekStartDate(new Date(year, monthIndex + 1, 0))
|
||||||
|
gridEnd.setDate(gridEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const from = toYmd(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate())
|
||||||
|
const to = toYmd(gridEnd.getFullYear(), gridEnd.getMonth(), gridEnd.getDate())
|
||||||
|
const days = await getWorkHourValidationStatus(from, to, { driver: true })
|
||||||
|
validatedDaysByMonth.value = { ...validatedDaysByMonth.value, [key]: days }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCalendarMonthChange = (payload: { month: number; year: number }) => {
|
||||||
|
void loadValidationMonth(payload.month, payload.year)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadValidationMonth = async (dateYmd: string) => {
|
||||||
|
const parsed = parseYmd(dateYmd)
|
||||||
|
if (!parsed) return
|
||||||
|
const key = monthKey(parsed.getFullYear(), parsed.getMonth())
|
||||||
|
if (!validatedDaysByMonth.value[key]) return
|
||||||
|
await loadValidationMonth(parsed.getMonth(), parsed.getFullYear(), { force: true })
|
||||||
|
}
|
||||||
|
|
||||||
const refreshByDate = async () => {
|
const refreshByDate = async () => {
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
@@ -877,18 +933,8 @@ export const useDriverHoursPage = () => {
|
|||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSave = async () => {
|
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
|
||||||
if (isSubmitting.value || employees.value.length === 0) return
|
const buildEntry = (employeeId: number, row: DriverHourRow) => {
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const driverEmployees = employees.value.filter(
|
|
||||||
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
const entries = driverEmployees.map((employee) => {
|
|
||||||
const employeeId = employee.id
|
|
||||||
const row = rows.value[employeeId] ?? emptyRow()
|
|
||||||
const dayMin = toMinutes(row.dayHours)
|
const dayMin = toMinutes(row.dayHours)
|
||||||
const nightMin = toMinutes(row.nightHours)
|
const nightMin = toMinutes(row.nightHours)
|
||||||
const workshopMin = toMinutes(row.workshopHours)
|
const workshopMin = toMinutes(row.workshopHours)
|
||||||
@@ -911,7 +957,27 @@ export const useDriverHoursPage = () => {
|
|||||||
hasDinner: row.hasDinner,
|
hasDinner: row.hasDinner,
|
||||||
hasOvernight: row.hasOvernight
|
hasOvernight: row.hasOvernight
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSubmitting.value || employees.value.length === 0) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const driverEmployees = employees.value.filter(
|
||||||
|
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const entries = driverEmployees
|
||||||
|
.map((employee) => {
|
||||||
|
const current = buildEntry(employee.id, rows.value[employee.id] ?? emptyRow())
|
||||||
|
const original = buildEntry(employee.id, loadedRows.value[employee.id] ?? emptyRow())
|
||||||
|
return { current, original }
|
||||||
})
|
})
|
||||||
|
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||||
|
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||||
|
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||||
|
.map(({ current }) => current)
|
||||||
|
|
||||||
if (entries.length === 0) return
|
if (entries.length === 0) return
|
||||||
|
|
||||||
@@ -921,6 +987,7 @@ export const useDriverHoursPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -1003,6 +1070,8 @@ export const useDriverHoursPage = () => {
|
|||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
markedDates,
|
||||||
|
onCalendarMonthChange,
|
||||||
isWeekCommentDrawerOpen,
|
isWeekCommentDrawerOpen,
|
||||||
weekCommentContext,
|
weekCommentContext,
|
||||||
openWeekCommentDrawer,
|
openWeekCommentDrawer,
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import type { Employee } from '~/services/dto/employee'
|
|||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { getEmployee } from '~/services/employees'
|
import { getEmployee } from '~/services/employees'
|
||||||
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
||||||
|
import { getEmployeeOvertimeContingent, type OvertimeContingent } from '~/services/employee-overtime-contingent'
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
export const useEmployeeDetailPage = () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const employee = ref<Employee | null>(null)
|
const employee = ref<Employee | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
||||||
|
const overtimeContingent = ref<OvertimeContingent | null>(null)
|
||||||
|
|
||||||
const phase = useEmployeeContractPhase(employee)
|
const phase = useEmployeeContractPhase(employee)
|
||||||
|
|
||||||
@@ -28,6 +30,18 @@ export const useEmployeeDetailPage = () => {
|
|||||||
return contract.name || '-'
|
return contract.name || '-'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loadOvertimeContingent = async () => {
|
||||||
|
if (!employee.value || !showRttTab.value) {
|
||||||
|
overtimeContingent.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
overtimeContingent.value = await getEmployeeOvertimeContingent(employee.value.id)
|
||||||
|
} catch {
|
||||||
|
overtimeContingent.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadEmployee = async () => {
|
const loadEmployee = async () => {
|
||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
const employeeId = Number(idParam)
|
const employeeId = Number(idParam)
|
||||||
@@ -71,6 +85,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
// qui proviennent du récap congés — nécessaire même quand on ouvre un autre onglet.
|
// qui proviennent du récap congés — nécessaire même quand on ouvre un autre onglet.
|
||||||
await leave.loadLeaveData()
|
await leave.loadLeaveData()
|
||||||
}
|
}
|
||||||
|
await loadOvertimeContingent()
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -94,6 +109,18 @@ export const useEmployeeDetailPage = () => {
|
|||||||
if (presence === undefined || presence === null) return ''
|
if (presence === undefined || presence === null) return ''
|
||||||
return ` (${formatDays(presence)} présence)`
|
return ` (${formatDays(presence)} présence)`
|
||||||
})
|
})
|
||||||
|
const overtimeContingentLabel = computed(() => {
|
||||||
|
if (!showRttTab.value) return ''
|
||||||
|
const c = overtimeContingent.value
|
||||||
|
if (!c) return ''
|
||||||
|
const h = c.paidMinutes / 60
|
||||||
|
const hStr = Number.isInteger(h) ? String(h) : (Math.round(h * 10) / 10).toFixed(1).replace('.', ',')
|
||||||
|
return `Total H.payés ${c.year} : ${hStr} h / ${c.capHours} h`
|
||||||
|
})
|
||||||
|
const overtimeContingentExceeded = computed(() => {
|
||||||
|
const c = overtimeContingent.value
|
||||||
|
return c ? c.paidMinutes > c.capHours * 60 : false
|
||||||
|
})
|
||||||
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
const rtt = useEmployeeRtt(employee, loadEmployee, phase.selectedPhase)
|
||||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||||
const formation = useEmployeeFormation(employee, loadEmployee)
|
const formation = useEmployeeFormation(employee, loadEmployee)
|
||||||
@@ -147,6 +174,8 @@ export const useEmployeeDetailPage = () => {
|
|||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
forfaitRemainingDaysLabel,
|
forfaitRemainingDaysLabel,
|
||||||
nonForfaitPresenceLabel,
|
nonForfaitPresenceLabel,
|
||||||
|
overtimeContingentLabel,
|
||||||
|
overtimeContingentExceeded,
|
||||||
...phase,
|
...phase,
|
||||||
...contract,
|
...contract,
|
||||||
...leave,
|
...leave,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
bulkUpdateWorkHourValidation,
|
bulkUpdateWorkHourValidation,
|
||||||
bulkUpsertWorkHours,
|
bulkUpsertWorkHours,
|
||||||
getWorkHourDayContext,
|
getWorkHourDayContext,
|
||||||
|
getWorkHourValidationStatus,
|
||||||
getWeeklyWorkHourSummary,
|
getWeeklyWorkHourSummary,
|
||||||
listWorkHoursByDate,
|
listWorkHoursByDate,
|
||||||
updateWorkHourSiteValidation,
|
updateWorkHourSiteValidation,
|
||||||
@@ -30,7 +31,8 @@ import {
|
|||||||
getWeekStartDate,
|
getWeekStartDate,
|
||||||
getTodayYmd,
|
getTodayYmd,
|
||||||
parseYmd,
|
parseYmd,
|
||||||
shiftYmd
|
shiftYmd,
|
||||||
|
toYmd
|
||||||
} from '~/utils/date'
|
} from '~/utils/date'
|
||||||
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
||||||
|
|
||||||
@@ -48,6 +50,11 @@ export const useHoursPage = () => {
|
|||||||
const selectedSiteIds = ref<number[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
const rows = ref<Record<number, HourRow>>({})
|
const rows = ref<Record<number, HourRow>>({})
|
||||||
|
// Instantané des lignes telles que chargées depuis le serveur (clé = employeeId).
|
||||||
|
// Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées par l'utilisateur,
|
||||||
|
// afin de ne jamais écraser/supprimer une ligne saisie entre-temps par un autre utilisateur
|
||||||
|
// (perte de données par enregistrement « à l'aveugle » d'une grille périmée).
|
||||||
|
const loadedRows = ref<Record<number, HourRow>>({})
|
||||||
const dayContext = ref<WorkHourDayContext | null>(null)
|
const dayContext = ref<WorkHourDayContext | null>(null)
|
||||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||||
const absenceTypes = ref<AbsenceType[]>([])
|
const absenceTypes = ref<AbsenceType[]>([])
|
||||||
@@ -70,6 +77,10 @@ export const useHoursPage = () => {
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const validatingRowIds = ref<number[]>([])
|
const validatingRowIds = ref<number[]>([])
|
||||||
const siteValidatingRowIds = ref<number[]>([])
|
const siteValidatingRowIds = ref<number[]>([])
|
||||||
|
// Jours entièrement validés (admin) par mois civil affiché dans le calendrier
|
||||||
|
// de la vue Jour. Clé = 'YYYY-MM', valeur = liste de dates Y-m-d. Chargé à la
|
||||||
|
// volée sur @month-change (jamais préchargé sur plusieurs années).
|
||||||
|
const validatedDaysByMonth = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
const dayGridCols = computed(() => {
|
const dayGridCols = computed(() => {
|
||||||
const metricCol = '0.4fr'
|
const metricCol = '0.4fr'
|
||||||
@@ -517,6 +528,11 @@ export const useHoursPage = () => {
|
|||||||
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
|
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jours travaillés du planning (contrats CUSTOM uniquement), ex. "LU,VE". null sinon.
|
||||||
|
const getRowWorkedDaysLabel = (employeeId: number): string | null => {
|
||||||
|
return formatWorkedDaysShort(dayContextByEmployeeId.value.get(employeeId)?.workDaysHours)
|
||||||
|
}
|
||||||
|
|
||||||
const getRowUpdatedAt = (employeeId: number): string => {
|
const getRowUpdatedAt = (employeeId: number): string => {
|
||||||
const raw = rows.value[employeeId]?.updatedAt
|
const raw = rows.value[employeeId]?.updatedAt
|
||||||
if (!raw) return ''
|
if (!raw) return ''
|
||||||
@@ -589,6 +605,10 @@ export const useHoursPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows.value = nextRows
|
rows.value = nextRows
|
||||||
|
// Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci.
|
||||||
|
loadedRows.value = Object.fromEntries(
|
||||||
|
Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAbsenceTypes = async () => {
|
const loadAbsenceTypes = async () => {
|
||||||
@@ -681,12 +701,12 @@ export const useHoursPage = () => {
|
|||||||
const refreshAfterAbsenceChange = async () => {
|
const refreshAfterAbsenceChange = async () => {
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
weeklySummary.value = null
|
weeklySummary.value = null
|
||||||
await Promise.all([loadDayContext(), loadAbsences()])
|
await Promise.all([loadDayContext(), loadAbsences()])
|
||||||
}
|
}
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
|
}
|
||||||
|
|
||||||
const submitAbsence = async () => {
|
const submitAbsence = async () => {
|
||||||
const form = absenceForm.value
|
const form = absenceForm.value
|
||||||
@@ -782,6 +802,7 @@ export const useHoursPage = () => {
|
|||||||
try {
|
try {
|
||||||
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
||||||
updatedRow.isValid = checked
|
updatedRow.isValid = checked
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
} finally {
|
} finally {
|
||||||
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
}
|
}
|
||||||
@@ -886,6 +907,7 @@ export const useHoursPage = () => {
|
|||||||
}, { toast: false })
|
}, { toast: false })
|
||||||
|
|
||||||
await loadWorkHours()
|
await loadWorkHours()
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
|
|
||||||
if (result.updated === 0) {
|
if (result.updated === 0) {
|
||||||
toast.error({
|
toast.error({
|
||||||
@@ -1026,6 +1048,50 @@ export const useHoursPage = () => {
|
|||||||
dayContext.value = await getWorkHourDayContext(selectedDate.value)
|
dayContext.value = await getWorkHourDayContext(selectedDate.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Calendrier vue Jour : jours validés en vert ---------------------------
|
||||||
|
const monthKey = (year: number, monthIndex: number) => `${year}-${String(monthIndex + 1).padStart(2, '0')}`
|
||||||
|
|
||||||
|
// Fusionne tous les mois chargés en une seule map ISO → 'success' pour MalioDate.
|
||||||
|
const markedDates = computed<Record<string, 'success'>>(() => {
|
||||||
|
const map: Record<string, 'success'> = {}
|
||||||
|
for (const days of Object.values(validatedDaysByMonth.value)) {
|
||||||
|
for (const day of days) map[day] = 'success'
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// Charge le statut du mois affiché. La plage couvre toute la grille visible
|
||||||
|
// (lundi avant le 1er → dimanche après le dernier jour) pour colorer aussi les
|
||||||
|
// jours débordants des mois adjacents.
|
||||||
|
const loadValidationMonth = async (monthIndex: number, year: number, options: { force?: boolean } = {}) => {
|
||||||
|
const key = monthKey(year, monthIndex)
|
||||||
|
if (!options.force && validatedDaysByMonth.value[key]) return
|
||||||
|
|
||||||
|
const gridStart = getWeekStartDate(new Date(year, monthIndex, 1))
|
||||||
|
const gridEnd = getWeekStartDate(new Date(year, monthIndex + 1, 0))
|
||||||
|
gridEnd.setDate(gridEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const from = toYmd(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate())
|
||||||
|
const to = toYmd(gridEnd.getFullYear(), gridEnd.getMonth(), gridEnd.getDate())
|
||||||
|
const days = await getWorkHourValidationStatus(from, to)
|
||||||
|
validatedDaysByMonth.value = { ...validatedDaysByMonth.value, [key]: days }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCalendarMonthChange = (payload: { month: number; year: number }) => {
|
||||||
|
void loadValidationMonth(payload.month, payload.year)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Après une modification qui touche la validation d'un jour (validation,
|
||||||
|
// sauvegarde d'heures, absence), recharge le mois concerné s'il est déjà en
|
||||||
|
// cache → le calendrier se recolore. Sinon no-op (le prochain affichage fetch).
|
||||||
|
const reloadValidationMonth = async (dateYmd: string) => {
|
||||||
|
const parsed = parseYmd(dateYmd)
|
||||||
|
if (!parsed) return
|
||||||
|
const key = monthKey(parsed.getFullYear(), parsed.getMonth())
|
||||||
|
if (!validatedDaysByMonth.value[key]) return
|
||||||
|
await loadValidationMonth(parsed.getMonth(), parsed.getFullYear(), { force: true })
|
||||||
|
}
|
||||||
|
|
||||||
const refreshByDate = async () => {
|
const refreshByDate = async () => {
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
@@ -1079,16 +1145,9 @@ export const useHoursPage = () => {
|
|||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSave = async () => {
|
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
|
||||||
if (isSubmitting.value || employees.value.length === 0) return
|
const buildEntry = (employee: Employee, row: HourRow) => {
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const entries = employees.value
|
|
||||||
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
|
||||||
.map((employee) => {
|
|
||||||
const employeeId = employee.id
|
const employeeId = employee.id
|
||||||
const row = rows.value[employeeId] ?? emptyRow()
|
|
||||||
if (isPresenceTracking(employee)) {
|
if (isPresenceTracking(employee)) {
|
||||||
return {
|
return {
|
||||||
employeeId,
|
employeeId,
|
||||||
@@ -1114,7 +1173,24 @@ export const useHoursPage = () => {
|
|||||||
isPresentMorning: false,
|
isPresentMorning: false,
|
||||||
isPresentAfternoon: false
|
isPresentAfternoon: false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSubmitting.value || employees.value.length === 0) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const entries = employees.value
|
||||||
|
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
||||||
|
.map((employee) => {
|
||||||
|
const current = buildEntry(employee, rows.value[employee.id] ?? emptyRow())
|
||||||
|
const original = buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow())
|
||||||
|
return { current, original }
|
||||||
})
|
})
|
||||||
|
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||||
|
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||||
|
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||||
|
.map(({ current }) => current)
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -1126,6 +1202,7 @@ export const useHoursPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -1207,6 +1284,7 @@ export const useHoursPage = () => {
|
|||||||
hasRowFormation,
|
hasRowFormation,
|
||||||
getRowFormationLabel,
|
getRowFormationLabel,
|
||||||
getRowContractNature,
|
getRowContractNature,
|
||||||
|
getRowWorkedDaysLabel,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
@@ -1215,6 +1293,8 @@ export const useHoursPage = () => {
|
|||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
markedDates,
|
||||||
|
onCalendarMonthChange,
|
||||||
isWeekCommentDrawerOpen,
|
isWeekCommentDrawerOpen,
|
||||||
weekCommentContext,
|
weekCommentContext,
|
||||||
openWeekCommentDrawer,
|
openWeekCommentDrawer,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
|
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
|
||||||
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
|
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
|
||||||
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
|
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
|
||||||
|
{ type: 'paragraph', content: 'Pour un contrat à temps partiel avec un planning de jours travaillés (contrat « personnalisé »), les jours travaillés sont rappelés à la suite du libellé site et nature, en abrégé : par exemple « BUREAU — CDI — LU,JE » pour un salarié travaillant le lundi et le jeudi. Les contrats 35h, 39h, forfait et intérim n\'affichent pas ce rappel.' },
|
||||||
{ type: 'paragraph', content: 'Sur la vue Jour, l\'affichage (saisie d\'heures ou cases de 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 (par exemple un passage en forfait), les jours antérieurs à ce changement restent affichés selon l\'ancien contrat.' },
|
{ type: 'paragraph', content: 'Sur la vue Jour, l\'affichage (saisie d\'heures ou cases de 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 (par exemple un passage en forfait), les jours antérieurs à ce changement restent affichés selon l\'ancien contrat.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -84,9 +85,10 @@ export const documentationSections: DocSection[] = [
|
|||||||
{
|
{
|
||||||
id: 'export-heures-jour',
|
id: 'export-heures-jour',
|
||||||
title: 'Exporter les heures (PDF par jour)',
|
title: 'Exporter les heures (PDF par jour)',
|
||||||
requiredLevel: 'admin',
|
requiredLevel: 'site_manager',
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'Le bouton « Exporter », à droite du titre « Heures » (visible uniquement en vue Jour), ouvre un panneau permettant de générer un PDF des heures d\'une journée. Choisissez la date et les sites concernés.' },
|
{ type: 'paragraph', content: 'Le bouton « Exporter », à droite du titre « Heures » (visible uniquement en vue Jour), ouvre un panneau permettant de générer un PDF des heures d\'une journée. Choisissez la date et les sites concernés.' },
|
||||||
|
{ type: 'paragraph', content: 'Les administrateurs peuvent exporter tous les sites. Un chef de site ne voit dans le panneau que ses propres sites et n\'exporte que ceux-ci.' },
|
||||||
{ type: 'paragraph', content: 'Le PDF est organisé par site et reprend les colonnes de la vue Jour (nom, statut, horaires matin/après-midi/soir, jour, nuit, total en gras), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.' },
|
{ type: 'paragraph', content: 'Le PDF est organisé par site et reprend les colonnes de la vue Jour (nom, statut, horaires matin/après-midi/soir, jour, nuit, total en gras), sans la colonne de validation. Les employés sous contrat ce jour-là apparaissent même sans saisie.' },
|
||||||
{ type: 'paragraph', content: 'La colonne Statut affiche le code du type d\'absence (ex. « AT ») sur sa couleur. Une légende sous le tableau associe chaque code présent à son libellé.' },
|
{ type: 'paragraph', content: 'La colonne Statut affiche le code du type d\'absence (ex. « AT ») sur sa couleur. Une légende sous le tableau associe chaque code présent à son libellé.' },
|
||||||
],
|
],
|
||||||
@@ -157,6 +159,17 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'note', content: 'Toute vraie modification effectuée par un administrateur remet automatiquement les deux validations à zéro.' },
|
{ type: 'note', content: 'Toute vraie modification effectuée par un administrateur remet automatiquement les deux validations à zéro.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'calendrier-jours-valides',
|
||||||
|
title: 'Calendrier des jours validés (vue Jour)',
|
||||||
|
requiredLevel: 'site_manager',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'En vue Jour, le sélecteur de date est un calendrier qui colore en vert les jours entièrement validés. Vous repérez ainsi d\'un coup d\'œil les jours où il reste de la validation à faire.' },
|
||||||
|
{ type: 'list', content: 'Vert : le jour porte au moins une ligne et toutes sont validées par un administrateur.\nNeutre (sans couleur) : il reste au moins une ligne à valider, ou aucune ligne n\'a encore été saisie ce jour-là.' },
|
||||||
|
{ type: 'paragraph', content: 'Le vert reflète tout votre périmètre (vos sites), indépendamment du filtre Sites de l\'écran. Les conducteurs ne sont pas pris en compte (écran Heures Conducteurs). Cliquez sur un jour pour vous y rendre.' },
|
||||||
|
{ type: 'note', content: 'La couleur se met à jour automatiquement quand vous validez des lignes, enregistrez des heures ou modifiez une absence. La validation de site (chef de site) ne change pas la couleur : seule la validation RH/admin compte.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -374,8 +387,8 @@ export const documentationSections: DocSection[] = [
|
|||||||
requiredLevel: 'admin',
|
requiredLevel: 'admin',
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
|
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
|
||||||
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus\nINTERIM : aucune récupération, aucun bonus' },
|
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus. Une semaine sous les heures contractuelles réduit le cumul RTT (1h manquante = -1h), sans passer par les tranches 25/50\nINTERIM : aucune récupération, aucun bonus' },
|
||||||
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d\'abord des heures à 50%, puis des heures à 25%.' },
|
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT. Pour un 35h/39h, il est puisé d\'abord dans les heures à 50%, puis à 25%. Pour un contrat CUSTOM (4h, etc.), il réduit directement le cumul (pas de tranches 25/50) ; le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -491,6 +504,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
|
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
|
||||||
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
|
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
|
||||||
|
{ type: 'note', content: 'Les dimanches ne sont jamais comptés comme congés pris. Une période de congé à cheval sur un week-end (par exemple du jeudi au mardi) ne décompte pas le dimanche. Le dimanche reste affiché sur le calendrier mais n\'entre dans aucun compteur.' },
|
||||||
{ type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' },
|
{ type: 'note', content: 'La case « En cours d\'acquisition » affiche deux valeurs : à gauche les jours encore à acquérir (déduction faite des congés déjà posés en anticipé), à droite le total brut acquis sur l\'exercice à ce jour. Exemple : « 14,50 / 17,50 » signifie 17,50 jours acquis dont 3 déjà pris en anticipé.' },
|
||||||
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
|
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter l\'exercice suivant ainsi que les exercices passés. La plage proposée part de l\'exercice suivant (l\'exercice à venir, pour consulter en avance les congés déjà posés) et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
|
||||||
{ type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' },
|
{ type: 'note', content: 'Sur l\'exercice suivant, le calendrier et les congés déjà posés sont exacts, mais les compteurs « Année acquis » et report N-1 sont provisoires : ils dépendent de la clôture de l\'exercice courant et ne se figeront qu\'à cette clôture.' },
|
||||||
@@ -504,7 +518,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
|
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
|
||||||
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
|
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
|
||||||
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
|
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-1 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S15 inclus. Les heures et absences postérieures ne sont pas comptées.' },
|
||||||
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
|
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -535,6 +549,8 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
|
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
|
||||||
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
|
{ type: 'note', content: 'Au passage à l\'exercice suivant (1er juin), le « Report N-1 » du nouvel exercice reprend exactement le « Disponible » de fin d\'exercice précédent, c\'est-à-dire report précédent + acquis − RTT payés. Le report déjà présent en début d\'année n\'est donc jamais perdu.' },
|
||||||
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée − paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
|
||||||
|
{ type: 'note', content: 'Contrats CUSTOM (ex. 4h) : une semaine travaillée sous les heures contractuelles génère un déficit qui réduit le cumul RTT (1h manquante = -1h), sans tranches 25/50. Le cumul peut devenir négatif et est reporté à l\'exercice suivant.' },
|
||||||
|
{ type: 'paragraph', content: 'Jour de solidarité : pour un contrat de moins de 35h, le Lundi de Pentecôte applique un déficit fixe proportionnel (7/35 des heures hebdomadaires, soit 12 minutes par heure : 4h donne 48 min). Ce déficit réduit le cumul RTT, quel que soit ce qui est saisi ce jour-là. Un salarié qui ne travaille pas le lundi n\'est pas concerné : aucun déficit ne lui est imputé.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -637,6 +653,21 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
|
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées (en-tête fusionné scindé en deux sous-colonnes 25 % et 50 %), congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
|
||||||
{ type: 'note', content: 'Seuls les salariés ayant un contrat couvrant tout ou partie du mois apparaissent : un salarié dont le contrat est terminé (ex. parti en février) n\'est pas listé sur le récap des mois suivants.' },
|
{ type: 'note', content: 'Seuls les salariés ayant un contrat couvrant tout ou partie du mois apparaissent : un salarié dont le contrat est terminé (ex. parti en février) n\'est pas listé sur le récap des mois suivants.' },
|
||||||
{ type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' },
|
{ type: 'note', content: 'Forfait : un congé imputé sur le stock de l\'année précédente (N-1) n\'apparaît pas dans la colonne congés et compte comme un jour de présence. Le budget N-1 est consommé dans l\'ordre chronologique depuis janvier, de façon cohérente avec la fiche employé (les jours payés réduisent le stock N-1 d\'abord). Au-delà du budget N-1, les congés s\'affichent normalement.' },
|
||||||
|
{ type: 'note', content: 'Un congé posé un dimanche n\'est jamais décompté comme congé pris (colonne congés), comme partout ailleurs dans l\'application. Vous pouvez donc poser une période à cheval sur un week-end (par exemple du jeudi au mardi) sans « perdre » le dimanche. Le dimanche reste visible sur le calendrier et son impression.' },
|
||||||
|
{ type: 'note', content: 'Export « Contingent H.nuit » : depuis la liste des employés, bouton Export → « Contingent H.nuit » + année. Génère un PDF A4 paysage avec une ligne par employé (groupés par site) et une colonne par mois, chacune avec le total d\'heures de nuit (travail entre 21h et 6h) et le nombre de nuits (jours où au moins 4h ont été travaillées de nuit). Les conducteurs utilisent leurs heures de nuit saisies.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contingent-heures-supp',
|
||||||
|
title: 'Export Contingent H.supp.',
|
||||||
|
requiredLevel: 'admin',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'L\'encart « Total H.payés {année} : X h / plafond h », affiché dans l\'en-tête de la fiche d\'un employé non-forfait, indique le total d\'heures supplémentaires payées sur l\'année civile en cours face au plafond légal. Il passe en rouge si ce plafond est dépassé.' },
|
||||||
|
{ type: 'list', content: 'Plafond chauffeur (contrat courant « conducteur ») : 350 h\nPlafond autres salariés non-forfait : 220 h\nSeuls les employés non-forfait disposent de cet encart (FORFAIT exclus)' },
|
||||||
|
{ type: 'paragraph', content: 'L\'export PDF « Contingent H.supp. » est accessible depuis la liste des employés, via le bouton Export → option « Contingent H.supp. ». Choisissez l\'année civile (par défaut l\'année courante) et éventuellement des sites ; sans sélection de site, tous les sites de votre périmètre sont inclus.' },
|
||||||
|
{ type: 'list', content: 'PDF A4 paysage, une ligne par employé non-forfait, groupé par site\nTri : ordre d\'affichage du site, puis nom, puis prénom\nColonnes : Janv à Déc (heures payées par mois) + colonne « Total payé / payable »\nLes employés FORFAIT n\'apparaissent pas dans cet export' },
|
||||||
|
{ type: 'note', content: 'Les heures prises en compte sont les bases payées (25 % et 50 % confondus), hors majorations. Le contingent est calculé sur l\'année civile (janvier–décembre), indépendamment de l\'exercice RTT (juin–mai) : un paiement RTT saisi pour le mois de juin est rattaché à l\'année civile précédente.' },
|
||||||
|
{ type: 'note', content: 'Heures structurelles : les heures contractuelles au-delà de 35 h (ex. un contrat 39 h) sont des heures supplémentaires payées chaque mois, indépendamment des paiements RTT. Elles sont automatiquement ajoutées au contingent : (heures hebdo − 35) × 52 / 12 par mois, soit 17,33 h/mois pour un 39 h (proratisé aux jours réellement sous contrat). Les contrats forfait, intérim et ≤ 35 h n\'en génèrent pas.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+833
-5
@@ -7,7 +7,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.4.6",
|
"@malio/layer-ui": "^1.7.15",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
@@ -1196,6 +1196,31 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.7.5",
|
||||||
|
"@floating-ui/utils": "^0.2.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||||
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -2222,14 +2247,22 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.4.6",
|
"version": "1.7.15",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.6/layer-ui-1.4.6.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.15/layer-ui-1.7.15.tgz",
|
||||||
"integrity": "sha512-stHqUAJ8E6a62Ka7QXlE177GhkIsjtmYNa/tNk1TVpbJ099okfLLivrlofEl7CCAqDeMaIepnW4q0vxJT+EFEA==",
|
"integrity": "sha512-CgEC0l2pkR6rlzpi1zZqswHs+/yGTSd861tdT678/wSKtQPQ6JxUIf63ugFDItyvyLW+nbcNWuHTFC2Bimp1EQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"@tiptap/extension-color": "^3.22.5",
|
||||||
|
"@tiptap/extension-highlight": "^3.22.5",
|
||||||
|
"@tiptap/extension-placeholder": "^3.22.5",
|
||||||
|
"@tiptap/extension-text-style": "^3.22.5",
|
||||||
|
"@tiptap/pm": "^3.22.5",
|
||||||
|
"@tiptap/starter-kit": "^3.22.5",
|
||||||
|
"@tiptap/vue-3": "^3.22.5",
|
||||||
"maska": "^3.2.0",
|
"maska": "^3.2.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tiptap-markdown": "^0.9.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"nuxt": "^4.0.0"
|
"nuxt": "^4.0.0"
|
||||||
@@ -5323,6 +5356,480 @@
|
|||||||
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/core": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-WaKjKmUaadgvZDDBk9JOn/oidlOFr6booqJIWHGL5S0aUUTKHS19oGfKQq/l9Z1y1niaRePk0Y4fy/jxCnfKPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bold": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bubble-menu": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-Y3R9wFKP/U9M04JG+0PM/yW3OV+MSbUp6YBKQWZmUu8x6y7TbcNvDsaJ6QEFZt5aRMS6qH1ksYPTOz47JdjcfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-bullet-list": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-JB6bEJJHxXNAXEXTIAN3/j70p1ARHdeMfhzshGZswWKUWtDibTCrspIp7p1VNeiuVtJ/HB6PpFkGi7yWtQ3RTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-t9/VR5k3rGPyhcGau9YvVgaAQ+nP9R9WzS996bQQ7GIrMOTSXb0FWwoQFBiYl83V6VA16Tlj/oScC7SFlA8lvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-code-block": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-color": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-f8yy+CBRDqMeaIaQ0UHDbGUqjfGka5O16ja47shatXm49lqLcL06js9tGoiZFVzp9/lcKOLSXjuzxNf0OZ9SbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-text-style": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-document": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-dropcursor": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-floating-menu": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-gapcursor": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-BWW1yMQQA4TbEU0LLK+4cd9ebLTuZG5KjHwFMBRD/bGiRW9V1gTWFsCqThBbczcANoQiZK9pn5/4Ad/rGM3HUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-hard-break": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-gzNb1e/fK6HN+ko1axsrasjK7F1q0Bnm0G4ZY/0eq7pV7s1wZuwoCiGbvUx/9LCFKRV6+94FTqlb0A3NbYN36g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-heading": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-eRlv9XxzUL8FobKAiF1WjP35CT2QpbcxxeyYFF7BmGEONvKI7r5g7JGwyGli4Cvclh70h8w6JuoXSmGUVEU65A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-highlight": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-qgx4Eetqkogh3OyomZO0yIMQGHhjatCDAtELkC7NQxnmPsp2c9i6ck/hh7mP5We5ccBwUoYRuNGC9lkflCx75g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-l9lPZYeSmY90y/2GkQcKaICFD5Atr8sx2SzJGkQzpNC9tRxZXyAHnfJE3OjBkspuGzjWIN0DimxBj4ibz58sKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-italic": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-link": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-aLLGLgikuhLFHRbjfUC6D4gRg+NUty4uhW7YkyVl8AxxPME47dPbCOX4H6uLCjEZcn3WnfNuCTr6HCTl0KEmGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"linkifyjs": "^4.3.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-item": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-5gLXJUiP763NA6i4HgrtcwUDXPP8820hsaBQyF1Y1VsXNi02uW9FVLe3RZK8jF0NZUNh9CqD0gogYJCbKOUU8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-list-keymap": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-ordered-list": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-list": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-paragraph": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-placeholder": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-oJCEVmaaUY1Jn5v8KbRMdgYLFH9aptLkir+M0ZMnl+8TTmvMdLK2H02X9ofZQwAb12qreQgb890hB3PFen7TDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extensions": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-strike": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-7hmQ2mBsA+75GRrJIKYxb+10H23mblEQSGGsv9Ptl7JLaGmj+8sv2HGQGSUT9QBiBVprxaYTqyWFXQC9akfLWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-text-style": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-V8t7qOyLH7IBR2HjjJVZ9rUHTnuFToJx07L9PN9PpgQLhz9q8Jah4gAwmjLBXDRG2YaXImGK0RwKKCU/yHhwOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-underline": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extensions": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/pm": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
"prosemirror-commands": "^1.6.2",
|
||||||
|
"prosemirror-dropcursor": "^1.8.1",
|
||||||
|
"prosemirror-gapcursor": "^1.3.2",
|
||||||
|
"prosemirror-history": "^1.4.1",
|
||||||
|
"prosemirror-inputrules": "^1.4.0",
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.7",
|
||||||
|
"prosemirror-schema-list": "^1.5.0",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-tables": "^1.8.0",
|
||||||
|
"prosemirror-transform": "^1.12.0",
|
||||||
|
"prosemirror-view": "^1.41.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-A0zsvwGU9exLND34F8e8KqUXFSfs835tNN+VC+ZT3yNeaO/WXnlh/Cgal1F6pHHbcxy7RV2CRwJU5S3cWLPxrA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/core": "^3.26.1",
|
||||||
|
"@tiptap/extension-blockquote": "^3.26.1",
|
||||||
|
"@tiptap/extension-bold": "^3.26.1",
|
||||||
|
"@tiptap/extension-bullet-list": "^3.26.1",
|
||||||
|
"@tiptap/extension-code": "^3.26.1",
|
||||||
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
|
"@tiptap/extension-document": "^3.26.1",
|
||||||
|
"@tiptap/extension-dropcursor": "^3.26.1",
|
||||||
|
"@tiptap/extension-gapcursor": "^3.26.1",
|
||||||
|
"@tiptap/extension-hard-break": "^3.26.1",
|
||||||
|
"@tiptap/extension-heading": "^3.26.1",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^3.26.1",
|
||||||
|
"@tiptap/extension-italic": "^3.26.1",
|
||||||
|
"@tiptap/extension-link": "^3.26.1",
|
||||||
|
"@tiptap/extension-list": "^3.26.1",
|
||||||
|
"@tiptap/extension-list-item": "^3.26.1",
|
||||||
|
"@tiptap/extension-list-keymap": "^3.26.1",
|
||||||
|
"@tiptap/extension-ordered-list": "^3.26.1",
|
||||||
|
"@tiptap/extension-paragraph": "^3.26.1",
|
||||||
|
"@tiptap/extension-strike": "^3.26.1",
|
||||||
|
"@tiptap/extension-text": "^3.26.1",
|
||||||
|
"@tiptap/extension-underline": "^3.26.1",
|
||||||
|
"@tiptap/extensions": "^3.26.1",
|
||||||
|
"@tiptap/pm": "^3.26.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/vue-3": {
|
||||||
|
"version": "3.26.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-3.26.1.tgz",
|
||||||
|
"integrity": "sha512-ihhAYUeOpAQqtY7NcgBFQoIrB5zaB4rYr81dqsfqoqjbnUv5cfDWLIeMQKuXoisqk312IVpvz6Ut+y9fCyIvhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tiptap/extension-bubble-menu": "^3.26.1",
|
||||||
|
"@tiptap/extension-floating-menu": "^3.26.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@floating-ui/dom": "^1.0.0",
|
||||||
|
"@tiptap/core": "3.26.1",
|
||||||
|
"@tiptap/pm": "3.26.1",
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -5346,6 +5853,28 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "13.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
|
||||||
|
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^3",
|
||||||
|
"@types/mdurl": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/parse-path": {
|
"node_modules/@types/parse-path": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||||
@@ -9466,6 +9995,31 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkifyjs": {
|
||||||
|
"version": "4.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||||
|
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/listhen": {
|
"node_modules/listhen": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/listhen/-/listhen-1.9.0.tgz",
|
||||||
@@ -9640,6 +10194,51 @@
|
|||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"linkify-it": "^5.0.1",
|
||||||
|
"mdurl": "^2.0.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it-task-lists": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/maska": {
|
"node_modules/maska": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz",
|
||||||
@@ -9661,6 +10260,12 @@
|
|||||||
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@@ -10444,6 +11049,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/oxc-minify": {
|
"node_modules/oxc-minify": {
|
||||||
"version": "0.110.0",
|
"version": "0.110.0",
|
||||||
"resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.110.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.110.0.tgz",
|
||||||
@@ -11527,6 +12138,178 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-changeset": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-dropcursor": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0",
|
||||||
|
"prosemirror-view": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-gapcursor": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.0.0",
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-inputrules": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.0.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown/node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown/node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||||
|
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-markdown/node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
|
||||||
|
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.41.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
|
||||||
|
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.8",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/protocols": {
|
"node_modules/protocols": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz",
|
||||||
@@ -11543,6 +12326,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quansync": {
|
"node_modules/quansync": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||||
@@ -11948,6 +12740,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rou3": {
|
"node_modules/rou3": {
|
||||||
"version": "0.7.12",
|
"version": "0.7.12",
|
||||||
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
|
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
|
||||||
@@ -12918,6 +13716,24 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiptap-markdown": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"example"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^13.0.7",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
|
"prosemirror-markdown": "^1.11.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -13077,6 +13893,12 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ufo": {
|
"node_modules/ufo": {
|
||||||
"version": "1.6.3",
|
"version": "1.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||||
@@ -13950,6 +14772,12 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@malio/layer-ui": "^1.7.15",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@malio/layer-ui": "^1.4.6",
|
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"nuxt": "^4.3.0",
|
"nuxt": "^4.3.0",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="flex items-center justify-between pb-6">
|
<div class="flex items-center justify-between pb-6">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Ajouter un type"
|
label="Ajouter"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
@@ -55,16 +55,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<MalioDrawer v-model="isDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">{{ drawerTitle }}</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
label="Code *"
|
label="Code *"
|
||||||
group-class="mt-2"
|
group-class="mt-2"
|
||||||
:max-length="10"
|
:max-length="10"
|
||||||
:error="showCodeError ? 'Le code est obligatoire.' : ''"
|
:error="showCodeError ? 'Le code est obligatoire.' : ''"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
label="Libellé *"
|
label="Libellé *"
|
||||||
group-class="mt-2"
|
group-class="mt-2"
|
||||||
|
|||||||
+206
-218
@@ -1,254 +1,242 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col overflow-hidden">
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1>
|
<div class="flex items-center justify-between pb-6">
|
||||||
|
<h1 class="text-4xl font-bold text-primary-500">Journal des actions</h1>
|
||||||
<div class="flex items-end gap-4 pb-6 flex-wrap">
|
<MalioButton
|
||||||
<div>
|
variant="tertiary"
|
||||||
<label class="text-md font-semibold text-neutral-700">Employé</label>
|
:label="filterButtonLabel"
|
||||||
<select
|
icon-name="mdi:tune"
|
||||||
v-model="filters.employeeId"
|
@click="list.openFilters()"
|
||||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
>
|
|
||||||
<option :value="undefined">Tous</option>
|
|
||||||
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
|
|
||||||
{{ emp.lastName }} {{ emp.firstName }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700">Du</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.from"
|
|
||||||
type="date"
|
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700">Au</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.to"
|
|
||||||
type="date"
|
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-md font-semibold text-neutral-700">Type</label>
|
|
||||||
<select
|
|
||||||
v-model="filters.entityType"
|
|
||||||
class="mt-2 h-[42px] w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
>
|
|
||||||
<option :value="undefined">Tous</option>
|
|
||||||
<option value="work_hour">Heures</option>
|
|
||||||
<option value="absence">Absences</option>
|
|
||||||
<option value="employee">Employé</option>
|
|
||||||
<option value="contract_suspension">Suspension</option>
|
|
||||||
<option value="rtt_payment">Paiement RTT</option>
|
|
||||||
<option value="fractioned_days">Jours fractionnés</option>
|
|
||||||
<option value="paid_leave_days">Congés N-1 payés</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="search"
|
|
||||||
>
|
|
||||||
Rechercher
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
Chargement...
|
<MalioDataTable
|
||||||
</div>
|
:columns="columns"
|
||||||
|
:items="list.items.value"
|
||||||
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
:total-items="list.total.value"
|
||||||
Aucune entrée trouvée.
|
:page="list.page.value"
|
||||||
</div>
|
:per-page="list.perPage.value"
|
||||||
|
:per-page-options="[10, 25, 50, 100]"
|
||||||
<template v-else>
|
empty-message="Aucune entrée trouvée."
|
||||||
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
|
@row-click="openDetail"
|
||||||
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
@update:page="list.goToPage($event)"
|
||||||
<span>Date action</span>
|
@update:per-page="list.setPerPage($event)"
|
||||||
<span>Utilisateur</span>
|
|
||||||
<span>Action</span>
|
|
||||||
<span>Type</span>
|
|
||||||
<span>Employé</span>
|
|
||||||
<span>Description</span>
|
|
||||||
<span>Date affectée</span>
|
|
||||||
</div>
|
|
||||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
|
||||||
<template v-for="log in logs" :key="log.id">
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] 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"
|
|
||||||
@click="toggleExpand(log.id)"
|
|
||||||
>
|
>
|
||||||
<span>{{ formatDateTime(log.createdAt) }}</span>
|
<template #cell-createdAt="{ item }">
|
||||||
<span>{{ log.username }}</span>
|
{{ formatDateTime((item as AuditLog).createdAt) }}
|
||||||
<span>
|
|
||||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
|
|
||||||
{{ actionLabel(log.action) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span>{{ entityTypeLabel(log.entityType) }}</span>
|
|
||||||
<span>{{ log.employeeName ?? '-' }}</span>
|
|
||||||
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
|
|
||||||
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="expandedIds.has(log.id)"
|
|
||||||
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
|
|
||||||
>
|
|
||||||
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
|
|
||||||
<div v-if="log.changes.old">
|
|
||||||
<p class="font-bold text-red-600 mb-2">Ancien</p>
|
|
||||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
|
|
||||||
</div>
|
|
||||||
<div v-if="log.changes.new">
|
|
||||||
<p class="font-bold text-green-600 mb-2">Nouveau</p>
|
|
||||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
<template #cell-action="{ item }">
|
||||||
|
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass((item as AuditLog).action)">
|
||||||
|
{{ actionLabel((item as AuditLog).action) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-entityType="{ item }">
|
||||||
|
{{ entityTypeLabel((item as AuditLog).entityType) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-employeeName="{ item }">
|
||||||
|
{{ (item as AuditLog).employeeName ?? '—' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-deviceLabel="{ item }">
|
||||||
|
{{ (item as AuditLog).deviceLabel ?? '—' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-description="{ item }">
|
||||||
|
<span class="block max-w-[320px] truncate" :title="(item as AuditLog).description">{{ (item as AuditLog).description }}</span>
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-4">
|
<!-- Filter drawer -->
|
||||||
<p class="text-md text-neutral-500">
|
<MalioDrawer
|
||||||
{{ total }} résultat{{ total > 1 ? 's' : '' }} — page {{ currentPage }}/{{ totalPages }}
|
v-model="list.filterOpen.value"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Filtres</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<MalioAccordionItem title="Période" value="period">
|
||||||
|
<MalioDateRange v-model="list.draftRange.value" clearable />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="Employé" value="employee">
|
||||||
|
<MalioInputText v-model="list.draftEmployee.value" icon-name="mdi:magnify" />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="Type d'entité" value="entityType">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in entityTypeOptions"
|
||||||
|
:id="`filter-type-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="list.draftEntityTypes.value.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => list.toggleEntityType(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="Action" value="action">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in actionOptions"
|
||||||
|
:id="`filter-action-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="list.draftActions.value.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => list.toggleAction(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="Utilisateur / compte" value="username">
|
||||||
|
<MalioInputText v-model="list.draftUsername.value" icon-name="mdi:magnify" />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="IP" value="ip">
|
||||||
|
<MalioInputText v-model="list.draftIp.value" icon-name="mdi:magnify" />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<MalioAccordionItem title="Appareil" value="device">
|
||||||
|
<MalioInputText v-model="list.draftDevice.value" icon-name="mdi:magnify" />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton variant="tertiary" label="Réinitialiser" @click="list.resetFilters()" />
|
||||||
|
<MalioButton variant="primary" label="Appliquer" button-class="w-[170px]" @click="list.applyFilters()" />
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
|
||||||
|
<!-- Detail drawer -->
|
||||||
|
<MalioDrawer v-model="detailOpen" drawer-class="max-w-xl">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Détail de l'action</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="selected" class="space-y-6 text-md text-primary-500">
|
||||||
|
<section class="space-y-1">
|
||||||
|
<p><span class="font-semibold">Utilisateur :</span> {{ selected.username }}</p>
|
||||||
|
<p><span class="font-semibold">Employé :</span> {{ selected.employeeName ?? '—' }}</p>
|
||||||
|
<p><span class="font-semibold">Date action :</span> {{ formatDateTime(selected.createdAt) }}</p>
|
||||||
|
<p><span class="font-semibold">Date affectée :</span> {{ selected.affectedDate ? formatDate(selected.affectedDate) : '—' }}</p>
|
||||||
|
<p>
|
||||||
|
<span class="font-semibold">Action :</span>
|
||||||
|
<span class="ml-1 rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(selected.action)">{{ actionLabel(selected.action) }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3">
|
<p><span class="font-semibold">Type :</span> {{ entityTypeLabel(selected.entityType) }}</p>
|
||||||
<button
|
</section>
|
||||||
type="button"
|
|
||||||
:disabled="currentPage <= 1"
|
<section class="space-y-1">
|
||||||
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
<h3 class="font-bold">Contexte technique</h3>
|
||||||
@click="goToPage(currentPage - 1)"
|
<p><span class="font-semibold">IP :</span> {{ selected.ipAddress ?? '—' }}</p>
|
||||||
>
|
<p><span class="font-semibold">Appareil :</span> {{ selected.deviceLabel ?? '—' }}</p>
|
||||||
Précédent
|
<p><span class="font-semibold">User-Agent :</span> <span class="break-all text-sm font-normal">{{ selected.userAgent ?? '—' }}</span></p>
|
||||||
</button>
|
<p><span class="font-semibold">Device id :</span> <span class="break-all text-sm font-normal">{{ selected.deviceId ?? '—' }}</span></p>
|
||||||
<button
|
</section>
|
||||||
type="button"
|
|
||||||
:disabled="currentPage >= totalPages"
|
<section class="space-y-1">
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
<h3 class="font-bold">Changements</h3>
|
||||||
@click="goToPage(currentPage + 1)"
|
<div v-if="changeRows.length > 0" class="space-y-1">
|
||||||
>
|
<div v-for="row in changeRows" :key="row.key" class="text-sm">
|
||||||
Suivant
|
<span class="font-semibold">{{ row.key }} :</span>
|
||||||
</button>
|
<span class="text-red-600">{{ row.old }}</span>
|
||||||
|
<span class="px-1">→</span>
|
||||||
|
<span class="text-green-600">{{ row.new }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import type { AuditLog } from '~/services/dto/audit-log'
|
import type { AuditLog } from '~/services/dto/audit-log'
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import { useAuditLogsList } from '~/composables/useAuditLogsList'
|
||||||
import { fetchAuditLogs } from '~/services/audit-logs'
|
|
||||||
import { listEmployees } from '~/services/employees'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'super-admin'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
definePageMeta({ middleware: 'super-admin' })
|
||||||
useHead({ title: 'Journal des actions' })
|
useHead({ title: 'Journal des actions' })
|
||||||
|
|
||||||
const logs = ref<AuditLog[]>([])
|
const list = useAuditLogsList()
|
||||||
const employees = ref<Employee[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const expandedIds = ref(new Set<number>())
|
|
||||||
const total = ref(0)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const perPage = ref(50)
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
|
const columns = [
|
||||||
|
{ key: 'createdAt', label: 'Date action' },
|
||||||
|
{ key: 'username', label: 'Utilisateur' },
|
||||||
|
{ key: 'action', label: 'Action' },
|
||||||
|
{ key: 'entityType', label: 'Type' },
|
||||||
|
{ key: 'employeeName', label: 'Employé' },
|
||||||
|
{ key: 'deviceLabel', label: 'Appareil' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
]
|
||||||
|
|
||||||
const filters = reactive<{
|
const entityTypeOptions = [
|
||||||
employeeId?: number
|
{ value: 'work_hour', label: 'Heures' },
|
||||||
from?: string
|
{ value: 'absence', label: 'Absence' },
|
||||||
to?: string
|
{ value: 'employee', label: 'Employé' },
|
||||||
entityType?: string
|
{ value: 'contract_suspension', label: 'Suspension' },
|
||||||
}>({})
|
{ value: 'rtt_payment', label: 'RTT' },
|
||||||
|
{ value: 'fractioned_days', label: 'Fract.' },
|
||||||
|
{ value: 'paid_leave_days', label: 'Congés payés' },
|
||||||
|
{ value: 'week_comment', label: 'Commentaire' },
|
||||||
|
]
|
||||||
|
|
||||||
const loadLogs = async (page = 1) => {
|
const actionOptions = [
|
||||||
isLoading.value = true
|
{ value: 'create', label: 'Créer' },
|
||||||
try {
|
{ value: 'update', label: 'Modifier' },
|
||||||
const result = await fetchAuditLogs({ ...filters, page })
|
{ value: 'delete', label: 'Supprimer' },
|
||||||
logs.value = result.items
|
{ value: 'validate', label: 'Valider' },
|
||||||
total.value = result.total
|
{ value: 'site_validate', label: 'Valider (site)' },
|
||||||
currentPage.value = result.page
|
]
|
||||||
perPage.value = result.perPage
|
|
||||||
expandedIds.value.clear()
|
const filterButtonLabel = computed(() =>
|
||||||
} finally {
|
list.activeFilterCount.value > 0 ? `Filtrer (${list.activeFilterCount.value})` : 'Filtrer',
|
||||||
isLoading.value = false
|
)
|
||||||
}
|
|
||||||
|
// Detail drawer
|
||||||
|
const detailOpen = ref(false)
|
||||||
|
const selected = ref<AuditLog | null>(null)
|
||||||
|
|
||||||
|
const openDetail = (item: Record<string, unknown>) => {
|
||||||
|
selected.value = item as unknown as AuditLog
|
||||||
|
detailOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = () => {
|
const changeRows = computed(() => {
|
||||||
loadLogs(1)
|
const c = selected.value?.changes
|
||||||
}
|
if (!c) return []
|
||||||
|
const keys = new Set<string>([...Object.keys(c.old ?? {}), ...Object.keys(c.new ?? {})])
|
||||||
const goToPage = (page: number) => {
|
return [...keys].map(key => ({
|
||||||
if (page >= 1 && page <= totalPages.value) {
|
key,
|
||||||
loadLogs(page)
|
old: c.old?.[key] === undefined ? '—' : JSON.stringify(c.old[key]),
|
||||||
}
|
new: c.new?.[key] === undefined ? '—' : JSON.stringify(c.new[key]),
|
||||||
}
|
}))
|
||||||
|
})
|
||||||
const toggleExpand = (id: number) => {
|
|
||||||
if (expandedIds.value.has(id)) {
|
|
||||||
expandedIds.value.delete(id)
|
|
||||||
} else {
|
|
||||||
expandedIds.value.add(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDateTime = (dt: string) => {
|
const formatDateTime = (dt: string) => {
|
||||||
const d = new Date(dt)
|
const d = new Date(dt)
|
||||||
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (d: string) => {
|
const formatDate = (d: string) => d.split('-').reverse().join('/')
|
||||||
return d.split('-').reverse().join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionLabel = (action: string): string => {
|
const actionLabel = (action: string): string => ({
|
||||||
const map: Record<string, string> = {
|
create: 'Créer', update: 'Modifier', delete: 'Suppr.', validate: 'Valid.', site_validate: 'Valid. site',
|
||||||
create: 'Créer',
|
}[action] ?? action)
|
||||||
update: 'Modifier',
|
|
||||||
delete: 'Suppr.',
|
|
||||||
validate: 'Valid.',
|
|
||||||
site_validate: 'Valid. site',
|
|
||||||
}
|
|
||||||
return map[action] ?? action
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionClass = (action: string): string => {
|
const actionClass = (action: string): string => ({
|
||||||
const map: Record<string, string> = {
|
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500', validate: 'bg-purple-500', site_validate: 'bg-indigo-500',
|
||||||
create: 'bg-green-500',
|
}[action] ?? 'bg-neutral-500')
|
||||||
update: 'bg-blue-500',
|
|
||||||
delete: 'bg-red-500',
|
|
||||||
validate: 'bg-purple-500',
|
|
||||||
site_validate: 'bg-indigo-500',
|
|
||||||
}
|
|
||||||
return map[action] ?? 'bg-neutral-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityTypeLabel = (type: string): string => {
|
const entityTypeLabel = (type: string): string => ({
|
||||||
const map: Record<string, string> = {
|
work_hour: 'Heures', absence: 'Absence', employee: 'Employé', contract_suspension: 'Suspension',
|
||||||
work_hour: 'Heures',
|
rtt_payment: 'RTT', fractioned_days: 'Fract.', paid_leave_days: 'Congés payés', week_comment: 'Commentaire',
|
||||||
absence: 'Absence',
|
}[type] ?? type)
|
||||||
employee: 'Employé',
|
|
||||||
contract_suspension: 'Suspension',
|
|
||||||
rtt_payment: 'RTT',
|
|
||||||
fractioned_days: 'Fract.',
|
|
||||||
paid_leave_days: 'Congés payés',
|
|
||||||
}
|
|
||||||
return map[type] ?? type
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => { list.init() })
|
||||||
employees.value = await listEmployees()
|
|
||||||
await loadLogs()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3 py-6">
|
<div class="flex flex-col gap-3 py-6">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox :reserve-message-space="false"
|
||||||
v-model="selectedSiteIds"
|
v-model="selectedSiteIds"
|
||||||
:options="siteOptions"
|
:options="siteOptions"
|
||||||
label="Sites"
|
label="Sites"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Ajouter une absence"
|
label="Ajouter"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
@click="openCreateFromToday"
|
@click="openCreateFromToday"
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-80">
|
<div class="w-80">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="employeeFilter"
|
v-model="employeeFilter"
|
||||||
label="Recherche d'un employé"
|
label="Recherche d'un employé"
|
||||||
icon-name="mdi:magnify"
|
icon-name="mdi:magnify"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
:sites="sites"
|
:sites="sites"
|
||||||
:absence-types="absenceTypes"
|
:absence-types="absenceTypes"
|
||||||
:formatted-selected-date="formattedSelectedDate"
|
:formatted-selected-date="formattedSelectedDate"
|
||||||
|
:show-validation-calendar="true"
|
||||||
|
:marked-dates="markedDates"
|
||||||
:shortcut-button-class="shortcutButtonClass"
|
:shortcut-button-class="shortcutButtonClass"
|
||||||
:week-shortcut-button-class="weekShortcutButtonClass"
|
:week-shortcut-button-class="weekShortcutButtonClass"
|
||||||
:get-week-shortcut-label="getWeekShortcutLabel"
|
:get-week-shortcut-label="getWeekShortcutLabel"
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
@set-this-week="setThisWeek"
|
@set-this-week="setThisWeek"
|
||||||
@set-next-week="setNextWeek"
|
@set-next-week="setNextWeek"
|
||||||
@shift-date="shiftDate"
|
@shift-date="shiftDate"
|
||||||
|
@month-change="onCalendarMonthChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
@@ -193,6 +196,8 @@ const {
|
|||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
selectedHolidayLabel,
|
selectedHolidayLabel,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
markedDates,
|
||||||
|
onCalendarMonthChange,
|
||||||
isWeekCommentDrawerOpen,
|
isWeekCommentDrawerOpen,
|
||||||
weekCommentContext,
|
weekCommentContext,
|
||||||
openWeekCommentDrawer,
|
openWeekCommentDrawer,
|
||||||
|
|||||||
@@ -28,10 +28,15 @@
|
|||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}{{ nonForfaitPresenceLabel }}</p>
|
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}{{ nonForfaitPresenceLabel }}</p>
|
||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
|
<p
|
||||||
|
v-if="overtimeContingentLabel"
|
||||||
|
class="text-[16px] font-semibold"
|
||||||
|
:class="overtimeContingentExceeded ? 'text-red-600' : ''"
|
||||||
|
>{{ overtimeContingentLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
label="Contrat"
|
label="Contrat"
|
||||||
:model-value="selectedPhase?.id ?? null"
|
:model-value="selectedPhase?.id ?? null"
|
||||||
:options="phaseOptions"
|
:options="phaseOptions"
|
||||||
@@ -300,6 +305,8 @@ const {
|
|||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
forfaitRemainingDaysLabel,
|
forfaitRemainingDaysLabel,
|
||||||
nonForfaitPresenceLabel,
|
nonForfaitPresenceLabel,
|
||||||
|
overtimeContingentLabel,
|
||||||
|
overtimeContingentExceeded,
|
||||||
contractForm,
|
contractForm,
|
||||||
createContractForm,
|
createContractForm,
|
||||||
isContractDrawerOpen,
|
isContractDrawerOpen,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
@click="openExportDrawer"
|
@click="openExportDrawer"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Ajouter un employé"
|
label="Ajouter"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
@@ -21,14 +21,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 py-7">
|
<div class="flex items-center gap-3 py-7">
|
||||||
<div class="w-80">
|
<div class="w-80">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="employeeFilter"
|
v-model="employeeFilter"
|
||||||
label="Recherche d'un employé"
|
label="Recherche d'un employé"
|
||||||
icon-name="mdi:magnify"
|
icon-name="mdi:magnify"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sites.length > 0" class="relative z-50 w-80">
|
<div v-if="sites.length > 0" class="relative z-50 w-80">
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox :reserve-message-space="false"
|
||||||
v-model="selectedSiteIds"
|
v-model="selectedSiteIds"
|
||||||
:options="siteOptions"
|
:options="siteOptions"
|
||||||
groupClass="w-80"
|
groupClass="w-80"
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
v-model="contractStatusFilter"
|
v-model="contractStatusFilter"
|
||||||
label="Statut contrat"
|
label="Statut contrat"
|
||||||
:options="contractStatusOptions"
|
:options="contractStatusOptions"
|
||||||
@@ -84,21 +84,24 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<MalioDrawer v-model="isDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">{{ drawerTitle }}</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="form.firstName"
|
v-model="form.firstName"
|
||||||
label="Prénom *"
|
label="Prénom *"
|
||||||
group-class="mt-2"
|
group-class="mt-2"
|
||||||
:error="showFirstNameError ? 'Le prénom est obligatoire.' : ''"
|
:error="showFirstNameError ? 'Le prénom est obligatoire.' : ''"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="form.lastName"
|
v-model="form.lastName"
|
||||||
label="Nom *"
|
label="Nom *"
|
||||||
group-class="mt-2"
|
group-class="mt-2"
|
||||||
:error="showLastNameError ? 'Le nom est obligatoire.' : ''"
|
:error="showLastNameError ? 'Le nom est obligatoire.' : ''"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="form.siteId === '' ? null : form.siteId"
|
:model-value="form.siteId === '' ? null : form.siteId"
|
||||||
:options="formSiteOptions"
|
:options="formSiteOptions"
|
||||||
label="Site *"
|
label="Site *"
|
||||||
@@ -107,7 +110,7 @@
|
|||||||
@update:model-value="(v) => { form.siteId = v === null ? '' : Number(v) }"
|
@update:model-value="(v) => { form.siteId = v === null ? '' : Number(v) }"
|
||||||
/>
|
/>
|
||||||
<template v-if="!editingEmployee">
|
<template v-if="!editingEmployee">
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="form.contractNature"
|
:model-value="form.contractNature"
|
||||||
:options="contractNatureFormOptions"
|
:options="contractNatureFormOptions"
|
||||||
label="Type de contrat *"
|
label="Type de contrat *"
|
||||||
@@ -115,7 +118,7 @@
|
|||||||
:error="showContractNatureError ? 'Le type de contrat est obligatoire.' : ''"
|
:error="showContractNatureError ? 'Le type de contrat est obligatoire.' : ''"
|
||||||
@update:model-value="(v) => { if (v !== null) form.contractNature = v as 'CDI' | 'CDD' | 'INTERIM' }"
|
@update:model-value="(v) => { if (v !== null) form.contractNature = v as 'CDI' | 'CDD' | 'INTERIM' }"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
v-if="form.contractNature === 'INTERIM'"
|
v-if="form.contractNature === 'INTERIM'"
|
||||||
:model-value="form.interimAgencyId === '' ? null : form.interimAgencyId"
|
:model-value="form.interimAgencyId === '' ? null : form.interimAgencyId"
|
||||||
:options="interimAgencyOptions"
|
:options="interimAgencyOptions"
|
||||||
@@ -123,7 +126,7 @@
|
|||||||
min-width=""
|
min-width=""
|
||||||
@update:model-value="(v) => { form.interimAgencyId = v === null ? '' : Number(v) }"
|
@update:model-value="(v) => { form.interimAgencyId = v === null ? '' : Number(v) }"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="form.contractId === '' ? null : form.contractId"
|
:model-value="form.contractId === '' ? null : form.contractId"
|
||||||
:options="contractFormOptions"
|
:options="contractFormOptions"
|
||||||
label="Temps de travail *"
|
label="Temps de travail *"
|
||||||
@@ -131,37 +134,27 @@
|
|||||||
:error="showContractError ? 'Le temps de travail est obligatoire.' : ''"
|
:error="showContractError ? 'Le temps de travail est obligatoire.' : ''"
|
||||||
@update:model-value="(v) => { form.contractId = v === null ? '' : Number(v) }"
|
@update:model-value="(v) => { form.contractId = v === null ? '' : Number(v) }"
|
||||||
/>
|
/>
|
||||||
<div>
|
<MalioDate
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
|
:model-value="form.contractStartDate"
|
||||||
Début contrat <span class="text-red-600">*</span>
|
label="Début contrat"
|
||||||
</label>
|
required
|
||||||
<input
|
:reserve-message-space="false"
|
||||||
id="contract-start-date"
|
:error="showContractStartDateError ? 'La date de début est obligatoire.' : ''"
|
||||||
v-model="form.contractStartDate"
|
group-class="w-full"
|
||||||
type="date"
|
@update:model-value="(v) => form.contractStartDate = v ?? ''"
|
||||||
:class="[dateInputBaseClass, form.contractStartDate ? 'border-black' : 'border-m-muted', showContractStartDateError ? '!border-m-danger' : '']"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
|
<MalioDate
|
||||||
La date de début est obligatoire.
|
v-if="showsContractEndDateComputed"
|
||||||
</p>
|
:model-value="form.contractEndDate"
|
||||||
</div>
|
label="Fin contrat"
|
||||||
<div v-if="showsContractEndDateComputed">
|
:required="requiresContractEndDateComputed"
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
:reserve-message-space="false"
|
||||||
Fin contrat
|
:error="showContractEndDateError ? 'La date de fin est obligatoire pour un CDD ou un Intérim.' : ''"
|
||||||
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
|
group-class="w-full"
|
||||||
</label>
|
@update:model-value="(v) => form.contractEndDate = v ?? ''"
|
||||||
<input
|
|
||||||
id="contract-end-date"
|
|
||||||
v-model="form.contractEndDate"
|
|
||||||
type="date"
|
|
||||||
:class="[dateInputBaseClass, form.contractEndDate ? 'border-black' : 'border-m-muted', showContractEndDateError ? '!border-m-danger' : '']"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
|
|
||||||
La date de fin est obligatoire pour un CDD ou un Intérim.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex h-10 items-center rounded-md border border-neutral-200 bg-neutral-50 px-3">
|
<div class="flex h-10 items-center rounded-md border border-neutral-200 bg-neutral-50 px-3">
|
||||||
<MalioCheckbox
|
<MalioCheckbox :reserve-message-space="false"
|
||||||
v-model="form.isDriver"
|
v-model="form.isDriver"
|
||||||
label="Chauffeur"
|
label="Chauffeur"
|
||||||
group-class="flex items-center"
|
group-class="flex items-center"
|
||||||
@@ -173,24 +166,29 @@
|
|||||||
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
|
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Annuler"
|
label="Annuler"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
button-class="w-full"
|
||||||
@click="isDrawerOpen = false"
|
@click="isDrawerOpen = false"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
type="submit"
|
|
||||||
label="Enregistrer"
|
label="Enregistrer"
|
||||||
|
button-class="w-full"
|
||||||
:disabled="isSubmitting || !isFormValid"
|
:disabled="isSubmitting || !isFormValid"
|
||||||
|
@click="handleSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
|
|
||||||
<MalioDrawer v-model="isExportDrawerOpen" title="Export">
|
<MalioDrawer v-model="isExportDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">Export</h2>
|
||||||
|
</template>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="exportChoice === '' ? null : exportChoice"
|
:model-value="exportChoice === '' ? null : exportChoice"
|
||||||
:options="exportTypeOptions"
|
:options="exportTypeOptions"
|
||||||
label="Type d'export"
|
label="Type d'export"
|
||||||
@@ -213,14 +211,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="exportChoice === 'yearly-hours'">
|
<template v-else-if="exportChoice === 'yearly-hours'">
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="exportYear"
|
:model-value="exportYear"
|
||||||
:options="exportYearOptions"
|
:options="exportYearOptions"
|
||||||
label="Année *"
|
label="Année *"
|
||||||
min-width=""
|
min-width=""
|
||||||
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
|
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="exportMonth === '' ? null : exportMonth"
|
:model-value="exportMonth === '' ? null : exportMonth"
|
||||||
:options="exportMonthOptions"
|
:options="exportMonthOptions"
|
||||||
label="Mois *"
|
label="Mois *"
|
||||||
@@ -230,6 +228,32 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div v-else-if="exportChoice === 'night-contingent'">
|
||||||
|
<MalioSelect :reserve-message-space="false"
|
||||||
|
:model-value="exportYear"
|
||||||
|
:options="exportYearOptions"
|
||||||
|
label="Année *"
|
||||||
|
min-width=""
|
||||||
|
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="exportChoice === 'overtime-contingent'" class="flex flex-col gap-4">
|
||||||
|
<MalioSelect :reserve-message-space="false"
|
||||||
|
:model-value="exportYear"
|
||||||
|
:options="exportYearOptions"
|
||||||
|
label="Année *"
|
||||||
|
min-width=""
|
||||||
|
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox :reserve-message-space="false"
|
||||||
|
v-model="exportSiteIds"
|
||||||
|
:options="siteOptions"
|
||||||
|
label="Sites"
|
||||||
|
min-width=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Valider"
|
label="Valider"
|
||||||
@@ -264,15 +288,18 @@ const isDrawerOpen = ref(false)
|
|||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isExportDrawerOpen = ref(false)
|
const isExportDrawerOpen = ref(false)
|
||||||
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
|
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | 'overtime-contingent' | ''>('')
|
||||||
const exportYear = ref<number>(new Date().getFullYear())
|
const exportYear = ref<number>(new Date().getFullYear())
|
||||||
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
|
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
|
||||||
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
|
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
|
||||||
|
const exportSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
const exportTypeOptions = [
|
const exportTypeOptions = [
|
||||||
{ label: 'Récap. congés', value: 'leave-recap' },
|
{ label: 'Récap. congés', value: 'leave-recap' },
|
||||||
{ label: 'Récap. salaire', value: 'salary-recap' },
|
{ label: 'Récap. salaire', value: 'salary-recap' },
|
||||||
{ label: 'Heures annuelles', value: 'yearly-hours' }
|
{ label: 'Heures annuelles', value: 'yearly-hours' },
|
||||||
|
{ label: 'Contingent H.nuit', value: 'night-contingent' },
|
||||||
|
{ label: 'Contingent H.supp.', value: 'overtime-contingent' }
|
||||||
]
|
]
|
||||||
const exportYearOptions = computed(() => {
|
const exportYearOptions = computed(() => {
|
||||||
const current = new Date().getFullYear()
|
const current = new Date().getFullYear()
|
||||||
@@ -301,11 +328,17 @@ const isExportValid = computed(() => {
|
|||||||
if (exportChoice.value === 'yearly-hours') {
|
if (exportChoice.value === 'yearly-hours') {
|
||||||
return exportYear.value > 0 && exportMonth.value !== ''
|
return exportYear.value > 0 && exportMonth.value !== ''
|
||||||
}
|
}
|
||||||
|
if (exportChoice.value === 'night-contingent') {
|
||||||
|
return exportYear.value > 0
|
||||||
|
}
|
||||||
|
if (exportChoice.value === 'overtime-contingent') {
|
||||||
|
return exportYear.value > 0
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const onExportChoiceChange = (value: string | number | null) => {
|
const onExportChoiceChange = (value: string | number | null) => {
|
||||||
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | ''
|
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | 'night-contingent' | 'overtime-contingent' | ''
|
||||||
}
|
}
|
||||||
const { printPdf } = usePdfPrinter()
|
const { printPdf } = usePdfPrinter()
|
||||||
const sitesInitialized = ref(false)
|
const sitesInitialized = ref(false)
|
||||||
@@ -432,9 +465,6 @@ const showContractEndDateError = computed(
|
|||||||
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
|
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const dateInputBaseClass =
|
|
||||||
'mt-2 h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
|
|
||||||
|
|
||||||
const formSiteOptions = computed(() =>
|
const formSiteOptions = computed(() =>
|
||||||
sites.value.map((site) => ({ label: site.name, value: site.id }))
|
sites.value.map((site) => ({ label: site.name, value: site.id }))
|
||||||
)
|
)
|
||||||
@@ -605,6 +635,7 @@ const openExportDrawer = () => {
|
|||||||
exportYear.value = now.getFullYear()
|
exportYear.value = now.getFullYear()
|
||||||
exportMonth.value = now.getMonth() + 1
|
exportMonth.value = now.getMonth() + 1
|
||||||
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
exportSiteIds.value = []
|
||||||
isExportDrawerOpen.value = true
|
isExportDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +649,11 @@ const handleExportValidate = async () => {
|
|||||||
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
|
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
|
||||||
} else if (choice === 'yearly-hours') {
|
} else if (choice === 'yearly-hours') {
|
||||||
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
|
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
|
||||||
|
} else if (choice === 'night-contingent') {
|
||||||
|
await printPdf(`/night-hours-contingent/print?year=${exportYear.value}`)
|
||||||
|
} else if (choice === 'overtime-contingent') {
|
||||||
|
const siteParam = exportSiteIds.value.length > 0 ? `&siteIds=${exportSiteIds.value.join(',')}` : ''
|
||||||
|
await printPdf(`/overtime-contingent/print?year=${exportYear.value}${siteParam}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="isAdmin && viewMode === 'day'"
|
v-if="(isAdmin || isSiteManager) && viewMode === 'day'"
|
||||||
label="Export"
|
label="Export"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon-name="mdi:download"
|
icon-name="mdi:download"
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
v-model="isExportDrawerOpen"
|
v-model="isExportDrawerOpen"
|
||||||
:sites="sites"
|
:sites="sites"
|
||||||
:initial-date="selectedDate"
|
:initial-date="selectedDate"
|
||||||
:initial-site-ids="selectedSiteIds"
|
|
||||||
:is-loading="isExporting"
|
:is-loading="isExporting"
|
||||||
@submit="handleExport"
|
@submit="handleExport"
|
||||||
/>
|
/>
|
||||||
@@ -30,6 +29,8 @@
|
|||||||
:sites="sites"
|
:sites="sites"
|
||||||
:absence-types="absenceTypes"
|
:absence-types="absenceTypes"
|
||||||
:formatted-selected-date="formattedSelectedDate"
|
:formatted-selected-date="formattedSelectedDate"
|
||||||
|
:show-validation-calendar="true"
|
||||||
|
:marked-dates="markedDates"
|
||||||
:shortcut-button-class="shortcutButtonClass"
|
:shortcut-button-class="shortcutButtonClass"
|
||||||
:week-shortcut-button-class="weekShortcutButtonClass"
|
:week-shortcut-button-class="weekShortcutButtonClass"
|
||||||
:get-week-shortcut-label="getWeekShortcutLabel"
|
:get-week-shortcut-label="getWeekShortcutLabel"
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
@set-this-week="setThisWeek"
|
@set-this-week="setThisWeek"
|
||||||
@set-next-week="setNextWeek"
|
@set-next-week="setNextWeek"
|
||||||
@shift-date="shiftDate"
|
@shift-date="shiftDate"
|
||||||
|
@month-change="onCalendarMonthChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
@@ -88,6 +90,7 @@
|
|||||||
:has-row-formation="hasRowFormation"
|
:has-row-formation="hasRowFormation"
|
||||||
:get-row-formation-label="getRowFormationLabel"
|
:get-row-formation-label="getRowFormationLabel"
|
||||||
:get-row-contract-nature="getRowContractNature"
|
:get-row-contract-nature="getRowContractNature"
|
||||||
|
:get-row-worked-days-label="getRowWorkedDaysLabel"
|
||||||
:get-row-updated-at="getRowUpdatedAt"
|
:get-row-updated-at="getRowUpdatedAt"
|
||||||
:get-presence-day-value="getPresenceDayValue"
|
:get-presence-day-value="getPresenceDayValue"
|
||||||
:on-absence-click="openAbsenceDrawer"
|
:on-absence-click="openAbsenceDrawer"
|
||||||
@@ -216,6 +219,7 @@ const {
|
|||||||
hasRowFormation,
|
hasRowFormation,
|
||||||
getRowFormationLabel,
|
getRowFormationLabel,
|
||||||
getRowContractNature,
|
getRowContractNature,
|
||||||
|
getRowWorkedDaysLabel,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
@@ -224,6 +228,8 @@ const {
|
|||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
markedDates,
|
||||||
|
onCalendarMonthChange,
|
||||||
isWeekCommentDrawerOpen,
|
isWeekCommentDrawerOpen,
|
||||||
weekCommentContext,
|
weekCommentContext,
|
||||||
openWeekCommentDrawer,
|
openWeekCommentDrawer,
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
>
|
>
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="username"
|
v-model="username"
|
||||||
label="Nom d'utilisateur"
|
label="Nom d'utilisateur"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
group-class="mt-2"
|
group-class="mt-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MalioInputPassword
|
<MalioInputPassword :reserve-message-space="false"
|
||||||
v-model="password"
|
v-model="password"
|
||||||
label="Mot de passe"
|
label="Mot de passe"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="flex items-center justify-between pb-6">
|
<div class="flex items-center justify-between pb-6">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Ajouter un site"
|
label="Ajouter"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
@@ -51,9 +51,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<MalioDrawer v-model="isDrawerOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">{{ drawerTitle }}</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
label="Nom *"
|
label="Nom *"
|
||||||
group-class="mt-2"
|
group-class="mt-2"
|
||||||
|
|||||||
@@ -94,10 +94,12 @@
|
|||||||
|
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
v-model="isDrawerOpen"
|
v-model="isDrawerOpen"
|
||||||
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
|
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[32px] font-semibold text-primary-500">{{ editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur' }}</h2>
|
||||||
|
</template>
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText :reserve-message-space="false"
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
:label="editingUser ? `Nom d'utilisateur` : `Nom d'utilisateur *`"
|
:label="editingUser ? `Nom d'utilisateur` : `Nom d'utilisateur *`"
|
||||||
group-class="mt-2"
|
group-class="mt-2"
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputPassword
|
<MalioInputPassword :reserve-message-space="false"
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
:label="editingUser ? 'Mot de passe' : 'Mot de passe *'"
|
:label="editingUser ? 'Mot de passe' : 'Mot de passe *'"
|
||||||
:hint="editingUser ? 'Laisse vide pour ne pas changer le mot de passe.' : ''"
|
:hint="editingUser ? 'Laisse vide pour ne pas changer le mot de passe.' : ''"
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="form.accessMode === 'self'">
|
<div v-if="form.accessMode === 'self'">
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="form.employeeId === '' ? null : form.employeeId"
|
:model-value="form.employeeId === '' ? null : form.employeeId"
|
||||||
:options="employeeOptions"
|
:options="employeeOptions"
|
||||||
label="Employé lié"
|
label="Employé lié"
|
||||||
@@ -172,7 +174,7 @@
|
|||||||
:key="site.id"
|
:key="site.id"
|
||||||
class="flex h-10 items-center rounded-md border border-neutral-200 px-3"
|
class="flex h-10 items-center rounded-md border border-neutral-200 px-3"
|
||||||
>
|
>
|
||||||
<MalioCheckbox
|
<MalioCheckbox :reserve-message-space="false"
|
||||||
:model-value="form.siteIds.includes(site.id)"
|
:model-value="form.siteIds.includes(site.id)"
|
||||||
:label="site.name"
|
:label="site.name"
|
||||||
group-class="flex items-center"
|
group-class="flex items-center"
|
||||||
@@ -186,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioCheckbox
|
<MalioCheckbox :reserve-message-space="false"
|
||||||
v-model="form.isLocked"
|
v-model="form.isLocked"
|
||||||
label="Verrouiller le compte"
|
label="Verrouiller le compte"
|
||||||
hint="Un compte verrouillé ne peut plus se connecter."
|
hint="Un compte verrouillé ne peut plus se connecter."
|
||||||
@@ -194,7 +196,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioCheckbox
|
<MalioCheckbox :reserve-message-space="false"
|
||||||
v-model="form.hasLeaveRecapAccess"
|
v-model="form.hasLeaveRecapAccess"
|
||||||
label="Accès à l'écran Récap. congés"
|
label="Accès à l'écran Récap. congés"
|
||||||
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap."
|
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap."
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import type { AuditLog } from './dto/audit-log'
|
import type { AuditLog } from './dto/audit-log'
|
||||||
|
|
||||||
export type AuditLogFilters = {
|
export type AuditLogFilters = {
|
||||||
employeeId?: number
|
employee?: string
|
||||||
from?: string
|
from?: string
|
||||||
to?: string
|
to?: string
|
||||||
entityType?: string
|
entityType?: string[]
|
||||||
|
action?: string[]
|
||||||
|
username?: string
|
||||||
|
ip?: string
|
||||||
|
device?: string
|
||||||
page?: number
|
page?: number
|
||||||
|
perPage?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuditLogPage = {
|
export type AuditLogPage = {
|
||||||
@@ -17,17 +22,18 @@ export type AuditLogPage = {
|
|||||||
|
|
||||||
export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
|
export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const params: Record<string, string> = {}
|
const params: Record<string, string | string[]> = {}
|
||||||
|
|
||||||
if (filters.employeeId) params.employeeId = String(filters.employeeId)
|
if (filters.employee && filters.employee.trim() !== '') params.employee = filters.employee.trim()
|
||||||
if (filters.from) params.from = filters.from
|
if (filters.from) params.from = filters.from
|
||||||
if (filters.to) params.to = filters.to
|
if (filters.to) params.to = filters.to
|
||||||
if (filters.entityType) params.entityType = filters.entityType
|
if (filters.entityType && filters.entityType.length > 0) params['entityType[]'] = filters.entityType
|
||||||
|
if (filters.action && filters.action.length > 0) params['action[]'] = filters.action
|
||||||
|
if (filters.username && filters.username.trim() !== '') params.username = filters.username.trim()
|
||||||
|
if (filters.ip && filters.ip.trim() !== '') params.ip = filters.ip.trim()
|
||||||
|
if (filters.device && filters.device.trim() !== '') params.device = filters.device.trim()
|
||||||
if (filters.page) params.page = String(filters.page)
|
if (filters.page) params.page = String(filters.page)
|
||||||
|
if (filters.perPage) params.perPage = String(filters.perPage)
|
||||||
|
|
||||||
return api.get<AuditLogPage>(
|
return api.get<AuditLogPage>('/audit-logs', params, { toast: false })
|
||||||
'/audit-logs',
|
|
||||||
params,
|
|
||||||
{ toast: false }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ export type AuditLog = {
|
|||||||
description: string
|
description: string
|
||||||
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
|
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
|
||||||
affectedDate: string | null
|
affectedDate: string | null
|
||||||
|
ipAddress: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
deviceLabel: string | null
|
||||||
|
deviceId: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type EmployeeRttWeekSummary = {
|
|||||||
bonus50Minutes: number
|
bonus50Minutes: number
|
||||||
totalMinutes: number
|
totalMinutes: number
|
||||||
cumulativeBalanceMinutes: number
|
cumulativeBalanceMinutes: number
|
||||||
|
isFlatRecovery: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RttMonthPayment = {
|
export type RttMonthPayment = {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export type WorkHourDayContextRow = {
|
|||||||
weeklyHours?: number | null
|
weeklyHours?: number | null
|
||||||
contractType?: ContractType | null
|
contractType?: ContractType | null
|
||||||
contractName?: string | null
|
contractName?: string | null
|
||||||
|
workDaysHours?: Record<number, number> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkHourDayContext = {
|
export type WorkHourDayContext = {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface OvertimeContingent {
|
||||||
|
year: number
|
||||||
|
paidMinutes: number
|
||||||
|
capHours: number
|
||||||
|
isDriver: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEmployeeOvertimeContingent = async (employeeId: number, year?: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
const query: Record<string, number> = {}
|
||||||
|
if (year) query.year = year
|
||||||
|
return api.get<OvertimeContingent>(`/employees/${employeeId}/overtime-contingent`, query, { toast: false })
|
||||||
|
}
|
||||||
@@ -138,3 +138,23 @@ export const getWorkHourDayContext = async (workDate: string) => {
|
|||||||
{ toast: false }
|
{ toast: false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jours entièrement validés (admin) sur une plage, pour colorer le calendrier de
|
||||||
|
// la vue Jour. `validatedDays` = liste de dates Y-m-d (cf. doc/hours-validated-days).
|
||||||
|
// `driver` : true → écran Heures Conducteurs (seuls les conducteurs), false → écran Heures.
|
||||||
|
export const getWorkHourValidationStatus = async (
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
options?: { driver?: boolean }
|
||||||
|
) => {
|
||||||
|
const api = useApi()
|
||||||
|
const query: Record<string, string> = { from, to }
|
||||||
|
if (options?.driver) query.driver = '1'
|
||||||
|
const data = await api.get<{ from: string; to: string; validatedDays: string[] }>(
|
||||||
|
'/work-hours/validation-status',
|
||||||
|
query,
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return data?.validatedDays ?? []
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,26 @@ export const requiresWorkDaysHours = (
|
|||||||
|
|
||||||
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
|
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
|
||||||
|
|
||||||
|
const DAY_TINY_LABELS: Record<number, string> = { 1: 'LU', 2: 'MA', 3: 'ME', 4: 'JE', 5: 'VE' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Very compact worked-days summary for the day view header, e.g. "LU,VE".
|
||||||
|
* Lists the iso days actually worked (minutes > 0), uppercase 2-letter, comma-separated.
|
||||||
|
* Returns null when the schedule is empty/unset (non-CUSTOM contracts have no schedule).
|
||||||
|
*/
|
||||||
|
export const formatWorkedDaysShort = (
|
||||||
|
workDaysHours: Record<number, number> | null | undefined
|
||||||
|
): string | null => {
|
||||||
|
if (!workDaysHours) return null
|
||||||
|
const days = Object.entries(workDaysHours)
|
||||||
|
.map(([iso, minutes]) => [Number(iso), Number(minutes)] as const)
|
||||||
|
.filter(([iso, minutes]) => iso >= 1 && iso <= 5 && minutes > 0)
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([iso]) => DAY_TINY_LABELS[iso])
|
||||||
|
if (days.length === 0) return null
|
||||||
|
return days.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
|
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
|
||||||
* Returns null when the schedule is empty/unset.
|
* Returns null when the schedule is empty/unset.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260624120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add forensic context columns (ip, user agent, device label, device id) to audit_logs';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD ip_address VARCHAR(45) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD user_agent TEXT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD device_label VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs ADD device_id VARCHAR(64) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN ip_address');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN user_agent');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_label');
|
||||||
|
$this->addSql('ALTER TABLE audit_logs DROP COLUMN device_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,16 @@ use App\State\AuditLogProvider;
|
|||||||
provider: AuditLogProvider::class,
|
provider: AuditLogProvider::class,
|
||||||
parameters: [
|
parameters: [
|
||||||
new QueryParameter(key: 'employeeId'),
|
new QueryParameter(key: 'employeeId'),
|
||||||
|
new QueryParameter(key: 'employee'),
|
||||||
new QueryParameter(key: 'from'),
|
new QueryParameter(key: 'from'),
|
||||||
new QueryParameter(key: 'to'),
|
new QueryParameter(key: 'to'),
|
||||||
new QueryParameter(key: 'entityType'),
|
new QueryParameter(key: 'entityType'),
|
||||||
|
new QueryParameter(key: 'action'),
|
||||||
|
new QueryParameter(key: 'username'),
|
||||||
|
new QueryParameter(key: 'ip'),
|
||||||
|
new QueryParameter(key: 'device'),
|
||||||
|
new QueryParameter(key: 'page'),
|
||||||
|
new QueryParameter(key: 'perPage'),
|
||||||
],
|
],
|
||||||
security: "is_granted('ROLE_SUPER_ADMIN')"
|
security: "is_granted('ROLE_SUPER_ADMIN')"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\EmployeeOvertimeContingentProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/employees/{id}/overtime-contingent',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeOvertimeContingentProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeOvertimeContingent
|
||||||
|
{
|
||||||
|
public int $year = 0;
|
||||||
|
public int $paidMinutes = 0;
|
||||||
|
public int $capHours = 0;
|
||||||
|
public bool $isDriver = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\QueryParameter;
|
||||||
|
use App\State\NightHoursContingentPrintProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/night-hours-contingent/print',
|
||||||
|
provider: NightHoursContingentPrintProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'year', required: true),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_USER')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class NightHoursContingentPrint {}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\QueryParameter;
|
||||||
|
use App\State\OvertimeContingentPrintProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/overtime-contingent/print',
|
||||||
|
provider: OvertimeContingentPrintProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'year', required: true),
|
||||||
|
new QueryParameter(key: 'siteIds', required: false),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_USER')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class OvertimeContingentPrint {}
|
||||||
@@ -41,7 +41,8 @@ final class WorkHourDayContext
|
|||||||
* trackingMode:?string,
|
* trackingMode:?string,
|
||||||
* weeklyHours:?int,
|
* weeklyHours:?int,
|
||||||
* contractType:?string,
|
* contractType:?string,
|
||||||
* contractName:?string
|
* contractName:?string,
|
||||||
|
* workDaysHours:?array<int, int>
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
public array $rows = [];
|
public array $rows = [];
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use App\State\WorkHourDayExportProvider;
|
|||||||
new QueryParameter(key: 'workDate', required: true),
|
new QueryParameter(key: 'workDate', required: true),
|
||||||
new QueryParameter(key: 'siteIds', required: true),
|
new QueryParameter(key: 'siteIds', required: true),
|
||||||
],
|
],
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
security: "is_granted('ROLE_USER')"
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\WorkHourValidationStatusProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/work-hours/validation-status',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: WorkHourValidationStatusProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class WorkHourValidationStatus
|
||||||
|
{
|
||||||
|
public string $from = '';
|
||||||
|
|
||||||
|
public string $to = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jours entièrement validés (admin) sur la plage, au format Y-m-d.
|
||||||
|
* Un jour est présent ssi il porte au moins une ligne (non-conducteur)
|
||||||
|
* et aucune n'est en attente de validation (isValid=false).
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $validatedDays = [];
|
||||||
|
}
|
||||||
@@ -636,6 +636,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
base50Minutes: $detail->base50Minutes,
|
base50Minutes: $detail->base50Minutes,
|
||||||
bonus50Minutes: $detail->bonus50Minutes,
|
bonus50Minutes: $detail->bonus50Minutes,
|
||||||
totalMinutes: $detail->totalMinutes,
|
totalMinutes: $detail->totalMinutes,
|
||||||
|
isFlatRecovery: $detail->isFlatRecovery,
|
||||||
);
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -672,6 +673,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
base50Minutes: (int) round($detail->base50Minutes * $ratio),
|
||||||
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
|
||||||
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
totalMinutes: (int) round($detail->totalMinutes * $ratio),
|
||||||
|
isFlatRecovery: $detail->isFlatRecovery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,7 +694,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
|
||||||
|
|
||||||
foreach ($weeks as $i => $week) {
|
foreach ($weeks as $i => $week) {
|
||||||
if ($week->totalMinutes >= 0) {
|
if ($week->totalMinutes >= 0 || $week->isFlatRecovery) {
|
||||||
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
|
||||||
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
|
||||||
|
|
||||||
@@ -714,6 +716,7 @@ final class DumpVerificationSnapshotCommand extends Command
|
|||||||
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
base50Minutes: $from50 > 0 ? -$from50 : 0,
|
||||||
bonus50Minutes: 0,
|
bonus50Minutes: 0,
|
||||||
totalMinutes: $week->totalMinutes,
|
totalMinutes: $week->totalMinutes,
|
||||||
|
isFlatRecovery: $week->isFlatRecovery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ final class EmployeeRttWeekSummary
|
|||||||
public int $bonus50Minutes = 0,
|
public int $bonus50Minutes = 0,
|
||||||
public int $totalMinutes = 0,
|
public int $totalMinutes = 0,
|
||||||
public int $cumulativeBalanceMinutes = 0,
|
public int $cumulativeBalanceMinutes = 0,
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ final class WeekRecoveryDetail
|
|||||||
public int $bonus50Minutes = 0,
|
public int $bonus50Minutes = 0,
|
||||||
public int $totalMinutes = 0,
|
public int $totalMinutes = 0,
|
||||||
public array $dailyMinutes = [],
|
public array $dailyMinutes = [],
|
||||||
|
public bool $isFlatRecovery = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ final class DayContextRow
|
|||||||
public ?int $weeklyHours = null,
|
public ?int $weeklyHours = null,
|
||||||
public ?string $contractType = null,
|
public ?string $contractType = null,
|
||||||
public ?string $contractName = null,
|
public ?string $contractName = null,
|
||||||
|
/** @var null|array<int, int> iso day (1=Mon..5=Fri) → minutes, planning des jours travaillés (CUSTOM uniquement) */
|
||||||
|
public ?array $workDaysHours = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function setFormation(string $label): void
|
public function setFormation(string $label): void
|
||||||
@@ -87,7 +89,8 @@ final class DayContextRow
|
|||||||
* trackingMode:?string,
|
* trackingMode:?string,
|
||||||
* weeklyHours:?int,
|
* weeklyHours:?int,
|
||||||
* contractType:?string,
|
* contractType:?string,
|
||||||
* contractName:?string
|
* contractName:?string,
|
||||||
|
* workDaysHours:?array<int, int>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
@@ -111,6 +114,7 @@ final class DayContextRow
|
|||||||
'weeklyHours' => $this->weeklyHours,
|
'weeklyHours' => $this->weeklyHours,
|
||||||
'contractType' => $this->contractType,
|
'contractType' => $this->contractType,
|
||||||
'contractName' => $this->contractName,
|
'contractName' => $this->contractName,
|
||||||
|
'workDaysHours' => $this->workDaysHours,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\WorkHours;
|
||||||
|
|
||||||
|
final class NightContingentRow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{nightMinutes: int, nightDays: int}> $months clé 1..12
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $employeeId,
|
||||||
|
public readonly string $employeeName,
|
||||||
|
public readonly array $months,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\WorkHours;
|
||||||
|
|
||||||
|
final class OvertimeContingentRow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $months clé 1..12 -> minutes base payées
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $employeeId,
|
||||||
|
public readonly string $employeeName,
|
||||||
|
public readonly array $months,
|
||||||
|
public readonly int $totalMinutes,
|
||||||
|
public readonly int $capHours,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -46,6 +46,18 @@ class AuditLog
|
|||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
private ?DateTimeImmutable $affectedDate = null;
|
private ?DateTimeImmutable $affectedDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 45, nullable: true)]
|
||||||
|
private ?string $ipAddress = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $userAgent = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $deviceLabel = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||||
|
private ?string $deviceId = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
@@ -155,6 +167,54 @@ class AuditLog
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIpAddress(?string $ipAddress): self
|
||||||
|
{
|
||||||
|
$this->ipAddress = $ipAddress;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUserAgent(?string $userAgent): self
|
||||||
|
{
|
||||||
|
$this->userAgent = $userAgent;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeviceLabel(?string $deviceLabel): self
|
||||||
|
{
|
||||||
|
$this->deviceLabel = $deviceLabel;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceId(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeviceId(?string $deviceId): self
|
||||||
|
{
|
||||||
|
$this->deviceId = $deviceId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->createdAt;
|
return $this->createdAt;
|
||||||
|
|||||||
@@ -5,28 +5,32 @@ declare(strict_types=1);
|
|||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\AuditLog;
|
use App\Entity\AuditLog;
|
||||||
|
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<AuditLog>
|
* @extends ServiceEntityRepository<AuditLog>
|
||||||
*/
|
*/
|
||||||
final class AuditLogRepository extends ServiceEntityRepository
|
final class AuditLogRepository extends ServiceEntityRepository implements AuditLogReadRepositoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
parent::__construct($registry, AuditLog::class);
|
parent::__construct($registry, AuditLog::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<AuditLog>
|
|
||||||
*/
|
|
||||||
public function findByFilters(
|
public function findByFilters(
|
||||||
?int $employeeId = null,
|
?int $employeeId = null,
|
||||||
?DateTimeImmutable $from = null,
|
?DateTimeImmutable $from = null,
|
||||||
?DateTimeImmutable $to = null,
|
?DateTimeImmutable $to = null,
|
||||||
?string $entityType = null,
|
?array $entityTypes = null,
|
||||||
|
?array $actions = null,
|
||||||
|
?string $username = null,
|
||||||
|
?string $ip = null,
|
||||||
|
?string $device = null,
|
||||||
|
?string $employeeName = null,
|
||||||
int $limit = 50,
|
int $limit = 50,
|
||||||
int $offset = 0,
|
int $offset = 0,
|
||||||
): array {
|
): array {
|
||||||
@@ -35,30 +39,7 @@ final class AuditLogRepository extends ServiceEntityRepository
|
|||||||
->setMaxResults($limit)
|
->setMaxResults($limit)
|
||||||
->setFirstResult($offset)
|
->setFirstResult($offset)
|
||||||
;
|
;
|
||||||
|
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
|
||||||
if (null !== $employeeId) {
|
|
||||||
$qb->andWhere('a.employee = :employeeId')
|
|
||||||
->setParameter('employeeId', $employeeId)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $from) {
|
|
||||||
$qb->andWhere('a.affectedDate >= :from')
|
|
||||||
->setParameter('from', $from)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $to) {
|
|
||||||
$qb->andWhere('a.affectedDate <= :to')
|
|
||||||
->setParameter('to', $to)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $entityType) {
|
|
||||||
$qb->andWhere('a.entityType = :entityType')
|
|
||||||
->setParameter('entityType', $entityType)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
@@ -67,36 +48,66 @@ final class AuditLogRepository extends ServiceEntityRepository
|
|||||||
?int $employeeId = null,
|
?int $employeeId = null,
|
||||||
?DateTimeImmutable $from = null,
|
?DateTimeImmutable $from = null,
|
||||||
?DateTimeImmutable $to = null,
|
?DateTimeImmutable $to = null,
|
||||||
?string $entityType = null,
|
?array $entityTypes = null,
|
||||||
|
?array $actions = null,
|
||||||
|
?string $username = null,
|
||||||
|
?string $ip = null,
|
||||||
|
?string $device = null,
|
||||||
|
?string $employeeName = null,
|
||||||
): int {
|
): int {
|
||||||
$qb = $this->createQueryBuilder('a')
|
$qb = $this->createQueryBuilder('a')->select('COUNT(a.id)');
|
||||||
->select('COUNT(a.id)')
|
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
|
||||||
;
|
|
||||||
|
|
||||||
if (null !== $employeeId) {
|
|
||||||
$qb->andWhere('a.employee = :employeeId')
|
|
||||||
->setParameter('employeeId', $employeeId)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $from) {
|
|
||||||
$qb->andWhere('a.affectedDate >= :from')
|
|
||||||
->setParameter('from', $from)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $to) {
|
|
||||||
$qb->andWhere('a.affectedDate <= :to')
|
|
||||||
->setParameter('to', $to)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $entityType) {
|
|
||||||
$qb->andWhere('a.entityType = :entityType')
|
|
||||||
->setParameter('entityType', $entityType)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param null|list<string> $entityTypes
|
||||||
|
* @param null|list<string> $actions
|
||||||
|
*/
|
||||||
|
private function applyFilters(
|
||||||
|
QueryBuilder $qb,
|
||||||
|
?int $employeeId,
|
||||||
|
?DateTimeImmutable $from,
|
||||||
|
?DateTimeImmutable $to,
|
||||||
|
?array $entityTypes,
|
||||||
|
?array $actions,
|
||||||
|
?string $username,
|
||||||
|
?string $ip,
|
||||||
|
?string $device,
|
||||||
|
?string $employeeName = null,
|
||||||
|
): void {
|
||||||
|
if (null !== $employeeId) {
|
||||||
|
$qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId);
|
||||||
|
}
|
||||||
|
if (null !== $employeeName && '' !== $employeeName) {
|
||||||
|
$qb->join('a.employee', 'e')
|
||||||
|
->andWhere('LOWER(e.lastName) LIKE :employeeName OR LOWER(e.firstName) LIKE :employeeName')
|
||||||
|
->setParameter('employeeName', '%'.mb_strtolower($employeeName).'%')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
if (null !== $from) {
|
||||||
|
$qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from);
|
||||||
|
}
|
||||||
|
if (null !== $to) {
|
||||||
|
$qb->andWhere('a.affectedDate <= :to')->setParameter('to', $to);
|
||||||
|
}
|
||||||
|
if (null !== $entityTypes && [] !== $entityTypes) {
|
||||||
|
$qb->andWhere('a.entityType IN (:entityTypes)')->setParameter('entityTypes', $entityTypes);
|
||||||
|
}
|
||||||
|
if (null !== $actions && [] !== $actions) {
|
||||||
|
$qb->andWhere('a.action IN (:actions)')->setParameter('actions', $actions);
|
||||||
|
}
|
||||||
|
if (null !== $username && '' !== $username) {
|
||||||
|
$qb->andWhere('LOWER(a.username) LIKE :username')->setParameter('username', '%'.mb_strtolower($username).'%');
|
||||||
|
}
|
||||||
|
if (null !== $ip && '' !== $ip) {
|
||||||
|
$qb->andWhere('LOWER(a.ipAddress) LIKE :ip')->setParameter('ip', '%'.mb_strtolower($ip).'%');
|
||||||
|
}
|
||||||
|
if (null !== $device && '' !== $device) {
|
||||||
|
$qb->andWhere('(LOWER(a.deviceLabel) LIKE :device OR LOWER(a.deviceId) LIKE :device)')
|
||||||
|
->setParameter('device', '%'.mb_strtolower($device).'%')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository\Contract;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
interface AuditLogReadRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param null|list<string> $entityTypes
|
||||||
|
* @param null|list<string> $actions
|
||||||
|
*
|
||||||
|
* @return list<AuditLog>
|
||||||
|
*/
|
||||||
|
public function findByFilters(
|
||||||
|
?int $employeeId = null,
|
||||||
|
?DateTimeImmutable $from = null,
|
||||||
|
?DateTimeImmutable $to = null,
|
||||||
|
?array $entityTypes = null,
|
||||||
|
?array $actions = null,
|
||||||
|
?string $username = null,
|
||||||
|
?string $ip = null,
|
||||||
|
?string $device = null,
|
||||||
|
?string $employeeName = null,
|
||||||
|
int $limit = 50,
|
||||||
|
int $offset = 0,
|
||||||
|
): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param null|list<string> $entityTypes
|
||||||
|
* @param null|list<string> $actions
|
||||||
|
*/
|
||||||
|
public function countByFilters(
|
||||||
|
?int $employeeId = null,
|
||||||
|
?DateTimeImmutable $from = null,
|
||||||
|
?DateTimeImmutable $to = null,
|
||||||
|
?array $entityTypes = null,
|
||||||
|
?array $actions = null,
|
||||||
|
?string $username = null,
|
||||||
|
?string $ip = null,
|
||||||
|
?string $device = null,
|
||||||
|
?string $employeeName = null,
|
||||||
|
): int;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||||
*/
|
*/
|
||||||
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
@@ -60,4 +60,31 @@ final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
|||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paiements de plusieurs employés sur plusieurs exercices (fetch groupé,
|
||||||
|
* évite le N+1 sur l'export PDF). Jointure employé chargée.
|
||||||
|
*
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
* @param list<int> $years années d'exercice
|
||||||
|
*
|
||||||
|
* @return EmployeeRttPayment[]
|
||||||
|
*/
|
||||||
|
public function findByEmployeesAndYears(array $employees, array $years): array
|
||||||
|
{
|
||||||
|
if ([] === $employees || [] === $years) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee IN (:employees)')
|
||||||
|
->andWhere('p.year IN (:years)')
|
||||||
|
->setParameter('employees', $employees)
|
||||||
|
->setParameter('years', $years)
|
||||||
|
->innerJoin('p.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ use App\Entity\User;
|
|||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
readonly class AuditLogger
|
readonly class AuditLogger
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private UserAgentParser $userAgentParser,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function log(
|
public function log(
|
||||||
@@ -30,6 +33,25 @@ readonly class AuditLogger
|
|||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
$username = $user instanceof User ? $user->getUsername() : 'system';
|
$username = $user instanceof User ? $user->getUsername() : 'system';
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$ipAddress = null;
|
||||||
|
$userAgent = null;
|
||||||
|
$deviceId = null;
|
||||||
|
|
||||||
|
if (null !== $request) {
|
||||||
|
$ipAddress = $request->getClientIp();
|
||||||
|
$userAgent = $request->headers->get('User-Agent');
|
||||||
|
$deviceId = $request->headers->get('X-Device-Id');
|
||||||
|
// The device id comes from an untrusted client header; cap it to the column width.
|
||||||
|
if (null !== $deviceId) {
|
||||||
|
$deviceId = mb_substr($deviceId, 0, 64);
|
||||||
|
}
|
||||||
|
// The user agent comes from an untrusted client header; cap it to prevent storage bloat.
|
||||||
|
if (null !== $userAgent) {
|
||||||
|
$userAgent = mb_substr($userAgent, 0, 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$auditLog = new AuditLog();
|
$auditLog = new AuditLog();
|
||||||
$auditLog
|
$auditLog
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
@@ -40,6 +62,10 @@ readonly class AuditLogger
|
|||||||
->setDescription($description)
|
->setDescription($description)
|
||||||
->setChanges($changes)
|
->setChanges($changes)
|
||||||
->setAffectedDate($affectedDate)
|
->setAffectedDate($affectedDate)
|
||||||
|
->setIpAddress($ipAddress)
|
||||||
|
->setUserAgent($userAgent)
|
||||||
|
->setDeviceLabel($this->userAgentParser->parse($userAgent))
|
||||||
|
->setDeviceId($deviceId)
|
||||||
;
|
;
|
||||||
|
|
||||||
$this->entityManager->persist($auditLog);
|
$this->entityManager->persist($auditLog);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use App\Service\Contracts\EmployeeContractResolver;
|
|||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||||
|
use App\Service\WorkHours\NightHoursCalculator;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ final readonly class RttRecoveryComputationService
|
|||||||
private EmployeeContractResolver $contractResolver,
|
private EmployeeContractResolver $contractResolver,
|
||||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private SolidarityDayResolver $solidarityDayResolver,
|
||||||
|
private NightHoursCalculator $nightHoursCalculator,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
$this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
|
||||||
@@ -163,6 +166,7 @@ final readonly class RttRecoveryComputationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$results = [];
|
$results = [];
|
||||||
|
$solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
|
||||||
foreach ($weeks as $week) {
|
foreach ($weeks as $week) {
|
||||||
$weekStart = $week['start'];
|
$weekStart = $week['start'];
|
||||||
$weekEnd = $week['end'];
|
$weekEnd = $week['end'];
|
||||||
@@ -244,35 +248,96 @@ final readonly class RttRecoveryComputationService
|
|||||||
? 0
|
? 0
|
||||||
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
||||||
|
|
||||||
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
foreach ($solidarityDates as $solidarityDate) {
|
||||||
|
// isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
|
||||||
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase25;
|
// (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
|
||||||
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
|
if (!isset($dailyWorkedMinutes[$solidarityDate])) {
|
||||||
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : $rawBase50;
|
continue;
|
||||||
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
|
|
||||||
|
|
||||||
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
|
|
||||||
$totalMinutes = 0;
|
|
||||||
} elseif ($isCustomContract) {
|
|
||||||
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
|
|
||||||
} else {
|
|
||||||
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$results[$weekKey] = new WeekRecoveryDetail(
|
$contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
|
||||||
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
// Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite.
|
||||||
|
$solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
|
||||||
|
// Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
|
||||||
|
// c'est ce qui rend la neutralisation correcte (cf. spec).
|
||||||
|
$solidarityExpected = $this->dailyReferenceResolver->resolve(
|
||||||
|
$contractAtSolidarity?->getWeeklyHours(),
|
||||||
|
$solidarityIsoDay,
|
||||||
|
$workDaysByDate[$employeeId][$solidarityDate] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
|
||||||
|
$contractAtSolidarity,
|
||||||
|
$solidarityExpected,
|
||||||
|
$dailyWorkedMinutes[$solidarityDate],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);
|
||||||
|
|
||||||
|
$results[$weekKey] = $this->buildWeekRecoveryDetail(
|
||||||
|
$isWeekPresenceTracking,
|
||||||
|
$disableOvertimeBonuses,
|
||||||
|
$isCustomContract,
|
||||||
|
$weeklyOvertimeTotalMinutes,
|
||||||
|
$rawBase25,
|
||||||
|
$rawBase50,
|
||||||
|
$dailyWorkedMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
|
||||||
|
* des bandes d'heures sup brutes.
|
||||||
|
*
|
||||||
|
* - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
|
||||||
|
* - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
|
||||||
|
* le total, donc une semaine travaillée sous les heures contractuelles produit un
|
||||||
|
* total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
|
||||||
|
* provider ne draine pas les tranches 25/50.
|
||||||
|
* - Standard 35h/39h : heures sup + bonus 25 %/50 %.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $dailyMinutes
|
||||||
|
*/
|
||||||
|
private function buildWeekRecoveryDetail(
|
||||||
|
bool $isPresence,
|
||||||
|
bool $disableBonuses,
|
||||||
|
bool $isCustom,
|
||||||
|
int $overtimeTotalMinutes,
|
||||||
|
int $rawBase25,
|
||||||
|
int $rawBase50,
|
||||||
|
array $dailyMinutes,
|
||||||
|
): WeekRecoveryDetail {
|
||||||
|
$noBands = $isPresence || $disableBonuses || $isCustom;
|
||||||
|
|
||||||
|
$base25 = $noBands ? 0 : $rawBase25;
|
||||||
|
$bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
|
||||||
|
$base50 = $noBands ? 0 : $rawBase50;
|
||||||
|
$bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);
|
||||||
|
|
||||||
|
if ($isPresence || $disableBonuses) {
|
||||||
|
$totalMinutes = 0;
|
||||||
|
} elseif ($isCustom) {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
|
||||||
|
} else {
|
||||||
|
$totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeekRecoveryDetail(
|
||||||
|
overtimeMinutes: $overtimeTotalMinutes,
|
||||||
base25Minutes: $base25,
|
base25Minutes: $base25,
|
||||||
bonus25Minutes: $bonus25,
|
bonus25Minutes: $bonus25,
|
||||||
base50Minutes: $base50,
|
base50Minutes: $base50,
|
||||||
bonus50Minutes: $bonus50,
|
bonus50Minutes: $bonus50,
|
||||||
totalMinutes: $totalMinutes,
|
totalMinutes: $totalMinutes,
|
||||||
dailyMinutes: $dailyWorkedMinutes,
|
dailyMinutes: $dailyMinutes,
|
||||||
|
isFlatRecovery: $isCustom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||||
{
|
{
|
||||||
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
||||||
@@ -296,12 +361,11 @@ final readonly class RttRecoveryComputationService
|
|||||||
];
|
];
|
||||||
|
|
||||||
$totalMinutes = 0;
|
$totalMinutes = 0;
|
||||||
$nightMinutes = 0;
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
foreach ($ranges as [$from, $to]) {
|
||||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||||
|
|
||||||
return new WorkMetrics(
|
return new WorkMetrics(
|
||||||
@@ -348,35 +412,6 @@ final readonly class RttRecoveryComputationService
|
|||||||
return max(0, $end - $start);
|
return max(0, $end - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
|
||||||
{
|
|
||||||
$interval = $this->resolveInterval($from, $to);
|
|
||||||
if (null === $interval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$start, $end] = $interval;
|
|
||||||
$windows = [[0, 360], [1260, 1440]];
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
|
||||||
$shift = $dayOffset * 1440;
|
|
||||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
|
||||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
|
||||||
{
|
|
||||||
$start = max($startA, $startB);
|
|
||||||
$end = min($endA, $endB);
|
|
||||||
|
|
||||||
return max(0, $end - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
@@ -415,6 +450,67 @@ final readonly class RttRecoveryComputationService
|
|||||||
return $weekDays[0];
|
return $weekDays[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
|
||||||
|
* Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
|
||||||
|
*
|
||||||
|
* @return list<string> dates au format 'Y-m-d'
|
||||||
|
*/
|
||||||
|
private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$dates = [];
|
||||||
|
$firstYear = (int) $from->format('Y');
|
||||||
|
$lastYear = (int) $to->format('Y');
|
||||||
|
|
||||||
|
for ($year = $firstYear; $year <= $lastYear; ++$year) {
|
||||||
|
$candidate = $this->solidarityDayResolver->pentecostMonday($year);
|
||||||
|
if ($candidate >= $from && $candidate <= $to) {
|
||||||
|
$dates[] = $candidate->format('Y-m-d');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
|
||||||
|
*
|
||||||
|
* Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
|
||||||
|
* du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
|
||||||
|
* par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
|
||||||
|
* retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
|
||||||
|
* semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à
|
||||||
|
* ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
|
||||||
|
*/
|
||||||
|
private function computeSolidarityDeficitAdjustment(
|
||||||
|
?Contract $contractAtSolidarity,
|
||||||
|
int $expectedMinutes,
|
||||||
|
int $workedMinutes,
|
||||||
|
): int {
|
||||||
|
$weeklyHours = $contractAtSolidarity?->getWeeklyHours();
|
||||||
|
$type = ContractType::resolve(
|
||||||
|
$contractAtSolidarity?->getName(),
|
||||||
|
$contractAtSolidarity?->getTrackingMode(),
|
||||||
|
$weeklyHours,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le salarié ne travaille pas le jour de solidarité (lundi non planifié au contrat,
|
||||||
|
// workDaysHours[lundi] absent → attendu = 0) : le jour ne le concerne pas, aucun
|
||||||
|
// déficit n'est imputé. Sans cette garde, (0 − 0) − prorata facturerait à tort le prorata
|
||||||
|
// à un temps partiel qui ne travaille jamais le lundi (ex. Nadia, Mar+Ven).
|
||||||
|
if (0 === $expectedMinutes) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prorata = (int) round($weeklyHours * 12);
|
||||||
|
|
||||||
|
return ($expectedMinutes - $workedMinutes) - $prorata;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
* @param array<string, ?Contract> $contractsByDate
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Rtt;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout le jour de solidarité (Lundi de Pentecôte) d'une année.
|
||||||
|
*
|
||||||
|
* Pur et déterministe : Pâques via l'algorithme de Meeus/Jones/Butcher (calendrier
|
||||||
|
* grégorien), sans dépendance à l'extension calendar ni au réseau. Lundi de Pentecôte
|
||||||
|
* = dimanche de Pâques + 50 jours.
|
||||||
|
*/
|
||||||
|
final class SolidarityDayResolver
|
||||||
|
{
|
||||||
|
public function pentecostMonday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->easterSunday($year)->modify('+50 days');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function easterSunday(int $year): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$a = $year % 19;
|
||||||
|
$b = intdiv($year, 100);
|
||||||
|
$c = $year % 100;
|
||||||
|
$d = intdiv($b, 4);
|
||||||
|
$e = $b % 4;
|
||||||
|
$f = intdiv($b + 8, 25);
|
||||||
|
$g = intdiv($b - $f + 1, 3);
|
||||||
|
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||||
|
$i = intdiv($c, 4);
|
||||||
|
$k = $c % 4;
|
||||||
|
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||||
|
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||||
|
|
||||||
|
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||||
|
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||||
|
|
||||||
|
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
|
||||||
|
* User-Agent string, used to add forensic context to audit log entries.
|
||||||
|
* Heuristic on purpose — enough to tell a phone from a desktop and identify
|
||||||
|
* OS/browser families on shared accounts.
|
||||||
|
*/
|
||||||
|
class UserAgentParser
|
||||||
|
{
|
||||||
|
public function parse(?string $userAgent): ?string
|
||||||
|
{
|
||||||
|
if (null === $userAgent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ua = trim($userAgent);
|
||||||
|
if ('' === $ua) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' · ', [
|
||||||
|
$this->detectType($ua),
|
||||||
|
$this->detectOs($ua),
|
||||||
|
$this->detectBrowser($ua),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectType(string $ua): string
|
||||||
|
{
|
||||||
|
if (1 === preg_match('/iPad|Tablet/i', $ua)) {
|
||||||
|
return 'Tablette';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) {
|
||||||
|
return 'Mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Ordinateur';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectOs(string $ua): string
|
||||||
|
{
|
||||||
|
// Order matters: iOS before macOS (iOS UAs contain "Mac OS X"),
|
||||||
|
// Android before Linux (Android UAs contain "Linux").
|
||||||
|
return match (true) {
|
||||||
|
1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS',
|
||||||
|
1 === preg_match('/Android/i', $ua) => 'Android',
|
||||||
|
1 === preg_match('/Windows/i', $ua) => 'Windows',
|
||||||
|
1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS',
|
||||||
|
1 === preg_match('/Linux/i', $ua) => 'Linux',
|
||||||
|
default => 'Autre',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectBrowser(string $ua): string
|
||||||
|
{
|
||||||
|
// Order matters: Edge/Opera contain "Chrome" and "Safari";
|
||||||
|
// Chrome contains "Safari". Match the most specific first.
|
||||||
|
return match (true) {
|
||||||
|
1 === preg_match('/Edg/i', $ua) => 'Edge',
|
||||||
|
1 === preg_match('/OPR|Opera/i', $ua) => 'Opera',
|
||||||
|
1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox',
|
||||||
|
1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome',
|
||||||
|
1 === preg_match('/Safari/i', $ua) => 'Safari',
|
||||||
|
default => 'Autre',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Dto\WorkHours\NightContingentRow;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit, par employe, les totaux mensuels d'heures de nuit et le nombre de
|
||||||
|
* nuits travaillees (>= 4h de nuit dans la journee). Fenetre 21h->6h via
|
||||||
|
* NightHoursCalculator. Conducteurs : minutes saisies (nightHoursMinutes).
|
||||||
|
* Aucun credit absence/ferie : seules les heures reellement travaillees comptent.
|
||||||
|
*/
|
||||||
|
final readonly class NightContingentExportBuilder
|
||||||
|
{
|
||||||
|
private const int NIGHT_DAY_THRESHOLD_MINUTES = 240;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
private NightHoursCalculator $nightHoursCalculator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<NightContingentRow>
|
||||||
|
*/
|
||||||
|
public function buildRows(array $employees, int $year): array
|
||||||
|
{
|
||||||
|
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
||||||
|
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
||||||
|
|
||||||
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
||||||
|
|
||||||
|
$byEmployee = [];
|
||||||
|
foreach ($workHours as $wh) {
|
||||||
|
$employeeId = $wh->getEmployee()?->getId();
|
||||||
|
if (null === $employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byEmployee[$employeeId][] = $wh;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = [];
|
||||||
|
foreach ($workHours as $wh) {
|
||||||
|
$days[$wh->getWorkDate()->format('Y-m-d')] = true;
|
||||||
|
}
|
||||||
|
$days = array_keys($days);
|
||||||
|
|
||||||
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (null === $employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = [];
|
||||||
|
for ($m = 1; $m <= 12; ++$m) {
|
||||||
|
$months[$m] = ['nightMinutes' => 0, 'nightDays' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($byEmployee[$employeeId] ?? [] as $wh) {
|
||||||
|
$date = DateTimeImmutable::createFromInterface($wh->getWorkDate());
|
||||||
|
$ymd = $date->format('Y-m-d');
|
||||||
|
$isDriver = $driverMap[$employeeId][$ymd] ?? false;
|
||||||
|
$nightMin = $this->nightHoursCalculator->nightMinutesForWorkHour($wh, $isDriver);
|
||||||
|
if ($nightMin <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$month = (int) $date->format('n');
|
||||||
|
$months[$month]['nightMinutes'] += $nightMin;
|
||||||
|
if ($nightMin >= self::NIGHT_DAY_THRESHOLD_MINUTES) {
|
||||||
|
++$months[$month]['nightDays'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = new NightContingentRow(
|
||||||
|
employeeId: $employeeId,
|
||||||
|
employeeName: trim($employee->getLastName().' '.$employee->getFirstName()),
|
||||||
|
months: $months,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcul des minutes travaillees de nuit (fenetre 21h->6h).
|
||||||
|
*
|
||||||
|
* Fenetres en minutes depuis 00:00 : [0,360] (00:00-06:00) et [1260,1440]
|
||||||
|
* (21:00-24:00). On projette sur J+1 pour les shifts qui traversent minuit.
|
||||||
|
* Source de verite unique partagee par les ecrans Heures et les exports.
|
||||||
|
*/
|
||||||
|
final readonly class NightHoursCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Minutes de nuit d'un WorkHour. Conducteurs : champ manuel nightHoursMinutes.
|
||||||
|
* Non-conducteurs : somme calculee depuis les plages matin/apres-midi/soir.
|
||||||
|
*/
|
||||||
|
public function nightMinutesForWorkHour(WorkHour $workHour, bool $isDriver): int
|
||||||
|
{
|
||||||
|
if ($isDriver) {
|
||||||
|
return $workHour->getNightHoursMinutes() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->nightMinutesFromRanges($workHour);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nightMinutesFromRanges(WorkHour $workHour): int
|
||||||
|
{
|
||||||
|
$ranges = [
|
||||||
|
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||||
|
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||||
|
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||||
|
];
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($ranges as [$from, $to]) {
|
||||||
|
$total += $this->nightIntervalMinutes($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nightIntervalMinutes(?string $from, ?string $to): int
|
||||||
|
{
|
||||||
|
$interval = $this->resolveInterval($from, $to);
|
||||||
|
if (null === $interval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $interval;
|
||||||
|
$windows = [[0, 360], [1260, 1440]];
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||||
|
$shift = $dayOffset * 1440;
|
||||||
|
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||||
|
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null|array{int, int}
|
||||||
|
*/
|
||||||
|
private function resolveInterval(?string $from, ?string $to): ?array
|
||||||
|
{
|
||||||
|
$fromMinutes = $this->toMinutes($from);
|
||||||
|
$toMinutes = $this->toMinutes($to);
|
||||||
|
if (null === $fromMinutes || null === $toMinutes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||||
|
|
||||||
|
return [$fromMinutes, $end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toMinutes(?string $time): ?int
|
||||||
|
{
|
||||||
|
if (null === $time || '' === $time) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||||
|
|
||||||
|
return ($hours * 60) + $minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||||
|
{
|
||||||
|
$start = max($startA, $startB);
|
||||||
|
$end = min($endA, $endB);
|
||||||
|
|
||||||
|
return max(0, $end - $start);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Dto\WorkHours\OvertimeContingentRow;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit, par employé, les heures supp payées (base, hors bonus) ventilées
|
||||||
|
* par mois civil pour l'année civile demandée, le total et le plafond légal.
|
||||||
|
*/
|
||||||
|
final readonly class OvertimeContingentExportBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
|
private OvertimePaidContingentCalculator $calculator,
|
||||||
|
private StructuralOvertimeContingentCalculator $structuralCalculator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<OvertimeContingentRow>
|
||||||
|
*/
|
||||||
|
public function buildRows(array $employees, int $civilYear): array
|
||||||
|
{
|
||||||
|
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
|
||||||
|
$payments = $this->rttPaymentRepository->findByEmployeesAndYears(
|
||||||
|
$employees,
|
||||||
|
[$civilYear, $civilYear + 1],
|
||||||
|
);
|
||||||
|
|
||||||
|
$byEmployee = [];
|
||||||
|
foreach ($payments as $payment) {
|
||||||
|
$employeeId = $payment->getEmployee()?->getId();
|
||||||
|
if (null === $employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$byEmployee[$employeeId][] = $payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (null === $employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$employeePayments = $byEmployee[$employeeId] ?? [];
|
||||||
|
$paidMonths = $this->calculator->monthlyBaseMinutes($employeePayments, $civilYear);
|
||||||
|
$structuralMonths = $this->structuralCalculator->monthlyStructuralMinutes($employee, $civilYear);
|
||||||
|
|
||||||
|
$months = [];
|
||||||
|
for ($m = 1; $m <= 12; ++$m) {
|
||||||
|
$months[$m] = $paidMonths[$m] + $structuralMonths[$m];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = new OvertimeContingentRow(
|
||||||
|
employeeId: $employeeId,
|
||||||
|
employeeName: trim($employee->getLastName().' '.$employee->getFirstName()),
|
||||||
|
months: $months,
|
||||||
|
totalMinutes: array_sum($months),
|
||||||
|
capHours: $this->calculator->capHours($employee->getIsDriver()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les paiements RTT (stockés par exercice Juin N-1 -> Mai N + mois)
|
||||||
|
* en agrégats par ANNEE CIVILE (Janv-Déc). Heures payées = base25 + base50,
|
||||||
|
* hors majoration (bonus). Plafond : 350 h chauffeur, 220 h autres.
|
||||||
|
*/
|
||||||
|
final readonly class OvertimePaidContingentCalculator
|
||||||
|
{
|
||||||
|
public const int CAP_HOURS_DRIVER = 350;
|
||||||
|
public const int CAP_HOURS_DEFAULT = 220;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<EmployeeRttPayment> $payments paiements d'un employé
|
||||||
|
* (typiquement exercices civilYear et civilYear+1)
|
||||||
|
*
|
||||||
|
* @return array<int, int> clé 1..12 -> minutes base payées (base25+base50)
|
||||||
|
*/
|
||||||
|
public function monthlyBaseMinutes(iterable $payments, int $civilYear): array
|
||||||
|
{
|
||||||
|
$months = array_fill(1, 12, 0);
|
||||||
|
|
||||||
|
foreach ($payments as $payment) {
|
||||||
|
$month = $payment->getMonth();
|
||||||
|
$paymentCivilYear = $month >= 6 ? $payment->getYear() - 1 : $payment->getYear();
|
||||||
|
if ($paymentCivilYear !== $civilYear) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$months[$month] += $payment->getBase25Minutes() + $payment->getBase50Minutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $months;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<EmployeeRttPayment> $payments
|
||||||
|
*/
|
||||||
|
public function totalBaseMinutes(iterable $payments, int $civilYear): int
|
||||||
|
{
|
||||||
|
return array_sum($this->monthlyBaseMinutes($payments, $civilYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capHours(bool $isDriver): int
|
||||||
|
{
|
||||||
|
return $isDriver ? self::CAP_HOURS_DRIVER : self::CAP_HOURS_DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heures supplémentaires « structurelles » payées chaque mois pour les contrats
|
||||||
|
* au-dessus de 35h (hors forfait/intérim) : les (weeklyHours − 35) h/semaine
|
||||||
|
* au-delà de la durée légale sont payées chaque mois, lissées sur l'année :
|
||||||
|
* (weeklyHours − 35) × 52/12 h/mois = (weeklyHours − 35) × 260 min/mois.
|
||||||
|
*
|
||||||
|
* Ces heures ne transitent pas par les paiements RTT (la référence d'un 39h est
|
||||||
|
* 39h, pas 35h) mais comptent dans le contingent légal d'heures supplémentaires.
|
||||||
|
* Elles sont proratisées aux jours réellement sous contrat dans chaque mois.
|
||||||
|
*/
|
||||||
|
final readonly class StructuralOvertimeContingentCalculator
|
||||||
|
{
|
||||||
|
/** 60 min × 52 semaines / 12 mois = minutes mensuelles par heure hebdo au-delà de 35h. */
|
||||||
|
private const int MINUTES_PER_WEEKLY_HOUR_PER_MONTH = 260;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int> clé 1..12 -> minutes structurelles payées (proratisées)
|
||||||
|
*/
|
||||||
|
public function monthlyStructuralMinutes(Employee $employee, int $civilYear): array
|
||||||
|
{
|
||||||
|
$accumulated = array_fill(1, 12, 0.0);
|
||||||
|
|
||||||
|
foreach ($employee->getContractPeriods() as $period) {
|
||||||
|
$contract = $period->getContract();
|
||||||
|
if (null === $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $contract->getType();
|
||||||
|
if (ContractType::FORFAIT === $type || ContractType::INTERIM === $type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeklyHours = $contract->getWeeklyHours();
|
||||||
|
if (null === $weeklyHours || $weeklyHours <= 35) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullMonthlyMinutes = ($weeklyHours - 35) * self::MINUTES_PER_WEEKLY_HOUR_PER_MONTH;
|
||||||
|
$periodStart = $period->getStartDate();
|
||||||
|
$periodEnd = $period->getEndDate();
|
||||||
|
|
||||||
|
for ($month = 1; $month <= 12; ++$month) {
|
||||||
|
$monthStart = new DateTimeImmutable(sprintf('%04d-%02d-01', $civilYear, $month));
|
||||||
|
$monthEnd = $monthStart->modify('last day of this month');
|
||||||
|
$daysInMonth = (int) $monthEnd->format('d');
|
||||||
|
|
||||||
|
$overlapStart = $periodStart > $monthStart ? $periodStart : $monthStart;
|
||||||
|
$overlapEnd = (null !== $periodEnd && $periodEnd < $monthEnd) ? $periodEnd : $monthEnd;
|
||||||
|
if ($overlapStart > $overlapEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$overlapDays = $overlapStart->diff($overlapEnd)->days + 1;
|
||||||
|
$accumulated[$month] += $fullMonthlyMinutes * $overlapDays / $daysInMonth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = [];
|
||||||
|
for ($month = 1; $month <= 12; ++$month) {
|
||||||
|
$months[$month] = (int) round($accumulated[$month]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $months;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totalStructuralMinutes(Employee $employee, int $civilYear): int
|
||||||
|
{
|
||||||
|
return array_sum($this->monthlyStructuralMinutes($employee, $civilYear));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class YearlyHoursExportBuilder
|
|||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private NightHoursCalculator $nightHoursCalculator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -541,13 +542,11 @@ class YearlyHoursExportBuilder
|
|||||||
];
|
];
|
||||||
|
|
||||||
$totalMinutes = 0;
|
$totalMinutes = 0;
|
||||||
$nightMinutes = 0;
|
|
||||||
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
foreach ($ranges as [$from, $to]) {
|
||||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
|
||||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||||
|
|
||||||
return new WorkMetrics(
|
return new WorkMetrics(
|
||||||
@@ -596,35 +595,6 @@ class YearlyHoursExportBuilder
|
|||||||
return max(0, $end - $start);
|
return max(0, $end - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
|
||||||
{
|
|
||||||
$interval = $this->resolveInterval($from, $to);
|
|
||||||
if (null === $interval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$start, $end] = $interval;
|
|
||||||
$windows = [[0, 360], [1260, 1440]];
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
|
||||||
$shift = $dayOffset * 1440;
|
|
||||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
|
||||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
|
||||||
{
|
|
||||||
$start = max($startA, $startB);
|
|
||||||
$end = min($endA, $endB);
|
|
||||||
|
|
||||||
return max(0, $end - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatMinutes(int $minutes): string
|
private function formatMinutes(int $minutes): string
|
||||||
{
|
{
|
||||||
if (0 === $minutes) {
|
if (0 === $minutes) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Repository\AuditLogRepository;
|
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
@@ -14,11 +14,12 @@ use Symfony\Component\HttpFoundation\RequestStack;
|
|||||||
|
|
||||||
class AuditLogProvider implements ProviderInterface
|
class AuditLogProvider implements ProviderInterface
|
||||||
{
|
{
|
||||||
private const PER_PAGE = 50;
|
private const DEFAULT_PER_PAGE = 10;
|
||||||
|
private const ALLOWED_PER_PAGE = [10, 25, 50, 100];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly AuditLogRepository $auditLogRepository,
|
private readonly AuditLogReadRepositoryInterface $auditLogRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
|
||||||
@@ -28,20 +29,33 @@ class AuditLogProvider implements ProviderInterface
|
|||||||
return new JsonResponse(['items' => [], 'total' => 0]);
|
return new JsonResponse(['items' => [], 'total' => 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$employeeId = $request->query->get('employeeId');
|
$query = $request->query;
|
||||||
$from = $request->query->get('from');
|
$all = $query->all();
|
||||||
$to = $request->query->get('to');
|
|
||||||
$entityType = $request->query->get('entityType');
|
$employeeId = $query->get('employeeId');
|
||||||
$page = max(1, (int) $request->query->get('page', '1'));
|
$from = $query->get('from');
|
||||||
|
$to = $query->get('to');
|
||||||
|
$page = max(1, (int) $query->get('page', '1'));
|
||||||
|
|
||||||
|
$perPage = (int) $query->get('perPage', (string) self::DEFAULT_PER_PAGE);
|
||||||
|
if (!in_array($perPage, self::ALLOWED_PER_PAGE, true)) {
|
||||||
|
$perPage = self::DEFAULT_PER_PAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityTypes = $this->normalizeList($all['entityType'] ?? null);
|
||||||
|
$actions = $this->normalizeList($all['action'] ?? null);
|
||||||
|
$username = $this->normalizeString($query->get('username'));
|
||||||
|
$ip = $this->normalizeString($query->get('ip'));
|
||||||
|
$device = $this->normalizeString($query->get('device'));
|
||||||
|
$employee = $this->normalizeString($query->get('employee'));
|
||||||
|
|
||||||
$empId = $employeeId ? (int) $employeeId : null;
|
$empId = $employeeId ? (int) $employeeId : null;
|
||||||
$fromDt = $from ? new DateTimeImmutable($from) : null;
|
$fromDt = $from ? new DateTimeImmutable((string) $from) : null;
|
||||||
$toDt = $to ? new DateTimeImmutable($to) : null;
|
$toDt = $to ? new DateTimeImmutable((string) $to) : null;
|
||||||
$type = $entityType ?: null;
|
$offset = ($page - 1) * $perPage;
|
||||||
$offset = ($page - 1) * self::PER_PAGE;
|
|
||||||
|
|
||||||
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type);
|
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee);
|
||||||
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset);
|
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee, $perPage, $offset);
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($logs as $log) {
|
foreach ($logs as $log) {
|
||||||
@@ -60,6 +74,10 @@ class AuditLogProvider implements ProviderInterface
|
|||||||
'description' => $log->getDescription(),
|
'description' => $log->getDescription(),
|
||||||
'changes' => $log->getChanges(),
|
'changes' => $log->getChanges(),
|
||||||
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
|
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
|
||||||
|
'ipAddress' => $log->getIpAddress(),
|
||||||
|
'userAgent' => $log->getUserAgent(),
|
||||||
|
'deviceLabel' => $log->getDeviceLabel(),
|
||||||
|
'deviceId' => $log->getDeviceId(),
|
||||||
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
|
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -68,7 +86,27 @@ class AuditLogProvider implements ProviderInterface
|
|||||||
'items' => $items,
|
'items' => $items,
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'perPage' => self::PER_PAGE,
|
'perPage' => $perPage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null|list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeList(mixed $value): ?array
|
||||||
|
{
|
||||||
|
$list = array_values(array_filter(
|
||||||
|
(array) ($value ?? []),
|
||||||
|
static fn ($v): bool => is_string($v) && '' !== trim($v),
|
||||||
|
));
|
||||||
|
|
||||||
|
return [] === $list ? null : $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$trimmed = trim((string) ($value ?? ''));
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\EmployeeOvertimeContingent;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use App\Service\WorkHours\OvertimePaidContingentCalculator;
|
||||||
|
use App\Service\WorkHours\StructuralOvertimeContingentCalculator;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
final readonly class EmployeeOvertimeContingentProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
|
private OvertimePaidContingentCalculator $calculator,
|
||||||
|
private StructuralOvertimeContingentCalculator $structuralCalculator,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeOvertimeContingent
|
||||||
|
{
|
||||||
|
$employeeId = (int) ($uriVariables['id'] ?? 0);
|
||||||
|
if ($employeeId <= 0) {
|
||||||
|
throw new UnprocessableEntityHttpException('id must be a positive integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $this->employeeRepository->find($employeeId);
|
||||||
|
if (!$employee instanceof Employee) {
|
||||||
|
throw new NotFoundHttpException('Employee not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
$year = (int) $request?->query->get('year', (string) (int) new DateTimeImmutable('now')->format('Y'));
|
||||||
|
if ($year < 2000 || $year > 2100) {
|
||||||
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Année civile Y = exercice Y (mois 1-5) + exercice Y+1 (mois 6-12).
|
||||||
|
$payments = array_merge(
|
||||||
|
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year),
|
||||||
|
$this->rttPaymentRepository->findByEmployeeAndYear($employee, $year + 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
$output = new EmployeeOvertimeContingent();
|
||||||
|
$output->year = $year;
|
||||||
|
$output->paidMinutes = $this->calculator->totalBaseMinutes($payments, $year)
|
||||||
|
+ $this->structuralCalculator->totalStructuralMinutes($employee, $year);
|
||||||
|
$output->isDriver = $employee->getIsDriver();
|
||||||
|
$output->capHours = $this->calculator->capHours($output->isDriver);
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user