feat : ajout des demi-journées d'absence dans le calendrier et l'export pdf
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled

This commit is contained in:
2026-02-10 16:11:09 +01:00
parent 4cf00e6ef3
commit 2a8c874985
11 changed files with 404 additions and 46 deletions

View File

@@ -58,7 +58,7 @@
:visible-employees="visibleEmployees"
:grid-style="gridStyle"
:get-cell-style="getCellStyle"
:get-cell-code="getCellCode"
:get-cell-info="getCellInfo"
:format-employee-name="formatEmployeeName"
:is-holiday-date="isHolidayDate"
@cell-click="openCreate"
@@ -90,6 +90,8 @@
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} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
@@ -99,6 +101,7 @@ import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
// 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 }>()
@@ -110,6 +113,7 @@ const sites = computed(() => {
return Array.from(siteMap.values()).sort((siteA, siteB) => siteA.name.localeCompare(siteB.name, 'fr'))
})
// Filtres de sites (par défaut: tous sélectionnés à l'init).
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
@@ -119,6 +123,7 @@ watch(sites, (next) => {
sitesInitialized.value = true
}, { immediate: true })
// Tri stable: site -> nom -> prénom.
const sortedEmployees = computed(() => {
return [...employees.value].sort((employeeA, employeeB) => {
const siteNameA = employeeA.site?.name ?? ''
@@ -133,21 +138,25 @@ const sortedEmployees = computed(() => {
})
})
// Employés visibles selon le filtre de sites.
const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
return sortedEmployees.value.filter((employee) => {
return employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
})
})
// Données de référence et absences du mois affiché.
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
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())
@@ -170,43 +179,53 @@ const months = [
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
// 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[]
})
// 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)
@@ -220,12 +239,43 @@ const closePrint = () => {
isPrintOpen.value = false
}
// Parse "YYYY-MM-DD" en Date (ou null).
const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
// 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) {
@@ -234,6 +284,7 @@ const addMonths = (date: Date, months: number) => {
return next
}
// Limite l'intervalle d'impression à 2 mois max.
const enforcePrintRange = () => {
if (!printForm.from) return
const start = parseYmd(printForm.from)
@@ -266,6 +317,7 @@ const enforcePrintRange = () => {
watch(() => printForm.from, enforcePrintRange)
watch(() => printForm.to, enforcePrintRange)
// Chargements API.
const loadEmployees = async () => {
employees.value = await listEmployees()
}
@@ -302,15 +354,17 @@ watch(selectedYear, async () => {
// 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; textColor?: string }>()
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 start = parseYmd(normalizeDate(absence.startDate))
const end = parseYmd(normalizeDate(absence.endDate))
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
@@ -322,11 +376,20 @@ const cellAbsenceMap = computed(() => {
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const key = `${employeeId}-${toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())}`
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'
color: absence.type?.color ?? '#222783',
halfLabel
})
}
}
@@ -334,15 +397,17 @@ const cellAbsenceMap = computed(() => {
return map
})
// Jours fériés (interdit pour la création).
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
}
// Renvoie l'absence effective pour une cellule (ou un "Férié").
const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) {
return {
id: 0,
code: 'F',
code: 'Férié',
color: '#b3e5fc',
textColor: '#0f172a'
}
@@ -352,20 +417,35 @@ const getCellAbsence = (employeeId: number, date: string) => {
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 getCellCode = (employeeId: number, date: string) => {
return getCellAbsence(employeeId, date)?.code ?? ''
const getCellInfo = (employeeId: number, date: string) => {
return getCellAbsence(employeeId, date)
}
// Ouverture du drawer depuis une cellule.
const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
@@ -384,12 +464,16 @@ const openCreate = (employee: Employee, date: string) => {
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 = ''
}
@@ -397,6 +481,7 @@ const openCreate = (employee: Employee, date: string) => {
isDrawerOpen.value = true
}
// Ouverture du drawer depuis le bouton "Ajouter".
const openCreateFromToday = () => {
editingAbsence.value = null
form.employeeId = ''
@@ -409,10 +494,13 @@ const openCreateFromToday = () => {
}
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)
@@ -430,6 +518,7 @@ const hasHolidayInRange = (startDate: string, endDate: string) => {
return false
}
// Soumission du formulaire: validations + chevauchement + save.
const handleSubmit = async () => {
if (isSubmitting.value) return
@@ -437,6 +526,14 @@ const handleSubmit = async () => {
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 (hasHolidayInRange(start, end)) {
window.alert("Impossible de creer une absence sur un jour ferie.")
return
@@ -446,7 +543,40 @@ const handleSubmit = async () => {
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
const aStart = normalizeDate(absence.startDate)
const aEnd = normalizeDate(absence.endDate)
return start <= aEnd && end >= aStart
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) {
@@ -466,7 +596,9 @@ const handleSubmit = async () => {
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: form.comment
})
} else {
@@ -474,7 +606,9 @@ const handleSubmit = async () => {
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: form.comment
})
}
@@ -486,6 +620,7 @@ const handleSubmit = async () => {
}
}
// Suppression de l'absence en cours d'édition.
const handleDelete = async () => {
if (!editingAbsence.value) return
@@ -497,11 +632,13 @@ const handleDelete = async () => {
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()