Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9261cb5b1a | ||
| b68fef61c4 | |||
|
|
5cced46254 | ||
| 07b84a2512 | |||
|
|
ca26b7f934 | ||
| 9cf978f0f2 | |||
|
|
ad9e8705ae | ||
| f8ca5e50a0 |
17
README.md
17
README.md
@@ -1,2 +1,19 @@
|
|||||||
# SIRH
|
# SIRH
|
||||||
Application de gestion des absences employée
|
Application de gestion des absences employée
|
||||||
|
|
||||||
|
## Importer un dump de prod en dev
|
||||||
|
Sur adminer fait un export bdd :
|
||||||
|
- Sortie : enregistrer
|
||||||
|
- Format : SQL
|
||||||
|
- Tables : DROP+CREATE, Incrément automatique, Déclencheurs
|
||||||
|
- Données : INSERT
|
||||||
|
|
||||||
|
Supprime la bdd et créer la bdd :
|
||||||
|
```shell
|
||||||
|
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplie la base avec le dump :
|
||||||
|
```shell
|
||||||
|
docker compose exec -T db psql -U root -d sirh < sirh.sql
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.9'
|
app.version: '0.1.13'
|
||||||
|
|||||||
@@ -6,21 +6,19 @@
|
|||||||
:style="{ gridTemplateColumns: dayGridCols }"
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
>
|
>
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
<span class="pl-4">Début matin</span>
|
<span class="pl-2">Absence</span>
|
||||||
<span class="pr-2">Fin matin</span>
|
<span class="pl-4">Début matin</span>
|
||||||
<span class="pl-2">Début après-midi</span>
|
<span class="pr-2">Fin matin</span>
|
||||||
<span class="pr-2">Fin après-midi</span>
|
<span class="pl-2">Début après-midi</span>
|
||||||
<span class="pl-2">Début soir</span>
|
<span class="pr-2">Fin après-midi</span>
|
||||||
<span class="pr-2">Fin soir</span>
|
<span class="pl-2">Début soir</span>
|
||||||
<span class="pl-2">Absence</span>
|
<span class="pr-2">Fin soir</span>
|
||||||
<span class="pl-2">Jour</span>
|
<span class="pl-2">Jour</span>
|
||||||
<span>Nuit</span>
|
<span>Nuit</span>
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span class="inline-flex items-center gap-2">
|
<span v-if="isAdmin" class="inline-flex items-center gap-2">
|
||||||
<span v-if="isAdmin">Valider</span>
|
<span>Valider</span>
|
||||||
<span v-else>Validation RH</span>
|
|
||||||
<input
|
<input
|
||||||
v-if="isAdmin"
|
|
||||||
ref="bulkValidationInput"
|
ref="bulkValidationInput"
|
||||||
:checked="isBulkValidationChecked"
|
:checked="isBulkValidationChecked"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -28,6 +26,8 @@
|
|||||||
@change="onBulkValidationChange"
|
@change="onBulkValidationChange"
|
||||||
/>
|
/>
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -41,81 +41,91 @@
|
|||||||
{{ employee.firstName }} {{ employee.lastName }}
|
{{ employee.firstName }} {{ employee.lastName }}
|
||||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-neutral-500 truncate">{{ employee.site?.name ?? 'Sans site' }}</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>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-4">
|
<div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
|
||||||
|
<p
|
||||||
|
class="w-full min-w-0 text-sm text-neutral-700 truncate"
|
||||||
|
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
|
||||||
|
:title="getRowAbsenceLabel(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
|
<TimeSelect
|
||||||
v-if="isTimeTracking(employee)"
|
v-if="isTimeTracking(employee)"
|
||||||
v-model="rows[employee.id].morningFrom"
|
v-model="rows[employee.id].morningFrom"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-else-if="isPresenceTracking(employee)"
|
v-else-if="isPresenceTracking(employee)"
|
||||||
v-model="rows[employee.id].isPresentMorning"
|
v-model="rows[employee.id].isPresentMorning"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="cursor-pointer h-4 w-4"
|
class="cursor-pointer h-4 w-4"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<TimeSelect
|
<TimeSelect
|
||||||
v-if="isTimeTracking(employee)"
|
v-if="isTimeTracking(employee)"
|
||||||
v-model="rows[employee.id].morningTo"
|
v-model="rows[employee.id].morningTo"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2">
|
<div class="pl-2">
|
||||||
<TimeSelect
|
<TimeSelect
|
||||||
v-if="isTimeTracking(employee)"
|
v-if="isTimeTracking(employee)"
|
||||||
v-model="rows[employee.id].afternoonFrom"
|
v-model="rows[employee.id].afternoonFrom"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-else-if="isPresenceTracking(employee)"
|
v-else-if="isPresenceTracking(employee)"
|
||||||
v-model="rows[employee.id].isPresentAfternoon"
|
v-model="rows[employee.id].isPresentAfternoon"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="cursor-pointer h-4 w-4"
|
class="cursor-pointer h-4 w-4"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<TimeSelect
|
<TimeSelect
|
||||||
v-if="isTimeTracking(employee)"
|
v-if="isTimeTracking(employee)"
|
||||||
v-model="rows[employee.id].afternoonTo"
|
v-model="rows[employee.id].afternoonTo"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2">
|
<div class="pl-2">
|
||||||
<TimeSelect
|
<TimeSelect
|
||||||
v-if="isTimeTracking(employee)"
|
v-if="isTimeTracking(employee)"
|
||||||
v-model="rows[employee.id].eveningFrom"
|
v-model="rows[employee.id].eveningFrom"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pr-2">
|
<div class="pr-2">
|
||||||
<TimeSelect
|
<TimeSelect
|
||||||
v-if="isTimeTracking(employee)"
|
v-if="isTimeTracking(employee)"
|
||||||
v-model="rows[employee.id].eveningTo"
|
v-model="rows[employee.id].eveningTo"
|
||||||
:disabled="isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2 self-stretch flex flex-col justify-between py-0.5">
|
|
||||||
<p
|
|
||||||
class="text-sm text-neutral-700 truncate"
|
|
||||||
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
|
|
||||||
>
|
|
||||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="self-start text-left text-xs font-semibold underline"
|
|
||||||
:class="isRowLocked(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
|
||||||
:disabled="isRowLocked(employee.id)"
|
|
||||||
@click="onAbsenceClick(employee.id)"
|
|
||||||
>
|
|
||||||
Modifier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
||||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,15 +136,29 @@
|
|||||||
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
||||||
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div v-if="isAdmin">
|
||||||
<input
|
<input
|
||||||
v-if="isAdmin"
|
|
||||||
:checked="rows[employee.id]?.isValid ?? false"
|
:checked="rows[employee.id]?.isValid ?? false"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4 cursor-pointer"
|
class="h-4 w-4 cursor-pointer"
|
||||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
<span v-else-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<input
|
||||||
|
v-if="isSiteManager"
|
||||||
|
:checked="rows[employee.id]?.isSiteValid ?? false"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
:disabled="!canToggleSiteValidation(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>
|
</div>
|
||||||
@@ -152,18 +176,24 @@ const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
employees: Employee[]
|
employees: Employee[]
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
isSiteManager: boolean
|
||||||
dayGridCols: string
|
dayGridCols: string
|
||||||
|
isHoliday: boolean
|
||||||
contractLabel: (employee: Employee) => string
|
contractLabel: (employee: Employee) => string
|
||||||
isTimeTracking: (employee: Employee) => boolean
|
isTimeTracking: (employee: Employee) => boolean
|
||||||
isPresenceTracking: (employee: Employee) => boolean
|
isPresenceTracking: (employee: Employee) => boolean
|
||||||
isRowLocked: (employeeId: number) => boolean
|
isRowLocked: (employeeId: number) => boolean
|
||||||
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
||||||
isEveningLockedByAbsence: (employeeId: number) => boolean
|
isEveningLockedByAbsence: (employeeId: number) => boolean
|
||||||
|
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||||
isValidationPending: (employeeId: number) => boolean
|
isValidationPending: (employeeId: number) => boolean
|
||||||
|
isSiteValidationPending: (employeeId: number) => boolean
|
||||||
canToggleValidation: (employeeId: number) => boolean
|
canToggleValidation: (employeeId: number) => boolean
|
||||||
|
canToggleSiteValidation: (employeeId: number) => boolean
|
||||||
isBulkValidationChecked: boolean
|
isBulkValidationChecked: boolean
|
||||||
isBulkValidationIndeterminate: boolean
|
isBulkValidationIndeterminate: boolean
|
||||||
onToggleValidation: (employeeId: number, checked: boolean) => void
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||||
|
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
||||||
onToggleValidationBulk: (checked: boolean) => void
|
onToggleValidationBulk: (checked: boolean) => void
|
||||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||||
getRowAbsenceLabel: (employeeId: number) => string
|
getRowAbsenceLabel: (employeeId: number) => string
|
||||||
@@ -176,6 +206,10 @@ const onBulkValidationChange = (event: Event) => {
|
|||||||
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
||||||
|
props.onToggleSiteValidation(employeeId, checked)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isBulkValidationIndeterminate,
|
() => props.isBulkValidationIndeterminate,
|
||||||
(isIndeterminate) => {
|
(isIndeterminate) => {
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export type HourRow = {
|
|||||||
eveningTo: string
|
eveningTo: string
|
||||||
isPresentMorning: boolean
|
isPresentMorning: boolean
|
||||||
isPresentAfternoon: boolean
|
isPresentAfternoon: boolean
|
||||||
|
isSiteValid: boolean
|
||||||
isValid: boolean
|
isValid: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="root" class="relative w-full">
|
<div ref="root" class="relative w-full">
|
||||||
<button
|
<div
|
||||||
ref="trigger"
|
ref="trigger"
|
||||||
type="button"
|
class="w-full flex items-center rounded-md border border-neutral-300 px-2 text-sm text-neutral-900 focus-within:border-primary-500"
|
||||||
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500"
|
:class="props.disabled ? 'cursor-not-allowed border-neutral-300 bg-neutral-200 text-neutral-500' : 'bg-white'"
|
||||||
:disabled="props.disabled"
|
|
||||||
@click="toggleOpen"
|
|
||||||
>
|
>
|
||||||
{{ displayValue }}
|
<input
|
||||||
<Icon name="mdi:chevron-down" class="self-center"/>
|
ref="inputRef"
|
||||||
</button>
|
v-model="inputValue"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
||||||
|
@focus="openMenu"
|
||||||
|
@keydown.down.prevent="openMenuAndFocusFirst"
|
||||||
|
@keydown.enter.prevent="commitInput"
|
||||||
|
@keydown.esc.prevent="closeMenu"
|
||||||
|
@input="onInput($event)"
|
||||||
|
@blur="onInputBlur"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="toggleOpen"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:chevron-down" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -18,15 +39,11 @@
|
|||||||
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
||||||
:style="menuStyle"
|
:style="menuStyle"
|
||||||
>
|
>
|
||||||
<button
|
<button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
|
||||||
type="button"
|
|
||||||
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
|
||||||
@click="selectValue('')"
|
|
||||||
>
|
|
||||||
{{ placeholder }}
|
{{ placeholder }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="slot in timeSlots"
|
v-for="slot in filteredTimeSlots"
|
||||||
:key="slot"
|
:key="slot"
|
||||||
type="button"
|
type="button"
|
||||||
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
||||||
@@ -34,6 +51,9 @@
|
|||||||
>
|
>
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
|
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
|
||||||
|
Aucun résultat
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
@@ -55,7 +75,9 @@ const emit = defineEmits<{
|
|||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
const trigger = ref<HTMLElement | null>(null)
|
const trigger = ref<HTMLElement | null>(null)
|
||||||
const menu = ref<HTMLElement | null>(null)
|
const menu = ref<HTMLElement | null>(null)
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
const inputValue = ref('')
|
||||||
const menuStyle = ref<Record<string, string>>({
|
const menuStyle = ref<Record<string, string>>({
|
||||||
top: '0px',
|
top: '0px',
|
||||||
left: '0px',
|
left: '0px',
|
||||||
@@ -73,7 +95,31 @@ const timeSlots = computed(() => {
|
|||||||
return slots
|
return slots
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayValue = computed(() => props.modelValue || props.placeholder)
|
const filteredTimeSlots = computed(() => {
|
||||||
|
const query = inputValue.value.trim()
|
||||||
|
if (!query) return timeSlots.value
|
||||||
|
return timeSlots.value.filter((slot) => slot.includes(query))
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyTimeMask = (value: string): string => {
|
||||||
|
const digits = value.replace(/\D/g, '').slice(0, 4)
|
||||||
|
if (digits.length <= 2) return digits
|
||||||
|
return `${digits.slice(0, 2)}:${digits.slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTypedTime = (value: string): string | null => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed === '') return ''
|
||||||
|
|
||||||
|
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
|
||||||
|
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
|
||||||
|
if (!match) return null
|
||||||
|
const hours = Number(match[1])
|
||||||
|
const minutes = Number(match[2])
|
||||||
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
|
||||||
|
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
const updateMenuPosition = () => {
|
const updateMenuPosition = () => {
|
||||||
const triggerEl = trigger.value
|
const triggerEl = trigger.value
|
||||||
@@ -103,10 +149,57 @@ const toggleOpen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
if (!isOpen.value) {
|
||||||
|
isOpen.value = true
|
||||||
|
nextTick(updateMenuPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenuAndFocusFirst = () => {
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitInput = () => {
|
||||||
|
const normalized = normalizeTypedTime(inputValue.value)
|
||||||
|
if (normalized === null) {
|
||||||
|
inputValue.value = props.modelValue
|
||||||
|
closeMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', normalized)
|
||||||
|
inputValue.value = normalized
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const masked = applyTimeMask(target.value)
|
||||||
|
if (masked !== inputValue.value) {
|
||||||
|
inputValue.value = masked
|
||||||
|
}
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInputBlur = () => {
|
||||||
|
// Laisse le temps au click menu de passer avant fermeture.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (menu.value?.contains(document.activeElement)) return
|
||||||
|
commitInput()
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
const selectValue = (value: string) => {
|
const selectValue = (value: string) => {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
emit('update:modelValue', value)
|
emit('update:modelValue', value)
|
||||||
|
inputValue.value = value
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
|
nextTick(() => inputRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDocumentClick = (event: MouseEvent) => {
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
@@ -139,6 +232,14 @@ watch(() => props.disabled, (disabled) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
inputValue.value = value
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import type { HourRow } from '~/components/hours/types'
|
|||||||
import { listScopedEmployees } from '~/services/employees'
|
import { listScopedEmployees } from '~/services/employees'
|
||||||
import { listAbsenceTypes } from '~/services/absence-types'
|
import { listAbsenceTypes } from '~/services/absence-types'
|
||||||
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
|
||||||
|
import { listPublicHolidays } from '~/services/public-holidays'
|
||||||
import {
|
import {
|
||||||
bulkUpsertWorkHours,
|
bulkUpsertWorkHours,
|
||||||
getWorkHourDayContext,
|
getWorkHourDayContext,
|
||||||
getWeeklyWorkHourSummary,
|
getWeeklyWorkHourSummary,
|
||||||
listWorkHoursByDate,
|
listWorkHoursByDate,
|
||||||
|
updateWorkHourSiteValidation,
|
||||||
updateWorkHourValidation
|
updateWorkHourValidation
|
||||||
} from '~/services/work-hours'
|
} from '~/services/work-hours'
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +36,8 @@ export const useHoursPage = () => {
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
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 viewMode = ref<'day' | 'week'>('day')
|
||||||
|
|
||||||
const selectedDate = ref(getTodayYmd())
|
const selectedDate = ref(getTodayYmd())
|
||||||
@@ -46,6 +50,7 @@ export const useHoursPage = () => {
|
|||||||
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||||
const absenceTypes = ref<AbsenceType[]>([])
|
const absenceTypes = ref<AbsenceType[]>([])
|
||||||
const absences = ref<Absence[]>([])
|
const absences = ref<Absence[]>([])
|
||||||
|
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
|
||||||
const isAbsenceDrawerOpen = ref(false)
|
const isAbsenceDrawerOpen = ref(false)
|
||||||
const isAbsenceSubmitting = ref(false)
|
const isAbsenceSubmitting = ref(false)
|
||||||
const editingAbsence = ref<Absence | null>(null)
|
const editingAbsence = ref<Absence | null>(null)
|
||||||
@@ -62,10 +67,12 @@ export const useHoursPage = () => {
|
|||||||
const isWeekLoading = ref(false)
|
const isWeekLoading = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const validatingRowIds = ref<number[]>([])
|
const validatingRowIds = ref<number[]>([])
|
||||||
|
const siteValidatingRowIds = ref<number[]>([])
|
||||||
|
|
||||||
const dayGridCols = computed(() => {
|
const dayGridCols = computed(() => {
|
||||||
const metricCol = '0.4fr'
|
const metricCol = '0.4fr'
|
||||||
return `1.2fr repeat(6, 1fr) 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
|
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
|
||||||
|
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
|
const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
|
||||||
@@ -118,7 +125,16 @@ export const useHoursPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
|
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 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
|
||||||
|
// Une validation RH fige la ligne côté chef de site.
|
||||||
|
if (row.isValid) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const validatableEmployeeIds = computed(() => {
|
const validatableEmployeeIds = computed(() => {
|
||||||
return employees.value
|
return employees.value
|
||||||
@@ -203,6 +219,19 @@ export const useHoursPage = () => {
|
|||||||
return formatDateLongFr(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 weekDayHeaders = computed(() => {
|
||||||
const days = weeklySummary.value?.days ?? []
|
const days = weeklySummary.value?.days ?? []
|
||||||
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
|
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
|
||||||
@@ -273,12 +302,19 @@ export const useHoursPage = () => {
|
|||||||
eveningTo: '',
|
eveningTo: '',
|
||||||
isPresentMorning: false,
|
isPresentMorning: false,
|
||||||
isPresentAfternoon: false,
|
isPresentAfternoon: false,
|
||||||
|
isSiteValid: false,
|
||||||
isValid: false
|
isValid: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
||||||
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
||||||
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
|
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 contractLabel = (employee: Employee) => {
|
||||||
const contract = employee.contract
|
const contract = employee.contract
|
||||||
@@ -371,6 +407,10 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
const getRowAbsenceLabel = (employeeId: number) => {
|
const getRowAbsenceLabel = (employeeId: number) => {
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
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?.absenceLabel) return ''
|
||||||
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
||||||
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
||||||
@@ -387,13 +427,21 @@ export const useHoursPage = () => {
|
|||||||
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasContractAtSelectedDate = (employeeId: number) => {
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (!dayRow) return true
|
||||||
|
return dayRow.hasContractAtDate !== false
|
||||||
|
}
|
||||||
|
|
||||||
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
|
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
|
||||||
|
if (!hasContractAtSelectedDate(employeeId)) return true
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
if (!dayRow) return false
|
if (!dayRow) return false
|
||||||
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
|
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEveningLockedByAbsence = (employeeId: number) => {
|
const isEveningLockedByAbsence = (employeeId: number) => {
|
||||||
|
if (!hasContractAtSelectedDate(employeeId)) return true
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
if (!dayRow) return false
|
if (!dayRow) return false
|
||||||
return dayRow.absentAfternoon
|
return dayRow.absentAfternoon
|
||||||
@@ -425,6 +473,7 @@ export const useHoursPage = () => {
|
|||||||
eveningTo: workHour?.eveningTo ?? '',
|
eveningTo: workHour?.eveningTo ?? '',
|
||||||
isPresentMorning: workHour?.isPresentMorning ?? false,
|
isPresentMorning: workHour?.isPresentMorning ?? false,
|
||||||
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
|
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
|
||||||
|
isSiteValid: workHour?.isSiteValid ?? false,
|
||||||
isValid: workHour?.isValid ?? false
|
isValid: workHour?.isValid ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,6 +485,18 @@ export const useHoursPage = () => {
|
|||||||
absenceTypes.value = await listAbsenceTypes()
|
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 () => {
|
const loadAbsences = async () => {
|
||||||
absences.value = await listAbsences({
|
absences.value = await listAbsences({
|
||||||
from: selectedDate.value,
|
from: selectedDate.value,
|
||||||
@@ -445,6 +506,9 @@ export const useHoursPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAbsenceDrawer = (employeeId: number) => {
|
const openAbsenceDrawer = (employeeId: number) => {
|
||||||
|
if (!hasContractAtSelectedDate(employeeId)) return
|
||||||
|
if (isSelectedDateHoliday.value) return
|
||||||
|
|
||||||
const existing = absences.value.find((absence) => {
|
const existing = absences.value.find((absence) => {
|
||||||
if (absence.employee?.id !== employeeId) return false
|
if (absence.employee?.id !== employeeId) return false
|
||||||
const start = absence.startDate.slice(0, 10)
|
const start = absence.startDate.slice(0, 10)
|
||||||
@@ -571,17 +635,109 @@ export const useHoursPage = () => {
|
|||||||
options: { toast?: boolean } = {}
|
options: { toast?: boolean } = {}
|
||||||
) => {
|
) => {
|
||||||
const row = rows.value[employeeId]
|
const row = rows.value[employeeId]
|
||||||
if (!row?.workHourId || isValidationPending(employeeId)) return
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (!row?.workHourId && checked) {
|
||||||
|
const employee = employees.value.find((item) => item.id === employeeId)
|
||||||
|
const hasAbsence = !!dayRow?.absenceLabel
|
||||||
|
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
|
||||||
|
|
||||||
|
if (canCreateFromAbsence) {
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries: [{
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false
|
||||||
|
}]
|
||||||
|
}, { 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]
|
validatingRowIds.value = [...validatingRowIds.value, employeeId]
|
||||||
try {
|
try {
|
||||||
await updateWorkHourValidation(row.workHourId, checked, { toast: options.toast })
|
await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
|
||||||
row.isValid = checked
|
updatedRow.isValid = checked
|
||||||
} finally {
|
} finally {
|
||||||
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSiteValidation = async (
|
||||||
|
employeeId: number,
|
||||||
|
checked: boolean,
|
||||||
|
options: { toast?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
|
if (!row?.workHourId && checked) {
|
||||||
|
const employee = employees.value.find((item) => item.id === employeeId)
|
||||||
|
const hasAbsence = !!dayRow?.absenceLabel
|
||||||
|
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
|
||||||
|
|
||||||
|
if (canCreateFromAbsence) {
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries: [{
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false
|
||||||
|
}]
|
||||||
|
}, { 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 toggleValidationBulk = async (checked: boolean) => {
|
||||||
const employeeIds = validatableEmployeeIds.value
|
const employeeIds = validatableEmployeeIds.value
|
||||||
if (employeeIds.length === 0) return
|
if (employeeIds.length === 0) return
|
||||||
@@ -659,6 +815,7 @@ export const useHoursPage = () => {
|
|||||||
const loadPage = async () => {
|
const loadPage = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
await loadPublicHolidaysForSelectedYear()
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
await loadAbsenceTypes()
|
await loadAbsenceTypes()
|
||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
@@ -694,6 +851,7 @@ export const useHoursPage = () => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(selectedDate, async () => {
|
watch(selectedDate, async () => {
|
||||||
|
await loadPublicHolidaysForSelectedYear()
|
||||||
await refreshByDate()
|
await refreshByDate()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -702,7 +860,9 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const entries = employees.value.map((employee) => {
|
const entries = employees.value
|
||||||
|
.filter((employee) => hasContractAtSelectedDate(employee.id))
|
||||||
|
.map((employee) => {
|
||||||
const employeeId = employee.id
|
const employeeId = employee.id
|
||||||
const row = rows.value[employeeId] ?? emptyRow()
|
const row = rows.value[employeeId] ?? emptyRow()
|
||||||
if (isPresenceTracking(employee)) {
|
if (isPresenceTracking(employee)) {
|
||||||
@@ -730,7 +890,11 @@ export const useHoursPage = () => {
|
|||||||
isPresentMorning: false,
|
isPresentMorning: false,
|
||||||
isPresentAfternoon: false
|
isPresentAfternoon: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await bulkUpsertWorkHours({
|
await bulkUpsertWorkHours({
|
||||||
workDate: selectedDate.value,
|
workDate: selectedDate.value,
|
||||||
@@ -745,6 +909,8 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isSelfUser,
|
||||||
|
isSiteManager,
|
||||||
viewMode,
|
viewMode,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
employeeFilter,
|
employeeFilter,
|
||||||
@@ -767,6 +933,7 @@ export const useHoursPage = () => {
|
|||||||
weekGridCols,
|
weekGridCols,
|
||||||
saveButtonClass,
|
saveButtonClass,
|
||||||
formattedSelectedDate,
|
formattedSelectedDate,
|
||||||
|
isSelectedDateHoliday,
|
||||||
weekDayHeaders,
|
weekDayHeaders,
|
||||||
shortcutButtonClass,
|
shortcutButtonClass,
|
||||||
weekShortcutButtonClass,
|
weekShortcutButtonClass,
|
||||||
@@ -784,11 +951,16 @@ export const useHoursPage = () => {
|
|||||||
isRowLocked,
|
isRowLocked,
|
||||||
isHalfLockedByAbsence,
|
isHalfLockedByAbsence,
|
||||||
isEveningLockedByAbsence,
|
isEveningLockedByAbsence,
|
||||||
|
hasContractAtSelectedDate,
|
||||||
isValidationPending,
|
isValidationPending,
|
||||||
|
isSiteValidationPending,
|
||||||
canToggleValidation,
|
canToggleValidation,
|
||||||
|
canToggleSiteValidation,
|
||||||
|
validatableEmployeeIds,
|
||||||
isBulkValidationChecked,
|
isBulkValidationChecked,
|
||||||
isBulkValidationIndeterminate,
|
isBulkValidationIndeterminate,
|
||||||
toggleValidation,
|
toggleValidation,
|
||||||
|
toggleSiteValidation,
|
||||||
toggleValidationBulk,
|
toggleValidationBulk,
|
||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
|
|||||||
@@ -170,6 +170,10 @@
|
|||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Types d\'absences'
|
||||||
|
})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
|||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Calendrier'
|
||||||
|
})
|
||||||
|
|
||||||
// Données principales affichées dans la grille.
|
// Données principales affichées dans la grille.
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = computed(() => {
|
const sites = computed(() => {
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ import { listContracts } from '~/services/contracts'
|
|||||||
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
||||||
import { listSites } from '~/services/sites'
|
import { listSites } from '~/services/sites'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
useHead({
|
||||||
|
title: 'Employés'
|
||||||
|
})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
|||||||
@@ -33,32 +33,38 @@
|
|||||||
Aucun employé accessible.
|
Aucun employé accessible.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-1 min-h-0 flex-col gap-4">
|
<div v-else class="flex min-h-0 flex-col gap-4">
|
||||||
<div class="flex-1 min-h-0 flex flex-col">
|
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
|
||||||
<HoursDayView
|
<HoursDayView
|
||||||
v-if="viewMode === 'day'"
|
v-if="viewMode === 'day'"
|
||||||
v-model:rows="rows"
|
v-model:rows="rows"
|
||||||
:employees="visibleEmployees"
|
:employees="visibleEmployees"
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
|
:is-site-manager="isSiteManager"
|
||||||
:day-grid-cols="dayGridCols"
|
:day-grid-cols="dayGridCols"
|
||||||
|
:is-holiday="isSelectedDateHoliday"
|
||||||
:contract-label="contractLabel"
|
:contract-label="contractLabel"
|
||||||
:is-time-tracking="isTimeTracking"
|
:is-time-tracking="isTimeTracking"
|
||||||
:is-presence-tracking="isPresenceTracking"
|
:is-presence-tracking="isPresenceTracking"
|
||||||
:is-row-locked="isRowLocked"
|
:is-row-locked="isRowLocked"
|
||||||
:is-half-locked-by-absence="isHalfLockedByAbsence"
|
:is-half-locked-by-absence="isHalfLockedByAbsence"
|
||||||
:is-evening-locked-by-absence="isEveningLockedByAbsence"
|
:is-evening-locked-by-absence="isEveningLockedByAbsence"
|
||||||
|
:has-contract-at-selected-date="hasContractAtSelectedDate"
|
||||||
:is-validation-pending="isValidationPending"
|
:is-validation-pending="isValidationPending"
|
||||||
|
:is-site-validation-pending="isSiteValidationPending"
|
||||||
:can-toggle-validation="canToggleValidation"
|
:can-toggle-validation="canToggleValidation"
|
||||||
|
:can-toggle-site-validation="canToggleSiteValidation"
|
||||||
:is-bulk-validation-checked="isBulkValidationChecked"
|
:is-bulk-validation-checked="isBulkValidationChecked"
|
||||||
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
|
||||||
:on-toggle-validation="toggleValidation"
|
:on-toggle-validation="toggleValidation"
|
||||||
|
:on-toggle-site-validation="toggleSiteValidation"
|
||||||
:on-toggle-validation-bulk="toggleValidationBulk"
|
:on-toggle-validation-bulk="toggleValidationBulk"
|
||||||
:get-row-metrics="getRowMetrics"
|
:get-row-metrics="getRowMetrics"
|
||||||
:get-row-absence-label="getRowAbsenceLabel"
|
:get-row-absence-label="getRowAbsenceLabel"
|
||||||
:get-presence-day-value="getPresenceDayValue"
|
:get-presence-day-value="getPresenceDayValue"
|
||||||
:on-absence-click="openAbsenceDrawer"
|
:on-absence-click="openAbsenceDrawer"
|
||||||
:format-minutes="formatMinutes"
|
:format-minutes="formatMinutes"
|
||||||
class="max-h-full"
|
class="max-h-[calc(100vh-300px)]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HoursWeekView
|
<HoursWeekView
|
||||||
@@ -68,7 +74,7 @@
|
|||||||
:weekly-summary="filteredWeeklySummary"
|
:weekly-summary="filteredWeeklySummary"
|
||||||
:week-day-headers="weekDayHeaders"
|
:week-day-headers="weekDayHeaders"
|
||||||
:format-minutes="formatMinutes"
|
:format-minutes="formatMinutes"
|
||||||
class="max-h-full"
|
class="max-h-[calc(100vh-300px)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,6 +111,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const {
|
const {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isSiteManager,
|
||||||
viewMode,
|
viewMode,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
employeeFilter,
|
employeeFilter,
|
||||||
@@ -123,6 +130,7 @@ const {
|
|||||||
isWeekLoading,
|
isWeekLoading,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
dayGridCols,
|
dayGridCols,
|
||||||
|
isSelectedDateHoliday,
|
||||||
weekGridCols,
|
weekGridCols,
|
||||||
saveButtonClass,
|
saveButtonClass,
|
||||||
formattedSelectedDate,
|
formattedSelectedDate,
|
||||||
@@ -143,11 +151,15 @@ const {
|
|||||||
isRowLocked,
|
isRowLocked,
|
||||||
isHalfLockedByAbsence,
|
isHalfLockedByAbsence,
|
||||||
isEveningLockedByAbsence,
|
isEveningLockedByAbsence,
|
||||||
|
hasContractAtSelectedDate,
|
||||||
isValidationPending,
|
isValidationPending,
|
||||||
|
isSiteValidationPending,
|
||||||
canToggleValidation,
|
canToggleValidation,
|
||||||
|
canToggleSiteValidation,
|
||||||
isBulkValidationChecked,
|
isBulkValidationChecked,
|
||||||
isBulkValidationIndeterminate,
|
isBulkValidationIndeterminate,
|
||||||
toggleValidation,
|
toggleValidation,
|
||||||
|
toggleSiteValidation,
|
||||||
toggleValidationBulk,
|
toggleValidationBulk,
|
||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
@@ -159,4 +171,8 @@ const {
|
|||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave
|
||||||
} = useHoursPage()
|
} = useHoursPage()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Heures'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Tableau de bord'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({layout: 'auth'})
|
definePageMeta({layout: 'auth'})
|
||||||
|
useHead({
|
||||||
|
title: 'Connexion'
|
||||||
|
})
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -123,6 +123,10 @@
|
|||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Sites'
|
||||||
|
})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ import { createUser, listUsers, updateUser } from '~/services/users'
|
|||||||
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
|
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['admin'] })
|
||||||
|
useHead({
|
||||||
|
title: 'Utilisateurs'
|
||||||
|
})
|
||||||
|
|
||||||
const users = ref<User[]>([])
|
const users = ref<User[]>([])
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type WorkHour = {
|
|||||||
eveningTo?: string | null
|
eveningTo?: string | null
|
||||||
isPresentMorning?: boolean
|
isPresentMorning?: boolean
|
||||||
isPresentAfternoon?: boolean
|
isPresentAfternoon?: boolean
|
||||||
|
isSiteValid?: boolean
|
||||||
isValid?: boolean
|
isValid?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export type WeeklyWorkHourSummary = {
|
|||||||
|
|
||||||
export type WorkHourDayContextRow = {
|
export type WorkHourDayContextRow = {
|
||||||
employeeId: number
|
employeeId: number
|
||||||
|
hasContractAtDate: boolean
|
||||||
absenceLabel?: string | null
|
absenceLabel?: string | null
|
||||||
absenceHalf?: 'AM' | 'PM' | null
|
absenceHalf?: 'AM' | 'PM' | null
|
||||||
absentMorning: boolean
|
absentMorning: boolean
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const listWorkHoursByDate = async (workDate: string) => {
|
|||||||
export const bulkUpsertWorkHours = async (payload: {
|
export const bulkUpsertWorkHours = async (payload: {
|
||||||
workDate: string
|
workDate: string
|
||||||
entries: WorkHourEntryPayload[]
|
entries: WorkHourEntryPayload[]
|
||||||
}) => {
|
}, options?: { toast?: boolean }) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<{
|
return api.post<{
|
||||||
processed: number
|
processed: number
|
||||||
@@ -34,6 +34,7 @@ export const bulkUpsertWorkHours = async (payload: {
|
|||||||
'/work-hours/bulk-upsert',
|
'/work-hours/bulk-upsert',
|
||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
|
toast: options?.toast ?? true,
|
||||||
toastSuccessMessage: 'Horaires enregistrés.',
|
toastSuccessMessage: 'Horaires enregistrés.',
|
||||||
toastErrorMessage: "Impossible d'enregistrer les horaires."
|
toastErrorMessage: "Impossible d'enregistrer les horaires."
|
||||||
}
|
}
|
||||||
@@ -57,6 +58,23 @@ export const updateWorkHourValidation = async (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updateWorkHourSiteValidation = async (
|
||||||
|
id: number,
|
||||||
|
isSiteValid: boolean,
|
||||||
|
options?: { toast?: boolean }
|
||||||
|
) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.patch<WorkHour>(
|
||||||
|
`/work_hours/${id}/site-validation`,
|
||||||
|
{ isSiteValid },
|
||||||
|
{
|
||||||
|
toast: options?.toast ?? true,
|
||||||
|
toastSuccessMessage: isSiteValid ? 'Validation site enregistrée.' : 'Validation site retirée.',
|
||||||
|
toastErrorMessage: "Impossible de mettre à jour la validation site."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
|
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.get<WeeklyWorkHourSummary>(
|
return api.get<WeeklyWorkHourSummary>(
|
||||||
|
|||||||
40
migrations/Version20260220133000.php
Normal file
40
migrations/Version20260220133000.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260220133000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add employee contract periods history table and seed current contracts';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE employee_contract_periods (id SERIAL NOT NULL, employee_id INT NOT NULL, contract_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_start ON employee_contract_periods (employee_id, start_date)');
|
||||||
|
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_end ON employee_contract_periods (employee_id, end_date)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_831EED7A8C03F15C ON employee_contract_periods (employee_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_831EED7A2576E0FD ON employee_contract_periods (contract_id)');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A2576E0FD FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
|
||||||
|
// Initialise l\'historique avec le contrat actuel de chaque employé.
|
||||||
|
$this->addSql("INSERT INTO employee_contract_periods (employee_id, contract_id, start_date, end_date, created_at)
|
||||||
|
SELECT id, contract_id, DATE '1970-01-01', NULL, NOW()
|
||||||
|
FROM employees
|
||||||
|
WHERE contract_id IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A8C03F15C');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A2576E0FD');
|
||||||
|
$this->addSql('DROP TABLE employee_contract_periods');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
migrations/Version20260226183000.php
Normal file
26
migrations/Version20260226183000.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 Version20260226183000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add site validation flag to work hours';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD is_site_valid BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP is_site_valid');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ final class DayContextRow
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $employeeId,
|
public int $employeeId,
|
||||||
|
public bool $hasContractAtDate = true,
|
||||||
public ?string $absenceLabel = null,
|
public ?string $absenceLabel = null,
|
||||||
public ?string $absenceHalf = null,
|
public ?string $absenceHalf = null,
|
||||||
public bool $absentMorning = false,
|
public bool $absentMorning = false,
|
||||||
@@ -45,6 +46,7 @@ final class DayContextRow
|
|||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* employeeId:int,
|
* employeeId:int,
|
||||||
|
* hasContractAtDate:bool,
|
||||||
* absenceLabel:?string,
|
* absenceLabel:?string,
|
||||||
* absenceHalf:?string,
|
* absenceHalf:?string,
|
||||||
* absentMorning:bool,
|
* absentMorning:bool,
|
||||||
@@ -57,6 +59,7 @@ final class DayContextRow
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'employeeId' => $this->employeeId,
|
'employeeId' => $this->employeeId,
|
||||||
|
'hasContractAtDate' => $this->hasContractAtDate,
|
||||||
'absenceLabel' => $this->absenceLabel,
|
'absenceLabel' => $this->absenceLabel,
|
||||||
'absenceHalf' => $this->absenceHalf,
|
'absenceHalf' => $this->absenceHalf,
|
||||||
'absentMorning' => $this->absentMorning,
|
'absentMorning' => $this->absentMorning,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Entity;
|
|||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\State\EmployeeWriteProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -15,7 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['employee:write']],
|
denormalizationContext: ['groups' => ['employee:write']],
|
||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
processor: EmployeeWriteProcessor::class,
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
||||||
#[ORM\Table(name: 'employees')]
|
#[ORM\Table(name: 'employees')]
|
||||||
|
|||||||
102
src/Entity/EmployeeContractPeriod.php
Normal file
102
src/Entity/EmployeeContractPeriod.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_contract_periods')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'start_date'], name: 'idx_emp_contract_period_employee_start')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'end_date'], name: 'idx_emp_contract_period_employee_end')]
|
||||||
|
class EmployeeContractPeriod
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Contract::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Contract $contract = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable')]
|
||||||
|
private DateTimeImmutable $startDate;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
private ?DateTimeImmutable $endDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new DateTimeImmutable();
|
||||||
|
$this->startDate = new DateTimeImmutable('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployee(): ?Employee
|
||||||
|
{
|
||||||
|
return $this->employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmployee(?Employee $employee): self
|
||||||
|
{
|
||||||
|
$this->employee = $employee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContract(): ?Contract
|
||||||
|
{
|
||||||
|
return $this->contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContract(?Contract $contract): self
|
||||||
|
{
|
||||||
|
$this->contract = $contract;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStartDate(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStartDate(DateTimeImmutable $startDate): self
|
||||||
|
{
|
||||||
|
$this->startDate = $startDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEndDate(?DateTimeImmutable $endDate): self
|
||||||
|
{
|
||||||
|
$this->endDate = $endDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
|
use App\State\WorkHourSiteValidationProcessor;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -33,6 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
denormalizationContext: ['groups' => ['work_hour:validate']],
|
denormalizationContext: ['groups' => ['work_hour:validate']],
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
),
|
),
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/work_hours/{id}/site-validation',
|
||||||
|
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
||||||
|
denormalizationContext: ['groups' => ['work_hour:site_validate']],
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
processor: WorkHourSiteValidationProcessor::class
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
|
||||||
@@ -94,6 +102,10 @@ class WorkHour
|
|||||||
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
||||||
private bool $isValid = false;
|
private bool $isValid = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
|
||||||
|
private bool $isSiteValid = false;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -245,4 +257,21 @@ class WorkHour
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isSiteValid(): bool
|
||||||
|
{
|
||||||
|
return $this->isSiteValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsSiteValid(): bool
|
||||||
|
{
|
||||||
|
return $this->isSiteValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsSiteValid(bool $isSiteValid): self
|
||||||
|
{
|
||||||
|
$this->isSiteValid = $isSiteValid;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ interface WorkHourReadRepositoryInterface
|
|||||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
||||||
|
|
||||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||||
|
|
||||||
|
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||||
}
|
}
|
||||||
|
|||||||
75
src/Repository/EmployeeContractPeriodRepository.php
Normal file
75
src/Repository/EmployeeContractPeriodRepository.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeContractPeriod>
|
||||||
|
*/
|
||||||
|
final class EmployeeContractPeriodRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeContractPeriod::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<EmployeeContractPeriod>
|
||||||
|
*/
|
||||||
|
public function findByEmployeesAndDateRange(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
if ([] === $employees) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee IN (:employees)')
|
||||||
|
->andWhere('p.startDate <= :to')
|
||||||
|
->andWhere('p.endDate IS NULL OR p.endDate >= :from')
|
||||||
|
->setParameter('employees', $employees)
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->orderBy('p.startDate', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.startDate <= :date')
|
||||||
|
->andWhere('p.endDate IS NULL OR p.endDate >= :date')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('date', $date)
|
||||||
|
->orderBy('p.startDate', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->update()
|
||||||
|
->set('p.endDate', ':endDate')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.endDate IS NULL')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('endDate', $endDate)
|
||||||
|
->getQuery()
|
||||||
|
->execute()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,26 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
|
||||||
|
{
|
||||||
|
$fromDate = DateTimeImmutable::createFromInterface($from);
|
||||||
|
$toDate = DateTimeImmutable::createFromInterface($to);
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('w')
|
||||||
|
->select('COUNT(w.id)')
|
||||||
|
->andWhere('w.employee = :employee')
|
||||||
|
->andWhere('w.workDate >= :from')
|
||||||
|
->andWhere('w.workDate <= :to')
|
||||||
|
->andWhere('w.isSiteValid = :isSiteValid')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('from', $fromDate)
|
||||||
|
->setParameter('to', $toDate)
|
||||||
|
->setParameter('isSiteValid', true)
|
||||||
|
;
|
||||||
|
|
||||||
|
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
|
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
|
||||||
{
|
{
|
||||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||||
@@ -114,7 +134,7 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
->setMaxResults(1)
|
->setMaxResults(1)
|
||||||
;
|
;
|
||||||
|
|
||||||
/** @var null|WorkHour $workHour */
|
// @var null|WorkHour $workHour
|
||||||
return $qb->getQuery()->getOneOrNullResult();
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
src/Service/Contracts/EmployeeContractResolver.php
Normal file
71
src/Service/Contracts/EmployeeContractResolver.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Contracts;
|
||||||
|
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
readonly class EmployeeContractResolver
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract
|
||||||
|
{
|
||||||
|
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
|
||||||
|
|
||||||
|
return $period?->getContract();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
* @param list<string> $days
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, ?Contract>>
|
||||||
|
*/
|
||||||
|
public function resolveForEmployeesAndDays(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] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
$contract = $period->getContract();
|
||||||
|
if (!$employeeId || null === $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $period->getStartDate()->format('Y-m-d');
|
||||||
|
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
|
||||||
|
foreach ($days as $day) {
|
||||||
|
if ($day < $start || $day > $end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$resolved[$employeeId][$day] = $contract;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,16 @@ namespace App\Service\WorkHours;
|
|||||||
|
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use DateMalformedStringException;
|
use DateMalformedStringException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
final class WorkedHoursCreditPolicy
|
final readonly class WorkedHoursCreditPolicy
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws DateMalformedStringException
|
* @throws DateMalformedStringException
|
||||||
*/
|
*/
|
||||||
@@ -23,14 +28,19 @@ final class WorkedHoursCreditPolicy
|
|||||||
}
|
}
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
$employee = $absence->getEmployee();
|
||||||
|
if (null === $employee) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$workDate = new DateTimeImmutable($dateYmd);
|
||||||
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||||
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
||||||
if (TrackingMode::TIME->value !== $employee?->getContract()?->getTrackingMode()) {
|
if (TrackingMode::TIME->value !== $contract?->getTrackingMode()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weekday = (int) new DateTimeImmutable($dateYmd)->format('N');
|
$weekday = (int) $workDate->format('N');
|
||||||
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
|
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
|
||||||
$dayMinutes = $this->resolveContractDayMinutes($employee->getContract()?->getWeeklyHours(), $weekday);
|
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday);
|
||||||
if ($dayMinutes <= 0) {
|
if ($dayMinutes <= 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -41,15 +51,26 @@ final class WorkedHoursCreditPolicy
|
|||||||
return (int) round(($dayMinutes / 2) * $halfUnits);
|
return (int) round(($dayMinutes / 2) * $halfUnits);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function computeCreditedPresenceUnits(Absence $absence, bool $absentMorning, bool $absentAfternoon): float
|
/**
|
||||||
{
|
* @throws DateMalformedStringException
|
||||||
|
*/
|
||||||
|
public function computeCreditedPresenceUnits(
|
||||||
|
Absence $absence,
|
||||||
|
string $dateYmd,
|
||||||
|
bool $absentMorning,
|
||||||
|
bool $absentAfternoon
|
||||||
|
): float {
|
||||||
$type = $absence->getType();
|
$type = $absence->getType();
|
||||||
if (!$type?->getCountAsWorkedHours()) {
|
if (!$type?->getCountAsWorkedHours()) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
$employee = $absence->getEmployee();
|
||||||
if (TrackingMode::PRESENCE->value !== $employee?->getContract()?->getTrackingMode()) {
|
if (null === $employee) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, new DateTimeImmutable($dateYmd));
|
||||||
|
if (TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Enum\HalfDay;
|
use App\Enum\HalfDay;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
@@ -16,7 +17,9 @@ use DateInterval;
|
|||||||
use DatePeriod;
|
use DatePeriod;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use DateTimeInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||||
|
private Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -39,8 +43,11 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
$isAdmin = $user instanceof User && in_array('ROLE_ADMIN', $user->getRoles(), true);
|
||||||
|
|
||||||
if ($operation instanceof DeleteOperationInterface) {
|
if ($operation instanceof DeleteOperationInterface) {
|
||||||
if ($this->workHourRepository->hasValidatedInRange($employee, $data->getStartDate(), $data->getEndDate())) {
|
if ($this->isLockedByValidation($employee, $data->getStartDate(), $data->getEndDate(), $isAdmin)) {
|
||||||
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +65,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
$from = DateTimeImmutable::createFromInterface($segments[0]['date']);
|
$from = DateTimeImmutable::createFromInterface($segments[0]['date']);
|
||||||
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
|
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
|
||||||
|
|
||||||
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
|
if ($this->isLockedByValidation($employee, $from, $to, $isAdmin)) {
|
||||||
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +185,19 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
return DateTime::createFromImmutable($date);
|
return DateTime::createFromImmutable($date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isLockedByValidation(Employee $employee, DateTimeInterface $from, DateTimeInterface $to, bool $isAdmin): bool
|
||||||
|
{
|
||||||
|
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isAdmin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->workHourRepository->hasSiteValidatedInRange($employee, $from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
|
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
|
||||||
*/
|
*/
|
||||||
@@ -193,6 +213,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
$workHour
|
$workHour
|
||||||
->setMorningFrom(null)
|
->setMorningFrom(null)
|
||||||
->setMorningTo(null)
|
->setMorningTo(null)
|
||||||
|
->setIsSiteValid(false)
|
||||||
|
->setIsValid(false)
|
||||||
;
|
;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -205,6 +227,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
->setAfternoonTo(null)
|
->setAfternoonTo(null)
|
||||||
->setEveningFrom(null)
|
->setEveningFrom(null)
|
||||||
->setEveningTo(null)
|
->setEveningTo(null)
|
||||||
|
->setIsSiteValid(false)
|
||||||
|
->setIsValid(false)
|
||||||
;
|
;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -218,6 +242,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
->setAfternoonTo(null)
|
->setAfternoonTo(null)
|
||||||
->setEveningFrom(null)
|
->setEveningFrom(null)
|
||||||
->setEveningTo(null)
|
->setEveningTo(null)
|
||||||
|
->setIsSiteValid(false)
|
||||||
|
->setIsValid(false)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/State/EmployeeWriteProcessor.php
Normal file
121
src/State/EmployeeWriteProcessor.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\Contract;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private ProcessorInterface $persistProcessor,
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||||
|
private ProcessorInterface $removeProcessor,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private EmployeeContractPeriodRepository $periodRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(
|
||||||
|
mixed $data,
|
||||||
|
Operation $operation,
|
||||||
|
array $uriVariables = [],
|
||||||
|
array $context = []
|
||||||
|
): mixed {
|
||||||
|
if ($operation instanceof DeleteOperationInterface) {
|
||||||
|
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$data instanceof Employee) {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isNew = null === $data->getId();
|
||||||
|
$previousContract = $this->resolvePreviousContract($data);
|
||||||
|
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|
||||||
|
$currentContract = $data->getContract();
|
||||||
|
if (!$currentContract instanceof Contract) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
if ($isNew) {
|
||||||
|
$this->ensureContractPeriodExists($data, $currentContract, new DateTimeImmutable('1970-01-01'));
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSameContract($previousContract, $currentContract)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
||||||
|
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate()->format('Y-m-d') === $today->format('Y-m-d')) {
|
||||||
|
$todayPeriod->setContract($currentContract);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day'));
|
||||||
|
$this->createPeriod($data, $currentContract, $today);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePreviousContract(Employee $employee): ?Contract
|
||||||
|
{
|
||||||
|
if (null === $employee->getId()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($employee);
|
||||||
|
$original = $originalData['contract'] ?? null;
|
||||||
|
|
||||||
|
return $original instanceof Contract ? $original : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSameContract(?Contract $first, ?Contract $second): bool
|
||||||
|
{
|
||||||
|
if (null === $first || null === $second) {
|
||||||
|
return $first === $second;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $first->getId() === $second->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
||||||
|
{
|
||||||
|
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
|
||||||
|
if (null !== $covered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createPeriod($employee, $contract, $startDate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void
|
||||||
|
{
|
||||||
|
$period = new EmployeeContractPeriod()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setContract($contract)
|
||||||
|
->setStartDate($startDate)
|
||||||
|
->setEndDate(null)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($period);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ use App\ApiResource\WorkHourBulkUpsertResult;
|
|||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -27,6 +29,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
private Security $security,
|
private Security $security,
|
||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(
|
public function process(
|
||||||
@@ -65,6 +69,13 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
$existingByEmployeeId = $this->workHourRepository
|
$existingByEmployeeId = $this->workHourRepository
|
||||||
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
|
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
|
||||||
;
|
;
|
||||||
|
$absenceByEmployeeId = [];
|
||||||
|
foreach ($this->absenceRepository->findByDateAndEmployees($workDate, array_values($employeesById)) as $absence) {
|
||||||
|
$absenceEmployeeId = $absence->getEmployee()?->getId();
|
||||||
|
if ($absenceEmployeeId) {
|
||||||
|
$absenceByEmployeeId[$absenceEmployeeId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = new WorkHourBulkUpsertResult();
|
$result = new WorkHourBulkUpsertResult();
|
||||||
|
|
||||||
@@ -75,9 +86,18 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||||
|
if (null === $contract) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf(
|
||||||
|
'Employee %d has no active contract on %s.',
|
||||||
|
$employeeId,
|
||||||
|
$data->workDate
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
|
||||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||||
|
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
|
||||||
|
|
||||||
if ($existing?->isValid()) {
|
if ($existing?->isValid()) {
|
||||||
if (!$this->isSameAsExisting($existing, $normalized)) {
|
if (!$this->isSameAsExisting($existing, $normalized)) {
|
||||||
@@ -92,11 +112,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$isAdmin && $existing?->isSiteValid()) {
|
||||||
|
if (!$this->isSameAsExisting($existing, $normalized)) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf(
|
||||||
|
'Employee %d: site validated work hour cannot be modified.',
|
||||||
|
$employeeId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
++$result->processed;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->isEntryEmpty($normalized)) {
|
if ($this->isEntryEmpty($normalized)) {
|
||||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
$this->entityManager->remove($existing);
|
$this->entityManager->remove($existing);
|
||||||
++$result->deleted;
|
++$result->deleted;
|
||||||
|
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true) {
|
||||||
|
// Si une absence existe ce jour, on garde une ligne technique pour pouvoir valider la journée.
|
||||||
|
$workHour = new WorkHour()
|
||||||
|
->setEmployee($employee)
|
||||||
|
->setWorkDate($workDate)
|
||||||
|
;
|
||||||
|
$this->hydrateWorkHour($workHour, $normalized);
|
||||||
|
$this->entityManager->persist($workHour);
|
||||||
|
$existingByEmployeeId[$employeeId] = $workHour;
|
||||||
|
++$result->created;
|
||||||
}
|
}
|
||||||
|
|
||||||
++$result->processed;
|
++$result->processed;
|
||||||
@@ -184,14 +227,16 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
||||||
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
||||||
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
||||||
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
||||||
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
||||||
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
||||||
'isPresentMorning' => false,
|
// On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
|
||||||
'isPresentAfternoon' => false,
|
// même si le contrat résolu ce jour est en suivi horaire.
|
||||||
|
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
||||||
|
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +326,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
->setEveningTo($entry['eveningTo'])
|
->setEveningTo($entry['eveningTo'])
|
||||||
->setIsPresentMorning($entry['isPresentMorning'])
|
->setIsPresentMorning($entry['isPresentMorning'])
|
||||||
->setIsPresentAfternoon($entry['isPresentAfternoon'])
|
->setIsPresentAfternoon($entry['isPresentAfternoon'])
|
||||||
|
// Toute modification invalide la validation chef de site.
|
||||||
|
->setIsSiteValid(false)
|
||||||
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
||||||
->setIsValid(false)
|
->setIsValid(false)
|
||||||
;
|
;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Dto\WorkHours\DayContextRow;
|
|||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -26,6 +27,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
|||||||
private RequestStack $requestStack,
|
private RequestStack $requestStack,
|
||||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
private EmployeeScopedRepositoryInterface $employeeRepository,
|
||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
) {}
|
) {}
|
||||||
@@ -50,7 +52,10 @@ 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à.
|
||||||
$rowsByEmployeeId[$employeeId] = new DayContextRow(employeeId: $employeeId);
|
$rowsByEmployeeId[$employeeId] = new DayContextRow(
|
||||||
|
employeeId: $employeeId,
|
||||||
|
hasContractAtDate: null !== $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$dateKey = $workDate->format('Y-m-d');
|
$dateKey = $workDate->format('Y-m-d');
|
||||||
@@ -69,7 +74,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
|||||||
|
|
||||||
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
|
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
|
||||||
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
|
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
|
||||||
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon);
|
||||||
$rowsByEmployeeId[$employeeId]->addAbsence(
|
$rowsByEmployeeId[$employeeId]->addAbsence(
|
||||||
label: $absence->getType()?->getLabel(),
|
label: $absence->getType()?->getLabel(),
|
||||||
morning: $absentMorning,
|
morning: $absentMorning,
|
||||||
|
|||||||
54
src/State/WorkHourSiteValidationProcessor.php
Normal file
54
src/State/WorkHourSiteValidationProcessor.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Security\EmployeeScopeService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
|
||||||
|
final readonly class WorkHourSiteValidationProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private EmployeeScopeService $employeeScopeService,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WorkHour
|
||||||
|
{
|
||||||
|
if (!$data instanceof WorkHour) {
|
||||||
|
throw new AccessDeniedHttpException('Invalid payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réservé aux profils "Sites" (ni admin, ni self).
|
||||||
|
if (in_array('ROLE_ADMIN', $user->getRoles(), true) || in_array('ROLE_SELF', $user->getRoles(), true)) {
|
||||||
|
throw new AccessDeniedHttpException('Only site managers can update site validation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteId = $data->getEmployee()?->getSite()?->getId();
|
||||||
|
if (!$siteId) {
|
||||||
|
throw new AccessDeniedHttpException('Employee site is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedSiteIds = $this->employeeScopeService->getAllowedSiteIds($user);
|
||||||
|
if (!in_array($siteId, $allowedSiteIds, true)) {
|
||||||
|
throw new AccessDeniedHttpException('Employee is outside your site scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Dto\WorkHours\WeeklyDaySummary;
|
|||||||
use App\Dto\WorkHours\WeeklySummaryRow;
|
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||||
use App\Dto\WorkHours\WorkMetrics;
|
use App\Dto\WorkHours\WorkMetrics;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
@@ -19,6 +20,7 @@ use App\Enum\TrackingMode;
|
|||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -37,6 +39,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||||
@@ -58,7 +61,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$summary->weekStart = $weekStart->format('Y-m-d');
|
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||||
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||||
$summary->days = $days;
|
$summary->days = $days;
|
||||||
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days);
|
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -108,9 +111,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
*
|
*
|
||||||
* @return list<WeeklySummaryRow>
|
* @return list<WeeklySummaryRow>
|
||||||
*/
|
*/
|
||||||
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
|
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
|
||||||
{
|
{
|
||||||
$metricsByEmployeeDate = [];
|
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
|
$metricsByEmployeeDate = [];
|
||||||
foreach ($workHours as $workHour) {
|
foreach ($workHours as $workHour) {
|
||||||
$employeeId = $workHour->getEmployee()?->getId();
|
$employeeId = $workHour->getEmployee()?->getId();
|
||||||
if (!$employeeId) {
|
if (!$employeeId) {
|
||||||
@@ -158,7 +162,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
||||||
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $absentMorning, $absentAfternoon);
|
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +179,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$weeklyPresenceCount = 0.0;
|
$weeklyPresenceCount = 0.0;
|
||||||
$daily = [];
|
$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.
|
||||||
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
|
||||||
|
?? $contractsByEmployeeDate[$employeeId][$days[0]]
|
||||||
|
?? null;
|
||||||
|
$employeeContractsByDate = [];
|
||||||
|
foreach ($days as $date) {
|
||||||
|
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
$metrics = $entry['metrics'] ?? new WorkMetrics();
|
||||||
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
$creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
|
||||||
|
$contractAtDate = $employeeContractsByDate[$date] ?? null;
|
||||||
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
|
||||||
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
// Les absences "comptées comme travaillées" alimentent le total du jour.
|
||||||
$metrics->addCreditedMinutes($creditedMinutes);
|
$metrics->addCreditedMinutes($creditedMinutes);
|
||||||
$present = null;
|
$present = null;
|
||||||
@@ -210,18 +222,20 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
|
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
|
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract);
|
||||||
$weeklyOvertimeTotalMinutes = $isPresenceTracking
|
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
|
||||||
|
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
|
||||||
|
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
|
||||||
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
$weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
|
||||||
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
$weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
||||||
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
$weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
||||||
|
|
||||||
@@ -230,9 +244,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
firstName: $employee->getFirstName(),
|
firstName: $employee->getFirstName(),
|
||||||
lastName: $employee->getLastName(),
|
lastName: $employee->getLastName(),
|
||||||
siteName: $employee->getSite()?->getName(),
|
siteName: $employee->getSite()?->getName(),
|
||||||
contractName: $employee->getContract()?->getName(),
|
contractName: $weekAnchorContract?->getName(),
|
||||||
contractType: $employee->getContract()?->getType()->value,
|
contractType: $weekAnchorContract?->getType()->value,
|
||||||
trackingMode: $employee->getContract()?->getTrackingMode(),
|
trackingMode: $weekAnchorContract?->getTrackingMode(),
|
||||||
daily: $daily,
|
daily: $daily,
|
||||||
weeklyDayMinutes: $weeklyDayMinutes,
|
weeklyDayMinutes: $weeklyDayMinutes,
|
||||||
weeklyNightMinutes: $weeklyNightMinutes,
|
weeklyNightMinutes: $weeklyNightMinutes,
|
||||||
@@ -344,25 +358,43 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
return max(0, $end - $start);
|
return max(0, $end - $start);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function computeOvertimeTotalMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
/**
|
||||||
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
*/
|
||||||
|
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
|
||||||
{
|
{
|
||||||
if (null === $contractWeeklyHours || $contractWeeklyHours <= 0) {
|
$total = 0;
|
||||||
return 0;
|
foreach ($days as $date) {
|
||||||
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
|
$hours = $contract?->getWeeklyHours();
|
||||||
|
$referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null;
|
||||||
|
$total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Règle métier: tout contrat < 35h est traité comme un 35h pour la base supp.
|
return $total;
|
||||||
$referenceHours = max(35, $contractWeeklyHours);
|
|
||||||
|
|
||||||
return max(0, $weeklyTotalMinutes - ($referenceHours * 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, ?int $contractWeeklyHours): int
|
/**
|
||||||
|
* @param array<string, ?Contract> $contractsByDate
|
||||||
|
*/
|
||||||
|
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
|
||||||
{
|
{
|
||||||
// Règle métier:
|
$total = 0;
|
||||||
// - contrats <= 35h: 25% entre 35h et 43h
|
foreach ($days as $date) {
|
||||||
// - contrats >= 39h: 25% entre 39h et 43h
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
$startHours = (null !== $contractWeeklyHours && $contractWeeklyHours >= 39) ? 39 : 35;
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - ($startHours * 60));
|
$hours = $contract?->getWeeklyHours();
|
||||||
|
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
|
||||||
|
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int
|
||||||
|
{
|
||||||
|
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes);
|
||||||
|
|
||||||
return (int) round($trancheMinutes * 0.25);
|
return (int) round($trancheMinutes * 0.25);
|
||||||
}
|
}
|
||||||
@@ -375,10 +407,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
return (int) round($trancheMinutes * 0.5);
|
return (int) round($trancheMinutes * 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hasDisabledOvertimeBonuses(Employee $employee): bool
|
private function hasDisabledOvertimeBonuses(?Contract $contract): bool
|
||||||
{
|
{
|
||||||
$contract = $employee->getContract();
|
$type = ContractType::resolve(
|
||||||
$type = ContractType::resolve(
|
|
||||||
$contract?->getName(),
|
$contract?->getName(),
|
||||||
$contract?->getTrackingMode(),
|
$contract?->getTrackingMode(),
|
||||||
$contract?->getWeeklyHours()
|
$contract?->getWeeklyHours()
|
||||||
@@ -386,4 +417,26 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
return ContractType::INTERIM === $type;
|
return ContractType::INTERIM === $type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
|
||||||
|
{
|
||||||
|
// Week-end hors base de référence.
|
||||||
|
if ($isoWeekDay >= 6) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $weeklyHours || $weeklyHours <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (39 === $weeklyHours) {
|
||||||
|
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (35 === $weeklyHours) {
|
||||||
|
return 7 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round(($weeklyHours * 60) / 5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Entity\Absence;
|
|||||||
use App\Entity\AbsenceType;
|
use App\Entity\AbsenceType;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -19,7 +20,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function testComputeCreditedMinutesFor35hHalfDay(): void
|
public function testComputeCreditedMinutesFor35hHalfDay(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
|
||||||
|
|
||||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
|
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
|
||||||
@@ -29,7 +30,7 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
|
|
||||||
public function testComputeCreditedMinutesFor4hContractFullDay(): void
|
public function testComputeCreditedMinutesFor4hContractFullDay(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
|
||||||
|
|
||||||
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
|
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
|
||||||
@@ -39,21 +40,21 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
|
|
||||||
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
|
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
|
||||||
|
|
||||||
$units = $policy->computeCreditedPresenceUnits($absence, true, false);
|
$units = $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false);
|
||||||
|
|
||||||
self::assertSame(0.5, $units);
|
self::assertSame(0.5, $units);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
|
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
|
||||||
{
|
{
|
||||||
$policy = new WorkedHoursCreditPolicy();
|
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
|
||||||
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
|
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
|
||||||
|
|
||||||
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
|
||||||
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, true, true));
|
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
|
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
|
||||||
@@ -79,6 +80,18 @@ final class WorkedHoursCreditPolicyTest extends TestCase
|
|||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
->setType($type)
|
->setType($type)
|
||||||
->setStartDate(new DateTime('2026-02-16'))
|
->setStartDate(new DateTime('2026-02-16'))
|
||||||
->setEndDate(new DateTime('2026-02-16'));
|
->setEndDate(new DateTime('2026-02-16'))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildResolverStub(): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Entity\Absence;
|
|||||||
use App\Entity\AbsenceType;
|
use App\Entity\AbsenceType;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Enum\HalfDay;
|
use App\Enum\HalfDay;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
@@ -17,6 +18,7 @@ use App\State\AbsenceWriteProcessor;
|
|||||||
use DateTime;
|
use DateTime;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
@@ -32,7 +34,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
|||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
|
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
$security = $this->createAdminSecurityStub();
|
||||||
|
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
|
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
|
||||||
|
|
||||||
@@ -59,7 +62,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
|||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
$security = $this->createAdminSecurityStub();
|
||||||
|
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
||||||
|
|
||||||
@@ -79,7 +83,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
|||||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
$security = $this->createAdminSecurityStub();
|
||||||
|
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
||||||
|
|
||||||
@@ -100,7 +105,8 @@ final class AbsenceWriteProcessorTest extends TestCase
|
|||||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||||
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository);
|
$security = $this->createAdminSecurityStub();
|
||||||
|
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
|
||||||
|
|
||||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
|
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
|
||||||
|
|
||||||
@@ -121,6 +127,17 @@ final class AbsenceWriteProcessorTest extends TestCase
|
|||||||
->setStartDate(new DateTime($startDate))
|
->setStartDate(new DateTime($startDate))
|
||||||
->setEndDate(new DateTime($endDate))
|
->setEndDate(new DateTime($endDate))
|
||||||
->setStartHalf($startHalf)
|
->setStartHalf($startHalf)
|
||||||
->setEndHalf($endHalf);
|
->setEndHalf($endHalf)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAdminSecurityStub(): Security
|
||||||
|
{
|
||||||
|
$security = $this->createStub(Security::class);
|
||||||
|
$security->method('getUser')->willReturn(
|
||||||
|
new User()->setUsername('admin')->setRoles(['ROLE_ADMIN'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return $security;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Entity\User;
|
|||||||
use App\Enum\HalfDay;
|
use App\Enum\HalfDay;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use App\State\WorkHourDayContextProvider;
|
use App\State\WorkHourDayContextProvider;
|
||||||
@@ -52,8 +53,9 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$this->requestStack,
|
$this->requestStack,
|
||||||
$this->employeeRepository,
|
$this->employeeRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
|
$this->buildResolverStub(),
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -70,8 +72,9 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$this->requestStack,
|
$this->requestStack,
|
||||||
$this->employeeRepository,
|
$this->employeeRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
|
$this->buildResolverStub(),
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(UnprocessableEntityHttpException::class);
|
$this->expectException(UnprocessableEntityHttpException::class);
|
||||||
@@ -94,8 +97,9 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$this->requestStack,
|
$this->requestStack,
|
||||||
$this->employeeRepository,
|
$this->employeeRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
|
$this->buildResolverStub(),
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub())
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
$result = $provider->provide(new Get());
|
||||||
@@ -151,4 +155,15 @@ final class WorkHourDayContextProviderTest extends TestCase
|
|||||||
$property->setAccessible(true);
|
$property->setAccessible(true);
|
||||||
$property->setValue($entity, $id);
|
$property->setValue($entity, $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildResolverStub(): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use App\Enum\HalfDay;
|
|||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||||
use App\State\WorkHourWeeklySummaryProvider;
|
use App\State\WorkHourWeeklySummaryProvider;
|
||||||
@@ -58,7 +59,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->workHourRepository,
|
$this->workHourRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub()),
|
||||||
|
$this->buildResolverStub()
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -117,7 +119,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->workHourRepository,
|
$this->workHourRepository,
|
||||||
$this->absenceRepository,
|
$this->absenceRepository,
|
||||||
new AbsenceSegmentsResolver(),
|
new AbsenceSegmentsResolver(),
|
||||||
new WorkedHoursCreditPolicy()
|
new WorkedHoursCreditPolicy($this->buildResolverStub()),
|
||||||
|
$this->buildWeeklyResolverStub($employees)
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
$result = $provider->provide(new Get());
|
||||||
@@ -167,4 +170,50 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$property->setAccessible(true);
|
$property->setAccessible(true);
|
||||||
$property->setValue($entity, $id);
|
$property->setValue($entity, $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildResolverStub(): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeesAndDays')
|
||||||
|
->willReturn([])
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*/
|
||||||
|
private function buildWeeklyResolverStub(array $employees): EmployeeContractResolver
|
||||||
|
{
|
||||||
|
$resolver = $this->createStub(EmployeeContractResolver::class);
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeeAndDate')
|
||||||
|
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
|
||||||
|
;
|
||||||
|
$resolver
|
||||||
|
->method('resolveForEmployeesAndDays')
|
||||||
|
->willReturnCallback(static function (array $scopedEmployees, array $days): array {
|
||||||
|
$map = [];
|
||||||
|
foreach ($scopedEmployees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($days as $day) {
|
||||||
|
$map[$employeeId][$day] = $employee->getContract();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
|
return $resolver;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user