feat : ajout de la gestion des heures chauffeurs
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
225
frontend/components/driver-hours/DriverHoursDayView.vue
Normal file
225
frontend/components/driver-hours/DriverHoursDayView.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="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 border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span class="pl-2">Absence</span>
|
||||
<span class="pl-4">Heure de jour</span>
|
||||
<span class="pl-2">Heure de nuit</span>
|
||||
<span class="pl-2">Total</span>
|
||||
<span>Petit déj.</span>
|
||||
<span>Déjeuner</span>
|
||||
<span>Nuitée</span>
|
||||
<span v-if="isAdmin" class="flex justify-between items-center">
|
||||
<span>Valider</span>
|
||||
<input
|
||||
ref="bulkValidationInput"
|
||||
:checked="isBulkValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onBulkValidationChange"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
||||
<span>Site</span>
|
||||
<input
|
||||
ref="bulkSiteValidationInput"
|
||||
:checked="isBulkSiteValidationChecked"
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
||||
:disabled="!canBulkToggleSiteValidation"
|
||||
@change="onBulkSiteValidationChange"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
:key="employee.id"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||
:style="{ gridTemplateColumns: dayGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ employee.firstName }} {{ employee.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
title="Validation site"
|
||||
>
|
||||
<Icon name="mdi:check"/>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
|
||||
Modifié le {{ getRowUpdatedAt(employee.id) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
||||
<p
|
||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
||||
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
||||
:title="getRowAbsenceLabel(employee.id) || ''"
|
||||
:style="getRowAbsenceStyle(employee.id)"
|
||||
>
|
||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="self-start text-left text-xs font-semibold underline"
|
||||
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||
@click="onAbsenceClick(employee.id)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].dayHours"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2">
|
||||
<TimeSelect
|
||||
v-model="rows[employee.id].nightHours"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="pl-2 text-sm font-semibold">
|
||||
{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input
|
||||
v-model="rows[employee.id].hasBreakfast"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input
|
||||
v-model="rows[employee.id].hasLunch"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<input
|
||||
v-model="rows[employee.id].hasOvernight"
|
||||
type="checkbox"
|
||||
class="cursor-pointer h-4 w-4"
|
||||
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="text-right">
|
||||
<input
|
||||
:checked="rows[employee.id]?.isValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-right p-5">
|
||||
<input
|
||||
v-if="isSiteManager"
|
||||
:checked="rows[employee.id]?.isSiteValid ?? false"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
|
||||
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
<div v-if="!isAdmin">
|
||||
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||
<span v-else class="text-xs text-neutral-500">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||
import type { DriverHourRow } from '~/services/dto/work-hour'
|
||||
|
||||
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
|
||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
employees: Employee[]
|
||||
isAdmin: boolean
|
||||
isSiteManager: boolean
|
||||
dayGridCols: string
|
||||
isHoliday: boolean
|
||||
contractLabel: (employee: Employee) => string
|
||||
isRowLocked: (employeeId: number) => boolean
|
||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||
isValidationPending: (employeeId: number) => boolean
|
||||
isSiteValidationPending: (employeeId: number) => boolean
|
||||
canToggleValidation: (employeeId: number) => boolean
|
||||
canToggleSiteValidation: (employeeId: number) => boolean
|
||||
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
|
||||
isBulkValidationChecked: boolean
|
||||
isBulkValidationIndeterminate: boolean
|
||||
isBulkSiteValidationChecked: boolean
|
||||
isBulkSiteValidationIndeterminate: boolean
|
||||
canBulkToggleSiteValidation: boolean
|
||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||
getRowAbsenceLabel: (employeeId: number) => string
|
||||
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
||||
getRowUpdatedAt: (employeeId: number) => string
|
||||
onAbsenceClick: (employeeId: number) => void
|
||||
formatMinutes: (minutes: number) => string
|
||||
}>()
|
||||
|
||||
const onBulkValidationChange = (event: Event) => {
|
||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onBulkSiteValidationChange = (event: Event) => {
|
||||
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
||||
}
|
||||
|
||||
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||
props.onToggleSiteValidation(employeeId, checked)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isBulkValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkValidationInput.value) return
|
||||
bulkValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.isBulkSiteValidationIndeterminate,
|
||||
(isIndeterminate) => {
|
||||
if (!bulkSiteValidationInput.value) return
|
||||
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
100
frontend/components/driver-hours/DriverHoursWeekView.vue
Normal file
100
frontend/components/driver-hours/DriverHoursWeekView.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="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
|
||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||
:style="{ gridTemplateColumns: weekGridCols }"
|
||||
>
|
||||
<span>Nom</span>
|
||||
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
|
||||
<span>Jour/Nuit <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>
|
||||
<span>Petit <br>déj.</span>
|
||||
<span>Déj.</span>
|
||||
<span>Nuitée</span>
|
||||
</div>
|
||||
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="row in weeklySummary?.rows ?? []"
|
||||
:key="row.employeeId"
|
||||
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||
:style="{ gridTemplateColumns: weekGridCols }"
|
||||
>
|
||||
<div class="text-neutral-900 min-w-0">
|
||||
<p class="font-semibold truncate">
|
||||
{{ row.firstName }} {{ row.lastName }}
|
||||
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
||||
</p>
|
||||
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="daily in row.daily"
|
||||
:key="daily.date"
|
||||
class="text-left leading-4 rounded-md px-2 py-1"
|
||||
:class="daily.hasAbsence ? 'text-white' : ''"
|
||||
:style="getDailyCellStyle(daily)"
|
||||
:title="daily.absenceLabel ?? ''"
|
||||
>
|
||||
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
|
||||
<div v-if="daily.hasBreakfast || daily.hasLunch || daily.hasOvernight" class="text-[10px] flex gap-1 mt-0.5">
|
||||
<span v-if="daily.hasBreakfast" title="Petit déjeuner">PD</span>
|
||||
<span v-if="daily.hasLunch" title="Déjeuner">DJ</span>
|
||||
<span v-if="daily.hasOvernight" title="Nuitée">NU</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold leading-4">
|
||||
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
|
||||
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyTotalMinutes) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||
</div>
|
||||
<div class="font-semibold">{{ row.weeklyBreakfastCount ?? 0 }}</div>
|
||||
<div class="font-semibold">{{ row.weeklyLunchCount ?? 0 }}</div>
|
||||
<div class="font-semibold">{{ row.weeklyOvernightCount ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||
|
||||
const getDailyCellStyle = (daily: {
|
||||
hasAbsence?: boolean
|
||||
absenceColor?: string | null
|
||||
}) => {
|
||||
if (!daily.hasAbsence) return undefined
|
||||
return { backgroundColor: daily.absenceColor || '#dc2626' }
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
isWeekLoading: boolean
|
||||
weekGridCols: string
|
||||
weeklySummary: WeeklyWorkHourSummary | null
|
||||
weekDayHeaders: Array<{ date: string; label: string }>
|
||||
formatMinutes: (minutes: number) => string
|
||||
}>()
|
||||
</script>
|
||||
@@ -240,6 +240,18 @@
|
||||
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
||||
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="create-contract-is-driver">
|
||||
<input
|
||||
id="create-contract-is-driver"
|
||||
v-model="createContractForm.isDriver"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Chauffeur
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -281,6 +293,7 @@ type CreateContractForm = {
|
||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||
startDate: string
|
||||
endDate: string
|
||||
isDriver: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
960
frontend/composables/useDriverHoursPage.ts
Normal file
960
frontend/composables/useDriverHoursPage.ts
Normal file
@@ -0,0 +1,960 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import type { Site } from '~/services/dto/site'
|
||||
import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import type { Absence } from '~/services/dto/absence'
|
||||
import type { HalfDay } from '~/services/dto/half-day'
|
||||
import type { DriverHourRow } from '~/services/dto/work-hour'
|
||||
import { listScopedEmployees } from '~/services/employees'
|
||||
import { listAbsenceTypes } from '~/services/absence-types'
|
||||
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
||||
import { listPublicHolidays } from '~/services/public-holidays'
|
||||
import {
|
||||
bulkUpdateWorkHourSiteValidation,
|
||||
bulkUpdateWorkHourValidation,
|
||||
bulkUpsertWorkHours,
|
||||
getWorkHourDayContext,
|
||||
getWeeklyWorkHourSummary,
|
||||
listWorkHoursByDate,
|
||||
updateWorkHourSiteValidation,
|
||||
updateWorkHourValidation
|
||||
} from '~/services/work-hours'
|
||||
import {
|
||||
formatDateLongFr,
|
||||
formatWeekDayHeaderFr,
|
||||
formatWeekRangeFr,
|
||||
getIsoWeekNumber,
|
||||
getOffsetFromTodayYmd,
|
||||
getWeekStartDate,
|
||||
getTodayYmd,
|
||||
parseYmd,
|
||||
shiftYmd
|
||||
} from '~/utils/date'
|
||||
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
||||
|
||||
export const useDriverHoursPage = () => {
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
|
||||
const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
|
||||
const viewMode = ref<'day' | 'week'>('day')
|
||||
|
||||
const selectedDate = ref(getTodayYmd())
|
||||
const employees = ref<Employee[]>([])
|
||||
const employeeFilter = ref('')
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
const sitesInitialized = ref(false)
|
||||
const rows = ref<Record<number, DriverHourRow>>({})
|
||||
const dayContext = ref<WorkHourDayContext | null>(null)
|
||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||
const absenceTypes = ref<AbsenceType[]>([])
|
||||
const absences = ref<Absence[]>([])
|
||||
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
|
||||
const isAbsenceDrawerOpen = ref(false)
|
||||
const isAbsenceSubmitting = ref(false)
|
||||
const editingAbsence = ref<Absence | null>(null)
|
||||
const absenceForm = ref({
|
||||
employeeId: '' as number | '',
|
||||
typeId: '' as number | '',
|
||||
startDate: '',
|
||||
startHalf: 'AM' as HalfDay,
|
||||
endDate: '',
|
||||
endHalf: 'PM' as HalfDay,
|
||||
comment: ''
|
||||
})
|
||||
const isLoading = ref(false)
|
||||
const isWeekLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const validatingRowIds = ref<number[]>([])
|
||||
const siteValidatingRowIds = ref<number[]>([])
|
||||
|
||||
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<Site[]>(() => {
|
||||
const siteMap = new Map<number, Site>()
|
||||
for (const employee of employees.value) {
|
||||
if (employee.site) {
|
||||
siteMap.set(employee.site.id, employee.site)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(siteMap.values()).sort((siteA, siteB) => {
|
||||
const orderA = siteA.displayOrder ?? 0
|
||||
const orderB = siteB.displayOrder ?? 0
|
||||
if (orderA !== orderB) return orderA - orderB
|
||||
return siteA.name.localeCompare(siteB.name, 'fr')
|
||||
})
|
||||
})
|
||||
|
||||
const visibleEmployees = computed(() => {
|
||||
if (selectedSiteIds.value.length === 0) return []
|
||||
const filter = employeeFilter.value.trim().toLowerCase()
|
||||
return employees.value.filter((employee) => {
|
||||
if (employee.isDriver !== true) return false
|
||||
const siteId = employee.site?.id
|
||||
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
||||
if (!filter) return true
|
||||
const firstName = employee.firstName?.toLowerCase() ?? ''
|
||||
const lastName = employee.lastName?.toLowerCase() ?? ''
|
||||
return firstName.includes(filter) || lastName.includes(filter)
|
||||
})
|
||||
})
|
||||
|
||||
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
|
||||
|
||||
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
|
||||
if (!weeklySummary.value) return null
|
||||
return {
|
||||
...weeklySummary.value,
|
||||
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId))
|
||||
}
|
||||
})
|
||||
|
||||
const saveButtonClass = computed(() => {
|
||||
if (isSubmitting.value || employees.value.length === 0) {
|
||||
return 'opacity-50 cursor-not-allowed'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
|
||||
const 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<number, WorkHourDayContext['rows'][number]>()
|
||||
for (const row of dayContext.value?.rows ?? []) {
|
||||
map.set(row.employeeId, row)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
|
||||
const targetDate = target === 'yesterday'
|
||||
? getOffsetFromTodayYmd(-1)
|
||||
: target === 'tomorrow'
|
||||
? getOffsetFromTodayYmd(1)
|
||||
: getTodayYmd()
|
||||
|
||||
if (selectedDate.value === targetDate) {
|
||||
return 'bg-primary-500 text-white'
|
||||
}
|
||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||
}
|
||||
|
||||
const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
||||
const selected = parseYmd(selectedDate.value)
|
||||
if (!selected) {
|
||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
const targetDate = new Date(today)
|
||||
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
|
||||
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
|
||||
|
||||
const selectedWeekStart = getWeekStartDate(selected)
|
||||
const targetWeekStart = getWeekStartDate(targetDate)
|
||||
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
|
||||
|
||||
if (isActive) {
|
||||
return 'bg-primary-500 text-white'
|
||||
}
|
||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||
}
|
||||
|
||||
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
|
||||
const today = new Date()
|
||||
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
|
||||
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
|
||||
|
||||
const weekNumber = getIsoWeekNumber(today)
|
||||
return `Sem. S${weekNumber}`
|
||||
}
|
||||
|
||||
const formattedSelectedDate = computed(() => {
|
||||
const parsed = parseYmd(selectedDate.value)
|
||||
if (!parsed) return selectedDate.value
|
||||
|
||||
if (viewMode.value === 'week') {
|
||||
return formatWeekRangeFr(parsed)
|
||||
}
|
||||
|
||||
return formatDateLongFr(parsed)
|
||||
})
|
||||
|
||||
const selectedYear = computed(() => {
|
||||
const parsed = parseYmd(selectedDate.value)
|
||||
return parsed ? parsed.getFullYear() : null
|
||||
})
|
||||
|
||||
const selectedHolidayLabel = computed(() => {
|
||||
const year = selectedYear.value
|
||||
if (!year) return ''
|
||||
return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
|
||||
})
|
||||
|
||||
const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
|
||||
|
||||
const weekDayHeaders = computed(() => {
|
||||
const days = weeklySummary.value?.days ?? []
|
||||
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
|
||||
})
|
||||
|
||||
const shiftDate = (steps: number) => {
|
||||
const offset = viewMode.value === 'week' ? (steps * 7) : steps
|
||||
const next = shiftYmd(selectedDate.value, offset)
|
||||
if (!next) return
|
||||
selectedDate.value = next
|
||||
}
|
||||
|
||||
const setToday = () => { selectedDate.value = getTodayYmd() }
|
||||
const setYesterday = () => { setToday(); shiftDate(-1) }
|
||||
const setTomorrow = () => { setToday(); shiftDate(1) }
|
||||
const setThisWeek = () => { selectedDate.value = getTodayYmd() }
|
||||
const setPreviousWeek = () => {
|
||||
const previousWeek = shiftYmd(getTodayYmd(), -7)
|
||||
if (!previousWeek) return
|
||||
selectedDate.value = previousWeek
|
||||
}
|
||||
const setNextWeek = () => {
|
||||
const nextWeek = shiftYmd(getTodayYmd(), 7)
|
||||
if (!nextWeek) return
|
||||
selectedDate.value = nextWeek
|
||||
}
|
||||
|
||||
const resetAbsenceForm = () => {
|
||||
absenceForm.value = {
|
||||
employeeId: '',
|
||||
typeId: '',
|
||||
startDate: '',
|
||||
startHalf: 'AM',
|
||||
endDate: '',
|
||||
endHalf: 'PM',
|
||||
comment: ''
|
||||
}
|
||||
}
|
||||
|
||||
const closeAbsenceDrawer = () => {
|
||||
isAbsenceDrawerOpen.value = false
|
||||
editingAbsence.value = null
|
||||
resetAbsenceForm()
|
||||
}
|
||||
|
||||
const 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<number, WorkHour>()
|
||||
for (const workHour of workHours) {
|
||||
byEmployeeId.set(workHour.employee.id, workHour)
|
||||
}
|
||||
|
||||
const nextRows: Record<number, DriverHourRow> = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, 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<Employee | null>, 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<Employee | null>, 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()
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@
|
||||
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
||||
<p>Heures</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/driver-hours"
|
||||
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
:class="route.path.startsWith('/driver-hours')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
<Icon name="mdi:truck-outline" size="24"/>
|
||||
<p>Heures Conducteurs</p>
|
||||
</NuxtLink>
|
||||
<template v-if="isAdmin">
|
||||
<NuxtLink
|
||||
to="/employees"
|
||||
|
||||
182
frontend/pages/driver-hours.vue
Normal file
182
frontend/pages/driver-hours.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="h-full overflow-hidden flex flex-col">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Heures Conducteurs</h1>
|
||||
</div>
|
||||
|
||||
<HoursToolbar
|
||||
v-model:selected-date="selectedDate"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:selected-site-ids="selectedSiteIds"
|
||||
v-model:employee-filter="employeeFilter"
|
||||
:is-admin="isAdmin"
|
||||
:sites="sites"
|
||||
:absence-types="absenceTypes"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<div v-else-if="visibleEmployees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Aucun conducteur accessible.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex min-h-0 flex-col gap-4">
|
||||
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
|
||||
<DriverHoursDayView
|
||||
v-if="viewMode === 'day'"
|
||||
v-model:rows="rows"
|
||||
:employees="visibleEmployees"
|
||||
:is-admin="isAdmin"
|
||||
:is-site-manager="isSiteManager"
|
||||
:day-grid-cols="dayGridCols"
|
||||
:is-holiday="isSelectedDateHoliday"
|
||||
:contract-label="contractLabel"
|
||||
:is-row-locked="isRowLocked"
|
||||
:has-contract-at-selected-date="hasContractAtSelectedDate"
|
||||
:is-validation-pending="isValidationPending"
|
||||
:is-site-validation-pending="isSiteValidationPending"
|
||||
:can-toggle-validation="canToggleValidation"
|
||||
:can-toggle-site-validation="canToggleSiteValidation"
|
||||
:can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
|
||||
:is-bulk-validation-checked="isBulkValidationChecked"
|
||||
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
||||
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
|
||||
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
|
||||
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
|
||||
:on-toggle-validation="toggleValidation"
|
||||
:on-toggle-site-validation="toggleSiteValidation"
|
||||
:on-toggle-validation-bulk="toggleValidationBulk"
|
||||
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
|
||||
:get-row-metrics="getRowMetrics"
|
||||
:get-row-absence-label="getRowAbsenceLabel"
|
||||
:get-row-absence-style="getRowAbsenceStyle"
|
||||
:get-row-updated-at="getRowUpdatedAt"
|
||||
:on-absence-click="openAbsenceDrawer"
|
||||
:format-minutes="formatMinutes"
|
||||
class="max-h-[calc(100vh-300px)]"
|
||||
/>
|
||||
|
||||
<DriverHoursWeekView
|
||||
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-[calc(100vh-300px)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
:class="saveButtonClass"
|
||||
:disabled="isSubmitting || visibleEmployees.length === 0"
|
||||
@click="handleSave"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
isAdmin,
|
||||
isSiteManager,
|
||||
viewMode,
|
||||
selectedDate,
|
||||
employeeFilter,
|
||||
sites,
|
||||
selectedSiteIds,
|
||||
employees,
|
||||
visibleEmployees,
|
||||
rows,
|
||||
absenceTypes,
|
||||
absenceForm,
|
||||
isAbsenceDrawerOpen,
|
||||
isAbsenceSubmitting,
|
||||
editingAbsence,
|
||||
filteredWeeklySummary,
|
||||
isLoading,
|
||||
isWeekLoading,
|
||||
isSubmitting,
|
||||
dayGridCols,
|
||||
weekGridCols,
|
||||
saveButtonClass,
|
||||
formattedSelectedDate,
|
||||
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,
|
||||
isSelectedDateHoliday,
|
||||
handleSave
|
||||
} = useDriverHoursPage()
|
||||
|
||||
useHead({
|
||||
title: 'Heures Conducteurs'
|
||||
})
|
||||
</script>
|
||||
@@ -170,6 +170,17 @@
|
||||
La date de fin est obligatoire pour un CDD.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
||||
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="is-driver">
|
||||
<input
|
||||
id="is-driver"
|
||||
v-model="form.isDriver"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
Chauffeur
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
@@ -246,7 +257,8 @@ const form = reactive({
|
||||
contractId: '' as number | '',
|
||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||
contractStartDate: '',
|
||||
contractEndDate: ''
|
||||
contractEndDate: '',
|
||||
isDriver: false
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
@@ -431,7 +443,8 @@ const handleSubmit = async () => {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Employee>('/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<Employee>(`/employees/${id}`, body, {
|
||||
toastSuccessKey: 'success.employee.update',
|
||||
|
||||
26
migrations/Version20260315100000.php
Normal file
26
migrations/Version20260315100000.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260315100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add is_driver flag to employee_contract_periods';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
34
migrations/Version20260315100100.php
Normal file
34
migrations/Version20260315100100.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260315100100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add driver-specific fields to work_hours';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -27,5 +27,7 @@ final class ContractHistoryItem
|
||||
public ?int $periodId = null,
|
||||
#[Groups(['employee:read'])]
|
||||
public array $suspensions = [],
|
||||
#[Groups(['employee:read'])]
|
||||
public bool $isDriver = false,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ final class EmployeeContractChangeRequestFactory
|
||||
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
|
||||
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
||||
contractComment: $employee->getContractComment(),
|
||||
isDriver: $employee->getIsDriverInput(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Employee> $employees
|
||||
* @param list<string> $days
|
||||
*
|
||||
* @return array<int, array<string, bool>>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user