diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index a3c8666..8eaa2eb 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -21,7 +21,8 @@
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
"Bash(which python3:*)",
"Bash(sudo apt-get:*)",
- "Bash(npx xlsx-cli:*)"
+ "Bash(npx xlsx-cli:*)",
+ "Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)"
]
}
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 7ae0446..10254f0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,6 +32,7 @@
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
+- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
@@ -43,6 +44,7 @@
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
- INTERIM: no overtime bonuses, no recovery time
+- Driver contracts: no overtime calculation
## Frontend Patterns
diff --git a/doc/functional-rules.md b/doc/functional-rules.md
index 97a4ee1..4ae479d 100644
--- a/doc/functional-rules.md
+++ b/doc/functional-rules.md
@@ -117,6 +117,29 @@ Documents complementaires:
- pas de bonus 50%
- pas de total récup
+## 6bis) Heures Conducteurs
+
+- Écran dédié `/driver-hours` pour les employés dont le contrat est marqué `isDriver = true`
+- Les conducteurs sont exclus de l'écran `/hours` classique
+- Colonnes spécifiques (vue jour):
+ - Heure de jour (durée HH:MM via TimeSelect)
+ - Heure de nuit (durée HH:MM via TimeSelect)
+ - Total (somme jour + nuit, calculé)
+ - Petit déjeuner (checkbox)
+ - Déjeuner (checkbox)
+ - Nuitée (checkbox)
+- Stockage backend:
+ - `dayHoursMinutes` et `nightHoursMinutes` (entiers, minutes) sur `WorkHour`
+ - `hasBreakfast`, `hasLunch`, `hasOvernight` (booleans) sur `WorkHour`
+ - les champs time classiques (morning/afternoon/evening) sont mis à null pour les chauffeurs
+- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
+- Vue semaine:
+ - jour/nuit par jour + indicateurs repas/nuitée
+ - totaux hebdo: jour, nuit, total, compteurs petit déj/déjeuner/nuitée
+ - pas de calcul d'heures supplémentaires pour les conducteurs
+- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
+- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
+
## 7) Fériés
- Les jours fériés sont identifiés et affichés
diff --git a/frontend/components/driver-hours/DriverHoursDayView.vue b/frontend/components/driver-hours/DriverHoursDayView.vue
new file mode 100644
index 0000000..90162b2
--- /dev/null
+++ b/frontend/components/driver-hours/DriverHoursDayView.vue
@@ -0,0 +1,225 @@
+
+
+
+
+ Nom
+ Absence
+ Heure de jour
+ Heure de nuit
+ Total
+ Petit déj.
+ Déjeuner
+ Nuitée
+
+ Valider
+
+
+
+ Site
+
+
+ Site
+ RH
+
+
+
+
+
+
+ {{ employee.firstName }} {{ employee.lastName }}
+ ({{ contractLabel(employee) }})
+
+
+ {{ employee.site?.name ?? 'Sans site' }}
+
+
+
+
+
+ Modifié le {{ getRowUpdatedAt(employee.id) }}
+
+
+
+
+ {{ getRowAbsenceLabel(employee.id) || '—' }}
+
+
+ Modifier
+
+
+
+
+
+
+
+
+
+ {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Validé
+ -
+
+
+ Validé
+ -
+
+
+
+
+
+
+
+
diff --git a/frontend/components/driver-hours/DriverHoursWeekView.vue b/frontend/components/driver-hours/DriverHoursWeekView.vue
new file mode 100644
index 0000000..52fd3c4
--- /dev/null
+++ b/frontend/components/driver-hours/DriverHoursWeekView.vue
@@ -0,0 +1,100 @@
+
+
+
Chargement de la semaine...
+
+
+ Nom
+ {{ day.label }}
+ Jour/Nuit sem.
+ Total sem.
+ Total h. supp.
+ +25%
+ +50%
+ Total récup.
+ Petit déj.
+ Déj.
+ Nuitée
+
+
+
+
+
+
+ {{ row.firstName }} {{ row.lastName }}
+ ({{ row.contractName ?? '-' }})
+
+
{{ row.siteName ?? 'Sans site' }}
+
+
+
+
J {{ formatMinutes(daily.dayMinutes) }}
+
N {{ formatMinutes(daily.nightMinutes) }}
+
+ PD
+ DJ
+ NU
+
+
+
+
+
J {{ formatMinutes(row.weeklyDayMinutes) }}
+
N {{ formatMinutes(row.weeklyNightMinutes) }}
+
+
+ {{ formatMinutes(row.weeklyTotalMinutes) }}
+
+
+ {{ formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
+
+
+ {{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
+
+
+ {{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
+
+
+ {{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
+
+
{{ row.weeklyBreakfastCount ?? 0 }}
+
{{ row.weeklyLunchCount ?? 0 }}
+
{{ row.weeklyOvernightCount ?? 0 }}
+
+
+
+
+
+
+
diff --git a/frontend/components/employees/ContractTab.vue b/frontend/components/employees/ContractTab.vue
index b92977f..5651292 100644
--- a/frontend/components/employees/ContractTab.vue
+++ b/frontend/components/employees/ContractTab.vue
@@ -240,6 +240,18 @@
+
+
+
+ Chauffeur
+
+
+
{
+ const auth = useAuthStore()
+ const toast = useToast()
+ const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
+ const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
+ const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
+ const viewMode = ref<'day' | 'week'>('day')
+
+ const selectedDate = ref(getTodayYmd())
+ const employees = ref([])
+ const employeeFilter = ref('')
+ const selectedSiteIds = ref([])
+ const sitesInitialized = ref(false)
+ const rows = ref>({})
+ const dayContext = ref(null)
+ const weeklySummary = ref(null)
+ const absenceTypes = ref([])
+ const absences = ref([])
+ const publicHolidaysByYear = ref>>({})
+ const isAbsenceDrawerOpen = ref(false)
+ const isAbsenceSubmitting = ref(false)
+ const editingAbsence = ref(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([])
+ const siteValidatingRowIds = ref([])
+
+ const dayGridCols = computed(() => {
+ const metricCol = '0.4fr'
+ const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
+ return `1.2fr 0.6fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
+ })
+
+ const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr) repeat(3, 0.4fr)'
+
+ const sites = computed(() => {
+ const siteMap = new Map()
+ for (const employee of employees.value) {
+ if (employee.site) {
+ siteMap.set(employee.site.id, employee.site)
+ }
+ }
+
+ return Array.from(siteMap.values()).sort((siteA, siteB) => {
+ const orderA = siteA.displayOrder ?? 0
+ const orderB = siteB.displayOrder ?? 0
+ if (orderA !== orderB) return orderA - orderB
+ return siteA.name.localeCompare(siteB.name, 'fr')
+ })
+ })
+
+ const visibleEmployees = computed(() => {
+ if (selectedSiteIds.value.length === 0) return []
+ const filter = employeeFilter.value.trim().toLowerCase()
+ return employees.value.filter((employee) => {
+ if (employee.isDriver !== true) return false
+ const siteId = employee.site?.id
+ if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
+ if (!filter) return true
+ const firstName = employee.firstName?.toLowerCase() ?? ''
+ const lastName = employee.lastName?.toLowerCase() ?? ''
+ return firstName.includes(filter) || lastName.includes(filter)
+ })
+ })
+
+ const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
+
+ const filteredWeeklySummary = computed(() => {
+ 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 isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId)
+ const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
+ const canToggleSiteValidation = (employeeId: number) => {
+ if (!isSiteManager.value) return false
+ const row = rows.value[employeeId]
+ if (!row?.workHourId) return false
+ if (row.isValid) return false
+ return true
+ }
+
+ const canCreateEmptyValidationRow = (employeeId: number) => {
+ const row = rows.value[employeeId]
+ if (row?.workHourId) return false
+ if (!hasContractAtSelectedDate(employeeId)) return false
+ const dayRow = dayContextByEmployeeId.value.get(employeeId)
+ return !!dayRow?.absenceLabel
+ }
+
+ const canCreateValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
+ const canCreateSiteValidationRowFromAbsence = (employeeId: number) => canCreateEmptyValidationRow(employeeId)
+
+ const bulkValidatableEmployeeIds = computed(() => {
+ return visibleEmployees.value
+ .map((employee) => employee.id)
+ .filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
+ })
+
+ const isBulkValidationChecked = computed(() => {
+ const ids = bulkValidatableEmployeeIds.value
+ if (ids.length === 0) return false
+ return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
+ })
+
+ const isBulkValidationIndeterminate = computed(() => {
+ const ids = bulkValidatableEmployeeIds.value
+ if (ids.length === 0) return false
+ const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
+ return checkedCount > 0 && checkedCount < ids.length
+ })
+
+ const bulkSiteValidatableEmployeeIds = computed(() => {
+ if (!isSiteManager.value) return []
+ return visibleEmployees.value
+ .map((employee) => employee.id)
+ .filter((employeeId) => canToggleSiteValidation(employeeId) || canCreateSiteValidationRowFromAbsence(employeeId))
+ })
+
+ const isBulkSiteValidationChecked = computed(() => {
+ const ids = bulkSiteValidatableEmployeeIds.value
+ if (ids.length === 0) return false
+ return ids.every((employeeId) => rows.value[employeeId]?.isSiteValid ?? false)
+ })
+
+ const isBulkSiteValidationIndeterminate = computed(() => {
+ const ids = bulkSiteValidatableEmployeeIds.value
+ if (ids.length === 0) return false
+ const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isSiteValid ?? false).length
+ return checkedCount > 0 && checkedCount < ids.length
+ })
+
+ const canBulkToggleSiteValidation = computed(() => bulkSiteValidatableEmployeeIds.value.length > 0)
+
+ const dayContextByEmployeeId = computed(() => {
+ const map = new Map()
+ for (const row of dayContext.value?.rows ?? []) {
+ map.set(row.employeeId, row)
+ }
+ return map
+ })
+
+ const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
+ const targetDate = target === 'yesterday'
+ ? getOffsetFromTodayYmd(-1)
+ : target === 'tomorrow'
+ ? getOffsetFromTodayYmd(1)
+ : getTodayYmd()
+
+ if (selectedDate.value === targetDate) {
+ return 'bg-primary-500 text-white'
+ }
+ return 'bg-white text-primary-500 hover:bg-tertiary-500'
+ }
+
+ const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
+ const selected = parseYmd(selectedDate.value)
+ if (!selected) {
+ return 'bg-white text-primary-500 hover:bg-tertiary-500'
+ }
+
+ const today = new Date()
+ const targetDate = new Date(today)
+ if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
+ if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
+
+ const selectedWeekStart = getWeekStartDate(selected)
+ const targetWeekStart = getWeekStartDate(targetDate)
+ const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
+
+ if (isActive) {
+ return 'bg-primary-500 text-white'
+ }
+ return 'bg-white text-primary-500 hover:bg-tertiary-500'
+ }
+
+ const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
+ const today = new Date()
+ if (target === 'previousWeek') today.setDate(today.getDate() - 7)
+ if (target === 'nextWeek') today.setDate(today.getDate() + 7)
+
+ const weekNumber = getIsoWeekNumber(today)
+ return `Sem. S${weekNumber}`
+ }
+
+ const formattedSelectedDate = computed(() => {
+ const parsed = parseYmd(selectedDate.value)
+ if (!parsed) return selectedDate.value
+
+ if (viewMode.value === 'week') {
+ return formatWeekRangeFr(parsed)
+ }
+
+ return formatDateLongFr(parsed)
+ })
+
+ const selectedYear = computed(() => {
+ const parsed = parseYmd(selectedDate.value)
+ return parsed ? parsed.getFullYear() : null
+ })
+
+ const selectedHolidayLabel = computed(() => {
+ const year = selectedYear.value
+ if (!year) return ''
+ return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
+ })
+
+ const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
+
+ const weekDayHeaders = computed(() => {
+ const days = weeklySummary.value?.days ?? []
+ return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
+ })
+
+ const shiftDate = (steps: number) => {
+ const offset = viewMode.value === 'week' ? (steps * 7) : steps
+ const next = shiftYmd(selectedDate.value, offset)
+ if (!next) return
+ selectedDate.value = next
+ }
+
+ const setToday = () => { selectedDate.value = getTodayYmd() }
+ const setYesterday = () => { setToday(); shiftDate(-1) }
+ const setTomorrow = () => { setToday(); shiftDate(1) }
+ const setThisWeek = () => { selectedDate.value = getTodayYmd() }
+ const setPreviousWeek = () => {
+ const previousWeek = shiftYmd(getTodayYmd(), -7)
+ if (!previousWeek) return
+ selectedDate.value = previousWeek
+ }
+ const setNextWeek = () => {
+ const nextWeek = shiftYmd(getTodayYmd(), 7)
+ if (!nextWeek) return
+ selectedDate.value = nextWeek
+ }
+
+ const resetAbsenceForm = () => {
+ absenceForm.value = {
+ employeeId: '',
+ typeId: '',
+ startDate: '',
+ startHalf: 'AM',
+ endDate: '',
+ endHalf: 'PM',
+ comment: ''
+ }
+ }
+
+ const closeAbsenceDrawer = () => {
+ isAbsenceDrawerOpen.value = false
+ editingAbsence.value = null
+ resetAbsenceForm()
+ }
+
+ const toMinutes = (time: string): number => {
+ if (!time) return 0
+ const [hours, minutes] = time.split(':').map(Number)
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return 0
+ return (hours * 60) + minutes
+ }
+
+ 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 minutesToTimeString = (minutes: number | null | undefined): string => {
+ if (minutes === null || minutes === undefined || minutes === 0) return ''
+ return formatMinutes(minutes)
+ }
+
+ const emptyRow = (): DriverHourRow => ({
+ workHourId: null,
+ dayHours: '',
+ nightHours: '',
+ hasBreakfast: false,
+ hasLunch: false,
+ hasOvernight: false,
+ isSiteValid: false,
+ isValid: false,
+ updatedAt: null
+ })
+
+ const isRowLocked = (employeeId: number) => {
+ const row = rows.value[employeeId]
+ if (!row) return false
+ if (row.isValid) return true
+ if (!isAdmin.value && row.isSiteValid) return true
+ return false
+ }
+
+ const contractLabel = (employee: Employee) => {
+ const contract = employee.contract
+ if (!contract) return '-'
+ return contract.name
+ }
+
+ const getRowMetrics = (employeeId: number) => {
+ const row = rows.value[employeeId] ?? emptyRow()
+ const dayMinutes = toMinutes(row.dayHours)
+ const nightMinutes = toMinutes(row.nightHours)
+ const totalMinutes = dayMinutes + nightMinutes
+ return { dayMinutes, nightMinutes, totalMinutes }
+ }
+
+ const getRowAbsenceLabel = (employeeId: number) => {
+ const dayRow = dayContextByEmployeeId.value.get(employeeId)
+ if (dayRow && dayRow.hasContractAtDate === false) {
+ return 'Contrat non démarré'
+ }
+ if (isSelectedDateHoliday.value) return 'Férié'
+ if (!dayRow?.absenceLabel) return ''
+ if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
+ const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
+ return `${dayRow.absenceLabel} (${halfLabel})`
+ }
+ return `${dayRow.absenceLabel} (journée)`
+ }
+
+ const getRowAbsenceStyle = (employeeId: number) => {
+ const dayRow = dayContextByEmployeeId.value.get(employeeId)
+ if (dayRow && dayRow.hasContractAtDate === false) {
+ return { backgroundColor: '#6b7280' }
+ }
+ if (!dayRow?.absenceLabel) return undefined
+ return { backgroundColor: dayRow.absenceColor || '#dc2626' }
+ }
+
+ const getRowUpdatedAt = (employeeId: number): string => {
+ const raw = rows.value[employeeId]?.updatedAt
+ if (!raw) return ''
+ const date = new Date(raw)
+ if (Number.isNaN(date.getTime())) return ''
+ return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
+ }
+
+ const hasContractAtSelectedDate = (employeeId: number) => {
+ const dayRow = dayContextByEmployeeId.value.get(employeeId)
+ if (!dayRow) return true
+ return dayRow.hasContractAtDate !== false
+ }
+
+ const hydrateRows = (workHours: WorkHour[]) => {
+ const byEmployeeId = new Map()
+ for (const workHour of workHours) {
+ byEmployeeId.set(workHour.employee.id, workHour)
+ }
+
+ const nextRows: Record = {}
+ for (const employee of employees.value) {
+ if (employee.isDriver !== true) continue
+ const workHour = byEmployeeId.get(employee.id)
+ nextRows[employee.id] = {
+ workHourId: workHour?.id ?? null,
+ dayHours: minutesToTimeString(workHour?.dayHoursMinutes),
+ nightHours: minutesToTimeString(workHour?.nightHoursMinutes),
+ hasBreakfast: workHour?.hasBreakfast ?? false,
+ hasLunch: workHour?.hasLunch ?? false,
+ hasOvernight: workHour?.hasOvernight ?? false,
+ isSiteValid: workHour?.isSiteValid ?? false,
+ isValid: workHour?.isValid ?? false,
+ updatedAt: workHour?.updatedAt ?? null
+ }
+ }
+
+ rows.value = nextRows
+ }
+
+ const loadAbsenceTypes = async () => {
+ absenceTypes.value = await listAbsenceTypes()
+ }
+
+ const loadPublicHolidaysForSelectedYear = async () => {
+ const year = selectedYear.value
+ if (!year) return
+ if (publicHolidaysByYear.value[year]) return
+
+ const holidays = await listPublicHolidays('metropole', year)
+ publicHolidaysByYear.value = {
+ ...publicHolidaysByYear.value,
+ [year]: holidays
+ }
+ }
+
+ const loadAbsences = async () => {
+ absences.value = await listAbsences({
+ from: selectedDate.value,
+ to: selectedDate.value,
+ siteIds: isAdmin.value ? selectedSiteIds.value : undefined
+ })
+ }
+
+ const openAbsenceDrawer = (employeeId: number) => {
+ if (!hasContractAtSelectedDate(employeeId)) return
+ if (isSelectedDateHoliday.value) return
+
+ const existing = absences.value.find((absence) => {
+ if (absence.employee?.id !== employeeId) return false
+ const start = absence.startDate.slice(0, 10)
+ const end = absence.endDate.slice(0, 10)
+ return selectedDate.value >= start && selectedDate.value <= end
+ }) ?? null
+
+ if (existing) {
+ editingAbsence.value = existing
+ absenceForm.value = {
+ employeeId,
+ typeId: existing.type?.id ?? '',
+ startDate: existing.startDate.slice(0, 10),
+ startHalf: existing.startHalf ?? 'AM',
+ endDate: existing.endDate.slice(0, 10),
+ endHalf: existing.endHalf ?? 'PM',
+ comment: existing.comment ?? ''
+ }
+ } else {
+ editingAbsence.value = null
+ absenceForm.value = {
+ employeeId,
+ typeId: '',
+ startDate: selectedDate.value,
+ startHalf: 'AM',
+ endDate: selectedDate.value,
+ endHalf: 'PM',
+ comment: ''
+ }
+ }
+
+ isAbsenceDrawerOpen.value = true
+ }
+
+ const refreshAfterAbsenceChange = async () => {
+ if (isAdmin.value) {
+ await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
+ return
+ }
+ weeklySummary.value = null
+ await Promise.all([loadDayContext(), loadAbsences()])
+ }
+
+ const submitAbsence = async () => {
+ const form = absenceForm.value
+ if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
+
+ isAbsenceSubmitting.value = true
+ try {
+ if (editingAbsence.value) {
+ await updateAbsence({
+ id: editingAbsence.value.id,
+ employeeId: Number(form.employeeId),
+ typeId: Number(form.typeId),
+ startDate: form.startDate,
+ startHalf: form.startHalf,
+ endDate: form.endDate,
+ endHalf: form.endHalf,
+ comment: editingAbsence.value.comment ?? ''
+ })
+ } else {
+ await createAbsence({
+ employeeId: Number(form.employeeId),
+ typeId: Number(form.typeId),
+ startDate: form.startDate,
+ startHalf: form.startHalf,
+ endDate: form.endDate,
+ endHalf: form.endHalf,
+ comment: ''
+ })
+ }
+
+ closeAbsenceDrawer()
+ await refreshAfterAbsenceChange()
+ } finally {
+ isAbsenceSubmitting.value = false
+ }
+ }
+
+ const deleteAbsenceFromDrawer = async () => {
+ if (!editingAbsence.value || isAbsenceSubmitting.value) return
+
+ isAbsenceSubmitting.value = true
+ try {
+ await deleteAbsence(editingAbsence.value.id)
+ closeAbsenceDrawer()
+ await refreshAfterAbsenceChange()
+ } finally {
+ isAbsenceSubmitting.value = false
+ }
+ }
+
+ const buildEmptyDriverEntry = (employeeId: number) => ({
+ employeeId,
+ morningFrom: null,
+ morningTo: null,
+ afternoonFrom: null,
+ afternoonTo: null,
+ eveningFrom: null,
+ eveningTo: null,
+ isPresentMorning: false,
+ isPresentAfternoon: false,
+ dayHoursMinutes: null,
+ nightHoursMinutes: null,
+ hasBreakfast: false,
+ hasLunch: false,
+ hasOvernight: false
+ })
+
+ const toggleValidation = async (
+ employeeId: number,
+ checked: boolean,
+ options: { toast?: boolean } = {}
+ ) => {
+ const row = rows.value[employeeId]
+ if (!row?.workHourId && checked) {
+ if (canCreateEmptyValidationRow(employeeId)) {
+ await bulkUpsertWorkHours({
+ workDate: selectedDate.value,
+ entries: [buildEmptyDriverEntry(employeeId)]
+ }, { toast: false })
+ await loadWorkHours()
+ }
+ }
+
+ const updatedRow = rows.value[employeeId]
+ if (!updatedRow?.workHourId) {
+ if (options.toast !== false) {
+ toast.error({
+ title: 'Validation impossible',
+ message: 'La ligne doit contenir des heures ou une absence.'
+ })
+ }
+ return
+ }
+
+ if (isValidationPending(employeeId)) return
+
+ validatingRowIds.value = [...validatingRowIds.value, employeeId]
+ try {
+ await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
+ updatedRow.isValid = checked
+ } finally {
+ validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
+ }
+ }
+
+ const toggleSiteValidation = async (
+ employeeId: number,
+ checked: boolean,
+ options: { toast?: boolean } = {}
+ ) => {
+ const row = rows.value[employeeId]
+ if (!row?.workHourId && checked) {
+ if (canCreateEmptyValidationRow(employeeId)) {
+ await bulkUpsertWorkHours({
+ workDate: selectedDate.value,
+ entries: [buildEmptyDriverEntry(employeeId)]
+ }, { toast: false })
+ await loadWorkHours()
+ }
+ }
+
+ const updatedRow = rows.value[employeeId]
+ if (!updatedRow?.workHourId) {
+ if (options.toast !== false) {
+ toast.error({
+ title: 'Validation impossible',
+ message: 'La ligne doit contenir des heures ou une absence.'
+ })
+ }
+ return
+ }
+
+ if (isSiteValidationPending(employeeId)) return
+ if (!canToggleSiteValidation(employeeId)) return
+
+ siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId]
+ try {
+ await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast })
+ updatedRow.isSiteValid = checked
+ } finally {
+ siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => id !== employeeId)
+ }
+ }
+
+ const toggleValidationBulk = async (checked: boolean) => {
+ const employeeIds = bulkValidatableEmployeeIds.value
+ if (employeeIds.length === 0) return
+
+ const pendingIds = new Set(validatingRowIds.value)
+ const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
+ if (availableEmployeeIds.length === 0) return
+
+ if (checked) {
+ const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(employeeId))
+ if (toCreateIds.length > 0) {
+ await bulkUpsertWorkHours({
+ workDate: selectedDate.value,
+ entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
+ }, { toast: false })
+ await loadWorkHours()
+ }
+ }
+
+ const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
+ if (targetEmployeeIds.length === 0) {
+ toast.error({
+ title: 'Validation impossible',
+ message: 'Aucune ligne ne peut être validée.'
+ })
+ return
+ }
+
+ validatingRowIds.value = Array.from(new Set([...validatingRowIds.value, ...targetEmployeeIds]))
+
+ try {
+ const result = await bulkUpdateWorkHourValidation({
+ workDate: selectedDate.value,
+ isValid: checked,
+ employeeIds: targetEmployeeIds
+ }, { toast: false })
+
+ await loadWorkHours()
+
+ if (result.updated === 0) {
+ toast.error({ title: 'Erreur', message: 'Aucune ligne mise à jour.' })
+ return
+ }
+
+ if (result.skipped > 0) {
+ toast.success({
+ title: 'Succès partiel',
+ message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
+ })
+ return
+ }
+
+ toast.success({
+ title: 'Succès',
+ message: checked
+ ? `${result.updated} ligne(s) validée(s).`
+ : `${result.updated} validation(s) retirée(s).`
+ })
+ } catch {
+ toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations.' })
+ } finally {
+ validatingRowIds.value = validatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
+ }
+ }
+
+ const toggleSiteValidationBulk = async (checked: boolean) => {
+ if (!isSiteManager.value) return
+
+ const employeeIds = bulkSiteValidatableEmployeeIds.value
+ if (employeeIds.length === 0) return
+
+ const pendingIds = new Set(siteValidatingRowIds.value)
+ const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
+ if (availableEmployeeIds.length === 0) return
+
+ if (checked) {
+ const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateSiteValidationRowFromAbsence(employeeId))
+ if (toCreateIds.length > 0) {
+ await bulkUpsertWorkHours({
+ workDate: selectedDate.value,
+ entries: toCreateIds.map((employeeId) => buildEmptyDriverEntry(employeeId))
+ }, { toast: false })
+ await loadWorkHours()
+ }
+ }
+
+ const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleSiteValidation(employeeId))
+ if (targetEmployeeIds.length === 0) {
+ toast.error({
+ title: 'Validation impossible',
+ message: 'Aucune ligne ne peut être validée côté site.'
+ })
+ return
+ }
+
+ siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
+
+ try {
+ const result = await bulkUpdateWorkHourSiteValidation({
+ workDate: selectedDate.value,
+ isSiteValid: checked,
+ employeeIds: targetEmployeeIds
+ }, { toast: false })
+
+ await loadWorkHours()
+
+ if (result.updated === 0) {
+ toast.error({ title: 'Erreur', message: 'Aucune ligne site mise à jour.' })
+ return
+ }
+
+ if (result.skipped > 0) {
+ toast.success({
+ title: 'Succès partiel',
+ message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
+ })
+ return
+ }
+
+ toast.success({
+ title: 'Succès',
+ message: checked
+ ? `${result.updated} validation(s) site enregistrée(s).`
+ : `${result.updated} validation(s) site retirée(s).`
+ })
+ } catch {
+ toast.error({ title: 'Erreur', message: 'Impossible de mettre à jour les validations site.' })
+ } finally {
+ siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
+ }
+ }
+
+ const loadEmployees = async () => {
+ const scopedEmployees = await listScopedEmployees()
+ employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
+ }
+
+ const loadWorkHours = async () => {
+ const workHours = await listWorkHoursByDate(selectedDate.value)
+ hydrateRows(workHours)
+ }
+
+ const loadWeeklySummary = async () => {
+ isWeekLoading.value = true
+ try {
+ weeklySummary.value = await getWeeklyWorkHourSummary(selectedDate.value)
+ } finally {
+ isWeekLoading.value = false
+ }
+ }
+
+ const loadDayContext = async () => {
+ dayContext.value = await getWorkHourDayContext(selectedDate.value)
+ }
+
+ const refreshByDate = async () => {
+ if (isAdmin.value) {
+ await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
+ return
+ }
+ weeklySummary.value = null
+ await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
+ }
+
+ const loadPage = async () => {
+ isLoading.value = true
+ try {
+ await loadPublicHolidaysForSelectedYear()
+ await loadEmployees()
+ await loadAbsenceTypes()
+ await refreshByDate()
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ onMounted(loadPage)
+
+ watch(sites, (nextSites) => {
+ const currentSiteIds = nextSites.map((site) => site.id)
+
+ if (!sitesInitialized.value) {
+ if (currentSiteIds.length === 0) return
+ selectedSiteIds.value = currentSiteIds
+ sitesInitialized.value = true
+ return
+ }
+
+ selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
+ }, { immediate: true })
+
+ watch(isAdmin, async (admin) => {
+ if (!admin) {
+ viewMode.value = 'day'
+ weeklySummary.value = null
+ await Promise.all([loadAbsenceTypes(), loadAbsences()])
+ return
+ }
+ await loadAbsenceTypes()
+ await loadAbsences()
+ }, { immediate: true })
+
+ watch(selectedDate, async () => {
+ await loadPublicHolidaysForSelectedYear()
+ await refreshByDate()
+ })
+
+ const handleSave = async () => {
+ if (isSubmitting.value || employees.value.length === 0) return
+
+ isSubmitting.value = true
+ try {
+ const driverEmployees = employees.value.filter(
+ (e) => e.isDriver === true && hasContractAtSelectedDate(e.id)
+ )
+
+ const entries = driverEmployees.map((employee) => {
+ const employeeId = employee.id
+ const row = rows.value[employeeId] ?? emptyRow()
+ const dayMin = toMinutes(row.dayHours)
+ const nightMin = toMinutes(row.nightHours)
+
+ return {
+ employeeId,
+ morningFrom: null,
+ morningTo: null,
+ afternoonFrom: null,
+ afternoonTo: null,
+ eveningFrom: null,
+ eveningTo: null,
+ isPresentMorning: false,
+ isPresentAfternoon: false,
+ dayHoursMinutes: dayMin || null,
+ nightHoursMinutes: nightMin || null,
+ hasBreakfast: row.hasBreakfast,
+ hasLunch: row.hasLunch,
+ hasOvernight: row.hasOvernight
+ }
+ })
+
+ if (entries.length === 0) return
+
+ await bulkUpsertWorkHours({
+ workDate: selectedDate.value,
+ entries
+ })
+
+ await refreshByDate()
+ } finally {
+ isSubmitting.value = false
+ }
+ }
+
+ return {
+ isAdmin,
+ isSelfUser,
+ isSiteManager,
+ viewMode,
+ selectedDate,
+ employeeFilter,
+ sites,
+ selectedSiteIds,
+ employees,
+ visibleEmployees,
+ rows,
+ absenceTypes,
+ absenceForm,
+ isAbsenceDrawerOpen,
+ isAbsenceSubmitting,
+ editingAbsence,
+ weeklySummary,
+ filteredWeeklySummary,
+ isLoading,
+ isWeekLoading,
+ isSubmitting,
+ dayGridCols,
+ weekGridCols,
+ saveButtonClass,
+ formattedSelectedDate,
+ isSelectedDateHoliday,
+ weekDayHeaders,
+ shortcutButtonClass,
+ weekShortcutButtonClass,
+ getWeekShortcutLabel,
+ setToday,
+ setYesterday,
+ setTomorrow,
+ setThisWeek,
+ setPreviousWeek,
+ setNextWeek,
+ shiftDate,
+ contractLabel,
+ isRowLocked,
+ hasContractAtSelectedDate,
+ isValidationPending,
+ isSiteValidationPending,
+ canToggleValidation,
+ canToggleSiteValidation,
+ canCreateSiteValidationRowFromAbsence,
+ isBulkValidationChecked,
+ isBulkValidationIndeterminate,
+ isBulkSiteValidationChecked,
+ isBulkSiteValidationIndeterminate,
+ canBulkToggleSiteValidation,
+ toggleValidation,
+ toggleSiteValidation,
+ toggleValidationBulk,
+ toggleSiteValidationBulk,
+ getRowMetrics,
+ getRowAbsenceLabel,
+ getRowAbsenceStyle,
+ getRowUpdatedAt,
+ openAbsenceDrawer,
+ submitAbsence,
+ deleteAbsenceFromDrawer,
+ closeAbsenceDrawer,
+ formatMinutes,
+ handleSave
+ }
+}
diff --git a/frontend/composables/useEmployeeContract.ts b/frontend/composables/useEmployeeContract.ts
index 5d9d3ed..85929af 100644
--- a/frontend/composables/useEmployeeContract.ts
+++ b/frontend/composables/useEmployeeContract.ts
@@ -43,7 +43,8 @@ export const useEmployeeContract = (employee: Ref, reloadEmploy
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
- endDate: ''
+ endDate: '',
+ isDriver: false
})
const createValidationTouched = reactive({
@@ -171,6 +172,7 @@ export const useEmployeeContract = (employee: Ref, reloadEmploy
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
+ createContractForm.isDriver = false
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
@@ -244,7 +246,8 @@ export const useEmployeeContract = (employee: Ref, reloadEmploy
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
- contractEndDate: createContractForm.endDate || null
+ contractEndDate: createContractForm.endDate || null,
+ isDriverInput: createContractForm.isDriver
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts
index 6b99f95..97459c5 100644
--- a/frontend/composables/useHoursPage.ts
+++ b/frontend/composables/useHoursPage.ts
@@ -99,6 +99,7 @@ export const useHoursPage = () => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return employees.value.filter((employee) => {
+ if (employee.isDriver === true) return false
const siteId = employee.site?.id
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
if (!filter) return true
@@ -462,6 +463,9 @@ export const useHoursPage = () => {
const getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
+ if (dayRow && dayRow.hasContractAtDate === false) {
+ return { backgroundColor: '#6b7280' }
+ }
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue
index 421b896..2ff6436 100644
--- a/frontend/layouts/default.vue
+++ b/frontend/layouts/default.vue
@@ -28,6 +28,16 @@
Heures
+
+
+ Heures Conducteurs
+
+
+
+
Heures Conducteurs
+
+
+
+
+
+ Chargement...
+
+
+
+ Aucun conducteur accessible.
+
+
+
+
+
+
+
+
+
+
+
+ Enregistrer
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/employees/index.vue b/frontend/pages/employees/index.vue
index 2159e91..d97426e 100644
--- a/frontend/pages/employees/index.vue
+++ b/frontend/pages/employees/index.vue
@@ -170,6 +170,17 @@
La date de fin est obligatoire pour un CDD.
+
+
+
+ Chauffeur
+
+
{
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
- contractEndDate: form.contractEndDate || null
+ contractEndDate: form.contractEndDate || null,
+ isDriverInput: form.isDriver
})
}
@@ -442,6 +455,7 @@ const handleSubmit = async () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
+ form.isDriver = false
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -485,6 +499,7 @@ const openCreate = () => {
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
+ form.isDriver = false
isDrawerOpen.value = true
}
diff --git a/frontend/services/dto/employee.ts b/frontend/services/dto/employee.ts
index 7f000c1..6269112 100644
--- a/frontend/services/dto/employee.ts
+++ b/frontend/services/dto/employee.ts
@@ -18,6 +18,7 @@ export type ContractHistoryItem = {
comment?: string | null
periodId?: number | null
suspensions?: ContractSuspension[]
+ isDriver?: boolean
}
export type Employee = {
@@ -26,6 +27,7 @@ export type Employee = {
lastName: string
site: Site
contract?: Contract | null
+ isDriver?: boolean
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts
index 953eada..eaadece 100644
--- a/frontend/services/dto/work-hour.ts
+++ b/frontend/services/dto/work-hour.ts
@@ -13,6 +13,11 @@ export type WorkHour = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
+ dayHoursMinutes?: number | null
+ nightHoursMinutes?: number | null
+ hasBreakfast?: boolean
+ hasLunch?: boolean
+ hasOvernight?: boolean
isSiteValid?: boolean
isValid?: boolean
updatedAt?: string | null
@@ -28,6 +33,11 @@ export type WorkHourEntryPayload = {
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
+ dayHoursMinutes?: number | null
+ nightHoursMinutes?: number | null
+ hasBreakfast?: boolean
+ hasLunch?: boolean
+ hasOvernight?: boolean
}
export type WeeklyWorkHourDailySummary = {
@@ -39,6 +49,9 @@ export type WeeklyWorkHourDailySummary = {
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
+ hasBreakfast?: boolean
+ hasLunch?: boolean
+ hasOvernight?: boolean
}
export type WeeklyWorkHourRowSummary = {
@@ -58,6 +71,10 @@ export type WeeklyWorkHourRowSummary = {
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
+ isDriver?: boolean
+ weeklyBreakfastCount?: number
+ weeklyLunchCount?: number
+ weeklyOvernightCount?: number
}
export type WeeklyWorkHourSummary = {
@@ -77,9 +94,22 @@ export type WorkHourDayContextRow = {
absentAfternoon: boolean
creditedMinutes: number
creditedPresenceUnits: number
+ isDriverContract?: boolean
}
export type WorkHourDayContext = {
workDate: string
rows: WorkHourDayContextRow[]
}
+
+export type DriverHourRow = {
+ workHourId: number | null
+ dayHours: string
+ nightHours: string
+ hasBreakfast: boolean
+ hasLunch: boolean
+ hasOvernight: boolean
+ isSiteValid: boolean
+ isValid: boolean
+ updatedAt: string | null
+}
diff --git a/frontend/services/employees.ts b/frontend/services/employees.ts
index 19dd6e6..9caa35e 100644
--- a/frontend/services/employees.ts
+++ b/frontend/services/employees.ts
@@ -34,6 +34,7 @@ export const createEmployee = async (payload: {
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
+ isDriverInput?: boolean
}) => {
const api = useApi()
return api.post('/employees', {
@@ -43,7 +44,8 @@ export const createEmployee = async (payload: {
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
- contractEndDate: payload.contractEndDate ?? null
+ contractEndDate: payload.contractEndDate ?? null,
+ isDriverInput: payload.isDriverInput ?? false
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -63,6 +65,7 @@ export const updateEmployee = async (
contractPaidLeaveSettled?: boolean
contractComment?: string | null
displayOrder?: number
+ isDriverInput?: boolean
}
) => {
const api = useApi()
@@ -91,6 +94,9 @@ export const updateEmployee = async (
if (payload.contractComment !== undefined) {
body.contractComment = payload.contractComment ?? null
}
+ if (payload.isDriverInput !== undefined) {
+ body.isDriverInput = payload.isDriverInput
+ }
return api.patch(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',
diff --git a/migrations/Version20260315100000.php b/migrations/Version20260315100000.php
new file mode 100644
index 0000000..9ccc395
--- /dev/null
+++ b/migrations/Version20260315100000.php
@@ -0,0 +1,26 @@
+addSql('ALTER TABLE employee_contract_periods ADD is_driver BOOLEAN DEFAULT FALSE NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN is_driver');
+ }
+}
diff --git a/migrations/Version20260315100100.php b/migrations/Version20260315100100.php
new file mode 100644
index 0000000..0e649ef
--- /dev/null
+++ b/migrations/Version20260315100100.php
@@ -0,0 +1,34 @@
+addSql('ALTER TABLE work_hours ADD day_hours_minutes INTEGER DEFAULT NULL');
+ $this->addSql('ALTER TABLE work_hours ADD night_hours_minutes INTEGER DEFAULT NULL');
+ $this->addSql('ALTER TABLE work_hours ADD has_breakfast BOOLEAN DEFAULT FALSE NOT NULL');
+ $this->addSql('ALTER TABLE work_hours ADD has_lunch BOOLEAN DEFAULT FALSE NOT NULL');
+ $this->addSql('ALTER TABLE work_hours ADD has_overnight BOOLEAN DEFAULT FALSE NOT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE work_hours DROP COLUMN day_hours_minutes');
+ $this->addSql('ALTER TABLE work_hours DROP COLUMN night_hours_minutes');
+ $this->addSql('ALTER TABLE work_hours DROP COLUMN has_breakfast');
+ $this->addSql('ALTER TABLE work_hours DROP COLUMN has_lunch');
+ $this->addSql('ALTER TABLE work_hours DROP COLUMN has_overnight');
+ }
+}
diff --git a/src/ApiResource/WorkHourBulkUpsert.php b/src/ApiResource/WorkHourBulkUpsert.php
index 741a09f..5208aac 100644
--- a/src/ApiResource/WorkHourBulkUpsert.php
+++ b/src/ApiResource/WorkHourBulkUpsert.php
@@ -32,7 +32,12 @@ final class WorkHourBulkUpsert
* eveningFrom?:?string,
* eveningTo?:?string,
* isPresentMorning?:bool,
- * isPresentAfternoon?:bool
+ * isPresentAfternoon?:bool,
+ * dayHoursMinutes?:?int,
+ * nightHoursMinutes?:?int,
+ * hasBreakfast?:bool,
+ * hasLunch?:bool,
+ * hasOvernight?:bool
* }>
*/
public array $entries = [];
diff --git a/src/Dto/Employees/ContractHistoryItem.php b/src/Dto/Employees/ContractHistoryItem.php
index 2dce07e..7e9a622 100644
--- a/src/Dto/Employees/ContractHistoryItem.php
+++ b/src/Dto/Employees/ContractHistoryItem.php
@@ -27,5 +27,7 @@ final class ContractHistoryItem
public ?int $periodId = null,
#[Groups(['employee:read'])]
public array $suspensions = [],
+ #[Groups(['employee:read'])]
+ public bool $isDriver = false,
) {}
}
diff --git a/src/Dto/WorkHours/DayContextRow.php b/src/Dto/WorkHours/DayContextRow.php
index 83aac89..30ecf64 100644
--- a/src/Dto/WorkHours/DayContextRow.php
+++ b/src/Dto/WorkHours/DayContextRow.php
@@ -16,6 +16,7 @@ final class DayContextRow
public bool $absentAfternoon = false,
public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0,
+ public bool $isDriverContract = false,
) {}
public function addAbsence(
@@ -78,6 +79,7 @@ final class DayContextRow
'absentAfternoon' => $this->absentAfternoon,
'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits,
+ 'isDriverContract' => $this->isDriverContract,
];
}
diff --git a/src/Dto/WorkHours/WeeklyDaySummary.php b/src/Dto/WorkHours/WeeklyDaySummary.php
index 97fcf89..ab2d4d0 100644
--- a/src/Dto/WorkHours/WeeklyDaySummary.php
+++ b/src/Dto/WorkHours/WeeklyDaySummary.php
@@ -15,5 +15,8 @@ final class WeeklyDaySummary
public bool $hasAbsence = false,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
+ public bool $hasBreakfast = false,
+ public bool $hasLunch = false,
+ public bool $hasOvernight = false,
) {}
}
diff --git a/src/Dto/WorkHours/WeeklySummaryRow.php b/src/Dto/WorkHours/WeeklySummaryRow.php
index 0f5fdba..4e01cfc 100644
--- a/src/Dto/WorkHours/WeeklySummaryRow.php
+++ b/src/Dto/WorkHours/WeeklySummaryRow.php
@@ -26,5 +26,9 @@ final class WeeklySummaryRow
public int $weeklyOvertime25Minutes,
public int $weeklyOvertime50Minutes,
public int $weeklyRecoveryMinutes,
+ public bool $isDriver = false,
+ public int $weeklyBreakfastCount = 0,
+ public int $weeklyLunchCount = 0,
+ public int $weeklyOvernightCount = 0,
) {}
}
diff --git a/src/Entity/Employee.php b/src/Entity/Employee.php
index c3aac91..dd904cb 100644
--- a/src/Entity/Employee.php
+++ b/src/Entity/Employee.php
@@ -88,6 +88,9 @@ class Employee
#[Groups(['employee:write'])]
private ?string $contractComment = null;
+ #[Groups(['employee:write'])]
+ private ?bool $isDriverInput = null;
+
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -245,6 +248,24 @@ class Employee
return $this;
}
+ public function getIsDriverInput(): ?bool
+ {
+ return $this->isDriverInput;
+ }
+
+ public function setIsDriverInput(?bool $isDriverInput): self
+ {
+ $this->isDriverInput = $isDriverInput;
+
+ return $this;
+ }
+
+ #[Groups(['employee:read'])]
+ public function getIsDriver(): bool
+ {
+ return $this->resolveCurrentContractPeriod()?->getIsDriver() ?? false;
+ }
+
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
@@ -329,6 +350,7 @@ class Employee
comment: $period->getComment(),
periodId: $period->getId(),
suspensions: $suspensionData,
+ isDriver: $period->getIsDriver(),
);
},
$periods
diff --git a/src/Entity/EmployeeContractPeriod.php b/src/Entity/EmployeeContractPeriod.php
index e87ab49..8ffe19f 100644
--- a/src/Entity/EmployeeContractPeriod.php
+++ b/src/Entity/EmployeeContractPeriod.php
@@ -39,6 +39,9 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
+ #[ORM\Column(type: 'boolean', options: ['default' => false])]
+ private bool $isDriver = false;
+
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $paidLeaveSettled = false;
@@ -137,6 +140,18 @@ class EmployeeContractPeriod
return $this->createdAt;
}
+ public function getIsDriver(): bool
+ {
+ return $this->isDriver;
+ }
+
+ public function setIsDriver(bool $isDriver): self
+ {
+ $this->isDriver = $isDriver;
+
+ return $this;
+ }
+
public function isPaidLeaveSettled(): bool
{
return $this->paidLeaveSettled;
diff --git a/src/Entity/WorkHour.php b/src/Entity/WorkHour.php
index 6a3dfb2..1b54319 100644
--- a/src/Entity/WorkHour.php
+++ b/src/Entity/WorkHour.php
@@ -99,6 +99,26 @@ class WorkHour
#[Groups(['work_hour:read'])]
private bool $isPresentAfternoon = false;
+ #[ORM\Column(type: 'integer', nullable: true)]
+ #[Groups(['work_hour:read'])]
+ private ?int $dayHoursMinutes = null;
+
+ #[ORM\Column(type: 'integer', nullable: true)]
+ #[Groups(['work_hour:read'])]
+ private ?int $nightHoursMinutes = null;
+
+ #[ORM\Column(type: 'boolean', options: ['default' => false])]
+ #[Groups(['work_hour:read'])]
+ private bool $hasBreakfast = false;
+
+ #[ORM\Column(type: 'boolean', options: ['default' => false])]
+ #[Groups(['work_hour:read'])]
+ private bool $hasLunch = false;
+
+ #[ORM\Column(type: 'boolean', options: ['default' => false])]
+ #[Groups(['work_hour:read'])]
+ private bool $hasOvernight = false;
+
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false;
@@ -212,6 +232,66 @@ class WorkHour
return $this;
}
+ public function getDayHoursMinutes(): ?int
+ {
+ return $this->dayHoursMinutes;
+ }
+
+ public function setDayHoursMinutes(?int $dayHoursMinutes): self
+ {
+ $this->dayHoursMinutes = $dayHoursMinutes;
+
+ return $this;
+ }
+
+ public function getNightHoursMinutes(): ?int
+ {
+ return $this->nightHoursMinutes;
+ }
+
+ public function setNightHoursMinutes(?int $nightHoursMinutes): self
+ {
+ $this->nightHoursMinutes = $nightHoursMinutes;
+
+ return $this;
+ }
+
+ public function getHasBreakfast(): bool
+ {
+ return $this->hasBreakfast;
+ }
+
+ public function setHasBreakfast(bool $hasBreakfast): self
+ {
+ $this->hasBreakfast = $hasBreakfast;
+
+ return $this;
+ }
+
+ public function getHasLunch(): bool
+ {
+ return $this->hasLunch;
+ }
+
+ public function setHasLunch(bool $hasLunch): self
+ {
+ $this->hasLunch = $hasLunch;
+
+ return $this;
+ }
+
+ public function getHasOvernight(): bool
+ {
+ return $this->hasOvernight;
+ }
+
+ public function setHasOvernight(bool $hasOvernight): self
+ {
+ $this->hasOvernight = $hasOvernight;
+
+ return $this;
+ }
+
public function isPresentMorning(): bool
{
return $this->isPresentMorning;
diff --git a/src/Service/Contracts/EmployeeContractChangeRequest.php b/src/Service/Contracts/EmployeeContractChangeRequest.php
index e1e0e68..c1ea397 100644
--- a/src/Service/Contracts/EmployeeContractChangeRequest.php
+++ b/src/Service/Contracts/EmployeeContractChangeRequest.php
@@ -15,6 +15,7 @@ final readonly class EmployeeContractChangeRequest
public ?DateTimeImmutable $contractEndDate,
public ?bool $contractPaidLeaveSettled,
public ?string $contractComment,
+ public ?bool $isDriver = null,
) {}
public function hasPeriodChangeRequest(): bool
diff --git a/src/Service/Contracts/EmployeeContractChangeRequestFactory.php b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
index 57ea84e..02e17fa 100644
--- a/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
+++ b/src/Service/Contracts/EmployeeContractChangeRequestFactory.php
@@ -19,6 +19,7 @@ final class EmployeeContractChangeRequestFactory
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(),
+ isDriver: $employee->getIsDriverInput(),
);
}
diff --git a/src/Service/Contracts/EmployeeContractPeriodBuilder.php b/src/Service/Contracts/EmployeeContractPeriodBuilder.php
index a297946..5f75bfa 100644
--- a/src/Service/Contracts/EmployeeContractPeriodBuilder.php
+++ b/src/Service/Contracts/EmployeeContractPeriodBuilder.php
@@ -18,6 +18,7 @@ final class EmployeeContractPeriodBuilder
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
+ bool $isDriver = false,
): EmployeeContractPeriod {
return new EmployeeContractPeriod()
->setEmployee($employee)
@@ -25,6 +26,7 @@ final class EmployeeContractPeriodBuilder
->setStartDate($startDate)
->setEndDate($endDate)
->setContractNature($nature)
+ ->setIsDriver($isDriver)
;
}
}
diff --git a/src/Service/Contracts/EmployeeContractPeriodManager.php b/src/Service/Contracts/EmployeeContractPeriodManager.php
index afe029c..6f8fb01 100644
--- a/src/Service/Contracts/EmployeeContractPeriodManager.php
+++ b/src/Service/Contracts/EmployeeContractPeriodManager.php
@@ -28,6 +28,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
+ bool $isDriver = false,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
@@ -36,7 +37,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
return;
}
- $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
+ $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->entityManager->flush();
}
@@ -69,7 +70,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
- ?EmployeeContractPeriod $todayPeriod
+ ?EmployeeContractPeriod $todayPeriod,
+ bool $isDriver = false,
): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
@@ -81,7 +83,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
}
}
- $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
+ $this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->entityManager->flush();
}
@@ -91,8 +93,9 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
+ bool $isDriver = false,
): void {
- $period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature);
+ $period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver);
$this->entityManager->persist($period);
}
}
diff --git a/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
index c93d350..12697f2 100644
--- a/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
+++ b/src/Service/Contracts/EmployeeContractPeriodManagerInterface.php
@@ -18,6 +18,7 @@ interface EmployeeContractPeriodManagerInterface
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
+ bool $isDriver = false,
): void;
public function closeCurrentPeriod(
@@ -33,6 +34,7 @@ interface EmployeeContractPeriodManagerInterface
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
- ?EmployeeContractPeriod $todayPeriod
+ ?EmployeeContractPeriod $todayPeriod,
+ bool $isDriver = false,
): void;
}
diff --git a/src/Service/Contracts/EmployeeContractResolver.php b/src/Service/Contracts/EmployeeContractResolver.php
index 88950d6..53b7162 100644
--- a/src/Service/Contracts/EmployeeContractResolver.php
+++ b/src/Service/Contracts/EmployeeContractResolver.php
@@ -23,6 +23,60 @@ readonly class EmployeeContractResolver
return $period?->getContract();
}
+ public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool
+ {
+ $period = $this->periodRepository->findOneCoveringDate($employee, $date);
+
+ return $period?->getIsDriver() ?? false;
+ }
+
+ /**
+ * @param list $employees
+ * @param list $days
+ *
+ * @return array>
+ */
+ public function resolveIsDriverForEmployeesAndDays(array $employees, array $days): array
+ {
+ $resolved = [];
+ if ([] === $employees || [] === $days) {
+ return $resolved;
+ }
+
+ foreach ($employees as $employee) {
+ $employeeId = $employee->getId();
+ if (!$employeeId) {
+ continue;
+ }
+
+ foreach ($days as $day) {
+ $resolved[$employeeId][$day] = false;
+ }
+ }
+
+ $from = new DateTimeImmutable(min($days));
+ $to = new DateTimeImmutable(max($days));
+ $periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
+ foreach ($periods as $period) {
+ $employeeId = $period->getEmployee()?->getId();
+ if (!$employeeId) {
+ continue;
+ }
+
+ $start = $period->getStartDate()->format('Y-m-d');
+ $end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
+ $isDriver = $period->getIsDriver();
+ foreach ($days as $day) {
+ if ($day < $start || $day > $end) {
+ continue;
+ }
+ $resolved[$employeeId][$day] = $isDriver;
+ }
+ }
+
+ return $resolved;
+ }
+
public function resolveNatureForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ContractNature
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
diff --git a/src/State/EmployeeWriteProcessor.php b/src/State/EmployeeWriteProcessor.php
index 56c9dc3..b2bbf23 100644
--- a/src/State/EmployeeWriteProcessor.php
+++ b/src/State/EmployeeWriteProcessor.php
@@ -65,7 +65,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
contract: $currentContract,
startDate: $startDate,
endDate: $changeRequest->contractEndDate,
- nature: $nature
+ nature: $nature,
+ isDriver: $changeRequest->isDriver ?? false,
);
$data->setEntryDate($startDate);
@@ -108,7 +109,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
startDate: $startDate,
endDate: $changeRequest->contractEndDate,
nature: $nature,
- todayPeriod: $todayPeriod
+ todayPeriod: $todayPeriod,
+ isDriver: $changeRequest->isDriver ?? false,
);
return $result;
diff --git a/src/State/WorkHourBulkUpsertProcessor.php b/src/State/WorkHourBulkUpsertProcessor.php
index 97f447c..8486fb7 100644
--- a/src/State/WorkHourBulkUpsertProcessor.php
+++ b/src/State/WorkHourBulkUpsertProcessor.php
@@ -95,7 +95,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
));
}
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
- $normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
+ $isDriver = $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate);
+ $normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking, $isDriver);
$existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
$isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
@@ -225,11 +226,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
- * isPresentAfternoon:bool
+ * isPresentAfternoon:bool,
+ * dayHoursMinutes:?int,
+ * nightHoursMinutes:?int,
+ * hasBreakfast:bool,
+ * hasLunch:bool,
+ * hasOvernight:bool
* }
*/
- private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking): array
+ private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking, bool $isDriver): array
{
+ if ($isDriver) {
+ return [
+ 'morningFrom' => null,
+ 'morningTo' => null,
+ 'afternoonFrom' => null,
+ 'afternoonTo' => null,
+ 'eveningFrom' => null,
+ 'eveningTo' => null,
+ 'isPresentMorning' => false,
+ 'isPresentAfternoon' => false,
+ 'dayHoursMinutes' => $this->normalizeMinutes($entry['dayHoursMinutes'] ?? null, $employeeId, 'dayHoursMinutes'),
+ 'nightHoursMinutes' => $this->normalizeMinutes($entry['nightHoursMinutes'] ?? null, $employeeId, 'nightHoursMinutes'),
+ 'hasBreakfast' => $this->normalizePresence($entry['hasBreakfast'] ?? false, $employeeId, 'hasBreakfast'),
+ 'hasLunch' => $this->normalizePresence($entry['hasLunch'] ?? false, $employeeId, 'hasLunch'),
+ 'hasOvernight' => $this->normalizePresence($entry['hasOvernight'] ?? false, $employeeId, 'hasOvernight'),
+ ];
+ }
+
if ($isPresenceTracking) {
return [
'morningFrom' => null,
@@ -240,6 +264,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
'eveningTo' => null,
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
+ 'dayHoursMinutes' => null,
+ 'nightHoursMinutes' => null,
+ 'hasBreakfast' => false,
+ 'hasLunch' => false,
+ 'hasOvernight' => false,
];
}
@@ -254,6 +283,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
// même si le contrat résolu ce jour est en suivi horaire.
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
+ 'dayHoursMinutes' => null,
+ 'nightHoursMinutes' => null,
+ 'hasBreakfast' => false,
+ 'hasLunch' => false,
+ 'hasOvernight' => false,
];
}
@@ -283,6 +317,32 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
return $time;
}
+ private function normalizeMinutes(mixed $value, int $employeeId, string $field): ?int
+ {
+ if (null === $value || '' === $value) {
+ return null;
+ }
+
+ if (!is_int($value) && !is_float($value)) {
+ throw new UnprocessableEntityHttpException(sprintf(
+ 'Employee %d: %s must be an integer (minutes).',
+ $employeeId,
+ $field
+ ));
+ }
+
+ $minutes = (int) $value;
+ if ($minutes < 0) {
+ throw new UnprocessableEntityHttpException(sprintf(
+ 'Employee %d: %s must be >= 0.',
+ $employeeId,
+ $field
+ ));
+ }
+
+ return $minutes;
+ }
+
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
{
if (!is_bool($value)) {
@@ -305,7 +365,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
- * isPresentAfternoon:bool
+ * isPresentAfternoon:bool,
+ * dayHoursMinutes:?int,
+ * nightHoursMinutes:?int,
+ * hasBreakfast:bool,
+ * hasLunch:bool,
+ * hasOvernight:bool
* } $entry
*/
private function isEntryEmpty(array $entry): bool
@@ -317,7 +382,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
&& null === $entry['eveningFrom']
&& null === $entry['eveningTo']
&& false === $entry['isPresentMorning']
- && false === $entry['isPresentAfternoon'];
+ && false === $entry['isPresentAfternoon']
+ && (null === $entry['dayHoursMinutes'] || 0 === $entry['dayHoursMinutes'])
+ && (null === $entry['nightHoursMinutes'] || 0 === $entry['nightHoursMinutes'])
+ && false === $entry['hasBreakfast']
+ && false === $entry['hasLunch']
+ && false === $entry['hasOvernight'];
}
/**
@@ -329,7 +399,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
- * isPresentAfternoon:bool
+ * isPresentAfternoon:bool,
+ * dayHoursMinutes:?int,
+ * nightHoursMinutes:?int,
+ * hasBreakfast:bool,
+ * hasLunch:bool,
+ * hasOvernight:bool
* } $entry
*/
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
@@ -343,6 +418,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon'])
+ ->setDayHoursMinutes($entry['dayHoursMinutes'])
+ ->setNightHoursMinutes($entry['nightHoursMinutes'])
+ ->setHasBreakfast($entry['hasBreakfast'])
+ ->setHasLunch($entry['hasLunch'])
+ ->setHasOvernight($entry['hasOvernight'])
// Toute modification invalide la validation chef de site.
->setIsSiteValid(false)
// Toute modification utilisateur repasse la ligne en attente de validation RH.
@@ -359,7 +439,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
* eveningFrom:?string,
* eveningTo:?string,
* isPresentMorning:bool,
- * isPresentAfternoon:bool
+ * isPresentAfternoon:bool,
+ * dayHoursMinutes:?int,
+ * nightHoursMinutes:?int,
+ * hasBreakfast:bool,
+ * hasLunch:bool,
+ * hasOvernight:bool
* } $entry
*/
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
@@ -371,6 +456,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
&& $workHour->getEveningFrom() === $entry['eveningFrom']
&& $workHour->getEveningTo() === $entry['eveningTo']
&& $workHour->getIsPresentMorning() === $entry['isPresentMorning']
- && $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon'];
+ && $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon']
+ && $workHour->getDayHoursMinutes() === $entry['dayHoursMinutes']
+ && $workHour->getNightHoursMinutes() === $entry['nightHoursMinutes']
+ && $workHour->getHasBreakfast() === $entry['hasBreakfast']
+ && $workHour->getHasLunch() === $entry['hasLunch']
+ && $workHour->getHasOvernight() === $entry['hasOvernight'];
}
}
diff --git a/src/State/WorkHourDayContextProvider.php b/src/State/WorkHourDayContextProvider.php
index 52ccc57..bade542 100644
--- a/src/State/WorkHourDayContextProvider.php
+++ b/src/State/WorkHourDayContextProvider.php
@@ -52,9 +52,11 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
}
// On initialise toutes les lignes, même sans absence ce jour-là.
+ $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
- hasContractAtDate: null !== $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate)
+ hasContractAtDate: null !== $contract,
+ isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
);
}
diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php
index 526e2fe..79b029a 100644
--- a/src/State/WorkHourWeeklySummaryProvider.php
+++ b/src/State/WorkHourWeeklySummaryProvider.php
@@ -116,6 +116,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
+ $isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
@@ -129,6 +130,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
'metrics' => $this->computeMetrics($workHour),
'isPresentMorning' => $workHour->getIsPresentMorning(),
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
+ 'dayHoursMinutes' => $workHour->getDayHoursMinutes(),
+ 'nightHoursMinutes' => $workHour->getNightHoursMinutes(),
+ 'hasBreakfast' => $workHour->getHasBreakfast(),
+ 'hasLunch' => $workHour->getHasLunch(),
+ 'hasOvernight' => $workHour->getHasOvernight(),
];
}
@@ -156,7 +162,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($absentMorning || $absentAfternoon) {
$absenceByEmployeeDate[$employeeId][$date] = true;
- $absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
+ $absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
@@ -179,15 +185,21 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
continue;
}
- $weeklyDayMinutes = 0;
- $weeklyNightMinutes = 0;
- $weeklyTotalMinutes = 0;
- $weeklyPresenceCount = 0.0;
- $daily = [];
+ $weeklyDayMinutes = 0;
+ $weeklyNightMinutes = 0;
+ $weeklyTotalMinutes = 0;
+ $weeklyPresenceCount = 0.0;
+ $weeklyBreakfastCount = 0;
+ $weeklyLunchCount = 0;
+ $weeklyOvernightCount = 0;
+ $daily = [];
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractsByEmployeeDate[$employeeId][$days[0]]
?? null;
+ $isDriver = $isDriverByEmployeeDate[$employeeId][$anchorDateYmd]
+ ?? $isDriverByEmployeeDate[$employeeId][$days[0]]
+ ?? false;
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
?? ContractNature::CDI;
@@ -198,14 +210,42 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
foreach ($days as $date) {
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
- $metrics = $entry['metrics'] ?? new WorkMetrics();
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
$contractAtDate = $employeeContractsByDate[$date] ?? null;
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
- // Les absences "comptées comme travaillées" alimentent le total du jour.
- $metrics->addCreditedMinutes($creditedMinutes);
+ $isDateDriver = $isDriverByEmployeeDate[$employeeId][$date] ?? false;
+
+ $hasBreakfast = false;
+ $hasLunch = false;
+ $hasOvernight = false;
+
+ if ($isDateDriver) {
+ $dayMinutes = ($entry['dayHoursMinutes'] ?? 0);
+ $nightMinutes = ($entry['nightHoursMinutes'] ?? 0);
+ $totalMinutes = $dayMinutes + $nightMinutes;
+ $hasBreakfast = $entry['hasBreakfast'] ?? false;
+ $hasLunch = $entry['hasLunch'] ?? false;
+ $hasOvernight = $entry['hasOvernight'] ?? false;
+ if ($hasBreakfast) {
+ ++$weeklyBreakfastCount;
+ }
+ if ($hasLunch) {
+ ++$weeklyLunchCount;
+ }
+ if ($hasOvernight) {
+ ++$weeklyOvernightCount;
+ }
+ } else {
+ $metrics = $entry['metrics'] ?? new WorkMetrics();
+ // Les absences "comptées comme travaillées" alimentent le total du jour.
+ $metrics->addCreditedMinutes($creditedMinutes);
+ $dayMinutes = $metrics->dayMinutes;
+ $nightMinutes = $metrics->nightMinutes;
+ $totalMinutes = $metrics->totalMinutes;
+ }
+
$present = null;
- if ($isPresenceTracking) {
+ if ($isPresenceTracking && !$isDateDriver) {
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
@@ -214,30 +254,33 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
- $weeklyDayMinutes += $metrics->dayMinutes;
- $weeklyNightMinutes += $metrics->nightMinutes;
- $weeklyTotalMinutes += $metrics->totalMinutes;
+ $weeklyDayMinutes += $dayMinutes;
+ $weeklyNightMinutes += $nightMinutes;
+ $weeklyTotalMinutes += $totalMinutes;
if (null !== $present) {
$weeklyPresenceCount += $present;
}
$daily[] = new WeeklyDaySummary(
date: $date,
- dayMinutes: $metrics->dayMinutes,
- nightMinutes: $metrics->nightMinutes,
- totalMinutes: $metrics->totalMinutes,
+ dayMinutes: $dayMinutes,
+ nightMinutes: $nightMinutes,
+ totalMinutes: $totalMinutes,
present: $present,
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
+ hasBreakfast: $hasBreakfast,
+ hasLunch: $hasLunch,
+ hasOvernight: $hasOvernight,
);
}
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
- $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
+ $disableOvertimeBonuses = $isDriver || $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
- $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
+ $weeklyOvertimeTotalMinutes = ($isWeekPresenceTracking || $isDriver)
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
@@ -266,7 +309,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
- weeklyRecoveryMinutes: $weeklyRecoveryMinutes
+ weeklyRecoveryMinutes: $weeklyRecoveryMinutes,
+ isDriver: $isDriver,
+ weeklyBreakfastCount: $weeklyBreakfastCount,
+ weeklyLunchCount: $weeklyLunchCount,
+ weeklyOvernightCount: $weeklyOvernightCount,
);
}