Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9261cb5b1a | ||
| b68fef61c4 |
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.12'
|
||||
app.version: '0.1.13'
|
||||
|
||||
@@ -6,21 +6,19 @@
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span class="pl-4">Début matin</span>
|
||||
<span class="pr-2">Fin matin</span>
|
||||
<span class="pl-2">Début après-midi</span>
|
||||
<span class="pr-2">Fin après-midi</span>
|
||||
<span class="pl-2">Début soir</span>
|
||||
<span class="pr-2">Fin soir</span>
|
||||
<span class="pl-2">Absence</span>
|
||||
<span class="pl-2">Absence</span>
|
||||
<span class="pl-4">Début matin</span>
|
||||
<span class="pr-2">Fin matin</span>
|
||||
<span class="pl-2">Début après-midi</span>
|
||||
<span class="pr-2">Fin après-midi</span>
|
||||
<span class="pl-2">Début soir</span>
|
||||
<span class="pr-2">Fin soir</span>
|
||||
<span class="pl-2">Jour</span>
|
||||
<span>Nuit</span>
|
||||
<span>Total</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span v-if="isAdmin">Valider</span>
|
||||
<span v-else>Validation RH</span>
|
||||
<span v-if="isAdmin" class="inline-flex items-center gap-2">
|
||||
<span>Valider</span>
|
||||
<input
|
||||
v-if="isAdmin"
|
||||
ref="bulkValidationInput"
|
||||
:checked="isBulkValidationChecked"
|
||||
type="checkbox"
|
||||
@@ -28,6 +26,8 @@
|
||||
@change="onBulkValidationChange"
|
||||
/>
|
||||
</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
|
||||
@@ -41,82 +41,91 @@
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</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 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
|
||||
v-if="isTimeTracking(employee)"
|
||||
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
|
||||
v-else-if="isPresenceTracking(employee)"
|
||||
v-model="rows[employee.id].isPresentMorning"
|
||||
type="checkbox"
|
||||
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 class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
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 class="pl-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
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
|
||||
v-else-if="isPresenceTracking(employee)"
|
||||
v-model="rows[employee.id].isPresentAfternoon"
|
||||
type="checkbox"
|
||||
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 class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
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 class="pl-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
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 class="pr-2">
|
||||
<TimeSelect
|
||||
v-if="isTimeTracking(employee)"
|
||||
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 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 v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
||||
</div>
|
||||
@@ -127,15 +136,29 @@
|
||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
||||
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="isAdmin">
|
||||
<input
|
||||
v-if="isAdmin"
|
||||
:checked="rows[employee.id]?.isValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@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>
|
||||
@@ -153,18 +176,24 @@ const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||
const props = defineProps<{
|
||||
employees: Employee[]
|
||||
isAdmin: boolean
|
||||
isSiteManager: boolean
|
||||
dayGridCols: string
|
||||
isHoliday: boolean
|
||||
contractLabel: (employee: Employee) => string
|
||||
isTimeTracking: (employee: Employee) => boolean
|
||||
isPresenceTracking: (employee: Employee) => boolean
|
||||
isRowLocked: (employeeId: number) => boolean
|
||||
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
||||
isEveningLockedByAbsence: (employeeId: number) => boolean
|
||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||
isValidationPending: (employeeId: number) => boolean
|
||||
isSiteValidationPending: (employeeId: number) => boolean
|
||||
canToggleValidation: (employeeId: number) => boolean
|
||||
canToggleSiteValidation: (employeeId: number) => boolean
|
||||
isBulkValidationChecked: boolean
|
||||
isBulkValidationIndeterminate: boolean
|
||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
@@ -177,6 +206,10 @@ const onBulkValidationChange = (event: Event) => {
|
||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||
props.onToggleSiteValidation(employeeId, checked)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isBulkValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
|
||||
@@ -8,5 +8,6 @@ export type HourRow = {
|
||||
eveningTo: string
|
||||
isPresentMorning: boolean
|
||||
isPresentAfternoon: boolean
|
||||
isSiteValid: boolean
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div ref="root" class="relative w-full">
|
||||
<div
|
||||
ref="trigger"
|
||||
class="w-full flex items-center rounded-md border border-neutral-300 bg-white px-2 text-sm text-neutral-900 focus-within:border-primary-500"
|
||||
:class="props.disabled ? 'cursor-not-allowed bg-neutral-100 text-neutral-500' : ''"
|
||||
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="props.disabled ? 'cursor-not-allowed border-neutral-300 bg-neutral-200 text-neutral-500' : 'bg-white'"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
@@ -12,7 +12,7 @@
|
||||
inputmode="numeric"
|
||||
:placeholder="placeholder"
|
||||
:disabled="props.disabled"
|
||||
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed"
|
||||
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"
|
||||
@@ -23,7 +23,7 @@
|
||||
<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"
|
||||
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"
|
||||
|
||||
@@ -10,11 +10,13 @@ import type { HourRow } from '~/components/hours/types'
|
||||
import { listScopedEmployees } from '~/services/employees'
|
||||
import { listAbsenceTypes } from '~/services/absence-types'
|
||||
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
||||
import { listPublicHolidays } from '~/services/public-holidays'
|
||||
import {
|
||||
bulkUpsertWorkHours,
|
||||
getWorkHourDayContext,
|
||||
getWeeklyWorkHourSummary,
|
||||
listWorkHoursByDate,
|
||||
updateWorkHourSiteValidation,
|
||||
updateWorkHourValidation
|
||||
} from '~/services/work-hours'
|
||||
import {
|
||||
@@ -34,6 +36,8 @@ export const useHoursPage = () => {
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
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 selectedDate = ref(getTodayYmd())
|
||||
@@ -46,6 +50,7 @@ export const useHoursPage = () => {
|
||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||
const absenceTypes = ref<AbsenceType[]>([])
|
||||
const absences = ref<Absence[]>([])
|
||||
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
|
||||
const isAbsenceDrawerOpen = ref(false)
|
||||
const isAbsenceSubmitting = ref(false)
|
||||
const editingAbsence = ref<Absence | null>(null)
|
||||
@@ -62,10 +67,12 @@ export const useHoursPage = () => {
|
||||
const isWeekLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const validatingRowIds = ref<number[]>([])
|
||||
const siteValidatingRowIds = ref<number[]>([])
|
||||
|
||||
const dayGridCols = computed(() => {
|
||||
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)'
|
||||
@@ -118,7 +125,16 @@ export const useHoursPage = () => {
|
||||
})
|
||||
|
||||
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 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(() => {
|
||||
return employees.value
|
||||
@@ -203,6 +219,19 @@ export const useHoursPage = () => {
|
||||
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 days = weeklySummary.value?.days ?? []
|
||||
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
|
||||
@@ -273,12 +302,19 @@ export const useHoursPage = () => {
|
||||
eveningTo: '',
|
||||
isPresentMorning: false,
|
||||
isPresentAfternoon: false,
|
||||
isSiteValid: false,
|
||||
isValid: false
|
||||
})
|
||||
|
||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
||||
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 contract = employee.contract
|
||||
@@ -371,6 +407,10 @@ export const useHoursPage = () => {
|
||||
|
||||
const getRowAbsenceLabel = (employeeId: number) => {
|
||||
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.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
||||
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
||||
@@ -387,13 +427,21 @@ export const useHoursPage = () => {
|
||||
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') => {
|
||||
if (!hasContractAtSelectedDate(employeeId)) return true
|
||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||
if (!dayRow) return false
|
||||
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
|
||||
}
|
||||
|
||||
const isEveningLockedByAbsence = (employeeId: number) => {
|
||||
if (!hasContractAtSelectedDate(employeeId)) return true
|
||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||
if (!dayRow) return false
|
||||
return dayRow.absentAfternoon
|
||||
@@ -425,6 +473,7 @@ export const useHoursPage = () => {
|
||||
eveningTo: workHour?.eveningTo ?? '',
|
||||
isPresentMorning: workHour?.isPresentMorning ?? false,
|
||||
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
|
||||
isSiteValid: workHour?.isSiteValid ?? false,
|
||||
isValid: workHour?.isValid ?? false
|
||||
}
|
||||
}
|
||||
@@ -436,6 +485,18 @@ export const useHoursPage = () => {
|
||||
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 () => {
|
||||
absences.value = await listAbsences({
|
||||
from: selectedDate.value,
|
||||
@@ -445,6 +506,9 @@ export const useHoursPage = () => {
|
||||
}
|
||||
|
||||
const openAbsenceDrawer = (employeeId: number) => {
|
||||
if (!hasContractAtSelectedDate(employeeId)) return
|
||||
if (isSelectedDateHoliday.value) return
|
||||
|
||||
const existing = absences.value.find((absence) => {
|
||||
if (absence.employee?.id !== employeeId) return false
|
||||
const start = absence.startDate.slice(0, 10)
|
||||
@@ -571,17 +635,109 @@ export const useHoursPage = () => {
|
||||
options: { toast?: boolean } = {}
|
||||
) => {
|
||||
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]
|
||||
try {
|
||||
await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast })
|
||||
row.isValid = checked
|
||||
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
||||
updatedRow.isValid = checked
|
||||
} finally {
|
||||
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 employeeIds = validatableEmployeeIds.value
|
||||
if (employeeIds.length === 0) return
|
||||
@@ -659,6 +815,7 @@ export const useHoursPage = () => {
|
||||
const loadPage = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await loadPublicHolidaysForSelectedYear()
|
||||
await loadEmployees()
|
||||
await loadAbsenceTypes()
|
||||
await refreshByDate()
|
||||
@@ -694,6 +851,7 @@ export const useHoursPage = () => {
|
||||
}, { immediate: true })
|
||||
|
||||
watch(selectedDate, async () => {
|
||||
await loadPublicHolidaysForSelectedYear()
|
||||
await refreshByDate()
|
||||
})
|
||||
|
||||
@@ -702,7 +860,9 @@ export const useHoursPage = () => {
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const entries = employees.value.map((employee) => {
|
||||
const entries = employees.value
|
||||
.filter((employee) => hasContractAtSelectedDate(employee.id))
|
||||
.map((employee) => {
|
||||
const employeeId = employee.id
|
||||
const row = rows.value[employeeId] ?? emptyRow()
|
||||
if (isPresenceTracking(employee)) {
|
||||
@@ -730,7 +890,11 @@ export const useHoursPage = () => {
|
||||
isPresentMorning: false,
|
||||
isPresentAfternoon: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await bulkUpsertWorkHours({
|
||||
workDate: selectedDate.value,
|
||||
@@ -745,6 +909,8 @@ export const useHoursPage = () => {
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
isSelfUser,
|
||||
isSiteManager,
|
||||
viewMode,
|
||||
selectedDate,
|
||||
employeeFilter,
|
||||
@@ -767,6 +933,7 @@ export const useHoursPage = () => {
|
||||
weekGridCols,
|
||||
saveButtonClass,
|
||||
formattedSelectedDate,
|
||||
isSelectedDateHoliday,
|
||||
weekDayHeaders,
|
||||
shortcutButtonClass,
|
||||
weekShortcutButtonClass,
|
||||
@@ -784,11 +951,16 @@ export const useHoursPage = () => {
|
||||
isRowLocked,
|
||||
isHalfLockedByAbsence,
|
||||
isEveningLockedByAbsence,
|
||||
hasContractAtSelectedDate,
|
||||
isValidationPending,
|
||||
isSiteValidationPending,
|
||||
canToggleValidation,
|
||||
canToggleSiteValidation,
|
||||
validatableEmployeeIds,
|
||||
isBulkValidationChecked,
|
||||
isBulkValidationIndeterminate,
|
||||
toggleValidation,
|
||||
toggleSiteValidation,
|
||||
toggleValidationBulk,
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
|
||||
@@ -33,32 +33,38 @@
|
||||
Aucun employé accessible.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<div v-else class="flex min-h-0 flex-col gap-4">
|
||||
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
|
||||
<HoursDayView
|
||||
v-if="viewMode === 'day'"
|
||||
v-model:rows="rows"
|
||||
:employees="visibleEmployees"
|
||||
:is-admin="isAdmin"
|
||||
:is-site-manager="isSiteManager"
|
||||
:day-grid-cols="dayGridCols"
|
||||
:is-holiday="isSelectedDateHoliday"
|
||||
:contract-label="contractLabel"
|
||||
:is-time-tracking="isTimeTracking"
|
||||
:is-presence-tracking="isPresenceTracking"
|
||||
:is-row-locked="isRowLocked"
|
||||
:is-half-locked-by-absence="isHalfLockedByAbsence"
|
||||
:is-evening-locked-by-absence="isEveningLockedByAbsence"
|
||||
:has-contract-at-selected-date="hasContractAtSelectedDate"
|
||||
:is-validation-pending="isValidationPending"
|
||||
:is-site-validation-pending="isSiteValidationPending"
|
||||
:can-toggle-validation="canToggleValidation"
|
||||
:can-toggle-site-validation="canToggleSiteValidation"
|
||||
:is-bulk-validation-checked="isBulkValidationChecked"
|
||||
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
||||
:on-toggle-validation="toggleValidation"
|
||||
:on-toggle-site-validation="toggleSiteValidation"
|
||||
:on-toggle-validation-bulk="toggleValidationBulk"
|
||||
:get-row-metrics="getRowMetrics"
|
||||
:get-row-absence-label="getRowAbsenceLabel"
|
||||
:get-presence-day-value="getPresenceDayValue"
|
||||
:on-absence-click="openAbsenceDrawer"
|
||||
:format-minutes="formatMinutes"
|
||||
class="max-h-full"
|
||||
class="max-h-[calc(100vh-300px)]"
|
||||
/>
|
||||
|
||||
<HoursWeekView
|
||||
@@ -68,7 +74,7 @@
|
||||
:weekly-summary="filteredWeeklySummary"
|
||||
:week-day-headers="weekDayHeaders"
|
||||
:format-minutes="formatMinutes"
|
||||
class="max-h-full"
|
||||
class="max-h-[calc(100vh-300px)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -105,6 +111,7 @@
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
isAdmin,
|
||||
isSiteManager,
|
||||
viewMode,
|
||||
selectedDate,
|
||||
employeeFilter,
|
||||
@@ -123,6 +130,7 @@ const {
|
||||
isWeekLoading,
|
||||
isSubmitting,
|
||||
dayGridCols,
|
||||
isSelectedDateHoliday,
|
||||
weekGridCols,
|
||||
saveButtonClass,
|
||||
formattedSelectedDate,
|
||||
@@ -143,11 +151,15 @@ const {
|
||||
isRowLocked,
|
||||
isHalfLockedByAbsence,
|
||||
isEveningLockedByAbsence,
|
||||
hasContractAtSelectedDate,
|
||||
isValidationPending,
|
||||
isSiteValidationPending,
|
||||
canToggleValidation,
|
||||
canToggleSiteValidation,
|
||||
isBulkValidationChecked,
|
||||
isBulkValidationIndeterminate,
|
||||
toggleValidation,
|
||||
toggleSiteValidation,
|
||||
toggleValidationBulk,
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
|
||||
@@ -13,6 +13,7 @@ export type WorkHour = {
|
||||
eveningTo?: string | null
|
||||
isPresentMorning?: boolean
|
||||
isPresentAfternoon?: boolean
|
||||
isSiteValid?: boolean
|
||||
isValid?: boolean
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ export type WeeklyWorkHourSummary = {
|
||||
|
||||
export type WorkHourDayContextRow = {
|
||||
employeeId: number
|
||||
hasContractAtDate: boolean
|
||||
absenceLabel?: string | null
|
||||
absenceHalf?: 'AM' | 'PM' | null
|
||||
absentMorning: boolean
|
||||
|
||||
@@ -23,7 +23,7 @@ export const listWorkHoursByDate = async (workDate: string) => {
|
||||
export const bulkUpsertWorkHours = async (payload: {
|
||||
workDate: string
|
||||
entries: WorkHourEntryPayload[]
|
||||
}) => {
|
||||
}, options?: { toast?: boolean }) => {
|
||||
const api = useApi()
|
||||
return api.post<{
|
||||
processed: number
|
||||
@@ -34,6 +34,7 @@ export const bulkUpsertWorkHours = async (payload: {
|
||||
'/work-hours/bulk-upsert',
|
||||
payload,
|
||||
{
|
||||
toast: options?.toast ?? true,
|
||||
toastSuccessMessage: 'Horaires enregistrés.',
|
||||
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) => {
|
||||
const api = useApi()
|
||||
return api.get<WeeklyWorkHourSummary>(
|
||||
|
||||
26
migrations/Version20260226183000.php
Normal file
26
migrations/Version20260226183000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ final class DayContextRow
|
||||
{
|
||||
public function __construct(
|
||||
public int $employeeId,
|
||||
public bool $hasContractAtDate = true,
|
||||
public ?string $absenceLabel = null,
|
||||
public ?string $absenceHalf = null,
|
||||
public bool $absentMorning = false,
|
||||
@@ -45,6 +46,7 @@ final class DayContextRow
|
||||
/**
|
||||
* @return array{
|
||||
* employeeId:int,
|
||||
* hasContractAtDate:bool,
|
||||
* absenceLabel:?string,
|
||||
* absenceHalf:?string,
|
||||
* absentMorning:bool,
|
||||
@@ -57,6 +59,7 @@ final class DayContextRow
|
||||
{
|
||||
return [
|
||||
'employeeId' => $this->employeeId,
|
||||
'hasContractAtDate' => $this->hasContractAtDate,
|
||||
'absenceLabel' => $this->absenceLabel,
|
||||
'absenceHalf' => $this->absenceHalf,
|
||||
'absentMorning' => $this->absentMorning,
|
||||
|
||||
@@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\State\WorkHourSiteValidationProcessor;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -33,6 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
denormalizationContext: ['groups' => ['work_hour:validate']],
|
||||
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'])]
|
||||
@@ -94,6 +102,10 @@ class WorkHour
|
||||
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
||||
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
|
||||
{
|
||||
return $this->id;
|
||||
@@ -245,4 +257,21 @@ class WorkHour
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,6 @@ interface WorkHourReadRepositoryInterface
|
||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
||||
|
||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||
|
||||
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,26 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
||||
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
|
||||
{
|
||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||
@@ -114,7 +134,7 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
||||
->setMaxResults(1)
|
||||
;
|
||||
|
||||
/** @var null|WorkHour $workHour */
|
||||
// @var null|WorkHour $workHour
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use DateTimeImmutable;
|
||||
use LogicException;
|
||||
|
||||
readonly class EmployeeContractResolver
|
||||
{
|
||||
@@ -18,17 +17,9 @@ readonly class EmployeeContractResolver
|
||||
|
||||
public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract
|
||||
{
|
||||
$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')
|
||||
));
|
||||
}
|
||||
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
@@ -16,7 +17,9 @@ use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTime;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
@@ -26,6 +29,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
@@ -39,8 +43,11 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
return $data;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$isAdmin = $user instanceof User && in_array('ROLE_ADMIN', $user->getRoles(), true);
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -58,7 +65,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
$from = DateTimeImmutable::createFromInterface($segments[0]['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.');
|
||||
}
|
||||
|
||||
@@ -178,6 +185,19 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
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
|
||||
*/
|
||||
@@ -193,6 +213,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
$workHour
|
||||
->setMorningFrom(null)
|
||||
->setMorningTo(null)
|
||||
->setIsSiteValid(false)
|
||||
->setIsValid(false)
|
||||
;
|
||||
|
||||
return;
|
||||
@@ -205,6 +227,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
->setAfternoonTo(null)
|
||||
->setEveningFrom(null)
|
||||
->setEveningTo(null)
|
||||
->setIsSiteValid(false)
|
||||
->setIsValid(false)
|
||||
;
|
||||
|
||||
return;
|
||||
@@ -218,6 +242,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
->setAfternoonTo(null)
|
||||
->setEveningFrom(null)
|
||||
->setEveningTo(null)
|
||||
->setIsSiteValid(false)
|
||||
->setIsValid(false)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
if ($isNew) {
|
||||
$this->ensureContractPeriodExists($data, $currentContract, $today);
|
||||
$this->ensureContractPeriodExists($data, $currentContract, new DateTimeImmutable('1970-01-01'));
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$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);
|
||||
$this->entityManager->flush();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\ApiResource\WorkHourBulkUpsertResult;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
@@ -28,6 +29,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
private Security $security,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
) {}
|
||||
|
||||
@@ -67,6 +69,13 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
$existingByEmployeeId = $this->workHourRepository
|
||||
->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();
|
||||
|
||||
@@ -77,10 +86,18 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
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();
|
||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
|
||||
|
||||
if ($existing?->isValid()) {
|
||||
if (!$this->isSameAsExisting($existing, $normalized)) {
|
||||
@@ -95,11 +112,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
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)) {
|
||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||
if ($existing) {
|
||||
$this->entityManager->remove($existing);
|
||||
++$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;
|
||||
@@ -187,14 +227,16 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
return [
|
||||
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
||||
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
||||
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
||||
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
||||
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
||||
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
||||
'isPresentMorning' => false,
|
||||
'isPresentAfternoon' => false,
|
||||
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
||||
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
||||
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
||||
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
||||
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
||||
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
||||
// On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
|
||||
// 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'])
|
||||
->setIsPresentMorning($entry['isPresentMorning'])
|
||||
->setIsPresentAfternoon($entry['isPresentAfternoon'])
|
||||
// Toute modification invalide la validation chef de site.
|
||||
->setIsSiteValid(false)
|
||||
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
||||
->setIsValid(false)
|
||||
;
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Dto\WorkHours\DayContextRow;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateTimeImmutable;
|
||||
@@ -26,6 +27,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
||||
private RequestStack $requestStack,
|
||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
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à.
|
||||
$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');
|
||||
|
||||
54
src/State/WorkHourSiteValidationProcessor.php
Normal file
54
src/State/WorkHourSiteValidationProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Entity\Absence;
|
||||
use App\Entity\AbsenceType;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
@@ -17,6 +18,7 @@ use App\State\AbsenceWriteProcessor;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
@@ -32,7 +34,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::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);
|
||||
|
||||
@@ -59,7 +62,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::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);
|
||||
|
||||
@@ -79,7 +83,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::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);
|
||||
|
||||
@@ -100,7 +105,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::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);
|
||||
|
||||
@@ -121,6 +127,17 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
->setStartDate(new DateTime($startDate))
|
||||
->setEndDate(new DateTime($endDate))
|
||||
->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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ final class WorkHourDayContextProviderTest extends TestCase
|
||||
$this->requestStack,
|
||||
$this->employeeRepository,
|
||||
$this->absenceRepository,
|
||||
$this->buildResolverStub(),
|
||||
new AbsenceSegmentsResolver(),
|
||||
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||
);
|
||||
@@ -71,6 +72,7 @@ final class WorkHourDayContextProviderTest extends TestCase
|
||||
$this->requestStack,
|
||||
$this->employeeRepository,
|
||||
$this->absenceRepository,
|
||||
$this->buildResolverStub(),
|
||||
new AbsenceSegmentsResolver(),
|
||||
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||
);
|
||||
@@ -95,6 +97,7 @@ final class WorkHourDayContextProviderTest extends TestCase
|
||||
$this->requestStack,
|
||||
$this->employeeRepository,
|
||||
$this->absenceRepository,
|
||||
$this->buildResolverStub(),
|
||||
new AbsenceSegmentsResolver(),
|
||||
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user