4aeff28af4
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>
877 lines
31 KiB
Vue
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>
|