From 4aeff28af42475b67efb26893f69099a428b11f6 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 24 Jun 2026 14:23:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(calendar)=20:=20suppression=20et=20modific?= =?UTF-8?q?ation=20d'une=20plage=20de=20cong=C3=A9s=20d'un=20coup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur le calendrier, une absence est stockée une ligne par jour sans lien entre les jours. La suppression et la modification n'agissaient donc que sur le jour cliqué. - Supprimer (handleDelete) : efface toutes les absences de l'employé comprises dans la plage [début ; fin] du drawer (jours sans absence ignorés, jour validé protégé côté backend). - 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. Corrige le bug du PATCH qui laissait des jours fantômes (raccourcissement) et des doublons (allongement). updateAbsence n'est plus utilisé sur le calendrier. Backend AbsenceWriteProcessor non touché : les écrans Heures verrouillent les dates du drawer, le PATCH y reste mono-jour. Doc : functional-rules.md, documentation-content.ts (in-app), CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- 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() }