Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions
ae42c70d50 chore: bump version to v0.1.18
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-03-03 08:35:44 +00:00
812215f5f6 fix : validation bulk des heures. Moins de lag et de bug
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-03 09:33:53 +01:00
gitea-actions
36fe9ae54c chore: bump version to v0.1.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-02 09:50:14 +00:00
6395ffbe1c feat : modification des sélecteurs de date sur le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:50:02 +01:00
14 changed files with 694 additions and 96 deletions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.16'
app.version: '0.1.18'

View 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>

View File

@@ -26,6 +26,18 @@
@change="onBulkValidationChange"
/>
</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-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
@@ -173,6 +185,7 @@ import type { HourRow } from './types'
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null)
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
employees: Employee[]
@@ -193,9 +206,13 @@ const props = defineProps<{
canToggleSiteValidation: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
isBulkSiteValidationChecked: boolean
isBulkSiteValidationIndeterminate: boolean
canBulkToggleSiteValidation: boolean
onToggleValidation: (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 }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
@@ -208,6 +225,10 @@ const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
const onBulkSiteValidationChange = (event: Event) => {
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
}
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
@@ -220,4 +241,13 @@ watch(
},
{ immediate: true }
)
watch(
() => props.isBulkSiteValidationIndeterminate,
(isIndeterminate) => {
if (!bulkSiteValidationInput.value) return
bulkSiteValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script>

View File

@@ -64,41 +64,17 @@
</button>
</div>
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
<input
ref="nativeDateInput"
:value="pickerValue"
:type="viewMode === 'week' ? 'week' : 'date'"
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] 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>
<PeriodStepperPicker
width-class="w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
prev-aria-label="Période précédente"
next-aria-label="Période suivante"
@prev="emit('shift-date', -1)"
@next="emit('shift-date', 1)"
@pick="onPickerValue"
/>
</div>
<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 EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
@@ -172,7 +149,6 @@ const emit = defineEmits<{
(e: 'shift-date', value: number): void
}>()
const nativeDateInput = ref<HTMLInputElement | null>(null)
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value
@@ -186,19 +162,7 @@ const viewModeButtonClass = (mode: 'day' | 'week') => {
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const openDatePicker = () => {
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
const onPickerValue = (value: string) => {
if (!value) return
if (viewMode.value === 'week') {

View File

@@ -12,6 +12,8 @@ import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import { listPublicHolidays } from '~/services/public-holidays'
import {
bulkUpdateWorkHourSiteValidation,
bulkUpdateWorkHourValidation,
bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary,
@@ -136,25 +138,61 @@ export const useHoursPage = () => {
return true
}
const validatableEmployeeIds = computed(() => {
return employees.value
const canCreateValidationRowFromAbsence = (employeeId: number) => {
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)
.filter((employeeId) => canToggleValidation(employeeId))
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
})
const isBulkValidationChecked = computed(() => {
const ids = validatableEmployeeIds.value
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
})
const isBulkValidationIndeterminate = computed(() => {
const ids = validatableEmployeeIds.value
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).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 map = new Map<number, WorkHourDayContext['rows'][number]>()
for (const row of dayContext.value?.rows ?? []) {
@@ -748,44 +786,169 @@ export const useHoursPage = () => {
}
const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = validatableEmployeeIds.value
const employeeIds = bulkValidatableEmployeeIds.value
if (employeeIds.length === 0) return
let successCount = 0
let failedCount = 0
const pendingIds = new Set(validatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
for (const employeeId of employeeIds) {
if (isValidationPending(employeeId)) continue
try {
await toggleValidation(employeeId, checked, { toast: false })
successCount += 1
} catch {
failedCount += 1
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(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()
}
}
if (failedCount === 0) {
toast.success({
title: 'Succès',
message: checked
? `${successCount} ligne(s) validée(s).`
: `${successCount} validation(s) retirée(s).`
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée.'
})
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({
title: 'Erreur',
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
}
toast.error({
title: 'Erreur',
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
})
siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
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 () => {
@@ -965,12 +1128,15 @@ export const useHoursPage = () => {
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
validatableEmployeeIds,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,

View File

@@ -30,22 +30,17 @@
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
</div>
<select
v-model="selectedMonth"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
</option>
</select>
<select
v-model="selectedYear"
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>
<PeriodStepperPicker
width-class="w-[260px]"
:label="selectedMonthLabel"
picker-type="month"
:picker-value="monthPickerValue"
prev-aria-label="Mois précédent"
next-aria-label="Mois suivant"
@prev="shiftMonth(-1)"
@next="shiftMonth(1)"
@pick="onMonthPickerValue"
/>
</div>
</div>
<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 AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
@@ -195,8 +191,8 @@ const months = [
{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.
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
@@ -316,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
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.
const enforcePrintRange = () => {
if (!printForm.from) return

View File

@@ -56,9 +56,13 @@
:can-toggle-site-validation="canToggleSiteValidation"
:is-bulk-validation-checked="isBulkValidationChecked"
: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-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
@@ -159,9 +163,13 @@ const {
canToggleSiteValidation,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,

View File

@@ -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 (
id: number,
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) => {
const api = useApi()
return api.get<WeeklyWorkHourSummary>(

View 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 = [];
}

View 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 = [];
}

View 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 = [];
}

View 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);
}
}

View 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);
}
);
}
}

View 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);
}
);
}
}