1177 lines
36 KiB
TypeScript
1177 lines
36 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 { listPublicHolidays } from '~/services/public-holidays'
|
|
import {
|
|
bulkUpdateWorkHourSiteValidation,
|
|
bulkUpdateWorkHourValidation,
|
|
bulkUpsertWorkHours,
|
|
getWorkHourDayContext,
|
|
getWeeklyWorkHourSummary,
|
|
listWorkHoursByDate,
|
|
updateWorkHourSiteValidation,
|
|
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 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())
|
|
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 publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
|
|
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 siteValidatingRowIds = ref<number[]>([])
|
|
|
|
const dayGridCols = computed(() => {
|
|
const metricCol = '0.4fr'
|
|
const validationCols = isAdmin.value || isSiteManager.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) 0.3fr'
|
|
|
|
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) => {
|
|
if (employee.isDriver === true) return false
|
|
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 displayedEmployees = computed(() => {
|
|
return visibleEmployees.value.filter((employee) => hasContractAtSelectedDate(employee.id))
|
|
})
|
|
|
|
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) && row.hasContractForWeek !== false
|
|
)
|
|
}
|
|
})
|
|
|
|
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 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 canCreateEmptyValidationRow = (employeeId: number) => {
|
|
const row = rows.value[employeeId]
|
|
if (row?.workHourId) return false
|
|
if (!hasContractAtSelectedDate(employeeId)) return false
|
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
return !!dayRow?.absenceLabel || is4hContract(employeeId)
|
|
}
|
|
|
|
const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
|
|
|
|
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
|
|
|
|
const bulkValidatableEmployeeIds = computed(() => {
|
|
return visibleEmployees.value
|
|
.map((employee) => employee.id)
|
|
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
|
|
})
|
|
|
|
const isBulkValidationChecked = computed(() => {
|
|
const ids = bulkValidatableEmployeeIds.value
|
|
if (ids.length === 0) return false
|
|
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
|
|
})
|
|
|
|
const isBulkValidationIndeterminate = computed(() => {
|
|
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<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 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) }))
|
|
})
|
|
|
|
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,
|
|
isSiteValid: false,
|
|
isValid: false,
|
|
updatedAt: null
|
|
})
|
|
|
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
|
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
|
const is4hContract = (employeeId: number) => {
|
|
const employee = employees.value.find((e) => e.id === employeeId)
|
|
return employee?.contract?.weeklyHours === 4
|
|
}
|
|
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
|
|
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 && dayRow.hasContractAtDate === false) {
|
|
return 'Contrat non démarré'
|
|
}
|
|
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 getRowAbsenceStyle = (employeeId: number) => {
|
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
if (dayRow && dayRow.hasContractAtDate === false) {
|
|
return { backgroundColor: '#6b7280' }
|
|
}
|
|
if (!dayRow?.absenceLabel) return undefined
|
|
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
|
|
}
|
|
|
|
const hasRowFormation = (employeeId: number): boolean => {
|
|
return dayContextByEmployeeId.value.get(employeeId)?.hasFormation === true
|
|
}
|
|
|
|
const getRowFormationLabel = (employeeId: number): string => {
|
|
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
|
|
}
|
|
|
|
const getRowUpdatedAt = (employeeId: number): string => {
|
|
const raw = rows.value[employeeId]?.updatedAt
|
|
if (!raw) return ''
|
|
const date = new Date(raw)
|
|
if (Number.isNaN(date.getTime())) return ''
|
|
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
const getPresenceDayValue = (employeeId: number) => {
|
|
const row = rows.value[employeeId]
|
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
const absentMorning = dayRow?.absentMorning ?? false
|
|
const absentAfternoon = dayRow?.absentAfternoon ?? false
|
|
const basePresence = ((row?.isPresentMorning && !absentMorning) ? 0.5 : 0) + ((row?.isPresentAfternoon && !absentAfternoon) ? 0.5 : 0)
|
|
const creditedPresence = dayRow?.creditedPresenceUnits ?? 0
|
|
const total = Math.min(1, basePresence + creditedPresence)
|
|
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
|
|
}
|
|
|
|
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,
|
|
isSiteValid: workHour?.isSiteValid ?? false,
|
|
isValid: workHour?.isValid ?? false,
|
|
updatedAt: workHour?.updatedAt ?? null
|
|
}
|
|
}
|
|
|
|
rows.value = nextRows
|
|
}
|
|
|
|
const loadAbsenceTypes = async () => {
|
|
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,
|
|
to: selectedDate.value,
|
|
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
|
|
})
|
|
}
|
|
|
|
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)
|
|
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 && checked) {
|
|
if (canCreateEmptyValidationRow(employeeId)) {
|
|
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(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]
|
|
if (!row?.workHourId && checked) {
|
|
if (canCreateEmptyValidationRow(employeeId)) {
|
|
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 = bulkValidatableEmployeeIds.value
|
|
if (employeeIds.length === 0) return
|
|
|
|
const pendingIds = new Set(validatingRowIds.value)
|
|
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
|
|
if (availableEmployeeIds.length === 0) return
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 () => {
|
|
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 loadPublicHolidaysForSelectedYear()
|
|
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 loadPublicHolidaysForSelectedYear()
|
|
await refreshByDate()
|
|
})
|
|
|
|
const handleSave = async () => {
|
|
if (isSubmitting.value || employees.value.length === 0) return
|
|
|
|
isSubmitting.value = true
|
|
try {
|
|
const entries = employees.value
|
|
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
|
.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
|
|
}
|
|
})
|
|
|
|
if (entries.length === 0) {
|
|
return
|
|
}
|
|
|
|
await bulkUpsertWorkHours({
|
|
workDate: selectedDate.value,
|
|
entries
|
|
})
|
|
|
|
await refreshByDate()
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
isAdmin,
|
|
isSelfUser,
|
|
isSiteManager,
|
|
viewMode,
|
|
selectedDate,
|
|
employeeFilter,
|
|
sites,
|
|
selectedSiteIds,
|
|
employees,
|
|
visibleEmployees,
|
|
displayedEmployees,
|
|
rows,
|
|
absenceTypes,
|
|
absenceForm,
|
|
isAbsenceDrawerOpen,
|
|
isAbsenceSubmitting,
|
|
editingAbsence,
|
|
weeklySummary,
|
|
filteredWeeklySummary,
|
|
isLoading,
|
|
isWeekLoading,
|
|
isSubmitting,
|
|
dayGridCols,
|
|
weekGridCols,
|
|
saveButtonClass,
|
|
formattedSelectedDate,
|
|
isSelectedDateHoliday,
|
|
selectedHolidayLabel,
|
|
weekDayHeaders,
|
|
shortcutButtonClass,
|
|
weekShortcutButtonClass,
|
|
getWeekShortcutLabel,
|
|
setToday,
|
|
setYesterday,
|
|
setTomorrow,
|
|
setThisWeek,
|
|
setPreviousWeek,
|
|
setNextWeek,
|
|
shiftDate,
|
|
contractLabel,
|
|
isTimeTracking,
|
|
isPresenceTracking,
|
|
isRowLocked,
|
|
isHalfLockedByAbsence,
|
|
isEveningLockedByAbsence,
|
|
hasContractAtSelectedDate,
|
|
isValidationPending,
|
|
isSiteValidationPending,
|
|
canToggleValidation,
|
|
canToggleSiteValidation,
|
|
canCreateSiteValidationRowFromAbsence,
|
|
isBulkValidationChecked,
|
|
isBulkValidationIndeterminate,
|
|
isBulkSiteValidationChecked,
|
|
isBulkSiteValidationIndeterminate,
|
|
canBulkToggleSiteValidation,
|
|
toggleValidation,
|
|
toggleSiteValidation,
|
|
toggleValidationBulk,
|
|
toggleSiteValidationBulk,
|
|
getRowMetrics,
|
|
getRowAbsenceLabel,
|
|
getRowAbsenceStyle,
|
|
hasRowFormation,
|
|
getRowFormationLabel,
|
|
getRowUpdatedAt,
|
|
getPresenceDayValue,
|
|
openAbsenceDrawer,
|
|
submitAbsence,
|
|
deleteAbsenceFromDrawer,
|
|
closeAbsenceDrawer,
|
|
formatMinutes,
|
|
handleSave
|
|
}
|
|
}
|