feat : refacto de la partie calendrier + ajout de validation sur les formulaires + ajout des jours fériés
This commit is contained in:
201
frontend/components/AbsenceFormDrawer.vue
Normal file
201
frontend/components/AbsenceFormDrawer.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="employee">
|
||||
Employé <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="employee"
|
||||
v-model="absenceForm.employeeId"
|
||||
:class="employeeFieldClass"
|
||||
>
|
||||
<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>
|
||||
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
|
||||
L'employé est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="type">
|
||||
Type d'absence <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
v-model="absenceForm.typeId"
|
||||
:class="typeFieldClass"
|
||||
>
|
||||
<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>
|
||||
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
|
||||
Le type d'absence est obligatoire.
|
||||
</p>
|
||||
</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="absenceForm.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="absenceForm.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="absenceForm.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="handleCancel"
|
||||
>
|
||||
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"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, toRef, watch } from 'vue'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import type { Absence } from '~/services/dto/absence'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
employees: Employee[]
|
||||
absenceTypes: AbsenceType[]
|
||||
form: {
|
||||
employeeId: number | ''
|
||||
typeId: number | ''
|
||||
startDate: string
|
||||
endDate: string
|
||||
comment: string
|
||||
}
|
||||
editingAbsence: Absence | null
|
||||
isSubmitting: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit'): void
|
||||
(event: 'delete'): void
|
||||
(event: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const absenceForm = toRef(props, 'form')
|
||||
const editingAbsence = toRef(props, 'editingAbsence')
|
||||
|
||||
const validationTouched = reactive({
|
||||
employee: false,
|
||||
type: false
|
||||
})
|
||||
|
||||
const isEmployeeValid = computed(() => absenceForm.value.employeeId !== '')
|
||||
const isTypeValid = computed(() => absenceForm.value.typeId !== '')
|
||||
const isFormValid = computed(() => isEmployeeValid.value && isTypeValid.value)
|
||||
|
||||
const showEmployeeError = computed(
|
||||
() => validationTouched.employee && !isEmployeeValid.value
|
||||
)
|
||||
const showTypeError = computed(
|
||||
() => validationTouched.type && !isTypeValid.value
|
||||
)
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (props.isSubmitting || !isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const baseSelectClass =
|
||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||
const employeeFieldClass = computed(() => {
|
||||
if (showEmployeeError.value) {
|
||||
return `${baseSelectClass} border-red-500`
|
||||
}
|
||||
return `${baseSelectClass} border-neutral-300`
|
||||
})
|
||||
const typeFieldClass = computed(() => {
|
||||
if (showTypeError.value) {
|
||||
return `${baseSelectClass} border-red-500`
|
||||
}
|
||||
return `${baseSelectClass} border-neutral-300`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.employee = false
|
||||
validationTouched.type = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubmit = () => {
|
||||
validationTouched.employee = true
|
||||
validationTouched.type = true
|
||||
if (!isEmployeeValid.value || !isTypeValid.value) return
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
172
frontend/components/AbsencePrintDrawer.vue
Normal file
172
frontend/components/AbsencePrintDrawer.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Imprimer les absences">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<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 <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="print-from"
|
||||
v-model="printForm.from"
|
||||
type="date"
|
||||
:class="fromFieldClass"
|
||||
/>
|
||||
<p v-if="showFromError" class="mt-1 text-sm text-red-600">
|
||||
La date de début est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="print-to">
|
||||
Date de fin <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="print-to"
|
||||
v-model="printForm.to"
|
||||
type="date"
|
||||
:class="toFieldClass"
|
||||
/>
|
||||
<p v-if="showToError" class="mt-1 text-sm text-red-600">
|
||||
La date de fin est obligatoire.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-md font-semibold text-neutral-700">
|
||||
Sites <span class="text-red-600">*</span>
|
||||
</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>
|
||||
<p v-if="showSitesError" class="text-sm text-red-600">
|
||||
Sélectionne au moins un site.
|
||||
</p>
|
||||
</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="handleCancel"
|
||||
>
|
||||
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"
|
||||
:class="submitButtonClass"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, toRef, watch } from 'vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
type SiteOption = {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
sites: SiteOption[]
|
||||
printForm: {
|
||||
from: string
|
||||
to: string
|
||||
siteIds: number[]
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit'): void
|
||||
(event: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const printForm = toRef(props, 'printForm')
|
||||
|
||||
const validationTouched = reactive({
|
||||
from: false,
|
||||
to: false,
|
||||
sites: false
|
||||
})
|
||||
|
||||
const isFromValid = computed(() => printForm.value.from.trim() !== '')
|
||||
const isToValid = computed(() => printForm.value.to.trim() !== '')
|
||||
const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
|
||||
const isFormValid = computed(
|
||||
() => isFromValid.value && isToValid.value && isSitesValid.value
|
||||
)
|
||||
|
||||
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
|
||||
const showToError = computed(() => validationTouched.to && !isToValid.value)
|
||||
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
|
||||
|
||||
const baseInputClass =
|
||||
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||
const fromFieldClass = computed(() => {
|
||||
if (showFromError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
const toFieldClass = computed(() => {
|
||||
if (showToError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
}
|
||||
return `${baseInputClass} border-neutral-300`
|
||||
})
|
||||
|
||||
const submitButtonClass = computed(() => {
|
||||
if (!isFormValid.value) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
validationTouched.from = true
|
||||
validationTouched.to = true
|
||||
validationTouched.sites = true
|
||||
if (!isFormValid.value) return
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
validationTouched.from = false
|
||||
validationTouched.to = false
|
||||
validationTouched.sites = false
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
76
frontend/components/CalendarGrid.vue
Normal file
76
frontend/components/CalendarGrid.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<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"
|
||||
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
||||
:style="getCellStyle(employee.id, day.date)"
|
||||
:disabled="isHolidayDate(day.date)"
|
||||
@click="handleCellClick(employee, day.date)"
|
||||
>
|
||||
<span v-if="getCellCode(employee.id, day.date)">
|
||||
{{ getCellCode(employee.id, day.date) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
|
||||
type DayInfo = {
|
||||
date: string
|
||||
label: string
|
||||
weekday: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
daysInMonth: DayInfo[]
|
||||
visibleEmployees: Employee[]
|
||||
gridStyle: Record<string, string>
|
||||
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
|
||||
getCellCode: (employeeId: number, date: string) => string
|
||||
formatEmployeeName: (employee: Employee) => string
|
||||
isHolidayDate: (date: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'cell-click', employee: Employee, date: string): void
|
||||
}>()
|
||||
|
||||
const handleCellClick = (employee: Employee, date: string) => {
|
||||
emit('cell-click', employee, date)
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<div class="flex min-h-screen">
|
||||
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500">
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
||||
<div>
|
||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 px-8 py-8">
|
||||
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import type { Absence } from './dto/absence'
|
||||
import { extractItems } from '~/utils/api'
|
||||
|
||||
export const listAbsences = async () => {
|
||||
type ListAbsencesFilters = {
|
||||
from?: string
|
||||
to?: string
|
||||
siteIds?: number[]
|
||||
}
|
||||
|
||||
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
|
||||
const api = useApi()
|
||||
const query: Record<string, string | string[]> = {}
|
||||
if (filters.from) {
|
||||
query['endDate[after]'] = filters.from
|
||||
}
|
||||
if (filters.to) {
|
||||
query['startDate[before]'] = filters.to
|
||||
}
|
||||
if (filters.siteIds && filters.siteIds.length > 0) {
|
||||
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
|
||||
}
|
||||
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
|
||||
'/absences',
|
||||
{},
|
||||
query,
|
||||
{ toast: false }
|
||||
)
|
||||
return extractItems<Absence>(data)
|
||||
|
||||
@@ -4,5 +4,5 @@ export type Employee = {
|
||||
id: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
site?: Site | null
|
||||
site: Site
|
||||
}
|
||||
|
||||
18
frontend/services/public-holidays.ts
Normal file
18
frontend/services/public-holidays.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type PublicHolidaysResponse =
|
||||
| { days?: Record<string, string> }
|
||||
| Record<string, string>
|
||||
|
||||
export const listPublicHolidays = async (zone: string, year: number) => {
|
||||
const api = useApi()
|
||||
const data = await api.get<PublicHolidaysResponse>(
|
||||
`/public-holidays/${zone}/${year}`,
|
||||
{},
|
||||
{ toast: false }
|
||||
)
|
||||
|
||||
if (data && typeof data === 'object' && 'days' in data) {
|
||||
return (data.days ?? {}) as Record<string, string>
|
||||
}
|
||||
|
||||
return (data ?? {}) as Record<string, string>
|
||||
}
|
||||
Reference in New Issue
Block a user