All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | #322 | Page horaire | ## Description de la PR [#322] Page horaire ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #4 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
804 lines
24 KiB
TypeScript
804 lines
24 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, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
|
import type { Absence } from '~/services/dto/absence'
|
|
import type { HalfDay } from '~/services/dto/half-day'
|
|
import { CONTRACT_TYPES, TRACKING_MODES } from '~/services/dto/contract'
|
|
import type { HourRow } from '~/components/hours/types'
|
|
import { listScopedEmployees } from '~/services/employees'
|
|
import { listAbsenceTypes } from '~/services/absence-types'
|
|
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
|
import {
|
|
bulkUpsertWorkHours,
|
|
getWorkHourDayContext,
|
|
getWeeklyWorkHourSummary,
|
|
listWorkHoursByDate,
|
|
updateWorkHourValidation
|
|
} from '~/services/work-hours'
|
|
import {
|
|
formatDateLongFr,
|
|
formatWeekDayHeaderFr,
|
|
formatWeekRangeFr,
|
|
getIsoWeekNumber,
|
|
getOffsetFromTodayYmd,
|
|
getWeekStartDate,
|
|
getTodayYmd,
|
|
parseYmd,
|
|
shiftYmd
|
|
} from '~/utils/date'
|
|
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
|
|
|
export const useHoursPage = () => {
|
|
const auth = useAuthStore()
|
|
const toast = useToast()
|
|
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 dayContext = ref<WorkHourDayContext | null>(null)
|
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
|
const absenceTypes = ref<AbsenceType[]>([])
|
|
const absences = ref<Absence[]>([])
|
|
const isAbsenceDrawerOpen = ref(false)
|
|
const isAbsenceSubmitting = ref(false)
|
|
const editingAbsence = ref<Absence | null>(null)
|
|
const absenceForm = ref({
|
|
employeeId: '' as number | '',
|
|
typeId: '' as number | '',
|
|
startDate: '',
|
|
startHalf: 'AM' as HalfDay,
|
|
endDate: '',
|
|
endHalf: 'PM' as HalfDay,
|
|
comment: ''
|
|
})
|
|
const isLoading = ref(false)
|
|
const isWeekLoading = ref(false)
|
|
const isSubmitting = ref(false)
|
|
const validatingRowIds = ref<number[]>([])
|
|
|
|
const dayGridCols = computed(() => {
|
|
const metricCol = '0.4fr'
|
|
return `1.2fr repeat(6, 1fr) 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
|
|
})
|
|
|
|
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
|
|
|
|
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 canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
|
|
|
|
const validatableEmployeeIds = computed(() => {
|
|
return employees.value
|
|
.map((employee) => employee.id)
|
|
.filter((employeeId) => canToggleValidation(employeeId))
|
|
})
|
|
|
|
const isBulkValidationChecked = computed(() => {
|
|
const ids = validatableEmployeeIds.value
|
|
if (ids.length === 0) return false
|
|
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
|
|
})
|
|
|
|
const isBulkValidationIndeterminate = computed(() => {
|
|
const ids = validatableEmployeeIds.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 dayContextByEmployeeId = computed(() => {
|
|
const map = new Map<number, WorkHourDayContext['rows'][number]>()
|
|
for (const row of dayContext.value?.rows ?? []) {
|
|
map.set(row.employeeId, row)
|
|
}
|
|
return map
|
|
})
|
|
|
|
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 weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
|
const selected = parseYmd(selectedDate.value)
|
|
if (!selected) {
|
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
|
}
|
|
|
|
const today = new Date()
|
|
const targetDate = new Date(today)
|
|
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
|
|
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
|
|
|
|
const selectedWeekStart = getWeekStartDate(selected)
|
|
const targetWeekStart = getWeekStartDate(targetDate)
|
|
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
|
|
|
|
if (isActive) {
|
|
return 'bg-primary-500 text-white'
|
|
}
|
|
|
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
|
}
|
|
|
|
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
|
const today = new Date()
|
|
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
|
|
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
|
|
|
|
const weekNumber = getIsoWeekNumber(today)
|
|
return `Sem. S${weekNumber}`
|
|
}
|
|
|
|
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 setThisWeek = () => {
|
|
selectedDate.value = getTodayYmd()
|
|
}
|
|
|
|
const setPreviousWeek = () => {
|
|
const previousWeek = shiftYmd(getTodayYmd(), -7)
|
|
if (!previousWeek) return
|
|
selectedDate.value = previousWeek
|
|
}
|
|
|
|
const setNextWeek = () => {
|
|
const nextWeek = shiftYmd(getTodayYmd(), 7)
|
|
if (!nextWeek) return
|
|
selectedDate.value = nextWeek
|
|
}
|
|
|
|
const resetAbsenceForm = () => {
|
|
absenceForm.value = {
|
|
employeeId: '',
|
|
typeId: '',
|
|
startDate: '',
|
|
startHalf: 'AM',
|
|
endDate: '',
|
|
endHalf: 'PM',
|
|
comment: ''
|
|
}
|
|
}
|
|
|
|
const closeAbsenceDrawer = () => {
|
|
isAbsenceDrawerOpen.value = false
|
|
editingAbsence.value = null
|
|
resetAbsenceForm()
|
|
}
|
|
|
|
const emptyRow = (): HourRow => ({
|
|
workHourId: null,
|
|
morningFrom: '',
|
|
morningTo: '',
|
|
afternoonFrom: '',
|
|
afternoonTo: '',
|
|
eveningFrom: '',
|
|
eveningTo: '',
|
|
isPresentMorning: false,
|
|
isPresentAfternoon: 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 contractLabel = (employee: Employee) => {
|
|
const contract = employee.contract
|
|
if (!contract) return '-'
|
|
if (contract.type === CONTRACT_TYPES.INTERIM) {
|
|
return contract.name
|
|
}
|
|
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.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 creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
|
|
totalMinutes += creditedMinutes
|
|
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
|
|
return { dayMinutes, nightMinutes, totalMinutes }
|
|
}
|
|
|
|
const getRowAbsenceLabel = (employeeId: number) => {
|
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
if (!dayRow?.absenceLabel) return ''
|
|
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
|
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
|
return `${dayRow.absenceLabel} (${halfLabel})`
|
|
}
|
|
return `${dayRow.absenceLabel} (journée)`
|
|
}
|
|
|
|
const getPresenceDayValue = (employeeId: number) => {
|
|
const row = rows.value[employeeId]
|
|
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
|
|
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
|
|
const total = Math.min(1, basePresence + creditedPresence)
|
|
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
|
}
|
|
|
|
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
|
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
if (!dayRow) return false
|
|
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
|
|
}
|
|
|
|
const isEveningLockedByAbsence = (employeeId: number) => {
|
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
if (!dayRow) return false
|
|
return dayRow.absentAfternoon
|
|
}
|
|
|
|
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 loadAbsenceTypes = async () => {
|
|
absenceTypes.value = await listAbsenceTypes()
|
|
}
|
|
|
|
const loadAbsences = async () => {
|
|
absences.value = await listAbsences({
|
|
from: selectedDate.value,
|
|
to: selectedDate.value,
|
|
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
|
|
})
|
|
}
|
|
|
|
const openAbsenceDrawer = (employeeId: number) => {
|
|
const existing = absences.value.find((absence) => {
|
|
if (absence.employee?.id !== employeeId) return false
|
|
const start = absence.startDate.slice(0, 10)
|
|
const end = absence.endDate.slice(0, 10)
|
|
return selectedDate.value >= start && selectedDate.value <= end
|
|
}) ?? null
|
|
|
|
if (existing) {
|
|
editingAbsence.value = existing
|
|
absenceForm.value = {
|
|
employeeId,
|
|
typeId: existing.type?.id ?? '',
|
|
startDate: existing.startDate.slice(0, 10),
|
|
startHalf: existing.startHalf ?? 'AM',
|
|
endDate: existing.endDate.slice(0, 10),
|
|
endHalf: existing.endHalf ?? 'PM',
|
|
comment: existing.comment ?? ''
|
|
}
|
|
} else {
|
|
editingAbsence.value = null
|
|
absenceForm.value = {
|
|
employeeId,
|
|
typeId: '',
|
|
startDate: selectedDate.value,
|
|
startHalf: 'AM',
|
|
endDate: selectedDate.value,
|
|
endHalf: 'PM',
|
|
comment: ''
|
|
}
|
|
}
|
|
|
|
isAbsenceDrawerOpen.value = true
|
|
}
|
|
|
|
const applyLocalClearFromAbsence = (employeeId: number, startHalf: HalfDay, endHalf: HalfDay) => {
|
|
const row = rows.value[employeeId]
|
|
if (!row) return
|
|
|
|
if (startHalf === 'AM' && endHalf === 'AM') {
|
|
row.morningFrom = ''
|
|
row.morningTo = ''
|
|
return
|
|
}
|
|
|
|
if (startHalf === 'PM' && endHalf === 'PM') {
|
|
row.afternoonFrom = ''
|
|
row.afternoonTo = ''
|
|
row.eveningFrom = ''
|
|
row.eveningTo = ''
|
|
return
|
|
}
|
|
|
|
row.morningFrom = ''
|
|
row.morningTo = ''
|
|
row.afternoonFrom = ''
|
|
row.afternoonTo = ''
|
|
row.eveningFrom = ''
|
|
row.eveningTo = ''
|
|
}
|
|
|
|
const refreshAfterAbsenceChange = async () => {
|
|
if (isAdmin.value) {
|
|
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
|
return
|
|
}
|
|
|
|
weeklySummary.value = null
|
|
await Promise.all([loadDayContext(), loadAbsences()])
|
|
}
|
|
|
|
const submitAbsence = async () => {
|
|
const form = absenceForm.value
|
|
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
|
|
|
|
isAbsenceSubmitting.value = true
|
|
try {
|
|
if (editingAbsence.value) {
|
|
await updateAbsence({
|
|
id: editingAbsence.value.id,
|
|
employeeId: Number(form.employeeId),
|
|
typeId: Number(form.typeId),
|
|
startDate: form.startDate,
|
|
startHalf: form.startHalf,
|
|
endDate: form.endDate,
|
|
endHalf: form.endHalf,
|
|
comment: editingAbsence.value.comment ?? ''
|
|
})
|
|
} else {
|
|
await createAbsence({
|
|
employeeId: Number(form.employeeId),
|
|
typeId: Number(form.typeId),
|
|
startDate: form.startDate,
|
|
startHalf: form.startHalf,
|
|
endDate: form.endDate,
|
|
endHalf: form.endHalf,
|
|
comment: ''
|
|
})
|
|
}
|
|
|
|
applyLocalClearFromAbsence(Number(form.employeeId), form.startHalf, form.endHalf)
|
|
closeAbsenceDrawer()
|
|
await refreshAfterAbsenceChange()
|
|
} finally {
|
|
isAbsenceSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
const deleteAbsenceFromDrawer = async () => {
|
|
if (!editingAbsence.value || isAbsenceSubmitting.value) return
|
|
|
|
isAbsenceSubmitting.value = true
|
|
try {
|
|
await deleteAbsence(editingAbsence.value.id)
|
|
closeAbsenceDrawer()
|
|
await refreshAfterAbsenceChange()
|
|
} finally {
|
|
isAbsenceSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
const toggleValidation = async (
|
|
employeeId: number,
|
|
checked: boolean,
|
|
options: { toast?: boolean } = {}
|
|
) => {
|
|
const row = rows.value[employeeId]
|
|
if (!row?.workHourId || isValidationPending(employeeId)) return
|
|
|
|
validatingRowIds.value = [...validatingRowIds.value, employeeId]
|
|
try {
|
|
await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast })
|
|
row.isValid = checked
|
|
} finally {
|
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
|
}
|
|
}
|
|
|
|
const toggleValidationBulk = async (checked: boolean) => {
|
|
const employeeIds = validatableEmployeeIds.value
|
|
if (employeeIds.length === 0) return
|
|
|
|
let successCount = 0
|
|
let failedCount = 0
|
|
|
|
for (const employeeId of employeeIds) {
|
|
if (isValidationPending(employeeId)) continue
|
|
try {
|
|
await toggleValidation(employeeId, checked, { toast: false })
|
|
successCount += 1
|
|
} catch {
|
|
failedCount += 1
|
|
}
|
|
}
|
|
|
|
if (failedCount === 0) {
|
|
toast.success({
|
|
title: 'Succès',
|
|
message: checked
|
|
? `${successCount} ligne(s) validée(s).`
|
|
: `${successCount} validation(s) retirée(s).`
|
|
})
|
|
return
|
|
}
|
|
|
|
if (successCount === 0) {
|
|
toast.error({
|
|
title: 'Erreur',
|
|
message: 'Impossible de mettre à jour les validations.'
|
|
})
|
|
return
|
|
}
|
|
|
|
toast.error({
|
|
title: 'Erreur',
|
|
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
|
|
})
|
|
}
|
|
|
|
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 loadDayContext = async () => {
|
|
dayContext.value = await getWorkHourDayContext(selectedDate.value)
|
|
}
|
|
|
|
const refreshByDate = async () => {
|
|
if (isAdmin.value) {
|
|
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
|
return
|
|
}
|
|
|
|
weeklySummary.value = null
|
|
await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
|
|
}
|
|
|
|
const loadPage = async () => {
|
|
isLoading.value = true
|
|
try {
|
|
await loadEmployees()
|
|
await loadAbsenceTypes()
|
|
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, async (admin) => {
|
|
if (!admin) {
|
|
viewMode.value = 'day'
|
|
weeklySummary.value = null
|
|
await Promise.all([loadAbsenceTypes(), loadAbsences()])
|
|
return
|
|
}
|
|
await loadAbsenceTypes()
|
|
await loadAbsences()
|
|
}, { 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,
|
|
absenceTypes,
|
|
absenceForm,
|
|
isAbsenceDrawerOpen,
|
|
isAbsenceSubmitting,
|
|
editingAbsence,
|
|
weeklySummary,
|
|
filteredWeeklySummary,
|
|
isLoading,
|
|
isWeekLoading,
|
|
isSubmitting,
|
|
dayGridCols,
|
|
weekGridCols,
|
|
saveButtonClass,
|
|
formattedSelectedDate,
|
|
weekDayHeaders,
|
|
shortcutButtonClass,
|
|
weekShortcutButtonClass,
|
|
getWeekShortcutLabel,
|
|
setToday,
|
|
setYesterday,
|
|
setTomorrow,
|
|
setThisWeek,
|
|
setPreviousWeek,
|
|
setNextWeek,
|
|
shiftDate,
|
|
contractLabel,
|
|
isTimeTracking,
|
|
isPresenceTracking,
|
|
isRowLocked,
|
|
isHalfLockedByAbsence,
|
|
isEveningLockedByAbsence,
|
|
isValidationPending,
|
|
canToggleValidation,
|
|
isBulkValidationChecked,
|
|
isBulkValidationIndeterminate,
|
|
toggleValidation,
|
|
toggleValidationBulk,
|
|
getRowMetrics,
|
|
getRowAbsenceLabel,
|
|
getPresenceDayValue,
|
|
openAbsenceDrawer,
|
|
submitAbsence,
|
|
deleteAbsenceFromDrawer,
|
|
closeAbsenceDrawer,
|
|
formatMinutes,
|
|
handleSave
|
|
}
|
|
}
|