511 lines
16 KiB
Vue
511 lines
16 KiB
Vue
<template>
|
|
<div>
|
|
<div class="flex flex-wrap items-center justify-between gap-4 pb-10 no-print">
|
|
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
|
<div class="flex items-center gap-3">
|
|
<select
|
|
v-model="selectedMonth"
|
|
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
|
>
|
|
<option v-for="month in months" :key="month.value" :value="month.value">
|
|
{{ month.label }}
|
|
</option>
|
|
</select>
|
|
<select
|
|
v-model="selectedYear"
|
|
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
|
>
|
|
<option v-for="year in years" :key="year" :value="year">
|
|
{{ year }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
@click="openCreateFromToday"
|
|
>
|
|
Ajouter une absence
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
@click="printMonth('1')"
|
|
>
|
|
Imprimer 1 mois
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
@click="printMonth('3')"
|
|
>
|
|
Imprimer 3 mois
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white no-print">
|
|
<div class="min-w-[900px]">
|
|
<div class="grid" :style="gridStyle">
|
|
<div class="sticky left-0 z-10 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700">
|
|
Employés
|
|
</div>
|
|
<div
|
|
v-for="day in daysInMonth"
|
|
:key="day.date"
|
|
class="border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
|
|
>
|
|
<div>{{ day.label }}</div>
|
|
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
|
|
</div>
|
|
|
|
<template v-for="employee in employees" :key="employee.id">
|
|
<div class="sticky left-0 z-10 border-b border-neutral-100 bg-white px-4 py-3 text-md font-semibold text-neutral-800">
|
|
{{ employee.firstName }} {{ employee.lastName }}
|
|
</div>
|
|
<div
|
|
v-for="day in daysInMonth"
|
|
:key="employee.id + '-' + day.date"
|
|
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
|
|
:style="getCellStyle(employee.id, day.date)"
|
|
@click="openCreate(employee, day.date)"
|
|
>
|
|
<span v-if="getCellCode(employee.id, day.date)">
|
|
{{ getCellCode(employee.id, day.date) }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="printMode" class="print-only space-y-8">
|
|
<div
|
|
v-for="month in printMonths"
|
|
:key="`${month.year}-${month.month}`"
|
|
class="overflow-hidden rounded-lg border border-neutral-200 bg-white"
|
|
>
|
|
<div class="flex items-center justify-between border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-lg font-bold text-neutral-700">
|
|
<span>Calendrier {{ month.label }} {{ month.year }}</span>
|
|
</div>
|
|
<div class="overflow-auto">
|
|
<div class="min-w-[900px]">
|
|
<div class="grid" :style="getGridStyle(month.days.length)">
|
|
<div class="sticky left-0 z-10 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700">
|
|
Employé
|
|
</div>
|
|
<div
|
|
v-for="day in month.days"
|
|
:key="day.date"
|
|
class="border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
|
|
>
|
|
<div>{{ day.label }}</div>
|
|
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
|
|
</div>
|
|
|
|
<template v-for="employee in employees" :key="employee.id">
|
|
<div class="sticky left-0 z-10 border-b border-neutral-100 bg-white px-4 py-3 text-md font-semibold text-neutral-800">
|
|
{{ employee.firstName }} {{ employee.lastName }}
|
|
</div>
|
|
<div
|
|
v-for="day in month.days"
|
|
:key="employee.id + '-' + day.date"
|
|
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
|
|
>
|
|
<div
|
|
class="flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
|
|
:style="getCellStyle(employee.id, day.date)"
|
|
>
|
|
<span v-if="getCellCode(employee.id, day.date)">
|
|
{{ getCellCode(employee.id, day.date) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AppDrawer v-model="isDrawerOpen" title="Nouvelle absence">
|
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
|
<div>
|
|
<label class="text-md font-semibold text-neutral-700" for="employee">Employé</label>
|
|
<select
|
|
id="employee"
|
|
v-model="form.employeeId"
|
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
|
>
|
|
<option value="" disabled>Choisir un employé</option>
|
|
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
|
{{ employee.firstName }} {{ employee.lastName }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="text-md font-semibold text-neutral-700" for="type">Type d'absence</label>
|
|
<select
|
|
id="type"
|
|
v-model="form.typeId"
|
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
|
>
|
|
<option value="" disabled>Choisir un type</option>
|
|
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
|
|
{{ type.label }} ({{ type.code }})
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="text-md font-semibold text-neutral-700" for="start-date">Date de début</label>
|
|
<input
|
|
id="start-date"
|
|
v-model="form.startDate"
|
|
type="date"
|
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="text-md font-semibold text-neutral-700" for="end-date">Date de fin</label>
|
|
<input
|
|
id="end-date"
|
|
v-model="form.endDate"
|
|
type="date"
|
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
|
|
<textarea
|
|
id="comment"
|
|
v-model="form.comment"
|
|
rows="3"
|
|
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-3 pt-2">
|
|
<button
|
|
v-if="editingAbsence"
|
|
type="button"
|
|
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
|
|
@click="handleDelete"
|
|
>
|
|
Supprimer
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
|
|
@click="closeDrawer"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
|
:disabled="isSubmitting"
|
|
>
|
|
Enregistrer
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</AppDrawer>
|
|
</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 { listEmployees } from '~/services/employees'
|
|
import { listAbsenceTypes } from '~/services/absence-types'
|
|
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
|
import { getDaysInMonth, normalizeDate, toYmd } from '~/utils/date'
|
|
|
|
const employees = ref<Employee[]>([])
|
|
const absenceTypes = ref<AbsenceType[]>([])
|
|
const absences = ref<Absence[]>([])
|
|
|
|
const isDrawerOpen = ref(false)
|
|
const isSubmitting = ref(false)
|
|
const editingAbsence = ref<Absence | null>(null)
|
|
|
|
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 years = Array.from({ length: 5 }, (_, i) => now.getFullYear() - 2 + i)
|
|
|
|
|
|
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
|
|
|
const getGridStyle = (daysCount: number) => {
|
|
return {
|
|
gridTemplateColumns: `220px repeat(${daysCount}, minmax(44px, 1fr))`
|
|
}
|
|
}
|
|
|
|
const gridStyle = computed(() => getGridStyle(daysInMonth.value.length))
|
|
|
|
const form = reactive({
|
|
employeeId: '' as number | '',
|
|
typeId: '' as number | '',
|
|
startDate: '',
|
|
endDate: '',
|
|
comment: ''
|
|
})
|
|
|
|
const printMode = ref<'1' | '3' | null>(null)
|
|
|
|
const monthLabel = (monthIndex: number) => months.find((m) => m.value === monthIndex)?.label ?? ''
|
|
|
|
const printMonths = computed(() => {
|
|
if (!printMode.value) return []
|
|
|
|
const startYear = selectedYear.value
|
|
const startMonth = selectedMonth.value
|
|
const count = printMode.value === '3' ? 3 : 1
|
|
|
|
return Array.from({ length: count }, (_, offset) => {
|
|
const date = new Date(startYear, startMonth + offset, 1)
|
|
const year = date.getFullYear()
|
|
const month = date.getMonth()
|
|
return {
|
|
year,
|
|
month,
|
|
label: monthLabel(month),
|
|
days: getDaysInMonth(year, month)
|
|
}
|
|
})
|
|
})
|
|
|
|
const resetForm = () => {
|
|
form.employeeId = ''
|
|
form.typeId = ''
|
|
form.startDate = ''
|
|
form.endDate = ''
|
|
form.comment = ''
|
|
}
|
|
|
|
const closeDrawer = () => {
|
|
isDrawerOpen.value = false
|
|
editingAbsence.value = null
|
|
resetForm()
|
|
}
|
|
|
|
const loadEmployees = async () => {
|
|
employees.value = await listEmployees()
|
|
}
|
|
|
|
const loadAbsenceTypes = async () => {
|
|
absenceTypes.value = await listAbsenceTypes()
|
|
}
|
|
|
|
const loadAbsences = async () => {
|
|
absences.value = await listAbsences()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadAbsences()])
|
|
})
|
|
|
|
watch([selectedMonth, selectedYear], async () => {
|
|
await loadAbsences()
|
|
})
|
|
|
|
const getCellAbsence = (employeeId: number, date: string) => {
|
|
const match = absences.value.find((absence) => {
|
|
const employee = absence.employee?.id
|
|
const start = normalizeDate(absence.startDate)
|
|
const end = normalizeDate(absence.endDate)
|
|
return Number(employee) === employeeId && date >= start && date <= end
|
|
})
|
|
|
|
if (!match) return null
|
|
|
|
return {
|
|
id: match.id,
|
|
code: match.type?.code ?? '',
|
|
color: match.type?.color ?? '#222783'
|
|
}
|
|
}
|
|
|
|
const getCellStyle = (employeeId: number, date: string) => {
|
|
const absence = getCellAbsence(employeeId, date)
|
|
if (!absence) return undefined
|
|
|
|
return {
|
|
backgroundColor: absence.color,
|
|
color: '#fff'
|
|
}
|
|
}
|
|
|
|
const getCellCode = (employeeId: number, date: string) => {
|
|
return getCellAbsence(employeeId, date)?.code ?? ''
|
|
}
|
|
|
|
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.comment = existing.comment ?? ''
|
|
} else {
|
|
editingAbsence.value = null
|
|
form.employeeId = employee.id
|
|
form.startDate = date
|
|
form.endDate = date
|
|
form.typeId = ''
|
|
form.comment = ''
|
|
}
|
|
|
|
isDrawerOpen.value = true
|
|
}
|
|
|
|
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.comment = ''
|
|
isDrawerOpen.value = true
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (isSubmitting.value) return
|
|
|
|
isSubmitting.value = true
|
|
try {
|
|
const start = normalizeDate(form.startDate)
|
|
const end = normalizeDate(form.endDate)
|
|
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)
|
|
return start <= aEnd && end >= aStart
|
|
})
|
|
|
|
for (const overlap of overlaps) {
|
|
await deleteAbsence(overlap.id)
|
|
}
|
|
|
|
if (editingAbsence.value) {
|
|
await updateAbsence({
|
|
id: editingAbsence.value.id,
|
|
employeeId: Number(form.employeeId),
|
|
typeId: Number(form.typeId),
|
|
startDate: form.startDate,
|
|
endDate: form.endDate,
|
|
comment: form.comment
|
|
})
|
|
} else {
|
|
await createAbsence({
|
|
employeeId: Number(form.employeeId),
|
|
typeId: Number(form.typeId),
|
|
startDate: form.startDate,
|
|
endDate: form.endDate,
|
|
comment: form.comment
|
|
})
|
|
}
|
|
|
|
closeDrawer()
|
|
await loadAbsences()
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
if (!editingAbsence.value) return
|
|
|
|
const ok = window.confirm('Supprimer cette absence ?')
|
|
if (!ok) return
|
|
|
|
await deleteAbsence(editingAbsence.value.id)
|
|
closeDrawer()
|
|
await loadAbsences()
|
|
}
|
|
|
|
const printMonth = async (mode: '1' | '3') => {
|
|
printMode.value = mode
|
|
await nextTick()
|
|
window.print()
|
|
printMode.value = null
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
.print-only {
|
|
display: none;
|
|
}
|
|
|
|
@media print {
|
|
.no-print {
|
|
display: none !important;
|
|
}
|
|
|
|
.print-only {
|
|
display: block;
|
|
}
|
|
|
|
* {
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
|
|
.print-only .border-neutral-200,
|
|
.print-only .border-neutral-100 {
|
|
border-color: #d1d5db !important;
|
|
}
|
|
|
|
.print-only .bg-tertiary-500 {
|
|
background-color: #f3f4f8 !important;
|
|
}
|
|
|
|
.print-only .rounded-lg,
|
|
.print-only .rounded-md {
|
|
border-radius: 0 !important;
|
|
}
|
|
|
|
body {
|
|
background: #fff;
|
|
}
|
|
}
|
|
</style>
|