5bec2c38cf
L'écran Heures / Heures Conducteurs envoyait au bulk-upsert une entrée pour tous les employés visibles non verrouillés, à partir de l'état en mémoire. Le backend traitant une entrée vide comme une suppression, un admin avec une grille périmée pouvait supprimer une ligne saisie entre-temps par un autre utilisateur (ex. ROLE_SELF non encore validé, donc non verrouillé). handleSave capture désormais un instantané des lignes chargées (loadedRows, dans hydrateRows) et ne transmet que les lignes dont l'état courant en diffère. Une ligne intouchée n'est jamais envoyée → jamais supprimée. Symétrique dans useHoursPage.ts et useDriverHoursPage.ts. Doc : doc/hours-save-dirty-tracking.md + note CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1304 lines
43 KiB
TypeScript
1304 lines
43 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,
|
|
getWorkHourValidationStatus,
|
|
getWeeklyWorkHourSummary,
|
|
listWorkHoursByDate,
|
|
updateWorkHourSiteValidation,
|
|
updateWorkHourValidation
|
|
} from '~/services/work-hours'
|
|
import {
|
|
formatDateLongFr,
|
|
formatWeekDayHeaderFr,
|
|
formatWeekRangeFr,
|
|
getIsoWeekNumber,
|
|
getOffsetFromTodayYmd,
|
|
getWeekStartDate,
|
|
getTodayYmd,
|
|
parseYmd,
|
|
shiftYmd,
|
|
toYmd
|
|
} 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>>({})
|
|
// Instantané des lignes telles que chargées depuis le serveur (clé = employeeId).
|
|
// Sert à n'envoyer au bulk-upsert que les lignes réellement modifiées par l'utilisateur,
|
|
// afin de ne jamais écraser/supprimer une ligne saisie entre-temps par un autre utilisateur
|
|
// (perte de données par enregistrement « à l'aveugle » d'une grille périmée).
|
|
const loadedRows = 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[]>([])
|
|
// Jours entièrement validés (admin) par mois civil affiché dans le calendrier
|
|
// de la vue Jour. Clé = 'YYYY-MM', valeur = liste de dates Y-m-d. Chargé à la
|
|
// volée sur @month-change (jamais préchargé sur plusieurs années).
|
|
const validatedDaysByMonth = ref<Record<string, string[]>>({})
|
|
|
|
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
|
|
})
|
|
|
|
// Résout le contrat à la date affichée (ligne du jour), avec repli sur le contrat courant.
|
|
const resolveDayContract = (employee: Employee) => {
|
|
const dayRow = dayContextByEmployeeId.value.get(employee.id)
|
|
if (dayRow?.hasContractAtDate) {
|
|
return {
|
|
trackingMode: dayRow.trackingMode ?? null,
|
|
weeklyHours: dayRow.weeklyHours ?? null,
|
|
type: dayRow.contractType ?? null,
|
|
name: dayRow.contractName ?? ''
|
|
}
|
|
}
|
|
return {
|
|
trackingMode: employee.contract?.trackingMode ?? null,
|
|
weeklyHours: employee.contract?.weeklyHours ?? null,
|
|
type: employee.contract?.type ?? null,
|
|
name: employee.contract?.name ?? ''
|
|
}
|
|
}
|
|
|
|
const isPresenceTracking = (employee: Employee) => resolveDayContract(employee).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 ? resolveDayContract(employee).weeklyHours === 4 : 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 = resolveDayContract(employee)
|
|
if (!contract.type && !contract.name) 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 dayRow = dayContextByEmployeeId.value.get(employeeId)
|
|
const creditedMinutes = dayRow?.creditedMinutes ?? 0
|
|
totalMinutes += creditedMinutes
|
|
let dayMinutes = Math.max(0, totalMinutes - nightMinutes)
|
|
|
|
// Virtual holiday credit: the backend already applies the contract-period
|
|
// schedule (workDaysHours) and the absence-override rule, so just use the
|
|
// computed value instead of recomputing on the client.
|
|
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
|
|
if (virtualHolidayMinutes > totalMinutes) {
|
|
dayMinutes += virtualHolidayMinutes - totalMinutes
|
|
totalMinutes = virtualHolidayMinutes
|
|
}
|
|
|
|
return { dayMinutes, nightMinutes, totalMinutes, virtualHolidayMinutes }
|
|
}
|
|
|
|
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 getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
|
|
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
|
|
}
|
|
|
|
// Jours travaillés du planning (contrats CUSTOM uniquement), ex. "LU,VE". null sinon.
|
|
const getRowWorkedDaysLabel = (employeeId: number): string | null => {
|
|
return formatWorkedDaysShort(dayContextByEmployeeId.value.get(employeeId)?.workDaysHours)
|
|
}
|
|
|
|
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
|
|
// Clone indépendant : les éditions mutent les objets de rows.value, pas ceux-ci.
|
|
loadedRows.value = Object.fromEntries(
|
|
Object.entries(nextRows).map(([employeeId, row]) => [employeeId, { ...row }])
|
|
)
|
|
}
|
|
|
|
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
|
|
|
|
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()])
|
|
} else {
|
|
weeklySummary.value = null
|
|
await Promise.all([loadDayContext(), loadAbsences()])
|
|
}
|
|
await reloadValidationMonth(selectedDate.value)
|
|
}
|
|
|
|
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
|
|
await reloadValidationMonth(selectedDate.value)
|
|
} 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()
|
|
await reloadValidationMonth(selectedDate.value)
|
|
|
|
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)
|
|
}
|
|
|
|
// --- Calendrier vue Jour : jours validés en vert ---------------------------
|
|
const monthKey = (year: number, monthIndex: number) => `${year}-${String(monthIndex + 1).padStart(2, '0')}`
|
|
|
|
// Fusionne tous les mois chargés en une seule map ISO → 'success' pour MalioDate.
|
|
const markedDates = computed<Record<string, 'success'>>(() => {
|
|
const map: Record<string, 'success'> = {}
|
|
for (const days of Object.values(validatedDaysByMonth.value)) {
|
|
for (const day of days) map[day] = 'success'
|
|
}
|
|
return map
|
|
})
|
|
|
|
// Charge le statut du mois affiché. La plage couvre toute la grille visible
|
|
// (lundi avant le 1er → dimanche après le dernier jour) pour colorer aussi les
|
|
// jours débordants des mois adjacents.
|
|
const loadValidationMonth = async (monthIndex: number, year: number, options: { force?: boolean } = {}) => {
|
|
const key = monthKey(year, monthIndex)
|
|
if (!options.force && validatedDaysByMonth.value[key]) return
|
|
|
|
const gridStart = getWeekStartDate(new Date(year, monthIndex, 1))
|
|
const gridEnd = getWeekStartDate(new Date(year, monthIndex + 1, 0))
|
|
gridEnd.setDate(gridEnd.getDate() + 6)
|
|
|
|
const from = toYmd(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate())
|
|
const to = toYmd(gridEnd.getFullYear(), gridEnd.getMonth(), gridEnd.getDate())
|
|
const days = await getWorkHourValidationStatus(from, to)
|
|
validatedDaysByMonth.value = { ...validatedDaysByMonth.value, [key]: days }
|
|
}
|
|
|
|
const onCalendarMonthChange = (payload: { month: number; year: number }) => {
|
|
void loadValidationMonth(payload.month, payload.year)
|
|
}
|
|
|
|
// Après une modification qui touche la validation d'un jour (validation,
|
|
// sauvegarde d'heures, absence), recharge le mois concerné s'il est déjà en
|
|
// cache → le calendrier se recolore. Sinon no-op (le prochain affichage fetch).
|
|
const reloadValidationMonth = async (dateYmd: string) => {
|
|
const parsed = parseYmd(dateYmd)
|
|
if (!parsed) return
|
|
const key = monthKey(parsed.getFullYear(), parsed.getMonth())
|
|
if (!validatedDaysByMonth.value[key]) return
|
|
await loadValidationMonth(parsed.getMonth(), parsed.getFullYear(), { force: true })
|
|
}
|
|
|
|
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()
|
|
})
|
|
|
|
// Construit l'entrée bulk-upsert à partir d'une ligne (état courant OU instantané chargé).
|
|
const buildEntry = (employee: Employee, row: HourRow) => {
|
|
const employeeId = employee.id
|
|
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
|
|
}
|
|
}
|
|
|
|
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 current = buildEntry(employee, rows.value[employee.id] ?? emptyRow())
|
|
const original = buildEntry(employee, loadedRows.value[employee.id] ?? emptyRow())
|
|
return { current, original }
|
|
})
|
|
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
|
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
|
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
|
.map(({ current }) => current)
|
|
|
|
if (entries.length === 0) {
|
|
return
|
|
}
|
|
|
|
await bulkUpsertWorkHours({
|
|
workDate: selectedDate.value,
|
|
entries
|
|
})
|
|
|
|
await refreshByDate()
|
|
await reloadValidationMonth(selectedDate.value)
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
const isWeekCommentDrawerOpen = ref(false)
|
|
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
|
|
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
|
|
if (!weeklySummary.value) return
|
|
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
|
|
isWeekCommentDrawerOpen.value = true
|
|
}
|
|
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
|
|
|
|
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,
|
|
getRowContractNature,
|
|
getRowWorkedDaysLabel,
|
|
getRowUpdatedAt,
|
|
getPresenceDayValue,
|
|
openAbsenceDrawer,
|
|
submitAbsence,
|
|
deleteAbsenceFromDrawer,
|
|
closeAbsenceDrawer,
|
|
formatMinutes,
|
|
handleSave,
|
|
markedDates,
|
|
onCalendarMonthChange,
|
|
isWeekCommentDrawerOpen,
|
|
weekCommentContext,
|
|
openWeekCommentDrawer,
|
|
reloadWeeklySummary
|
|
}
|
|
}
|