fix : wip

This commit is contained in:
2026-02-18 17:59:57 +01:00
parent 4256702add
commit c2e118dc33
47 changed files with 2689 additions and 345 deletions

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="min-w-[900px]">
<div class="grid" :style="gridStyle">
<div class="grid" :style="gridStyle" @mouseleave="clearHoveredCell">
<div
class="sticky left-0 top-0 z-30 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
>
@@ -10,16 +10,23 @@
<div
v-for="day in daysInMonth"
:key="day.date"
class="sticky top-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
:class="isHoveredColumn(day.date) ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
>
<div>{{ day.label }}</div>
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
<div
class="text-[10px]"
:class="isHoveredColumn(day.date) ? 'text-white/90' : 'text-neutral-500'"
>
{{ day.weekday }}
</div>
</div>
<template v-for="employee in visibleEmployees" :key="employee.id">
<div
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer"
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer transition-shadow"
:class="isHoveredRow(employee.id) ? 'bg-primary-500 text-white ring-2 ring-inset ring-primary-500/40' : ''"
:style="rowHeaderStyle(employee)"
draggable="true"
@dragstart="handleDragStart($event, employee)"
@dragover="handleDragOver"
@@ -30,12 +37,14 @@
<div
v-for="day in daysInMonth"
:key="employee.id + '-' + day.date"
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 hover:bg-neutral-500"
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 transition-colors"
:class="cellContainerClass(employee.id, day.date)"
@mouseenter="setHoveredCell(employee.id, day.date)"
>
<template v-if="getCellInfo(employee.id, day.date)">
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-white"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@@ -63,7 +72,7 @@
<template v-else>
<button
type="button"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white hover:border-white"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@@ -89,7 +98,7 @@ type DayInfo = {
weekday: string
}
defineProps<{
const props = defineProps<{
daysInMonth: DayInfo[]
visibleEmployees: Employee[]
gridStyle: Record<string, string>
@@ -124,4 +133,56 @@ const handleDrop = (event: DragEvent, employee: Employee) => {
if (!dragId || dragId === employee.id) return
emit('reorder', { dragId, dropId: employee.id })
}
// Etat de la cellule actuellement survolee.
const hoveredEmployeeId = ref<number | null>(null)
const hoveredDate = ref<string | null>(null)
const setHoveredCell = (employeeId: number, date: string) => {
hoveredEmployeeId.value = employeeId
hoveredDate.value = date
}
const clearHoveredCell = () => {
hoveredEmployeeId.value = null
hoveredDate.value = null
}
const isHoveredRow = (employeeId: number) => hoveredEmployeeId.value === employeeId
const isHoveredColumn = (date: string) => hoveredDate.value === date
// On garde la couleur du site tant que la ligne n'est pas survolee.
const rowHeaderStyle = (employee: Employee) => {
if (isHoveredRow(employee.id)) return undefined
return { backgroundColor: employee.site?.color ?? '#304998' }
}
// Index de ligne par employe pour savoir si une case est "au-dessus" de la case survolee.
const employeeIndexById = computed(() => {
const indexMap = new Map<number, number>()
props.visibleEmployees.forEach((employee, index) => {
indexMap.set(employee.id, index)
})
return indexMap
})
const cellContainerClass = (employeeId: number, date: string) => {
if (!hoveredEmployeeId.value || !hoveredDate.value) return 'hover:bg-primary-500'
const hoveredRowIndex = employeeIndexById.value.get(hoveredEmployeeId.value)
const currentRowIndex = employeeIndexById.value.get(employeeId)
// Forme en L:
// - ligne: toutes les cases a gauche (et la case cible)
// - colonne: toutes les cases au-dessus (et la case cible)
const isOnLeftSegment = isHoveredRow(employeeId) && date <= hoveredDate.value
const isOnTopSegment = isHoveredColumn(date)
&& typeof hoveredRowIndex === 'number'
&& typeof currentRowIndex === 'number'
&& currentRowIndex <= hoveredRowIndex
if (isOnLeftSegment || isOnTopSegment) return 'bg-primary-500'
return 'hover:bg-primary-500'
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<input
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
/>
</template>
<script setup lang="ts">
const model = defineModel<string>({ required: true })
withDefaults(defineProps<{
placeholder?: string
}>(), {
placeholder: 'Chercher un employé (nom ou prénom)'
})
</script>

View File

@@ -0,0 +1,25 @@
<template>
<div class="inline-flex w-fit max-w-full flex-wrap items-center gap-6 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded" />
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
const selectedSiteIds = defineModel<number[]>({ required: true })
defineProps<{
sites: Site[]
}>()
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-4">Début matin</span>
<span class="pr-2">Fin matin</span>
<span class="pl-2">Début après-midi</span>
<span class="pr-2">Fin après-midi</span>
<span class="pl-2">Début soir</span>
<span class="pr-2">Fin soir</span>
<span class="pl-2">Présent</span>
<span class="pl-2">Jour</span>
<span>Nuit</span>
<span>Total</span>
<span v-if="isAdmin">Valider</span>
</div>
<div
v-for="employee in employees"
:key="employee.id"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
:style="{ gridTemplateColumns: dayGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p>
<p class="text-neutral-500 truncate">{{ employee.site?.name ?? 'Sans site' }}</p>
</div>
<div class="pl-4">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningFrom" :disabled="isRowLocked(employee.id)"/>
<input
v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentMorning"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="isRowLocked(employee.id)"
/>
</div>
<div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningTo" :disabled="isRowLocked(employee.id)"/>
</div>
<div class="pl-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonFrom" :disabled="isRowLocked(employee.id)"/>
<input
v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentAfternoon"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="isRowLocked(employee.id)"
/>
</div>
<div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonTo" :disabled="isRowLocked(employee.id)"/>
</div>
<div class="pl-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningFrom" :disabled="isRowLocked(employee.id)"/>
</div>
<div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningTo" :disabled="isRowLocked(employee.id)"/>
</div>
<div class="pl-2"></div>
<div class="pl-2 text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
</div>
<div v-if="isAdmin">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4"
:class="rows[employee.id]?.workHourId ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
:disabled="!rows[employee.id]?.workHourId || isValidationPending(employee.id)"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { HourRow } from './types'
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
defineProps<{
employees: Employee[]
isAdmin: boolean
dayGridCols: string
contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
isRowLocked: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean
onToggleValidation: (employeeId: number, checked: boolean) => void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
formatMinutes: (minutes: number) => string
}>()
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="py-6 flex flex-col gap-3">
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
<div
v-if="viewMode === 'day'"
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="shortcutButtonClass('yesterday')"
@click="emit('set-yesterday')"
>
Hier
</button>
<button
type="button"
class="flex-1 border-x border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="shortcutButtonClass('today')"
@click="emit('set-today')"
>
Aujourd'hui
</button>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="shortcutButtonClass('tomorrow')"
@click="emit('set-tomorrow')"
>
Demain
</button>
</div>
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
<input
ref="nativeDateInput"
v-model="selectedDate"
type="date"
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
tabindex="-1"
aria-hidden="true"
/>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
aria-label="Période précédente"
@click="emit('shift-date', -1)"
>
</button>
<button
type="button"
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
@click="openDatePicker"
>
{{ formattedSelectedDate }}
</button>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
aria-label="Période suivante"
@click="emit('shift-date', 1)"
>
</button>
</div>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="inline-flex items-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="viewModeButtonClass('week')"
@click="viewMode = 'week'"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
<div class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
const selectedDate = defineModel<string>('selectedDate', { required: true })
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{
isAdmin: boolean
sites: Site[]
formattedSelectedDate: string
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
}>()
const emit = defineEmits<{
(e: 'set-yesterday'): void
(e: 'set-today'): void
(e: 'set-tomorrow'): void
(e: 'shift-date', value: number): void
}>()
const nativeDateInput = ref<HTMLInputElement | null>(null)
const viewModeButtonClass = (mode: 'day' | 'week') => {
if (viewMode.value === mode) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const openDatePicker = () => {
const input = nativeDateInput.value
if (!input) return
if (typeof input.showPicker === 'function') {
input.showPicker()
return
}
input.focus()
input.click()
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
<span>Jour/Nuit sem.</span>
<span>Total sem.</span>
<span>+25%</span>
<span>+50%</span>
</div>
<div
v-for="row in weeklySummary?.rows ?? []"
:key="row.employeeId"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
</div>
<div v-for="daily in row.daily" :key="daily.date" class="text-left leading-4">
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
<template v-else>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
</template>
</div>
<div class="font-semibold leading-4">
<template v-if="row.trackingMode === 'PRESENCE'">-</template>
<template v-else>
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
</template>
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
defineProps<{
isWeekLoading: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string
}>()
</script>

View File

@@ -0,0 +1,12 @@
export type HourRow = {
workHourId: number | null
morningFrom: string
morningTo: string
afternoonFrom: string
afternoonTo: string
eveningFrom: string
eveningTo: string
isPresentMorning: boolean
isPresentAfternoon: boolean
isValid: boolean
}

View File

@@ -0,0 +1,151 @@
<template>
<div ref="root" class="relative w-full">
<button
ref="trigger"
type="button"
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500"
:disabled="props.disabled"
@click="toggleOpen"
>
{{ displayValue }}
<Icon name="mdi:chevron-down" class="self-center"/>
</button>
</div>
<Teleport to="body">
<div
v-if="isOpen"
ref="menu"
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
:style="menuStyle"
>
<button
type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@click="selectValue('')"
>
{{ placeholder }}
</button>
<button
v-for="slot in timeSlots"
:key="slot"
type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@click="selectValue(slot)"
>
{{ slot }}
</button>
</div>
</Teleport>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
}>(), {
placeholder: '--',
disabled: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const root = ref<HTMLElement | null>(null)
const trigger = ref<HTMLElement | null>(null)
const menu = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const menuStyle = ref<Record<string, string>>({
top: '0px',
left: '0px',
width: '0px',
maxHeight: '224px'
})
const timeSlots = computed(() => {
const slots: string[] = []
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`)
}
}
return slots
})
const displayValue = computed(() => props.modelValue || props.placeholder)
const updateMenuPosition = () => {
const triggerEl = trigger.value
if (!triggerEl) return
const rect = triggerEl.getBoundingClientRect()
const menuHeight = 224
const belowTop = rect.bottom + 4
const aboveTop = Math.max(8, rect.top - menuHeight - 4)
const canOpenBelow = belowTop + menuHeight <= window.innerHeight - 8
const top = canOpenBelow ? belowTop : aboveTop
menuStyle.value = {
top: `${top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
maxHeight: `${menuHeight}px`
}
}
const toggleOpen = () => {
if (props.disabled) return
const next = !isOpen.value
isOpen.value = next
if (next) {
nextTick(updateMenuPosition)
}
}
const selectValue = (value: string) => {
if (props.disabled) return
emit('update:modelValue', value)
isOpen.value = false
}
const onDocumentClick = (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (root.value?.contains(target) || menu.value?.contains(target)) return
isOpen.value = false
}
const onWindowChange = () => {
if (!isOpen.value) return
updateMenuPosition()
}
watch(isOpen, (open) => {
if (open) {
window.addEventListener('resize', onWindowChange)
window.addEventListener('scroll', onWindowChange, true)
nextTick(updateMenuPosition)
} else {
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
}
})
watch(() => props.disabled, (disabled) => {
if (disabled) {
isOpen.value = false
}
})
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
})
</script>

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-tertiary-500 from-tertiary-50 via-white to-neutral-100 text-neutral-900">
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
<slot />
</main>

View File

@@ -6,46 +6,53 @@
<img src="/malio.png" alt="Logo" class="w-auto"/>
</div>
<nav class="flex-1 px-4 pb-6">
<NuxtLink
to="/hours"
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Heures
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink
to="/"
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600 border-t border-secondary-500"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Tableau de bord
</NuxtLink>
<NuxtLink
to="/calendar"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Calendrier
</NuxtLink>
<NuxtLink
to="/employees"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Employés
</NuxtLink>
<NuxtLink
to="/sites"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Sites
</NuxtLink>
<NuxtLink
to="/absence-types"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Types d'absence
</NuxtLink>
<NuxtLink
to="/users"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Utilisateurs
</NuxtLink>

View File

@@ -6,10 +6,11 @@ export default defineNuxtConfig({
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
},
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n'
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n',
'@nuxt/icon'
],
runtimeConfig: {
public: {
@@ -36,4 +37,4 @@ export default defineNuxtConfig({
typescript: {
strict: true
}
})
})

View File

@@ -7,6 +7,7 @@
"name": "frontend",
"hasInstallScript": true,
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",
@@ -32,6 +33,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1220,6 +1234,47 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/collections": {
"version": "1.0.651",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.651.tgz",
"integrity": "sha512-ALGlYxNVOIylxNHjFaylqPTzgNaMHeoFA8ao/piPHjYGD526xEp847F7KePy9jvOLChy2bzQVwAV9Em3HiicjQ==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"mlly": "^1.8.0"
}
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@intlify/bundle-utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
@@ -2372,6 +2427,28 @@
"devtools-wizard": "cli.mjs"
}
},
"node_modules/@nuxt/icon": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.641",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.1.1",
"@nuxt/kit": "^4.2.2",
"consola": "^3.4.2",
"local-pkg": "^1.1.2",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinyglobby": "^0.2.15"
}
},
"node_modules/@nuxt/kit": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",

View File

@@ -11,6 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",

View File

@@ -171,7 +171,7 @@ const showLabelError = computed(() => validationTouched.label && !isLabelValid.v
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`

View File

@@ -3,38 +3,10 @@
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
</div>
<div class="py-6">
<div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<select
v-model="selectedMonth"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
</option>
</select>
<select
v-model="selectedYear"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="year in years" :key="year" :value="year">
{{ year }}
</option>
</select>
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
</div>
<div class="flex gap-4">
<button
@@ -53,24 +25,34 @@
</button>
</div>
</div>
<div class="flex justify-between mt-3">
<div class="flex items-center gap-4 w-80">
<input
v-model="employeeFilter"
type="text"
placeholder="Chercher un employé (nom ou prénom)"
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
/>
</div>
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
<div class="flex justify-between">
<div class="flex items-center gap-4">
<EmployeeNameFilterInput v-model="employeeFilter"/>
<select
v-model="selectedMonth"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
</option>
</select>
<select
v-model="selectedYear"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="year in years" :key="year" :value="year">
{{ year }}
</option>
</select>
</div>
</div>
<div class="flex flex-wrap items-center gap-6 py-2">
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
</div>
</div>
<div class="flex-1 min-h-0">
@@ -119,10 +101,13 @@ import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
// Données principales affichées dans la grille.
const employees = ref<Employee[]>([])
@@ -149,27 +134,11 @@ watch(sites, (next) => {
if (sitesInitialized.value || next.length === 0) return
selectedSiteIds.value = next.map((site) => site.id)
sitesInitialized.value = true
}, { immediate: true })
}, {immediate: true})
// Tri stable: site -> nom -> prénom.
const sortedEmployees = computed(() => {
return [...employees.value].sort((employeeA, employeeB) => {
const siteOrderA = employeeA.site?.displayOrder ?? 0
const siteOrderB = employeeB.site?.displayOrder ?? 0
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
const orderA = employeeA.displayOrder ?? 0
const orderB = employeeB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
})
return sortEmployeesBySiteAndOrder(employees.value)
})
// Employés visibles selon le filtre de sites.
@@ -281,13 +250,6 @@ const closePrint = () => {
isPrintOpen.value = false
}
// Parse "YYYY-MM-DD" en Date (ou null).
const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
const getHalfForDate = (
startDate: string,
@@ -681,7 +643,7 @@ const formatEmployeeName = (employee: Employee) => {
}
// Impression PDF de l'intervalle sélectionné.
const { printPdf } = usePdfPrinter()
const {printPdf} = usePdfPrinter()
const handlePrint = async () => {
const params = new URLSearchParams()
params.set('from', printForm.from)
@@ -703,17 +665,7 @@ const handleReorder = async (payload: { dragId: number; dropId: number }) => {
const siteEmployees = [...employees.value]
.filter((employee) => employee.site?.id === dragSiteId)
.sort((employeeA, employeeB) => {
const orderA = employeeA.displayOrder ?? 0
const orderB = employeeB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
})
.sort(compareEmployeesInSite)
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.id)
const toIndex = siteEmployees.findIndex((employee) => employee.id === dropEmployee.id)
@@ -726,7 +678,7 @@ const handleReorder = async (payload: { dragId: number; dropId: number }) => {
siteEmployees.forEach((employee, index) => {
const nextOrder = index + 1
if ((employee.displayOrder ?? 0) !== nextOrder) {
updates.push({ id: employee.id, displayOrder: nextOrder })
updates.push({id: employee.id, displayOrder: nextOrder})
}
employee.displayOrder = nextOrder
})

View File

@@ -19,10 +19,11 @@
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_1fr_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<div class="grid grid-cols-[120px_1fr_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<span class="text-left">Prénom</span>
<span class="text-left">Nom</span>
<span class="text-left">Site</span>
<span class="text-left">Contrat</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
@@ -32,11 +33,12 @@
<div
v-for="employee in employees"
:key="employee.id"
class="grid grid-cols-[120px_1fr_1fr_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-[120px_1fr_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
>
<span>{{ employee.firstName }}</span>
<span>{{ employee.lastName }}</span>
<span>{{ employee.site?.name ?? '-' }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
type="button"
@@ -105,6 +107,24 @@
Le site est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Contrat <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le contrat est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -127,8 +147,10 @@
</template>
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import { listContracts } from '~/services/contracts'
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
import { listSites } from '~/services/sites'
@@ -142,24 +164,28 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const form = reactive({
firstName: '',
lastName: '',
siteId: '' as number | ''
siteId: '' as number | '',
contractId: '' as number | ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false
siteId: false,
contractId: false
})
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value && isContractValid.value
)
const showFirstNameError = computed(
@@ -171,9 +197,12 @@ const showLastNameError = computed(
const showSiteError = computed(
() => validationTouched.siteId && !isSiteValid.value
)
const showContractError = computed(
() => validationTouched.contractId && !isContractValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
@@ -194,6 +223,14 @@ const siteFieldClass = computed(() => {
}
return `${baseSelectClass} border-neutral-300`
})
const contractFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
@@ -215,8 +252,12 @@ const loadSites = async () => {
sites.value = await listSites()
}
const loadContracts = async () => {
contracts.value = await listContracts()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites()])
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
})
const handleSubmit = async () => {
@@ -224,6 +265,7 @@ const handleSubmit = async () => {
validationTouched.firstName = true
validationTouched.lastName = true
validationTouched.siteId = true
validationTouched.contractId = true
if (!isFormValid.value) return
isSubmitting.value = true
@@ -232,19 +274,22 @@ const handleSubmit = async () => {
await updateEmployee(editingEmployee.value.id, {
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId)
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId)
})
} else {
await createEmployee({
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId)
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId)
})
}
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -258,6 +303,7 @@ watch(isDrawerOpen, (isOpen) => {
validationTouched.firstName = false
validationTouched.lastName = false
validationTouched.siteId = false
validationTouched.contractId = false
}
})
@@ -266,6 +312,7 @@ const openEdit = (employee: Employee) => {
form.firstName = employee.firstName
form.lastName = employee.lastName
form.siteId = employee.site?.id ?? ''
form.contractId = employee.contract?.id ?? ''
isDrawerOpen.value = true
}

106
frontend/pages/hours.vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
</div>
<HoursToolbar
v-model:selected-date="selectedDate"
v-model:view-mode="viewMode"
v-model:selected-site-ids="selectedSiteIds"
v-model:employee-filter="employeeFilter"
:is-admin="isAdmin"
:sites="sites"
:formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass"
@set-yesterday="setYesterday"
@set-today="setToday"
@set-tomorrow="setTomorrow"
@shift-date="shiftDate"
/>
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="employees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Aucun employé accessible.
</div>
<div v-else class="flex flex-1 min-h-0 flex-col gap-4">
<HoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="visibleEmployees"
:is-admin="isAdmin"
:day-grid-cols="dayGridCols"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
:is-row-locked="isRowLocked"
:is-validation-pending="isValidationPending"
:on-toggle-validation="toggleValidation"
:get-row-metrics="getRowMetrics"
:format-minutes="formatMinutes"
/>
<HoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
/>
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
<button
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="saveButtonClass"
:disabled="isSubmitting || visibleEmployees.length === 0"
@click="handleSave"
>
Enregistrer
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {
isAdmin,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
rows,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
weekDayHeaders,
shortcutButtonClass,
setToday,
setYesterday,
setTomorrow,
shiftDate,
contractLabel,
isTimeTracking,
isPresenceTracking,
isRowLocked,
isValidationPending,
toggleValidation,
getRowMetrics,
formatMinutes,
handleSave
} = useHoursPage()
</script>

View File

@@ -18,7 +18,7 @@
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
@@ -31,13 +31,13 @@
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Se connecter

View File

@@ -150,7 +150,7 @@ const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`

View File

@@ -103,7 +103,7 @@
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('admin')"
>
Admin
@@ -111,7 +111,7 @@
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'self' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
:class="form.accessMode === 'self' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('self')"
>
Accès personnel
@@ -119,7 +119,7 @@
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('sites')"
>
Sites
@@ -288,7 +288,7 @@ const getSiteLabels = (user: User) => {
}
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const usernameFieldClass = computed(() => {
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`

View File

@@ -0,0 +1,13 @@
import type { Contract } from './dto/contract'
import { extractItems } from '~/utils/api'
export const listContracts = async () => {
const api = useApi()
const data = await api.get<Contract[] | { 'hydra:member'?: Contract[] }>(
'/contracts',
{},
{ toast: false }
)
return extractItems<Contract>(data)
}

View File

@@ -0,0 +1,7 @@
export type Contract = {
id: number
name: string
trackingMode: 'TIME' | 'PRESENCE'
weeklyHours?: number | null
isActive?: boolean
}

View File

@@ -1,9 +1,11 @@
import type { Site } from './site'
import type { Contract } from './contract'
export type Employee = {
id: number
firstName: string
lastName: string
site: Site
contract?: Contract | null
displayOrder?: number
}

View File

@@ -0,0 +1,59 @@
import type { Employee } from './employee'
export type WorkHour = {
id: number
employee: Employee
workDate: string
morningFrom?: string | null
morningTo?: string | null
afternoonFrom?: string | null
afternoonTo?: string | null
eveningFrom?: string | null
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
isValid?: boolean
}
export type WorkHourEntryPayload = {
employeeId: number
morningFrom?: string | null
morningTo?: string | null
afternoonFrom?: string | null
afternoonTo?: string | null
eveningFrom?: string | null
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
}
export type WeeklyWorkHourDailySummary = {
date: string
dayMinutes: number
nightMinutes: number
totalMinutes: number
present?: number | null
}
export type WeeklyWorkHourRowSummary = {
employeeId: number
firstName: string
lastName: string
siteName?: string | null
contractName?: string | null
trackingMode?: 'TIME' | 'PRESENCE' | null
daily: WeeklyWorkHourDailySummary[]
weeklyDayMinutes: number
weeklyNightMinutes: number
weeklyTotalMinutes: number
weeklyPresenceCount?: number
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
}
export type WeeklyWorkHourSummary = {
weekStart: string
weekEnd: string
days: string[]
rows: WeeklyWorkHourRowSummary[]
}

View File

@@ -11,16 +11,28 @@ export const listEmployees = async () => {
return extractItems<Employee>(data)
}
export const listScopedEmployees = async () => {
const api = useApi()
const data = await api.get<Employee[] | { 'hydra:member'?: Employee[] }>(
'/employees/scoped',
{},
{ toast: false }
)
return extractItems<Employee>(data)
}
export const createEmployee = async (payload: {
firstName: string
lastName: string
siteId?: number | null
contractId: number
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -33,6 +45,7 @@ export const updateEmployee = async (
firstName: string
lastName: string
siteId?: number | null
contractId: number
displayOrder?: number
}
) => {
@@ -41,6 +54,7 @@ export const updateEmployee = async (
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`,
displayOrder: payload.displayOrder
}, {
toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,61 @@
import type {
WorkHour,
WorkHourEntryPayload,
WeeklyWorkHourSummary
} from './dto/work-hour'
import { extractItems } from '~/utils/api'
export const listWorkHoursByDate = async (workDate: string) => {
const api = useApi()
const data = await api.get<WorkHour[] | { 'hydra:member'?: WorkHour[] }>(
'/work_hours',
{
'workDate[after]': workDate,
'workDate[before]': workDate
},
{ toast: false }
)
return extractItems<WorkHour>(data)
}
export const bulkUpsertWorkHours = async (payload: {
workDate: string
entries: WorkHourEntryPayload[]
}) => {
const api = useApi()
return api.post<{
processed: number
created: number
updated: number
deleted: number
}>(
'/work-hours/bulk-upsert',
payload,
{
toastSuccessMessage: 'Horaires enregistrés.',
toastErrorMessage: "Impossible d'enregistrer les horaires."
}
)
}
export const updateWorkHourValidation = async (id: number, isValid: boolean) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}`,
{ isValid },
{
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
toastErrorMessage: 'Impossible de mettre à jour la validation.'
}
)
}
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
const api = useApi()
return api.get<WeeklyWorkHourSummary>(
'/work-hours/weekly-summary',
{ weekStart },
{ toast: false }
)
}

View File

@@ -6,6 +6,74 @@ export const toYmd = (year: number, month: number, day: number) => {
export const normalizeDate = (value: string) => value.slice(0, 10)
export const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
export const formatDateLongFr = (date: Date) => {
const label = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date)
return label.charAt(0).toUpperCase() + label.slice(1)
}
export const formatWeekDayHeaderFr = (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return dateYmd
return new Intl.DateTimeFormat('fr-FR', {
weekday: 'short',
day: '2-digit',
month: '2-digit'
}).format(parsed)
}
export const getWeekStartDate = (date: Date) => {
const copy = new Date(date)
const day = copy.getDay()
const diff = day === 0 ? -6 : 1 - day
copy.setDate(copy.getDate() + diff)
copy.setHours(0, 0, 0, 0)
return copy
}
export const getTodayYmd = () => {
const date = new Date()
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
}
export const getOffsetFromTodayYmd = (offset: number) => {
const date = new Date()
date.setDate(date.getDate() + offset)
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
}
export const shiftYmd = (value: string, days: number) => {
const parsed = parseYmd(value)
if (!parsed) return null
parsed.setDate(parsed.getDate() + days)
return toYmd(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
}
export const formatWeekRangeFr = (date: Date) => {
const start = getWeekStartDate(date)
const end = new Date(start)
end.setDate(start.getDate() + 6)
const formatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
return `Semaine du ${formatter.format(start)} au ${formatter.format(end)}`
}
export const getDaysInMonth = (year: number, month: number) => {
const total = new Date(year, month + 1, 0).getDate()
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']

View File

@@ -0,0 +1,31 @@
import type { Employee } from '~/services/dto/employee'
export const compareEmployeesInSite = (employeeA: Employee, employeeB: Employee) => {
const orderA = employeeA.displayOrder ?? 0
const orderB = employeeB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
}
export const compareEmployeesBySiteAndOrder = (employeeA: Employee, employeeB: Employee) => {
const siteOrderA = employeeA.site?.displayOrder ?? 0
const siteOrderB = employeeB.site?.displayOrder ?? 0
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
return compareEmployeesInSite(employeeA, employeeB)
}
export const sortEmployeesBySiteAndOrder = (employees: Employee[]) => {
return [...employees].sort(compareEmployeesBySiteAndOrder)
}