From 77ae6820d7888da2dbe8c44d3e7c158d6040a3fe Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 14:03:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(calendar)=20:=20supprimer=20/=20modifier?= =?UTF-8?q?=20une=20plage=20de=20cong=C3=A9s=20d'un=20coup=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Sur le calendrier, une absence est stockée **une ligne par jour** sans lien entre les jours (cf. `AbsenceWriteProcessor::expandAbsenceRange`). Conséquences pour la RH : - **Impossible de retirer une plage** : la suppression n'effaçait que le jour cliqué → il fallait supprimer chaque jour un par un. - **Modifier une plage laissait des incohérences** : le `PATCH` réutilisait une ligne et en recréait d'autres sans nettoyer l'ancien bloc → jours « fantômes » au raccourcissement et doublons à l'allongement. ## Changements (`frontend/pages/calendar.vue`) - **Supprimer** (`handleDelete`) : efface **toutes** les absences de l'employé comprises dans la plage `[début ; fin]` du drawer. Flux RH : clic sur un jour → étendre la date de fin → Supprimer. Jours sans absence ignorés (aucune erreur) ; jour validé (`isValid`/site) protégé côté backend. Confirmation avec nombre de jours + intervalle. - **Modifier** (`handleSubmit`) : **remplacement de bloc** — supprime l'ancien bloc contigu de même type (vers l'avant depuis le jour cliqué) + les absences recouvertes par la nouvelle plage, puis recrée la plage via `createAbsence`. Corrige le bug du `PATCH`. Les jours antérieurs au jour cliqué ne sont jamais touchés ; confirmation « chevauche une autre » seulement pour un autre type. `updateAbsence` n'est plus appelé depuis le calendrier. ## Pourquoi côté frontend Le backend ne peut pas reconstituer « la plage » (aucun identifiant de groupe en BDD) ; le frontend a la plage visible. Vérifié : les écrans **Heures** et **Heures Conducteurs** verrouillent les dates du drawer (`lock-dates`), donc le `PATCH` y reste mono-jour — le calendrier est le seul écran à reshaper une plage. `AbsenceWriteProcessor` non modifié. ## Documentation - `doc/functional-rules.md`, `frontend/data/documentation-content.ts` (in-app), `CLAUDE.md`. ## Tests - 253 tests PHPUnit verts (hook pre-commit). Pas de framework de test frontend dans le projet. - À valider en réel : raccourcir / allonger une plage (pas de jour fantôme ni doublon), supprimer une plage d'un coup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.malio.fr/MALIO-DEV/SIRH/pulls/34 Co-authored-by: tristan Co-committed-by: tristan --- CLAUDE.md | 4 + doc/functional-rules.md | 11 +++ frontend/data/documentation-content.ts | 2 + frontend/pages/calendar.vue | 130 +++++++++++++++++++------ 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a7ca49..85d248e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,10 @@ - **Calendrier des jours validés (vue Jour)** (`WorkHourValidationStatusProvider`, ressource `WorkHourValidationStatus`, endpoint `GET /work-hours/validation-status?from=&to=[&driver=1]`, `ROLE_USER`) : en **vue Jour**, sur les **deux écrans** (Heures **et** Heures Conducteurs), le sélecteur de date est un `MalioDate` (layer `@malio/layer-ui >= 1.7.x` : prop `markedDates` + event `@month-change`) qui peint **en vert** (`markedDates` → `'success'`) les jours **entièrement validés**. **Définition** : un jour est vert ssi il porte ≥1 ligne `WorkHour` du scope ce jour-là **et** aucune n'est `isValid=false` — on se base sur la **seule** colonne `is_valid` (validation admin ; `isSiteValid` ignoré). Jour **sans aucune ligne** → neutre (jamais vert). **Périmètre complet** via `EmployeeRepository::findScoped` (admin = tous sites, chef de site = ses sites), **indépendant du filtre sites** de l'écran. **Scope conducteur inversé** par `?driver=1` : écran Heures → non-conducteurs (défaut), écran Heures Conducteurs → conducteurs (résolu par date via `EmployeeContractResolver::resolveIsDriverForEmployeeAndDate`, mémoïsé ; garde `if ($isDriver !== $driverOnly) continue`). Provider : une requête `WorkHourReadRepositoryInterface::findByDateRangeAndEmployees`, agrégation par jour (`total`/`pending`), plage bornée à 366 j. **Chargement à la volée par mois** (jamais préchargé) : `@month-change {month,year}` (à l'ouverture + nav) → fetch de la **grille visible** (lundi avant le 1er → dimanche après le dernier) → cache `validatedDaysByMonth` (`useHoursPage` / `useDriverHoursPage`, ce dernier passe `{ driver: true }` au service) → `markedDates` réactif. **Rafraîchissement** du mois en cache (`reloadValidationMonth`) après `toggleValidation`/`toggleValidationBulk`/`handleSave`/`refreshAfterAbsenceChange` (pas la validation site). La **vue Semaine** utilise un `MalioDateWeek` (sélecteur de semaine, v-model ISO week `YYYY-Www`, sans coloration). Le **stepper ‹ › du mode jour est remplacé** par le `MalioDate` (raccourcis Hier/Aujourd'hui/Demain conservés) ; `PeriodStepperPicker` reste un fallback de la vue Jour quand `showValidationCalendar` est absent (aucun appelant actuel). Activation par écran via la prop `showValidationCalendar` de `HoursToolbar` (les deux pages la passent à `true` + `markedDates` + `@month-change`). Alignement vertical de la ligne via `lg:items-center` (les champs Malio font `h-12` vs `h-10` des boutons). Doc complète : `doc/hours-validated-days.md`. - **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`. - **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`). **L'impression PDF des absences applique le même filtre** côté backend (`AbsencePrintProvider::hasContractInRange` sur la période `[from, to]`) : un salarié parti en avril n'apparaît pas sur une impression de mai. **Le récap salaire applique le même filtre** (`SalaryRecapPrintProvider::hasContractInRange` sur le mois imprimé) : un salarié sans contrat sur le mois (ex. parti en février) n'apparaît pas sur le récap de juin. + - **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`. - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots - Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 25d4be6..de3923c 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -78,6 +78,17 @@ Documents complementaires: - Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`) - demi-journée: dégradé diagonal - 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: - 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é diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index d42404a..e6360c4 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -416,6 +416,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: '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: '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.' }, ], }, { diff --git a/frontend/pages/calendar.vue b/frontend/pages/calendar.vue index 2aaa060..39e0623 100644 --- a/frontend/pages/calendar.vue +++ b/frontend/pages/calendar.vue @@ -109,11 +109,11 @@ import type {HalfDay} from '~/services/dto/half-day' import {HALF_DAYS} from '~/services/dto/half-day' import {listEmployees, updateEmployeeOrder} from '~/services/employees' 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 type {Formation} from '~/services/dto/formation' 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 CalendarGrid from '~/components/CalendarGrid.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.") 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() + 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) => { 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 aEnd = normalizeDate(absence.endDate) if (start > aEnd || end < aStart) return false @@ -701,28 +760,15 @@ const handleSubmit = async () => { } } - if (editingAbsence.value) { - await updateAbsence({ - id: editingAbsence.value.id, - employeeId: Number(form.employeeId), - typeId: Number(form.typeId), - startDate: form.startDate, - startHalf: form.startHalf, - endDate: form.endDate, - 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 - }) - } + 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() 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 () => { 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 - await deleteAbsence(editingAbsence.value.id) + for (const absence of toDelete) { + await deleteAbsence(absence.id) + } closeDrawer() await loadAbsences() }