feat : refacto de la partie calendrier + ajout de validation sur les formulaires + ajout des jours fériés
This commit is contained in:
6
.idea/SIRH.iml
generated
6
.idea/SIRH.iml
generated
@@ -136,6 +136,12 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/willdurand/negotiation" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/willdurand/negotiation" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/dompdf" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-font-lib" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-svg-lib" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
4
.idea/material_theme_project_new.xml
generated
4
.idea/material_theme_project_new.xml
generated
@@ -3,7 +3,9 @@
|
|||||||
<component name="MaterialThemeProjectNewConfig">
|
<component name="MaterialThemeProjectNewConfig">
|
||||||
<option name="metadata">
|
<option name="metadata">
|
||||||
<MTProjectMetadataState>
|
<MTProjectMetadataState>
|
||||||
<option name="userId" value="-7cf7a629:19c1e9ce3f8:-7e79" />
|
<option name="migrated" value="true" />
|
||||||
|
<option name="pristineConfig" value="false" />
|
||||||
|
<option name="userId" value="-3bc0fa3e:19bc6e06872:-7ff9" />
|
||||||
</MTProjectMetadataState>
|
</MTProjectMetadataState>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
6
.idea/php.xml
generated
6
.idea/php.xml
generated
@@ -144,6 +144,12 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div class="min-h-screen">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex min-h-screen">
|
<div class="flex h-full">
|
||||||
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500">
|
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
||||||
<div>
|
<div>
|
||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1 px-8 py-8">
|
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,35 +66,50 @@
|
|||||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<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
|
<input
|
||||||
id="code"
|
id="code"
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="10"
|
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>
|
||||||
<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
|
<input
|
||||||
id="label"
|
id="label"
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
type="text"
|
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>
|
||||||
<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">
|
<div class="mt-2 flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
id="color"
|
id="color"
|
||||||
v-model="form.color"
|
v-model="form.color"
|
||||||
type="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>
|
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="showColorError" class="mt-1 text-sm text-red-600">
|
||||||
|
La couleur est obligatoire.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@@ -107,7 +122,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
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
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -135,7 +150,53 @@ const drawerTitle = computed(() =>
|
|||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: '',
|
code: '',
|
||||||
label: '',
|
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 () => {
|
const loadAbsenceTypes = async () => {
|
||||||
@@ -152,7 +213,7 @@ onMounted(loadAbsenceTypes)
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.code = ''
|
form.code = ''
|
||||||
form.label = ''
|
form.label = ''
|
||||||
form.color = ''
|
form.color = '#222783'
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
@@ -177,6 +238,10 @@ const closeDrawer = () => {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
validationTouched.code = true
|
||||||
|
validationTouched.label = true
|
||||||
|
validationTouched.color = true
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
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 confirmDelete = async (type: AbsenceType) => {
|
||||||
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
|
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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>
|
<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 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 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>
|
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
|
||||||
@@ -18,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
v-model="selectedMonth"
|
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">
|
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||||
{{ month.label }}
|
{{ month.label }}
|
||||||
@@ -26,22 +28,24 @@
|
|||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
v-model="selectedYear"
|
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">
|
<option v-for="year in years" :key="year" :value="year">
|
||||||
{{ year }}
|
{{ year }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="openCreateFromToday"
|
||||||
>
|
>
|
||||||
Ajouter une absence
|
Ajouter une absence
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="openPrint"
|
||||||
>
|
>
|
||||||
Imprimer
|
Imprimer
|
||||||
@@ -49,196 +53,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
<CalendarGrid
|
||||||
<div class="min-w-[900px]">
|
:days-in-month="daysInMonth"
|
||||||
<div class="grid" :style="gridStyle">
|
:visible-employees="visibleEmployees"
|
||||||
<div
|
:grid-style="gridStyle"
|
||||||
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">
|
:get-cell-style="getCellStyle"
|
||||||
Employés
|
:get-cell-code="getCellCode"
|
||||||
</div>
|
:format-employee-name="formatEmployeeName"
|
||||||
<div
|
:is-holiday-date="isHolidayDate"
|
||||||
v-for="day in daysInMonth"
|
@cell-click="openCreate"
|
||||||
: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">
|
<AbsenceFormDrawer
|
||||||
<div
|
v-model="isDrawerOpen"
|
||||||
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
|
:employees="employees"
|
||||||
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
|
:absence-types="absenceTypes"
|
||||||
>
|
:form="form"
|
||||||
{{ formatEmployeeName(employee) }}
|
:editing-absence="editingAbsence"
|
||||||
</div>
|
:is-submitting="isSubmitting"
|
||||||
<div
|
@submit="handleSubmit"
|
||||||
v-for="day in daysInMonth"
|
@delete="handleDelete"
|
||||||
:key="employee.id + '-' + day.date"
|
@cancel="closeDrawer"
|
||||||
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>
|
|
||||||
|
|
||||||
|
<AbsencePrintDrawer
|
||||||
<AppDrawer v-model="isDrawerOpen" title="Nouvelle absence">
|
v-model="isPrintOpen"
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
:sites="sites"
|
||||||
<div>
|
:print-form="printForm"
|
||||||
<label class="text-md font-semibold text-neutral-700" for="employee">Employé</label>
|
@submit="handlePrint"
|
||||||
<select
|
@cancel="closePrint"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -249,17 +93,21 @@ import type {Absence} from '~/services/dto/absence'
|
|||||||
import {listEmployees} from '~/services/employees'
|
import {listEmployees} from '~/services/employees'
|
||||||
import {listAbsenceTypes} from '~/services/absence-types'
|
import {listAbsenceTypes} from '~/services/absence-types'
|
||||||
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
||||||
|
import {listPublicHolidays} from '~/services/public-holidays'
|
||||||
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
|
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 employees = ref<Employee[]>([])
|
||||||
const sites = computed(() => {
|
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) {
|
for (const employee of employees.value) {
|
||||||
if (employee.site) {
|
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[]>([])
|
const selectedSiteIds = ref<number[]>([])
|
||||||
@@ -272,16 +120,16 @@ watch(sites, (next) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const sortedEmployees = computed(() => {
|
const sortedEmployees = computed(() => {
|
||||||
return [...employees.value].sort((a, b) => {
|
return [...employees.value].sort((employeeA, employeeB) => {
|
||||||
const siteA = a.site?.name ?? ''
|
const siteNameA = employeeA.site?.name ?? ''
|
||||||
const siteB = b.site?.name ?? ''
|
const siteNameB = employeeB.site?.name ?? ''
|
||||||
if (siteA !== siteB) return siteA.localeCompare(siteB, 'fr')
|
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
|
||||||
const lastA = a.lastName ?? ''
|
const lastNameA = employeeA.lastName ?? ''
|
||||||
const lastB = b.lastName ?? ''
|
const lastNameB = employeeB.lastName ?? ''
|
||||||
if (lastA !== lastB) return lastA.localeCompare(lastB, 'fr')
|
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
|
||||||
const firstA = a.firstName ?? ''
|
const firstNameA = employeeA.firstName ?? ''
|
||||||
const firstB = b.firstName ?? ''
|
const firstNameB = employeeB.firstName ?? ''
|
||||||
return firstA.localeCompare(firstB, 'fr')
|
return firstNameA.localeCompare(firstNameB, 'fr')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -293,6 +141,7 @@ const visibleEmployees = computed(() => {
|
|||||||
})
|
})
|
||||||
const absenceTypes = ref<AbsenceType[]>([])
|
const absenceTypes = ref<AbsenceType[]>([])
|
||||||
const absences = ref<Absence[]>([])
|
const absences = ref<Absence[]>([])
|
||||||
|
const publicHolidays = ref<Record<string, string>>({})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
@@ -318,10 +167,12 @@ const months = [
|
|||||||
{value: 11, label: 'Décembre'}
|
{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 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(() => ({
|
const gridStyle = computed(() => ({
|
||||||
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
|
||||||
@@ -423,33 +274,82 @@ const loadAbsenceTypes = async () => {
|
|||||||
absenceTypes.value = await listAbsenceTypes()
|
absenceTypes.value = await listAbsenceTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadPublicHolidays = async () => {
|
||||||
|
publicHolidays.value = await listPublicHolidays('metropole', selectedYear.value)
|
||||||
|
}
|
||||||
|
|
||||||
const loadAbsences = async () => {
|
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 () => {
|
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()
|
await loadAbsences()
|
||||||
})
|
})
|
||||||
|
|
||||||
const getCellAbsence = (employeeId: number, date: string) => {
|
watch(selectedYear, async () => {
|
||||||
const match = absences.value.find((absence) => {
|
await loadPublicHolidays()
|
||||||
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
|
// 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 {
|
for (const absence of absences.value) {
|
||||||
id: match.id,
|
const employeeId = absence.employee?.id
|
||||||
code: match.type?.code ?? '',
|
if (!employeeId) continue
|
||||||
color: match.type?.color ?? '#222783'
|
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) => {
|
const getCellStyle = (employeeId: number, date: string) => {
|
||||||
@@ -458,7 +358,7 @@ const getCellStyle = (employeeId: number, date: string) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: absence.color,
|
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) => {
|
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 existing = absences.value.find((absence) => {
|
||||||
const start = normalizeDate(absence.startDate)
|
const start = normalizeDate(absence.startDate)
|
||||||
const end = normalizeDate(absence.endDate)
|
const end = normalizeDate(absence.endDate)
|
||||||
@@ -498,12 +403,33 @@ const openCreateFromToday = () => {
|
|||||||
form.typeId = ''
|
form.typeId = ''
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
|
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.startDate = today
|
||||||
form.endDate = today
|
form.endDate = today
|
||||||
form.comment = ''
|
form.comment = ''
|
||||||
isDrawerOpen.value = true
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
@@ -511,6 +437,10 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
const start = normalizeDate(form.startDate)
|
const start = normalizeDate(form.startDate)
|
||||||
const end = normalizeDate(form.endDate)
|
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) => {
|
const overlaps = absences.value.filter((absence) => {
|
||||||
if (absence.employee?.id !== Number(form.employeeId)) return false
|
if (absence.employee?.id !== Number(form.employeeId)) return false
|
||||||
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
|
||||||
@@ -519,8 +449,15 @@ const handleSubmit = async () => {
|
|||||||
return start <= aEnd && end >= aStart
|
return start <= aEnd && end >= aStart
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const overlap of overlaps) {
|
if (overlaps.length > 0) {
|
||||||
await deleteAbsence(overlap.id)
|
// 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) {
|
if (editingAbsence.value) {
|
||||||
@@ -552,8 +489,8 @@ const handleSubmit = async () => {
|
|||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!editingAbsence.value) return
|
if (!editingAbsence.value) return
|
||||||
|
|
||||||
const ok = window.confirm('Supprimer cette absence ?')
|
const confirmDelete = window.confirm('Supprimer cette absence ?')
|
||||||
if (!ok) return
|
if (!confirmDelete) return
|
||||||
|
|
||||||
await deleteAbsence(editingAbsence.value.id)
|
await deleteAbsence(editingAbsence.value.id)
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
|
|||||||
@@ -60,35 +60,50 @@
|
|||||||
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<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
|
<input
|
||||||
id="first-name"
|
id="first-name"
|
||||||
v-model="form.firstName"
|
v-model="form.firstName"
|
||||||
type="text"
|
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>
|
||||||
<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
|
<input
|
||||||
id="last-name"
|
id="last-name"
|
||||||
v-model="form.lastName"
|
v-model="form.lastName"
|
||||||
type="text"
|
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>
|
||||||
<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
|
<select
|
||||||
id="site"
|
id="site"
|
||||||
v-model="form.siteId"
|
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 value="">Aucun site</option>
|
||||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||||
{{ site.name }}
|
{{ site.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le site est obligatoire.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@@ -101,7 +116,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
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
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -134,6 +149,59 @@ const form = reactive({
|
|||||||
siteId: '' as number | ''
|
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 () => {
|
const loadEmployees = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -153,6 +221,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
validationTouched.firstName = true
|
||||||
|
validationTouched.lastName = true
|
||||||
|
validationTouched.siteId = true
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
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) => {
|
const openEdit = (employee: Employee) => {
|
||||||
editingEmployee.value = employee
|
editingEmployee.value = employee
|
||||||
form.firstName = employee.firstName
|
form.firstName = employee.firstName
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<span
|
<span
|
||||||
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
|
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>
|
</span>
|
||||||
<form
|
<form
|
||||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
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">
|
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<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
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
type="text"
|
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>
|
||||||
<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">
|
<div class="mt-2 flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
id="color"
|
id="color"
|
||||||
@@ -95,7 +102,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
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
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -125,6 +132,31 @@ const form = reactive({
|
|||||||
color: '#222783'
|
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 () => {
|
const loadSites = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +194,8 @@ const closeDrawer = () => {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting.value) return
|
if (isSubmitting.value) return
|
||||||
|
validationTouched.name = true
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -184,6 +218,12 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(isDrawerOpen, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
validationTouched.name = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const confirmDelete = async (site: Site) => {
|
const confirmDelete = async (site: Site) => {
|
||||||
const ok = window.confirm(`Supprimer le site ${site.name} ?`)
|
const ok = window.confirm(`Supprimer le site ${site.name} ?`)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import type { Absence } from './dto/absence'
|
import type { Absence } from './dto/absence'
|
||||||
import { extractItems } from '~/utils/api'
|
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 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[] }>(
|
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
|
||||||
'/absences',
|
'/absences',
|
||||||
{},
|
query,
|
||||||
{ toast: false }
|
{ toast: false }
|
||||||
)
|
)
|
||||||
return extractItems<Absence>(data)
|
return extractItems<Absence>(data)
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ export type Employee = {
|
|||||||
id: number
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: 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>
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
@@ -19,6 +22,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
'datetime_format' => 'Y-m-d',
|
'datetime_format' => 'Y-m-d',
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
|
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\Table(name: 'absences')]
|
#[ORM\Table(name: 'absences')]
|
||||||
class Absence
|
class Absence
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -15,6 +16,7 @@ use Dompdf\Dompdf;
|
|||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Throwable;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
use Twig\Error\LoaderError;
|
use Twig\Error\LoaderError;
|
||||||
use Twig\Error\RuntimeError;
|
use Twig\Error\RuntimeError;
|
||||||
@@ -26,6 +28,7 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +59,7 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
|
|
||||||
$days = $this->buildDays($fromDate, $toDate);
|
$days = $this->buildDays($fromDate, $toDate);
|
||||||
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
|
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
|
||||||
|
$holidayMap = $this->buildHolidayMap($fromDate, $toDate);
|
||||||
|
|
||||||
$options = new Options();
|
$options = new Options();
|
||||||
$options->set('isRemoteEnabled', true);
|
$options->set('isRemoteEnabled', true);
|
||||||
@@ -67,6 +71,7 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
'days' => $days,
|
'days' => $days,
|
||||||
'employees' => $employees,
|
'employees' => $employees,
|
||||||
'absenceMap' => $absenceMap,
|
'absenceMap' => $absenceMap,
|
||||||
|
'holidayMap' => $holidayMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$dompdf->loadHtml($html);
|
$dompdf->loadHtml($html);
|
||||||
@@ -116,7 +121,7 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var list<Employee> $result */
|
// @var list<Employee> $result
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +145,7 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
->setParameter('employees', $employees)
|
->setParameter('employees', $employees)
|
||||||
;
|
;
|
||||||
|
|
||||||
/** @var list<Absence> $result */
|
// @var list<Absence> $result
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,4 +199,24 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
|
|
||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
$startYear = (int) $from->format('Y');
|
||||||
|
$endYear = (int) $to->format('Y');
|
||||||
|
|
||||||
|
try {
|
||||||
|
for ($year = $startYear; $year <= $endYear; ++$year) {
|
||||||
|
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||||||
|
foreach ($holidays as $date => $label) {
|
||||||
|
$map[$date] = (string) $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
219
templates/absence/print.html.twig
Normal file
219
templates/absence/print.html.twig
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Absences</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@page { size: A3 landscape; margin: 8mm; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2mm;
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.calendar {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
border: 4px solid #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 2px solid #0a0a0a;
|
||||||
|
padding: 2px 1px;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-employee {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 10mm;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-day {
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title td {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-color: #0a0a0a;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-title .label {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-separator {
|
||||||
|
border-right: 4px solid #0a0a0a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekend {
|
||||||
|
background: grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holiday {
|
||||||
|
background: #b3e5fc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% set months = {
|
||||||
|
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
|
||||||
|
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
|
||||||
|
} %}
|
||||||
|
{% set dow = ['Lu','Ma','Me','Je','Ve','Sa','Di'] %}
|
||||||
|
|
||||||
|
{% set dayColWidthMm = 5 %}
|
||||||
|
|
||||||
|
<table class="calendar">
|
||||||
|
<thead>
|
||||||
|
{# Ligne 1 : mois #}
|
||||||
|
<tr>
|
||||||
|
<th class="col-employee" rowspan="4"></th>
|
||||||
|
|
||||||
|
{% set currentMonth = '' %}
|
||||||
|
{% set monthCount = 0 %}
|
||||||
|
|
||||||
|
{% for day in days %}
|
||||||
|
{% set m = day.date|date('n') %}
|
||||||
|
{% set monthLabel = months[m] ~ ' ' ~ (day.date|date('Y')) %}
|
||||||
|
|
||||||
|
{% if monthLabel != currentMonth %}
|
||||||
|
{% if not loop.first %}
|
||||||
|
<th class="month month-separator" colspan="{{ monthCount }}">{{ currentMonth }}</th>
|
||||||
|
{% endif %}
|
||||||
|
{% set currentMonth = monthLabel %}
|
||||||
|
{% set monthCount = 1 %}
|
||||||
|
{% else %}
|
||||||
|
{% set monthCount = monthCount + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if loop.last %}
|
||||||
|
<th class="month" colspan="{{ monthCount }}">{{ currentMonth }}</th>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{# Ligne 2 : numéro de semaine #}
|
||||||
|
<tr>
|
||||||
|
{% set currentWeek = '' %}
|
||||||
|
{% set weekCount = 0 %}
|
||||||
|
{% for day in days %}
|
||||||
|
{% set weekLabel = 'S' ~ (day.date|date('W')) %}
|
||||||
|
{% if weekLabel != currentWeek %}
|
||||||
|
{% if not loop.first %}
|
||||||
|
<th class="col-day" colspan="{{ weekCount }}">{{ currentWeek }}</th>
|
||||||
|
{% endif %}
|
||||||
|
{% set currentWeek = weekLabel %}
|
||||||
|
{% set weekCount = 1 %}
|
||||||
|
{% else %}
|
||||||
|
{% set weekCount = weekCount + 1 %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if loop.last %}
|
||||||
|
<th class="col-day" colspan="{{ weekCount }}">{{ currentWeek }}</th>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{# Ligne 3 : jour semaine #}
|
||||||
|
<tr>
|
||||||
|
{% for day in days %}
|
||||||
|
{% set idx = (day.date|date('N') - 1) %}
|
||||||
|
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
|
||||||
|
{% set isWeekend = day.date|date('N') in [6, 7] %}
|
||||||
|
<th class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}" style="width: {{ dayColWidthMm }}mm;">{{ dow[idx] }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{# Ligne 4 : numéro #}
|
||||||
|
<tr>
|
||||||
|
{% for day in days %}
|
||||||
|
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
|
||||||
|
{% set isWeekend = day.date|date('N') in [6, 7] %}
|
||||||
|
<th class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}" style="width: {{ dayColWidthMm }}mm;">{{ day.label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% set currentSiteId = null %}
|
||||||
|
|
||||||
|
{% for employee in employees %}
|
||||||
|
{% set site = employee.site %}
|
||||||
|
{% set siteId = site ? site.id : 'none' %}
|
||||||
|
{% set siteName = site ? site.name : 'Sans site' %}
|
||||||
|
|
||||||
|
{# couleur de fond du site (si tu as un champ color) #}
|
||||||
|
{% set siteColor = '#ffd7d7' %}
|
||||||
|
{% if site and attribute(site, 'color') is defined and site.color %}
|
||||||
|
{% set siteColor = site.color %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Nouvelle section site #}
|
||||||
|
{% if siteId != currentSiteId %}
|
||||||
|
<tr class="site-title">
|
||||||
|
<td class="label" style="background: {{ siteColor }};" colspan="{{ 1 + (days|length) }}">
|
||||||
|
{{ siteName }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% set currentSiteId = siteId %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Ligne employé #}
|
||||||
|
<tr>
|
||||||
|
<td class="col-employee">
|
||||||
|
{{ employee.firstName }}{% if employee.lastName %} {{ employee.lastName|first }}.{% endif %}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{% for day in days %}
|
||||||
|
{% set isHoliday = holidayMap[day.date] ?? null %}
|
||||||
|
{% set info = absenceMap[employee.id][day.date] ?? null %}
|
||||||
|
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
|
||||||
|
{% set isWeekend = day.date|date('N') in [6, 7] %}
|
||||||
|
<td class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday %} background-color: {{ info.color }};{% endif %}">
|
||||||
|
{% if isHoliday %}
|
||||||
|
<span class="code">F</span>
|
||||||
|
{% elseif info %}
|
||||||
|
<span class="code">{{ info.code }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ 1 + (days|length) }}">Aucun employé.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user