Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions
9261cb5b1a chore: bump version to v0.1.13
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-26 13:49:38 +00:00
b68fef61c4 fix : correction des Heures et ajout d'une validation pour les chefs de site
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-26 14:49:28 +01:00
gitea-actions
5cced46254 chore: bump version to v0.1.12
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-23 14:36:06 +00:00
07b84a2512 fix : modification du composant TimeSelect.vue pour pouvoir taper les heures au clavier
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-23 15:35:57 +01:00
21 changed files with 660 additions and 118 deletions

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.11' app.version: '0.1.13'

View File

@@ -6,21 +6,19 @@
:style="{ gridTemplateColumns: dayGridCols }" :style="{ gridTemplateColumns: dayGridCols }"
> >
<span>Nom</span> <span>Nom</span>
<span class="pl-4">Début matin</span> <span class="pl-2">Absence</span>
<span class="pr-2">Fin matin</span> <span class="pl-4">Début matin</span>
<span class="pl-2">Début après-midi</span> <span class="pr-2">Fin matin</span>
<span class="pr-2">Fin après-midi</span> <span class="pl-2">Début après-midi</span>
<span class="pl-2">Début soir</span> <span class="pr-2">Fin après-midi</span>
<span class="pr-2">Fin soir</span> <span class="pl-2">Début soir</span>
<span class="pl-2">Absence</span> <span class="pr-2">Fin soir</span>
<span class="pl-2">Jour</span> <span class="pl-2">Jour</span>
<span>Nuit</span> <span>Nuit</span>
<span>Total</span> <span>Total</span>
<span class="inline-flex items-center gap-2"> <span v-if="isAdmin" class="inline-flex items-center gap-2">
<span v-if="isAdmin">Valider</span> <span>Valider</span>
<span v-else>Validation RH</span>
<input <input
v-if="isAdmin"
ref="bulkValidationInput" ref="bulkValidationInput"
:checked="isBulkValidationChecked" :checked="isBulkValidationChecked"
type="checkbox" type="checkbox"
@@ -28,6 +26,8 @@
@change="onBulkValidationChange" @change="onBulkValidationChange"
/> />
</span> </span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div> </div>
<div <div
@@ -41,82 +41,91 @@
{{ employee.firstName }} {{ employee.lastName }} {{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate">{{ employee.site?.name ?? 'Sans site' }}</p> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
title="Validation site"
>
<Icon name="mdi:check"/>
</span>
</p>
</div> </div>
<div class="pl-4"> <div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
<p
class="w-full min-w-0 text-sm text-neutral-700 truncate"
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<button
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'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<div class="pl-4">
<TimeSelect <TimeSelect
v-if="isTimeTracking(employee)" v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningFrom" v-model="rows[employee.id].morningFrom"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/> />
<input <input
v-else-if="isPresenceTracking(employee)" v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentMorning" v-model="rows[employee.id].isPresentMorning"
type="checkbox" type="checkbox"
class="cursor-pointer h-4 w-4" class="cursor-pointer h-4 w-4"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/> />
</div> </div>
<div class="pr-2"> <div class="pr-2">
<TimeSelect <TimeSelect
v-if="isTimeTracking(employee)" v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningTo" v-model="rows[employee.id].morningTo"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/> />
</div> </div>
<div class="pl-2"> <div class="pl-2">
<TimeSelect <TimeSelect
v-if="isTimeTracking(employee)" v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonFrom" v-model="rows[employee.id].afternoonFrom"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/> />
<input <input
v-else-if="isPresenceTracking(employee)" v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentAfternoon" v-model="rows[employee.id].isPresentAfternoon"
type="checkbox" type="checkbox"
class="cursor-pointer h-4 w-4" class="cursor-pointer h-4 w-4"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/> />
</div> </div>
<div class="pr-2"> <div class="pr-2">
<TimeSelect <TimeSelect
v-if="isTimeTracking(employee)" v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonTo" v-model="rows[employee.id].afternoonTo"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/> />
</div> </div>
<div class="pl-2"> <div class="pl-2">
<TimeSelect <TimeSelect
v-if="isTimeTracking(employee)" v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningFrom" v-model="rows[employee.id].eveningFrom"
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/> />
</div> </div>
<div class="pr-2"> <div class="pr-2">
<TimeSelect <TimeSelect
v-if="isTimeTracking(employee)" v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningTo" v-model="rows[employee.id].eveningTo"
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/> />
</div> </div>
<div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
<p
class="w-full min-w-0 text-sm text-neutral-700 truncate"
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isRowLocked(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<div class="pl-2 text-sm font-semibold text-neutral-700"> <div class="pl-2 text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div> <div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
</div> </div>
@@ -127,15 +136,29 @@
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div> <div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
<div v-else>{{ getPresenceDayValue(employee.id) }}</div> <div v-else>{{ getPresenceDayValue(employee.id) }}</div>
</div> </div>
<div> <div v-if="isAdmin">
<input <input
v-if="isAdmin"
:checked="rows[employee.id]?.isValid ?? false" :checked="rows[employee.id]?.isValid ?? false"
type="checkbox" type="checkbox"
class="h-4 w-4 cursor-pointer" class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)" @change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/> />
<span v-else-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span> </div>
<div v-else>
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!canToggleSiteValidation(employee.id) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div> </div>
</div> </div>
</div> </div>
@@ -153,18 +176,24 @@ const bulkValidationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{ const props = defineProps<{
employees: Employee[] employees: Employee[]
isAdmin: boolean isAdmin: boolean
isSiteManager: boolean
dayGridCols: string dayGridCols: string
isHoliday: boolean
contractLabel: (employee: Employee) => string contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean isPresenceTracking: (employee: Employee) => boolean
isRowLocked: (employeeId: number) => boolean isRowLocked: (employeeId: number) => boolean
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
isEveningLockedByAbsence: (employeeId: number) => boolean isEveningLockedByAbsence: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean isValidationPending: (employeeId: number) => boolean
isSiteValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean canToggleValidation: (employeeId: number) => boolean
canToggleSiteValidation: (employeeId: number) => boolean
isBulkValidationChecked: boolean isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean isBulkValidationIndeterminate: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => void onToggleValidationBulk: (checked: boolean) => void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number } getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string getRowAbsenceLabel: (employeeId: number) => string
@@ -177,6 +206,10 @@ const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked) props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
} }
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
watch( watch(
() => props.isBulkValidationIndeterminate, () => props.isBulkValidationIndeterminate,
(isIndeterminate) => { (isIndeterminate) => {

View File

@@ -8,5 +8,6 @@ export type HourRow = {
eveningTo: string eveningTo: string
isPresentMorning: boolean isPresentMorning: boolean
isPresentAfternoon: boolean isPresentAfternoon: boolean
isSiteValid: boolean
isValid: boolean isValid: boolean
} }

View File

@@ -1,15 +1,36 @@
<template> <template>
<div ref="root" class="relative w-full"> <div ref="root" class="relative w-full">
<button <div
ref="trigger" ref="trigger"
type="button" class="w-full flex items-center rounded-md border border-neutral-300 px-2 text-sm text-neutral-900 focus-within:border-primary-500"
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500" :class="props.disabled ? 'cursor-not-allowed border-neutral-300 bg-neutral-200 text-neutral-500' : 'bg-white'"
:disabled="props.disabled"
@click="toggleOpen"
> >
{{ displayValue }} <input
<Icon name="mdi:chevron-down" class="self-center"/> ref="inputRef"
</button> v-model="inputValue"
type="text"
inputmode="numeric"
:placeholder="placeholder"
:disabled="props.disabled"
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
@focus="openMenu"
@keydown.down.prevent="openMenuAndFocusFirst"
@keydown.enter.prevent="commitInput"
@keydown.esc.prevent="closeMenu"
@input="onInput($event)"
@blur="onInputBlur"
/>
<button
type="button"
tabindex="-1"
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
:disabled="props.disabled"
@mousedown.prevent
@click="toggleOpen"
>
<Icon name="mdi:chevron-down" />
</button>
</div>
</div> </div>
<Teleport to="body"> <Teleport to="body">
<div <div
@@ -18,15 +39,11 @@
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm" class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
:style="menuStyle" :style="menuStyle"
> >
<button <button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@click="selectValue('')"
>
{{ placeholder }} {{ placeholder }}
</button> </button>
<button <button
v-for="slot in timeSlots" v-for="slot in filteredTimeSlots"
:key="slot" :key="slot"
type="button" type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@@ -34,6 +51,9 @@
> >
{{ slot }} {{ slot }}
</button> </button>
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
Aucun résultat
</p>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -55,7 +75,9 @@ const emit = defineEmits<{
const root = ref<HTMLElement | null>(null) const root = ref<HTMLElement | null>(null)
const trigger = ref<HTMLElement | null>(null) const trigger = ref<HTMLElement | null>(null)
const menu = ref<HTMLElement | null>(null) const menu = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const isOpen = ref(false) const isOpen = ref(false)
const inputValue = ref('')
const menuStyle = ref<Record<string, string>>({ const menuStyle = ref<Record<string, string>>({
top: '0px', top: '0px',
left: '0px', left: '0px',
@@ -73,7 +95,31 @@ const timeSlots = computed(() => {
return slots return slots
}) })
const displayValue = computed(() => props.modelValue || props.placeholder) const filteredTimeSlots = computed(() => {
const query = inputValue.value.trim()
if (!query) return timeSlots.value
return timeSlots.value.filter((slot) => slot.includes(query))
})
const applyTimeMask = (value: string): string => {
const digits = value.replace(/\D/g, '').slice(0, 4)
if (digits.length <= 2) return digits
return `${digits.slice(0, 2)}:${digits.slice(2)}`
}
const normalizeTypedTime = (value: string): string | null => {
const trimmed = value.trim()
if (trimmed === '') return ''
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
if (!match) return null
const hours = Number(match[1])
const minutes = Number(match[2])
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
const updateMenuPosition = () => { const updateMenuPosition = () => {
const triggerEl = trigger.value const triggerEl = trigger.value
@@ -103,10 +149,57 @@ const toggleOpen = () => {
} }
} }
const openMenu = () => {
if (props.disabled) return
if (!isOpen.value) {
isOpen.value = true
nextTick(updateMenuPosition)
}
}
const openMenuAndFocusFirst = () => {
openMenu()
}
const closeMenu = () => {
isOpen.value = false
}
const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value)
if (normalized === null) {
inputValue.value = props.modelValue
closeMenu()
return
}
emit('update:modelValue', normalized)
inputValue.value = normalized
closeMenu()
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const masked = applyTimeMask(target.value)
if (masked !== inputValue.value) {
inputValue.value = masked
}
openMenu()
}
const onInputBlur = () => {
// Laisse le temps au click menu de passer avant fermeture.
setTimeout(() => {
if (menu.value?.contains(document.activeElement)) return
commitInput()
}, 50)
}
const selectValue = (value: string) => { const selectValue = (value: string) => {
if (props.disabled) return if (props.disabled) return
emit('update:modelValue', value) emit('update:modelValue', value)
inputValue.value = value
isOpen.value = false isOpen.value = false
nextTick(() => inputRef.value?.focus())
} }
const onDocumentClick = (event: MouseEvent) => { const onDocumentClick = (event: MouseEvent) => {
@@ -139,6 +232,14 @@ watch(() => props.disabled, (disabled) => {
} }
}) })
watch(
() => props.modelValue,
(value) => {
inputValue.value = value
},
{ immediate: true }
)
onMounted(() => { onMounted(() => {
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
}) })

View File

@@ -10,11 +10,13 @@ import type { HourRow } from '~/components/hours/types'
import { listScopedEmployees } from '~/services/employees' import { listScopedEmployees } 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 { import {
bulkUpsertWorkHours, bulkUpsertWorkHours,
getWorkHourDayContext, getWorkHourDayContext,
getWeeklyWorkHourSummary, getWeeklyWorkHourSummary,
listWorkHoursByDate, listWorkHoursByDate,
updateWorkHourSiteValidation,
updateWorkHourValidation updateWorkHourValidation
} from '~/services/work-hours' } from '~/services/work-hours'
import { import {
@@ -34,6 +36,8 @@ export const useHoursPage = () => {
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
const viewMode = ref<'day' | 'week'>('day') const viewMode = ref<'day' | 'week'>('day')
const selectedDate = ref(getTodayYmd()) const selectedDate = ref(getTodayYmd())
@@ -46,6 +50,7 @@ export const useHoursPage = () => {
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null) const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([]) const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([]) const absences = ref<Absence[]>([])
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
const isAbsenceDrawerOpen = ref(false) const isAbsenceDrawerOpen = ref(false)
const isAbsenceSubmitting = ref(false) const isAbsenceSubmitting = ref(false)
const editingAbsence = ref<Absence | null>(null) const editingAbsence = ref<Absence | null>(null)
@@ -62,10 +67,12 @@ export const useHoursPage = () => {
const isWeekLoading = ref(false) const isWeekLoading = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([]) const validatingRowIds = ref<number[]>([])
const siteValidatingRowIds = ref<number[]>([])
const dayGridCols = computed(() => { const dayGridCols = computed(() => {
const metricCol = '0.4fr' const metricCol = '0.4fr'
return `1.2fr repeat(6, 1fr) 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol}` const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
}) })
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)' const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
@@ -118,7 +125,16 @@ export const useHoursPage = () => {
}) })
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId) const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
const isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId)
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
const canToggleSiteValidation = (employeeId: number) => {
if (!isSiteManager.value) return false
const row = rows.value[employeeId]
if (!row?.workHourId) return false
// Une validation RH fige la ligne côté chef de site.
if (row.isValid) return false
return true
}
const validatableEmployeeIds = computed(() => { const validatableEmployeeIds = computed(() => {
return employees.value return employees.value
@@ -203,6 +219,19 @@ export const useHoursPage = () => {
return formatDateLongFr(parsed) return formatDateLongFr(parsed)
}) })
const selectedYear = computed(() => {
const parsed = parseYmd(selectedDate.value)
return parsed ? parsed.getFullYear() : null
})
const selectedHolidayLabel = computed(() => {
const year = selectedYear.value
if (!year) return ''
return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
})
const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
const weekDayHeaders = computed(() => { const weekDayHeaders = computed(() => {
const days = weeklySummary.value?.days ?? [] const days = weeklySummary.value?.days ?? []
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) })) return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
@@ -273,12 +302,19 @@ export const useHoursPage = () => {
eveningTo: '', eveningTo: '',
isPresentMorning: false, isPresentMorning: false,
isPresentAfternoon: false, isPresentAfternoon: false,
isSiteValid: false,
isValid: false isValid: false
}) })
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee) const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
if (row.isValid) return true
if (!isAdmin.value && row.isSiteValid) return true
return false
}
const contractLabel = (employee: Employee) => { const contractLabel = (employee: Employee) => {
const contract = employee.contract const contract = employee.contract
@@ -371,6 +407,10 @@ export const useHoursPage = () => {
const getRowAbsenceLabel = (employeeId: number) => { const getRowAbsenceLabel = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId) const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return '' if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') { if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.' const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -387,13 +427,21 @@ export const useHoursPage = () => {
return Number.isInteger(total) ? String(total) : total.toFixed(1) return Number.isInteger(total) ? String(total) : total.toFixed(1)
} }
const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true
return dayRow.hasContractAtDate !== false
}
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => { const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
if (!hasContractAtSelectedDate(employeeId)) return true
const dayRow = dayContextByEmployeeId.value.get(employeeId) const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false if (!dayRow) return false
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
} }
const isEveningLockedByAbsence = (employeeId: number) => { const isEveningLockedByAbsence = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return true
const dayRow = dayContextByEmployeeId.value.get(employeeId) const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false if (!dayRow) return false
return dayRow.absentAfternoon return dayRow.absentAfternoon
@@ -425,6 +473,7 @@ export const useHoursPage = () => {
eveningTo: workHour?.eveningTo ?? '', eveningTo: workHour?.eveningTo ?? '',
isPresentMorning: workHour?.isPresentMorning ?? false, isPresentMorning: workHour?.isPresentMorning ?? false,
isPresentAfternoon: workHour?.isPresentAfternoon ?? false, isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
isSiteValid: workHour?.isSiteValid ?? false,
isValid: workHour?.isValid ?? false isValid: workHour?.isValid ?? false
} }
} }
@@ -436,6 +485,18 @@ export const useHoursPage = () => {
absenceTypes.value = await listAbsenceTypes() absenceTypes.value = await listAbsenceTypes()
} }
const loadPublicHolidaysForSelectedYear = async () => {
const year = selectedYear.value
if (!year) return
if (publicHolidaysByYear.value[year]) return
const holidays = await listPublicHolidays('metropole', year)
publicHolidaysByYear.value = {
...publicHolidaysByYear.value,
[year]: holidays
}
}
const loadAbsences = async () => { const loadAbsences = async () => {
absences.value = await listAbsences({ absences.value = await listAbsences({
from: selectedDate.value, from: selectedDate.value,
@@ -445,6 +506,9 @@ export const useHoursPage = () => {
} }
const openAbsenceDrawer = (employeeId: number) => { const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => { const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false if (absence.employee?.id !== employeeId) return false
const start = absence.startDate.slice(0, 10) const start = absence.startDate.slice(0, 10)
@@ -571,17 +635,109 @@ export const useHoursPage = () => {
options: { toast?: boolean } = {} options: { toast?: boolean } = {}
) => { ) => {
const row = rows.value[employeeId] const row = rows.value[employeeId]
if (!row?.workHourId || isValidationPending(employeeId)) return const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isValidationPending(employeeId)) return
validatingRowIds.value = [...validatingRowIds.value, employeeId] validatingRowIds.value = [...validatingRowIds.value, employeeId]
try { try {
await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast }) await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
row.isValid = checked updatedRow.isValid = checked
} finally { } finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId) validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
} }
} }
const toggleSiteValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isSiteValidationPending(employeeId)) return
if (!canToggleSiteValidation(employeeId)) return
siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId]
try {
await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isSiteValid = checked
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleValidationBulk = async (checked: boolean) => { const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = validatableEmployeeIds.value const employeeIds = validatableEmployeeIds.value
if (employeeIds.length === 0) return if (employeeIds.length === 0) return
@@ -659,6 +815,7 @@ export const useHoursPage = () => {
const loadPage = async () => { const loadPage = async () => {
isLoading.value = true isLoading.value = true
try { try {
await loadPublicHolidaysForSelectedYear()
await loadEmployees() await loadEmployees()
await loadAbsenceTypes() await loadAbsenceTypes()
await refreshByDate() await refreshByDate()
@@ -694,6 +851,7 @@ export const useHoursPage = () => {
}, { immediate: true }) }, { immediate: true })
watch(selectedDate, async () => { watch(selectedDate, async () => {
await loadPublicHolidaysForSelectedYear()
await refreshByDate() await refreshByDate()
}) })
@@ -702,7 +860,9 @@ export const useHoursPage = () => {
isSubmitting.value = true isSubmitting.value = true
try { try {
const entries = employees.value.map((employee) => { const entries = employees.value
.filter((employee) => hasContractAtSelectedDate(employee.id))
.map((employee) => {
const employeeId = employee.id const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow() const row = rows.value[employeeId] ?? emptyRow()
if (isPresenceTracking(employee)) { if (isPresenceTracking(employee)) {
@@ -730,7 +890,11 @@ export const useHoursPage = () => {
isPresentMorning: false, isPresentMorning: false,
isPresentAfternoon: false isPresentAfternoon: false
} }
}) })
if (entries.length === 0) {
return
}
await bulkUpsertWorkHours({ await bulkUpsertWorkHours({
workDate: selectedDate.value, workDate: selectedDate.value,
@@ -745,6 +909,8 @@ export const useHoursPage = () => {
return { return {
isAdmin, isAdmin,
isSelfUser,
isSiteManager,
viewMode, viewMode,
selectedDate, selectedDate,
employeeFilter, employeeFilter,
@@ -767,6 +933,7 @@ export const useHoursPage = () => {
weekGridCols, weekGridCols,
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
isSelectedDateHoliday,
weekDayHeaders, weekDayHeaders,
shortcutButtonClass, shortcutButtonClass,
weekShortcutButtonClass, weekShortcutButtonClass,
@@ -784,11 +951,16 @@ export const useHoursPage = () => {
isRowLocked, isRowLocked,
isHalfLockedByAbsence, isHalfLockedByAbsence,
isEveningLockedByAbsence, isEveningLockedByAbsence,
hasContractAtSelectedDate,
isValidationPending, isValidationPending,
isSiteValidationPending,
canToggleValidation, canToggleValidation,
canToggleSiteValidation,
validatableEmployeeIds,
isBulkValidationChecked, isBulkValidationChecked,
isBulkValidationIndeterminate, isBulkValidationIndeterminate,
toggleValidation, toggleValidation,
toggleSiteValidation,
toggleValidationBulk, toggleValidationBulk,
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,

View File

@@ -33,32 +33,38 @@
Aucun employé accessible. Aucun employé accessible.
</div> </div>
<div v-else class="flex flex-1 min-h-0 flex-col gap-4"> <div v-else class="flex min-h-0 flex-col gap-4">
<div class="flex-1 min-h-0 flex flex-col"> <div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
<HoursDayView <HoursDayView
v-if="viewMode === 'day'" v-if="viewMode === 'day'"
v-model:rows="rows" v-model:rows="rows"
:employees="visibleEmployees" :employees="visibleEmployees"
:is-admin="isAdmin" :is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols" :day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:contract-label="contractLabel" :contract-label="contractLabel"
:is-time-tracking="isTimeTracking" :is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking" :is-presence-tracking="isPresenceTracking"
:is-row-locked="isRowLocked" :is-row-locked="isRowLocked"
:is-half-locked-by-absence="isHalfLockedByAbsence" :is-half-locked-by-absence="isHalfLockedByAbsence"
:is-evening-locked-by-absence="isEveningLockedByAbsence" :is-evening-locked-by-absence="isEveningLockedByAbsence"
:has-contract-at-selected-date="hasContractAtSelectedDate"
:is-validation-pending="isValidationPending" :is-validation-pending="isValidationPending"
:is-site-validation-pending="isSiteValidationPending"
:can-toggle-validation="canToggleValidation" :can-toggle-validation="canToggleValidation"
:can-toggle-site-validation="canToggleSiteValidation"
:is-bulk-validation-checked="isBulkValidationChecked" :is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate" :is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:on-toggle-validation="toggleValidation" :on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk" :on-toggle-validation-bulk="toggleValidationBulk"
:get-row-metrics="getRowMetrics" :get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel" :get-row-absence-label="getRowAbsenceLabel"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
class="max-h-full" class="max-h-[calc(100vh-300px)]"
/> />
<HoursWeekView <HoursWeekView
@@ -68,7 +74,7 @@
:weekly-summary="filteredWeeklySummary" :weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders" :week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
class="max-h-full" class="max-h-[calc(100vh-300px)]"
/> />
</div> </div>
@@ -105,6 +111,7 @@
<script setup lang="ts"> <script setup lang="ts">
const { const {
isAdmin, isAdmin,
isSiteManager,
viewMode, viewMode,
selectedDate, selectedDate,
employeeFilter, employeeFilter,
@@ -123,6 +130,7 @@ const {
isWeekLoading, isWeekLoading,
isSubmitting, isSubmitting,
dayGridCols, dayGridCols,
isSelectedDateHoliday,
weekGridCols, weekGridCols,
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
@@ -143,11 +151,15 @@ const {
isRowLocked, isRowLocked,
isHalfLockedByAbsence, isHalfLockedByAbsence,
isEveningLockedByAbsence, isEveningLockedByAbsence,
hasContractAtSelectedDate,
isValidationPending, isValidationPending,
isSiteValidationPending,
canToggleValidation, canToggleValidation,
canToggleSiteValidation,
isBulkValidationChecked, isBulkValidationChecked,
isBulkValidationIndeterminate, isBulkValidationIndeterminate,
toggleValidation, toggleValidation,
toggleSiteValidation,
toggleValidationBulk, toggleValidationBulk,
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,

View File

@@ -13,6 +13,7 @@ export type WorkHour = {
eveningTo?: string | null eveningTo?: string | null
isPresentMorning?: boolean isPresentMorning?: boolean
isPresentAfternoon?: boolean isPresentAfternoon?: boolean
isSiteValid?: boolean
isValid?: boolean isValid?: boolean
} }
@@ -67,6 +68,7 @@ export type WeeklyWorkHourSummary = {
export type WorkHourDayContextRow = { export type WorkHourDayContextRow = {
employeeId: number employeeId: number
hasContractAtDate: boolean
absenceLabel?: string | null absenceLabel?: string | null
absenceHalf?: 'AM' | 'PM' | null absenceHalf?: 'AM' | 'PM' | null
absentMorning: boolean absentMorning: boolean

View File

@@ -23,7 +23,7 @@ export const listWorkHoursByDate = async (workDate: string) => {
export const bulkUpsertWorkHours = async (payload: { export const bulkUpsertWorkHours = async (payload: {
workDate: string workDate: string
entries: WorkHourEntryPayload[] entries: WorkHourEntryPayload[]
}) => { }, options?: { toast?: boolean }) => {
const api = useApi() const api = useApi()
return api.post<{ return api.post<{
processed: number processed: number
@@ -34,6 +34,7 @@ export const bulkUpsertWorkHours = async (payload: {
'/work-hours/bulk-upsert', '/work-hours/bulk-upsert',
payload, payload,
{ {
toast: options?.toast ?? true,
toastSuccessMessage: 'Horaires enregistrés.', toastSuccessMessage: 'Horaires enregistrés.',
toastErrorMessage: "Impossible d'enregistrer les horaires." toastErrorMessage: "Impossible d'enregistrer les horaires."
} }
@@ -57,6 +58,23 @@ export const updateWorkHourValidation = async (
) )
} }
export const updateWorkHourSiteValidation = async (
id: number,
isSiteValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}/site-validation`,
{ isSiteValid },
{
toast: options?.toast ?? true,
toastSuccessMessage: isSiteValid ? 'Validation site enregistrée.' : 'Validation site retirée.',
toastErrorMessage: "Impossible de mettre à jour la validation site."
}
)
}
export const getWeeklyWorkHourSummary = async (weekStart: string) => { export const getWeeklyWorkHourSummary = async (weekStart: string) => {
const api = useApi() const api = useApi()
return api.get<WeeklyWorkHourSummary>( return api.get<WeeklyWorkHourSummary>(

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226183000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add site validation flag to work hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD is_site_valid BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP is_site_valid');
}
}

View File

@@ -8,6 +8,7 @@ final class DayContextRow
{ {
public function __construct( public function __construct(
public int $employeeId, public int $employeeId,
public bool $hasContractAtDate = true,
public ?string $absenceLabel = null, public ?string $absenceLabel = null,
public ?string $absenceHalf = null, public ?string $absenceHalf = null,
public bool $absentMorning = false, public bool $absentMorning = false,
@@ -45,6 +46,7 @@ final class DayContextRow
/** /**
* @return array{ * @return array{
* employeeId:int, * employeeId:int,
* hasContractAtDate:bool,
* absenceLabel:?string, * absenceLabel:?string,
* absenceHalf:?string, * absenceHalf:?string,
* absentMorning:bool, * absentMorning:bool,
@@ -57,6 +59,7 @@ final class DayContextRow
{ {
return [ return [
'employeeId' => $this->employeeId, 'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel, 'absenceLabel' => $this->absenceLabel,
'absenceHalf' => $this->absenceHalf, 'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning, 'absentMorning' => $this->absentMorning,

View File

@@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\State\WorkHourSiteValidationProcessor;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -33,6 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['work_hour:validate']], denormalizationContext: ['groups' => ['work_hour:validate']],
security: "is_granted('ROLE_ADMIN')" security: "is_granted('ROLE_ADMIN')"
), ),
new Patch(
uriTemplate: '/work_hours/{id}/site-validation',
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
denormalizationContext: ['groups' => ['work_hour:site_validate']],
security: "is_granted('ROLE_USER')",
processor: WorkHourSiteValidationProcessor::class
),
], ],
)] )]
#[ApiFilter(DateFilter::class, properties: ['workDate'])] #[ApiFilter(DateFilter::class, properties: ['workDate'])]
@@ -94,6 +102,10 @@ class WorkHour
#[Groups(['work_hour:read', 'work_hour:validate'])] #[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false; private bool $isValid = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
private bool $isSiteValid = false;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -245,4 +257,21 @@ class WorkHour
return $this; return $this;
} }
public function isSiteValid(): bool
{
return $this->isSiteValid;
}
public function getIsSiteValid(): bool
{
return $this->isSiteValid;
}
public function setIsSiteValid(bool $isSiteValid): self
{
$this->isSiteValid = $isSiteValid;
return $this;
}
} }

View File

@@ -21,4 +21,6 @@ interface WorkHourReadRepositoryInterface
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour; public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool; public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
} }

View File

@@ -102,6 +102,26 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
} }
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
{
$fromDate = DateTimeImmutable::createFromInterface($from);
$toDate = DateTimeImmutable::createFromInterface($to);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isSiteValid = :isSiteValid')
->setParameter('employee', $employee)
->setParameter('from', $fromDate)
->setParameter('to', $toDate)
->setParameter('isSiteValid', true)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
{ {
$workDate = DateTimeImmutable::createFromInterface($date); $workDate = DateTimeImmutable::createFromInterface($date);
@@ -114,7 +134,7 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
->setMaxResults(1) ->setMaxResults(1)
; ;
/** @var null|WorkHour $workHour */ // @var null|WorkHour $workHour
return $qb->getQuery()->getOneOrNullResult(); return $qb->getQuery()->getOneOrNullResult();
} }
} }

View File

@@ -8,7 +8,6 @@ use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Repository\EmployeeContractPeriodRepository; use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable; use DateTimeImmutable;
use LogicException;
readonly class EmployeeContractResolver readonly class EmployeeContractResolver
{ {
@@ -18,17 +17,9 @@ readonly class EmployeeContractResolver
public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract
{ {
$period = $this->periodRepository->findOneCoveringDate($employee, $date); $period = $this->periodRepository->findOneCoveringDate($employee, $date);
$contract = $period?->getContract();
if (null === $contract) {
throw new LogicException(sprintf(
'Missing contract period for employee %d on %s.',
$employee->getId() ?? 0,
$date->format('Y-m-d')
));
}
return $contract; return $period?->getContract();
} }
/** /**
@@ -75,23 +66,6 @@ readonly class EmployeeContractResolver
} }
} }
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
if (null === ($resolved[$employeeId][$day] ?? null)) {
throw new LogicException(sprintf(
'Missing contract period for employee %d on %s.',
$employeeId,
$day
));
}
}
}
return $resolved; return $resolved;
} }
} }

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\Absence; use App\Entity\Absence;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\User;
use App\Enum\HalfDay; use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface;
@@ -16,7 +17,9 @@ use DateInterval;
use DatePeriod; use DatePeriod;
use DateTime; use DateTime;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -26,6 +29,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository, private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -39,8 +43,11 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
return $data; return $data;
} }
$user = $this->security->getUser();
$isAdmin = $user instanceof User && in_array('ROLE_ADMIN', $user->getRoles(), true);
if ($operation instanceof DeleteOperationInterface) { if ($operation instanceof DeleteOperationInterface) {
if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) { if ($this->isLockedByValidation($employee, $data->getStartDate(), $data->getEndDate(), $isAdmin)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.'); throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
} }
@@ -58,7 +65,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
$from = DateTimeImmutable::createFromInterface($segments[0]['date']); $from = DateTimeImmutable::createFromInterface($segments[0]['date']);
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']); $to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) { if ($this->isLockedByValidation($employee, $from, $to, $isAdmin)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.'); throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
} }
@@ -178,6 +185,19 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
return DateTime::createFromImmutable($date); return DateTime::createFromImmutable($date);
} }
private function isLockedByValidation(Employee $employee, DateTimeInterface $from, DateTimeInterface $to, bool $isAdmin): bool
{
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
return true;
}
if ($isAdmin) {
return false;
}
return $this->workHourRepository->hasSiteValidatedInRange($employee, $from, $to);
}
/** /**
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment * @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
*/ */
@@ -193,6 +213,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
$workHour $workHour
->setMorningFrom(null) ->setMorningFrom(null)
->setMorningTo(null) ->setMorningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
; ;
return; return;
@@ -205,6 +227,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setAfternoonTo(null) ->setAfternoonTo(null)
->setEveningFrom(null) ->setEveningFrom(null)
->setEveningTo(null) ->setEveningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
; ;
return; return;
@@ -218,6 +242,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setAfternoonTo(null) ->setAfternoonTo(null)
->setEveningFrom(null) ->setEveningFrom(null)
->setEveningTo(null) ->setEveningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
; ;
} }
} }

View File

@@ -51,7 +51,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
$today = new DateTimeImmutable('today'); $today = new DateTimeImmutable('today');
if ($isNew) { if ($isNew) {
$this->ensureContractPeriodExists($data, $currentContract, $today); $this->ensureContractPeriodExists($data, $currentContract, new DateTimeImmutable('1970-01-01'));
return $result; return $result;
} }
@@ -61,7 +61,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
} }
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today); $todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate() === $today) { if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate()->format('Y-m-d') === $today->format('Y-m-d')) {
$todayPeriod->setContract($currentContract); $todayPeriod->setContract($currentContract);
$this->entityManager->flush(); $this->entityManager->flush();

View File

@@ -11,6 +11,7 @@ use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\User; use App\Entity\User;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Enum\TrackingMode; use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver; use App\Service\Contracts\EmployeeContractResolver;
@@ -28,6 +29,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
private Security $security, private Security $security,
private EmployeeRepository $employeeRepository, private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository, private WorkHourRepository $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver, private EmployeeContractResolver $contractResolver,
) {} ) {}
@@ -67,6 +69,13 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$existingByEmployeeId = $this->workHourRepository $existingByEmployeeId = $this->workHourRepository
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById)) ->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
; ;
$absenceByEmployeeId = [];
foreach ($this->absenceRepository->findByDateAndEmployees($workDate, array_values($employeesById)) as $absence) {
$absenceEmployeeId = $absence->getEmployee()?->getId();
if ($absenceEmployeeId) {
$absenceByEmployeeId[$absenceEmployeeId] = true;
}
}
$result = new WorkHourBulkUpsertResult(); $result = new WorkHourBulkUpsertResult();
@@ -77,10 +86,18 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId)); throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
} }
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate); $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
if (null === $contract) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d has no active contract on %s.',
$employeeId,
$data->workDate
));
}
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode(); $isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking); $normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null; $existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
if ($existing?->isValid()) { if ($existing?->isValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) { if (!$this->isSameAsExisting($existing, $normalized)) {
@@ -95,11 +112,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
continue; continue;
} }
if (!$isAdmin && $existing?->isSiteValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: site validated work hour cannot be modified.',
$employeeId
));
}
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) { if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant. // Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) { if ($existing) {
$this->entityManager->remove($existing); $this->entityManager->remove($existing);
++$result->deleted; ++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true) {
// Si une absence existe ce jour, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour()
->setEmployee($employee)
->setWorkDate($workDate)
;
$this->hydrateWorkHour($workHour, $normalized);
$this->entityManager->persist($workHour);
$existingByEmployeeId[$employeeId] = $workHour;
++$result->created;
} }
++$result->processed; ++$result->processed;
@@ -187,14 +227,16 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
} }
return [ return [
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'), 'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'), 'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'), 'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'), 'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'), 'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'), 'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
'isPresentMorning' => false, // On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
'isPresentAfternoon' => false, // même si le contrat résolu ce jour est en suivi horaire.
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
]; ];
} }
@@ -284,6 +326,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setEveningTo($entry['eveningTo']) ->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning']) ->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon']) ->setIsPresentAfternoon($entry['isPresentAfternoon'])
// Toute modification invalide la validation chef de site.
->setIsSiteValid(false)
// Toute modification utilisateur repasse la ligne en attente de validation RH. // Toute modification utilisateur repasse la ligne en attente de validation RH.
->setIsValid(false) ->setIsValid(false)
; ;

View File

@@ -11,6 +11,7 @@ use App\Dto\WorkHours\DayContextRow;
use App\Entity\User; use App\Entity\User;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable; use DateTimeImmutable;
@@ -26,6 +27,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
private RequestStack $requestStack, private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository, private EmployeeScopedRepositoryInterface $employeeRepository,
private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver, private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy, private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {} ) {}
@@ -50,7 +52,10 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
} }
// On initialise toutes les lignes, même sans absence ce jour-là. // On initialise toutes les lignes, même sans absence ce jour-là.
$rowsByEmployeeId[$employeeId] = new DayContextRow(employeeId: $employeeId); $rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
hasContractAtDate: null !== $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate)
);
} }
$dateKey = $workDate->format('Y-m-d'); $dateKey = $workDate->format('Y-m-d');

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class WorkHourSiteValidationProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WorkHour
{
if (!$data instanceof WorkHour) {
throw new AccessDeniedHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
// Réservé aux profils "Sites" (ni admin, ni self).
if (in_array('ROLE_ADMIN', $user->getRoles(), true) || in_array('ROLE_SELF', $user->getRoles(), true)) {
throw new AccessDeniedHttpException('Only site managers can update site validation.');
}
$siteId = $data->getEmployee()?->getSite()?->getId();
if (!$siteId) {
throw new AccessDeniedHttpException('Employee site is required.');
}
$allowedSiteIds = $this->employeeScopeService->getAllowedSiteIds($user);
if (!in_array($siteId, $allowedSiteIds, true)) {
throw new AccessDeniedHttpException('Employee is outside your site scope.');
}
$this->entityManager->flush();
return $data;
}
}

View File

@@ -10,6 +10,7 @@ use App\Entity\Absence;
use App\Entity\AbsenceType; use App\Entity\AbsenceType;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\User;
use App\Enum\HalfDay; use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Repository\Contract\WorkHourReadRepositoryInterface;
@@ -17,6 +18,7 @@ use App\State\AbsenceWriteProcessor;
use DateTime; use DateTime;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -32,7 +34,8 @@ final class AbsenceWriteProcessorTest extends TestCase
$entityManager = $this->createMock(EntityManagerInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class);
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM); $absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
@@ -59,7 +62,8 @@ final class AbsenceWriteProcessorTest extends TestCase
$entityManager = $this->createMock(EntityManagerInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -79,7 +83,8 @@ final class AbsenceWriteProcessorTest extends TestCase
$entityManager = $this->createMock(EntityManagerInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
@@ -100,7 +105,8 @@ final class AbsenceWriteProcessorTest extends TestCase
$entityManager = $this->createStub(EntityManagerInterface::class); $entityManager = $this->createStub(EntityManagerInterface::class);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class); $workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); $security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM); $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
@@ -121,6 +127,17 @@ final class AbsenceWriteProcessorTest extends TestCase
->setStartDate(new DateTime($startDate)) ->setStartDate(new DateTime($startDate))
->setEndDate(new DateTime($endDate)) ->setEndDate(new DateTime($endDate))
->setStartHalf($startHalf) ->setStartHalf($startHalf)
->setEndHalf($endHalf); ->setEndHalf($endHalf)
;
}
private function createAdminSecurityStub(): Security
{
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn(
new User()->setUsername('admin')->setRoles(['ROLE_ADMIN'])
);
return $security;
} }
} }

View File

@@ -53,6 +53,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack, $this->requestStack,
$this->employeeRepository, $this->employeeRepository,
$this->absenceRepository, $this->absenceRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(), new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub()) new WorkedHoursCreditPolicy($this->buildResolverStub())
); );
@@ -71,6 +72,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack, $this->requestStack,
$this->employeeRepository, $this->employeeRepository,
$this->absenceRepository, $this->absenceRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(), new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub()) new WorkedHoursCreditPolicy($this->buildResolverStub())
); );
@@ -95,6 +97,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack, $this->requestStack,
$this->employeeRepository, $this->employeeRepository,
$this->absenceRepository, $this->absenceRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(), new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub()) new WorkedHoursCreditPolicy($this->buildResolverStub())
); );