438 lines
16 KiB
Vue
438 lines
16 KiB
Vue
<template>
|
|
<div>
|
|
<div class="flex flex-wrap items-center justify-between gap-4 pb-10">
|
|
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
|
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
|
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
|
|
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
|
<input
|
|
:id="`site-${site.id}`"
|
|
v-model="selectedSiteIds"
|
|
:value="site.id"
|
|
type="checkbox"
|
|
class="h-4 w-4"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
|
<div class="min-w-[900px]">
|
|
<div class="grid" :style="gridStyle">
|
|
<div
|
|
class="sticky left-0 z-20 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 visibleEmployees" :key="employee.id">
|
|
<div
|
|
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
|
|
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
|
|
>
|
|
{{ formatEmployeeName(employee) }}
|
|
</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>
|
|
|
|
|
|
<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 sites = computed(() => {
|
|
const map = new Map<number, { id: number; name: string; color: string }>()
|
|
for (const employee of employees.value) {
|
|
if (employee.site) {
|
|
map.set(employee.site.id, employee.site)
|
|
}
|
|
}
|
|
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
|
})
|
|
|
|
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 })
|
|
|
|
const sortedEmployees = computed(() => {
|
|
return [...employees.value].sort((a, b) => {
|
|
const siteA = a.site?.name ?? ''
|
|
const siteB = b.site?.name ?? ''
|
|
if (siteA !== siteB) return siteA.localeCompare(siteB, 'fr')
|
|
const lastA = a.lastName ?? ''
|
|
const lastB = b.lastName ?? ''
|
|
if (lastA !== lastB) return lastA.localeCompare(lastB, 'fr')
|
|
const firstA = a.firstName ?? ''
|
|
const firstB = b.firstName ?? ''
|
|
return firstA.localeCompare(firstB, 'fr')
|
|
})
|
|
})
|
|
|
|
const visibleEmployees = computed(() => {
|
|
if (selectedSiteIds.value.length === 0) return []
|
|
return sortedEmployees.value.filter((employee) => {
|
|
return employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
|
|
})
|
|
})
|
|
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 gridStyle = computed(() => ({
|
|
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
|
}))
|
|
|
|
const form = reactive({
|
|
employeeId: '' as number | '',
|
|
typeId: '' as number | '',
|
|
startDate: '',
|
|
endDate: '',
|
|
comment: ''
|
|
})
|
|
|
|
|
|
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 formatEmployeeName = (employee: Employee) => {
|
|
const initial = employee.lastName ? `${employee.lastName[0].toUpperCase()}.` : ''
|
|
return `${employee.firstName} ${initial}`.trim()
|
|
}
|
|
|
|
</script>
|