feat : refacto de la partie calendrier + ajout de validation sur les formulaires + ajout des jours fériés
This commit is contained in:
@@ -66,35 +66,50 @@
|
||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="code">Code</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="code">
|
||||
Code <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
v-model="form.code"
|
||||
type="text"
|
||||
maxlength="10"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
:class="codeFieldClass"
|
||||
/>
|
||||
<p v-if="showCodeError" class="mt-1 text-sm text-red-600">
|
||||
Le code est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="label">Libellé</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="label">
|
||||
Libellé <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="label"
|
||||
v-model="form.label"
|
||||
type="text"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
:class="labelFieldClass"
|
||||
/>
|
||||
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
|
||||
Le libellé est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="color">
|
||||
Couleur <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<input
|
||||
id="color"
|
||||
v-model="form.color"
|
||||
type="color"
|
||||
class="h-10 w-16 cursor-pointer rounded-md border border-neutral-300 bg-white p-1"
|
||||
:class="colorFieldClass"
|
||||
/>
|
||||
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
|
||||
</div>
|
||||
<p v-if="showColorError" class="mt-1 text-sm text-red-600">
|
||||
La couleur est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
@@ -107,7 +122,7 @@
|
||||
<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"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@@ -135,7 +150,53 @@ const drawerTitle = computed(() =>
|
||||
const form = reactive({
|
||||
code: '',
|
||||
label: '',
|
||||
color: ''
|
||||
color: '#222783'
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
code: false,
|
||||
label: false,
|
||||
color: false
|
||||
})
|
||||
|
||||
const isCodeValid = computed(() => form.code.trim() !== '')
|
||||
const isLabelValid = computed(() => form.label.trim() !== '')
|
||||
const isColorValid = computed(() => form.color.trim() !== '')
|
||||
const isFormValid = computed(
|
||||
() => isCodeValid.value && isLabelValid.value && isColorValid.value
|
||||
)
|
||||
|
||||
const showCodeError = computed(() => validationTouched.code && !isCodeValid.value)
|
||||
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
|
||||
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
|
||||
const codeFieldClass = computed(() => {
|
||||
if (showCodeError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const labelFieldClass = computed(() => {
|
||||
if (showLabelError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const colorFieldClass = computed(() => {
|
||||
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
|
||||
if (showColorError.value) {
|
||||
return `${baseColorClass} border-red-500`
|
||||
}
|
||||
return `${baseColorClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (isSubmitting.value || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const loadAbsenceTypes = async () => {
|
||||
@@ -152,7 +213,7 @@ onMounted(loadAbsenceTypes)
|
||||
const resetForm = () => {
|
||||
form.code = ''
|
||||
form.label = ''
|
||||
form.color = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -177,6 +238,10 @@ const closeDrawer = () => {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value) return
|
||||
validationTouched.code = true
|
||||
validationTouched.label = true
|
||||
validationTouched.color = true
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -201,6 +266,14 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDrawerOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.code = false
|
||||
validationTouched.label = false
|
||||
validationTouched.color = false
|
||||
}
|
||||
})
|
||||
|
||||
const confirmDelete = async (type: AbsenceType) => {
|
||||
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
|
||||
if (!ok) return
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 pb-10">
|
||||
<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 class="flex items-center gap-3">
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
@@ -18,7 +20,7 @@
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedMonth"
|
||||
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||
{{ month.label }}
|
||||
@@ -26,22 +28,24 @@
|
||||
</select>
|
||||
<select
|
||||
v-model="selectedYear"
|
||||
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="year in years" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openCreateFromToday"
|
||||
>
|
||||
Ajouter une absence
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="openPrint"
|
||||
>
|
||||
Imprimer
|
||||
@@ -49,196 +53,36 @@
|
||||
</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>
|
||||
<CalendarGrid
|
||||
:days-in-month="daysInMonth"
|
||||
:visible-employees="visibleEmployees"
|
||||
:grid-style="gridStyle"
|
||||
:get-cell-style="getCellStyle"
|
||||
:get-cell-code="getCellCode"
|
||||
:format-employee-name="formatEmployeeName"
|
||||
:is-holiday-date="isHolidayDate"
|
||||
@cell-click="openCreate"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<AbsenceFormDrawer
|
||||
v-model="isDrawerOpen"
|
||||
:employees="employees"
|
||||
:absence-types="absenceTypes"
|
||||
:form="form"
|
||||
:editing-absence="editingAbsence"
|
||||
:is-submitting="isSubmitting"
|
||||
@submit="handleSubmit"
|
||||
@delete="handleDelete"
|
||||
@cancel="closeDrawer"
|
||||
/>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
<AppDrawer v-model="isPrintOpen" title="Imprimer les absences">
|
||||
<form class="space-y-4" @submit.prevent="handlePrint">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="print-from">Date de début</label>
|
||||
<input
|
||||
id="print-from"
|
||||
v-model="printForm.from"
|
||||
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="print-to">Date de fin</label>
|
||||
<input
|
||||
id="print-to"
|
||||
v-model="printForm.to"
|
||||
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 class="space-y-2">
|
||||
<p class="text-md font-semibold text-neutral-700">Sites</p>
|
||||
<div class="flex flex-wrap 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="`print-site-${site.id}`">{{ site.name }}</label>
|
||||
<input
|
||||
:id="`print-site-${site.id}`"
|
||||
v-model="printForm.siteIds"
|
||||
:value="site.id"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<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="closePrint"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
<AbsencePrintDrawer
|
||||
v-model="isPrintOpen"
|
||||
:sites="sites"
|
||||
:print-form="printForm"
|
||||
@submit="handlePrint"
|
||||
@cancel="closePrint"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -249,17 +93,21 @@ 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 {listPublicHolidays} from '~/services/public-holidays'
|
||||
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
|
||||
import CalendarGrid from '~/components/CalendarGrid.vue'
|
||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||
|
||||
const employees = ref<Employee[]>([])
|
||||
const sites = computed(() => {
|
||||
const map = new Map<number, { id: number; name: string; color: string }>()
|
||||
const siteMap = 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)
|
||||
siteMap.set(employee.site.id, employee.site)
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
return Array.from(siteMap.values()).sort((siteA, siteB) => siteA.name.localeCompare(siteB.name, 'fr'))
|
||||
})
|
||||
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
@@ -272,16 +120,16 @@ watch(sites, (next) => {
|
||||
}, { 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')
|
||||
return [...employees.value].sort((employeeA, employeeB) => {
|
||||
const siteNameA = employeeA.site?.name ?? ''
|
||||
const siteNameB = employeeB.site?.name ?? ''
|
||||
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
|
||||
const lastNameA = employeeA.lastName ?? ''
|
||||
const lastNameB = employeeB.lastName ?? ''
|
||||
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
|
||||
const firstNameA = employeeA.firstName ?? ''
|
||||
const firstNameB = employeeB.firstName ?? ''
|
||||
return firstNameA.localeCompare(firstNameB, 'fr')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,6 +141,7 @@ const visibleEmployees = computed(() => {
|
||||
})
|
||||
const absenceTypes = ref<AbsenceType[]>([])
|
||||
const absences = ref<Absence[]>([])
|
||||
const publicHolidays = ref<Record<string, string>>({})
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
@@ -318,10 +167,12 @@ const months = [
|
||||
{value: 11, label: 'Décembre'}
|
||||
]
|
||||
|
||||
const years = Array.from({length: 5}, (_, i) => now.getFullYear() - 2 + i)
|
||||
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
||||
@@ -423,33 +274,82 @@ const loadAbsenceTypes = async () => {
|
||||
absenceTypes.value = await listAbsenceTypes()
|
||||
}
|
||||
|
||||
const loadPublicHolidays = async () => {
|
||||
publicHolidays.value = await listPublicHolidays('metropole', selectedYear.value)
|
||||
}
|
||||
|
||||
const loadAbsences = async () => {
|
||||
absences.value = await listAbsences()
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadAbsences()])
|
||||
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
|
||||
})
|
||||
|
||||
watch([selectedMonth, selectedYear], async () => {
|
||||
watch([selectedMonth, selectedYear, selectedSiteIds], 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
|
||||
})
|
||||
watch(selectedYear, async () => {
|
||||
await loadPublicHolidays()
|
||||
})
|
||||
|
||||
if (!match) return null
|
||||
// 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 monthStart = monthStartDate.value
|
||||
const monthEnd = monthEndDate.value
|
||||
|
||||
return {
|
||||
id: match.id,
|
||||
code: match.type?.code ?? '',
|
||||
color: match.type?.color ?? '#222783'
|
||||
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))
|
||||
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 key = `${employeeId}-${toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())}`
|
||||
map.set(key, {
|
||||
id: absence.id,
|
||||
code: absence.type?.code ?? '',
|
||||
color: absence.type?.color ?? '#222783'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const isHolidayDate = (date: string) => {
|
||||
return Boolean(publicHolidays.value[date])
|
||||
}
|
||||
|
||||
const getCellAbsence = (employeeId: number, date: string) => {
|
||||
if (isHolidayDate(date)) {
|
||||
return {
|
||||
id: 0,
|
||||
code: 'F',
|
||||
color: '#b3e5fc',
|
||||
textColor: '#0f172a'
|
||||
}
|
||||
}
|
||||
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
|
||||
if (absence) return absence
|
||||
return null
|
||||
}
|
||||
|
||||
const getCellStyle = (employeeId: number, date: string) => {
|
||||
@@ -458,7 +358,7 @@ const getCellStyle = (employeeId: number, date: string) => {
|
||||
|
||||
return {
|
||||
backgroundColor: absence.color,
|
||||
color: '#fff'
|
||||
color: absence.textColor ?? '#fff'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +367,11 @@ const getCellCode = (employeeId: number, date: string) => {
|
||||
}
|
||||
|
||||
const openCreate = (employee: Employee, date: string) => {
|
||||
if (isHolidayDate(date)) {
|
||||
window.alert("Impossible de creer une absence un jour ferie.")
|
||||
return
|
||||
}
|
||||
|
||||
const existing = absences.value.find((absence) => {
|
||||
const start = normalizeDate(absence.startDate)
|
||||
const end = normalizeDate(absence.endDate)
|
||||
@@ -498,12 +403,33 @@ const openCreateFromToday = () => {
|
||||
form.typeId = ''
|
||||
const now = new Date()
|
||||
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
if (isHolidayDate(today)) {
|
||||
window.alert("Impossible de creer une absence un jour ferie.")
|
||||
return
|
||||
}
|
||||
form.startDate = today
|
||||
form.endDate = today
|
||||
form.comment = ''
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value) return
|
||||
|
||||
@@ -511,6 +437,10 @@ const handleSubmit = async () => {
|
||||
try {
|
||||
const start = normalizeDate(form.startDate)
|
||||
const end = normalizeDate(form.endDate)
|
||||
if (hasHolidayInRange(start, end)) {
|
||||
window.alert("Impossible de creer une absence sur un jour ferie.")
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -519,8 +449,15 @@ const handleSubmit = async () => {
|
||||
return start <= aEnd && end >= aStart
|
||||
})
|
||||
|
||||
for (const overlap of overlaps) {
|
||||
await deleteAbsence(overlap.id)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (editingAbsence.value) {
|
||||
@@ -552,8 +489,8 @@ const handleSubmit = async () => {
|
||||
const handleDelete = async () => {
|
||||
if (!editingAbsence.value) return
|
||||
|
||||
const ok = window.confirm('Supprimer cette absence ?')
|
||||
if (!ok) return
|
||||
const confirmDelete = window.confirm('Supprimer cette absence ?')
|
||||
if (!confirmDelete) return
|
||||
|
||||
await deleteAbsence(editingAbsence.value.id)
|
||||
closeDrawer()
|
||||
|
||||
@@ -60,35 +60,50 @@
|
||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="first-name">Prénom</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="first-name">
|
||||
Prénom <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="first-name"
|
||||
v-model="form.firstName"
|
||||
type="text"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
:class="firstNameFieldClass"
|
||||
/>
|
||||
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
|
||||
Le prénom est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="last-name">Nom</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="last-name">
|
||||
Nom <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="last-name"
|
||||
v-model="form.lastName"
|
||||
type="text"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
:class="lastNameFieldClass"
|
||||
/>
|
||||
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
|
||||
Le nom est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="site">Site</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="site">
|
||||
Site <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="site"
|
||||
v-model="form.siteId"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
||||
:class="siteFieldClass"
|
||||
>
|
||||
<option value="">Aucun site</option>
|
||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
|
||||
Le site est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
@@ -101,7 +116,7 @@
|
||||
<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"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@@ -134,6 +149,59 @@ const form = reactive({
|
||||
siteId: '' as number | ''
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
firstName: false,
|
||||
lastName: false,
|
||||
siteId: false
|
||||
})
|
||||
|
||||
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
||||
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
||||
const isSiteValid = computed(() => form.siteId !== '')
|
||||
const isFormValid = computed(
|
||||
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
|
||||
)
|
||||
|
||||
const showFirstNameError = computed(
|
||||
() => validationTouched.firstName && !isFirstNameValid.value
|
||||
)
|
||||
const showLastNameError = computed(
|
||||
() => validationTouched.lastName && !isLastNameValid.value
|
||||
)
|
||||
const showSiteError = computed(
|
||||
() => validationTouched.siteId && !isSiteValid.value
|
||||
)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
|
||||
const firstNameFieldClass = computed(() => {
|
||||
if (showFirstNameError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const lastNameFieldClass = computed(() => {
|
||||
if (showLastNameError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const siteFieldClass = computed(() => {
|
||||
const baseSelectClass =
|
||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||
if (showSiteError.value) {
|
||||
return `${baseSelectClass} border-red-500`
|
||||
}
|
||||
return `${baseSelectClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (isSubmitting.value || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const loadEmployees = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -153,6 +221,10 @@ onMounted(async () => {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value) return
|
||||
validationTouched.firstName = true
|
||||
validationTouched.lastName = true
|
||||
validationTouched.siteId = true
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -181,6 +253,14 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDrawerOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.firstName = false
|
||||
validationTouched.lastName = false
|
||||
validationTouched.siteId = false
|
||||
}
|
||||
})
|
||||
|
||||
const openEdit = (employee: Employee) => {
|
||||
editingEmployee.value = employee
|
||||
form.firstName = employee.firstName
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span
|
||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
||||
>
|
||||
LOGO
|
||||
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
|
||||
@@ -64,16 +64,23 @@
|
||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="name">Nom</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="name">
|
||||
Nom <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
||||
:class="nameFieldClass"
|
||||
/>
|
||||
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
|
||||
Le nom du site est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
|
||||
<label class="text-md font-semibold text-neutral-700" for="color">
|
||||
Couleur <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<input
|
||||
id="color"
|
||||
@@ -95,7 +102,7 @@
|
||||
<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"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@@ -125,6 +132,31 @@ const form = reactive({
|
||||
color: '#222783'
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
name: false
|
||||
})
|
||||
|
||||
const isNameValid = computed(() => form.name.trim() !== '')
|
||||
const isFormValid = computed(() => isNameValid.value)
|
||||
|
||||
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
|
||||
const nameFieldClass = computed(() => {
|
||||
if (showNameError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (isSubmitting.value || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const loadSites = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -162,6 +194,8 @@ const closeDrawer = () => {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting.value) return
|
||||
validationTouched.name = true
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -184,6 +218,12 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDrawerOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const confirmDelete = async (site: Site) => {
|
||||
const ok = window.confirm(`Supprimer le site ${site.name} ?`)
|
||||
if (!ok) return
|
||||
|
||||
Reference in New Issue
Block a user