feat : ajout de la gestion Congé

This commit is contained in:
2026-03-05 14:09:50 +01:00
parent fc2b184c50
commit 20a651895f
55 changed files with 4171 additions and 144 deletions

View File

@@ -86,6 +86,18 @@
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="contract-paid-leave-settled">
<input
id="contract-paid-leave-settled"
v-model="contractForm.paidLeaveSettled"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Soldé dans le solde de tout compte
</label>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -178,6 +190,7 @@ type ContractForm = {
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
paidLeaveSettled: boolean
}
type CreateContractForm = {

View File

@@ -1,7 +1,239 @@
<template>
<section class="mt-8">
<div class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Bloc Congé (à implémenter)
<div class="grid grid-cols-4 rounded-md bg-primary-500 text-white text-[20]">
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><strong class="uppercase font-semibold">Acquis année :</strong> {{ formatCount(summary?.acquiredDays) }} Jours</p>
<p><strong class="uppercase font-semibold">Reste à prendre :</strong> {{ formatCount(summary?.remainingDays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Samedi acquis :</span> {{ formatCount(summary?.acquiredSaturdays) }} Jours</p>
<p><span class="uppercase font-semibold">Reste à prendre :</span> {{ formatCount(summary?.remainingSaturdays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center border-r-4 border-white py-3">
<p><span class="uppercase font-semibold">Acquis fractionné :</span></p>
<p>{{ formatCount(summary?.fractionedDays) }} Jours</p>
</div>
<div class="flex flex-col jutify-center items-center py-3">
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>
<p>{{ formatCount(summary?.accruingDays) }} Jours</p>
</div>
</div>
<div class="mt-8 grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label" class="rounded-md bg-tertiary-500 text-primary-500">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">{{ month.label }}</div>
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
</div>
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
<div v-if="!day" class="h-6" />
<div
v-else
class="flex items-center justify-center"
>
<div
class="h-6 w-6"
:class="getDayClass(day.leave)"
:style="getDayStyle(day.leave)"
:title="getDayTitle(day.leave)"
>
{{ getDayText(day) }}
</div>
</div>
</template>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import { normalizeDate, toYmd } from '~/utils/date'
type DayLeaveState = {
am: boolean
pm: boolean
labels: string[]
hasCongeTypeC: boolean
hasOtherTypes: boolean
}
const props = defineProps<{
absences: Absence[]
summary: EmployeeLeaveSummary | null
}>()
const monthLabels = [
'Janvier',
'Fevrier',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Aout',
'Septembre',
'Octobre',
'Novembre',
'Decembre'
] as const
const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
const displayedYear = computed(() => {
if (props.summary?.year) return props.summary.year
const today = new Date()
const year = today.getFullYear()
const month = today.getMonth() + 1
return month >= 6 ? year + 1 : year
})
const orderedMonthIndexes = computed(() => {
if (isForfaitRule.value) return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
return [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4]
})
const buildDateFromYmd = (value: string) => new Date(`${value}T00:00:00`)
const dayLeaveMap = computed(() => {
const map = new Map<string, DayLeaveState>()
for (const absence of props.absences) {
const startYmd = normalizeDate(absence.startDate)
const endYmd = normalizeDate(absence.endDate)
const start = buildDateFromYmd(startYmd)
const end = buildDateFromYmd(endYmd)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue
for (const cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
const ymd = toYmd(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())
const existing = map.get(ymd) ?? {
am: false,
pm: false,
labels: [] as string[],
hasCongeTypeC: false,
hasOtherTypes: false
}
const isStart = ymd === startYmd
const isEnd = ymd === endYmd
const isSingleDay = startYmd === endYmd
let am = false
let pm = false
if (isSingleDay) {
am = absence.startHalf === 'AM'
pm = absence.endHalf === 'PM'
} else if (isStart) {
am = absence.startHalf === 'AM'
pm = true
} else if (isEnd) {
am = true
pm = absence.endHalf === 'PM'
} else {
am = true
pm = true
}
const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence'
const typeCode = (absence.type?.code ?? '').toUpperCase()
const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '')
const hoverLabel = `${typeLabel}${halfSuffix}`
map.set(ymd, {
am: existing.am || am,
pm: existing.pm || pm,
labels: existing.labels.includes(hoverLabel)
? existing.labels
: [...existing.labels, hoverLabel],
hasCongeTypeC: existing.hasCongeTypeC || typeCode === 'C',
hasOtherTypes: existing.hasOtherTypes || typeCode !== 'C'
})
}
}
return map
})
const months = computed(() => {
return orderedMonthIndexes.value.map((monthIndex) => {
const label = monthLabels[monthIndex]
const monthYear = isForfaitRule.value
? displayedYear.value
: (monthIndex >= 5 ? displayedYear.value - 1 : displayedYear.value)
const first = new Date(monthYear, monthIndex, 1)
const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate()
const mondayBasedFirstDay = (first.getDay() + 6) % 7
const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null } | null> = []
for (let i = 0; i < mondayBasedFirstDay; i += 1) {
cells.push(null)
}
for (let day = 1; day <= daysInMonth; day += 1) {
const ymd = toYmd(monthYear, monthIndex, day)
cells.push({
ymd,
label: String(day),
leave: dayLeaveMap.value.get(ymd) ?? null
})
}
while (cells.length % 7 !== 0) {
cells.push(null)
}
return {
label,
cells
}
})
})
const getDayClass = (leave: DayLeaveState | null) => {
if (!leave) return 'text-primary-500'
if (leave.am && leave.pm) {
return leave.hasOtherTypes
? 'bg-red-600 text-white rounded font-semibold'
: 'bg-primary-500 text-white rounded font-semibold'
}
return 'rounded text-primary-700 font-semibold text-white'
}
const getDayStyle = (leave: DayLeaveState | null) => {
if (!leave || (leave.am && leave.pm)) return undefined
const color = leave.hasOtherTypes ? '#dc2626' : '#222783'
const backgroundImage = leave.am
? `linear-gradient(135deg, ${color} 0 50%, transparent 50% 100%)`
: `linear-gradient(135deg, transparent 0 50%, ${color} 50% 100%)`
return {
backgroundImage,
backgroundColor: 'transparent'
}
}
const getDayText = (day: { label: string; leave: DayLeaveState | null }) => {
return day.label
}
const getDayTitle = (leave: DayLeaveState | null) => {
if (!leave || leave.labels.length === 0) return ''
return leave.labels.join(' / ')
}
const formatCount = (value: number | null | undefined) => {
if (value === null || value === undefined) return '-'
const rounded = Math.round(value * 100) / 100
if (Number.isInteger(rounded)) return String(rounded)
return rounded.toFixed(2).replace('.', ',')
}
</script>

View File

@@ -1,7 +1,11 @@
import type { Contract } from '~/services/dto/contract'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { listContracts } from '~/services/contracts'
import { getEmployeeLeaveSummary } from '~/services/employee-leave-summary'
import { getEmployee, updateEmployee } from '~/services/employees'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
@@ -13,6 +17,8 @@ export const useEmployeeDetailPage = () => {
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const contracts = ref<Contract[]>([])
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
@@ -24,7 +30,8 @@ export const useEmployeeDetailPage = () => {
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
endDate: '',
paidLeaveSettled: false
})
const validationTouched = reactive({
@@ -46,6 +53,7 @@ export const useEmployeeDetailPage = () => {
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
@@ -126,6 +134,7 @@ export const useEmployeeDetailPage = () => {
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false
}
const openCloseContractDrawer = () => {
@@ -171,7 +180,35 @@ export const useEmployeeDetailPage = () => {
isLoading.value = true
try {
employee.value = await getEmployee(employeeId)
const loadedEmployee = await getEmployee(employeeId)
employee.value = loadedEmployee
const now = new Date()
const isForfait = loadedEmployee.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
const from = isForfait
? `${leaveYear}-01-01`
: `${leaveYear - 1}-06-01`
const to = isForfait
? `${leaveYear}-12-31`
: `${leaveYear}-05-31`
const [absences, summary] = await Promise.all([
listAbsences({
from,
to,
employeeId: loadedEmployee.id
}),
showLeaveTab.value
? getEmployeeLeaveSummary(loadedEmployee.id, leaveYear)
: Promise.resolve(null)
])
employeeAbsences.value = absences
leaveSummary.value = summary
if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract'
}
} finally {
isLoading.value = false
}
@@ -198,7 +235,8 @@ export const useEmployeeDetailPage = () => {
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled
})
isContractDrawerOpen.value = false
@@ -262,6 +300,9 @@ export const useEmployeeDetailPage = () => {
isLoading,
activeTab,
contracts,
employeeAbsences,
leaveSummary,
showLeaveTab,
contractHistory,
employeeContractWorkLabel,
contractForm,

View File

@@ -1,5 +1,5 @@
<template>
<div class="h-full overflow-auto">
<div>
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
@@ -27,17 +27,18 @@
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'contract'"
>
<Icon name="mdi:magnify" size="24" class="align-self"/>
<Icon name="mdi:file-check-outline" size="24" class="align-self"/>
Suivi contrat
</button>
<button
v-if="showLeaveTab"
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'leave'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'leave'"
>
<Icon name="mdi:magnify" size="24" class="align-self"/>
<Icon name="mdi:event-blank-outline" size="24" class="align-self"/>
Congé
</button>
<button
@@ -47,7 +48,7 @@
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'rtt'"
>
<Icon name="mdi:magnify" size="24" class="align-self"/>
<Icon name="mdi:schedule" size="24" class="align-self"/>
RTT
</button>
</div>
@@ -86,7 +87,7 @@
:on-submit-close-contract="submitContractUpdate"
:on-submit-create-contract="submitCreateContract"
/>
<EmployeesLeaveTab v-else-if="activeTab === 'leave'" />
<EmployeesLeaveTab v-else-if="showLeaveTab && activeTab === 'leave'" :absences="employeeAbsences" :summary="leaveSummary" />
<EmployeesRttTab v-else />
</div>
</div>
@@ -98,6 +99,9 @@ const {
isLoading,
activeTab,
contracts,
employeeAbsences,
leaveSummary,
showLeaveTab,
contractHistory,
employeeContractWorkLabel,
contractForm,

View File

@@ -6,6 +6,7 @@ type ListAbsencesFilters = {
from?: string
to?: string
siteIds?: number[]
employeeId?: number
}
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
@@ -20,6 +21,9 @@ export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
if (filters.siteIds && filters.siteIds.length > 0) {
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
}
if (filters.employeeId) {
query.employee = `/api/employees/${filters.employeeId}`
}
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
'/absences',
query,

View File

@@ -0,0 +1,14 @@
export type EmployeeLeaveSummary = {
year: number
isSupported: boolean
ruleCode: string
acquiredDays: number
remainingDays: number
takenDays: number
acquiredSaturdays: number
remainingSaturdays: number
takenSaturdays: number
fractionedDays: number
accruingDays: number
}

View File

@@ -0,0 +1,10 @@
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
const api = useApi()
const query: Record<string, string> = {}
if (year) query.year = String(year)
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
}

View File

@@ -60,6 +60,7 @@ export const updateEmployee = async (
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
contractPaidLeaveSettled?: boolean
displayOrder?: number
}
) => {
@@ -83,6 +84,9 @@ export const updateEmployee = async (
if (payload.contractEndDate !== undefined) {
body.contractEndDate = payload.contractEndDate ?? null
}
if (payload.contractPaidLeaveSettled !== undefined) {
body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled
}
return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',