Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae42c70d50 | ||
| 812215f5f6 | |||
|
|
36fe9ae54c | ||
| 6395ffbe1c |
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.16'
|
app.version: '0.1.18'
|
||||||
|
|||||||
77
frontend/components/PeriodStepperPicker.vue
Normal file
77
frontend/components/PeriodStepperPicker.vue
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative inline-flex h-10 items-center overflow-hidden rounded-md border border-primary-500 bg-white" :class="widthClass">
|
||||||
|
<input
|
||||||
|
ref="nativeInput"
|
||||||
|
:value="pickerValue"
|
||||||
|
:type="pickerType"
|
||||||
|
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
@input="onPickerInput"
|
||||||
|
@change="onPickerInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||||
|
:aria-label="prevAriaLabel"
|
||||||
|
@click="emit('prev')"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500"
|
||||||
|
@click="openPicker"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||||
|
:aria-label="nextAriaLabel"
|
||||||
|
@click="emit('next')"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
label: string
|
||||||
|
pickerType: 'date' | 'week' | 'month'
|
||||||
|
pickerValue: string
|
||||||
|
widthClass?: string
|
||||||
|
prevAriaLabel?: string
|
||||||
|
nextAriaLabel?: string
|
||||||
|
}>(), {
|
||||||
|
widthClass: 'w-[320px]',
|
||||||
|
prevAriaLabel: 'Précédent',
|
||||||
|
nextAriaLabel: 'Suivant'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'prev'): void
|
||||||
|
(e: 'next'): void
|
||||||
|
(e: 'pick', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nativeInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
const input = nativeInput.value
|
||||||
|
if (!input) return
|
||||||
|
if (typeof input.showPicker === 'function') {
|
||||||
|
input.showPicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.focus()
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPickerInput = (event: Event) => {
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
if (!value) return
|
||||||
|
emit('pick', value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -26,6 +26,18 @@
|
|||||||
@change="onBulkValidationChange"
|
@change="onBulkValidationChange"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
||||||
|
<span>Site</span>
|
||||||
|
<input
|
||||||
|
ref="bulkSiteValidationInput"
|
||||||
|
:checked="isBulkSiteValidationChecked"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
||||||
|
:disabled="!canBulkToggleSiteValidation"
|
||||||
|
@change="onBulkSiteValidationChange"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></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>
|
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -173,6 +185,7 @@ import type { HourRow } from './types'
|
|||||||
|
|
||||||
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
|
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
|
||||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
employees: Employee[]
|
employees: Employee[]
|
||||||
@@ -193,9 +206,13 @@ const props = defineProps<{
|
|||||||
canToggleSiteValidation: (employeeId: number) => boolean
|
canToggleSiteValidation: (employeeId: number) => boolean
|
||||||
isBulkValidationChecked: boolean
|
isBulkValidationChecked: boolean
|
||||||
isBulkValidationIndeterminate: boolean
|
isBulkValidationIndeterminate: boolean
|
||||||
|
isBulkSiteValidationChecked: boolean
|
||||||
|
isBulkSiteValidationIndeterminate: boolean
|
||||||
|
canBulkToggleSiteValidation: boolean
|
||||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||||
onToggleValidationBulk: (checked: boolean) => void
|
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||||
|
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | 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
|
||||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||||
@@ -208,6 +225,10 @@ const onBulkValidationChange = (event: Event) => {
|
|||||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onBulkSiteValidationChange = (event: Event) => {
|
||||||
|
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
|
}
|
||||||
|
|
||||||
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||||
props.onToggleSiteValidation(employeeId, checked)
|
props.onToggleSiteValidation(employeeId, checked)
|
||||||
}
|
}
|
||||||
@@ -220,4 +241,13 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isBulkSiteValidationIndeterminate,
|
||||||
|
(isIndeterminate) => {
|
||||||
|
if (!bulkSiteValidationInput.value) return
|
||||||
|
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -64,41 +64,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
|
<PeriodStepperPicker
|
||||||
<input
|
width-class="w-[320px]"
|
||||||
ref="nativeDateInput"
|
:label="formattedSelectedDate"
|
||||||
:value="pickerValue"
|
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
||||||
:type="viewMode === 'week' ? 'week' : 'date'"
|
:picker-value="pickerValue"
|
||||||
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
prev-aria-label="Période précédente"
|
||||||
tabindex="-1"
|
next-aria-label="Période suivante"
|
||||||
aria-hidden="true"
|
@prev="emit('shift-date', -1)"
|
||||||
@input="onPickerInput"
|
@next="emit('shift-date', 1)"
|
||||||
@change="onPickerInput"
|
@pick="onPickerValue"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
||||||
aria-label="Période précédente"
|
|
||||||
@click="emit('shift-date', -1)"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
||||||
@click="openDatePicker"
|
|
||||||
>
|
|
||||||
{{ formattedSelectedDate }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
||||||
aria-label="Période suivante"
|
|
||||||
@click="emit('shift-date', 1)"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
@@ -145,6 +121,7 @@ import type { Site } from '~/services/dto/site'
|
|||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||||
|
|
||||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||||
@@ -172,7 +149,6 @@ const emit = defineEmits<{
|
|||||||
(e: 'shift-date', value: number): void
|
(e: 'shift-date', value: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const nativeDateInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const pickerValue = computed(() => {
|
const pickerValue = computed(() => {
|
||||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
||||||
return selectedDate.value
|
return selectedDate.value
|
||||||
@@ -186,19 +162,7 @@ const viewModeButtonClass = (mode: 'day' | 'week') => {
|
|||||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
const openDatePicker = () => {
|
const onPickerValue = (value: string) => {
|
||||||
const input = nativeDateInput.value
|
|
||||||
if (!input) return
|
|
||||||
if (typeof input.showPicker === 'function') {
|
|
||||||
input.showPicker()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
input.focus()
|
|
||||||
input.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPickerInput = (event: Event) => {
|
|
||||||
const value = (event.target as HTMLInputElement).value
|
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
||||||
if (viewMode.value === 'week') {
|
if (viewMode.value === 'week') {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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 { listPublicHolidays } from '~/services/public-holidays'
|
||||||
import {
|
import {
|
||||||
|
bulkUpdateWorkHourSiteValidation,
|
||||||
|
bulkUpdateWorkHourValidation,
|
||||||
bulkUpsertWorkHours,
|
bulkUpsertWorkHours,
|
||||||
getWorkHourDayContext,
|
getWorkHourDayContext,
|
||||||
getWeeklyWorkHourSummary,
|
getWeeklyWorkHourSummary,
|
||||||
@@ -136,25 +138,61 @@ export const useHoursPage = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatableEmployeeIds = computed(() => {
|
const canCreateValidationRowFromAbsence = (employeeId: number) => {
|
||||||
return employees.value
|
const row = rows.value[employeeId]
|
||||||
|
if (row?.workHourId) return false
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (row?.workHourId) return false
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bulkValidatableEmployeeIds = computed(() => {
|
||||||
|
return visibleEmployees.value
|
||||||
.map((employee) => employee.id)
|
.map((employee) => employee.id)
|
||||||
.filter((employeeId) => canToggleValidation(employeeId))
|
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
|
||||||
})
|
})
|
||||||
|
|
||||||
const isBulkValidationChecked = computed(() => {
|
const isBulkValidationChecked = computed(() => {
|
||||||
const ids = validatableEmployeeIds.value
|
const ids = bulkValidatableEmployeeIds.value
|
||||||
if (ids.length === 0) return false
|
if (ids.length === 0) return false
|
||||||
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
|
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
|
||||||
})
|
})
|
||||||
|
|
||||||
const isBulkValidationIndeterminate = computed(() => {
|
const isBulkValidationIndeterminate = computed(() => {
|
||||||
const ids = validatableEmployeeIds.value
|
const ids = bulkValidatableEmployeeIds.value
|
||||||
if (ids.length === 0) return false
|
if (ids.length === 0) return false
|
||||||
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
|
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
|
||||||
return checkedCount > 0 && checkedCount < ids.length
|
return checkedCount > 0 && checkedCount < ids.length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bulkSiteValidatableEmployeeIds = computed(() => {
|
||||||
|
if (!isSiteManager.value) return []
|
||||||
|
return visibleEmployees.value
|
||||||
|
.map((employee) => employee.id)
|
||||||
|
.filter((employeeId) => canToggleSiteValidation(employeeId) || canCreateSiteValidationRowFromAbsence(employeeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkSiteValidationChecked = computed(() => {
|
||||||
|
const ids = bulkSiteValidatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
return ids.every((employeeId) => rows.value[employeeId]?.isSiteValid ?? false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBulkSiteValidationIndeterminate = computed(() => {
|
||||||
|
const ids = bulkSiteValidatableEmployeeIds.value
|
||||||
|
if (ids.length === 0) return false
|
||||||
|
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isSiteValid ?? false).length
|
||||||
|
return checkedCount > 0 && checkedCount < ids.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const canBulkToggleSiteValidation = computed(() => bulkSiteValidatableEmployeeIds.value.length > 0)
|
||||||
|
|
||||||
const dayContextByEmployeeId = computed(() => {
|
const dayContextByEmployeeId = computed(() => {
|
||||||
const map = new Map<number, WorkHourDayContext['rows'][number]>()
|
const map = new Map<number, WorkHourDayContext['rows'][number]>()
|
||||||
for (const row of dayContext.value?.rows ?? []) {
|
for (const row of dayContext.value?.rows ?? []) {
|
||||||
@@ -748,44 +786,169 @@ export const useHoursPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleValidationBulk = async (checked: boolean) => {
|
const toggleValidationBulk = async (checked: boolean) => {
|
||||||
const employeeIds = validatableEmployeeIds.value
|
const employeeIds = bulkValidatableEmployeeIds.value
|
||||||
if (employeeIds.length === 0) return
|
if (employeeIds.length === 0) return
|
||||||
|
|
||||||
let successCount = 0
|
const pendingIds = new Set(validatingRowIds.value)
|
||||||
let failedCount = 0
|
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
|
||||||
|
if (availableEmployeeIds.length === 0) return
|
||||||
|
|
||||||
for (const employeeId of employeeIds) {
|
if (checked) {
|
||||||
if (isValidationPending(employeeId)) continue
|
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(employeeId))
|
||||||
try {
|
if (toCreateIds.length > 0) {
|
||||||
await toggleValidation(employeeId, checked, { toast: false })
|
await bulkUpsertWorkHours({
|
||||||
successCount += 1
|
workDate: selectedDate.value,
|
||||||
} catch {
|
entries: toCreateIds.map((employeeId) => ({
|
||||||
failedCount += 1
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false
|
||||||
|
}))
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
await loadWorkHours()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failedCount === 0) {
|
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
|
||||||
toast.success({
|
if (targetEmployeeIds.length === 0) {
|
||||||
title: 'Succès',
|
toast.error({
|
||||||
message: checked
|
title: 'Validation impossible',
|
||||||
? `${successCount} ligne(s) validée(s).`
|
message: 'Aucune ligne ne peut être validée.'
|
||||||
: `${successCount} validation(s) retirée(s).`
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (successCount === 0) {
|
validatingRowIds.value = Array.from(new Set([...validatingRowIds.value, ...targetEmployeeIds]))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await bulkUpdateWorkHourValidation({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
isValid: checked,
|
||||||
|
employeeIds: targetEmployeeIds
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
await loadWorkHours()
|
||||||
|
|
||||||
|
if (result.updated === 0) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Aucune ligne mise à jour.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès partiel',
|
||||||
|
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: checked
|
||||||
|
? `${result.updated} ligne(s) validée(s).`
|
||||||
|
: `${result.updated} validation(s) retirée(s).`
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
toast.error({
|
toast.error({
|
||||||
title: 'Erreur',
|
title: 'Erreur',
|
||||||
message: 'Impossible de mettre à jour les validations.'
|
message: 'Impossible de mettre à jour les validations.'
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
validatingRowIds.value = validatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSiteValidationBulk = async (checked: boolean) => {
|
||||||
|
if (!isSiteManager.value) return
|
||||||
|
|
||||||
|
const employeeIds = bulkSiteValidatableEmployeeIds.value
|
||||||
|
if (employeeIds.length === 0) return
|
||||||
|
|
||||||
|
const pendingIds = new Set(siteValidatingRowIds.value)
|
||||||
|
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
|
||||||
|
if (availableEmployeeIds.length === 0) return
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateSiteValidationRowFromAbsence(employeeId))
|
||||||
|
if (toCreateIds.length > 0) {
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries: toCreateIds.map((employeeId) => ({
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false
|
||||||
|
}))
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
await loadWorkHours()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleSiteValidation(employeeId))
|
||||||
|
if (targetEmployeeIds.length === 0) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Validation impossible',
|
||||||
|
message: 'Aucune ligne ne peut être validée côté site.'
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error({
|
siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
|
||||||
title: 'Erreur',
|
|
||||||
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
|
try {
|
||||||
})
|
const result = await bulkUpdateWorkHourSiteValidation({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
isSiteValid: checked,
|
||||||
|
employeeIds: targetEmployeeIds
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
await loadWorkHours()
|
||||||
|
|
||||||
|
if (result.updated === 0) {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Aucune ligne site mise à jour.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès partiel',
|
||||||
|
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success({
|
||||||
|
title: 'Succès',
|
||||||
|
message: checked
|
||||||
|
? `${result.updated} validation(s) site enregistrée(s).`
|
||||||
|
: `${result.updated} validation(s) site retirée(s).`
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: 'Impossible de mettre à jour les validations site.'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadEmployees = async () => {
|
const loadEmployees = async () => {
|
||||||
@@ -965,12 +1128,15 @@ export const useHoursPage = () => {
|
|||||||
isSiteValidationPending,
|
isSiteValidationPending,
|
||||||
canToggleValidation,
|
canToggleValidation,
|
||||||
canToggleSiteValidation,
|
canToggleSiteValidation,
|
||||||
validatableEmployeeIds,
|
|
||||||
isBulkValidationChecked,
|
isBulkValidationChecked,
|
||||||
isBulkValidationIndeterminate,
|
isBulkValidationIndeterminate,
|
||||||
|
isBulkSiteValidationChecked,
|
||||||
|
isBulkSiteValidationIndeterminate,
|
||||||
|
canBulkToggleSiteValidation,
|
||||||
toggleValidation,
|
toggleValidation,
|
||||||
toggleSiteValidation,
|
toggleSiteValidation,
|
||||||
toggleValidationBulk,
|
toggleValidationBulk,
|
||||||
|
toggleSiteValidationBulk,
|
||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
|
|||||||
@@ -30,22 +30,17 @@
|
|||||||
<div class="w-80">
|
<div class="w-80">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<PeriodStepperPicker
|
||||||
v-model="selectedMonth"
|
width-class="w-[260px]"
|
||||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
:label="selectedMonthLabel"
|
||||||
>
|
picker-type="month"
|
||||||
<option v-for="month in months" :key="month.value" :value="month.value">
|
:picker-value="monthPickerValue"
|
||||||
{{ month.label }}
|
prev-aria-label="Mois précédent"
|
||||||
</option>
|
next-aria-label="Mois suivant"
|
||||||
</select>
|
@prev="shiftMonth(-1)"
|
||||||
<select
|
@next="shiftMonth(1)"
|
||||||
v-model="selectedYear"
|
@pick="onMonthPickerValue"
|
||||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
/>
|
||||||
>
|
|
||||||
<option v-for="year in years" :key="year" :value="year">
|
|
||||||
{{ year }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-6 py-2">
|
<div class="flex flex-wrap items-center gap-6 py-2">
|
||||||
@@ -111,6 +106,7 @@ import CalendarGrid from '~/components/CalendarGrid.vue'
|
|||||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
|
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
@@ -195,8 +191,8 @@ const months = [
|
|||||||
{value: 11, label: 'Décembre'}
|
{value: 11, label: 'Décembre'}
|
||||||
]
|
]
|
||||||
|
|
||||||
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
|
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
|
||||||
|
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
|
||||||
|
|
||||||
// Infos de calendrier calculées.
|
// Infos de calendrier calculées.
|
||||||
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
||||||
@@ -316,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shiftMonth = (delta: number) => {
|
||||||
|
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
|
||||||
|
selectedYear.value = next.getFullYear()
|
||||||
|
selectedMonth.value = next.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMonthPickerValue = (value: string) => {
|
||||||
|
if (!value) return
|
||||||
|
const [yearStr, monthStr] = value.split('-')
|
||||||
|
const year = Number(yearStr)
|
||||||
|
const month = Number(monthStr)
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
|
||||||
|
selectedYear.value = year
|
||||||
|
selectedMonth.value = month - 1
|
||||||
|
}
|
||||||
|
|
||||||
// Limite l'intervalle d'impression à 2 mois max.
|
// Limite l'intervalle d'impression à 2 mois max.
|
||||||
const enforcePrintRange = () => {
|
const enforcePrintRange = () => {
|
||||||
if (!printForm.from) return
|
if (!printForm.from) return
|
||||||
|
|||||||
@@ -56,9 +56,13 @@
|
|||||||
:can-toggle-site-validation="canToggleSiteValidation"
|
: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"
|
||||||
|
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
|
||||||
|
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
|
||||||
|
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
|
||||||
:on-toggle-validation="toggleValidation"
|
:on-toggle-validation="toggleValidation"
|
||||||
:on-toggle-site-validation="toggleSiteValidation"
|
:on-toggle-site-validation="toggleSiteValidation"
|
||||||
:on-toggle-validation-bulk="toggleValidationBulk"
|
:on-toggle-validation-bulk="toggleValidationBulk"
|
||||||
|
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
|
||||||
:get-row-metrics="getRowMetrics"
|
:get-row-metrics="getRowMetrics"
|
||||||
:get-row-absence-label="getRowAbsenceLabel"
|
:get-row-absence-label="getRowAbsenceLabel"
|
||||||
:get-row-absence-style="getRowAbsenceStyle"
|
:get-row-absence-style="getRowAbsenceStyle"
|
||||||
@@ -159,9 +163,13 @@ const {
|
|||||||
canToggleSiteValidation,
|
canToggleSiteValidation,
|
||||||
isBulkValidationChecked,
|
isBulkValidationChecked,
|
||||||
isBulkValidationIndeterminate,
|
isBulkValidationIndeterminate,
|
||||||
|
isBulkSiteValidationChecked,
|
||||||
|
isBulkSiteValidationIndeterminate,
|
||||||
|
canBulkToggleSiteValidation,
|
||||||
toggleValidation,
|
toggleValidation,
|
||||||
toggleSiteValidation,
|
toggleSiteValidation,
|
||||||
toggleValidationBulk,
|
toggleValidationBulk,
|
||||||
|
toggleSiteValidationBulk,
|
||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ export const updateWorkHourValidation = async (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const bulkUpdateWorkHourValidation = async (payload: {
|
||||||
|
workDate: string
|
||||||
|
isValid: boolean
|
||||||
|
employeeIds: number[]
|
||||||
|
}, options?: { toast?: boolean }) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<{
|
||||||
|
requested: number
|
||||||
|
updated: number
|
||||||
|
skipped: number
|
||||||
|
updatedEmployeeIds: number[]
|
||||||
|
skippedEmployeeIds: number[]
|
||||||
|
}>(
|
||||||
|
'/work-hours/bulk-validation',
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
toast: options?.toast ?? true,
|
||||||
|
toastSuccessMessage: payload.isValid ? 'Validations enregistrées.' : 'Validations retirées.',
|
||||||
|
toastErrorMessage: "Impossible de mettre à jour les validations."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const updateWorkHourSiteValidation = async (
|
export const updateWorkHourSiteValidation = async (
|
||||||
id: number,
|
id: number,
|
||||||
isSiteValid: boolean,
|
isSiteValid: boolean,
|
||||||
@@ -75,6 +98,29 @@ export const updateWorkHourSiteValidation = async (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const bulkUpdateWorkHourSiteValidation = async (payload: {
|
||||||
|
workDate: string
|
||||||
|
isSiteValid: boolean
|
||||||
|
employeeIds: number[]
|
||||||
|
}, options?: { toast?: boolean }) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<{
|
||||||
|
requested: number
|
||||||
|
updated: number
|
||||||
|
skipped: number
|
||||||
|
updatedEmployeeIds: number[]
|
||||||
|
skippedEmployeeIds: number[]
|
||||||
|
}>(
|
||||||
|
'/work-hours/site-bulk-validation',
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
toast: options?.toast ?? true,
|
||||||
|
toastSuccessMessage: payload.isSiteValid ? 'Validations site enregistrées.' : 'Validations site retirées.',
|
||||||
|
toastErrorMessage: "Impossible de mettre à jour les validations 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>(
|
||||||
|
|||||||
31
src/ApiResource/WorkHourBulkSiteValidation.php
Normal file
31
src/ApiResource/WorkHourBulkSiteValidation.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\State\WorkHourBulkSiteValidationProcessor;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/work-hours/site-bulk-validation',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
output: WorkHourBulkValidationResult::class,
|
||||||
|
processor: WorkHourBulkSiteValidationProcessor::class
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class WorkHourBulkSiteValidation
|
||||||
|
{
|
||||||
|
public string $workDate = '';
|
||||||
|
|
||||||
|
public bool $isSiteValid = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<int>
|
||||||
|
*/
|
||||||
|
public array $employeeIds = [];
|
||||||
|
}
|
||||||
31
src/ApiResource/WorkHourBulkValidation.php
Normal file
31
src/ApiResource/WorkHourBulkValidation.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\State\WorkHourBulkValidationProcessor;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/work-hours/bulk-validation',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
output: WorkHourBulkValidationResult::class,
|
||||||
|
processor: WorkHourBulkValidationProcessor::class
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class WorkHourBulkValidation
|
||||||
|
{
|
||||||
|
public string $workDate = '';
|
||||||
|
|
||||||
|
public bool $isValid = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<int>
|
||||||
|
*/
|
||||||
|
public array $employeeIds = [];
|
||||||
|
}
|
||||||
22
src/ApiResource/WorkHourBulkValidationResult.php
Normal file
22
src/ApiResource/WorkHourBulkValidationResult.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
final class WorkHourBulkValidationResult
|
||||||
|
{
|
||||||
|
public int $requested = 0;
|
||||||
|
public int $updated = 0;
|
||||||
|
public int $skipped = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<int>
|
||||||
|
*/
|
||||||
|
public array $updatedEmployeeIds = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<int>
|
||||||
|
*/
|
||||||
|
public array $skippedEmployeeIds = [];
|
||||||
|
}
|
||||||
103
src/Service/WorkHours/WorkHourBulkValidationExecutor.php
Normal file
103
src/Service/WorkHours/WorkHourBulkValidationExecutor.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\ApiResource\WorkHourBulkValidationResult;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
final readonly class WorkHourBulkValidationExecutor
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<mixed> $employeeIds
|
||||||
|
* @param callable(?WorkHour, int): bool $shouldSkip
|
||||||
|
* @param callable(WorkHour, int): void $applyUpdate
|
||||||
|
*/
|
||||||
|
public function execute(
|
||||||
|
User $user,
|
||||||
|
string $workDateValue,
|
||||||
|
array $employeeIds,
|
||||||
|
callable $shouldSkip,
|
||||||
|
callable $applyUpdate
|
||||||
|
): WorkHourBulkValidationResult {
|
||||||
|
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $workDateValue);
|
||||||
|
if (!$workDate || $workDate->format('Y-m-d') !== $workDateValue) {
|
||||||
|
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedEmployeeIds = $this->normalizeEmployeeIds($employeeIds);
|
||||||
|
if ([] === $normalizedEmployeeIds) {
|
||||||
|
throw new UnprocessableEntityHttpException('employeeIds must contain at least one employee.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$employeesById = $this->employeeRepository->findAccessibleByIds($normalizedEmployeeIds, $user);
|
||||||
|
if (count($employeesById) !== count($normalizedEmployeeIds)) {
|
||||||
|
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingByEmployeeId = $this->workHourRepository
|
||||||
|
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
|
||||||
|
;
|
||||||
|
|
||||||
|
$result = new WorkHourBulkValidationResult();
|
||||||
|
$result->requested = count($normalizedEmployeeIds);
|
||||||
|
|
||||||
|
foreach ($normalizedEmployeeIds as $employeeId) {
|
||||||
|
$workHour = $existingByEmployeeId[$employeeId] ?? null;
|
||||||
|
if (null === $workHour || $shouldSkip($workHour, $employeeId)) {
|
||||||
|
++$result->skipped;
|
||||||
|
$result->skippedEmployeeIds[] = $employeeId;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$applyUpdate($workHour, $employeeId);
|
||||||
|
++$result->updated;
|
||||||
|
$result->updatedEmployeeIds[] = $employeeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result->updated > 0) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<mixed> $employeeIds
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function normalizeEmployeeIds(array $employeeIds): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($employeeIds as $index => $rawId) {
|
||||||
|
$employeeId = (int) $rawId;
|
||||||
|
if ($employeeId <= 0) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf('employeeIds[%d] must be a positive integer.', $index));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($normalized[$employeeId])) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in payload.', $employeeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[$employeeId] = $employeeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/State/WorkHourBulkSiteValidationProcessor.php
Normal file
54
src/State/WorkHourBulkSiteValidationProcessor.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\ApiResource\WorkHourBulkSiteValidation;
|
||||||
|
use App\ApiResource\WorkHourBulkValidationResult;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
|
final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private WorkHourBulkValidationExecutor $executor,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
mixed $data,
|
||||||
|
Operation $operation,
|
||||||
|
array $uriVariables = [],
|
||||||
|
array $context = []
|
||||||
|
): WorkHourBulkValidationResult {
|
||||||
|
if (!$data instanceof WorkHourBulkSiteValidation) {
|
||||||
|
throw new BadRequestHttpException('Invalid payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('ROLE_ADMIN', $user->getRoles(), true) || in_array('ROLE_SELF', $user->getRoles(), true)) {
|
||||||
|
throw new AccessDeniedHttpException('Only site managers can bulk update site validation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->executor->execute(
|
||||||
|
user: $user,
|
||||||
|
workDateValue: $data->workDate,
|
||||||
|
employeeIds: $data->employeeIds,
|
||||||
|
shouldSkip: static fn (WorkHour $workHour): bool => $workHour->isValid() || $workHour->isSiteValid() === $data->isSiteValid,
|
||||||
|
applyUpdate: static function (WorkHour $workHour) use ($data): void {
|
||||||
|
$workHour->setIsSiteValid($data->isSiteValid);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/State/WorkHourBulkValidationProcessor.php
Normal file
54
src/State/WorkHourBulkValidationProcessor.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\ApiResource\WorkHourBulkValidation;
|
||||||
|
use App\ApiResource\WorkHourBulkValidationResult;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
|
final readonly class WorkHourBulkValidationProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private WorkHourBulkValidationExecutor $executor,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
mixed $data,
|
||||||
|
Operation $operation,
|
||||||
|
array $uriVariables = [],
|
||||||
|
array $context = []
|
||||||
|
): WorkHourBulkValidationResult {
|
||||||
|
if (!$data instanceof WorkHourBulkValidation) {
|
||||||
|
throw new BadRequestHttpException('Invalid payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||||||
|
throw new AccessDeniedHttpException('Only admins can bulk validate work hours.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->executor->execute(
|
||||||
|
user: $user,
|
||||||
|
workDateValue: $data->workDate,
|
||||||
|
employeeIds: $data->employeeIds,
|
||||||
|
shouldSkip: static fn (WorkHour $workHour): bool => $workHour->isValid() === $data->isValid,
|
||||||
|
applyUpdate: static function (WorkHour $workHour) use ($data): void {
|
||||||
|
$workHour->setIsValid($data->isValid);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user