feat : ajout d'un onglet formation
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled

This commit is contained in:
2026-04-13 09:41:36 +02:00
parent b185accdbb
commit 4cd30de3e3
29 changed files with 1244 additions and 36 deletions

View File

@@ -45,9 +45,9 @@
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:class="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
:disabled="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)"
>
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -67,6 +67,13 @@
{{ getCellInfo(employee.id, day.date)?.code }}
</span>
</template>
<Icon
v-if="getCellInfo(employee.id, day.date)?.hasFormation && getCellInfo(employee.id, day.date)?.code !== 'F'"
name="mdi:school"
size="12"
class="absolute top-0 right-0 text-indigo-600 bg-white rounded-bl-md p-0.5"
title="Formation"
/>
</button>
</template>
<template v-else>
@@ -107,7 +114,7 @@ const props = defineProps<{
visibleEmployees: Employee[]
gridStyle: Record<string, string>
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string } | null
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string; hasFormation?: boolean } | null
formatEmployeeName: (employee: Employee) => string
isHolidayDate: (date: string) => boolean
}>()

View File

@@ -0,0 +1,251 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Date de début</p>
<p>Date de fin</p>
<p>Justificatif</p>
<p>Commentaire</p>
</div>
<div v-if="formations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune formation.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in formations"
:key="item.id"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
<p class="min-w-0">
<a
v-if="item.justificatifPath"
:href="getFormationJustificatifUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.justificatifName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
<p class="truncate">{{ item.comment ?? '-' }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" title="Formation">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-start-date">
Date de début <span class="text-red-600">*</span>
</label>
<input
id="formation-start-date"
v-model="form.startDate"
type="date"
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-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-end-date">
Date de fin <span class="text-red-600">*</span>
</label>
<input
id="formation-end-date"
v-model="form.endDate"
type="date"
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-secondary-500/20"
/>
<p v-if="isDateRangeInvalid" class="mt-1 text-sm text-red-600">La date de fin doit être postérieure ou égale à la date de début.</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-justificatif">
Justificatif
</label>
<div v-if="isEditing && editingItem?.justificatifName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.justificatifName }}
</div>
<input
id="formation-justificatif"
ref="justificatifInput"
type="file"
accept="application/pdf"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onJustificatifChange"
/>
<p v-if="justificatifError" class="mt-1 text-sm text-red-600">{{ justificatifError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-comment">
Commentaire
</label>
<textarea
id="formation-comment"
v-model="form.comment"
rows="3"
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-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type {Formation} from '~/services/dto/formation'
import {getFormationJustificatifUrl} from '~/services/formations'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
formations: Formation[]
apiBase: string
}>()
const emit = defineEmits<{
(event: 'create', data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'update', id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Formation | null>(null)
const selectedJustificatif = ref<File | undefined>(undefined)
const justificatifInput = ref<HTMLInputElement | null>(null)
const justificatifError = ref('')
const form = reactive({
startDate: '',
endDate: '',
comment: ''
})
const isDateRangeInvalid = computed(() => {
if (!form.startDate || !form.endDate) return false
return form.endDate < form.startDate
})
const isFormValid = computed(() => {
return Boolean(form.startDate) && Boolean(form.endDate) && !isDateRangeInvalid.value && !justificatifError.value
})
const formatDate = (dateStr: string): string => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
return date.toLocaleDateString('fr-FR')
}
const resetForm = () => {
form.startDate = ''
form.endDate = ''
form.comment = ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Formation) => {
isEditing.value = true
editingItem.value = item
form.startDate = item.startDate
form.endDate = item.endDate
form.comment = item.comment ?? ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
isDrawerOpen.value = true
}
const onJustificatifChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
justificatifError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedJustificatif.value = undefined
target.value = ''
return
}
justificatifError.value = ''
selectedJustificatif.value = file ?? undefined
}
const onSubmit = () => {
const data = {
startDate: form.startDate,
endDate: form.endDate,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data, selectedJustificatif.value)
} else {
emit('create', data, selectedJustificatif.value)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette formation ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -69,15 +69,26 @@
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<div class="flex flex-col gap-1 min-w-0">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="hasRowFormation(employee.id)"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
:title="getRowFormationLabel(employee.id)"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
</div>
<button
v-if="!hasRowFormation(employee.id)"
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
@@ -231,6 +242,8 @@ const props = defineProps<{
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string
getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void