import { computed, onMounted, ref, watch } from 'vue' import type { Employee } from '~/services/dto/employee' import type { Site } from '~/services/dto/site' import type { WorkHour, WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { HourRow } from '~/components/hours/types' import { listScopedEmployees } from '~/services/employees' import { bulkUpsertWorkHours, getWeeklyWorkHourSummary, listWorkHoursByDate, updateWorkHourValidation } from '~/services/work-hours' import { formatDateLongFr, formatWeekDayHeaderFr, formatWeekRangeFr, getOffsetFromTodayYmd, getTodayYmd, parseYmd, shiftYmd } from '~/utils/date' import { sortEmployeesBySiteAndOrder } from '~/utils/employee' export const useHoursPage = () => { const auth = useAuthStore() const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) const viewMode = ref<'day' | 'week'>('day') const selectedDate = ref(getTodayYmd()) const employees = ref([]) const employeeFilter = ref('') const selectedSiteIds = ref([]) const sitesInitialized = ref(false) const rows = ref>({}) const weeklySummary = ref(null) const isLoading = ref(false) const isWeekLoading = ref(false) const isSubmitting = ref(false) const validatingRowIds = ref([]) const dayGridCols = computed(() => { const metricCol = '0.5fr' const cols = `1.2fr repeat(6, 1fr) ${metricCol} ${metricCol} ${metricCol} ${metricCol}` return isAdmin.value ? `${cols} ${metricCol}` : cols }) const weekGridCols = '1.6fr repeat(7, 1fr) 1fr 0.8fr 0.8fr 0.8fr' const sites = computed(() => { const siteMap = new Map() for (const employee of employees.value) { if (employee.site) { siteMap.set(employee.site.id, employee.site) } } return Array.from(siteMap.values()).sort((siteA, siteB) => { const orderA = siteA.displayOrder ?? 0 const orderB = siteB.displayOrder ?? 0 if (orderA !== orderB) return orderA - orderB return siteA.name.localeCompare(siteB.name, 'fr') }) }) const visibleEmployees = computed(() => { if (selectedSiteIds.value.length === 0) return [] const filter = employeeFilter.value.trim().toLowerCase() return employees.value.filter((employee) => { const siteId = employee.site?.id if (!siteId || !selectedSiteIds.value.includes(siteId)) return false if (!filter) return true const firstName = employee.firstName?.toLowerCase() ?? '' const lastName = employee.lastName?.toLowerCase() ?? '' return firstName.includes(filter) || lastName.includes(filter) }) }) const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id))) const filteredWeeklySummary = computed(() => { if (!weeklySummary.value) return null return { ...weeklySummary.value, rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId)) } }) const saveButtonClass = computed(() => { if (isSubmitting.value || employees.value.length === 0) { return 'opacity-50 cursor-not-allowed' } return '' }) const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId) const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => { const targetDate = target === 'yesterday' ? getOffsetFromTodayYmd(-1) : target === 'tomorrow' ? getOffsetFromTodayYmd(1) : getTodayYmd() if (selectedDate.value === targetDate) { return 'bg-primary-500 text-white' } return 'bg-white text-primary-500 hover:bg-tertiary-500' } const formattedSelectedDate = computed(() => { const parsed = parseYmd(selectedDate.value) if (!parsed) return selectedDate.value if (viewMode.value === 'week') { return formatWeekRangeFr(parsed) } return formatDateLongFr(parsed) }) const weekDayHeaders = computed(() => { const days = weeklySummary.value?.days ?? [] return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) })) }) const shiftDate = (steps: number) => { const offset = viewMode.value === 'week' ? (steps * 7) : steps const next = shiftYmd(selectedDate.value, offset) if (!next) return selectedDate.value = next } const setToday = () => { selectedDate.value = getTodayYmd() } const setYesterday = () => { setToday() shiftDate(-1) } const setTomorrow = () => { setToday() shiftDate(1) } const emptyRow = (): HourRow => ({ workHourId: null, morningFrom: '', morningTo: '', afternoonFrom: '', afternoonTo: '', eveningFrom: '', eveningTo: '', isPresentMorning: false, isPresentAfternoon: false, isValid: false }) const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === 'PRESENCE' const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee) const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false const contractLabel = (employee: Employee) => { const contract = employee.contract if (!contract) return '-' if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === 'TIME') { return `${contract.weeklyHours}h` } return contract.name } const normalizeTime = (value: string): string | null => { const trimmed = value.trim() return trimmed === '' ? null : trimmed } const toMinutes = (time: string | null | undefined): number | null => { if (!time) return null const [hours, minutes] = time.split(':').map(Number) if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null return (hours * 60) + minutes } const resolveInterval = (from: string | null | undefined, to: string | null | undefined): [number, number] | null => { const fromMinutes = toMinutes(from) const toMinutesValue = toMinutes(to) if (fromMinutes === null || toMinutesValue === null) return null const end = toMinutesValue <= fromMinutes ? toMinutesValue + 1440 : toMinutesValue return [fromMinutes, end] } const intervalMinutes = (from: string | null | undefined, to: string | null | undefined): number => { const interval = resolveInterval(from, to) if (!interval) return 0 const [start, end] = interval return Math.max(0, end - start) } const overlap = (startA: number, endA: number, startB: number, endB: number): number => { const start = Math.max(startA, startB) const end = Math.min(endA, endB) return Math.max(0, end - start) } const nightIntervalMinutes = (from: string | null | undefined, to: string | null | undefined): number => { const interval = resolveInterval(from, to) if (!interval) return 0 const [start, end] = interval const nightWindows: Array<[number, number]> = [ [0, 360], [1260, 1440] ] let total = 0 for (let dayOffset = 0; dayOffset <= 1; dayOffset++) { const shift = dayOffset * 1440 for (const [nightStart, nightEnd] of nightWindows) { total += overlap(start, end, nightStart + shift, nightEnd + shift) } } return total } const getRowMetrics = (employeeId: number) => { const row = rows.value[employeeId] ?? emptyRow() const ranges = [ [row.morningFrom, row.morningTo], [row.afternoonFrom, row.afternoonTo], [row.eveningFrom, row.eveningTo] ] as const let totalMinutes = 0 let nightMinutes = 0 for (const [from, to] of ranges) { totalMinutes += intervalMinutes(from, to) nightMinutes += nightIntervalMinutes(from, to) } const dayMinutes = Math.max(0, totalMinutes - nightMinutes) return { dayMinutes, nightMinutes, totalMinutes } } const formatMinutes = (minutes: number) => { const safeMinutes = Math.max(0, minutes) const hours = Math.floor(safeMinutes / 60) const rest = safeMinutes % 60 return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}` } const hydrateRows = (workHours: WorkHour[]) => { const byEmployeeId = new Map() for (const workHour of workHours) { byEmployeeId.set(workHour.employee.id, workHour) } const nextRows: Record = {} for (const employee of employees.value) { const workHour = byEmployeeId.get(employee.id) nextRows[employee.id] = { workHourId: workHour?.id ?? null, morningFrom: workHour?.morningFrom ?? '', morningTo: workHour?.morningTo ?? '', afternoonFrom: workHour?.afternoonFrom ?? '', afternoonTo: workHour?.afternoonTo ?? '', eveningFrom: workHour?.eveningFrom ?? '', eveningTo: workHour?.eveningTo ?? '', isPresentMorning: workHour?.isPresentMorning ?? false, isPresentAfternoon: workHour?.isPresentAfternoon ?? false, isValid: workHour?.isValid ?? false } } rows.value = nextRows } const toggleValidation = async (employeeId: number, checked: boolean) => { const row = rows.value[employeeId] if (!row?.workHourId || isValidationPending(employeeId)) return validatingRowIds.value = [...validatingRowIds.value, employeeId] try { await updateWorkHourValidation(row.workHourId, checked) row.isValid = checked } finally { validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId) } } const loadEmployees = async () => { const scopedEmployees = await listScopedEmployees() employees.value = sortEmployeesBySiteAndOrder(scopedEmployees) } const loadWorkHours = async () => { const workHours = await listWorkHoursByDate(selectedDate.value) hydrateRows(workHours) } const loadWeeklySummary = async () => { isWeekLoading.value = true try { weeklySummary.value = await getWeeklyWorkHourSummary(selectedDate.value) } finally { isWeekLoading.value = false } } const refreshByDate = async () => { if (isAdmin.value) { await Promise.all([loadWorkHours(), loadWeeklySummary()]) return } weeklySummary.value = null await loadWorkHours() } const loadPage = async () => { isLoading.value = true try { await loadEmployees() await refreshByDate() } finally { isLoading.value = false } } onMounted(loadPage) watch(sites, (nextSites) => { const currentSiteIds = nextSites.map((site) => site.id) if (!sitesInitialized.value) { if (currentSiteIds.length === 0) return selectedSiteIds.value = currentSiteIds sitesInitialized.value = true return } selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId)) }, { immediate: true }) watch(isAdmin, (admin) => { if (!admin) { viewMode.value = 'day' weeklySummary.value = null } }, { immediate: true }) watch(selectedDate, async () => { await refreshByDate() }) const handleSave = async () => { if (isSubmitting.value || employees.value.length === 0) return isSubmitting.value = true try { const entries = employees.value.map((employee) => { const employeeId = employee.id const row = rows.value[employeeId] ?? emptyRow() if (isPresenceTracking(employee)) { return { employeeId, morningFrom: null, morningTo: null, afternoonFrom: null, afternoonTo: null, eveningFrom: null, eveningTo: null, isPresentMorning: row.isPresentMorning, isPresentAfternoon: row.isPresentAfternoon } } return { employeeId, morningFrom: normalizeTime(row.morningFrom), morningTo: normalizeTime(row.morningTo), afternoonFrom: normalizeTime(row.afternoonFrom), afternoonTo: normalizeTime(row.afternoonTo), eveningFrom: normalizeTime(row.eveningFrom), eveningTo: normalizeTime(row.eveningTo), isPresentMorning: false, isPresentAfternoon: false } }) await bulkUpsertWorkHours({ workDate: selectedDate.value, entries }) await refreshByDate() } finally { isSubmitting.value = false } } return { isAdmin, viewMode, selectedDate, employeeFilter, sites, selectedSiteIds, employees, visibleEmployees, rows, weeklySummary, filteredWeeklySummary, isLoading, isWeekLoading, isSubmitting, dayGridCols, weekGridCols, saveButtonClass, formattedSelectedDate, weekDayHeaders, shortcutButtonClass, setToday, setYesterday, setTomorrow, shiftDate, contractLabel, isTimeTracking, isPresenceTracking, isRowLocked, isValidationPending, toggleValidation, getRowMetrics, formatMinutes, handleSave } }