diff --git a/frontend/components/hours/HoursDayView.vue b/frontend/components/hours/HoursDayView.vue
index b1c8322..384e49f 100644
--- a/frontend/components/hours/HoursDayView.vue
+++ b/frontend/components/hours/HoursDayView.vue
@@ -26,6 +26,18 @@
@change="onBulkValidationChange"
/>
+
+ Site
+
+
Site
RH
@@ -173,6 +185,7 @@ import type { HourRow } from './types'
const rows = defineModel>('rows', { required: true })
const bulkValidationInput = ref(null)
+const bulkSiteValidationInput = ref(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
+ onToggleSiteValidationBulk: (checked: boolean) => Promise | 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 }
+)
diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts
index 36ffa21..864291a 100644
--- a/frontend/composables/useHoursPage.ts
+++ b/frontend/composables/useHoursPage.ts
@@ -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()
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,
diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue
index b32c2da..9245760 100644
--- a/frontend/pages/hours.vue
+++ b/frontend/pages/hours.vue
@@ -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,
diff --git a/frontend/services/work-hours.ts b/frontend/services/work-hours.ts
index 32e755b..9f3dbe2 100644
--- a/frontend/services/work-hours.ts
+++ b/frontend/services/work-hours.ts
@@ -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(
diff --git a/src/ApiResource/WorkHourBulkSiteValidation.php b/src/ApiResource/WorkHourBulkSiteValidation.php
new file mode 100644
index 0000000..7ccd443
--- /dev/null
+++ b/src/ApiResource/WorkHourBulkSiteValidation.php
@@ -0,0 +1,31 @@
+
+ */
+ public array $employeeIds = [];
+}
diff --git a/src/ApiResource/WorkHourBulkValidation.php b/src/ApiResource/WorkHourBulkValidation.php
new file mode 100644
index 0000000..6ff6fc2
--- /dev/null
+++ b/src/ApiResource/WorkHourBulkValidation.php
@@ -0,0 +1,31 @@
+
+ */
+ public array $employeeIds = [];
+}
diff --git a/src/ApiResource/WorkHourBulkValidationResult.php b/src/ApiResource/WorkHourBulkValidationResult.php
new file mode 100644
index 0000000..196fcf2
--- /dev/null
+++ b/src/ApiResource/WorkHourBulkValidationResult.php
@@ -0,0 +1,22 @@
+
+ */
+ public array $updatedEmployeeIds = [];
+
+ /**
+ * @var list
+ */
+ public array $skippedEmployeeIds = [];
+}
diff --git a/src/Service/WorkHours/WorkHourBulkValidationExecutor.php b/src/Service/WorkHours/WorkHourBulkValidationExecutor.php
new file mode 100644
index 0000000..fb09f6e
--- /dev/null
+++ b/src/Service/WorkHours/WorkHourBulkValidationExecutor.php
@@ -0,0 +1,103 @@
+ $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 $employeeIds
+ *
+ * @return list
+ */
+ 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);
+ }
+}
diff --git a/src/State/WorkHourBulkSiteValidationProcessor.php b/src/State/WorkHourBulkSiteValidationProcessor.php
new file mode 100644
index 0000000..b4a1c79
--- /dev/null
+++ b/src/State/WorkHourBulkSiteValidationProcessor.php
@@ -0,0 +1,54 @@
+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);
+ }
+ );
+ }
+}
diff --git a/src/State/WorkHourBulkValidationProcessor.php b/src/State/WorkHourBulkValidationProcessor.php
new file mode 100644
index 0000000..d0d471f
--- /dev/null
+++ b/src/State/WorkHourBulkValidationProcessor.php
@@ -0,0 +1,54 @@
+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);
+ }
+ );
+ }
+}