diff --git a/frontend/components/hours/HoursDayView.vue b/frontend/components/hours/HoursDayView.vue index 8a1e856..d8a090f 100644 --- a/frontend/components/hours/HoursDayView.vue +++ b/frontend/components/hours/HoursDayView.vue @@ -6,21 +6,19 @@ :style="{ gridTemplateColumns: dayGridCols }" > Nom - Début matin - Fin matin - Début après-midi - Fin après-midi - Début soir - Fin soir - Absence + Absence + Début matin + Fin matin + Début après-midi + Fin après-midi + Début soir + Fin soir Jour Nuit Total - - Valider - Validation RH + + Valider + Site + RH
({{ contractLabel(employee) }})

-

{{ employee.site?.name ?? 'Sans site' }}

+

+ {{ employee.site?.name ?? 'Sans site' }} + + + +

-
+
+

+ {{ getRowAbsenceLabel(employee.id) || '—' }} +

+ +
+
-
-

- {{ getRowAbsenceLabel(employee.id) || '—' }} -

- -
{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}
@@ -127,15 +136,29 @@
{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
{{ getPresenceDayValue(employee.id) }}
-
+
- Validé +
+
+ + Validé + - +
+
+ Validé + -
@@ -153,18 +176,24 @@ const bulkValidationInput = ref(null) const props = defineProps<{ employees: Employee[] isAdmin: boolean + isSiteManager: boolean dayGridCols: string + isHoliday: boolean contractLabel: (employee: Employee) => string isTimeTracking: (employee: Employee) => boolean isPresenceTracking: (employee: Employee) => boolean isRowLocked: (employeeId: number) => boolean isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean isEveningLockedByAbsence: (employeeId: number) => boolean + hasContractAtSelectedDate: (employeeId: number) => boolean isValidationPending: (employeeId: number) => boolean + isSiteValidationPending: (employeeId: number) => boolean canToggleValidation: (employeeId: number) => boolean + canToggleSiteValidation: (employeeId: number) => boolean isBulkValidationChecked: boolean isBulkValidationIndeterminate: boolean onToggleValidation: (employeeId: number, checked: boolean) => void + onToggleSiteValidation: (employeeId: number, checked: boolean) => void onToggleValidationBulk: (checked: boolean) => void getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number } getRowAbsenceLabel: (employeeId: number) => string @@ -177,6 +206,10 @@ const onBulkValidationChange = (event: Event) => { props.onToggleValidationBulk((event.target as HTMLInputElement).checked) } +const onToggleSiteValidation = (employeeId: number, checked: boolean) => { + props.onToggleSiteValidation(employeeId, checked) +} + watch( () => props.isBulkValidationIndeterminate, (isIndeterminate) => { diff --git a/frontend/components/hours/types.ts b/frontend/components/hours/types.ts index 99ee86c..086413c 100644 --- a/frontend/components/hours/types.ts +++ b/frontend/components/hours/types.ts @@ -8,5 +8,6 @@ export type HourRow = { eveningTo: string isPresentMorning: boolean isPresentAfternoon: boolean + isSiteValid: boolean isValid: boolean } diff --git a/frontend/components/ui/TimeSelect.vue b/frontend/components/ui/TimeSelect.vue index 1ef18e5..6ffec40 100644 --- a/frontend/components/ui/TimeSelect.vue +++ b/frontend/components/ui/TimeSelect.vue @@ -2,8 +2,8 @@
{ const auth = useAuthStore() const toast = useToast() const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) + const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false) + const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value) const viewMode = ref<'day' | 'week'>('day') const selectedDate = ref(getTodayYmd()) @@ -46,6 +50,7 @@ export const useHoursPage = () => { const weeklySummary = ref(null) const absenceTypes = ref([]) const absences = ref([]) + const publicHolidaysByYear = ref>>({}) const isAbsenceDrawerOpen = ref(false) const isAbsenceSubmitting = ref(false) const editingAbsence = ref(null) @@ -62,10 +67,12 @@ export const useHoursPage = () => { const isWeekLoading = ref(false) const isSubmitting = ref(false) const validatingRowIds = ref([]) + const siteValidatingRowIds = ref([]) const dayGridCols = computed(() => { const metricCol = '0.4fr' - return `1.2fr repeat(6, 1fr) 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol}` + const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}` + return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}` }) const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)' @@ -118,7 +125,16 @@ export const useHoursPage = () => { }) const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId) + const isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId) const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId + const canToggleSiteValidation = (employeeId: number) => { + if (!isSiteManager.value) return false + const row = rows.value[employeeId] + if (!row?.workHourId) return false + // Une validation RH fige la ligne côté chef de site. + if (row.isValid) return false + return true + } const validatableEmployeeIds = computed(() => { return employees.value @@ -203,6 +219,19 @@ export const useHoursPage = () => { return formatDateLongFr(parsed) }) + const selectedYear = computed(() => { + const parsed = parseYmd(selectedDate.value) + return parsed ? parsed.getFullYear() : null + }) + + const selectedHolidayLabel = computed(() => { + const year = selectedYear.value + if (!year) return '' + return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? '' + }) + + const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '') + const weekDayHeaders = computed(() => { const days = weeklySummary.value?.days ?? [] return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) })) @@ -273,12 +302,19 @@ export const useHoursPage = () => { eveningTo: '', isPresentMorning: false, isPresentAfternoon: false, + isSiteValid: false, isValid: false }) const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee) - const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false + const isRowLocked = (employeeId: number) => { + const row = rows.value[employeeId] + if (!row) return false + if (row.isValid) return true + if (!isAdmin.value && row.isSiteValid) return true + return false + } const contractLabel = (employee: Employee) => { const contract = employee.contract @@ -371,6 +407,10 @@ export const useHoursPage = () => { const getRowAbsenceLabel = (employeeId: number) => { const dayRow = dayContextByEmployeeId.value.get(employeeId) + if (dayRow && dayRow.hasContractAtDate === false) { + return 'Contrat non démarré' + } + if (isSelectedDateHoliday.value) return 'Férié' if (!dayRow?.absenceLabel) return '' if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') { const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.' @@ -387,13 +427,21 @@ export const useHoursPage = () => { return Number.isInteger(total) ? String(total) : total.toFixed(1) } + const hasContractAtSelectedDate = (employeeId: number) => { + const dayRow = dayContextByEmployeeId.value.get(employeeId) + if (!dayRow) return true + return dayRow.hasContractAtDate !== false + } + const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => { + if (!hasContractAtSelectedDate(employeeId)) return true const dayRow = dayContextByEmployeeId.value.get(employeeId) if (!dayRow) return false return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon } const isEveningLockedByAbsence = (employeeId: number) => { + if (!hasContractAtSelectedDate(employeeId)) return true const dayRow = dayContextByEmployeeId.value.get(employeeId) if (!dayRow) return false return dayRow.absentAfternoon @@ -425,6 +473,7 @@ export const useHoursPage = () => { eveningTo: workHour?.eveningTo ?? '', isPresentMorning: workHour?.isPresentMorning ?? false, isPresentAfternoon: workHour?.isPresentAfternoon ?? false, + isSiteValid: workHour?.isSiteValid ?? false, isValid: workHour?.isValid ?? false } } @@ -436,6 +485,18 @@ export const useHoursPage = () => { absenceTypes.value = await listAbsenceTypes() } + const loadPublicHolidaysForSelectedYear = async () => { + const year = selectedYear.value + if (!year) return + if (publicHolidaysByYear.value[year]) return + + const holidays = await listPublicHolidays('metropole', year) + publicHolidaysByYear.value = { + ...publicHolidaysByYear.value, + [year]: holidays + } + } + const loadAbsences = async () => { absences.value = await listAbsences({ from: selectedDate.value, @@ -445,6 +506,9 @@ export const useHoursPage = () => { } const openAbsenceDrawer = (employeeId: number) => { + if (!hasContractAtSelectedDate(employeeId)) return + if (isSelectedDateHoliday.value) return + const existing = absences.value.find((absence) => { if (absence.employee?.id !== employeeId) return false const start = absence.startDate.slice(0, 10) @@ -571,17 +635,109 @@ export const useHoursPage = () => { options: { toast?: boolean } = {} ) => { const row = rows.value[employeeId] - if (!row?.workHourId || isValidationPending(employeeId)) return + const dayRow = dayContextByEmployeeId.value.get(employeeId) + if (!row?.workHourId && checked) { + const employee = employees.value.find((item) => item.id === employeeId) + const hasAbsence = !!dayRow?.absenceLabel + const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId) + + if (canCreateFromAbsence) { + await bulkUpsertWorkHours({ + workDate: selectedDate.value, + entries: [{ + employeeId, + morningFrom: null, + morningTo: null, + afternoonFrom: null, + afternoonTo: null, + eveningFrom: null, + eveningTo: null, + isPresentMorning: false, + isPresentAfternoon: false + }] + }, { toast: false }) + + await loadWorkHours() + } + } + + const updatedRow = rows.value[employeeId] + if (!updatedRow?.workHourId) { + if (options.toast !== false) { + toast.error({ + title: 'Validation impossible', + message: 'La ligne doit contenir des heures ou une absence.' + }) + } + return + } + + if (isValidationPending(employeeId)) return validatingRowIds.value = [...validatingRowIds.value, employeeId] try { - await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast }) - row.isValid = checked + await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast }) + updatedRow.isValid = checked } finally { validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId) } } + const toggleSiteValidation = async ( + employeeId: number, + checked: boolean, + options: { toast?: boolean } = {} + ) => { + const row = rows.value[employeeId] + const dayRow = dayContextByEmployeeId.value.get(employeeId) + if (!row?.workHourId && checked) { + const employee = employees.value.find((item) => item.id === employeeId) + const hasAbsence = !!dayRow?.absenceLabel + const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId) + + if (canCreateFromAbsence) { + await bulkUpsertWorkHours({ + workDate: selectedDate.value, + entries: [{ + employeeId, + morningFrom: null, + morningTo: null, + afternoonFrom: null, + afternoonTo: null, + eveningFrom: null, + eveningTo: null, + isPresentMorning: false, + isPresentAfternoon: false + }] + }, { toast: false }) + + await loadWorkHours() + } + } + + const updatedRow = rows.value[employeeId] + if (!updatedRow?.workHourId) { + if (options.toast !== false) { + toast.error({ + title: 'Validation impossible', + message: 'La ligne doit contenir des heures ou une absence.' + }) + } + return + } + + if (isSiteValidationPending(employeeId)) return + if (!canToggleSiteValidation(employeeId)) return + + siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId] + try { + await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast }) + updatedRow.isSiteValid = checked + } finally { + siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => id !== employeeId) + } + } + const toggleValidationBulk = async (checked: boolean) => { const employeeIds = validatableEmployeeIds.value if (employeeIds.length === 0) return @@ -659,6 +815,7 @@ export const useHoursPage = () => { const loadPage = async () => { isLoading.value = true try { + await loadPublicHolidaysForSelectedYear() await loadEmployees() await loadAbsenceTypes() await refreshByDate() @@ -694,6 +851,7 @@ export const useHoursPage = () => { }, { immediate: true }) watch(selectedDate, async () => { + await loadPublicHolidaysForSelectedYear() await refreshByDate() }) @@ -702,7 +860,9 @@ export const useHoursPage = () => { isSubmitting.value = true try { - const entries = employees.value.map((employee) => { + const entries = employees.value + .filter((employee) => hasContractAtSelectedDate(employee.id)) + .map((employee) => { const employeeId = employee.id const row = rows.value[employeeId] ?? emptyRow() if (isPresenceTracking(employee)) { @@ -730,7 +890,11 @@ export const useHoursPage = () => { isPresentMorning: false, isPresentAfternoon: false } - }) + }) + + if (entries.length === 0) { + return + } await bulkUpsertWorkHours({ workDate: selectedDate.value, @@ -745,6 +909,8 @@ export const useHoursPage = () => { return { isAdmin, + isSelfUser, + isSiteManager, viewMode, selectedDate, employeeFilter, @@ -767,6 +933,7 @@ export const useHoursPage = () => { weekGridCols, saveButtonClass, formattedSelectedDate, + isSelectedDateHoliday, weekDayHeaders, shortcutButtonClass, weekShortcutButtonClass, @@ -784,11 +951,16 @@ export const useHoursPage = () => { isRowLocked, isHalfLockedByAbsence, isEveningLockedByAbsence, + hasContractAtSelectedDate, isValidationPending, + isSiteValidationPending, canToggleValidation, + canToggleSiteValidation, + validatableEmployeeIds, isBulkValidationChecked, isBulkValidationIndeterminate, toggleValidation, + toggleSiteValidation, toggleValidationBulk, getRowMetrics, getRowAbsenceLabel, diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue index 9eefa5d..95aa8b9 100644 --- a/frontend/pages/hours.vue +++ b/frontend/pages/hours.vue @@ -33,32 +33,38 @@ Aucun employé accessible.
-
-
+
+
@@ -105,6 +111,7 @@