diff --git a/config/version.yaml b/config/version.yaml index bb12e2d..00fb710 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.17' + app.version: '0.1.18' 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); + } + ); + } +}