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)
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260217161000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contract_hours and is_forfait to employees';
}
public function up(Schema $schema): void
{
// Nettoie l'ancien modele "contracts" s'il a deja ete applique.
$this->addSql('ALTER TABLE employees DROP CONSTRAINT IF EXISTS FK_EMPLOYEES_CONTRACT');
$this->addSql('DROP INDEX IF EXISTS IDX_EMPLOYEES_CONTRACT');
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_id');
$this->addSql('DROP TABLE IF EXISTS contracts');
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS contract_hours INT DEFAULT NULL');
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_hours');
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS is_forfait');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260217162000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_present and is_valid columns to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_present BOOLEAN DEFAULT NULL');
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_valid BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_valid');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace employee contract_hours/is_forfait with contracts table and employee.contract_id';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contracts (id SERIAL NOT NULL, name VARCHAR(120) NOT NULL, tracking_mode VARCHAR(20) NOT NULL, weekly_hours INT DEFAULT NULL, is_active BOOLEAN DEFAULT TRUE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_CONTRACTS_TRACKING_MODE ON contracts (tracking_mode)');
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) VALUES ('Forfait', 'PRESENCE', NULL, TRUE)");
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT DISTINCT CONCAT(contract_hours::text, 'h'), 'TIME', contract_hours, TRUE FROM employees WHERE contract_hours IS NOT NULL");
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT '35h', 'TIME', 35, TRUE WHERE NOT EXISTS (SELECT 1 FROM contracts WHERE tracking_mode = 'TIME' AND weekly_hours = 35)");
$this->addSql('ALTER TABLE employees ADD contract_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_EMPLOYEES_CONTRACT ON employees (contract_id)');
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = TRUE AND c.tracking_mode = 'PRESENCE'");
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = FALSE AND e.contract_hours IS NOT NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = e.contract_hours");
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.contract_id IS NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = 35");
$this->addSql('ALTER TABLE employees ALTER COLUMN contract_id SET NOT NULL');
$this->addSql('ALTER TABLE employees ADD CONSTRAINT FK_EMPLOYEES_CONTRACT FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE employees DROP COLUMN contract_hours');
$this->addSql('ALTER TABLE employees DROP COLUMN is_forfait');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD contract_hours INT DEFAULT NULL');
$this->addSql('ALTER TABLE employees ADD is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql("UPDATE employees e SET is_forfait = CASE WHEN c.tracking_mode = 'PRESENCE' THEN TRUE ELSE FALSE END, contract_hours = CASE WHEN c.tracking_mode = 'TIME' THEN c.weekly_hours ELSE NULL END FROM contracts c WHERE e.contract_id = c.id");
$this->addSql('ALTER TABLE employees DROP CONSTRAINT FK_EMPLOYEES_CONTRACT');
$this->addSql('DROP INDEX IDX_EMPLOYEES_CONTRACT');
$this->addSql('ALTER TABLE employees DROP COLUMN contract_id');
$this->addSql('DROP INDEX IDX_CONTRACTS_TRACKING_MODE');
$this->addSql('DROP TABLE contracts');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218183000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace work_hours.is_present with is_present_morning and is_present_afternoon';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_morning BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_afternoon BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_morning');
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_afternoon');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present BOOLEAN DEFAULT NULL');
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\ScopedEmployeeProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/employees/scoped',
normalizationContext: ['groups' => ['employee:read', 'site:read']],
security: "is_granted('ROLE_USER')",
provider: ScopedEmployeeProvider::class,
paginationEnabled: false
),
]
)]
final class ScopedEmployee {}

View File

@@ -30,7 +30,9 @@ final class WorkHourBulkUpsert
* afternoonFrom?:?string,
* afternoonTo?:?string,
* eveningFrom?:?string,
* eveningTo?:?string
* eveningTo?:?string,
* isPresentMorning?:bool,
* isPresentAfternoon?:bool
* }>
*/
public array $entries = [];

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\WorkHourWeeklySummaryProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/work-hours/weekly-summary',
security: "is_granted('ROLE_USER')",
provider: WorkHourWeeklySummaryProvider::class
),
],
paginationEnabled: false
)]
final class WorkHourWeeklySummary
{
public string $weekStart = '';
public string $weekEnd = '';
/** @var list<string> */
public array $days = [];
/**
* @var list<array{
* employeeId:int,
* firstName:string,
* lastName:string,
* siteName:?string,
* contractName:?string,
* trackingMode:?string,
* daily:list<array{
* date:string,
* dayMinutes:int,
* nightMinutes:int,
* totalMinutes:int,
* present:?float
* }>,
* weeklyDayMinutes:int,
* weeklyNightMinutes:int,
* weeklyTotalMinutes:int,
* weeklyPresenceCount:float,
* weeklyOvertime25Minutes:int,
* weeklyOvertime50Minutes:int
* }>
*/
public array $rows = [];
}

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\AbsenceRepository;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -26,7 +27,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
#[ORM\Table(name: 'absences')]
class Absence
{

103
src/Entity/Contract.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['contract:read']],
denormalizationContext: ['groups' => ['contract:write']],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')"
)]
#[ORM\Entity]
#[ORM\Table(name: 'contracts')]
class Contract
{
public const string TRACKING_TIME = 'TIME';
public const string TRACKING_PRESENCE = 'PRESENCE';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['contract:read', 'employee:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 120)]
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
private string $name = '';
#[ORM\Column(type: 'string', length: 20)]
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
private string $trackingMode = self::TRACKING_TIME;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
private ?int $weeklyHours = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
#[Groups(['contract:read', 'contract:write'])]
private bool $isActive = true;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getTrackingMode(): string
{
return $this->trackingMode;
}
public function setTrackingMode(string $trackingMode): self
{
$this->trackingMode = $trackingMode;
return $this;
}
public function getWeeklyHours(): ?int
{
return $this->weeklyHours;
}
public function setWeeklyHours(?int $weeklyHours): self
{
$this->weeklyHours = $weeklyHours;
return $this;
}
public function isActive(): bool
{
return $this->isActive;
}
public function getIsActive(): bool
{
return $this->isActive;
}
public function setIsActive(bool $isActive): self
{
$this->isActive = $isActive;
return $this;
}
}

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\EmployeeRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -15,7 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')"
)]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\Table(name: 'employees')]
class Employee
{
@@ -33,12 +35,18 @@ class Employee
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
private string $lastName = '';
#[ApiPlatform\Metadata\ApiProperty(readableLink: true)]
#[ApiProperty(readableLink: true)]
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(nullable: true)]
#[Groups(['employee:read', 'employee:write'])]
private ?Site $site = null;
#[ApiProperty(readableLink: true)]
#[ORM\ManyToOne(targetEntity: Contract::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['employee:read', 'employee:write'])]
private ?Contract $contract = null;
#[ORM\Column(type: 'integer', options: ['default' => 0])]
#[Groups(['employee:read', 'employee:write'])]
private int $displayOrder = 0;
@@ -92,6 +100,18 @@ class Employee
return $this;
}
public function getContract(): ?Contract
{
return $this->contract;
}
public function setContract(?Contract $contract): self
{
$this->contract = $contract;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;

View File

@@ -11,6 +11,8 @@ use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -26,11 +28,16 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
security: "is_granted('WORK_HOUR_VIEW', object)"
),
new Patch(
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
denormalizationContext: ['groups' => ['work_hour:validate']],
security: "is_granted('ROLE_ADMIN')"
),
],
)]
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: WorkHourRepository::class)]
#[ORM\Table(name: 'work_hours')]
#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])]
class WorkHour
@@ -75,6 +82,18 @@ class WorkHour
#[Groups(['work_hour:read'])]
private ?string $eveningTo = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $isPresentMorning = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read'])]
private bool $isPresentAfternoon = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false;
public function getId(): ?int
{
return $this->id;
@@ -175,4 +194,55 @@ class WorkHour
return $this;
}
public function isPresentMorning(): bool
{
return $this->isPresentMorning;
}
public function getIsPresentMorning(): bool
{
return $this->isPresentMorning;
}
public function setIsPresentMorning(bool $isPresentMorning): self
{
$this->isPresentMorning = $isPresentMorning;
return $this;
}
public function isPresentAfternoon(): bool
{
return $this->isPresentAfternoon;
}
public function getIsPresentAfternoon(): bool
{
return $this->isPresentAfternoon;
}
public function setIsPresentAfternoon(bool $isPresentAfternoon): self
{
$this->isPresentAfternoon = $isPresentAfternoon;
return $this;
}
public function isValid(): bool
{
return $this->isValid;
}
public function getIsValid(): bool
{
return $this->isValid;
}
public function setIsValid(bool $isValid): self
{
$this->isValid = $isValid;
return $this;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Absence;
use App\Entity\Employee;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Absence>
*/
final class AbsenceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Absence::class);
}
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->andWhere('a.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
/** @var list<Absence> $absences */
return $qb->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\User;
use App\Security\EmployeeScopeService;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Employee>
*/
final class EmployeeRepository extends ServiceEntityRepository
{
public function __construct(
ManagerRegistry $registry,
private readonly EmployeeScopeService $employeeScopeService,
) {
parent::__construct($registry, Employee::class);
}
/**
* @param list<int> $employeeIds
*
* @return array<int, Employee>
*/
public function findAccessibleByIds(array $employeeIds, User $user): array
{
if ([] === $employeeIds) {
return [];
}
$qb = $this->createQueryBuilder('e')
->andWhere('e.id IN (:ids)')
->setParameter('ids', $employeeIds)
;
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_repo_scope', $user);
/** @var list<Employee> $employees */
$employees = $qb->getQuery()->getResult();
$byId = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if ($employeeId) {
$byId[$employeeId] = $employee;
}
}
return $byId;
}
/**
* @return list<Employee>
*/
public function findScoped(User $user): array
{
$qb = $this->createQueryBuilder('e')
->leftJoin('e.site', 's')
->addSelect('s')
->orderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')
->addOrderBy('e.lastName', 'ASC')
->addOrderBy('e.firstName', 'ASC')
;
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user);
/** @var list<Employee> $employees */
return $qb->getQuery()->getResult();
}
/**
* @param list<int> $siteIds
*
* @return list<Employee>
*/
public function findForPrintBySiteIds(array $siteIds): array
{
$qb = $this->createQueryBuilder('e')
->leftJoin('e.site', 's')
->addSelect('s')
->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')
->addOrderBy('e.lastName', 'ASC')
->addOrderBy('e.firstName', 'ASC')
;
if ([] !== $siteIds) {
$qb->andWhere('s.id IN (:siteIds)')
->setParameter('siteIds', $siteIds)
;
}
/** @var list<Employee> $employees */
return $qb->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\WorkHour;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WorkHour>
*/
final class WorkHourRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WorkHour::class);
}
/**
* @param list<Employee> $employees
*
* @return array<int, WorkHour>
*/
public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('w')
->leftJoin('w.employee', 'e')
->addSelect('e')
->andWhere('w.workDate = :workDate')
->andWhere('w.employee IN (:employees)')
->setParameter('workDate', $workDate)
->setParameter('employees', $employees)
;
/** @var list<WorkHour> $workHours */
$workHours = $qb->getQuery()->getResult();
$byEmployeeId = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if ($employeeId) {
$byEmployeeId[$employeeId] = $workHour;
}
}
return $byEmployeeId;
}
/**
* @param list<Employee> $employees
*
* @return list<WorkHour>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('w')
->leftJoin('w.employee', 'e')
->addSelect('e')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
/** @var list<WorkHour> $workHours */
return $qb->getQuery()->getResult();
}
}

View File

@@ -6,12 +6,11 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -27,7 +26,8 @@ class AbsencePrintProvider implements ProviderInterface
public function __construct(
private Environment $twig,
private readonly RequestStack $requestStack,
private EntityManagerInterface $entityManager,
private EmployeeRepository $employeeRepository,
private AbsenceRepository $absenceRepository,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
@@ -109,50 +109,12 @@ class AbsencePrintProvider implements ProviderInterface
private function loadEmployees(array $siteIds): array
{
$qb = $this->entityManager
->getRepository(Employee::class)
->createQueryBuilder('e')
->leftJoin('e.site', 's')
->addSelect('s')
->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')
->addOrderBy('e.lastName', 'ASC')
->addOrderBy('e.firstName', 'ASC')
;
if ([] !== $siteIds) {
$qb->andWhere('s.id IN (:siteIds)')
->setParameter('siteIds', $siteIds)
;
}
// @var list<Employee> $result
return $qb->getQuery()->getResult();
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
}
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->entityManager
->getRepository(Absence::class)
->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.startDate <= :to')
->andWhere('a.endDate >= :from')
->andWhere('a.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
// @var list<Absence> $result
return $qb->getQuery()->getResult();
return $this->absenceRepository->findForPrint($from, $to, $employees);
}
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use App\Repository\EmployeeRepository;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class ScopedEmployeeProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private EmployeeRepository $employeeRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
return $this->employeeRepository->findScoped($user);
}
}

View File

@@ -8,10 +8,11 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkUpsert;
use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\Employee;
use App\Entity\Contract;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -24,7 +25,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private EmployeeScopeService $employeeScopeService,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
) {}
public function process(
@@ -54,13 +56,15 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
// Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant.
$employeeIds = $this->extractEmployeeIds($data->entries);
$employeesById = $this->loadAccessibleEmployees($employeeIds, $user);
$employeesById = $this->employeeRepository->findAccessibleByIds($employeeIds, $user);
if (count($employeesById) !== count($employeeIds)) {
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
}
$existingByEmployeeId = $this->loadExistingWorkHours($workDate, array_values($employeesById));
$existingByEmployeeId = $this->workHourRepository
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
;
$result = new WorkHourBulkUpsertResult();
@@ -71,8 +75,22 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
}
$normalized = $this->normalizeEntry($entry, $employeeId);
$existing = $existingByEmployeeId[$employeeId] ?? null;
$isPresenceTracking = Contract::TRACKING_PRESENCE === $employee->getContract()?->getTrackingMode();
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null;
if ($existing?->isValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: validated work hour cannot be modified.',
$employeeId
));
}
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
@@ -136,76 +154,6 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
return array_values($ids);
}
/**
* @param list<int> $employeeIds
*
* @return array<int, Employee>
*/
private function loadAccessibleEmployees(array $employeeIds, User $user): array
{
if ([] === $employeeIds) {
return [];
}
$qb = $this->entityManager
->getRepository(Employee::class)
->createQueryBuilder('e')
->andWhere('e.id IN (:ids)')
->setParameter('ids', $employeeIds)
;
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'bulk_scope', $user);
/** @var list<Employee> $employees */
$employees = $qb->getQuery()->getResult();
$byId = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if ($employeeId) {
$byId[$employeeId] = $employee;
}
}
return $byId;
}
/**
* @param list<Employee> $employees
*
* @return array<int, WorkHour>
*/
private function loadExistingWorkHours(DateTimeImmutable $workDate, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->entityManager
->getRepository(WorkHour::class)
->createQueryBuilder('w')
->leftJoin('w.employee', 'e')
->addSelect('e')
->andWhere('w.workDate = :workDate')
->andWhere('w.employee IN (:employees)')
->setParameter('workDate', $workDate)
->setParameter('employees', $employees)
;
/** @var list<WorkHour> $workHours */
$workHours = $qb->getQuery()->getResult();
$byEmployeeId = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if ($employeeId) {
$byEmployeeId[$employeeId] = $workHour;
}
}
return $byEmployeeId;
}
/**
* @param array<string, mixed> $entry
*
@@ -215,23 +163,36 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* }
*/
private function normalizeEntry(array $entry, int $employeeId): array
private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking): array
{
$normalized = [
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
if ($isPresenceTracking) {
return [
'morningFrom' => null,
'morningTo' => null,
'afternoonFrom' => null,
'afternoonTo' => null,
'eveningFrom' => null,
'eveningTo' => null,
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
];
}
return [
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
'isPresentMorning' => false,
'isPresentAfternoon' => false,
];
$this->validateRanges($normalized, $employeeId);
return $normalized;
}
private function normalizeTime(mixed $value, int $employeeId, string $field): ?string
@@ -260,77 +221,17 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
return $time;
}
/**
* @param array{
* morningFrom:?string,
* morningTo:?string,
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string
* } $entry
*/
private function validateRanges(array $entry, int $employeeId): void
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
{
$ranges = [
'morning' => [$entry['morningFrom'], $entry['morningTo']],
'afternoon' => [$entry['afternoonFrom'], $entry['afternoonTo']],
'evening' => [$entry['eveningFrom'], $entry['eveningTo']],
];
$normalizedRanges = [];
foreach ($ranges as $label => [$from, $to]) {
// On force des paires from/to complètes par créneau.
if ((null === $from) xor (null === $to)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s range must contain both from and to.',
$employeeId,
$label
));
}
if (null === $from || null === $to) {
continue;
}
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if ($fromMinutes >= $toMinutes) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s from must be earlier than to.',
$employeeId,
$label
));
}
$normalizedRanges[] = [
'label' => $label,
'from' => $fromMinutes,
'to' => $toMinutes,
];
if (!is_bool($value)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s must be a boolean.',
$employeeId,
$field
));
}
usort(
$normalizedRanges,
static fn (array $rangeA, array $rangeB): int => $rangeA['from'] <=> $rangeB['from']
);
$previous = null;
foreach ($normalizedRanges as $range) {
// Empêche deux créneaux qui se chevauchent sur une même journée.
if (null !== $previous && $range['from'] < $previous['to']) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: %s overlaps %s.',
$employeeId,
$range['label'],
$previous['label']
));
}
$previous = $range;
}
return $value;
}
/**
@@ -340,7 +241,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* } $entry
*/
private function isEntryEmpty(array $entry): bool
@@ -350,7 +253,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
&& null === $entry['afternoonFrom']
&& null === $entry['afternoonTo']
&& null === $entry['eveningFrom']
&& null === $entry['eveningTo'];
&& null === $entry['eveningTo']
&& false === $entry['isPresentMorning']
&& false === $entry['isPresentAfternoon'];
}
/**
@@ -360,7 +265,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* } $entry
*/
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
@@ -372,13 +279,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setAfternoonTo($entry['afternoonTo'])
->setEveningFrom($entry['eveningFrom'])
->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon'])
// Toute modification utilisateur repasse la ligne en attente de validation RH.
->setIsValid(false)
;
}
private function toMinutes(string $time): int
/**
* @param array{
* morningFrom:?string,
* morningTo:?string,
* afternoonFrom:?string,
* afternoonTo:?string,
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
* isPresentAfternoon:bool
* } $entry
*/
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
{
[$hours, $minutes] = array_map('intval', explode(':', $time, 2));
return ($hours * 60) + $minutes;
return $workHour->getMorningFrom() === $entry['morningFrom']
&& $workHour->getMorningTo() === $entry['morningTo']
&& $workHour->getAfternoonFrom() === $entry['afternoonFrom']
&& $workHour->getAfternoonTo() === $entry['afternoonTo']
&& $workHour->getEveningFrom() === $entry['eveningFrom']
&& $workHour->getEveningTo() === $entry['eveningTo']
&& $workHour->getIsPresentMorning() === $entry['isPresentMorning']
&& $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon'];
}
}

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourWeeklySummary;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$anchorDate = $this->resolveAnchorDate();
[$weekStart, $weekEnd, $days] = $this->resolveWeek($anchorDate);
$employees = $this->employeeRepository->findScoped($user);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
$summary = new WorkHourWeeklySummary();
$summary->weekStart = $weekStart->format('Y-m-d');
$summary->weekEnd = $weekEnd->format('Y-m-d');
$summary->days = $days;
$summary->rows = $this->buildRows($employees, $workHours, $days);
return $summary;
}
private function resolveAnchorDate(): DateTimeImmutable
{
$query = $this->requestStack->getCurrentRequest()?->query;
$raw = (string) ($query?->get('weekStart') ?? '');
if ('' === $raw) {
return new DateTimeImmutable('today');
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
if (!$date || $date->format('Y-m-d') !== $raw) {
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
}
return $date;
}
/**
* @return array{DateTimeImmutable, DateTimeImmutable, list<string>}
*/
private function resolveWeek(DateTimeImmutable $anchorDate): array
{
$dayOfWeek = (int) $anchorDate->format('N');
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
$weekEnd = $weekStart->modify('+6 days');
$days = [];
for ($i = 0; $i < 7; ++$i) {
$days[] = $weekStart->modify(sprintf('+%d days', $i))->format('Y-m-d');
}
return [$weekStart, $weekEnd, $days];
}
/**
* @param list<Employee> $employees
* @param list<WorkHour> $workHours
* @param list<string> $days
*
* @return list<array{
* employeeId:int,
* firstName:string,
* lastName:string,
* siteName:?string,
* contractName:?string,
* trackingMode:?string,
* daily:list<array{date:string, dayMinutes:int, nightMinutes:int, totalMinutes:int, present:?float}>,
* weeklyDayMinutes:int,
* weeklyNightMinutes:int,
* weeklyTotalMinutes:int,
* weeklyPresenceCount:float,
* weeklyOvertime25Minutes:int,
* weeklyOvertime50Minutes:int
* }>
*/
private function buildRows(array $employees, array $workHours, array $days): array
{
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByEmployeeDate[$employeeId][$dateKey] = [
'metrics' => $this->computeMetrics($workHour),
'isPresentMorning' => $workHour->getIsPresentMorning(),
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
];
}
$rows = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
$weeklyDayMinutes = 0;
$weeklyNightMinutes = 0;
$weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0;
$daily = [];
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode();
foreach ($days as $date) {
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
$metrics = $entry['metrics'] ?? [
'dayMinutes' => 0,
'nightMinutes' => 0,
'totalMinutes' => 0,
];
$present = null;
if ($isPresenceTracking) {
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
$present = $morning + $afternoon;
}
$weeklyDayMinutes += $metrics['dayMinutes'];
$weeklyNightMinutes += $metrics['nightMinutes'];
$weeklyTotalMinutes += $metrics['totalMinutes'];
if (null !== $present) {
$weeklyPresenceCount += $present;
}
$daily[] = [
'date' => $date,
'dayMinutes' => $metrics['dayMinutes'],
'nightMinutes' => $metrics['nightMinutes'],
'totalMinutes' => $metrics['totalMinutes'],
'present' => $present,
];
}
$rows[] = [
'employeeId' => $employeeId,
'firstName' => $employee->getFirstName(),
'lastName' => $employee->getLastName(),
'siteName' => $employee->getSite()?->getName(),
'contractName' => $employee->getContract()?->getName(),
'trackingMode' => $employee->getContract()?->getTrackingMode(),
'daily' => $daily,
'weeklyDayMinutes' => $weeklyDayMinutes,
'weeklyNightMinutes' => $weeklyNightMinutes,
'weeklyTotalMinutes' => $weeklyTotalMinutes,
'weeklyPresenceCount' => $weeklyPresenceCount,
'weeklyOvertime25Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime25Minutes($weeklyTotalMinutes),
'weeklyOvertime50Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime50Minutes($weeklyTotalMinutes),
];
}
return $rows;
}
/**
* @return array{dayMinutes:int, nightMinutes:int, totalMinutes:int}
*/
private function computeMetrics(WorkHour $workHour): array
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
];
$totalMinutes = 0;
$nightMinutes = 0;
foreach ($ranges as [$from, $to]) {
$totalMinutes += $this->intervalMinutes($from, $to);
$nightMinutes += $this->nightIntervalMinutes($from, $to);
}
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return [
'dayMinutes' => $dayMinutes,
'nightMinutes' => $nightMinutes,
'totalMinutes' => $totalMinutes,
];
}
/**
* @return null|array{int, int}
*/
private function resolveInterval(?string $from, ?string $to): ?array
{
$fromMinutes = $this->toMinutes($from);
$toMinutes = $this->toMinutes($to);
if (null === $fromMinutes || null === $toMinutes) {
return null;
}
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
}
private function toMinutes(?string $time): ?int
{
if (null === $time || '' === $time) {
return null;
}
[$hours, $minutes] = array_map('intval', explode(':', $time));
return ($hours * 60) + $minutes;
}
private function intervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
return max(0, $end - $start);
}
private function nightIntervalMinutes(?string $from, ?string $to): int
{
$interval = $this->resolveInterval($from, $to);
if (null === $interval) {
return 0;
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
}
}
return $total;
}
private function overlap(int $startA, int $endA, int $startB, int $endB): int
{
$start = max($startA, $startB);
$end = min($endA, $endB);
return max(0, $end - $start);
}
private function computeOvertime25Minutes(int $weeklyTotalMinutes): int
{
return max(0, min($weeklyTotalMinutes, 43 * 60) - (35 * 60));
}
private function computeOvertime50Minutes(int $weeklyTotalMinutes): int
{
return max(0, $weeklyTotalMinutes - (43 * 60));
}
}