Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d723c7631a | |||
| 029a09dc09 | |||
| 45c32989e5 | |||
| 77ae6820d7 | |||
| 0333270089 | |||
| 832751d1ed | |||
| c119db0b02 | |||
| a52c35e082 | |||
| a1af125c78 | |||
| 8e59e9fd6a | |||
| 4d4bdba914 | |||
| 74abecbe03 |
@@ -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 ###
|
||||||
|
|||||||
@@ -36,8 +36,13 @@
|
|||||||
- **É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).
|
- **É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_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`.
|
- **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`.
|
- **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.
|
||||||
|
- **Suppression / modification d'une plage de congés** (`calendar.vue`) : une absence = **une ligne par jour** en BDD (aucun lien entre les jours, cf. `expandAbsenceRange`), donc tout se gère côté frontend qui a la plage visible.
|
||||||
|
- **Supprimer** (`handleDelete`) : efface **toutes les absences de l'employé dont le jour tombe dans `[form.startDate ; form.endDate]`** (filtrage sur `absences.value`, boucle `deleteAbsence`), pas seulement le jour cliqué. Flux RH : clic sur un jour → drawer (début = fin) → étendre la date de fin → Supprimer. Jours sans absence ignorés ; jour validé (`isValid`/site) bloque sa propre ligne (backend). Confirmation avec nombre de jours + intervalle (`formatYmdToFr`) dès > 1 jour.
|
||||||
|
- **Modifier** (`handleSubmit`, branche `editingAbsence`) : **remplacement de bloc** — supprime l'**ancien bloc contigu** (jours adjacents de **même type**, en partant du jour cliqué **vers l'avant** via `shiftYmd`) **+** toute absence recouverte par la nouvelle plage, puis **`createAbsence`** sur la nouvelle plage (plus de `PATCH`). Corrige le bug historique : raccourcir ne laisse plus de **jours fantômes**, ré-étendre ne crée plus de **doublons**. Jamais de modification des jours **antérieurs** au jour cliqué ; confirmation « chevauche une autre » seulement si on écrase un **autre type**. La branche **Création** garde sa détection de chevauchement demi-journée (`getSegmentsForDate`).
|
||||||
|
- Backend `AbsenceWriteProcessor` (PATCH) **non touché** : il reste mono-jour en pratique car les écrans Heures/Heures Conducteurs verrouillent les dates du drawer (`lock-dates`), seul le calendrier reshape une plage. `updateAbsence` n'est plus appelé depuis `calendar.vue` (import retiré).
|
||||||
- **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
|
||||||
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
||||||
@@ -63,6 +68,7 @@
|
|||||||
- `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
|
||||||
@@ -187,11 +193,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)
|
||||||
@@ -202,8 +212,14 @@
|
|||||||
- 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`
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
- Système : entité `Notification` (table `notifications`, `recipient`/`actor`/`message`/`category`/`target`/`isRead`), cloche **admin-only** dans `AppTopNav.vue`, providers `/notifications/{unread,today,history}` + `POST /notifications/mark-all-read`. Création historique : `WorkHourSiteValidationProcessor` (1 notif/admin via `UserRepository::findAllAdmins`).
|
||||||
|
- **Fin de contrat (J-1 ouvré)** : commande cron quotidienne `app:contract:end-notifications` (crontab prod, ~6h ; option `--date`). Notifie les admins sur le **dernier jour ouvré avant** `endDate` (inclusif) de la **dernière** période d'un employé (changement de contrat enchaîné exclu). Week-ends + fériés sautés (`WorkingDayCalculator`, via `getHolidaysDayByYears` → applique `EXCLUDED_PUBLIC_HOLIDAYS`, donc **Lundi de Pentecôte traité comme jour ouvré**, cohérent avec le reste de l'app). Fenêtre couverte un jour J = `]J ; prochain_jour_ouvré(J)]`. Message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, target `/employees/{id}`, acteur null. Idempotent (`NotificationRepository::existsForRecipientCategoryTargetMessage`). Logique pure testée : `ContractEndNotificationPlanner` + `WorkingDayCalculator`. Front : `AppTopNav.vue` masque le span acteur si `actorName` vide. Doc : `doc/contract-end-notifications.md`.
|
||||||
|
|
||||||
## Backend Conventions
|
## Backend Conventions
|
||||||
- Prefer explicit DTOs over associative arrays
|
- Prefer explicit DTOs over associative arrays
|
||||||
- Business rules in backend (providers/processors/services), frontend is display/interaction only
|
- Business rules in backend (providers/processors/services), frontend is display/interaction only
|
||||||
|
|||||||
@@ -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.120'
|
app.version: '0.1.126'
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Notification de fin de contrat (veille du dernier jour)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Prévenir les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||||
|
salarié arrive au terme de son emploi.
|
||||||
|
|
||||||
|
## Déclenchement
|
||||||
|
Commande `app:contract:end-notifications`, lancée chaque jour par le crontab de production
|
||||||
|
(ex. `0 6 * * *`). Option `--date=YYYY-MM-DD` pour test/rattrapage. Logger `cron`.
|
||||||
|
|
||||||
|
## Règle métier
|
||||||
|
- **Cible** : la **dernière** période de contrat d'un employé (aucune période ne lui succède).
|
||||||
|
Un changement de contrat enchaîné (ex. CDD → CDI) ne notifie pas.
|
||||||
|
- **Quand** : sur le **dernier jour ouvré strictement avant** `endDate` (`endDate` est inclusif
|
||||||
|
= dernier jour travaillé). Les week-ends ET jours fériés (`PublicHolidayService`, zone
|
||||||
|
`metropole`) sont sautés. Concrètement, le jour J ouvré couvre les fins de contrat dans
|
||||||
|
l'intervalle `]J ; prochain_jour_ouvré(J)]` — un vendredi notifie ainsi les fins du
|
||||||
|
samedi, dimanche et lundi (mardi si lundi férié).
|
||||||
|
- **Jour de solidarité (Lundi de Pentecôte)** : traité comme un **jour ouvré** (choix
|
||||||
|
délibéré). Le calcul s'appuie sur `getHolidaysDayByYears`, qui applique
|
||||||
|
`EXCLUDED_PUBLIC_HOLIDAYS` (défaut = `"Lundi de Pentecôte"`) — la même liste de fériés que
|
||||||
|
le reste de l'app (heures, congés, RTT). On évite ainsi une définition de « férié »
|
||||||
|
divergente pour ce seul calcul ; et le jour de solidarité est, par nature, un jour travaillé
|
||||||
|
(admins présents → la cloche est vue). Une fin de contrat le mardi après Pentecôte est donc
|
||||||
|
notifiée le Lundi de Pentecôte, pas le vendredi précédent.
|
||||||
|
- **Destinataires** : tous les `ROLE_ADMIN`.
|
||||||
|
- **Message** : `Fin de {CDI|CDD|Intérim} de {Prénom Nom} le {dd/mm/yyyy}`, catégorie
|
||||||
|
`Contrat`, cible `/employees/{id}`, sans acteur.
|
||||||
|
|
||||||
|
## Idempotence
|
||||||
|
Avant création, on vérifie l'absence d'une notif identique
|
||||||
|
`(recipient, category='Contrat', target, message)`. Le message étant unique par
|
||||||
|
(employé + date + nature), relancer la commande le même jour ne crée aucun doublon.
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
- `App\Service\Notification\WorkingDayCalculator` — jour ouvré / prochain jour ouvré.
|
||||||
|
- `App\Service\Notification\ContractEndNotificationPlanner` — sélection + message (pur, testé).
|
||||||
|
- `App\Service\Notification\ContractEndNotificationService` — persistance (1 notif/admin).
|
||||||
|
- `App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees`,
|
||||||
|
`NotificationRepository::existsForRecipientCategoryTargetMessage`.
|
||||||
|
- Pas de migration : réutilise la table `notifications`.
|
||||||
@@ -78,6 +78,17 @@ Documents complementaires:
|
|||||||
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
|
||||||
- demi-journée: dégradé diagonal
|
- demi-journée: dégradé diagonal
|
||||||
- journée complète: fond plein
|
- journée complète: fond plein
|
||||||
|
- Suppression d'une plage depuis le Calendrier:
|
||||||
|
- clic sur un jour d'une plage → le drawer s'ouvre sur ce jour (début = fin = jour cliqué)
|
||||||
|
- on étend la **date de fin** (ou de début) pour couvrir la plage à effacer, puis bouton **Supprimer**
|
||||||
|
- **toutes** les absences de l'employé dont le jour tombe dans la plage sélectionnée sont supprimées (1 ligne/jour en BDD)
|
||||||
|
- les jours de la plage sans absence sont ignorés (aucune erreur) ; un jour validé (`isValid`/site) bloque sa propre suppression
|
||||||
|
- confirmation unique avant suppression ; au-delà de 1 jour le message rappelle le nombre de jours et l'intervalle
|
||||||
|
- Modification d'une plage depuis le Calendrier (bouton **Modifier**):
|
||||||
|
- une absence n'a **aucun lien** entre ses jours en BDD (1 ligne/jour). Modifier réalise donc un **remplacement de bloc** : on supprime l'ancien **bloc contigu** (jours adjacents de **même type**, en partant du jour cliqué **vers l'avant**) puis on **recrée** la nouvelle plage
|
||||||
|
- corrige le bug historique du PATCH : raccourcir une plage ne laisse plus de **jours fantômes** au-delà de la nouvelle fin, et ré-étendre ne crée plus de **doublons**
|
||||||
|
- les jours **antérieurs** au jour cliqué ne sont jamais touchés ; toute absence d'un autre type recouverte par la nouvelle plage déclenche une confirmation « chevauche une autre »
|
||||||
|
- implémenté côté frontend (`calendar.vue::handleSubmit`) car le backend ne peut pas reconstituer le bloc sans identifiant de groupe ; sans danger sur les écrans Heures/Heures Conducteurs où les dates du drawer sont verrouillées (`lock-dates`), donc le PATCH y reste mono-jour
|
||||||
- Visibilité des employés dans le Calendrier:
|
- Visibilité des employés dans le Calendrier:
|
||||||
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
|
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
|
||||||
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
|
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
|
||||||
@@ -486,6 +497,13 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
|||||||
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
||||||
- destinataires: utilisateurs `ROLE_ADMIN`
|
- destinataires: utilisateurs `ROLE_ADMIN`
|
||||||
|
|
||||||
|
- **Fin de contrat (J-1 ouvré)** : une commande quotidienne (`app:contract:end-notifications`)
|
||||||
|
notifie tous les admins, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||||
|
salarié arrive au terme de son emploi. Cible = **dernière** période de l'employé (un
|
||||||
|
changement de contrat enchaîné ne notifie pas). Week-ends et fériés sautés. Message
|
||||||
|
« Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien vers la fiche employé,
|
||||||
|
sans acteur. Idempotente. Détail : `doc/contract-end-notifications.md`.
|
||||||
|
|
||||||
## 16) Export PDF des heures annuelles
|
## 16) Export PDF des heures annuelles
|
||||||
|
|
||||||
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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,974 @@
|
|||||||
|
# Notification de fin de contrat (veille du dernier jour) — 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:** Prévenir automatiquement les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un salarié arrive au terme de son emploi.
|
||||||
|
|
||||||
|
**Architecture:** Une commande console quotidienne (`app:contract:end-notifications`, déclenchée par le crontab prod) délègue à un service. La logique « dure » (saut des week-ends/fériés, fenêtre de détection, libellé du message) vit dans deux collaborateurs purs et testés en isolation (`WorkingDayCalculator`, `ContractEndNotificationPlanner`). Le service oriente le résultat vers la création de `Notification` (une par admin), avec déduplication par message exact. Aucune migration : on réutilise la table `notifications` existante.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 7 + API Platform + Doctrine ORM (backend), PHPUnit (tests), Nuxt 4 / Vue 3 (front). Conteneur de test Docker `php-sirh-fpm`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **PHP** : `declare(strict_types=1);` en tête de chaque fichier ; classes services en `final readonly` quand sans état mutable (suivre `RttRolloverCommand`, `HolidayVirtualHoursResolver`).
|
||||||
|
- **Commit message** : format `<type> : <message>` — **espace obligatoire avant les deux-points** (hook pre-commit), types autorisés : `feat, fix, docs, refactor, test, chore`, etc. Exemple : `feat : add working day calculator`.
|
||||||
|
- **Pre-commit hook** : lance php-cs-fixer + **toute** la suite PHPUnit. Tout commit échoue si un test casse → garder la suite verte à chaque commit.
|
||||||
|
- **Lancer les tests** : `make test` (suite complète) ou ciblé `make test FILES="--filter NomDuTest"` (= `docker exec -u www-data php-sirh-fpm php vendor/bin/phpunit ...`).
|
||||||
|
- **Fériés** : zone `'metropole'`, via `PublicHolidayServiceInterface::getHolidaysDayByYears('metropole', $year)` → tableau `['Y-m-d' => 'libellé']` (suivre `HolidayVirtualHoursResolver::isPublicHoliday`).
|
||||||
|
- **Catégorie** notif = `'Contrat'` ; **target** = `'/employees/{id}'` ; **acteur** = `null` ; destinataires = `UserRepository::findAllAdmins()`.
|
||||||
|
- **Règles projet (CLAUDE.md)** : toute évolution fonctionnelle MET À JOUR `doc/` ET `frontend/data/documentation-content.ts` dans la même intervention ; mettre à jour `CLAUDE.md` à la fin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Backend — nouveaux**
|
||||||
|
- `src/Service/Notification/WorkingDayCalculator.php` — jour ouvré (week-end + férié), prochain jour ouvré. Pur (dépend de `PublicHolidayServiceInterface`).
|
||||||
|
- `src/Service/Notification/ContractEndNotice.php` — DTO immuable `{ ?int employeeId, string message }`.
|
||||||
|
- `src/Service/Notification/ContractEndNotificationPlanner.php` — sélection des candidats + construction du message. Pur (dépend de `WorkingDayCalculator`).
|
||||||
|
- `src/Service/Notification/ContractEndNotificationResult.php` — DTO résultat `{ int notificationsCreated, int contractsMatched }`.
|
||||||
|
- `src/Service/Notification/ContractEndNotificationService.php` — orchestration (repos + EntityManager).
|
||||||
|
- `src/Command/ContractEndNotificationCommand.php` — commande `app:contract:end-notifications`.
|
||||||
|
|
||||||
|
**Backend — modifiés**
|
||||||
|
- `src/Repository/EmployeeContractPeriodRepository.php` — `findLatestPeriodsForAllEmployees()`.
|
||||||
|
- `src/Repository/NotificationRepository.php` — `existsForRecipientCategoryTargetMessage()`.
|
||||||
|
|
||||||
|
**Tests — nouveaux**
|
||||||
|
- `tests/Service/Notification/WorkingDayCalculatorTest.php`
|
||||||
|
- `tests/Service/Notification/ContractEndNotificationPlannerTest.php`
|
||||||
|
|
||||||
|
**Frontend — modifié**
|
||||||
|
- `frontend/components/AppTopNav.vue` — gérer `actorName` vide (ligne 65).
|
||||||
|
|
||||||
|
**Docs — modifiés/nouveaux**
|
||||||
|
- `doc/functional-rules.md` (section 15), `doc/contract-end-notifications.md` (nouveau), `frontend/data/documentation-content.ts`, `CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : WorkingDayCalculator (jour ouvré : week-end + férié)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/WorkingDayCalculator.php`
|
||||||
|
- Test: `tests/Service/Notification/WorkingDayCalculatorTest.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `App\Service\PublicHolidayServiceInterface::getHolidaysDayByYears(string $zone, string $year): array`
|
||||||
|
- Produces:
|
||||||
|
- `WorkingDayCalculator::__construct(PublicHolidayServiceInterface $holidays)`
|
||||||
|
- `WorkingDayCalculator::isWorkingDay(DateTimeImmutable $date): bool`
|
||||||
|
- `WorkingDayCalculator::nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable` — premier jour ouvré **strictement après** `$date` (heure remise à 00:00:00).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`tests/Service/Notification/WorkingDayCalculatorTest.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WorkingDayCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
private function calculator(): WorkingDayCalculator
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
// Lundi 14/07/2025 férié
|
||||||
|
'2025-07-14' => 'Fête nationale',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new WorkingDayCalculator($holidays);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeekdayIsWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07/2025
|
||||||
|
self::assertTrue($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-08')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaturdayAndSundayAreNotWorkingDays(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-12'))); // samedi
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-13'))); // dimanche
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublicHolidayIsNotWorkingDay(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-14'))); // lundi férié
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromWeekdayIsTomorrow(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> Mercredi 09/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-09',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-08'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromFridaySkipsWeekend(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 -> lundi 14/07 est férié -> mardi 15/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-15',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-11'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter WorkingDayCalculatorTest"`
|
||||||
|
Expected: FAIL — `Class "App\Service\Notification\WorkingDayCalculator" not found`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
`src/Service/Notification/WorkingDayCalculator.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class WorkingDayCalculator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PublicHolidayServiceInterface $holidays,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isWorkingDay(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
$dayOfWeek = (int) $date->format('N'); // 1 (lundi) .. 7 (dimanche)
|
||||||
|
if ($dayOfWeek >= 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->isPublicHoliday($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$candidate = $date->modify('+1 day')->setTime(0, 0, 0);
|
||||||
|
while (!$this->isWorkingDay($candidate)) {
|
||||||
|
$candidate = $candidate->modify('+1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPublicHoliday(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$holidays = $this->holidays->getHolidaysDayByYears('metropole', $date->format('Y'));
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($holidays[$date->format('Y-m-d')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter WorkingDayCalculatorTest"`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/WorkingDayCalculator.php tests/Service/Notification/WorkingDayCalculatorTest.php
|
||||||
|
git commit -m "feat : add working day calculator (weekend + holiday aware)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : ContractEndNotice DTO
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotice.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `ContractEndNotice::__construct(public ?int $employeeId, public string $message)` (lecture seule).
|
||||||
|
|
||||||
|
Pas de test dédié (DTO sans logique) — sera couvert par le test du planner (Task 3).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the DTO**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotice.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotice
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?int $employeeId,
|
||||||
|
public string $message,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/ContractEndNotice.php
|
||||||
|
git commit -m "feat : add contract end notice DTO"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : ContractEndNotificationPlanner (fenêtre + message)
|
||||||
|
|
||||||
|
Sélectionne, parmi les **dernières périodes** de chaque employé, celles dont la fin tombe dans la fenêtre `]today, nextWorkingDay(today)]`, et construit le message FR.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotificationPlanner.php`
|
||||||
|
- Test: `tests/Service/Notification/ContractEndNotificationPlannerTest.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes:
|
||||||
|
- `WorkingDayCalculator::isWorkingDay(...)`, `::nextWorkingDay(...)` (Task 1)
|
||||||
|
- `App\Entity\EmployeeContractPeriod::getEndDate(): ?DateTimeImmutable`, `::getEmployee(): ?Employee`, `::getContractNatureEnum(): App\Enum\ContractNature`
|
||||||
|
- `App\Entity\Employee::getId(): ?int`, `::getFirstName(): string`, `::getLastName(): string`
|
||||||
|
- `App\Enum\ContractNature` (cases `CDI`, `CDD`, `INTERIM`)
|
||||||
|
- Produces:
|
||||||
|
- `ContractEndNotificationPlanner::__construct(WorkingDayCalculator $calculator)`
|
||||||
|
- `ContractEndNotificationPlanner::plan(array $latestPeriods, DateTimeImmutable $today): array` — `@param EmployeeContractPeriod[] $latestPeriods` → `@return ContractEndNotice[]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`tests/Service/Notification/ContractEndNotificationPlannerTest.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Service\Notification\ContractEndNotificationPlanner;
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ContractEndNotificationPlannerTest extends TestCase
|
||||||
|
{
|
||||||
|
private function planner(): ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
'2025-07-14' => 'Fête nationale', // lundi 14/07 férié
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new ContractEndNotificationPlanner(new WorkingDayCalculator($holidays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function period(
|
||||||
|
string $firstName,
|
||||||
|
string $lastName,
|
||||||
|
?string $endDate,
|
||||||
|
ContractNature $nature = ContractNature::CDD,
|
||||||
|
): EmployeeContractPeriod {
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName($firstName)->setLastName($lastName);
|
||||||
|
|
||||||
|
$period = new EmployeeContractPeriod();
|
||||||
|
$period->setEmployee($employee)
|
||||||
|
->setContractNature($nature)
|
||||||
|
->setEndDate($endDate === null ? null : new DateTimeImmutable($endDate))
|
||||||
|
;
|
||||||
|
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNotifiesContractEndingTomorrowOnAWeekday(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> fin mercredi 09/07
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-09')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(1, $notices);
|
||||||
|
self::assertSame('Fin de CDD de Jean Dupont le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFridayNotifiesContractsEndingOverTheWeekendAndMonday(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 ; lundi 14/07 férié -> prochain ouvré = mardi 15/07.
|
||||||
|
// Fenêtre ]11/07 ; 15/07] -> samedi 12, dimanche 13, lundi 14, mardi 15.
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[
|
||||||
|
$this->period('A', 'Sat', '2025-07-12'), // samedi -> inclus
|
||||||
|
$this->period('B', 'Mon', '2025-07-14'), // lundi férié -> inclus
|
||||||
|
$this->period('C', 'Tue', '2025-07-15'), // mardi (= borne haute) -> inclus
|
||||||
|
$this->period('D', 'Wed', '2025-07-16'), // mercredi -> hors fenêtre
|
||||||
|
],
|
||||||
|
new DateTimeImmutable('2025-07-11'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(3, $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresOpenEndedContract(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', null, ContractNature::CDI)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresContractEndingToday(): void
|
||||||
|
{
|
||||||
|
// fin = today -> trop tard, pas de notif (on notifie la veille)
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-08')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsNothingWhenTodayIsNotAWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Samedi 12/07 -> aucun jour chômé ne génère de notif
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-14')],
|
||||||
|
new DateTimeImmutable('2025-07-12'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInterimNatureLabel(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Marie', 'Martin', '2025-07-09', ContractNature::INTERIM)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Fin de Intérim de Marie Martin le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter ContractEndNotificationPlannerTest"`
|
||||||
|
Expected: FAIL — `Class "App\Service\Notification\ContractEndNotificationPlanner" not found`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotificationPlanner.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WorkingDayCalculator $calculator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param EmployeeContractPeriod[] $latestPeriods
|
||||||
|
*
|
||||||
|
* @return ContractEndNotice[]
|
||||||
|
*/
|
||||||
|
public function plan(array $latestPeriods, DateTimeImmutable $today): array
|
||||||
|
{
|
||||||
|
$today = $today->setTime(0, 0, 0);
|
||||||
|
if (!$this->calculator->isWorkingDay($today)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$upperBound = $this->calculator->nextWorkingDay($today);
|
||||||
|
|
||||||
|
$notices = [];
|
||||||
|
foreach ($latestPeriods as $period) {
|
||||||
|
$endDate = $period->getEndDate();
|
||||||
|
if (null === $endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDate = $endDate->setTime(0, 0, 0);
|
||||||
|
if ($endDate <= $today || $endDate > $upperBound) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $period->getEmployee();
|
||||||
|
if (null === $employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'Fin de %s de %s %s le %s',
|
||||||
|
$this->natureLabel($period->getContractNatureEnum()),
|
||||||
|
$employee->getFirstName(),
|
||||||
|
$employee->getLastName(),
|
||||||
|
$endDate->format('d/m/Y'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$notices[] = new ContractEndNotice($employee->getId(), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function natureLabel(ContractNature $nature): string
|
||||||
|
{
|
||||||
|
return match ($nature) {
|
||||||
|
ContractNature::CDI => 'CDI',
|
||||||
|
ContractNature::CDD => 'CDD',
|
||||||
|
ContractNature::INTERIM => 'Intérim',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `make test FILES="--filter ContractEndNotificationPlannerTest"`
|
||||||
|
Expected: PASS (6 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/ContractEndNotificationPlanner.php tests/Service/Notification/ContractEndNotificationPlannerTest.php
|
||||||
|
git commit -m "feat : add contract end notification planner"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Méthodes de repository
|
||||||
|
|
||||||
|
Deux requêtes : la dernière période par employé, et le test d'existence anti-doublon.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Repository/EmployeeContractPeriodRepository.php`
|
||||||
|
- Modify: `src/Repository/NotificationRepository.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees(): array` (`@return EmployeeContractPeriod[]` — une période par employé, celle de `startDate` max).
|
||||||
|
- `NotificationRepository::existsForRecipientCategoryTargetMessage(User $recipient, string $category, string $target, string $message): bool`.
|
||||||
|
|
||||||
|
> Pas de test unitaire (accès Doctrine, pas de tests d'intégration DB dans ce projet) — vérifié manuellement en Task 6.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `findLatestPeriodsForAllEmployees` to EmployeeContractPeriodRepository**
|
||||||
|
|
||||||
|
Ajouter cette méthode dans `src/Repository/EmployeeContractPeriodRepository.php` (après `findLatestPeriod`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Latest contract period (max startDate) for every employee that has at least one.
|
||||||
|
*
|
||||||
|
* @return EmployeeContractPeriod[]
|
||||||
|
*/
|
||||||
|
public function findLatestPeriodsForAllEmployees(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.startDate = (
|
||||||
|
SELECT MAX(p2.startDate)
|
||||||
|
FROM App\Entity\EmployeeContractPeriod p2
|
||||||
|
WHERE p2.employee = p.employee
|
||||||
|
)')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `existsForRecipientCategoryTargetMessage` to NotificationRepository**
|
||||||
|
|
||||||
|
Ajouter dans `src/Repository/NotificationRepository.php` (après `markAllReadByRecipient`) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function existsForRecipientCategoryTargetMessage(
|
||||||
|
User $recipient,
|
||||||
|
string $category,
|
||||||
|
string $target,
|
||||||
|
string $message,
|
||||||
|
): bool {
|
||||||
|
$id = $this->createQueryBuilder('n')
|
||||||
|
->select('n.id')
|
||||||
|
->andWhere('n.recipient = :recipient')
|
||||||
|
->andWhere('n.category = :category')
|
||||||
|
->andWhere('n.target = :target')
|
||||||
|
->andWhere('n.message = :message')
|
||||||
|
->setParameter('recipient', $recipient)
|
||||||
|
->setParameter('category', $category)
|
||||||
|
->setParameter('target', $target)
|
||||||
|
->setParameter('message', $message)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return null !== $id;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `User` est déjà importé dans `NotificationRepository` (`use App\Entity\User;`). Si l'import manquait, l'ajouter.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the suite still passes**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (suite complète, aucun test cassé).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Repository/EmployeeContractPeriodRepository.php src/Repository/NotificationRepository.php
|
||||||
|
git commit -m "feat : add repository queries for contract end notifications"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Service + Result DTO + Command
|
||||||
|
|
||||||
|
Assemble la détection (planner) et la persistance (Notification par admin, dédupliquée), exposée par une commande console.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotificationResult.php`
|
||||||
|
- Create: `src/Service/Notification/ContractEndNotificationService.php`
|
||||||
|
- Create: `src/Command/ContractEndNotificationCommand.php`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes:
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees()` (Task 4)
|
||||||
|
- `NotificationRepository::existsForRecipientCategoryTargetMessage(...)` (Task 4)
|
||||||
|
- `App\Repository\UserRepository::findAllAdmins(): array` (existant)
|
||||||
|
- `ContractEndNotificationPlanner::plan(...)` (Task 3) renvoyant `ContractEndNotice[]`
|
||||||
|
- `App\Entity\Notification` setters `setRecipient/setMessage/setCategory/setTarget`
|
||||||
|
- `Doctrine\ORM\EntityManagerInterface`
|
||||||
|
- Produces:
|
||||||
|
- `ContractEndNotificationResult::__construct(public int $notificationsCreated, public int $contractsMatched)`
|
||||||
|
- `ContractEndNotificationService::run(DateTimeImmutable $today): ContractEndNotificationResult`
|
||||||
|
- Commande `app:contract:end-notifications` avec option `--date=YYYY-MM-DD`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the Result DTO**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotificationResult.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $notificationsCreated,
|
||||||
|
public int $contractsMatched,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the service**
|
||||||
|
|
||||||
|
`src/Service/Notification/ContractEndNotificationService.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Notification;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationService
|
||||||
|
{
|
||||||
|
private const CATEGORY = 'Contrat';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
private NotificationRepository $notificationRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private ContractEndNotificationPlanner $planner,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(DateTimeImmutable $today): ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
$latestPeriods = $this->periodRepository->findLatestPeriodsForAllEmployees();
|
||||||
|
$notices = $this->planner->plan($latestPeriods, $today);
|
||||||
|
|
||||||
|
if ([] === $notices) {
|
||||||
|
return new ContractEndNotificationResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$admins = $this->userRepository->findAllAdmins();
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($notices as $notice) {
|
||||||
|
if (null === $notice->employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = '/employees/'.$notice->employeeId;
|
||||||
|
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($this->notificationRepository->existsForRecipientCategoryTargetMessage(
|
||||||
|
$admin,
|
||||||
|
self::CATEGORY,
|
||||||
|
$target,
|
||||||
|
$notice->message,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = new Notification();
|
||||||
|
$notification->setRecipient($admin)
|
||||||
|
->setMessage($notice->message)
|
||||||
|
->setCategory(self::CATEGORY)
|
||||||
|
->setTarget($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($notification);
|
||||||
|
++$created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new ContractEndNotificationResult($created, \count($notices));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the command**
|
||||||
|
|
||||||
|
`src/Command/ContractEndNotificationCommand.php` :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Service\Notification\ContractEndNotificationService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:contract:end-notifications',
|
||||||
|
description: 'Notify admins on the last working day before a contract ends.'
|
||||||
|
)]
|
||||||
|
final class ContractEndNotificationCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContractEndNotificationService $service,
|
||||||
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'date',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Override the reference day (YYYY-MM-DD) for testing or manual catch-up.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$dateOption = $input->getOption('date');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$today = \is_string($dateOption) && '' !== $dateOption
|
||||||
|
? new DateTimeImmutable($dateOption)
|
||||||
|
: new DateTimeImmutable('today');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$io->error(sprintf('Invalid --date value: %s', $exception->getMessage()));
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->service->run($today);
|
||||||
|
|
||||||
|
$this->logger->info('Contract end notifications generated.', [
|
||||||
|
'date' => $today->format('Y-m-d'),
|
||||||
|
'contractsMatched' => $result->contractsMatched,
|
||||||
|
'notificationsCreated' => $result->notificationsCreated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'%d notification(s) créée(s) pour %d fin(s) de contrat (%s).',
|
||||||
|
$result->notificationsCreated,
|
||||||
|
$result->contractsMatched,
|
||||||
|
$today->format('Y-m-d'),
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the suite still passes and the command is registered**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected: PASS (suite complète).
|
||||||
|
|
||||||
|
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console list app:contract`
|
||||||
|
Expected: la commande `app:contract:end-notifications` apparaît dans la liste.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/Notification/ContractEndNotificationResult.php src/Service/Notification/ContractEndNotificationService.php src/Command/ContractEndNotificationCommand.php
|
||||||
|
git commit -m "feat : add contract end notification service and command"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 : Vérification manuelle de bout en bout (commande)
|
||||||
|
|
||||||
|
Confirme que la commande crée bien des notifications sur des données réelles, et qu'elle est idempotente.
|
||||||
|
|
||||||
|
**Files:** aucun (vérification).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Repérer un employé dont la dernière période finit bientôt**
|
||||||
|
|
||||||
|
Run (adapter la date au besoin) :
|
||||||
|
```bash
|
||||||
|
docker exec -t -u www-data php-sirh-fpm php bin/console dbal:run-sql \
|
||||||
|
"SELECT employee_id, MAX(start_date) AS s, end_date FROM employee_contract_periods GROUP BY employee_id HAVING end_date IS NOT NULL ORDER BY end_date DESC LIMIT 10"
|
||||||
|
```
|
||||||
|
Expected: liste d'employés avec leur dernière `end_date`. Choisir une `end_date` E pour viser un jour ouvré juste avant.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lancer la commande sur la veille ouvrée de E**
|
||||||
|
|
||||||
|
Run (remplacer `YYYY-MM-DD` par le dernier jour ouvré avant E) :
|
||||||
|
```bash
|
||||||
|
docker exec -t -u www-data php-sirh-fpm php bin/console app:contract:end-notifications --date=YYYY-MM-DD
|
||||||
|
```
|
||||||
|
Expected: `N notification(s) créée(s) pour M fin(s) de contrat...` avec M ≥ 1.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Vérifier l'idempotence (relancer la même commande)**
|
||||||
|
|
||||||
|
Run: même commande qu'au Step 2.
|
||||||
|
Expected: `0 notification(s) créée(s) pour M fin(s) de contrat...` (aucun doublon).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Vérifier le contenu en base**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec -t -u www-data php-sirh-fpm php bin/console dbal:run-sql \
|
||||||
|
"SELECT message, category, target, actor_id, is_read FROM notifications WHERE category='Contrat' ORDER BY id DESC LIMIT 5"
|
||||||
|
```
|
||||||
|
Expected: lignes `Fin de … de … le dd/mm/yyyy`, `category=Contrat`, `target=/employees/{id}`, `actor_id=NULL`, `is_read=0`.
|
||||||
|
|
||||||
|
> Aucune commande de commit ici — étape de vérification uniquement. Si un comportement diffère, revenir aux tasks concernées avant de continuer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 : Front — afficher le message sans acteur
|
||||||
|
|
||||||
|
La notif fin de contrat a `actorName` vide ; supprimer le span gras vide.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/AppTopNav.vue` (ligne 65)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remplacer la ligne de rendu du message**
|
||||||
|
|
||||||
|
Remplacer exactement (ligne 65) :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p class="text-black"><span v-if="notification.actorName" class="font-semibold capitalize">{{ notification.actorName }} </span>{{ notification.message }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Avec acteur : `**Jean** a validé les heures` (l'espace est dans le span). Sans acteur : `Fin de CDD de … le …` (pas de span, pas d'espace en tête).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Vérifier le typecheck front**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vue-tsc --noEmit -p tsconfig.json 2>&1 | head -20`
|
||||||
|
Expected: aucune nouvelle erreur liée à `AppTopNav.vue`. (Ne PAS lancer `npm run build`.)
|
||||||
|
|
||||||
|
> Si `vue-tsc` n'est pas disponible / trop lent, vérification visuelle suffisante : la modification est un simple `v-if` sur un span existant.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/AppTopNav.vue
|
||||||
|
git commit -m "feat : render actorless notifications without empty bold span"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 : Documentation
|
||||||
|
|
||||||
|
Mise à jour obligatoire (règles CLAUDE.md) : `doc/`, doc in-app, `CLAUDE.md`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `doc/contract-end-notifications.md`
|
||||||
|
- Modify: `doc/functional-rules.md` (section 15) Notifications)
|
||||||
|
- Modify: `frontend/data/documentation-content.ts`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Créer `doc/contract-end-notifications.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Notification de fin de contrat (veille du dernier jour)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
Prévenir les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||||
|
salarié arrive au terme de son emploi.
|
||||||
|
|
||||||
|
## Déclenchement
|
||||||
|
Commande `app:contract:end-notifications`, lancée chaque jour par le crontab de production
|
||||||
|
(ex. `0 6 * * *`). Option `--date=YYYY-MM-DD` pour test/rattrapage. Logger `cron`.
|
||||||
|
|
||||||
|
## Règle métier
|
||||||
|
- **Cible** : la **dernière** période de contrat d'un employé (aucune période ne lui succède).
|
||||||
|
Un changement de contrat enchaîné (ex. CDD → CDI) ne notifie pas.
|
||||||
|
- **Quand** : sur le **dernier jour ouvré strictement avant** `endDate` (`endDate` est inclusif
|
||||||
|
= dernier jour travaillé). Les week-ends ET jours fériés (`PublicHolidayService`, zone
|
||||||
|
`metropole`) sont sautés. Concrètement, le jour J ouvré couvre les fins de contrat dans
|
||||||
|
l'intervalle `]J ; prochain_jour_ouvré(J)]` — un vendredi notifie ainsi les fins du
|
||||||
|
samedi, dimanche et lundi (mardi si lundi férié).
|
||||||
|
- **Destinataires** : tous les `ROLE_ADMIN`.
|
||||||
|
- **Message** : `Fin de {CDI|CDD|Intérim} de {Prénom Nom} le {dd/mm/yyyy}`, catégorie
|
||||||
|
`Contrat`, cible `/employees/{id}`, sans acteur.
|
||||||
|
|
||||||
|
## Idempotence
|
||||||
|
Avant création, on vérifie l'absence d'une notif identique
|
||||||
|
`(recipient, category='Contrat', target, message)`. Le message étant unique par
|
||||||
|
(employé + date + nature), relancer la commande le même jour ne crée aucun doublon.
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
- `App\Service\Notification\WorkingDayCalculator` — jour ouvré / prochain jour ouvré.
|
||||||
|
- `App\Service\Notification\ContractEndNotificationPlanner` — sélection + message (pur, testé).
|
||||||
|
- `App\Service\Notification\ContractEndNotificationService` — persistance (1 notif/admin).
|
||||||
|
- `App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||||
|
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees`,
|
||||||
|
`NotificationRepository::existsForRecipientCategoryTargetMessage`.
|
||||||
|
- Pas de migration : réutilise la table `notifications`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Compléter `doc/functional-rules.md` section 15) Notifications**
|
||||||
|
|
||||||
|
Repérer la section `15) Notifications` (vers ligne 475). Ajouter, à la fin de la section, ce paragraphe :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **Fin de contrat (J-1 ouvré)** : une commande quotidienne (`app:contract:end-notifications`)
|
||||||
|
notifie tous les admins, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||||
|
salarié arrive au terme de son emploi. Cible = **dernière** période de l'employé (un
|
||||||
|
changement de contrat enchaîné ne notifie pas). Week-ends et fériés sautés. Message
|
||||||
|
« Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien vers la fiche employé,
|
||||||
|
sans acteur. Idempotente. Détail : `doc/contract-end-notifications.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Ajouter une entrée dans la doc in-app `frontend/data/documentation-content.ts`**
|
||||||
|
|
||||||
|
Localiser la section/article traitant des notifications (rechercher `Notification` dans le
|
||||||
|
fichier) au niveau d'accès `admin`. Y ajouter un bloc décrivant la notif fin de contrat. Si
|
||||||
|
aucun article notifications n'existe au niveau admin, ajouter un article dans la section la
|
||||||
|
plus proche (gestion employés / administration) avec `requiredLevel: 'admin'`. Exemple de bloc
|
||||||
|
à insérer dans le tableau `blocks` de l'article :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: "Chaque jour ouvré, l'application prévient les administrateurs (cloche en haut à droite) lorsqu'un salarié atteint le dernier jour ouvré avant la fin de son contrat. Le message indique la nature du contrat, le nom du salarié et la date de fin, et renvoie vers sa fiche. Les week-ends et jours fériés sont pris en compte : une fin de contrat le lundi est signalée dès le vendredi.",
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
> Respecter les types `DocBlock` de `frontend/types/documentation.ts` (vérifier le champ exact :
|
||||||
|
> `text` vs `content`) en s'alignant sur les blocs voisins existants du fichier.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Mettre à jour `CLAUDE.md`**
|
||||||
|
|
||||||
|
Sous la section `## Audit Logging` ou à la suite des sections « Notifications » existantes (il
|
||||||
|
n'y a pas encore de section Notifications dédiée dans CLAUDE.md — l'ajouter), insérer :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Notifications
|
||||||
|
- Système : entité `Notification` (table `notifications`, `recipient`/`actor`/`message`/`category`/`target`/`isRead`), cloche **admin-only** dans `AppTopNav.vue`, providers `/notifications/{unread,today,history}` + `POST /notifications/mark-all-read`. Création historique : `WorkHourSiteValidationProcessor` (1 notif/admin via `UserRepository::findAllAdmins`).
|
||||||
|
- **Fin de contrat (J-1 ouvré)** : commande cron quotidienne `app:contract:end-notifications` (crontab prod, ~6h ; option `--date`). Notifie les admins sur le **dernier jour ouvré avant** `endDate` (inclusif) de la **dernière** période d'un employé (changement de contrat enchaîné exclu). Week-ends + fériés sautés (`WorkingDayCalculator`). Fenêtre couverte un jour J = `]J ; prochain_jour_ouvré(J)]`. Message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, target `/employees/{id}`, acteur null. Idempotent (`NotificationRepository::existsForRecipientCategoryTargetMessage`). Logique pure testée : `ContractEndNotificationPlanner` + `WorkingDayCalculator`. Front : `AppTopNav.vue` masque le span acteur si `actorName` vide. Doc : `doc/contract-end-notifications.md`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add doc/contract-end-notifications.md doc/functional-rules.md frontend/data/documentation-content.ts CLAUDE.md
|
||||||
|
git commit -m "docs : document contract end notification feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (effectuée à la rédaction)
|
||||||
|
|
||||||
|
- **Couverture du spec** : détection (Task 1+3), idempotence (Task 4+5), création/destinataires (Task 5), commande cron (Task 5), front acteur vide (Task 7), tests (Task 1, 3), docs 4 fichiers (Task 8), vérif e2e (Task 6). ✅
|
||||||
|
- **Pas de placeholder** : tout le code est fourni ; les seules zones « à adapter » sont des valeurs runtime (dates réelles en Task 6) et l'emplacement exact de l'article doc in-app (Task 8 Step 3), explicitement cadrées. ✅
|
||||||
|
- **Cohérence des types** : `WorkingDayCalculator::{isWorkingDay,nextWorkingDay}`, `ContractEndNotificationPlanner::plan(array, DateTimeImmutable): ContractEndNotice[]`, `ContractEndNotice{employeeId,message}`, `ContractEndNotificationResult{notificationsCreated,contractsMatched}`, `findLatestPeriodsForAllEmployees()`, `existsForRecipientCategoryTargetMessage()` — noms identiques entre définition et usage. ✅
|
||||||
|
- **Note** : `findLatestPeriodsForAllEmployees` renvoie la période de `startDate` max par employé ; en cas d'égalité exacte de `startDate` (anomalie de données) plusieurs lignes peuvent remonter pour un même employé — sans impact fonctionnel (la dédup par message évite les doublons de notif).
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Notification de fin de contrat (veille du dernier jour) — Design
|
||||||
|
|
||||||
|
**Date :** 2026-06-24
|
||||||
|
**Branche :** feature/SIRH-43-ajouter-une-notif-la-veille-d-un-contrat-qui-se-te
|
||||||
|
**Statut :** Validé (brainstorming)
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Prévenir automatiquement **les administrateurs**, sur le **dernier jour ouvré précédant la fin
|
||||||
|
d'un contrat**, qu'un salarié arrive au terme de son emploi — afin qu'ils puissent anticiper
|
||||||
|
(solde de tout compte, désactivation des accès, etc.).
|
||||||
|
|
||||||
|
La notification réutilise le **système de notification existant** (entité `Notification`, cloche
|
||||||
|
admin dans `AppTopNav.vue`). Aucune migration de base de données.
|
||||||
|
|
||||||
|
## Décisions de cadrage
|
||||||
|
|
||||||
|
| Sujet | Décision |
|
||||||
|
|---|---|
|
||||||
|
| **Déclencheur** | Vraie fin d'emploi uniquement : la période qui se termine est la **dernière** période de contrat du salarié (aucune période ne lui succède). Un changement de contrat enchaîné (ex. CDD 35h → CDI 39h) ne notifie pas. |
|
||||||
|
| **Timing** | Le **dernier jour ouvré strictement avant** `endDate`, en sautant **week-ends ET jours fériés**. |
|
||||||
|
| **Message** | « Fin de {nature} de {Prénom Nom} le {dd/mm/yyyy} » — nature = libellé FR (CDI / CDD / Intérim). |
|
||||||
|
| **Catégorie** | `Contrat` |
|
||||||
|
| **Cible du clic** | `/employees/{id}` (fiche employé). |
|
||||||
|
| **Destinataires** | Tous les `ROLE_ADMIN` (`UserRepository::findAllAdmins()`). |
|
||||||
|
| **Acteur** | `null` (notif générée par un job automatique, pas par un utilisateur). |
|
||||||
|
| **Déclenchement** | Commande console quotidienne via crontab prod (~6h du matin). |
|
||||||
|
|
||||||
|
## Rappels sur l'existant
|
||||||
|
|
||||||
|
- `Notification` (table `notifications`) : `recipient` (NOT NULL), `actor` (nullable),
|
||||||
|
`message`, `category`, `target`, `isRead`, `createdAt`. Exposé via `getActorName()`.
|
||||||
|
- `endDate` d'une `EmployeeContractPeriod` est **inclusif** : c'est le dernier jour couvert
|
||||||
|
par le contrat (`findOneCoveringDate` : `endDate >= :date`).
|
||||||
|
- Pattern de notif existant : `WorkHourSiteValidationProcessor` crée une `Notification` par
|
||||||
|
admin (`findAllAdmins`) avec `new Notification()` + persist + flush. Pas de service factory.
|
||||||
|
- Pattern cron existant : `RttRolloverCommand` / `LeaveRolloverCommand` (`#[AsCommand]`,
|
||||||
|
logger `monolog.logger.cron`, options `--force`/`--recompute`). Déclenchées par le crontab
|
||||||
|
système (pas de Symfony Scheduler dans le projet).
|
||||||
|
- `PublicHolidayService` : source des fériés (cache 30j), déjà en place.
|
||||||
|
|
||||||
|
## Approche retenue
|
||||||
|
|
||||||
|
**Commande cron quotidienne + service métier dédié et testable.**
|
||||||
|
|
||||||
|
Alternatives écartées :
|
||||||
|
- **Symfony Scheduler (Messenger)** : brique non utilisée dans le projet, inutile ici.
|
||||||
|
- **Calcul à la volée dans le provider** : casse `isRead`/historique, recalcul à chaque
|
||||||
|
ouverture de la cloche, mélange notifs persistées et virtuelles.
|
||||||
|
|
||||||
|
## Conception détaillée
|
||||||
|
|
||||||
|
### 1. Détection (cœur métier)
|
||||||
|
|
||||||
|
Nouveau service `App\Service\Notification\ContractEndNotificationService`.
|
||||||
|
|
||||||
|
Algorithme (date du jour `T` injectable pour les tests) :
|
||||||
|
|
||||||
|
1. Si `T` est un week-end ou un férié → **sortie** (aucun jour chômé ne génère de notif).
|
||||||
|
2. Calculer `N` = **prochain jour ouvré strictement après `T`** (saute week-ends + fériés via
|
||||||
|
`PublicHolidayService`).
|
||||||
|
3. Charger en **une seule requête** la dernière période de chaque employé
|
||||||
|
(`EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees()`).
|
||||||
|
4. Candidat = dernière période dont `endDate` est non nul et vérifie `T < endDate <= N`.
|
||||||
|
- Le test « dernière période » assure nativement la règle « vraie fin d'emploi » : un
|
||||||
|
changement de contrat enchaîné a une période suivante, donc n'est jamais la dernière.
|
||||||
|
- `endDate = null` (CDI ouvert) → jamais candidat.
|
||||||
|
|
||||||
|
**Exemples :**
|
||||||
|
- Mardi (`T`), `N` = mercredi → notifie les contrats finissant mercredi (J-1 classique).
|
||||||
|
- Vendredi (`T`), `N` = lundi → notifie les contrats finissant samedi, dimanche **ou** lundi.
|
||||||
|
- Vendredi (`T`), lundi férié → `N` = mardi → notifie samedi…mardi.
|
||||||
|
- Week-end (`T`) → rien.
|
||||||
|
|
||||||
|
### 2. Création des notifications & idempotence
|
||||||
|
|
||||||
|
Pour chaque candidat :
|
||||||
|
- Message : `Fin de {nature} de {Prénom Nom} le {endDate->format('d/m/Y')}`.
|
||||||
|
- Une `Notification` par admin : `recipient=admin`, `actor=null`, `category='Contrat'`,
|
||||||
|
`target='/employees/{id}'`. Persist groupé, un seul `flush()` final.
|
||||||
|
|
||||||
|
**Idempotence** (le job peut être relancé le même jour) : avant création, vérifier qu'il
|
||||||
|
n'existe pas déjà une notif identique pour ce destinataire via
|
||||||
|
`(recipient, category='Contrat', target='/employees/{id}', message)` **exact**. Le message
|
||||||
|
étant unique par (employé + date + nature), cela empêche tout doublon — y compris après que
|
||||||
|
l'admin a lu la notif (`isRead=true`). Nouvelle méthode
|
||||||
|
`NotificationRepository::existsForRecipientTargetMessage(...)` (ou `findOneBy`). Pas de
|
||||||
|
migration : on ne stocke pas de FK « période » sur `Notification`.
|
||||||
|
|
||||||
|
### 3. Affichage front (cloche)
|
||||||
|
|
||||||
|
`AppTopNav.vue` rend aujourd'hui `**{actorName}** {message}`. Pour `actorName` vide :
|
||||||
|
- N'afficher que `{message}` (pas de span gras vide ni `capitalize` orphelin).
|
||||||
|
- Le reste est inchangé : avatar/pastille, `formatTimeAgo` + catégorie « Contrat », point
|
||||||
|
non-lu, lien `target`.
|
||||||
|
|
||||||
|
Aucune route ni service front nouveau : `category` et `target` passent par le DTO existant.
|
||||||
|
Cloche déjà admin-only → rien d'autre côté visibilité.
|
||||||
|
|
||||||
|
### 4. Commande console
|
||||||
|
|
||||||
|
`App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||||
|
- Délègue tout au service.
|
||||||
|
- Option `--date=YYYY-MM-DD` : forcer la date du jour (tests / rattrapage manuel).
|
||||||
|
- Logger `monolog.logger.cron`. Sortie `SymfonyStyle` (nb de notifs créées / employés concernés).
|
||||||
|
- **Idempotente** par construction (cf. §2) → relançable sans risque.
|
||||||
|
- Crontab prod (infra) : `0 6 * * *` (tous les jours, 6h). Pas de restriction jour de semaine
|
||||||
|
(la commande s'auto-neutralise week-ends/fériés).
|
||||||
|
|
||||||
|
## Fichiers
|
||||||
|
|
||||||
|
**Backend — nouveaux**
|
||||||
|
- `src/Service/Notification/ContractEndNotificationService.php`
|
||||||
|
- `src/Command/ContractEndNotificationCommand.php`
|
||||||
|
- `tests/...` (tests du service)
|
||||||
|
|
||||||
|
**Backend — modifiés**
|
||||||
|
- `src/Repository/EmployeeContractPeriodRepository.php` — `findLatestPeriodsForAllEmployees()`
|
||||||
|
- `src/Repository/NotificationRepository.php` — existence anti-doublon
|
||||||
|
- Helper « jour ouvré » (week-end + férié) — dans le service ou petit util réutilisable
|
||||||
|
|
||||||
|
**Frontend — modifié**
|
||||||
|
- `frontend/components/AppTopNav.vue` — gérer `actorName` vide
|
||||||
|
|
||||||
|
**Docs**
|
||||||
|
- `doc/functional-rules.md` — compléter la section 15) Notifications
|
||||||
|
- `doc/contract-end-notifications.md` — nouveau (règle complète)
|
||||||
|
- `frontend/data/documentation-content.ts` — entrée admin
|
||||||
|
- `CLAUDE.md` — note du nouveau pattern (commande cron de notification)
|
||||||
|
|
||||||
|
Pas de migration DB.
|
||||||
|
|
||||||
|
## Tests (PHPUnit)
|
||||||
|
|
||||||
|
Cœur isolé dans `ContractEndNotificationService` (date `T` fixe + `PublicHolidayService` mocké) :
|
||||||
|
- Fin mercredi, `T`=mardi → 1 notif/admin.
|
||||||
|
- Fin lundi, `T`=vendredi → notifié vendredi ; rien samedi/dimanche.
|
||||||
|
- Lundi férié + fin mardi, `T`=vendredi → notifié vendredi (`N` saute le lundi férié).
|
||||||
|
- Période suivante existante (changement enchaîné) → pas de notif.
|
||||||
|
- `endDate=null` → pas de notif.
|
||||||
|
- Idempotence : 2ᵉ exécution même jour → aucun doublon.
|
||||||
|
- `T` = week-end → aucune création.
|
||||||
@@ -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>
|
v-model="printForm.from"
|
||||||
<label class="text-md font-semibold text-neutral-700" for="print-from">
|
label="Date de début"
|
||||||
Date de début <span class="text-red-600">*</span>
|
required
|
||||||
</label>
|
:clearable="false"
|
||||||
<input
|
:reserve-message-space="false"
|
||||||
id="print-from"
|
:error="showFromError ? 'La date de début est obligatoire.' : ''"
|
||||||
v-model="printForm.from"
|
group-class="w-full"
|
||||||
type="date"
|
/>
|
||||||
:class="fromFieldClass"
|
|
||||||
/>
|
<MalioDate
|
||||||
<p v-if="showFromError" class="mt-1 text-sm text-red-600">
|
v-model="printForm.to"
|
||||||
La date de début est obligatoire.
|
label="Date de fin"
|
||||||
</p>
|
required
|
||||||
</div>
|
:clearable="false"
|
||||||
<div>
|
:reserve-message-space="false"
|
||||||
<label class="text-md font-semibold text-neutral-700" for="print-to">
|
:error="showToError ? 'La date de fin est obligatoire.' : ''"
|
||||||
Date de fin <span class="text-red-600">*</span>
|
group-class="w-full"
|
||||||
</label>
|
/>
|
||||||
<input
|
|
||||||
id="print-to"
|
|
||||||
v-model="printForm.to"
|
|
||||||
type="date"
|
|
||||||
:class="toFieldClass"
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
>
|
>
|
||||||
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
||||||
<div class="flex flex-col min-w-0 text-[16px]">
|
<div class="flex flex-col min-w-0 text-[16px]">
|
||||||
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
<p class="text-black"><span v-if="notification.actorName" class="font-semibold capitalize">{{ notification.actorName }} </span>{{ notification.message }}</p>
|
||||||
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
||||||
|
|||||||
@@ -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,49 +1,43 @@
|
|||||||
<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">
|
v-model="selectedDate"
|
||||||
Date <span class="text-red-600">*</span>
|
label="Date"
|
||||||
</label>
|
required
|
||||||
<input
|
:clearable="false"
|
||||||
id="hours-export-date"
|
:reserve-message-space="false"
|
||||||
v-model="selectedDate"
|
group-class="w-full"
|
||||||
type="date"
|
/>
|
||||||
class="mt-2 w-full rounded-md border border-black px-3 py-2 text-md text-neutral-900"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<MalioSelectCheckbox
|
||||||
<label class="text-md font-semibold text-neutral-700">
|
v-model="selectedSites"
|
||||||
Sites <span class="text-red-600">*</span>
|
:options="siteOptions"
|
||||||
</label>
|
label="Sites"
|
||||||
<MalioSelectCheckbox
|
required
|
||||||
v-model="selectedSites"
|
:reserve-message-space="false"
|
||||||
:options="siteOptions"
|
groupClass="w-full"
|
||||||
groupClass="w-full mt-2"
|
display-select-all
|
||||||
label="Sites"
|
display-tag
|
||||||
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<{
|
||||||
|
|||||||
@@ -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,10 +533,11 @@ 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
|
||||||
|
await Promise.all([loadDayContext(), loadAbsences()])
|
||||||
}
|
}
|
||||||
weeklySummary.value = null
|
await reloadValidationMonth(selectedDate.value)
|
||||||
await Promise.all([loadDayContext(), loadAbsences()])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitAbsence = async () => {
|
const submitAbsence = async () => {
|
||||||
@@ -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,6 +933,32 @@ export const useDriverHoursPage = () => {
|
|||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
|
||||||
|
const buildEntry = (employeeId: number, row: DriverHourRow) => {
|
||||||
|
const dayMin = toMinutes(row.dayHours)
|
||||||
|
const nightMin = toMinutes(row.nightHours)
|
||||||
|
const workshopMin = toMinutes(row.workshopHours)
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false,
|
||||||
|
dayHoursMinutes: dayMin || null,
|
||||||
|
nightHoursMinutes: nightMin || null,
|
||||||
|
workshopHoursMinutes: workshopMin || null,
|
||||||
|
hasBreakfast: row.hasBreakfast,
|
||||||
|
hasLunch: row.hasLunch,
|
||||||
|
hasDinner: row.hasDinner,
|
||||||
|
hasOvernight: row.hasOvernight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (isSubmitting.value || employees.value.length === 0) return
|
if (isSubmitting.value || employees.value.length === 0) return
|
||||||
|
|
||||||
@@ -886,32 +968,16 @@ export const useDriverHoursPage = () => {
|
|||||||
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
|
(e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
const entries = driverEmployees.map((employee) => {
|
const entries = driverEmployees
|
||||||
const employeeId = employee.id
|
.map((employee) => {
|
||||||
const row = rows.value[employeeId] ?? emptyRow()
|
const current = buildEntry(employee.id, rows.value[employee.id] ?? emptyRow())
|
||||||
const dayMin = toMinutes(row.dayHours)
|
const original = buildEntry(employee.id, loadedRows.value[employee.id] ?? emptyRow())
|
||||||
const nightMin = toMinutes(row.nightHours)
|
return { current, original }
|
||||||
const workshopMin = toMinutes(row.workshopHours)
|
})
|
||||||
|
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||||
return {
|
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||||
employeeId,
|
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||||
morningFrom: null,
|
.map(({ current }) => current)
|
||||||
morningTo: null,
|
|
||||||
afternoonFrom: null,
|
|
||||||
afternoonTo: null,
|
|
||||||
eveningFrom: null,
|
|
||||||
eveningTo: null,
|
|
||||||
isPresentMorning: false,
|
|
||||||
isPresentAfternoon: false,
|
|
||||||
dayHoursMinutes: dayMin || null,
|
|
||||||
nightHoursMinutes: nightMin || null,
|
|
||||||
workshopHoursMinutes: workshopMin || null,
|
|
||||||
hasBreakfast: row.hasBreakfast,
|
|
||||||
hasLunch: row.hasLunch,
|
|
||||||
hasDinner: row.hasDinner,
|
|
||||||
hasOvernight: row.hasOvernight
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -594,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 () => {
|
||||||
@@ -686,11 +701,11 @@ 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
|
||||||
|
await Promise.all([loadDayContext(), loadAbsences()])
|
||||||
}
|
}
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
weeklySummary.value = null
|
|
||||||
await Promise.all([loadDayContext(), loadAbsences()])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitAbsence = async () => {
|
const submitAbsence = async () => {
|
||||||
@@ -787,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)
|
||||||
}
|
}
|
||||||
@@ -891,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({
|
||||||
@@ -1031,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()])
|
||||||
@@ -1084,6 +1145,36 @@ export const useHoursPage = () => {
|
|||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
|
||||||
|
const buildEntry = (employee: Employee, row: HourRow) => {
|
||||||
|
const employeeId = employee.id
|
||||||
|
if (isPresenceTracking(employee)) {
|
||||||
|
return {
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: row.isPresentMorning,
|
||||||
|
isPresentAfternoon: row.isPresentAfternoon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId,
|
||||||
|
morningFrom: normalizeTime(row.morningFrom),
|
||||||
|
morningTo: normalizeTime(row.morningTo),
|
||||||
|
afternoonFrom: normalizeTime(row.afternoonFrom),
|
||||||
|
afternoonTo: normalizeTime(row.afternoonTo),
|
||||||
|
eveningFrom: normalizeTime(row.eveningFrom),
|
||||||
|
eveningTo: normalizeTime(row.eveningTo),
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (isSubmitting.value || employees.value.length === 0) return
|
if (isSubmitting.value || employees.value.length === 0) return
|
||||||
|
|
||||||
@@ -1092,34 +1183,14 @@ export const useHoursPage = () => {
|
|||||||
const entries = employees.value
|
const entries = employees.value
|
||||||
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
||||||
.map((employee) => {
|
.map((employee) => {
|
||||||
const employeeId = employee.id
|
const current = buildEntry(employee, rows.value[employee.id] ?? emptyRow())
|
||||||
const row = rows.value[employeeId] ?? emptyRow()
|
const original = buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow())
|
||||||
if (isPresenceTracking(employee)) {
|
return { current, original }
|
||||||
return {
|
|
||||||
employeeId,
|
|
||||||
morningFrom: null,
|
|
||||||
morningTo: null,
|
|
||||||
afternoonFrom: null,
|
|
||||||
afternoonTo: null,
|
|
||||||
eveningFrom: null,
|
|
||||||
eveningTo: null,
|
|
||||||
isPresentMorning: row.isPresentMorning,
|
|
||||||
isPresentAfternoon: row.isPresentAfternoon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
employeeId,
|
|
||||||
morningFrom: normalizeTime(row.morningFrom),
|
|
||||||
morningTo: normalizeTime(row.morningTo),
|
|
||||||
afternoonFrom: normalizeTime(row.afternoonFrom),
|
|
||||||
afternoonTo: normalizeTime(row.afternoonTo),
|
|
||||||
eveningFrom: normalizeTime(row.eveningFrom),
|
|
||||||
eveningTo: normalizeTime(row.eveningTo),
|
|
||||||
isPresentMorning: false,
|
|
||||||
isPresentAfternoon: false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
// 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
|
||||||
@@ -1131,6 +1202,7 @@ export const useHoursPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
|
await reloadValidationMonth(selectedDate.value)
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1293,8 @@ export const useHoursPage = () => {
|
|||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
markedDates,
|
||||||
|
onCalendarMonthChange,
|
||||||
isWeekCommentDrawerOpen,
|
isWeekCommentDrawerOpen,
|
||||||
weekCommentContext,
|
weekCommentContext,
|
||||||
openWeekCommentDrawer,
|
openWeekCommentDrawer,
|
||||||
|
|||||||
@@ -159,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.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -257,6 +268,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
|
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
|
||||||
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
|
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
|
||||||
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
|
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
|
||||||
|
{ type: 'paragraph', content: 'Notification fin de contrat : chaque jour ouvré, les administrateurs sont prévenus (cloche en haut à droite) lorsqu\'un salarié atteint le dernier jour ouvré avant la fin de son contrat. Le message indique la nature du contrat, le nom du salarié et la date de fin, et renvoie vers sa fiche. Les week-ends et jours fériés sont pris en compte : une fin de contrat le lundi est signalée dès le vendredi.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -405,6 +417,8 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
|
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
|
||||||
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
|
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
|
||||||
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
|
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
|
||||||
|
{ type: 'note', content: 'Supprimer une plage depuis le calendrier : cliquez sur un jour de la plage (le drawer s\'ouvre sur ce jour), étendez la date de fin (ou de début) pour couvrir toute la plage à effacer, puis cliquez sur « Supprimer ». Tous les jours de congé compris dans la plage sélectionnée sont supprimés en une fois ; les jours sans absence dans cette plage sont simplement ignorés. Un jour déjà validé reste protégé.' },
|
||||||
|
{ type: 'note', content: 'Modifier une plage depuis le calendrier : cliquez sur le premier jour de la plage, ajustez la date de fin (pour la raccourcir ou l\'allonger) puis « Modifier ». La plage est remplacée proprement par la nouvelle : plus de jours « fantômes » qui restaient après un raccourcissement, ni de doublons après un allongement. Les jours situés avant le jour cliqué ne sont jamais modifiés.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
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"
|
||||||
|
|||||||
+208
-220
@@ -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>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="filterButtonLabel"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
@click="list.openFilters()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-end gap-4 pb-6 flex-wrap">
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
<div>
|
<MalioDataTable
|
||||||
<label class="text-md font-semibold text-neutral-700">Employé</label>
|
:columns="columns"
|
||||||
<select
|
:items="list.items.value"
|
||||||
v-model="filters.employeeId"
|
:total-items="list.total.value"
|
||||||
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"
|
:page="list.page.value"
|
||||||
>
|
:per-page="list.perPage.value"
|
||||||
<option :value="undefined">Tous</option>
|
:per-page-options="[10, 25, 50, 100]"
|
||||||
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
|
empty-message="Aucune entrée trouvée."
|
||||||
{{ emp.lastName }} {{ emp.firstName }}
|
@row-click="openDetail"
|
||||||
</option>
|
@update:page="list.goToPage($event)"
|
||||||
</select>
|
@update:per-page="list.setPerPage($event)"
|
||||||
</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>
|
|
||||||
<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
|
<template #cell-createdAt="{ item }">
|
||||||
</button>
|
{{ formatDateTime((item as AuditLog).createdAt) }}
|
||||||
|
</template>
|
||||||
|
<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 v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<!-- Filter drawer -->
|
||||||
Chargement...
|
<MalioDrawer
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<MalioAccordion>
|
||||||
Aucune entrée trouvée.
|
<MalioAccordionItem title="Période" value="period">
|
||||||
</div>
|
<MalioDateRange v-model="list.draftRange.value" clearable />
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
<template v-else>
|
<MalioAccordionItem title="Employé" value="employee">
|
||||||
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
|
<MalioInputText v-model="list.draftEmployee.value" icon-name="mdi:magnify" />
|
||||||
<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">
|
</MalioAccordionItem>
|
||||||
<span>Date action</span>
|
|
||||||
<span>Utilisateur</span>
|
<MalioAccordionItem title="Type d'entité" value="entityType">
|
||||||
<span>Action</span>
|
<div class="flex flex-col">
|
||||||
<span>Type</span>
|
<MalioCheckbox
|
||||||
<span>Employé</span>
|
v-for="opt in entityTypeOptions"
|
||||||
<span>Description</span>
|
:id="`filter-type-${opt.value}`"
|
||||||
<span>Date affectée</span>
|
:key="opt.value"
|
||||||
</div>
|
:label="opt.label"
|
||||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
:model-value="list.draftEntityTypes.value.includes(opt.value)"
|
||||||
<template v-for="log in logs" :key="log.id">
|
@update:model-value="(val: boolean) => list.toggleEntityType(opt.value, val)"
|
||||||
<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"
|
</div>
|
||||||
@click="toggleExpand(log.id)"
|
</MalioAccordionItem>
|
||||||
>
|
|
||||||
<span>{{ formatDateTime(log.createdAt) }}</span>
|
<MalioAccordionItem title="Action" value="action">
|
||||||
<span>{{ log.username }}</span>
|
<div class="flex flex-col">
|
||||||
<span>
|
<MalioCheckbox
|
||||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
|
v-for="opt in actionOptions"
|
||||||
{{ actionLabel(log.action) }}
|
:id="`filter-action-${opt.value}`"
|
||||||
</span>
|
:key="opt.value"
|
||||||
</span>
|
:label="opt.label"
|
||||||
<span>{{ entityTypeLabel(log.entityType) }}</span>
|
:model-value="list.draftActions.value.includes(opt.value)"
|
||||||
<span>{{ log.employeeName ?? '-' }}</span>
|
@update:model-value="(val: boolean) => list.toggleAction(opt.value, val)"
|
||||||
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
|
/>
|
||||||
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
|
</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><span class="font-semibold">Type :</span> {{ entityTypeLabel(selected.entityType) }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-1">
|
||||||
|
<h3 class="font-bold">Contexte technique</h3>
|
||||||
|
<p><span class="font-semibold">IP :</span> {{ selected.ipAddress ?? '—' }}</p>
|
||||||
|
<p><span class="font-semibold">Appareil :</span> {{ selected.deviceLabel ?? '—' }}</p>
|
||||||
|
<p><span class="font-semibold">User-Agent :</span> <span class="break-all text-sm font-normal">{{ selected.userAgent ?? '—' }}</span></p>
|
||||||
|
<p><span class="font-semibold">Device id :</span> <span class="break-all text-sm font-normal">{{ selected.deviceId ?? '—' }}</span></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-1">
|
||||||
|
<h3 class="font-bold">Changements</h3>
|
||||||
|
<div v-if="changeRows.length > 0" class="space-y-1">
|
||||||
|
<div v-for="row in changeRows" :key="row.key" class="text-sm">
|
||||||
|
<span class="font-semibold">{{ row.key }} :</span>
|
||||||
|
<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
|
<p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
|
||||||
v-if="expandedIds.has(log.id)"
|
</section>
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</MalioDrawer>
|
||||||
<div class="flex items-center justify-between pt-4">
|
|
||||||
<p class="text-md text-neutral-500">
|
|
||||||
{{ total }} résultat{{ total > 1 ? 's' : '' }} — page {{ currentPage }}/{{ totalPages }}
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:disabled="currentPage <= 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"
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
>
|
|
||||||
Précédent
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:disabled="currentPage >= totalPages"
|
|
||||||
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"
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
>
|
|
||||||
Suivant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</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>
|
||||||
|
|||||||
+105
-31
@@ -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"
|
||||||
@@ -109,11 +109,11 @@ 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 {listEmployees, updateEmployeeOrder} from '~/services/employees'
|
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
|
||||||
import {listAbsenceTypes} from '~/services/absence-types'
|
import {listAbsenceTypes} from '~/services/absence-types'
|
||||||
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
import {createAbsence, deleteAbsence, listAbsences} from '~/services/absences'
|
||||||
import {listFormationsByDateRange} from '~/services/formations'
|
import {listFormationsByDateRange} from '~/services/formations'
|
||||||
import type {Formation} from '~/services/dto/formation'
|
import type {Formation} from '~/services/dto/formation'
|
||||||
import {listPublicHolidays} from '~/services/public-holidays'
|
import {listPublicHolidays} from '~/services/public-holidays'
|
||||||
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
|
import {formatYmdToFr, getDaysInMonth, normalizeDate, parseYmd, shiftYmd, toYmd} from '~/utils/date'
|
||||||
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
|
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
|
||||||
import CalendarGrid from '~/components/CalendarGrid.vue'
|
import CalendarGrid from '~/components/CalendarGrid.vue'
|
||||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||||
@@ -649,9 +649,68 @@ const handleSubmit = async () => {
|
|||||||
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (editingAbsence.value) {
|
||||||
|
// Modification d'une plage : une absence = une ligne par jour, sans lien en BDD.
|
||||||
|
// On remplace donc tout le bloc contigu (même type) partant du jour cliqué par la
|
||||||
|
// nouvelle plage : suppression de l'ancien bloc + recréation. Évite les jours
|
||||||
|
// fantômes (raccourcissement) et les doublons (l'ancien PATCH ne nettoyait rien).
|
||||||
|
const originalEmployeeId = editingAbsence.value.employee.id
|
||||||
|
const newEmployeeId = Number(form.employeeId)
|
||||||
|
const originalTypeId = editingAbsence.value.type.id
|
||||||
|
const clickedDate = normalizeDate(editingAbsence.value.startDate)
|
||||||
|
|
||||||
|
// Bloc contigu (vers l'avant) depuis le jour cliqué, même employé + même type d'origine.
|
||||||
|
// On ne touche jamais aux jours antérieurs au jour cliqué.
|
||||||
|
const sameLeaveDays = new Set(
|
||||||
|
absences.value
|
||||||
|
.filter((absence) => absence.employee?.id === originalEmployeeId && absence.type?.id === originalTypeId)
|
||||||
|
.map((absence) => normalizeDate(absence.startDate))
|
||||||
|
)
|
||||||
|
const blockDates = new Set<string>()
|
||||||
|
let cursor: string | null = clickedDate
|
||||||
|
while (cursor && sameLeaveDays.has(cursor)) {
|
||||||
|
blockDates.add(cursor)
|
||||||
|
cursor = shiftYmd(cursor, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// À supprimer : l'ancien bloc + toute absence recouverte par la nouvelle plage.
|
||||||
|
const toReplace = absences.value.filter((absence) => {
|
||||||
|
const day = normalizeDate(absence.startDate)
|
||||||
|
const inBlock = absence.employee?.id === originalEmployeeId && blockDates.has(day)
|
||||||
|
const inNewRange = absence.employee?.id === newEmployeeId && day >= start && day <= end
|
||||||
|
return inBlock || inNewRange
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confirmation uniquement si on écrase une absence d'un AUTRE type (vrai chevauchement).
|
||||||
|
const replacesForeign = toReplace.some((absence) => absence.type?.id !== originalTypeId)
|
||||||
|
if (replacesForeign) {
|
||||||
|
const confirmReplace = window.confirm(
|
||||||
|
"Cette absence chevauche une autre. Voulez-vous la remplacer ?"
|
||||||
|
)
|
||||||
|
if (!confirmReplace) return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const absence of toReplace) {
|
||||||
|
await deleteAbsence(absence.id)
|
||||||
|
}
|
||||||
|
await createAbsence({
|
||||||
|
employeeId: newEmployeeId,
|
||||||
|
typeId: Number(form.typeId),
|
||||||
|
startDate: form.startDate,
|
||||||
|
startHalf: form.startHalf,
|
||||||
|
endDate: form.endDate,
|
||||||
|
endHalf: form.endHalf,
|
||||||
|
comment: form.comment
|
||||||
|
})
|
||||||
|
|
||||||
|
closeDrawer()
|
||||||
|
await loadAbsences()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Création : détection de chevauchement (précision demi-journée) puis remplacement.
|
||||||
const overlaps = absences.value.filter((absence) => {
|
const overlaps = absences.value.filter((absence) => {
|
||||||
if (absence.employee?.id !== Number(form.employeeId)) return false
|
if (absence.employee?.id !== Number(form.employeeId)) return false
|
||||||
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
|
||||||
const aStart = normalizeDate(absence.startDate)
|
const aStart = normalizeDate(absence.startDate)
|
||||||
const aEnd = normalizeDate(absence.endDate)
|
const aEnd = normalizeDate(absence.endDate)
|
||||||
if (start > aEnd || end < aStart) return false
|
if (start > aEnd || end < aStart) return false
|
||||||
@@ -701,28 +760,15 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingAbsence.value) {
|
await createAbsence({
|
||||||
await updateAbsence({
|
employeeId: Number(form.employeeId),
|
||||||
id: editingAbsence.value.id,
|
typeId: Number(form.typeId),
|
||||||
employeeId: Number(form.employeeId),
|
startDate: form.startDate,
|
||||||
typeId: Number(form.typeId),
|
startHalf: form.startHalf,
|
||||||
startDate: form.startDate,
|
endDate: form.endDate,
|
||||||
startHalf: form.startHalf,
|
endHalf: form.endHalf,
|
||||||
endDate: form.endDate,
|
comment: form.comment
|
||||||
endHalf: form.endHalf,
|
})
|
||||||
comment: form.comment
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await createAbsence({
|
|
||||||
employeeId: Number(form.employeeId),
|
|
||||||
typeId: Number(form.typeId),
|
|
||||||
startDate: form.startDate,
|
|
||||||
startHalf: form.startHalf,
|
|
||||||
endDate: form.endDate,
|
|
||||||
endHalf: form.endHalf,
|
|
||||||
comment: form.comment
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
await loadAbsences()
|
await loadAbsences()
|
||||||
@@ -731,14 +777,42 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppression de l'absence en cours d'édition.
|
// Suppression: efface toutes les absences de l'employé comprises dans la plage
|
||||||
|
// sélectionnée (date début → date fin du drawer). Comme une absence = une ligne
|
||||||
|
// par jour en BDD, on supprime chaque jour existant de la plage ; les jours sans
|
||||||
|
// absence (ex. une date hors plage réelle) sont naturellement ignorés.
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!editingAbsence.value) return
|
if (!editingAbsence.value) return
|
||||||
|
|
||||||
const confirmDelete = window.confirm('Supprimer cette absence ?')
|
const employeeId = editingAbsence.value.employee.id
|
||||||
|
const rangeStart = normalizeDate(form.startDate)
|
||||||
|
const rangeEnd = normalizeDate(form.endDate)
|
||||||
|
if (rangeStart > rangeEnd) {
|
||||||
|
window.alert("La date de fin ne peut pas etre avant la date de debut.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDelete = absences.value.filter((absence) => {
|
||||||
|
if (absence.employee?.id !== employeeId) return false
|
||||||
|
const day = normalizeDate(absence.startDate)
|
||||||
|
return day >= rangeStart && day <= rangeEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
if (toDelete.length === 0) {
|
||||||
|
closeDrawer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = window.confirm(
|
||||||
|
toDelete.length === 1
|
||||||
|
? 'Supprimer cette absence ?'
|
||||||
|
: `Supprimer ${toDelete.length} jours de congé du ${formatYmdToFr(rangeStart)} au ${formatYmdToFr(rangeEnd)} ?`
|
||||||
|
)
|
||||||
if (!confirmDelete) return
|
if (!confirmDelete) return
|
||||||
|
|
||||||
await deleteAbsence(editingAbsence.value.id)
|
for (const absence of toDelete) {
|
||||||
|
await deleteAbsence(absence.id)
|
||||||
|
}
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
await loadAbsences()
|
await loadAbsences()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</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"
|
||||||
|
|||||||
@@ -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' : '']"
|
/>
|
||||||
/>
|
<MalioDate
|
||||||
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
|
v-if="showsContractEndDateComputed"
|
||||||
La date de début est obligatoire.
|
:model-value="form.contractEndDate"
|
||||||
</p>
|
label="Fin contrat"
|
||||||
</div>
|
:required="requiresContractEndDateComputed"
|
||||||
<div v-if="showsContractEndDateComputed">
|
:reserve-message-space="false"
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
|
:error="showContractEndDateError ? 'La date de fin est obligatoire pour un CDD ou un Intérim.' : ''"
|
||||||
Fin contrat
|
group-class="w-full"
|
||||||
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
|
@update:model-value="(v) => form.contractEndDate = v ?? ''"
|
||||||
</label>
|
/>
|
||||||
<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 *"
|
||||||
@@ -231,7 +229,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else-if="exportChoice === 'night-contingent'">
|
<div v-else-if="exportChoice === 'night-contingent'">
|
||||||
<MalioSelect
|
<MalioSelect :reserve-message-space="false"
|
||||||
:model-value="exportYear"
|
:model-value="exportYear"
|
||||||
:options="exportYearOptions"
|
:options="exportYearOptions"
|
||||||
label="Année *"
|
label="Année *"
|
||||||
@@ -241,14 +239,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="exportChoice === 'overtime-contingent'" class="flex flex-col gap-4">
|
<div v-else-if="exportChoice === 'overtime-contingent'" class="flex flex-col gap-4">
|
||||||
<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) }"
|
||||||
/>
|
/>
|
||||||
<MalioSelectCheckbox
|
<MalioSelectCheckbox :reserve-message-space="false"
|
||||||
v-model="exportSiteIds"
|
v-model="exportSiteIds"
|
||||||
:options="siteOptions"
|
:options="siteOptions"
|
||||||
label="Sites"
|
label="Sites"
|
||||||
@@ -467,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 }))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,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"
|
||||||
@@ -39,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">
|
||||||
@@ -225,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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 = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Service\Notification\ContractEndNotificationService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:contract:end-notifications',
|
||||||
|
description: 'Notify admins on the last working day before a contract ends.'
|
||||||
|
)]
|
||||||
|
final class ContractEndNotificationCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContractEndNotificationService $service,
|
||||||
|
#[Autowire(service: 'monolog.logger.cron')]
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'date',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_REQUIRED,
|
||||||
|
'Override the reference day (YYYY-MM-DD) for testing or manual catch-up.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
$dateOption = $input->getOption('date');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$today = is_string($dateOption) && '' !== $dateOption
|
||||||
|
? new DateTimeImmutable($dateOption)
|
||||||
|
: new DateTimeImmutable('today');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$io->error(sprintf('Invalid --date value: %s', $exception->getMessage()));
|
||||||
|
|
||||||
|
return Command::INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->service->run($today);
|
||||||
|
|
||||||
|
$this->logger->info('Contract end notifications generated.', [
|
||||||
|
'date' => $today->format('Y-m-d'),
|
||||||
|
'contractsMatched' => $result->contractsMatched,
|
||||||
|
'notificationsCreated' => $result->notificationsCreated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'%d notification(s) créée(s) pour %d fin(s) de contrat (%s).',
|
||||||
|
$result->notificationsCreated,
|
||||||
|
$result->contractsMatched,
|
||||||
|
$today->format('Y-m-d'),
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -72,6 +72,24 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository imp
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest contract period (max startDate) for every employee that has at least one.
|
||||||
|
*
|
||||||
|
* @return EmployeeContractPeriod[]
|
||||||
|
*/
|
||||||
|
public function findLatestPeriodsForAllEmployees(): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.startDate = (
|
||||||
|
SELECT MAX(p2.startDate)
|
||||||
|
FROM App\Entity\EmployeeContractPeriod p2
|
||||||
|
WHERE p2.employee = p.employee
|
||||||
|
)')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('p')
|
return $this->createQueryBuilder('p')
|
||||||
|
|||||||
@@ -84,4 +84,28 @@ final class NotificationRepository extends ServiceEntityRepository
|
|||||||
->execute()
|
->execute()
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function existsForRecipientCategoryTargetMessage(
|
||||||
|
User $recipient,
|
||||||
|
string $category,
|
||||||
|
string $target,
|
||||||
|
string $message,
|
||||||
|
): bool {
|
||||||
|
$id = $this->createQueryBuilder('n')
|
||||||
|
->select('n.id')
|
||||||
|
->andWhere('n.recipient = :recipient')
|
||||||
|
->andWhere('n.category = :category')
|
||||||
|
->andWhere('n.target = :target')
|
||||||
|
->andWhere('n.message = :message')
|
||||||
|
->setParameter('recipient', $recipient)
|
||||||
|
->setParameter('category', $category)
|
||||||
|
->setParameter('target', $target)
|
||||||
|
->setParameter('message', $message)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
return null !== $id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotice
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?int $employeeId,
|
||||||
|
public string $message,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WorkingDayCalculator $calculator,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param EmployeeContractPeriod[] $latestPeriods
|
||||||
|
*
|
||||||
|
* @return ContractEndNotice[]
|
||||||
|
*/
|
||||||
|
public function plan(array $latestPeriods, DateTimeImmutable $today): array
|
||||||
|
{
|
||||||
|
$today = $today->setTime(0, 0, 0);
|
||||||
|
if (!$this->calculator->isWorkingDay($today)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$upperBound = $this->calculator->nextWorkingDay($today);
|
||||||
|
|
||||||
|
$notices = [];
|
||||||
|
foreach ($latestPeriods as $period) {
|
||||||
|
$endDate = $period->getEndDate();
|
||||||
|
if (null === $endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDate = $endDate->setTime(0, 0, 0);
|
||||||
|
if ($endDate <= $today || $endDate > $upperBound) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $period->getEmployee();
|
||||||
|
if (null === $employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'Fin de %s de %s %s le %s',
|
||||||
|
$this->natureLabel($period->getContractNatureEnum()),
|
||||||
|
$employee->getFirstName(),
|
||||||
|
$employee->getLastName(),
|
||||||
|
$endDate->format('d/m/Y'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$notices[] = new ContractEndNotice($employee->getId(), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function natureLabel(ContractNature $nature): string
|
||||||
|
{
|
||||||
|
return match ($nature) {
|
||||||
|
ContractNature::CDI => 'CDI',
|
||||||
|
ContractNature::CDD => 'CDD',
|
||||||
|
ContractNature::INTERIM => 'Intérim',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $notificationsCreated,
|
||||||
|
public int $contractsMatched,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Notification;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use App\Repository\NotificationRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
final readonly class ContractEndNotificationService
|
||||||
|
{
|
||||||
|
private const CATEGORY = 'Contrat';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
private NotificationRepository $notificationRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private ContractEndNotificationPlanner $planner,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(DateTimeImmutable $today): ContractEndNotificationResult
|
||||||
|
{
|
||||||
|
$latestPeriods = $this->periodRepository->findLatestPeriodsForAllEmployees();
|
||||||
|
$notices = $this->planner->plan($latestPeriods, $today);
|
||||||
|
|
||||||
|
if ([] === $notices) {
|
||||||
|
return new ContractEndNotificationResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$admins = $this->userRepository->findAllAdmins();
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($notices as $notice) {
|
||||||
|
if (null === $notice->employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = '/employees/'.$notice->employeeId;
|
||||||
|
|
||||||
|
foreach ($admins as $admin) {
|
||||||
|
if ($this->notificationRepository->existsForRecipientCategoryTargetMessage(
|
||||||
|
$admin,
|
||||||
|
self::CATEGORY,
|
||||||
|
$target,
|
||||||
|
$notice->message,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = new Notification();
|
||||||
|
$notification->setRecipient($admin)
|
||||||
|
->setMessage($notice->message)
|
||||||
|
->setCategory(self::CATEGORY)
|
||||||
|
->setTarget($target)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($notification);
|
||||||
|
++$created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new ContractEndNotificationResult($created, count($notices));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class WorkingDayCalculator
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private PublicHolidayServiceInterface $holidays,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isWorkingDay(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
$dayOfWeek = (int) $date->format('N'); // 1 (lundi) .. 7 (dimanche)
|
||||||
|
if ($dayOfWeek >= 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->isPublicHoliday($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$candidate = $date->modify('+1 day')->setTime(0, 0, 0);
|
||||||
|
while (!$this->isWorkingDay($candidate)) {
|
||||||
|
$candidate = $candidate->modify('+1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPublicHoliday(DateTimeImmutable $date): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$holidays = $this->holidays->getHolidaysDayByYears('metropole', $date->format('Y'));
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isset($holidays[$date->format('Y-m-d')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\WorkHourValidationStatus;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statut de validation par jour pour le calendrier de la vue Jour (écran Heures).
|
||||||
|
*
|
||||||
|
* Un jour est « entièrement validé » (peint en vert côté front) ssi, dans le
|
||||||
|
* périmètre de l'utilisateur (admin = tous les sites, chef de site = ses sites) :
|
||||||
|
* - il porte au moins une ligne WorkHour de non-conducteur ce jour-là, ET
|
||||||
|
* - aucune de ces lignes n'est en attente de validation (isValid=false).
|
||||||
|
*
|
||||||
|
* Par défaut les conducteurs sont exclus (écran Heures). Avec `?driver=1`, le filtre
|
||||||
|
* s'inverse et seuls les conducteurs sont pris en compte (écran Heures Conducteurs).
|
||||||
|
* Le filtre sites de l'écran est volontairement ignoré :
|
||||||
|
* le statut reflète tout le périmètre (objectif RH : repérer le moindre jour
|
||||||
|
* incomplet, où qu'il soit). Un jour sans aucune ligne reste neutre (absent de
|
||||||
|
* la liste).
|
||||||
|
*/
|
||||||
|
final readonly class WorkHourValidationStatusProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
/** Garde-fou : borne la plage demandée pour éviter une requête démesurée. */
|
||||||
|
private const int MAX_RANGE_DAYS = 366;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private EmployeeScopedRepositoryInterface $employeeRepository,
|
||||||
|
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourValidationStatus
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$from, $to] = $this->resolveRange();
|
||||||
|
// ?driver=1 → ne garder que les conducteurs (écran Heures Conducteurs) ;
|
||||||
|
// défaut → ne garder que les non-conducteurs (écran Heures).
|
||||||
|
$driverOnly = filter_var(
|
||||||
|
$this->requestStack->getCurrentRequest()?->query->get('driver'),
|
||||||
|
FILTER_VALIDATE_BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
$employees = $this->employeeRepository->findScoped($user);
|
||||||
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
||||||
|
|
||||||
|
// Agrégation par jour : total = lignes non-conducteur, pending = lignes isValid=false.
|
||||||
|
/** @var array<string, array{total:int, pending:int}> $byDate */
|
||||||
|
$byDate = [];
|
||||||
|
// Mémoïsation de la résolution conducteur par (employé, jour) : un même
|
||||||
|
// couple peut revenir et resolveIsDriver... interroge la BDD.
|
||||||
|
$driverCache = [];
|
||||||
|
|
||||||
|
foreach ($workHours as $workHour) {
|
||||||
|
$employee = $workHour->getEmployee();
|
||||||
|
if (!$employee instanceof Employee) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$date = DateTimeImmutable::createFromInterface($workHour->getWorkDate());
|
||||||
|
$dateKey = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
$cacheKey = $employee->getId().'|'.$dateKey;
|
||||||
|
$isDriver = $driverCache[$cacheKey]
|
||||||
|
??= $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $date);
|
||||||
|
if ($isDriver !== $driverOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bucket = &$byDate[$dateKey];
|
||||||
|
$bucket ??= ['total' => 0, 'pending' => 0];
|
||||||
|
++$bucket['total'];
|
||||||
|
if (!$workHour->isValid()) {
|
||||||
|
++$bucket['pending'];
|
||||||
|
}
|
||||||
|
unset($bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validatedDays = [];
|
||||||
|
foreach ($byDate as $dateKey => $counts) {
|
||||||
|
if ($counts['total'] > 0 && 0 === $counts['pending']) {
|
||||||
|
$validatedDays[] = $dateKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($validatedDays);
|
||||||
|
|
||||||
|
$response = new WorkHourValidationStatus();
|
||||||
|
$response->from = $from->format('Y-m-d');
|
||||||
|
$response->to = $to->format('Y-m-d');
|
||||||
|
$response->validatedDays = $validatedDays;
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: DateTimeImmutable, 1: DateTimeImmutable}
|
||||||
|
*/
|
||||||
|
private function resolveRange(): array
|
||||||
|
{
|
||||||
|
$query = $this->requestStack->getCurrentRequest()?->query;
|
||||||
|
$from = $this->parseDate((string) ($query?->get('from') ?? ''), 'from');
|
||||||
|
$to = $this->parseDate((string) ($query?->get('to') ?? ''), 'to');
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
throw new UnprocessableEntityHttpException('from must be before or equal to to.');
|
||||||
|
}
|
||||||
|
if ($from->diff($to)->days > self::MAX_RANGE_DAYS) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf('Range must not exceed %d days.', self::MAX_RANGE_DAYS));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$from, $to];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseDate(string $raw, string $field): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
||||||
|
if (!$date || $date->format('Y-m-d') !== $raw) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise à minuit pour comparer des jours, pas des instants.
|
||||||
|
return $date->setTime(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?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->createStub(EntityManagerInterface::class);
|
||||||
|
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
$security = $this->createStub(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->createStub(EntityManagerInterface::class);
|
||||||
|
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
$security = $this->createStub(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->createStub(EntityManagerInterface::class);
|
||||||
|
$em->method('persist')->willReturnCallback(static function (object $entity) use (&$persisted): void {
|
||||||
|
$persisted = $entity;
|
||||||
|
});
|
||||||
|
$security = $this->createStub(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Service\Notification\ContractEndNotificationPlanner;
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ContractEndNotificationPlannerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testNotifiesContractEndingTomorrowOnAWeekday(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> fin mercredi 09/07
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-09')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(1, $notices);
|
||||||
|
self::assertSame('Fin de CDD de Jean Dupont le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFridayNotifiesContractsEndingOverTheWeekendAndMonday(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 ; lundi 14/07 férié -> prochain ouvré = mardi 15/07.
|
||||||
|
// Fenêtre ]11/07 ; 15/07] -> samedi 12, dimanche 13, lundi 14, mardi 15.
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[
|
||||||
|
$this->period('A', 'Sat', '2025-07-12'), // samedi -> inclus
|
||||||
|
$this->period('B', 'Mon', '2025-07-14'), // lundi férié -> inclus
|
||||||
|
$this->period('C', 'Tue', '2025-07-15'), // mardi (= borne haute) -> inclus
|
||||||
|
$this->period('D', 'Wed', '2025-07-16'), // mercredi -> hors fenêtre
|
||||||
|
],
|
||||||
|
new DateTimeImmutable('2025-07-11'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertCount(3, $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresOpenEndedContract(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', null, ContractNature::CDI)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIgnoresContractEndingToday(): void
|
||||||
|
{
|
||||||
|
// fin = today -> trop tard, pas de notif (on notifie la veille)
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-08')],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsNothingWhenTodayIsNotAWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Samedi 12/07 -> aucun jour chômé ne génère de notif
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Jean', 'Dupont', '2025-07-14')],
|
||||||
|
new DateTimeImmutable('2025-07-12'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([], $notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInterimNatureLabel(): void
|
||||||
|
{
|
||||||
|
$notices = $this->planner()->plan(
|
||||||
|
[$this->period('Marie', 'Martin', '2025-07-09', ContractNature::INTERIM)],
|
||||||
|
new DateTimeImmutable('2025-07-08'),
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Fin de Intérim de Marie Martin le 09/07/2025', $notices[0]->message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function planner(): ContractEndNotificationPlanner
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
'2025-07-14' => 'Fête nationale', // lundi 14/07 férié
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new ContractEndNotificationPlanner(new WorkingDayCalculator($holidays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function period(
|
||||||
|
string $firstName,
|
||||||
|
string $lastName,
|
||||||
|
?string $endDate,
|
||||||
|
ContractNature $nature = ContractNature::CDD,
|
||||||
|
): EmployeeContractPeriod {
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName($firstName)->setLastName($lastName);
|
||||||
|
|
||||||
|
$period = new EmployeeContractPeriod();
|
||||||
|
$period->setEmployee($employee)
|
||||||
|
->setContractNature($nature)
|
||||||
|
->setEndDate(null === $endDate ? null : new DateTimeImmutable($endDate))
|
||||||
|
;
|
||||||
|
|
||||||
|
return $period;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Notification;
|
||||||
|
|
||||||
|
use App\Service\Notification\WorkingDayCalculator;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WorkingDayCalculatorTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testWeekdayIsWorkingDay(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07/2025
|
||||||
|
self::assertTrue($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-08')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaturdayAndSundayAreNotWorkingDays(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-12'))); // samedi
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-13'))); // dimanche
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublicHolidayIsNotWorkingDay(): void
|
||||||
|
{
|
||||||
|
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-14'))); // lundi férié
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromWeekdayIsTomorrow(): void
|
||||||
|
{
|
||||||
|
// Mardi 08/07 -> Mercredi 09/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-09',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-08'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNextWorkingDayFromFridaySkipsWeekend(): void
|
||||||
|
{
|
||||||
|
// Vendredi 11/07 -> lundi 14/07 est férié -> mardi 15/07
|
||||||
|
self::assertSame(
|
||||||
|
'2025-07-15',
|
||||||
|
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-11'))->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculator(): WorkingDayCalculator
|
||||||
|
{
|
||||||
|
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||||
|
// Lundi 14/07/2025 férié
|
||||||
|
'2025-07-14' => 'Fête nationale',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new WorkingDayCalculator($holidays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||||
|
use App\State\AuditLogProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class AuditLogProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testExposesForensicFields(): 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')
|
||||||
|
;
|
||||||
|
|
||||||
|
$response = $this->provideWith($this->spyRepository([$log], 1), []);
|
||||||
|
$item = json_decode((string) $response->getContent(), true)['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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPassesNewFiltersToRepository(): void
|
||||||
|
{
|
||||||
|
$repo = $this->spyRepository();
|
||||||
|
$this->provideWith($repo, [
|
||||||
|
'employeeId' => '5',
|
||||||
|
'employee' => 'dupont',
|
||||||
|
'username' => 'usine',
|
||||||
|
'ip' => '10.0.',
|
||||||
|
'device' => 'android',
|
||||||
|
'entityType' => ['work_hour', 'absence'],
|
||||||
|
'action' => ['create'],
|
||||||
|
'perPage' => '25',
|
||||||
|
'page' => '2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame(5, $repo->findArgs['employeeId']);
|
||||||
|
self::assertSame('dupont', $repo->findArgs['employeeName']);
|
||||||
|
self::assertSame('usine', $repo->findArgs['username']);
|
||||||
|
self::assertSame('10.0.', $repo->findArgs['ip']);
|
||||||
|
self::assertSame('android', $repo->findArgs['device']);
|
||||||
|
self::assertSame(['work_hour', 'absence'], $repo->findArgs['entityTypes']);
|
||||||
|
self::assertSame(['create'], $repo->findArgs['actions']);
|
||||||
|
self::assertSame(25, $repo->findArgs['limit']);
|
||||||
|
self::assertSame(25, $repo->findArgs['offset']); // page 2, perPage 25 -> offset 25
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlankFiltersBecomeNull(): void
|
||||||
|
{
|
||||||
|
$repo = $this->spyRepository();
|
||||||
|
$this->provideWith($repo, ['username' => ' ', 'ip' => '', 'device' => '']);
|
||||||
|
|
||||||
|
self::assertNull($repo->findArgs['username']);
|
||||||
|
self::assertNull($repo->findArgs['ip']);
|
||||||
|
self::assertNull($repo->findArgs['device']);
|
||||||
|
self::assertNull($repo->findArgs['entityTypes']);
|
||||||
|
self::assertNull($repo->findArgs['actions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPerPageOutOfRangeFallsBackToDefault(): void
|
||||||
|
{
|
||||||
|
$repo = $this->spyRepository();
|
||||||
|
$response = $this->provideWith($repo, ['perPage' => '999']);
|
||||||
|
|
||||||
|
self::assertSame(10, $repo->findArgs['limit']);
|
||||||
|
self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultPerPageIs10(): void
|
||||||
|
{
|
||||||
|
$repo = $this->spyRepository();
|
||||||
|
$response = $this->provideWith($repo, []);
|
||||||
|
|
||||||
|
self::assertSame(10, $repo->findArgs['limit']);
|
||||||
|
self::assertSame(10, json_decode((string) $response->getContent(), true)['perPage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function spyRepository(array $items = [], int $count = 0): AuditLogReadRepositoryInterface
|
||||||
|
{
|
||||||
|
return new class($items, $count) implements AuditLogReadRepositoryInterface {
|
||||||
|
public array $findArgs = [];
|
||||||
|
public array $countArgs = [];
|
||||||
|
|
||||||
|
public function __construct(private array $items, private int $count) {}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$this->findArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName', 'limit', 'offset');
|
||||||
|
|
||||||
|
return $this->items;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$this->countArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'employeeName');
|
||||||
|
|
||||||
|
return $this->count;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function provideWith(AuditLogReadRepositoryInterface $repo, array $query): JsonResponse
|
||||||
|
{
|
||||||
|
$stack = new RequestStack();
|
||||||
|
$stack->push(Request::create('/api/audit-logs', 'GET', $query));
|
||||||
|
$provider = new AuditLogProvider($stack, $repo);
|
||||||
|
|
||||||
|
return $provider->provide($this->createStub(Operation::class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use App\State\WorkHourValidationStatusProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionObject;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class WorkHourValidationStatusProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
private Security $security;
|
||||||
|
private EmployeeScopedRepositoryInterface $employeeRepository;
|
||||||
|
private WorkHourReadRepositoryInterface $workHourRepository;
|
||||||
|
private RequestStack $requestStack;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->security = $this->createStub(Security::class);
|
||||||
|
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
|
||||||
|
$this->workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||||
|
$this->requestStack = new RequestStack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThrowsWhenAnonymous(): void
|
||||||
|
{
|
||||||
|
$this->security->method('getUser')->willReturn(null);
|
||||||
|
|
||||||
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
|
$this->buildProvider()->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThrowsWhenDateFormatInvalid(): void
|
||||||
|
{
|
||||||
|
$this->security->method('getUser')->willReturn(new User());
|
||||||
|
$this->requestStack->push(new Request(query: ['from' => '01-06-2026', 'to' => '2026-06-30']));
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->buildProvider()->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThrowsWhenFromAfterTo(): void
|
||||||
|
{
|
||||||
|
$this->security->method('getUser')->willReturn(new User());
|
||||||
|
$this->requestStack->push(new Request(query: ['from' => '2026-06-30', 'to' => '2026-06-01']));
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->buildProvider()->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThrowsWhenRangeTooLarge(): void
|
||||||
|
{
|
||||||
|
$this->security->method('getUser')->willReturn(new User());
|
||||||
|
$this->requestStack->push(new Request(query: ['from' => '2024-01-01', 'to' => '2026-01-01']));
|
||||||
|
|
||||||
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
|
$this->buildProvider()->provide(new Get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComputesValidatedDays(): void
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$alice = $this->buildEmployee(1);
|
||||||
|
$bob = $this->buildEmployee(2);
|
||||||
|
$driver = $this->buildEmployee(3);
|
||||||
|
|
||||||
|
// 2026-06-01 : Alice + Bob validés → vert.
|
||||||
|
// 2026-06-02 : Alice validée, Bob en attente → pas vert.
|
||||||
|
// 2026-06-03 : seul un conducteur (validé) → exclu → total non-conducteur 0 → pas vert.
|
||||||
|
$workHours = [
|
||||||
|
$this->buildWorkHour($alice, '2026-06-01', true),
|
||||||
|
$this->buildWorkHour($bob, '2026-06-01', true),
|
||||||
|
$this->buildWorkHour($alice, '2026-06-02', true),
|
||||||
|
$this->buildWorkHour($bob, '2026-06-02', false),
|
||||||
|
$this->buildWorkHour($driver, '2026-06-03', true),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->security->method('getUser')->willReturn($user);
|
||||||
|
$this->requestStack->push(new Request(query: ['from' => '2026-06-01', 'to' => '2026-06-30']));
|
||||||
|
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$alice, $bob, $driver]);
|
||||||
|
$this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn($workHours);
|
||||||
|
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver->method('resolveIsDriverForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $e): bool => 3 === $e->getId())
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = $this->buildProvider($resolver)->provide(new Get());
|
||||||
|
|
||||||
|
self::assertSame('2026-06-01', $result->from);
|
||||||
|
self::assertSame('2026-06-30', $result->to);
|
||||||
|
self::assertSame(['2026-06-01'], $result->validatedDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComputesValidatedDaysForDriverScope(): void
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$alice = $this->buildEmployee(1); // non-conducteur
|
||||||
|
$driver = $this->buildEmployee(3); // conducteur
|
||||||
|
|
||||||
|
// ?driver=1 : 01/06 conducteur validé → vert ; 02/06 conducteur en attente → non ;
|
||||||
|
// 03/06 seule Alice (non-conducteur) validée → ignorée → non.
|
||||||
|
$workHours = [
|
||||||
|
$this->buildWorkHour($driver, '2026-06-01', true),
|
||||||
|
$this->buildWorkHour($driver, '2026-06-02', false),
|
||||||
|
$this->buildWorkHour($alice, '2026-06-03', true),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->security->method('getUser')->willReturn($user);
|
||||||
|
$this->requestStack->push(new Request(query: ['from' => '2026-06-01', 'to' => '2026-06-30', 'driver' => '1']));
|
||||||
|
$this->employeeRepository->method('findScoped')->willReturn([$alice, $driver]);
|
||||||
|
$this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn($workHours);
|
||||||
|
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver->method('resolveIsDriverForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $e): bool => 3 === $e->getId())
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = $this->buildProvider($resolver)->provide(new Get());
|
||||||
|
|
||||||
|
self::assertSame(['2026-06-01'], $result->validatedDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyWhenNoWorkHours(): void
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$this->security->method('getUser')->willReturn($user);
|
||||||
|
$this->requestStack->push(new Request(query: ['from' => '2026-06-01', 'to' => '2026-06-30']));
|
||||||
|
$this->employeeRepository->method('findScoped')->willReturn([]);
|
||||||
|
$this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn([]);
|
||||||
|
|
||||||
|
$result = $this->buildProvider()->provide(new Get());
|
||||||
|
|
||||||
|
self::assertSame([], $result->validatedDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildProvider(?EmployeeContractResolver $resolver = null): WorkHourValidationStatusProvider
|
||||||
|
{
|
||||||
|
$resolver ??= $this->createStub(EmployeeContractResolver::class);
|
||||||
|
|
||||||
|
return new WorkHourValidationStatusProvider(
|
||||||
|
$this->security,
|
||||||
|
$this->requestStack,
|
||||||
|
$this->employeeRepository,
|
||||||
|
$this->workHourRepository,
|
||||||
|
$resolver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildEmployee(int $id): Employee
|
||||||
|
{
|
||||||
|
$employee = new Employee()
|
||||||
|
->setFirstName('Test')
|
||||||
|
->setLastName('Employee')
|
||||||
|
;
|
||||||
|
$reflection = new ReflectionObject($employee);
|
||||||
|
$property = $reflection->getProperty('id');
|
||||||
|
$property->setAccessible(true);
|
||||||
|
$property->setValue($employee, $id);
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildWorkHour(Employee $employee, string $date, bool $isValid): WorkHour
|
||||||
|
{
|
||||||
|
return new WorkHour()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setWorkDate(new DateTimeImmutable($date))
|
||||||
|
->setIsValid($isValid)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user