fix : wip

This commit is contained in:
2026-02-19 17:44:37 +01:00
parent c2e118dc33
commit 13274ff297
31 changed files with 1539 additions and 126 deletions

View File

@@ -9,6 +9,7 @@
id="employee"
v-model="absenceForm.employeeId"
:class="employeeFieldClass"
:disabled="props.lockEmployee"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
@@ -48,6 +49,7 @@
v-model="absenceForm.startDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.startHalf"
@@ -67,6 +69,7 @@
v-model="absenceForm.endDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.endHalf"
@@ -80,7 +83,7 @@
</div>
</div>
<div>
<div v-if="props.showComment !== false">
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea
id="comment"
@@ -142,6 +145,9 @@ const props = defineProps<{
}
editingAbsence: Absence | null
isSubmitting: boolean
lockEmployee?: boolean
lockDates?: boolean
showComment?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,5 +1,5 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex 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"
@@ -12,11 +12,22 @@
<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">Absence</span>
<span class="pl-2">Jour</span>
<span>Nuit</span>
<span>Total</span>
<span v-if="isAdmin">Valider</span>
<span class="inline-flex items-center gap-2">
<span v-if="isAdmin">Valider</span>
<span v-else>Validation RH</span>
<input
v-if="isAdmin"
ref="bulkValidationInput"
:checked="isBulkValidationChecked"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onBulkValidationChange"
/>
</span>
</div>
<div
@@ -33,38 +44,78 @@
<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)"/>
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningFrom"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
<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)"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningTo" :disabled="isRowLocked(employee.id)"/>
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningTo"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div class="pl-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonFrom" :disabled="isRowLocked(employee.id)"/>
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonFrom"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
<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)"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonTo" :disabled="isRowLocked(employee.id)"/>
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonTo"
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div class="pl-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningFrom" :disabled="isRowLocked(employee.id)"/>
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningFrom"
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningTo" :disabled="isRowLocked(employee.id)"/>
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningTo"
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pl-2 self-stretch flex flex-col justify-between py-0.5">
<p
class="text-sm text-neutral-700 truncate"
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isRowLocked(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</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>
@@ -73,16 +124,17 @@
</div>
<div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
</div>
<div v-if="isAdmin">
<div>
<input
v-if="isAdmin"
: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)"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
</div>
</div>
</div>
@@ -95,8 +147,9 @@ import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { HourRow } from './types'
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null)
defineProps<{
const props = defineProps<{
employees: Employee[]
isAdmin: boolean
dayGridCols: string
@@ -104,9 +157,31 @@ defineProps<{
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
isRowLocked: (employeeId: number) => boolean
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
isEveningLockedByAbsence: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string
}>()
const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
watch(
() => props.isBulkValidationIndeterminate,
(isIndeterminate) => {
if (!bulkValidationInput.value) return
bulkValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="py-6 flex flex-col gap-3">
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
@@ -34,14 +34,46 @@
</button>
</div>
<div
v-else
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="weekShortcutButtonClass('previousWeek')"
@click="emit('set-previous-week')"
>
{{ getWeekShortcutLabel('previousWeek') }}
</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="weekShortcutButtonClass('thisWeek')"
@click="emit('set-this-week')"
>
{{ getWeekShortcutLabel('thisWeek') }}
</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="weekShortcutButtonClass('nextWeek')"
@click="emit('set-next-week')"
>
{{ getWeekShortcutLabel('nextWeek') }}
</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"
:value="pickerValue"
:type="viewMode === 'week' ? 'week' : 'date'"
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
tabindex="-1"
aria-hidden="true"
@input="onPickerInput"
@change="onPickerInput"
/>
<button
type="button"
@@ -91,7 +123,7 @@
</div>
</div>
<div class="w-80 max-w-full">
<div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
@@ -101,6 +133,7 @@
import type { Site } from '~/services/dto/site'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
@@ -112,16 +145,25 @@ defineProps<{
sites: Site[]
formattedSelectedDate: string
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>()
const emit = defineEmits<{
(e: 'set-yesterday'): void
(e: 'set-today'): void
(e: 'set-tomorrow'): void
(e: 'set-previous-week'): void
(e: 'set-this-week'): void
(e: 'set-next-week'): void
(e: 'shift-date', value: number): void
}>()
const nativeDateInput = ref<HTMLInputElement | null>(null)
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value
})
const viewModeButtonClass = (mode: 'day' | 'week') => {
if (viewMode.value === mode) {
@@ -141,4 +183,18 @@ const openDatePicker = () => {
input.focus()
input.click()
}
const onPickerInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
if (!value) return
if (viewMode.value === 'week') {
const ymd = weekInputValueToYmd(value)
if (!ymd) return
selectedDate.value = ymd
return
}
selectedDate.value = value
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex 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
@@ -8,16 +8,18 @@
>
<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>Jour/Nuit <br>sem.</span>
<span>Total <br>sem.</span>
<span>Total <br>h. supp.</span>
<span>+25%</span>
<span>+50%</span>
<span>Total <br>récup.</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"
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 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
@@ -46,12 +48,18 @@
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
</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 class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
</div>
</div>
</div>

View File

@@ -1,11 +1,17 @@
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 { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import type { AbsenceType } from '~/services/dto/absence-type'
import type { Absence } from '~/services/dto/absence'
import type { HalfDay } from '~/services/dto/half-day'
import type { HourRow } from '~/components/hours/types'
import { listScopedEmployees } from '~/services/employees'
import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import {
bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary,
listWorkHoursByDate,
updateWorkHourValidation
@@ -14,7 +20,9 @@ import {
formatDateLongFr,
formatWeekDayHeaderFr,
formatWeekRangeFr,
getIsoWeekNumber,
getOffsetFromTodayYmd,
getWeekStartDate,
getTodayYmd,
parseYmd,
shiftYmd
@@ -23,6 +31,7 @@ import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
export const useHoursPage = () => {
const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const viewMode = ref<'day' | 'week'>('day')
@@ -32,19 +41,33 @@ export const useHoursPage = () => {
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
const rows = ref<Record<number, HourRow>>({})
const dayContext = ref<WorkHourDayContext | null>(null)
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const isAbsenceDrawerOpen = ref(false)
const isAbsenceSubmitting = ref(false)
const editingAbsence = ref<Absence | null>(null)
const absenceForm = ref({
employeeId: '' as number | '',
typeId: '' as number | '',
startDate: '',
startHalf: 'AM' as HalfDay,
endDate: '',
endHalf: 'PM' as HalfDay,
comment: ''
})
const isLoading = ref(false)
const isWeekLoading = ref(false)
const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([])
const dayGridCols = computed(() => {
const metricCol = '0.5fr'
const cols = `1.2fr repeat(6, 1fr) ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
return isAdmin.value ? `${cols} ${metricCol}` : cols
const metricCol = '0.4fr'
return `1.2fr repeat(6, 1fr) 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
})
const weekGridCols = '1.6fr repeat(7, 1fr) 1fr 0.8fr 0.8fr 0.8fr'
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>()
@@ -94,6 +117,34 @@ export const useHoursPage = () => {
})
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
const validatableEmployeeIds = computed(() => {
return employees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleValidation(employeeId))
})
const isBulkValidationChecked = computed(() => {
const ids = validatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
})
const isBulkValidationIndeterminate = computed(() => {
const ids = validatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const dayContextByEmployeeId = computed(() => {
const map = new Map<number, WorkHourDayContext['rows'][number]>()
for (const row of dayContext.value?.rows ?? []) {
map.set(row.employeeId, row)
}
return map
})
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
const targetDate = target === 'yesterday'
@@ -109,6 +160,37 @@ export const useHoursPage = () => {
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const selected = parseYmd(selectedDate.value)
if (!selected) {
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const today = new Date()
const targetDate = new Date(today)
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
const selectedWeekStart = getWeekStartDate(selected)
const targetWeekStart = getWeekStartDate(targetDate)
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
if (isActive) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const today = new Date()
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
const weekNumber = getIsoWeekNumber(today)
return `Sem. S${weekNumber}`
}
const formattedSelectedDate = computed(() => {
const parsed = parseYmd(selectedDate.value)
if (!parsed) return selectedDate.value
@@ -146,6 +228,40 @@ export const useHoursPage = () => {
shiftDate(1)
}
const setThisWeek = () => {
selectedDate.value = getTodayYmd()
}
const setPreviousWeek = () => {
const previousWeek = shiftYmd(getTodayYmd(), -7)
if (!previousWeek) return
selectedDate.value = previousWeek
}
const setNextWeek = () => {
const nextWeek = shiftYmd(getTodayYmd(), 7)
if (!nextWeek) return
selectedDate.value = nextWeek
}
const resetAbsenceForm = () => {
absenceForm.value = {
employeeId: '',
typeId: '',
startDate: '',
startHalf: 'AM',
endDate: '',
endHalf: 'PM',
comment: ''
}
}
const closeAbsenceDrawer = () => {
isAbsenceDrawerOpen.value = false
editingAbsence.value = null
resetAbsenceForm()
}
const emptyRow = (): HourRow => ({
workHourId: null,
morningFrom: '',
@@ -243,10 +359,42 @@ export const useHoursPage = () => {
nightMinutes += nightIntervalMinutes(from, to)
}
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes }
}
const getRowAbsenceLabel = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
return `${dayRow.absenceLabel} (${halfLabel})`
}
return `${dayRow.absenceLabel} (journée)`
}
const getPresenceDayValue = (employeeId: number) => {
const row = rows.value[employeeId]
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
const total = Math.min(1, basePresence + creditedPresence)
return Number.isInteger(total) ? String(total) : total.toFixed(1)
}
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
}
const isEveningLockedByAbsence = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false
return dayRow.absentMorning && dayRow.absentAfternoon
}
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes)
const hours = Math.floor(safeMinutes / 60)
@@ -280,19 +428,162 @@ export const useHoursPage = () => {
rows.value = nextRows
}
const toggleValidation = async (employeeId: number, checked: boolean) => {
const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadAbsences = async () => {
absences.value = await listAbsences({
from: selectedDate.value,
to: selectedDate.value,
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
})
}
const openAbsenceDrawer = (employeeId: number) => {
const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false
const start = absence.startDate.slice(0, 10)
const end = absence.endDate.slice(0, 10)
return selectedDate.value >= start && selectedDate.value <= end
}) ?? null
if (existing) {
editingAbsence.value = existing
absenceForm.value = {
employeeId,
typeId: existing.type?.id ?? '',
startDate: existing.startDate.slice(0, 10),
startHalf: existing.startHalf ?? 'AM',
endDate: existing.endDate.slice(0, 10),
endHalf: existing.endHalf ?? 'PM',
comment: existing.comment ?? ''
}
} else {
editingAbsence.value = null
absenceForm.value = {
employeeId,
typeId: '',
startDate: selectedDate.value,
startHalf: 'AM',
endDate: selectedDate.value,
endHalf: 'PM',
comment: ''
}
}
isAbsenceDrawerOpen.value = true
}
const submitAbsence = async () => {
const form = absenceForm.value
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
isAbsenceSubmitting.value = true
try {
if (editingAbsence.value) {
await updateAbsence({
id: editingAbsence.value.id,
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: editingAbsence.value.comment ?? ''
})
} else {
await createAbsence({
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: ''
})
}
closeAbsenceDrawer()
await refreshByDate()
await loadAbsences()
} finally {
isAbsenceSubmitting.value = false
}
}
const deleteAbsenceFromDrawer = async () => {
if (!editingAbsence.value || isAbsenceSubmitting.value) return
isAbsenceSubmitting.value = true
try {
await deleteAbsence(editingAbsence.value.id)
closeAbsenceDrawer()
await refreshByDate()
await loadAbsences()
} finally {
isAbsenceSubmitting.value = false
}
}
const toggleValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
if (!row?.workHourId || isValidationPending(employeeId)) return
validatingRowIds.value = [...validatingRowIds.value, employeeId]
try {
await updateWorkHourValidation(row.workHourId, checked)
await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast })
row.isValid = checked
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = validatableEmployeeIds.value
if (employeeIds.length === 0) return
let successCount = 0
let failedCount = 0
for (const employeeId of employeeIds) {
if (isValidationPending(employeeId)) continue
try {
await toggleValidation(employeeId, checked, { toast: false })
successCount += 1
} catch {
failedCount += 1
}
}
if (failedCount === 0) {
toast.success({
title: 'Succès',
message: checked
? `${successCount} ligne(s) validée(s).`
: `${successCount} validation(s) retirée(s).`
})
return
}
if (successCount === 0) {
toast.error({
title: 'Erreur',
message: 'Impossible de mettre à jour les validations.'
})
return
}
toast.error({
title: 'Erreur',
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
})
}
const loadEmployees = async () => {
const scopedEmployees = await listScopedEmployees()
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
@@ -312,20 +603,25 @@ export const useHoursPage = () => {
}
}
const loadDayContext = async () => {
dayContext.value = await getWorkHourDayContext(selectedDate.value)
}
const refreshByDate = async () => {
if (isAdmin.value) {
await Promise.all([loadWorkHours(), loadWeeklySummary()])
await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
}
weeklySummary.value = null
await loadWorkHours()
await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
}
const loadPage = async () => {
isLoading.value = true
try {
await loadEmployees()
await loadAbsenceTypes()
await refreshByDate()
} finally {
isLoading.value = false
@@ -347,11 +643,15 @@ export const useHoursPage = () => {
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
}, { immediate: true })
watch(isAdmin, (admin) => {
watch(isAdmin, async (admin) => {
if (!admin) {
viewMode.value = 'day'
weeklySummary.value = null
await Promise.all([loadAbsenceTypes(), loadAbsences()])
return
}
await loadAbsenceTypes()
await loadAbsences()
}, { immediate: true })
watch(selectedDate, async () => {
@@ -414,6 +714,11 @@ export const useHoursPage = () => {
employees,
visibleEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
weeklySummary,
filteredWeeklySummary,
isLoading,
@@ -425,17 +730,34 @@ export const useHoursPage = () => {
formattedSelectedDate,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isTimeTracking,
isPresenceTracking,
isRowLocked,
isHalfLockedByAbsence,
isEveningLockedByAbsence,
isValidationPending,
canToggleValidation,
isBulkValidationChecked,
isBulkValidationIndeterminate,
toggleValidation,
toggleValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
}

View File

@@ -3,7 +3,9 @@ export default defineNuxtConfig({
devtools: {enabled: false},
ssr: false,
app: {
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
: '/'
},
modules: [
'@nuxtjs/tailwindcss',
@@ -37,4 +39,4 @@ export default defineNuxtConfig({
typescript: {
strict: true
}
})
})

View File

@@ -19,10 +19,11 @@
</div>
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_120px_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_160px_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">Code</span>
<span class="text-left">Libellé</span>
<span class="text-left">Couleur</span>
<span class="text-left">Compte en heures</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
@@ -32,7 +33,7 @@
<div
v-for="type in absenceTypes"
:key="type.id"
class="grid grid-cols-[120px_120px_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_160px_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 class="font-semibold text-left">{{ type.code }}</span>
<span class="text-left">{{ type.label }}</span>
@@ -43,6 +44,14 @@
/>
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
</div>
<div class="text-left">
<span
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
>
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
@@ -94,6 +103,31 @@
Le libellé est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Compté comme travaillé
</label>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="true"
/>
Oui
</label>
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="false"
/>
Non
</label>
</div>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
@@ -150,7 +184,8 @@ const drawerTitle = computed(() =>
const form = reactive({
code: '',
label: '',
color: '#222783'
color: '#222783',
countAsWorkedHours: true
})
const validationTouched = reactive({
@@ -214,6 +249,7 @@ const resetForm = () => {
form.code = ''
form.label = ''
form.color = '#222783'
form.countAsWorkedHours = true
}
const openCreate = () => {
@@ -227,6 +263,7 @@ const openEdit = (type: AbsenceType) => {
form.code = type.code
form.label = type.label
form.color = type.color
form.countAsWorkedHours = type.countAsWorkedHours
isDrawerOpen.value = true
}
@@ -249,13 +286,15 @@ const handleSubmit = async () => {
await updateAbsenceType(editingType.value.id, {
code: form.code,
label: form.label,
color: form.color
color: form.color,
countAsWorkedHours: form.countAsWorkedHours
})
} else {
await createAbsenceType({
code: form.code,
label: form.label,
color: form.color
color: form.color,
countAsWorkedHours: form.countAsWorkedHours
})
}

View File

@@ -13,9 +13,14 @@
:sites="sites"
:formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass"
:week-shortcut-button-class="weekShortcutButtonClass"
:get-week-shortcut-label="getWeekShortcutLabel"
@set-yesterday="setYesterday"
@set-today="setToday"
@set-tomorrow="setTomorrow"
@set-previous-week="setPreviousWeek"
@set-this-week="setThisWeek"
@set-next-week="setNextWeek"
@shift-date="shiftDate"
/>
@@ -28,30 +33,43 @@
</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"
<div class="flex-1 min-h-0 flex flex-col">
<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-half-locked-by-absence="isHalfLockedByAbsence"
:is-evening-locked-by-absence="isEveningLockedByAbsence"
:is-validation-pending="isValidationPending"
:can-toggle-validation="canToggleValidation"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:on-toggle-validation="toggleValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
class="max-h-full"
/>
<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"
/>
<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"
class="max-h-full"
/>
</div>
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
<button
@@ -65,6 +83,21 @@
</button>
</div>
</div>
<AbsenceFormDrawer
v-model="isAbsenceDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="absenceForm"
:editing-absence="editingAbsence"
:is-submitting="isAbsenceSubmitting"
:lock-employee="true"
:lock-dates="true"
:show-comment="false"
@submit="submitAbsence"
@delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer"
/>
</div>
</template>
@@ -79,6 +112,11 @@ const {
employees,
visibleEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
filteredWeeklySummary,
isLoading,
isWeekLoading,
@@ -89,17 +127,34 @@ const {
formattedSelectedDate,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isTimeTracking,
isPresenceTracking,
isRowLocked,
isHalfLockedByAbsence,
isEveningLockedByAbsence,
isValidationPending,
canToggleValidation,
isBulkValidationChecked,
isBulkValidationIndeterminate,
toggleValidation,
toggleValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
} = useHoursPage()

View File

@@ -12,7 +12,7 @@ export const listAbsenceTypes = async () => {
}
export const createAbsenceType = async (
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => {
const api = useApi()
return api.post<AbsenceType>('/absence_types', payload, {
@@ -23,7 +23,7 @@ export const createAbsenceType = async (
export const updateAbsenceType = async (
id: number,
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => {
const api = useApi()
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {

View File

@@ -3,4 +3,5 @@ export type AbsenceType = {
code: string
label: string
color: string
countAsWorkedHours: boolean
}

View File

@@ -47,8 +47,10 @@ export type WeeklyWorkHourRowSummary = {
weeklyNightMinutes: number
weeklyTotalMinutes: number
weeklyPresenceCount?: number
weeklyOvertimeTotalMinutes?: number
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
}
export type WeeklyWorkHourSummary = {
@@ -57,3 +59,18 @@ export type WeeklyWorkHourSummary = {
days: string[]
rows: WeeklyWorkHourRowSummary[]
}
export type WorkHourDayContextRow = {
employeeId: number
absenceLabel?: string | null
absenceHalf?: 'AM' | 'PM' | null
absentMorning: boolean
absentAfternoon: boolean
creditedMinutes: number
creditedPresenceUnits: number
}
export type WorkHourDayContext = {
workDate: string
rows: WorkHourDayContextRow[]
}

View File

@@ -1,4 +1,5 @@
import type {
WorkHourDayContext,
WorkHour,
WorkHourEntryPayload,
WeeklyWorkHourSummary
@@ -39,12 +40,17 @@ export const bulkUpsertWorkHours = async (payload: {
)
}
export const updateWorkHourValidation = async (id: number, isValid: boolean) => {
export const updateWorkHourValidation = async (
id: number,
isValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}`,
{ isValid },
{
toast: options?.toast ?? true,
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
toastErrorMessage: 'Impossible de mettre à jour la validation.'
}
@@ -59,3 +65,12 @@ export const getWeeklyWorkHourSummary = async (weekStart: string) => {
{ toast: false }
)
}
export const getWorkHourDayContext = async (workDate: string) => {
const api = useApi()
return api.get<WorkHourDayContext>(
'/work-hours/day-context',
{ workDate },
{ toast: false }
)
}

View File

@@ -42,6 +42,46 @@ export const getWeekStartDate = (date: Date) => {
return copy
}
export const getIsoWeekNumber = (date: Date) => {
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const day = utc.getUTCDay() || 7
utc.setUTCDate(utc.getUTCDate() + 4 - day)
const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1))
return Math.ceil((((utc.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export const getIsoWeekYear = (date: Date) => {
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const day = utc.getUTCDay() || 7
utc.setUTCDate(utc.getUTCDate() + 4 - day)
return utc.getUTCFullYear()
}
export const ymdToWeekInputValue = (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return ''
const weekDate = getWeekStartDate(parsed)
const weekNumber = getIsoWeekNumber(weekDate)
const weekYear = getIsoWeekYear(weekDate)
return `${weekYear}-W${String(weekNumber).padStart(2, '0')}`
}
export const weekInputValueToYmd = (weekValue: string) => {
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue)
if (!match) return null
const year = Number(match[1])
const week = Number(match[2])
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) return null
const jan4 = new Date(year, 0, 4)
const week1Monday = getWeekStartDate(jan4)
const monday = new Date(week1Monday)
monday.setDate(week1Monday.getDate() + ((week - 1) * 7))
return toYmd(monday.getFullYear(), monday.getMonth(), monday.getDate())
}
export const getTodayYmd = () => {
const date = new Date()
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
@@ -64,6 +104,7 @@ export const formatWeekRangeFr = (date: Date) => {
const start = getWeekStartDate(date)
const end = new Date(start)
end.setDate(start.getDate() + 6)
const weekNumber = getIsoWeekNumber(start)
const formatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
@@ -71,7 +112,7 @@ export const formatWeekRangeFr = (date: Date) => {
year: 'numeric'
})
return `Semaine du ${formatter.format(start)} au ${formatter.format(end)}`
return `S${weekNumber} du ${formatter.format(start)} au ${formatter.format(end)}`
}
export const getDaysInMonth = (year: number, month: number) => {

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218190000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add count_as_worked_hours on absence_types';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_types ADD count_as_worked_hours BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_types DROP count_as_worked_hours');
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\WorkHourDayContextProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/work-hours/day-context',
security: "is_granted('ROLE_USER')",
provider: WorkHourDayContextProvider::class
),
],
paginationEnabled: false
)]
final class WorkHourDayContext
{
public string $workDate = '';
/**
* @var list<array{
* employeeId:int,
* absenceLabel:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* }>
*/
public array $rows = [];
}

View File

@@ -45,8 +45,10 @@ final class WorkHourWeeklySummary
* weeklyNightMinutes:int,
* weeklyTotalMinutes:int,
* weeklyPresenceCount:float,
* weeklyOvertimeTotalMinutes:int,
* weeklyOvertime25Minutes:int,
* weeklyOvertime50Minutes:int
* weeklyOvertime50Minutes:int,
* weeklyRecoveryMinutes:int
* }>
*/
public array $rows = [];

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Absence;
use App\Entity\User;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class AbsenceCollectionExtension implements QueryCollectionExtensionInterface
{
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
if (Absence::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
$queryBuilder->andWhere('1 = 0');
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$employeeAlias = 'absence_employee_scope';
$queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias)
->addSelect($employeeAlias)
;
$this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'absence_scope', $user);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class DayContextRow
{
public function __construct(
public int $employeeId,
public ?string $absenceLabel = null,
public ?string $absenceHalf = null,
public bool $absentMorning = false,
public bool $absentAfternoon = false,
public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0,
) {}
public function addAbsence(
?string $label,
bool $morning,
bool $afternoon,
int $creditedMinutes,
float $creditedPresenceUnits
): void {
// Fusionne plusieurs absences du même jour sur la ligne salarié.
$this->absentMorning = $this->absentMorning || $morning;
$this->absentAfternoon = $this->absentAfternoon || $afternoon;
// Garde un libellé lisible: unique si possible, sinon "Absences multiples".
if (null === $this->absenceLabel) {
$this->absenceLabel = $label;
} elseif ($label !== $this->absenceLabel) {
$this->absenceLabel = 'Absences multiples';
}
// AM/PM seulement pour les demi-journées, null pour journée complète.
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
// Cumule les minutes créditées par les absences "comptées comme travaillées".
$this->creditedMinutes += $creditedMinutes;
// Cumule les unités de présence créditées (0.5 par demi-journée).
$this->creditedPresenceUnits += $creditedPresenceUnits;
}
/**
* @return array{
* employeeId:int,
* absenceLabel:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* }
*/
public function toArray(): array
{
return [
'employeeId' => $this->employeeId,
'absenceLabel' => $this->absenceLabel,
'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning,
'absentAfternoon' => $this->absentAfternoon,
'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits,
];
}
private function resolveHalfLabel(bool $morning, bool $afternoon): ?string
{
// Matin + après-midi => journée complète, pas de libellé AM/PM.
if ($morning && $afternoon) {
return null;
}
if ($morning) {
return 'AM';
}
if ($afternoon) {
return 'PM';
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class WorkMetrics
{
public function __construct(
public int $dayMinutes = 0,
public int $nightMinutes = 0,
public int $totalMinutes = 0,
) {}
public function addCreditedMinutes(int $creditedMinutes): void
{
// Ignore les valeurs nulles ou négatives pour ne pas biaiser les totaux.
if ($creditedMinutes <= 0) {
return;
}
// Le crédit absence alimente les heures de jour et le total.
$this->dayMinutes += $creditedMinutes;
$this->totalMinutes += $creditedMinutes;
}
}

View File

@@ -9,12 +9,39 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\State\AbsenceWriteProcessor;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_USER')"
),
new Get(
security: "is_granted('ABSENCE_VIEW', object)"
),
new Post(
securityPostDenormalize: "is_granted('ABSENCE_EDIT', object)",
processor: AbsenceWriteProcessor::class
),
new Patch(
security: "is_granted('ABSENCE_EDIT', object)",
processor: AbsenceWriteProcessor::class
),
new Delete(
security: "is_granted('ABSENCE_EDIT', object)",
processor: AbsenceWriteProcessor::class
),
],
normalizationContext: [
'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
'datetime_format' => 'Y-m-d',
@@ -23,7 +50,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
'datetime_format' => 'Y-m-d',
],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')",
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
@@ -53,17 +79,17 @@ class Absence
#[Groups(['absence:read'])]
private DateTimeInterface $startDate;
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'AM'])]
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'AM'])]
#[Groups(['absence:read'])]
private string $startHalf = 'AM';
private HalfDay $startHalf = HalfDay::AM;
#[ORM\Column(type: 'date')]
#[Groups(['absence:read'])]
private DateTimeInterface $endDate;
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'PM'])]
#[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'PM'])]
#[Groups(['absence:read'])]
private string $endHalf = 'PM';
private HalfDay $endHalf = HalfDay::PM;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['absence:read'])]
@@ -122,24 +148,24 @@ class Absence
return $this;
}
public function getStartHalf(): string
public function getStartHalf(): HalfDay
{
return $this->startHalf;
}
public function setStartHalf(string $startHalf): self
public function setStartHalf(HalfDay $startHalf): self
{
$this->startHalf = $startHalf;
return $this;
}
public function getEndHalf(): string
public function getEndHalf(): HalfDay
{
return $this->endHalf;
}
public function setEndHalf(string $endHalf): self
public function setEndHalf(HalfDay $endHalf): self
{
$this->endHalf = $endHalf;

View File

@@ -5,13 +5,34 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_USER')"
),
new Get(
security: "is_granted('ROLE_USER')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')"
),
],
normalizationContext: ['groups' => ['absence_type:read']],
paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')"
)]
#[ORM\Entity]
#[ORM\Table(name: 'absence_types')]
@@ -35,6 +56,10 @@ class AbsenceType
#[Groups(['absence:read', 'absence_type:read'])]
private string $color = '';
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['absence:read', 'absence_type:read'])]
private bool $countAsWorkedHours = false;
public function getId(): ?int
{
return $this->id;
@@ -75,4 +100,21 @@ class AbsenceType
return $this;
}
public function isCountAsWorkedHours(): bool
{
return $this->countAsWorkedHours;
}
public function getCountAsWorkedHours(): bool
{
return $this->countAsWorkedHours;
}
public function setCountAsWorkedHours(bool $countAsWorkedHours): self
{
$this->countAsWorkedHours = $countAsWorkedHours;
return $this;
}
}

11
src/Enum/HalfDay.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum HalfDay: string
{
case AM = 'AM';
case PM = 'PM';
}

View File

@@ -43,7 +43,33 @@ final class AbsenceRepository extends ServiceEntityRepository
->setParameter('employees', $employees)
;
/** @var list<Absence> $absences */
// @var list<Absence> $absences
return $qb->getQuery()->getResult();
}
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.startDate <= :date')
->andWhere('a.endDate >= :date')
->andWhere('a.employee IN (:employees)')
->setParameter('date', $date)
->setParameter('employees', $employees)
;
// @var list<Absence> $absences
return $qb->getQuery()->getResult();
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Repository;
use App\Entity\Employee;
use App\Entity\WorkHour;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -76,7 +77,27 @@ final class WorkHourRepository extends ServiceEntityRepository
->setParameter('employees', $employees)
;
/** @var list<WorkHour> $workHours */
// @var list<WorkHour> $workHours
return $qb->getQuery()->getResult();
}
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
{
$fromDate = DateTimeImmutable::createFromInterface($from);
$toDate = DateTimeImmutable::createFromInterface($to);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isValid = :isValid')
->setParameter('employee', $employee)
->setParameter('from', $fromDate)
->setParameter('to', $toDate)
->setParameter('isValid', true)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Absence;
use App\Entity\User;
use App\Security\EmployeeScopeService;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class AbsenceVoter extends Voter
{
public const string VIEW = 'ABSENCE_VIEW';
public const string EDIT = 'ABSENCE_EDIT';
public function __construct(
private readonly Security $security,
private readonly EmployeeScopeService $employeeScopeService,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof Absence;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return false;
}
if (!$subject instanceof Absence) {
return false;
}
$employee = $subject->getEmployee();
if (null === $employee) {
return false;
}
return $this->employeeScopeService->canAccessEmployee($user, $employee);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\Absence;
use App\Enum\HalfDay;
final class AbsenceSegmentsResolver
{
/**
* @return array{bool, bool}
*/
public function resolveForDate(Absence $absence, string $dateYmd): array
{
$startDate = $absence->getStartDate()->format('Y-m-d');
$endDate = $absence->getEndDate()->format('Y-m-d');
$startHalf = $absence->getStartHalf();
$endHalf = $absence->getEndHalf();
// Cas d'une absence sur une seule date: on déduit matin/après-midi depuis les bornes.
if ($startDate === $endDate) {
if (HalfDay::AM === $startHalf && HalfDay::AM === $endHalf) {
// Uniquement le matin absent.
return [true, false];
}
if (HalfDay::PM === $startHalf && HalfDay::PM === $endHalf) {
// Uniquement l'après-midi absent.
return [false, true];
}
// Sinon, on considère la journée complète absente.
return [true, true];
}
// Premier jour d'une absence multi-jours qui commence l'après-midi.
if ($dateYmd === $startDate && HalfDay::PM === $startHalf) {
return [false, true];
}
// Dernier jour d'une absence multi-jours qui se termine le matin.
if ($dateYmd === $endDate && HalfDay::AM === $endHalf) {
return [true, false];
}
// Les jours intermédiaires sont entièrement absents.
return [true, true];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\Absence;
use App\Entity\Contract;
use DateMalformedStringException;
use DateTimeImmutable;
final class WorkedHoursCreditPolicy
{
/**
* @throws DateMalformedStringException
*/
public function computeCreditedMinutes(Absence $absence, string $dateYmd, bool $absentMorning, bool $absentAfternoon): int
{
$type = $absence->getType();
// Certaines absences ne doivent jamais générer d'heures créditées.
if (!$type?->getCountAsWorkedHours()) {
return 0;
}
$employee = $absence->getEmployee();
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
if (Contract::TRACKING_TIME !== $employee?->getContract()?->getTrackingMode()) {
return 0;
}
$weekday = (int) new DateTimeImmutable($dateYmd)->format('N');
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
$dayMinutes = $this->resolveContractDayMinutes($employee->getContract()?->getWeeklyHours(), $weekday);
if ($dayMinutes <= 0) {
return 0;
}
// Crédit en demi-journées: matin = 0.5, après-midi = 0.5.
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
return (int) round(($dayMinutes / 2) * $halfUnits);
}
public function computeCreditedPresenceUnits(Absence $absence, bool $absentMorning, bool $absentAfternoon): float
{
$type = $absence->getType();
if (!$type?->getCountAsWorkedHours()) {
return 0.0;
}
$employee = $absence->getEmployee();
if (Contract::TRACKING_PRESENCE !== $employee?->getContract()?->getTrackingMode()) {
return 0.0;
}
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
return $halfUnits * 0.5;
}
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
{
// Week-end non travaillé dans cette politique.
if ($isoWeekDay >= 6) {
return 0;
}
// Règle fixe: 35h => 7h/jour.
if (35 === $weeklyHours) {
return 7 * 60;
}
// Règle fixe: 39h => 8h lundi-jeudi, 7h le vendredi.
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
// Cas spécifique métier: contrat 4h/semaine réparti sur 2 jours => 2h/jour.
if (4 === $weeklyHours) {
return 2 * 60;
}
// Contrat non renseigné/invalide: aucun crédit.
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
// Fallback générique: répartition homogène sur 5 jours ouvrés.
return (int) round(($weeklyHours * 60) / 5);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Service\PublicHolidayServiceInterface;
@@ -164,13 +165,13 @@ class AbsencePrintProvider implements ProviderInterface
if ($isSameDay) {
if ($startHalf === $endHalf) {
$halfLabel = $startHalf;
$halfLabel = $startHalf->value;
}
} else {
if ($isStartDay && 'PM' === $startHalf) {
if ($isStartDay && HalfDay::PM === $startHalf) {
$halfLabel = 'PM';
}
if ($isEndDay && 'AM' === $endHalf) {
if ($isEndDay && HalfDay::AM === $endHalf) {
$halfLabel = 'AM';
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Absence;
use App\Repository\WorkHourRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private WorkHourRepository $workHourRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Absence) {
return $data;
}
$employee = $data->getEmployee();
if (null === $employee) {
return $data;
}
if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourDayContext;
use App\Dto\WorkHours\DayContextRow;
use App\Entity\User;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
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 WorkHourDayContextProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private AbsenceRepository $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
{
$user = $this->security->getUser();
// Endpoint protégé: on exige un utilisateur authentifié.
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$rowsByEmployeeId = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
// On initialise toutes les lignes, même sans absence ce jour-là.
$rowsByEmployeeId[$employeeId] = new DayContextRow(employeeId: $employeeId);
}
$dateKey = $workDate->format('Y-m-d');
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
// Ignore les absences orphelines ou hors scope utilisateur.
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $dateKey);
// Pas de segment absent sur ce jour: rien à injecter dans la ligne.
if (!$absentMorning && !$absentAfternoon) {
continue;
}
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
$rowsByEmployeeId[$employeeId]->addAbsence(
label: $absence->getType()?->getLabel(),
morning: $absentMorning,
afternoon: $absentAfternoon,
creditedMinutes: $creditedMinutes,
creditedPresenceUnits: $creditedPresenceUnits
);
}
$response = new WorkHourDayContext();
$response->workDate = $dateKey;
$response->rows = array_map(
static fn (DayContextRow $row): array => $row->toArray(),
array_values($rowsByEmployeeId)
);
return $response;
}
private function resolveWorkDate(): DateTimeImmutable
{
$query = $this->requestStack->getCurrentRequest()?->query;
$raw = (string) ($query?->get('workDate') ?? '');
// Sans paramètre, on cible la date du jour.
if ('' === $raw) {
return new DateTimeImmutable('today');
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
// Validation stricte du format pour éviter les ambiguïtés de parsing.
if (!$date || $date->format('Y-m-d') !== $raw) {
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
}
return $date;
}
}

View File

@@ -7,11 +7,16 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourWeeklySummary;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -25,11 +30,15 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
private AbsenceRepository $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
{
$user = $this->security->getUser();
// Endpoint protégé: résumé hebdo réservé aux utilisateurs authentifiés.
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
@@ -39,12 +48,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$employees = $this->employeeRepository->findScoped($user);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
$absences = $this->absenceRepository->findForPrint($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);
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days);
return $summary;
}
@@ -54,11 +64,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$query = $this->requestStack->getCurrentRequest()?->query;
$raw = (string) ($query?->get('weekStart') ?? '');
// Sans paramètre, on ancre la semaine sur aujourd'hui.
if ('' === $raw) {
return new DateTimeImmutable('today');
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
// Validation stricte du format attendu.
if (!$date || $date->format('Y-m-d') !== $raw) {
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
}
@@ -71,6 +83,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
*/
private function resolveWeek(DateTimeImmutable $anchorDate): array
{
// Convention ISO: semaine de lundi (1) à dimanche (7).
$dayOfWeek = (int) $anchorDate->format('N');
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
$weekEnd = $weekStart->modify('+6 days');
@@ -86,6 +99,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
/**
* @param list<Employee> $employees
* @param list<WorkHour> $workHours
* @param list<Absence> $absences
* @param list<string> $days
*
* @return list<array{
@@ -100,11 +114,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
* weeklyNightMinutes:int,
* weeklyTotalMinutes:int,
* weeklyPresenceCount:float,
* weeklyOvertimeTotalMinutes:int,
* weeklyOvertime25Minutes:int,
* weeklyOvertime50Minutes:int
* weeklyOvertime50Minutes:int,
* weeklyRecoveryMinutes:int
* }>
*/
private function buildRows(array $employees, array $workHours, array $days): array
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
{
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
@@ -113,6 +129,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
continue;
}
// Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale.
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByEmployeeDate[$employeeId][$dateKey] = [
'metrics' => $this->computeMetrics($workHour),
@@ -121,6 +138,30 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
];
}
$creditedByEmployeeDate = [];
$creditedPresenceByEmployeeDate = [];
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
// On ne crédite que les dates couvertes par l'intervalle d'absence.
if ($date < $start || $date > $end) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
}
}
$rows = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
@@ -133,62 +174,76 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0;
$daily = [];
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode();
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode();
foreach ($days as $date) {
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
$metrics = $entry['metrics'] ?? [
'dayMinutes' => 0,
'nightMinutes' => 0,
'totalMinutes' => 0,
];
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
$metrics = $entry['metrics'] ?? new WorkMetrics();
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
// Les absences "comptées comme travaillées" alimentent le total du jour.
$metrics->addCreditedMinutes($creditedMinutes);
$present = null;
if ($isPresenceTracking) {
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
$present = $morning + $afternoon;
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
$weeklyDayMinutes += $metrics['dayMinutes'];
$weeklyNightMinutes += $metrics['nightMinutes'];
$weeklyTotalMinutes += $metrics['totalMinutes'];
$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'],
'dayMinutes' => $metrics->dayMinutes,
'nightMinutes' => $metrics->nightMinutes,
'totalMinutes' => $metrics->totalMinutes,
'present' => $present,
];
}
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
$weeklyOvertimeTotalMinutes = $isPresenceTracking
? 0
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
$weeklyOvertime25Minutes = $isPresenceTracking
? 0
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
$weeklyOvertime50Minutes = $isPresenceTracking
? 0
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
$weeklyRecoveryMinutes = $isPresenceTracking
? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
$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),
'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,
'weeklyOvertimeTotalMinutes' => $weeklyOvertimeTotalMinutes,
'weeklyOvertime25Minutes' => $weeklyOvertime25Minutes,
'weeklyOvertime50Minutes' => $weeklyOvertime50Minutes,
'weeklyRecoveryMinutes' => $weeklyRecoveryMinutes,
];
}
return $rows;
}
/**
* @return array{dayMinutes:int, nightMinutes:int, totalMinutes:int}
*/
private function computeMetrics(WorkHour $workHour): array
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
@@ -206,11 +261,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
return [
'dayMinutes' => $dayMinutes,
'nightMinutes' => $nightMinutes,
'totalMinutes' => $totalMinutes,
];
return new WorkMetrics(
dayMinutes: $dayMinutes,
nightMinutes: $nightMinutes,
totalMinutes: $totalMinutes,
);
}
/**
@@ -224,6 +279,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return null;
}
// Si fin <= début, on considère un passage à minuit.
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end];
@@ -260,9 +316,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
}
[$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]];
$total = 0;
// Fenêtres de nuit: 00:00-06:00 et 21:00-24:00.
$windows = [[0, 360], [1260, 1440]];
$total = 0;
// On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit.
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) {
@@ -281,13 +339,34 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return max(0, $end - $start);
}
private function computeOvertime25Minutes(int $weeklyTotalMinutes): int
private function computeOvertimeTotalMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
{
return max(0, min($weeklyTotalMinutes, 43 * 60) - (35 * 60));
if (null === $contractWeeklyHours || $contractWeeklyHours <= 0) {
return 0;
}
// Règle métier: tout contrat < 35h est traité comme un 35h pour la base supp.
$referenceHours = max(35, $contractWeeklyHours);
return max(0, $weeklyTotalMinutes - ($referenceHours * 60));
}
private function computeOvertime50Minutes(int $weeklyTotalMinutes): int
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
{
return max(0, $weeklyTotalMinutes - (43 * 60));
// Règle métier:
// - contrats <= 35h: 25% entre 35h et 43h
// - contrats >= 39h: 25% entre 39h et 43h
$startHours = (null !== $contractWeeklyHours && $contractWeeklyHours >= 39) ? 39 : 35;
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - ($startHours * 60));
return (int) round($trancheMinutes * 0.25);
}
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
{
// Bonus 50% appliqué au-delà de 43h.
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
return (int) round($trancheMinutes * 0.5);
}
}