443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
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<Employee[]>([])
|
|
const employeeFilter = ref('')
|
|
const selectedSiteIds = ref<number[]>([])
|
|
const sitesInitialized = ref(false)
|
|
const rows = ref<Record<number, HourRow>>({})
|
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
|
const isLoading = ref(false)
|
|
const isWeekLoading = ref(false)
|
|
const isSubmitting = ref(false)
|
|
const validatingRowIds = ref<number[]>([])
|
|
|
|
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<Site[]>(() => {
|
|
const siteMap = new Map<number, Site>()
|
|
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<WeeklyWorkHourSummary | null>(() => {
|
|
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<number, WorkHour>()
|
|
for (const workHour of workHours) {
|
|
byEmployeeId.set(workHour.employee.id, workHour)
|
|
}
|
|
|
|
const nextRows: Record<number, HourRow> = {}
|
|
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
|
|
}
|
|
}
|