feat(calendar) : supprimer / modifier une plage de congés d'un coup (#34)
Auto Tag Develop / tag (push) Successful in 12s
Auto Tag Develop / tag (push) Successful in 12s
## 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: #34 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #34.
This commit is contained in:
+102
-28
@@ -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<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) => {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user