Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d26d6b50f | ||
| 339d650b41 |
@@ -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(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(which python3:*)",
|
||||||
"Bash(sudo apt-get:*)",
|
"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`
|
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
|
||||||
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
|
||||||
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
- 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
|
## Validation Rules
|
||||||
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
|
- `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 <= 35h: +25% from 35h to 43h, +50% beyond
|
||||||
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
||||||
- INTERIM: no overtime bonuses, no recovery time
|
- INTERIM: no overtime bonuses, no recovery time
|
||||||
|
- Driver contracts: no overtime calculation
|
||||||
|
|
||||||
## Frontend Patterns
|
## Frontend Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.38'
|
app.version: '0.1.39'
|
||||||
|
|||||||
@@ -117,6 +117,29 @@ Documents complementaires:
|
|||||||
- pas de bonus 50%
|
- pas de bonus 50%
|
||||||
- pas de total récup
|
- 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
|
## 7) Fériés
|
||||||
|
|
||||||
- Les jours fériés sont identifiés et affiché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" />
|
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
|
||||||
</div>
|
</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">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -281,6 +293,7 @@ type CreateContractForm = {
|
|||||||
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
startDate: string
|
startDate: string
|
||||||
endDate: string
|
endDate: string
|
||||||
|
isDriver: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
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 | '',
|
contractId: '' as number | '',
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: ''
|
endDate: '',
|
||||||
|
isDriver: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const createValidationTouched = reactive({
|
const createValidationTouched = reactive({
|
||||||
@@ -171,6 +172,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
createContractForm.contractId = ''
|
createContractForm.contractId = ''
|
||||||
createContractForm.contractNature = 'CDI'
|
createContractForm.contractNature = 'CDI'
|
||||||
createContractForm.endDate = ''
|
createContractForm.endDate = ''
|
||||||
|
createContractForm.isDriver = false
|
||||||
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
||||||
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
||||||
: getTodayYmd()
|
: getTodayYmd()
|
||||||
@@ -244,7 +246,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
contractId: Number(createContractForm.contractId),
|
contractId: Number(createContractForm.contractId),
|
||||||
contractNature: createContractForm.contractNature,
|
contractNature: createContractForm.contractNature,
|
||||||
contractStartDate: createContractForm.startDate,
|
contractStartDate: createContractForm.startDate,
|
||||||
contractEndDate: createContractForm.endDate || null
|
contractEndDate: createContractForm.endDate || null,
|
||||||
|
isDriverInput: createContractForm.isDriver
|
||||||
})
|
})
|
||||||
isCreateContractDrawerOpen.value = false
|
isCreateContractDrawerOpen.value = false
|
||||||
await reloadEmployee()
|
await reloadEmployee()
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const useHoursPage = () => {
|
|||||||
if (selectedSiteIds.value.length === 0) return []
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
const filter = employeeFilter.value.trim().toLowerCase()
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
return employees.value.filter((employee) => {
|
return employees.value.filter((employee) => {
|
||||||
|
if (employee.isDriver === true) return false
|
||||||
const siteId = employee.site?.id
|
const siteId = employee.site?.id
|
||||||
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
||||||
if (!filter) return true
|
if (!filter) return true
|
||||||
@@ -462,6 +463,9 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
const getRowAbsenceStyle = (employeeId: number) => {
|
const getRowAbsenceStyle = (employeeId: number) => {
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (dayRow && dayRow.hasContractAtDate === false) {
|
||||||
|
return { backgroundColor: '#6b7280' }
|
||||||
|
}
|
||||||
if (!dayRow?.absenceLabel) return undefined
|
if (!dayRow?.absenceLabel) return undefined
|
||||||
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
|
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@
|
|||||||
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
||||||
<p>Heures</p>
|
<p>Heures</p>
|
||||||
</NuxtLink>
|
</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">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
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.
|
La date de fin est obligatoire pour un CDD.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
@@ -246,7 +257,8 @@ const form = reactive({
|
|||||||
contractId: '' as number | '',
|
contractId: '' as number | '',
|
||||||
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
|
||||||
contractStartDate: '',
|
contractStartDate: '',
|
||||||
contractEndDate: ''
|
contractEndDate: '',
|
||||||
|
isDriver: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -431,7 +443,8 @@ const handleSubmit = async () => {
|
|||||||
contractId: Number(form.contractId),
|
contractId: Number(form.contractId),
|
||||||
contractNature: form.contractNature,
|
contractNature: form.contractNature,
|
||||||
contractStartDate: form.contractStartDate,
|
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.contractNature = 'CDI'
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
|
form.isDriver = false
|
||||||
editingEmployee.value = null
|
editingEmployee.value = null
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
@@ -485,6 +499,7 @@ const openCreate = () => {
|
|||||||
form.contractNature = 'CDI'
|
form.contractNature = 'CDI'
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
|
form.isDriver = false
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ContractHistoryItem = {
|
|||||||
comment?: string | null
|
comment?: string | null
|
||||||
periodId?: number | null
|
periodId?: number | null
|
||||||
suspensions?: ContractSuspension[]
|
suspensions?: ContractSuspension[]
|
||||||
|
isDriver?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
@@ -26,6 +27,7 @@ export type Employee = {
|
|||||||
lastName: string
|
lastName: string
|
||||||
site: Site
|
site: Site
|
||||||
contract?: Contract | null
|
contract?: Contract | null
|
||||||
|
isDriver?: boolean
|
||||||
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
currentContractStartDate?: string | null
|
currentContractStartDate?: string | null
|
||||||
currentContractEndDate?: string | null
|
currentContractEndDate?: string | null
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export type WorkHour = {
|
|||||||
eveningTo?: string | null
|
eveningTo?: string | null
|
||||||
isPresentMorning?: boolean
|
isPresentMorning?: boolean
|
||||||
isPresentAfternoon?: boolean
|
isPresentAfternoon?: boolean
|
||||||
|
dayHoursMinutes?: number | null
|
||||||
|
nightHoursMinutes?: number | null
|
||||||
|
hasBreakfast?: boolean
|
||||||
|
hasLunch?: boolean
|
||||||
|
hasOvernight?: boolean
|
||||||
isSiteValid?: boolean
|
isSiteValid?: boolean
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
@@ -28,6 +33,11 @@ export type WorkHourEntryPayload = {
|
|||||||
eveningTo?: string | null
|
eveningTo?: string | null
|
||||||
isPresentMorning?: boolean
|
isPresentMorning?: boolean
|
||||||
isPresentAfternoon?: boolean
|
isPresentAfternoon?: boolean
|
||||||
|
dayHoursMinutes?: number | null
|
||||||
|
nightHoursMinutes?: number | null
|
||||||
|
hasBreakfast?: boolean
|
||||||
|
hasLunch?: boolean
|
||||||
|
hasOvernight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourDailySummary = {
|
export type WeeklyWorkHourDailySummary = {
|
||||||
@@ -39,6 +49,9 @@ export type WeeklyWorkHourDailySummary = {
|
|||||||
hasAbsence?: boolean
|
hasAbsence?: boolean
|
||||||
absenceLabel?: string | null
|
absenceLabel?: string | null
|
||||||
absenceColor?: string | null
|
absenceColor?: string | null
|
||||||
|
hasBreakfast?: boolean
|
||||||
|
hasLunch?: boolean
|
||||||
|
hasOvernight?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourRowSummary = {
|
export type WeeklyWorkHourRowSummary = {
|
||||||
@@ -58,6 +71,10 @@ export type WeeklyWorkHourRowSummary = {
|
|||||||
weeklyOvertime25Minutes?: number
|
weeklyOvertime25Minutes?: number
|
||||||
weeklyOvertime50Minutes?: number
|
weeklyOvertime50Minutes?: number
|
||||||
weeklyRecoveryMinutes?: number
|
weeklyRecoveryMinutes?: number
|
||||||
|
isDriver?: boolean
|
||||||
|
weeklyBreakfastCount?: number
|
||||||
|
weeklyLunchCount?: number
|
||||||
|
weeklyOvernightCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourSummary = {
|
export type WeeklyWorkHourSummary = {
|
||||||
@@ -77,9 +94,22 @@ export type WorkHourDayContextRow = {
|
|||||||
absentAfternoon: boolean
|
absentAfternoon: boolean
|
||||||
creditedMinutes: number
|
creditedMinutes: number
|
||||||
creditedPresenceUnits: number
|
creditedPresenceUnits: number
|
||||||
|
isDriverContract?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkHourDayContext = {
|
export type WorkHourDayContext = {
|
||||||
workDate: string
|
workDate: string
|
||||||
rows: WorkHourDayContextRow[]
|
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'
|
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
contractStartDate?: string
|
contractStartDate?: string
|
||||||
contractEndDate?: string | null
|
contractEndDate?: string | null
|
||||||
|
isDriverInput?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Employee>('/employees', {
|
return api.post<Employee>('/employees', {
|
||||||
@@ -43,7 +44,8 @@ export const createEmployee = async (payload: {
|
|||||||
contract: `/api/contracts/${payload.contractId}`,
|
contract: `/api/contracts/${payload.contractId}`,
|
||||||
contractNature: payload.contractNature,
|
contractNature: payload.contractNature,
|
||||||
contractStartDate: payload.contractStartDate,
|
contractStartDate: payload.contractStartDate,
|
||||||
contractEndDate: payload.contractEndDate ?? null
|
contractEndDate: payload.contractEndDate ?? null,
|
||||||
|
isDriverInput: payload.isDriverInput ?? false
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.create',
|
toastSuccessKey: 'success.employee.create',
|
||||||
toastErrorKey: 'errors.employee.create'
|
toastErrorKey: 'errors.employee.create'
|
||||||
@@ -63,6 +65,7 @@ export const updateEmployee = async (
|
|||||||
contractPaidLeaveSettled?: boolean
|
contractPaidLeaveSettled?: boolean
|
||||||
contractComment?: string | null
|
contractComment?: string | null
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
|
isDriverInput?: boolean
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -91,6 +94,9 @@ export const updateEmployee = async (
|
|||||||
if (payload.contractComment !== undefined) {
|
if (payload.contractComment !== undefined) {
|
||||||
body.contractComment = payload.contractComment ?? null
|
body.contractComment = payload.contractComment ?? null
|
||||||
}
|
}
|
||||||
|
if (payload.isDriverInput !== undefined) {
|
||||||
|
body.isDriverInput = payload.isDriverInput
|
||||||
|
}
|
||||||
|
|
||||||
return api.patch<Employee>(`/employees/${id}`, body, {
|
return api.patch<Employee>(`/employees/${id}`, body, {
|
||||||
toastSuccessKey: 'success.employee.update',
|
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,
|
* eveningFrom?:?string,
|
||||||
* eveningTo?:?string,
|
* eveningTo?:?string,
|
||||||
* isPresentMorning?:bool,
|
* isPresentMorning?:bool,
|
||||||
* isPresentAfternoon?:bool
|
* isPresentAfternoon?:bool,
|
||||||
|
* dayHoursMinutes?:?int,
|
||||||
|
* nightHoursMinutes?:?int,
|
||||||
|
* hasBreakfast?:bool,
|
||||||
|
* hasLunch?:bool,
|
||||||
|
* hasOvernight?:bool
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
public array $entries = [];
|
public array $entries = [];
|
||||||
|
|||||||
@@ -27,5 +27,7 @@ final class ContractHistoryItem
|
|||||||
public ?int $periodId = null,
|
public ?int $periodId = null,
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public array $suspensions = [],
|
public array $suspensions = [],
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public bool $isDriver = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ final class DayContextRow
|
|||||||
public bool $absentAfternoon = false,
|
public bool $absentAfternoon = false,
|
||||||
public int $creditedMinutes = 0,
|
public int $creditedMinutes = 0,
|
||||||
public float $creditedPresenceUnits = 0.0,
|
public float $creditedPresenceUnits = 0.0,
|
||||||
|
public bool $isDriverContract = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function addAbsence(
|
public function addAbsence(
|
||||||
@@ -78,6 +79,7 @@ final class DayContextRow
|
|||||||
'absentAfternoon' => $this->absentAfternoon,
|
'absentAfternoon' => $this->absentAfternoon,
|
||||||
'creditedMinutes' => $this->creditedMinutes,
|
'creditedMinutes' => $this->creditedMinutes,
|
||||||
'creditedPresenceUnits' => $this->creditedPresenceUnits,
|
'creditedPresenceUnits' => $this->creditedPresenceUnits,
|
||||||
|
'isDriverContract' => $this->isDriverContract,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,8 @@ final class WeeklyDaySummary
|
|||||||
public bool $hasAbsence = false,
|
public bool $hasAbsence = false,
|
||||||
public ?string $absenceLabel = null,
|
public ?string $absenceLabel = null,
|
||||||
public ?string $absenceColor = 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 $weeklyOvertime25Minutes,
|
||||||
public int $weeklyOvertime50Minutes,
|
public int $weeklyOvertime50Minutes,
|
||||||
public int $weeklyRecoveryMinutes,
|
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'])]
|
#[Groups(['employee:write'])]
|
||||||
private ?string $contractComment = null;
|
private ?string $contractComment = null;
|
||||||
|
|
||||||
|
#[Groups(['employee:write'])]
|
||||||
|
private ?bool $isDriverInput = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
@@ -245,6 +248,24 @@ class Employee
|
|||||||
return $this;
|
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'])]
|
#[Groups(['employee:read'])]
|
||||||
public function getCurrentContractNature(): string
|
public function getCurrentContractNature(): string
|
||||||
{
|
{
|
||||||
@@ -329,6 +350,7 @@ class Employee
|
|||||||
comment: $period->getComment(),
|
comment: $period->getComment(),
|
||||||
periodId: $period->getId(),
|
periodId: $period->getId(),
|
||||||
suspensions: $suspensionData,
|
suspensions: $suspensionData,
|
||||||
|
isDriver: $period->getIsDriver(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
$periods
|
$periods
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class EmployeeContractPeriod
|
|||||||
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
|
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
|
||||||
private string $contractNature = 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])]
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
private bool $paidLeaveSettled = false;
|
private bool $paidLeaveSettled = false;
|
||||||
|
|
||||||
@@ -137,6 +140,18 @@ class EmployeeContractPeriod
|
|||||||
return $this->createdAt;
|
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
|
public function isPaidLeaveSettled(): bool
|
||||||
{
|
{
|
||||||
return $this->paidLeaveSettled;
|
return $this->paidLeaveSettled;
|
||||||
|
|||||||
@@ -99,6 +99,26 @@ class WorkHour
|
|||||||
#[Groups(['work_hour:read'])]
|
#[Groups(['work_hour:read'])]
|
||||||
private bool $isPresentAfternoon = false;
|
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])]
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
||||||
private bool $isValid = false;
|
private bool $isValid = false;
|
||||||
@@ -212,6 +232,66 @@ class WorkHour
|
|||||||
return $this;
|
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
|
public function isPresentMorning(): bool
|
||||||
{
|
{
|
||||||
return $this->isPresentMorning;
|
return $this->isPresentMorning;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final readonly class EmployeeContractChangeRequest
|
|||||||
public ?DateTimeImmutable $contractEndDate,
|
public ?DateTimeImmutable $contractEndDate,
|
||||||
public ?bool $contractPaidLeaveSettled,
|
public ?bool $contractPaidLeaveSettled,
|
||||||
public ?string $contractComment,
|
public ?string $contractComment,
|
||||||
|
public ?bool $isDriver = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function hasPeriodChangeRequest(): bool
|
public function hasPeriodChangeRequest(): bool
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class EmployeeContractChangeRequestFactory
|
|||||||
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
|
contractEndDate: $this->parseOptionalYmd($employee->getContractEndDate(), 'contractEndDate'),
|
||||||
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
|
||||||
contractComment: $employee->getContractComment(),
|
contractComment: $employee->getContractComment(),
|
||||||
|
isDriver: $employee->getIsDriverInput(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): EmployeeContractPeriod {
|
): EmployeeContractPeriod {
|
||||||
return new EmployeeContractPeriod()
|
return new EmployeeContractPeriod()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
@@ -25,6 +26,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
->setStartDate($startDate)
|
->setStartDate($startDate)
|
||||||
->setEndDate($endDate)
|
->setEndDate($endDate)
|
||||||
->setContractNature($nature)
|
->setContractNature($nature)
|
||||||
|
->setIsDriver($isDriver)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): void {
|
): void {
|
||||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature);
|
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
?EmployeeContractPeriod $todayPeriod
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
|
bool $isDriver = false,
|
||||||
): void {
|
): void {
|
||||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
$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();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +93,9 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): void {
|
): void {
|
||||||
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature);
|
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver);
|
||||||
$this->entityManager->persist($period);
|
$this->entityManager->persist($period);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
|
bool $isDriver = false,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
public function closeCurrentPeriod(
|
public function closeCurrentPeriod(
|
||||||
@@ -33,6 +34,7 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
DateTimeImmutable $startDate,
|
DateTimeImmutable $startDate,
|
||||||
?DateTimeImmutable $endDate,
|
?DateTimeImmutable $endDate,
|
||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
?EmployeeContractPeriod $todayPeriod
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
|
bool $isDriver = false,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,60 @@ readonly class EmployeeContractResolver
|
|||||||
return $period?->getContract();
|
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
|
public function resolveNatureForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ContractNature
|
||||||
{
|
{
|
||||||
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
contract: $currentContract,
|
contract: $currentContract,
|
||||||
startDate: $startDate,
|
startDate: $startDate,
|
||||||
endDate: $changeRequest->contractEndDate,
|
endDate: $changeRequest->contractEndDate,
|
||||||
nature: $nature
|
nature: $nature,
|
||||||
|
isDriver: $changeRequest->isDriver ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
$data->setEntryDate($startDate);
|
$data->setEntryDate($startDate);
|
||||||
@@ -108,7 +109,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
startDate: $startDate,
|
startDate: $startDate,
|
||||||
endDate: $changeRequest->contractEndDate,
|
endDate: $changeRequest->contractEndDate,
|
||||||
nature: $nature,
|
nature: $nature,
|
||||||
todayPeriod: $todayPeriod
|
todayPeriod: $todayPeriod,
|
||||||
|
isDriver: $changeRequest->isDriver ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
|
$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;
|
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||||
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
|
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
|
||||||
$isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
|
$isSelf = in_array('ROLE_SELF', $user->getRoles(), true);
|
||||||
@@ -225,11 +226,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
* eveningFrom:?string,
|
* eveningFrom:?string,
|
||||||
* eveningTo:?string,
|
* eveningTo:?string,
|
||||||
* isPresentMorning:bool,
|
* 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) {
|
if ($isPresenceTracking) {
|
||||||
return [
|
return [
|
||||||
'morningFrom' => null,
|
'morningFrom' => null,
|
||||||
@@ -240,6 +264,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
'eveningTo' => null,
|
'eveningTo' => null,
|
||||||
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
||||||
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
|
'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.
|
// même si le contrat résolu ce jour est en suivi horaire.
|
||||||
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
||||||
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
|
'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;
|
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
|
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
|
||||||
{
|
{
|
||||||
if (!is_bool($value)) {
|
if (!is_bool($value)) {
|
||||||
@@ -305,7 +365,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
* eveningFrom:?string,
|
* eveningFrom:?string,
|
||||||
* eveningTo:?string,
|
* eveningTo:?string,
|
||||||
* isPresentMorning:bool,
|
* isPresentMorning:bool,
|
||||||
* isPresentAfternoon:bool
|
* isPresentAfternoon:bool,
|
||||||
|
* dayHoursMinutes:?int,
|
||||||
|
* nightHoursMinutes:?int,
|
||||||
|
* hasBreakfast:bool,
|
||||||
|
* hasLunch:bool,
|
||||||
|
* hasOvernight:bool
|
||||||
* } $entry
|
* } $entry
|
||||||
*/
|
*/
|
||||||
private function isEntryEmpty(array $entry): bool
|
private function isEntryEmpty(array $entry): bool
|
||||||
@@ -317,7 +382,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
&& null === $entry['eveningFrom']
|
&& null === $entry['eveningFrom']
|
||||||
&& null === $entry['eveningTo']
|
&& null === $entry['eveningTo']
|
||||||
&& false === $entry['isPresentMorning']
|
&& 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,
|
* eveningFrom:?string,
|
||||||
* eveningTo:?string,
|
* eveningTo:?string,
|
||||||
* isPresentMorning:bool,
|
* isPresentMorning:bool,
|
||||||
* isPresentAfternoon:bool
|
* isPresentAfternoon:bool,
|
||||||
|
* dayHoursMinutes:?int,
|
||||||
|
* nightHoursMinutes:?int,
|
||||||
|
* hasBreakfast:bool,
|
||||||
|
* hasLunch:bool,
|
||||||
|
* hasOvernight:bool
|
||||||
* } $entry
|
* } $entry
|
||||||
*/
|
*/
|
||||||
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
|
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
|
||||||
@@ -343,6 +418,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
->setEveningTo($entry['eveningTo'])
|
->setEveningTo($entry['eveningTo'])
|
||||||
->setIsPresentMorning($entry['isPresentMorning'])
|
->setIsPresentMorning($entry['isPresentMorning'])
|
||||||
->setIsPresentAfternoon($entry['isPresentAfternoon'])
|
->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.
|
// Toute modification invalide la validation chef de site.
|
||||||
->setIsSiteValid(false)
|
->setIsSiteValid(false)
|
||||||
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
||||||
@@ -359,7 +439,12 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
* eveningFrom:?string,
|
* eveningFrom:?string,
|
||||||
* eveningTo:?string,
|
* eveningTo:?string,
|
||||||
* isPresentMorning:bool,
|
* isPresentMorning:bool,
|
||||||
* isPresentAfternoon:bool
|
* isPresentAfternoon:bool,
|
||||||
|
* dayHoursMinutes:?int,
|
||||||
|
* nightHoursMinutes:?int,
|
||||||
|
* hasBreakfast:bool,
|
||||||
|
* hasLunch:bool,
|
||||||
|
* hasOvernight:bool
|
||||||
* } $entry
|
* } $entry
|
||||||
*/
|
*/
|
||||||
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
|
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
|
||||||
@@ -371,6 +456,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
&& $workHour->getEveningFrom() === $entry['eveningFrom']
|
&& $workHour->getEveningFrom() === $entry['eveningFrom']
|
||||||
&& $workHour->getEveningTo() === $entry['eveningTo']
|
&& $workHour->getEveningTo() === $entry['eveningTo']
|
||||||
&& $workHour->getIsPresentMorning() === $entry['isPresentMorning']
|
&& $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à.
|
// On initialise toutes les lignes, même sans absence ce jour-là.
|
||||||
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||||
$rowsByEmployeeId[$employeeId] = new DayContextRow(
|
$rowsByEmployeeId[$employeeId] = new DayContextRow(
|
||||||
employeeId: $employeeId,
|
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);
|
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
|
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
|
||||||
|
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
$metricsByEmployeeDate = [];
|
$metricsByEmployeeDate = [];
|
||||||
foreach ($workHours as $workHour) {
|
foreach ($workHours as $workHour) {
|
||||||
$employeeId = $workHour->getEmployee()?->getId();
|
$employeeId = $workHour->getEmployee()?->getId();
|
||||||
@@ -129,6 +130,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
'metrics' => $this->computeMetrics($workHour),
|
'metrics' => $this->computeMetrics($workHour),
|
||||||
'isPresentMorning' => $workHour->getIsPresentMorning(),
|
'isPresentMorning' => $workHour->getIsPresentMorning(),
|
||||||
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
|
'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);
|
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||||
if ($absentMorning || $absentAfternoon) {
|
if ($absentMorning || $absentAfternoon) {
|
||||||
$absenceByEmployeeDate[$employeeId][$date] = true;
|
$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;
|
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
|
||||||
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
||||||
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
||||||
@@ -179,15 +185,21 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyDayMinutes = 0;
|
$weeklyDayMinutes = 0;
|
||||||
$weeklyNightMinutes = 0;
|
$weeklyNightMinutes = 0;
|
||||||
$weeklyTotalMinutes = 0;
|
$weeklyTotalMinutes = 0;
|
||||||
$weeklyPresenceCount = 0.0;
|
$weeklyPresenceCount = 0.0;
|
||||||
$daily = [];
|
$weeklyBreakfastCount = 0;
|
||||||
|
$weeklyLunchCount = 0;
|
||||||
|
$weeklyOvernightCount = 0;
|
||||||
|
$daily = [];
|
||||||
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
||||||
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
|
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
|
||||||
?? $contractsByEmployeeDate[$employeeId][$days[0]]
|
?? $contractsByEmployeeDate[$employeeId][$days[0]]
|
||||||
?? null;
|
?? null;
|
||||||
|
$isDriver = $isDriverByEmployeeDate[$employeeId][$anchorDateYmd]
|
||||||
|
?? $isDriverByEmployeeDate[$employeeId][$days[0]]
|
||||||
|
?? false;
|
||||||
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
|
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
|
||||||
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
|
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
|
||||||
?? ContractNature::CDI;
|
?? ContractNature::CDI;
|
||||||
@@ -198,14 +210,42 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
|
||||||
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
||||||
$contractAtDate = $employeeContractsByDate[$date] ?? null;
|
$contractAtDate = $employeeContractsByDate[$date] ?? null;
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
|
||||||
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
$isDateDriver = $isDriverByEmployeeDate[$employeeId][$date] ?? false;
|
||||||
$metrics->addCreditedMinutes($creditedMinutes);
|
|
||||||
|
$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;
|
$present = null;
|
||||||
if ($isPresenceTracking) {
|
if ($isPresenceTracking && !$isDateDriver) {
|
||||||
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
|
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
|
||||||
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
|
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
|
||||||
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
$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);
|
$present = min(1.0, $morning + $afternoon + $creditedPresence);
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyDayMinutes += $metrics->dayMinutes;
|
$weeklyDayMinutes += $dayMinutes;
|
||||||
$weeklyNightMinutes += $metrics->nightMinutes;
|
$weeklyNightMinutes += $nightMinutes;
|
||||||
$weeklyTotalMinutes += $metrics->totalMinutes;
|
$weeklyTotalMinutes += $totalMinutes;
|
||||||
if (null !== $present) {
|
if (null !== $present) {
|
||||||
$weeklyPresenceCount += $present;
|
$weeklyPresenceCount += $present;
|
||||||
}
|
}
|
||||||
|
|
||||||
$daily[] = new WeeklyDaySummary(
|
$daily[] = new WeeklyDaySummary(
|
||||||
date: $date,
|
date: $date,
|
||||||
dayMinutes: $metrics->dayMinutes,
|
dayMinutes: $dayMinutes,
|
||||||
nightMinutes: $metrics->nightMinutes,
|
nightMinutes: $nightMinutes,
|
||||||
totalMinutes: $metrics->totalMinutes,
|
totalMinutes: $totalMinutes,
|
||||||
present: $present,
|
present: $present,
|
||||||
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
|
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
|
||||||
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
|
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
|
||||||
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
|
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
|
||||||
|
hasBreakfast: $hasBreakfast,
|
||||||
|
hasLunch: $hasLunch,
|
||||||
|
hasOvernight: $hasOvernight,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
|
$disableOvertimeBonuses = $isDriver || $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
|
||||||
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
|
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
|
||||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
|
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
|
||||||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
$weeklyOvertimeTotalMinutes = ($isWeekPresenceTracking || $isDriver)
|
||||||
? 0
|
? 0
|
||||||
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
||||||
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
@@ -266,7 +309,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
|
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
|
||||||
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
|
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
|
||||||
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
|
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
|
||||||
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
|
weeklyRecoveryMinutes: $weeklyRecoveryMinutes,
|
||||||
|
isDriver: $isDriver,
|
||||||
|
weeklyBreakfastCount: $weeklyBreakfastCount,
|
||||||
|
weeklyLunchCount: $weeklyLunchCount,
|
||||||
|
weeklyOvernightCount: $weeklyOvernightCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user