Files
SIRH/frontend/pages/calendar.vue
T
tristan 4aeff28af4 feat(calendar) : suppression et modification d'une plage de congés d'un coup
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) <noreply@anthropic.com>
2026-06-24 14:23:05 +02:00

877 lines
31 KiB
Vue

<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
</div>
<div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<MalioSelectCheckbox :reserve-message-space="false"
v-model="selectedSiteIds"
:options="siteOptions"
label="Sites"
groupClass="relative z-50 w-80 h-10"
display-select-all
/>
<div class="flex gap-4">
<MalioButton
label="Ajouter"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateFromToday"
/>
<MalioButton
label="Imprimer"
variant="secondary"
icon-name="mdi:printer"
icon-position="left"
@click="openPrint"
/>
</div>
</div>
<div class="flex justify-between">
<div class="flex items-center gap-4">
<div class="w-80">
<MalioInputText :reserve-message-space="false"
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<PeriodStepperPicker
width-class="w-[260px]"
:label="selectedMonthLabel"
picker-type="month"
:picker-value="monthPickerValue"
prev-aria-label="Mois précédent"
next-aria-label="Mois suivant"
@prev="shiftMonth(-1)"
@next="shiftMonth(1)"
@pick="onMonthPickerValue"
/>
</div>
</div>
<div class="flex flex-wrap items-center gap-6 py-2">
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-indigo-500"></div>
<p>FORMATION</p>
</div>
</div>
</div>
<div class="flex-1 min-h-0">
<CalendarGrid
:days-in-month="daysInMonth"
:visible-employees="visibleEmployees"
:grid-style="gridStyle"
:get-cell-style="getCellStyle"
:get-cell-info="getCellInfo"
:format-employee-name="formatEmployeeName"
:is-holiday-date="isHolidayDate"
@cell-click="openCreate"
@reorder="handleReorder"
/>
</div>
<AbsenceFormDrawer
v-model="isDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="form"
:editing-absence="editingAbsence"
:is-submitting="isSubmitting"
@submit="handleSubmit"
@delete="handleDelete"
@cancel="closeDrawer"
/>
<AbsencePrintDrawer
v-model="isPrintOpen"
:sites="sites"
:contract-natures="contractNatureOptions"
:work-contracts="workContractOptions"
:print-form="printForm"
@submit="handlePrint"
@cancel="closePrint"
/>
</div>
</template>
<script setup lang="ts">
import type {Employee} from '~/services/dto/employee'
import type {AbsenceType} from '~/services/dto/absence-type'
import type {Absence} from '~/services/dto/absence'
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} from '~/services/absences'
import {listFormationsByDateRange} from '~/services/formations'
import type {Formation} from '~/services/dto/formation'
import {listPublicHolidays} from '~/services/public-holidays'
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'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
useHead({
title: 'Calendrier'
})
// Données principales affichées dans la grille.
const employees = ref<Employee[]>([])
const sites = computed(() => {
const siteMap = new Map<number, { id: number; name: string; color: string }>()
for (const employee of employees.value) {
if (employee.site) {
siteMap.set(employee.site.id, employee.site)
}
}
return Array.from(siteMap.values()).sort((siteA, siteB) => {
const orderA = siteA.displayOrder ?? 0
const orderB = siteB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
return siteA.name.localeCompare(siteB.name, 'fr')
})
})
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
// Filtres de sites (par défaut: tous sélectionnés à l'init).
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
watch(sites, (next) => {
if (sitesInitialized.value || next.length === 0) return
selectedSiteIds.value = next.map((site) => site.id)
sitesInitialized.value = true
}, {immediate: true})
// Tri stable: site -> nom -> prénom.
const sortedEmployees = computed(() => {
return sortEmployeesBySiteAndOrder(employees.value)
})
// Employés visibles selon le filtre de sites.
const employeeFilter = ref('')
// Un employé est considéré "présent" sur le mois affiché si au moins une de ses
// périodes de contrat intersecte [début du mois ; fin du mois]. Sinon il est masqué.
const hasContractInSelectedMonth = (employee: Employee): boolean => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
const history = employee.contractHistory ?? []
if (history.length === 0) return false
return history.some((period) => {
const start = period.startDate
const end = period.endDate ?? '9999-12-31'
return start <= monthEnd && end >= monthStart
})
}
const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return sortedEmployees.value.filter((employee) => {
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
if (!siteOk) return false
if (!hasContractInSelectedMonth(employee)) return false
if (!filter) return true
const first = employee.firstName?.toLowerCase() ?? ''
const last = employee.lastName?.toLowerCase() ?? ''
return first.includes(filter) || last.includes(filter)
})
})
// Données de référence et absences du mois affiché.
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const formations = ref<Formation[]>([])
const publicHolidays = ref<Record<string, string>>({})
// États UI.
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const editingAbsence = ref<Absence | null>(null)
const isPrintOpen = ref(false)
// Sélecteurs de période.
const now = new Date()
const selectedMonth = ref(now.getMonth())
const selectedYear = ref(now.getFullYear())
const months = [
{value: 0, label: 'Janvier'},
{value: 1, label: 'Février'},
{value: 2, label: 'Mars'},
{value: 3, label: 'Avril'},
{value: 4, label: 'Mai'},
{value: 5, label: 'Juin'},
{value: 6, label: 'Juillet'},
{value: 7, label: 'Août'},
{value: 8, label: 'Septembre'},
{value: 9, label: 'Octobre'},
{value: 10, label: 'Novembre'},
{value: 11, label: 'Décembre'}
]
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
// Infos de calendrier calculées.
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
const monthStartDate = computed(() => new Date(selectedYear.value, selectedMonth.value, 1))
const monthEndDate = computed(() => new Date(selectedYear.value, selectedMonth.value + 1, 0))
// Largeur fixe de la colonne employés + une colonne par jour.
const gridStyle = computed(() => ({
gridTemplateColumns: `160px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
}))
// Formulaire d'absence (AM/PM par défaut = journée complète).
const form = reactive({
employeeId: '' as number | '',
typeId: '' as number | '',
startDate: '',
startHalf: 'AM' as HalfDay,
endDate: '',
endHalf: 'PM' as HalfDay,
comment: ''
})
// Formulaire d'impression (intervalle + sites).
const printForm = reactive({
from: '',
to: '',
siteIds: [] as number[],
contractNatures: [] as Array<'CDI' | 'CDD' | 'INTERIM'>,
workContractIds: [] as number[]
})
const contractNatureOptions = [
{ value: 'CDI' as const, label: 'CDI' },
{ value: 'CDD' as const, label: 'CDD' },
{ value: 'INTERIM' as const, label: 'Intérim' }
]
const workContractOptions = computed(() => {
const byId = new Map<number, { id: number; name: string }>()
for (const employee of employees.value) {
const contract = employee.contract
if (!contract?.id) continue
byId.set(contract.id, { id: contract.id, name: contract.name })
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Remet le formulaire à zéro.
const resetForm = () => {
form.employeeId = ''
form.typeId = ''
form.startDate = ''
form.startHalf = 'AM'
form.endDate = ''
form.endHalf = 'PM'
form.comment = ''
}
// Ferme le drawer et nettoie l'état.
const closeDrawer = () => {
isDrawerOpen.value = false
editingAbsence.value = null
resetForm()
}
// Ouvre l'impression avec la période du mois courant.
const openPrint = () => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
printForm.from = monthStart
printForm.to = monthEnd
printForm.siteIds = [...selectedSiteIds.value]
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
isPrintOpen.value = true
}
const closePrint = () => {
isPrintOpen.value = false
}
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
const getHalfForDate = (
startDate: string,
endDate: string,
startHalf: HalfDay,
endHalf: HalfDay,
date: string
) => {
if (startDate === endDate) {
return startHalf === endHalf ? startHalf : null
}
if (date === startDate && startHalf === 'PM') return 'PM'
if (date === endDate && endHalf === 'AM') return 'AM'
return null
}
// Renvoie les segments occupés pour une date donnée (AM/PM).
const getSegmentsForDate = (
startDate: string,
endDate: string,
startHalf: HalfDay,
endHalf: HalfDay,
date: string
) => {
const half = getHalfForDate(startDate, endDate, startHalf, endHalf, date)
if (!half) return HALF_DAYS.map((item) => item.value) as HalfDay[]
return [half] as HalfDay[]
}
// Ajoute des mois tout en gardant un jour valide.
const addMonths = (date: Date, months: number) => {
const next = new Date(date.getFullYear(), date.getMonth() + months, date.getDate())
if (next.getMonth() !== (date.getMonth() + months) % 12) {
next.setDate(0)
}
return next
}
const shiftMonth = (delta: number) => {
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
selectedYear.value = next.getFullYear()
selectedMonth.value = next.getMonth()
}
const onMonthPickerValue = (value: string) => {
if (!value) return
const [yearStr, monthStr] = value.split('-')
const year = Number(yearStr)
const month = Number(monthStr)
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
selectedYear.value = year
selectedMonth.value = month - 1
}
// Limite l'intervalle d'impression à 2 mois max.
const enforcePrintRange = () => {
if (!printForm.from) return
const start = parseYmd(printForm.from)
if (!start) return
const maxEnd = addMonths(start, 2)
maxEnd.setDate(maxEnd.getDate() - 1)
const maxEndYmd = toYmd(maxEnd.getFullYear(), maxEnd.getMonth(), maxEnd.getDate())
if (!printForm.to) {
printForm.to = maxEndYmd
return
}
const end = parseYmd(printForm.to)
if (!end) {
printForm.to = maxEndYmd
return
}
if (end < start) {
printForm.to = printForm.from
return
}
if (end > maxEnd) {
printForm.to = maxEndYmd
}
}
watch(() => printForm.from, enforcePrintRange)
watch(() => printForm.to, enforcePrintRange)
// Chargements API.
const loadEmployees = async () => {
employees.value = await listEmployees()
}
const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadPublicHolidays = async () => {
publicHolidays.value = await listPublicHolidays('metropole', selectedYear.value)
}
const loadAbsences = async () => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
absences.value = await listAbsences({
from: monthStart,
to: monthEnd,
siteIds: selectedSiteIds.value
})
}
const loadFormations = async () => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
formations.value = await listFormationsByDateRange(monthStart, monthEnd)
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences(), loadFormations()])
})
watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await Promise.all([loadAbsences(), loadFormations()])
})
watch(selectedYear, async () => {
await loadPublicHolidays()
})
// Indexation des absences par cellule pour eviter un find() a chaque case.
const cellAbsenceMap = computed(() => {
const map = new Map<string, { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string }>()
const monthStart = monthStartDate.value
const monthEnd = monthEndDate.value
for (const absence of absences.value) {
const employeeId = absence.employee?.id
if (!employeeId) continue
const startDate = normalizeDate(absence.startDate)
const endDate = normalizeDate(absence.endDate)
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) continue
const rangeStart = start < monthStart ? monthStart : start
const rangeEnd = end > monthEnd ? monthEnd : end
if (rangeEnd < rangeStart) continue
for (
let currentDate = new Date(rangeStart.getTime());
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
const key = `${employeeId}-${dateKey}`
const halfLabel = getHalfForDate(
startDate,
endDate,
absence.startHalf ?? 'AM',
absence.endHalf ?? 'PM',
dateKey
) ?? undefined
map.set(key, {
id: absence.id,
code: absence.type?.code ?? '',
color: absence.type?.color ?? '#222783',
halfLabel
})
}
}
return map
})
// Indexation des formations par cellule pour un lookup O(1).
const cellFormationMap = computed(() => {
const set = new Set<string>()
const monthStart = monthStartDate.value
const monthEnd = monthEndDate.value
for (const formation of formations.value) {
const employeeId = formation.employee?.id
if (!employeeId) continue
const startDate = normalizeDate(formation.startDate)
const endDate = normalizeDate(formation.endDate)
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) continue
const rangeStart = start < monthStart ? monthStart : start
const rangeEnd = end > monthEnd ? monthEnd : end
if (rangeEnd < rangeStart) continue
for (
let currentDate = new Date(rangeStart.getTime());
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
set.add(`${employeeId}-${dateKey}`)
}
}
return set
})
const hasFormationOn = (employeeId: number, date: string): boolean => {
return cellFormationMap.value.has(`${employeeId}-${date}`)
}
// Jours fériés.
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
}
// Renvoie l'absence effective pour une cellule (ou un "Férié" si pas d'absence).
const getCellAbsence = (employeeId: number, date: string) => {
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (!absence && isHolidayDate(date)) {
return {
id: 0,
code: 'Férié',
color: '#b3e5fc',
textColor: '#0f172a'
}
}
if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (hasFormationOn(employeeId, date)) {
return {
id: 0,
code: 'F',
color: '#6366f1',
textColor: '#fff',
hasFormation: true
}
}
return null
}
// Style de cellule (plein ou demi-journée).
const getCellStyle = (employeeId: number, date: string) => {
const absence = getCellAbsence(employeeId, date)
if (!absence) return undefined
if (absence.halfLabel) {
const color = absence.color
const textColor = absence.textColor ?? '#FFF'
const backgroundImage = absence.halfLabel === 'AM'
? `linear-gradient(180deg, ${color} 0 50%, transparent 50% 100%)`
: `linear-gradient(180deg, transparent 0 50%, ${color} 50% 100%)`
return {
backgroundImage,
backgroundColor: 'transparent',
color: textColor
}
}
return {
backgroundColor: absence.color,
color: absence.textColor ?? '#fff'
}
}
const getCellInfo = (employeeId: number, date: string) => {
return getCellAbsence(employeeId, date)
}
// Ouverture du drawer depuis une cellule.
const openCreate = (employee: Employee, date: string) => {
const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
return absence.employee?.id === employee.id && date >= start && date <= end
})
if (existing) {
editingAbsence.value = existing
form.employeeId = existing.employee.id
form.typeId = existing.type.id
form.startDate = normalizeDate(existing.startDate)
form.endDate = normalizeDate(existing.endDate)
form.startHalf = existing.startHalf ?? 'AM'
form.endHalf = existing.endHalf ?? 'PM'
form.comment = existing.comment ?? ''
} else {
editingAbsence.value = null
form.employeeId = employee.id
form.startDate = date
form.endDate = date
form.startHalf = 'AM'
form.endHalf = 'PM'
form.typeId = ''
form.comment = ''
}
isDrawerOpen.value = true
}
// Ouverture du drawer depuis le bouton "Ajouter".
const openCreateFromToday = () => {
editingAbsence.value = null
form.employeeId = ''
form.typeId = ''
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
form.startDate = today
form.endDate = today
form.startHalf = 'AM'
form.endHalf = 'PM'
form.comment = ''
isDrawerOpen.value = true
}
// Vérifie la présence d'un férié dans l'intervalle.
const hasHolidayInRange = (startDate: string, endDate: string) => {
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) return false
for (
let currentDate = new Date(start.getTime());
currentDate <= end;
currentDate.setDate(currentDate.getDate() + 1)
) {
const key = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
if (isHolidayDate(key)) {
return true
}
}
return false
}
// Soumission du formulaire: validations + chevauchement + save.
const handleSubmit = async () => {
if (isSubmitting.value) return
isSubmitting.value = true
try {
const start = normalizeDate(form.startDate)
const end = normalizeDate(form.endDate)
if (start > end) {
window.alert("La date de fin ne peut pas etre avant la date de debut.")
return
}
if (start === end && form.startHalf === 'PM' && form.endHalf === 'AM') {
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
const aStart = normalizeDate(absence.startDate)
const aEnd = normalizeDate(absence.endDate)
if (start > aEnd || end < aStart) return false
const overlapStart = start > aStart ? start : aStart
const overlapEnd = end < aEnd ? end : aEnd
const overlapStartDate = parseYmd(overlapStart)
const overlapEndDate = parseYmd(overlapEnd)
if (!overlapStartDate || !overlapEndDate) return false
for (
let currentDate = new Date(overlapStartDate.getTime());
currentDate <= overlapEndDate;
currentDate.setDate(currentDate.getDate() + 1)
) {
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
const existingSegments = getSegmentsForDate(
aStart,
aEnd,
absence.startHalf ?? 'AM',
absence.endHalf ?? 'PM',
dateKey
)
const newSegments = getSegmentsForDate(
start,
end,
form.startHalf,
form.endHalf,
dateKey
)
if (existingSegments.some((segment) => newSegments.includes(segment))) {
return true
}
}
return false
})
if (overlaps.length > 0) {
// Securise le chevauchement: on demande confirmation avant suppression.
const confirmReplace = window.confirm(
"Cette absence chevauche une autre. Voulez-vous la remplacer ?"
)
if (!confirmReplace) return
for (const overlap of overlaps) {
await deleteAbsence(overlap.id)
}
}
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()
} finally {
isSubmitting.value = false
}
}
// 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 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
for (const absence of toDelete) {
await deleteAbsence(absence.id)
}
closeDrawer()
await loadAbsences()
}
// Affiche "Prénom N.".
const formatEmployeeName = (employee: Employee) => {
const initial = employee.lastName ? `${employee.lastName[0].toUpperCase()}.` : ''
return `${employee.firstName} ${initial}`.trim()
}
// Impression PDF de l'intervalle sélectionné.
const {printPdf} = usePdfPrinter()
const handlePrint = async () => {
const params = new URLSearchParams()
params.set('from', printForm.from)
params.set('to', printForm.to)
if (printForm.siteIds.length > 0) {
params.set('sites', printForm.siteIds.join(','))
}
if (printForm.contractNatures.length > 0) {
params.set('contractNatures', printForm.contractNatures.join(','))
}
if (printForm.workContractIds.length > 0) {
params.set('workContracts', printForm.workContractIds.join(','))
}
await printPdf(`/absences/print?${params.toString()}`)
isPrintOpen.value = false
}
const handleReorder = async (payload: { dragId: number; dropId: number }) => {
const dragEmployee = employees.value.find((employee) => employee.id === payload.dragId)
const dropEmployee = employees.value.find((employee) => employee.id === payload.dropId)
if (!dragEmployee || !dropEmployee) return
const dragSiteId = dragEmployee.site?.id
const dropSiteId = dropEmployee.site?.id
if (!dragSiteId || !dropSiteId || dragSiteId !== dropSiteId) return
const siteEmployees = [...employees.value]
.filter((employee) => employee.site?.id === dragSiteId)
.sort(compareEmployeesInSite)
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.id)
const toIndex = siteEmployees.findIndex((employee) => employee.id === dropEmployee.id)
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
const [moved] = siteEmployees.splice(fromIndex, 1)
siteEmployees.splice(toIndex, 0, moved)
const updates: Array<{ id: number; displayOrder: number }> = []
siteEmployees.forEach((employee, index) => {
const nextOrder = index + 1
if ((employee.displayOrder ?? 0) !== nextOrder) {
updates.push({id: employee.id, displayOrder: nextOrder})
}
employee.displayOrder = nextOrder
})
if (updates.length === 0) return
await Promise.all(updates.map((update) => updateEmployeeOrder(update.id, update.displayOrder)))
}
</script>