fix : wip
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
|
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||||
<div class="min-w-[900px]">
|
<div class="min-w-[900px]">
|
||||||
<div class="grid" :style="gridStyle">
|
<div class="grid" :style="gridStyle" @mouseleave="clearHoveredCell">
|
||||||
<div
|
<div
|
||||||
class="sticky left-0 top-0 z-30 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
|
class="sticky left-0 top-0 z-30 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
|
||||||
>
|
>
|
||||||
@@ -10,16 +10,23 @@
|
|||||||
<div
|
<div
|
||||||
v-for="day in daysInMonth"
|
v-for="day in daysInMonth"
|
||||||
:key="day.date"
|
:key="day.date"
|
||||||
class="sticky top-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
|
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
|
||||||
|
:class="isHoveredColumn(day.date) ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
|
||||||
>
|
>
|
||||||
<div>{{ day.label }}</div>
|
<div>{{ day.label }}</div>
|
||||||
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
|
<div
|
||||||
|
class="text-[10px]"
|
||||||
|
:class="isHoveredColumn(day.date) ? 'text-white/90' : 'text-neutral-500'"
|
||||||
|
>
|
||||||
|
{{ day.weekday }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-for="employee in visibleEmployees" :key="employee.id">
|
<template v-for="employee in visibleEmployees" :key="employee.id">
|
||||||
<div
|
<div
|
||||||
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer"
|
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer transition-shadow"
|
||||||
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
|
:class="isHoveredRow(employee.id) ? 'bg-primary-500 text-white ring-2 ring-inset ring-primary-500/40' : ''"
|
||||||
|
:style="rowHeaderStyle(employee)"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="handleDragStart($event, employee)"
|
@dragstart="handleDragStart($event, employee)"
|
||||||
@dragover="handleDragOver"
|
@dragover="handleDragOver"
|
||||||
@@ -30,12 +37,14 @@
|
|||||||
<div
|
<div
|
||||||
v-for="day in daysInMonth"
|
v-for="day in daysInMonth"
|
||||||
:key="employee.id + '-' + day.date"
|
:key="employee.id + '-' + day.date"
|
||||||
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 hover:bg-neutral-500"
|
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 transition-colors"
|
||||||
|
:class="cellContainerClass(employee.id, day.date)"
|
||||||
|
@mouseenter="setHoveredCell(employee.id, day.date)"
|
||||||
>
|
>
|
||||||
<template v-if="getCellInfo(employee.id, day.date)">
|
<template v-if="getCellInfo(employee.id, day.date)">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-white"
|
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
|
||||||
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
||||||
:style="getCellStyle(employee.id, day.date)"
|
:style="getCellStyle(employee.id, day.date)"
|
||||||
:disabled="isHolidayDate(day.date)"
|
:disabled="isHolidayDate(day.date)"
|
||||||
@@ -63,7 +72,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white hover:border-white"
|
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white"
|
||||||
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
||||||
:style="getCellStyle(employee.id, day.date)"
|
:style="getCellStyle(employee.id, day.date)"
|
||||||
:disabled="isHolidayDate(day.date)"
|
:disabled="isHolidayDate(day.date)"
|
||||||
@@ -89,7 +98,7 @@ type DayInfo = {
|
|||||||
weekday: string
|
weekday: string
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
daysInMonth: DayInfo[]
|
daysInMonth: DayInfo[]
|
||||||
visibleEmployees: Employee[]
|
visibleEmployees: Employee[]
|
||||||
gridStyle: Record<string, string>
|
gridStyle: Record<string, string>
|
||||||
@@ -124,4 +133,56 @@ const handleDrop = (event: DragEvent, employee: Employee) => {
|
|||||||
if (!dragId || dragId === employee.id) return
|
if (!dragId || dragId === employee.id) return
|
||||||
emit('reorder', { dragId, dropId: employee.id })
|
emit('reorder', { dragId, dropId: employee.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Etat de la cellule actuellement survolee.
|
||||||
|
const hoveredEmployeeId = ref<number | null>(null)
|
||||||
|
const hoveredDate = ref<string | null>(null)
|
||||||
|
|
||||||
|
const setHoveredCell = (employeeId: number, date: string) => {
|
||||||
|
hoveredEmployeeId.value = employeeId
|
||||||
|
hoveredDate.value = date
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHoveredCell = () => {
|
||||||
|
hoveredEmployeeId.value = null
|
||||||
|
hoveredDate.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHoveredRow = (employeeId: number) => hoveredEmployeeId.value === employeeId
|
||||||
|
|
||||||
|
const isHoveredColumn = (date: string) => hoveredDate.value === date
|
||||||
|
|
||||||
|
// On garde la couleur du site tant que la ligne n'est pas survolee.
|
||||||
|
const rowHeaderStyle = (employee: Employee) => {
|
||||||
|
if (isHoveredRow(employee.id)) return undefined
|
||||||
|
return { backgroundColor: employee.site?.color ?? '#304998' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index de ligne par employe pour savoir si une case est "au-dessus" de la case survolee.
|
||||||
|
const employeeIndexById = computed(() => {
|
||||||
|
const indexMap = new Map<number, number>()
|
||||||
|
props.visibleEmployees.forEach((employee, index) => {
|
||||||
|
indexMap.set(employee.id, index)
|
||||||
|
})
|
||||||
|
return indexMap
|
||||||
|
})
|
||||||
|
|
||||||
|
const cellContainerClass = (employeeId: number, date: string) => {
|
||||||
|
if (!hoveredEmployeeId.value || !hoveredDate.value) return 'hover:bg-primary-500'
|
||||||
|
|
||||||
|
const hoveredRowIndex = employeeIndexById.value.get(hoveredEmployeeId.value)
|
||||||
|
const currentRowIndex = employeeIndexById.value.get(employeeId)
|
||||||
|
|
||||||
|
// Forme en L:
|
||||||
|
// - ligne: toutes les cases a gauche (et la case cible)
|
||||||
|
// - colonne: toutes les cases au-dessus (et la case cible)
|
||||||
|
const isOnLeftSegment = isHoveredRow(employeeId) && date <= hoveredDate.value
|
||||||
|
const isOnTopSegment = isHoveredColumn(date)
|
||||||
|
&& typeof hoveredRowIndex === 'number'
|
||||||
|
&& typeof currentRowIndex === 'number'
|
||||||
|
&& currentRowIndex <= hoveredRowIndex
|
||||||
|
|
||||||
|
if (isOnLeftSegment || isOnTopSegment) return 'bg-primary-500'
|
||||||
|
return 'hover:bg-primary-500'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
18
frontend/components/EmployeeNameFilterInput.vue
Normal file
18
frontend/components/EmployeeNameFilterInput.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-model="model"
|
||||||
|
type="text"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
placeholder?: string
|
||||||
|
}>(), {
|
||||||
|
placeholder: 'Chercher un employé (nom ou prénom)'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
25
frontend/components/SiteFilterSelector.vue
Normal file
25
frontend/components/SiteFilterSelector.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex w-fit max-w-full flex-wrap items-center gap-6 py-2">
|
||||||
|
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
||||||
|
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded" />
|
||||||
|
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
||||||
|
<input
|
||||||
|
:id="`site-${site.id}`"
|
||||||
|
v-model="selectedSiteIds"
|
||||||
|
:value="site.id"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Site } from '~/services/dto/site'
|
||||||
|
|
||||||
|
const selectedSiteIds = defineModel<number[]>({ required: true })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sites: Site[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
112
frontend/components/hours/HoursDayView.vue
Normal file
112
frontend/components/hours/HoursDayView.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
|
||||||
|
<div class="overflow-y-auto min-h-0">
|
||||||
|
<div
|
||||||
|
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
||||||
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
|
>
|
||||||
|
<span>Nom</span>
|
||||||
|
<span class="pl-4">Début matin</span>
|
||||||
|
<span class="pr-2">Fin matin</span>
|
||||||
|
<span class="pl-2">Début après-midi</span>
|
||||||
|
<span class="pr-2">Fin après-midi</span>
|
||||||
|
<span class="pl-2">Début soir</span>
|
||||||
|
<span class="pr-2">Fin soir</span>
|
||||||
|
<span class="pl-2">Présent</span>
|
||||||
|
<span class="pl-2">Jour</span>
|
||||||
|
<span>Nuit</span>
|
||||||
|
<span>Total</span>
|
||||||
|
<span v-if="isAdmin">Valider</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="employee in employees"
|
||||||
|
:key="employee.id"
|
||||||
|
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
|
||||||
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
|
>
|
||||||
|
<div class="text-neutral-900 min-w-0">
|
||||||
|
<p class="font-semibold truncate">
|
||||||
|
{{ employee.firstName }} {{ employee.lastName }}
|
||||||
|
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-neutral-500 truncate">{{ employee.site?.name ?? 'Sans site' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningFrom" :disabled="isRowLocked(employee.id)"/>
|
||||||
|
<input
|
||||||
|
v-else-if="isPresenceTracking(employee)"
|
||||||
|
v-model="rows[employee.id].isPresentMorning"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pr-2">
|
||||||
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningTo" :disabled="isRowLocked(employee.id)"/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonFrom" :disabled="isRowLocked(employee.id)"/>
|
||||||
|
<input
|
||||||
|
v-else-if="isPresenceTracking(employee)"
|
||||||
|
v-model="rows[employee.id].isPresentAfternoon"
|
||||||
|
type="checkbox"
|
||||||
|
class="cursor-pointer h-4 w-4"
|
||||||
|
:disabled="isRowLocked(employee.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pr-2">
|
||||||
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonTo" :disabled="isRowLocked(employee.id)"/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2">
|
||||||
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningFrom" :disabled="isRowLocked(employee.id)"/>
|
||||||
|
</div>
|
||||||
|
<div class="pr-2">
|
||||||
|
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningTo" :disabled="isRowLocked(employee.id)"/>
|
||||||
|
</div>
|
||||||
|
<div class="pl-2"></div>
|
||||||
|
<div class="pl-2 text-sm font-semibold text-neutral-700">
|
||||||
|
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold text-neutral-700">
|
||||||
|
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold text-neutral-700">
|
||||||
|
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAdmin">
|
||||||
|
<input
|
||||||
|
:checked="rows[employee.id]?.isValid ?? false"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4"
|
||||||
|
:class="rows[employee.id]?.workHourId ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||||
|
:disabled="!rows[employee.id]?.workHourId || isValidationPending(employee.id)"
|
||||||
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||||
|
import type { HourRow } from './types'
|
||||||
|
|
||||||
|
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
employees: Employee[]
|
||||||
|
isAdmin: boolean
|
||||||
|
dayGridCols: string
|
||||||
|
contractLabel: (employee: Employee) => string
|
||||||
|
isTimeTracking: (employee: Employee) => boolean
|
||||||
|
isPresenceTracking: (employee: Employee) => boolean
|
||||||
|
isRowLocked: (employeeId: number) => boolean
|
||||||
|
isValidationPending: (employeeId: number) => boolean
|
||||||
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
||||||
|
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
|
||||||
|
formatMinutes: (minutes: number) => string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
144
frontend/components/hours/HoursToolbar.vue
Normal file
144
frontend/components/hours/HoursToolbar.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div class="py-6 flex flex-col gap-3">
|
||||||
|
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center gap-4">
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
<div
|
||||||
|
v-if="viewMode === 'day'"
|
||||||
|
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
:class="shortcutButtonClass('yesterday')"
|
||||||
|
@click="emit('set-yesterday')"
|
||||||
|
>
|
||||||
|
Hier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 border-x border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
:class="shortcutButtonClass('today')"
|
||||||
|
@click="emit('set-today')"
|
||||||
|
>
|
||||||
|
Aujourd'hui
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
:class="shortcutButtonClass('tomorrow')"
|
||||||
|
@click="emit('set-tomorrow')"
|
||||||
|
>
|
||||||
|
Demain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
|
<input
|
||||||
|
ref="nativeDateInput"
|
||||||
|
v-model="selectedDate"
|
||||||
|
type="date"
|
||||||
|
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
aria-label="Période précédente"
|
||||||
|
@click="emit('shift-date', -1)"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
@click="openDatePicker"
|
||||||
|
>
|
||||||
|
{{ formattedSelectedDate }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
aria-label="Période suivante"
|
||||||
|
@click="emit('shift-date', 1)"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
:class="viewModeButtonClass('day')"
|
||||||
|
@click="viewMode = 'day'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-clock" />
|
||||||
|
Jour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
|
:class="viewModeButtonClass('week')"
|
||||||
|
@click="viewMode = 'week'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-week" />
|
||||||
|
Semaine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-80 max-w-full">
|
||||||
|
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Site } from '~/services/dto/site'
|
||||||
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
|
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||||
|
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
|
||||||
|
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
|
||||||
|
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isAdmin: boolean
|
||||||
|
sites: Site[]
|
||||||
|
formattedSelectedDate: string
|
||||||
|
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'set-yesterday'): void
|
||||||
|
(e: 'set-today'): void
|
||||||
|
(e: 'set-tomorrow'): void
|
||||||
|
(e: 'shift-date', value: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const nativeDateInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const viewModeButtonClass = (mode: 'day' | 'week') => {
|
||||||
|
if (viewMode.value === mode) {
|
||||||
|
return 'bg-primary-500 text-white'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDatePicker = () => {
|
||||||
|
const input = nativeDateInput.value
|
||||||
|
if (!input) return
|
||||||
|
if (typeof input.showPicker === 'function') {
|
||||||
|
input.showPicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.focus()
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
70
frontend/components/hours/HoursWeekView.vue
Normal file
70
frontend/components/hours/HoursWeekView.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col">
|
||||||
|
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
||||||
|
<div v-else class="overflow-y-auto min-h-0">
|
||||||
|
<div
|
||||||
|
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
|
||||||
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
|
>
|
||||||
|
<span>Nom</span>
|
||||||
|
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
|
||||||
|
<span>Jour/Nuit sem.</span>
|
||||||
|
<span>Total sem.</span>
|
||||||
|
<span>+25%</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="row in weeklySummary?.rows ?? []"
|
||||||
|
:key="row.employeeId"
|
||||||
|
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
|
||||||
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
|
>
|
||||||
|
<div class="text-neutral-900 min-w-0">
|
||||||
|
<p class="font-semibold truncate">
|
||||||
|
{{ row.firstName }} {{ row.lastName }}
|
||||||
|
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="daily in row.daily" :key="daily.date" class="text-left leading-4">
|
||||||
|
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||||
|
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-semibold leading-4">
|
||||||
|
<template v-if="row.trackingMode === 'PRESENCE'">-</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
|
||||||
|
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isWeekLoading: boolean
|
||||||
|
weekGridCols: string
|
||||||
|
weeklySummary: WeeklyWorkHourSummary | null
|
||||||
|
weekDayHeaders: Array<{ date: string; label: string }>
|
||||||
|
formatMinutes: (minutes: number) => string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
12
frontend/components/hours/types.ts
Normal file
12
frontend/components/hours/types.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export type HourRow = {
|
||||||
|
workHourId: number | null
|
||||||
|
morningFrom: string
|
||||||
|
morningTo: string
|
||||||
|
afternoonFrom: string
|
||||||
|
afternoonTo: string
|
||||||
|
eveningFrom: string
|
||||||
|
eveningTo: string
|
||||||
|
isPresentMorning: boolean
|
||||||
|
isPresentAfternoon: boolean
|
||||||
|
isValid: boolean
|
||||||
|
}
|
||||||
151
frontend/components/ui/TimeSelect.vue
Normal file
151
frontend/components/ui/TimeSelect.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="root" class="relative w-full">
|
||||||
|
<button
|
||||||
|
ref="trigger"
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@click="toggleOpen"
|
||||||
|
>
|
||||||
|
{{ displayValue }}
|
||||||
|
<Icon name="mdi:chevron-down" class="self-center"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
ref="menu"
|
||||||
|
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
|
||||||
|
:style="menuStyle"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
||||||
|
@click="selectValue('')"
|
||||||
|
>
|
||||||
|
{{ placeholder }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="slot in timeSlots"
|
||||||
|
:key="slot"
|
||||||
|
type="button"
|
||||||
|
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
|
||||||
|
@click="selectValue(slot)"
|
||||||
|
>
|
||||||
|
{{ slot }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
placeholder: '--',
|
||||||
|
disabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const trigger = ref<HTMLElement | null>(null)
|
||||||
|
const menu = ref<HTMLElement | null>(null)
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const menuStyle = ref<Record<string, string>>({
|
||||||
|
top: '0px',
|
||||||
|
left: '0px',
|
||||||
|
width: '0px',
|
||||||
|
maxHeight: '224px'
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeSlots = computed(() => {
|
||||||
|
const slots: string[] = []
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
for (let minute = 0; minute < 60; minute += 15) {
|
||||||
|
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayValue = computed(() => props.modelValue || props.placeholder)
|
||||||
|
|
||||||
|
const updateMenuPosition = () => {
|
||||||
|
const triggerEl = trigger.value
|
||||||
|
if (!triggerEl) return
|
||||||
|
|
||||||
|
const rect = triggerEl.getBoundingClientRect()
|
||||||
|
const menuHeight = 224
|
||||||
|
const belowTop = rect.bottom + 4
|
||||||
|
const aboveTop = Math.max(8, rect.top - menuHeight - 4)
|
||||||
|
const canOpenBelow = belowTop + menuHeight <= window.innerHeight - 8
|
||||||
|
const top = canOpenBelow ? belowTop : aboveTop
|
||||||
|
|
||||||
|
menuStyle.value = {
|
||||||
|
top: `${top}px`,
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
width: `${rect.width}px`,
|
||||||
|
maxHeight: `${menuHeight}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOpen = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
const next = !isOpen.value
|
||||||
|
isOpen.value = next
|
||||||
|
if (next) {
|
||||||
|
nextTick(updateMenuPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectValue = (value: string) => {
|
||||||
|
if (props.disabled) return
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (!target) return
|
||||||
|
if (root.value?.contains(target) || menu.value?.contains(target)) return
|
||||||
|
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWindowChange = () => {
|
||||||
|
if (!isOpen.value) return
|
||||||
|
updateMenuPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('resize', onWindowChange)
|
||||||
|
window.addEventListener('scroll', onWindowChange, true)
|
||||||
|
nextTick(updateMenuPosition)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('resize', onWindowChange)
|
||||||
|
window.removeEventListener('scroll', onWindowChange, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.disabled, (disabled) => {
|
||||||
|
if (disabled) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onDocumentClick)
|
||||||
|
window.removeEventListener('resize', onWindowChange)
|
||||||
|
window.removeEventListener('scroll', onWindowChange, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
442
frontend/composables/useHoursPage.ts
Normal file
442
frontend/composables/useHoursPage.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import type { Site } from '~/services/dto/site'
|
||||||
|
import type { WorkHour, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
|
import type { HourRow } from '~/components/hours/types'
|
||||||
|
import { listScopedEmployees } from '~/services/employees'
|
||||||
|
import {
|
||||||
|
bulkUpsertWorkHours,
|
||||||
|
getWeeklyWorkHourSummary,
|
||||||
|
listWorkHoursByDate,
|
||||||
|
updateWorkHourValidation
|
||||||
|
} from '~/services/work-hours'
|
||||||
|
import {
|
||||||
|
formatDateLongFr,
|
||||||
|
formatWeekDayHeaderFr,
|
||||||
|
formatWeekRangeFr,
|
||||||
|
getOffsetFromTodayYmd,
|
||||||
|
getTodayYmd,
|
||||||
|
parseYmd,
|
||||||
|
shiftYmd
|
||||||
|
} from '~/utils/date'
|
||||||
|
import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
|
||||||
|
|
||||||
|
export const useHoursPage = () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
const viewMode = ref<'day' | 'week'>('day')
|
||||||
|
|
||||||
|
const selectedDate = ref(getTodayYmd())
|
||||||
|
const employees = ref<Employee[]>([])
|
||||||
|
const employeeFilter = ref('')
|
||||||
|
const selectedSiteIds = ref<number[]>([])
|
||||||
|
const sitesInitialized = ref(false)
|
||||||
|
const rows = ref<Record<number, HourRow>>({})
|
||||||
|
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isWeekLoading = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const validatingRowIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const dayGridCols = computed(() => {
|
||||||
|
const metricCol = '0.5fr'
|
||||||
|
const cols = `1.2fr repeat(6, 1fr) ${metricCol} ${metricCol} ${metricCol} ${metricCol}`
|
||||||
|
return isAdmin.value ? `${cols} ${metricCol}` : cols
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekGridCols = '1.6fr repeat(7, 1fr) 1fr 0.8fr 0.8fr 0.8fr'
|
||||||
|
|
||||||
|
const sites = computed<Site[]>(() => {
|
||||||
|
const siteMap = new Map<number, Site>()
|
||||||
|
for (const employee of employees.value) {
|
||||||
|
if (employee.site) {
|
||||||
|
siteMap.set(employee.site.id, employee.site)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(siteMap.values()).sort((siteA, siteB) => {
|
||||||
|
const orderA = siteA.displayOrder ?? 0
|
||||||
|
const orderB = siteB.displayOrder ?? 0
|
||||||
|
if (orderA !== orderB) return orderA - orderB
|
||||||
|
return siteA.name.localeCompare(siteB.name, 'fr')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleEmployees = computed(() => {
|
||||||
|
if (selectedSiteIds.value.length === 0) return []
|
||||||
|
const filter = employeeFilter.value.trim().toLowerCase()
|
||||||
|
return employees.value.filter((employee) => {
|
||||||
|
const siteId = employee.site?.id
|
||||||
|
if (!siteId || !selectedSiteIds.value.includes(siteId)) return false
|
||||||
|
if (!filter) return true
|
||||||
|
const firstName = employee.firstName?.toLowerCase() ?? ''
|
||||||
|
const lastName = employee.lastName?.toLowerCase() ?? ''
|
||||||
|
return firstName.includes(filter) || lastName.includes(filter)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleEmployeeIdSet = computed(() => new Set(visibleEmployees.value.map((employee) => employee.id)))
|
||||||
|
|
||||||
|
const filteredWeeklySummary = computed<WeeklyWorkHourSummary | null>(() => {
|
||||||
|
if (!weeklySummary.value) return null
|
||||||
|
return {
|
||||||
|
...weeklySummary.value,
|
||||||
|
rows: weeklySummary.value.rows.filter((row) => visibleEmployeeIdSet.value.has(row.employeeId))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveButtonClass = computed(() => {
|
||||||
|
if (isSubmitting.value || employees.value.length === 0) {
|
||||||
|
return 'opacity-50 cursor-not-allowed'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
|
||||||
|
|
||||||
|
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
|
||||||
|
const targetDate = target === 'yesterday'
|
||||||
|
? getOffsetFromTodayYmd(-1)
|
||||||
|
: target === 'tomorrow'
|
||||||
|
? getOffsetFromTodayYmd(1)
|
||||||
|
: getTodayYmd()
|
||||||
|
|
||||||
|
if (selectedDate.value === targetDate) {
|
||||||
|
return 'bg-primary-500 text-white'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedSelectedDate = computed(() => {
|
||||||
|
const parsed = parseYmd(selectedDate.value)
|
||||||
|
if (!parsed) return selectedDate.value
|
||||||
|
|
||||||
|
if (viewMode.value === 'week') {
|
||||||
|
return formatWeekRangeFr(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDateLongFr(parsed)
|
||||||
|
})
|
||||||
|
|
||||||
|
const weekDayHeaders = computed(() => {
|
||||||
|
const days = weeklySummary.value?.days ?? []
|
||||||
|
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const shiftDate = (steps: number) => {
|
||||||
|
const offset = viewMode.value === 'week' ? (steps * 7) : steps
|
||||||
|
const next = shiftYmd(selectedDate.value, offset)
|
||||||
|
if (!next) return
|
||||||
|
selectedDate.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const setToday = () => {
|
||||||
|
selectedDate.value = getTodayYmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setYesterday = () => {
|
||||||
|
setToday()
|
||||||
|
shiftDate(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTomorrow = () => {
|
||||||
|
setToday()
|
||||||
|
shiftDate(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRow = (): HourRow => ({
|
||||||
|
workHourId: null,
|
||||||
|
morningFrom: '',
|
||||||
|
morningTo: '',
|
||||||
|
afternoonFrom: '',
|
||||||
|
afternoonTo: '',
|
||||||
|
eveningFrom: '',
|
||||||
|
eveningTo: '',
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false,
|
||||||
|
isValid: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === 'PRESENCE'
|
||||||
|
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
||||||
|
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
|
||||||
|
|
||||||
|
const contractLabel = (employee: Employee) => {
|
||||||
|
const contract = employee.contract
|
||||||
|
if (!contract) return '-'
|
||||||
|
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === 'TIME') {
|
||||||
|
return `${contract.weeklyHours}h`
|
||||||
|
}
|
||||||
|
return contract.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTime = (value: string): string | null => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed === '' ? null : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMinutes = (time: string | null | undefined): number | null => {
|
||||||
|
if (!time) return null
|
||||||
|
const [hours, minutes] = time.split(':').map(Number)
|
||||||
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
|
||||||
|
return (hours * 60) + minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveInterval = (from: string | null | undefined, to: string | null | undefined): [number, number] | null => {
|
||||||
|
const fromMinutes = toMinutes(from)
|
||||||
|
const toMinutesValue = toMinutes(to)
|
||||||
|
if (fromMinutes === null || toMinutesValue === null) return null
|
||||||
|
|
||||||
|
const end = toMinutesValue <= fromMinutes ? toMinutesValue + 1440 : toMinutesValue
|
||||||
|
return [fromMinutes, end]
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMinutes = (from: string | null | undefined, to: string | null | undefined): number => {
|
||||||
|
const interval = resolveInterval(from, to)
|
||||||
|
if (!interval) return 0
|
||||||
|
const [start, end] = interval
|
||||||
|
return Math.max(0, end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlap = (startA: number, endA: number, startB: number, endB: number): number => {
|
||||||
|
const start = Math.max(startA, startB)
|
||||||
|
const end = Math.min(endA, endB)
|
||||||
|
return Math.max(0, end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nightIntervalMinutes = (from: string | null | undefined, to: string | null | undefined): number => {
|
||||||
|
const interval = resolveInterval(from, to)
|
||||||
|
if (!interval) return 0
|
||||||
|
const [start, end] = interval
|
||||||
|
|
||||||
|
const nightWindows: Array<[number, number]> = [
|
||||||
|
[0, 360],
|
||||||
|
[1260, 1440]
|
||||||
|
]
|
||||||
|
|
||||||
|
let total = 0
|
||||||
|
for (let dayOffset = 0; dayOffset <= 1; dayOffset++) {
|
||||||
|
const shift = dayOffset * 1440
|
||||||
|
for (const [nightStart, nightEnd] of nightWindows) {
|
||||||
|
total += overlap(start, end, nightStart + shift, nightEnd + shift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowMetrics = (employeeId: number) => {
|
||||||
|
const row = rows.value[employeeId] ?? emptyRow()
|
||||||
|
const ranges = [
|
||||||
|
[row.morningFrom, row.morningTo],
|
||||||
|
[row.afternoonFrom, row.afternoonTo],
|
||||||
|
[row.eveningFrom, row.eveningTo]
|
||||||
|
] as const
|
||||||
|
|
||||||
|
let totalMinutes = 0
|
||||||
|
let nightMinutes = 0
|
||||||
|
|
||||||
|
for (const [from, to] of ranges) {
|
||||||
|
totalMinutes += intervalMinutes(from, to)
|
||||||
|
nightMinutes += nightIntervalMinutes(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
|
||||||
|
return { dayMinutes, nightMinutes, totalMinutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMinutes = (minutes: number) => {
|
||||||
|
const safeMinutes = Math.max(0, minutes)
|
||||||
|
const hours = Math.floor(safeMinutes / 60)
|
||||||
|
const rest = safeMinutes % 60
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateRows = (workHours: WorkHour[]) => {
|
||||||
|
const byEmployeeId = new Map<number, WorkHour>()
|
||||||
|
for (const workHour of workHours) {
|
||||||
|
byEmployeeId.set(workHour.employee.id, workHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRows: Record<number, HourRow> = {}
|
||||||
|
for (const employee of employees.value) {
|
||||||
|
const workHour = byEmployeeId.get(employee.id)
|
||||||
|
nextRows[employee.id] = {
|
||||||
|
workHourId: workHour?.id ?? null,
|
||||||
|
morningFrom: workHour?.morningFrom ?? '',
|
||||||
|
morningTo: workHour?.morningTo ?? '',
|
||||||
|
afternoonFrom: workHour?.afternoonFrom ?? '',
|
||||||
|
afternoonTo: workHour?.afternoonTo ?? '',
|
||||||
|
eveningFrom: workHour?.eveningFrom ?? '',
|
||||||
|
eveningTo: workHour?.eveningTo ?? '',
|
||||||
|
isPresentMorning: workHour?.isPresentMorning ?? false,
|
||||||
|
isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
|
||||||
|
isValid: workHour?.isValid ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.value = nextRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleValidation = async (employeeId: number, checked: boolean) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row?.workHourId || isValidationPending(employeeId)) return
|
||||||
|
|
||||||
|
validatingRowIds.value = [...validatingRowIds.value, employeeId]
|
||||||
|
try {
|
||||||
|
await updateWorkHourValidation(row.workHourId, checked)
|
||||||
|
row.isValid = checked
|
||||||
|
} finally {
|
||||||
|
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEmployees = async () => {
|
||||||
|
const scopedEmployees = await listScopedEmployees()
|
||||||
|
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWorkHours = async () => {
|
||||||
|
const workHours = await listWorkHoursByDate(selectedDate.value)
|
||||||
|
hydrateRows(workHours)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWeeklySummary = async () => {
|
||||||
|
isWeekLoading.value = true
|
||||||
|
try {
|
||||||
|
weeklySummary.value = await getWeeklyWorkHourSummary(selectedDate.value)
|
||||||
|
} finally {
|
||||||
|
isWeekLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshByDate = async () => {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await Promise.all([loadWorkHours(), loadWeeklySummary()])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weeklySummary.value = null
|
||||||
|
await loadWorkHours()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
await loadEmployees()
|
||||||
|
await refreshByDate()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPage)
|
||||||
|
|
||||||
|
watch(sites, (nextSites) => {
|
||||||
|
const currentSiteIds = nextSites.map((site) => site.id)
|
||||||
|
|
||||||
|
if (!sitesInitialized.value) {
|
||||||
|
if (currentSiteIds.length === 0) return
|
||||||
|
selectedSiteIds.value = currentSiteIds
|
||||||
|
sitesInitialized.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(isAdmin, (admin) => {
|
||||||
|
if (!admin) {
|
||||||
|
viewMode.value = 'day'
|
||||||
|
weeklySummary.value = null
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(selectedDate, async () => {
|
||||||
|
await refreshByDate()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (isSubmitting.value || employees.value.length === 0) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const entries = employees.value.map((employee) => {
|
||||||
|
const employeeId = employee.id
|
||||||
|
const row = rows.value[employeeId] ?? emptyRow()
|
||||||
|
if (isPresenceTracking(employee)) {
|
||||||
|
return {
|
||||||
|
employeeId,
|
||||||
|
morningFrom: null,
|
||||||
|
morningTo: null,
|
||||||
|
afternoonFrom: null,
|
||||||
|
afternoonTo: null,
|
||||||
|
eveningFrom: null,
|
||||||
|
eveningTo: null,
|
||||||
|
isPresentMorning: row.isPresentMorning,
|
||||||
|
isPresentAfternoon: row.isPresentAfternoon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId,
|
||||||
|
morningFrom: normalizeTime(row.morningFrom),
|
||||||
|
morningTo: normalizeTime(row.morningTo),
|
||||||
|
afternoonFrom: normalizeTime(row.afternoonFrom),
|
||||||
|
afternoonTo: normalizeTime(row.afternoonTo),
|
||||||
|
eveningFrom: normalizeTime(row.eveningFrom),
|
||||||
|
eveningTo: normalizeTime(row.eveningTo),
|
||||||
|
isPresentMorning: false,
|
||||||
|
isPresentAfternoon: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await bulkUpsertWorkHours({
|
||||||
|
workDate: selectedDate.value,
|
||||||
|
entries
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshByDate()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
viewMode,
|
||||||
|
selectedDate,
|
||||||
|
employeeFilter,
|
||||||
|
sites,
|
||||||
|
selectedSiteIds,
|
||||||
|
employees,
|
||||||
|
visibleEmployees,
|
||||||
|
rows,
|
||||||
|
weeklySummary,
|
||||||
|
filteredWeeklySummary,
|
||||||
|
isLoading,
|
||||||
|
isWeekLoading,
|
||||||
|
isSubmitting,
|
||||||
|
dayGridCols,
|
||||||
|
weekGridCols,
|
||||||
|
saveButtonClass,
|
||||||
|
formattedSelectedDate,
|
||||||
|
weekDayHeaders,
|
||||||
|
shortcutButtonClass,
|
||||||
|
setToday,
|
||||||
|
setYesterday,
|
||||||
|
setTomorrow,
|
||||||
|
shiftDate,
|
||||||
|
contractLabel,
|
||||||
|
isTimeTracking,
|
||||||
|
isPresenceTracking,
|
||||||
|
isRowLocked,
|
||||||
|
isValidationPending,
|
||||||
|
toggleValidation,
|
||||||
|
getRowMetrics,
|
||||||
|
formatMinutes,
|
||||||
|
handleSave
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-tertiary-500 from-tertiary-50 via-white to-neutral-100 text-neutral-900">
|
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
|
||||||
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,46 +6,53 @@
|
|||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 px-4 pb-6">
|
||||||
|
<NuxtLink
|
||||||
|
to="/hours"
|
||||||
|
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||||
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
|
>
|
||||||
|
Heures
|
||||||
|
</NuxtLink>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/"
|
to="/"
|
||||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600 border-t border-secondary-500"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-primary-50 text-primary-600"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Tableau de bord
|
Tableau de bord
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/calendar"
|
to="/calendar"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-primary-50 text-primary-600"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Calendrier
|
Calendrier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
to="/employees"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-primary-50 text-primary-600"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Employés
|
Employés
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-primary-50 text-primary-600"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Sites
|
Sites
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/absence-types"
|
to="/absence-types"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-primary-50 text-primary-600"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Types d'absence
|
Types d'absence
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/users"
|
to="/users"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
active-class="bg-primary-50 text-primary-600"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Utilisateurs
|
Utilisateurs
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export default defineNuxtConfig({
|
|||||||
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
|
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
|
||||||
},
|
},
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxtjs/tailwindcss',
|
'@nuxtjs/tailwindcss',
|
||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
'nuxt-toast',
|
'nuxt-toast',
|
||||||
'@nuxtjs/i18n'
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/icon'
|
||||||
],
|
],
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
@@ -36,4 +37,4 @@ export default defineNuxtConfig({
|
|||||||
typescript: {
|
typescript: {
|
||||||
strict: true
|
strict: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"nuxt": "^4.3.0",
|
"nuxt": "^4.3.0",
|
||||||
@@ -32,6 +33,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@antfu/install-pkg": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"package-manager-detector": "^1.3.0",
|
||||||
|
"tinyexec": "^1.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1220,6 +1234,47 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@iconify/collections": {
|
||||||
|
"version": "1.0.651",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.651.tgz",
|
||||||
|
"integrity": "sha512-ALGlYxNVOIylxNHjFaylqPTzgNaMHeoFA8ao/piPHjYGD526xEp847F7KePy9jvOLChy2bzQVwAV9Em3HiicjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify/types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/types": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/utils": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@antfu/install-pkg": "^1.1.0",
|
||||||
|
"@iconify/types": "^2.0.0",
|
||||||
|
"mlly": "^1.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iconify/vue": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify/types": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/cyberalien"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@intlify/bundle-utils": {
|
"node_modules/@intlify/bundle-utils": {
|
||||||
"version": "11.0.3",
|
"version": "11.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
||||||
@@ -2372,6 +2427,28 @@
|
|||||||
"devtools-wizard": "cli.mjs"
|
"devtools-wizard": "cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nuxt/icon": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify/collections": "^1.0.641",
|
||||||
|
"@iconify/types": "^2.0.0",
|
||||||
|
"@iconify/utils": "^3.1.0",
|
||||||
|
"@iconify/vue": "^5.0.0",
|
||||||
|
"@nuxt/devtools-kit": "^3.1.1",
|
||||||
|
"@nuxt/kit": "^4.2.2",
|
||||||
|
"consola": "^3.4.2",
|
||||||
|
"local-pkg": "^1.1.2",
|
||||||
|
"mlly": "^1.8.0",
|
||||||
|
"ohash": "^2.0.11",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^3.10.0",
|
||||||
|
"tinyglobby": "^0.2.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nuxt/kit": {
|
"node_modules/@nuxt/kit": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.1",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
"nuxt": "^4.3.0",
|
"nuxt": "^4.3.0",
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ const showLabelError = computed(() => validationTouched.label && !isLabelValid.v
|
|||||||
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
|
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
|
||||||
|
|
||||||
const baseInputClass =
|
const baseInputClass =
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
|
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||||
const codeFieldClass = computed(() => {
|
const codeFieldClass = computed(() => {
|
||||||
if (showCodeError.value) {
|
if (showCodeError.value) {
|
||||||
return `${baseInputClass} border-red-500`
|
return `${baseInputClass} border-red-500`
|
||||||
|
|||||||
@@ -3,38 +3,10 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-6">
|
<div class="flex flex-col gap-3 py-6">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
|
||||||
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
|
|
||||||
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
|
|
||||||
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
|
|
||||||
<input
|
|
||||||
:id="`site-${site.id}`"
|
|
||||||
v-model="selectedSiteIds"
|
|
||||||
:value="site.id"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
v-model="selectedMonth"
|
|
||||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
|
||||||
>
|
|
||||||
<option v-for="month in months" :key="month.value" :value="month.value">
|
|
||||||
{{ month.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
v-model="selectedYear"
|
|
||||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
|
||||||
>
|
|
||||||
<option v-for="year in years" :key="year" :value="year">
|
|
||||||
{{ year }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -53,24 +25,34 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between mt-3">
|
<div class="flex justify-between">
|
||||||
<div class="flex items-center gap-4 w-80">
|
<div class="flex items-center gap-4">
|
||||||
<input
|
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||||
v-model="employeeFilter"
|
<select
|
||||||
type="text"
|
v-model="selectedMonth"
|
||||||
placeholder="Chercher un employé (nom ou prénom)"
|
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||||
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
>
|
||||||
/>
|
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||||
</div>
|
{{ month.label }}
|
||||||
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
</option>
|
||||||
<p class="font-bold">Légende :</p>
|
</select>
|
||||||
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
<select
|
||||||
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
v-model="selectedYear"
|
||||||
<p>{{ type.label }}</p>
|
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||||
</div>
|
>
|
||||||
|
<option v-for="year in years" :key="year" :value="year">
|
||||||
|
{{ year }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-6 py-2">
|
||||||
|
<p class="font-bold">Légende :</p>
|
||||||
|
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||||
|
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
||||||
|
<p>{{ type.label }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
@@ -119,10 +101,13 @@ import {listEmployees, updateEmployeeOrder} 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 {listPublicHolidays} from '~/services/public-holidays'
|
||||||
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
|
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
|
||||||
|
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
|
||||||
import CalendarGrid from '~/components/CalendarGrid.vue'
|
import CalendarGrid from '~/components/CalendarGrid.vue'
|
||||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||||
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
|
|
||||||
// Données principales affichées dans la grille.
|
// Données principales affichées dans la grille.
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
@@ -149,27 +134,11 @@ watch(sites, (next) => {
|
|||||||
if (sitesInitialized.value || next.length === 0) return
|
if (sitesInitialized.value || next.length === 0) return
|
||||||
selectedSiteIds.value = next.map((site) => site.id)
|
selectedSiteIds.value = next.map((site) => site.id)
|
||||||
sitesInitialized.value = true
|
sitesInitialized.value = true
|
||||||
}, { immediate: true })
|
}, {immediate: true})
|
||||||
|
|
||||||
// Tri stable: site -> nom -> prénom.
|
// Tri stable: site -> nom -> prénom.
|
||||||
const sortedEmployees = computed(() => {
|
const sortedEmployees = computed(() => {
|
||||||
return [...employees.value].sort((employeeA, employeeB) => {
|
return sortEmployeesBySiteAndOrder(employees.value)
|
||||||
const siteOrderA = employeeA.site?.displayOrder ?? 0
|
|
||||||
const siteOrderB = employeeB.site?.displayOrder ?? 0
|
|
||||||
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
|
|
||||||
const siteNameA = employeeA.site?.name ?? ''
|
|
||||||
const siteNameB = employeeB.site?.name ?? ''
|
|
||||||
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
|
|
||||||
const orderA = employeeA.displayOrder ?? 0
|
|
||||||
const orderB = employeeB.displayOrder ?? 0
|
|
||||||
if (orderA !== orderB) return orderA - orderB
|
|
||||||
const lastNameA = employeeA.lastName ?? ''
|
|
||||||
const lastNameB = employeeB.lastName ?? ''
|
|
||||||
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
|
|
||||||
const firstNameA = employeeA.firstName ?? ''
|
|
||||||
const firstNameB = employeeB.firstName ?? ''
|
|
||||||
return firstNameA.localeCompare(firstNameB, 'fr')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Employés visibles selon le filtre de sites.
|
// Employés visibles selon le filtre de sites.
|
||||||
@@ -281,13 +250,6 @@ const closePrint = () => {
|
|||||||
isPrintOpen.value = false
|
isPrintOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse "YYYY-MM-DD" en Date (ou null).
|
|
||||||
const parseYmd = (value: string) => {
|
|
||||||
const [year, month, day] = value.split('-').map(Number)
|
|
||||||
if (!year || !month || !day) return null
|
|
||||||
return new Date(year, month - 1, day)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
|
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
|
||||||
const getHalfForDate = (
|
const getHalfForDate = (
|
||||||
startDate: string,
|
startDate: string,
|
||||||
@@ -681,7 +643,7 @@ const formatEmployeeName = (employee: Employee) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Impression PDF de l'intervalle sélectionné.
|
// Impression PDF de l'intervalle sélectionné.
|
||||||
const { printPdf } = usePdfPrinter()
|
const {printPdf} = usePdfPrinter()
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('from', printForm.from)
|
params.set('from', printForm.from)
|
||||||
@@ -703,17 +665,7 @@ const handleReorder = async (payload: { dragId: number; dropId: number }) => {
|
|||||||
|
|
||||||
const siteEmployees = [...employees.value]
|
const siteEmployees = [...employees.value]
|
||||||
.filter((employee) => employee.site?.id === dragSiteId)
|
.filter((employee) => employee.site?.id === dragSiteId)
|
||||||
.sort((employeeA, employeeB) => {
|
.sort(compareEmployeesInSite)
|
||||||
const orderA = employeeA.displayOrder ?? 0
|
|
||||||
const orderB = employeeB.displayOrder ?? 0
|
|
||||||
if (orderA !== orderB) return orderA - orderB
|
|
||||||
const lastNameA = employeeA.lastName ?? ''
|
|
||||||
const lastNameB = employeeB.lastName ?? ''
|
|
||||||
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
|
|
||||||
const firstNameA = employeeA.firstName ?? ''
|
|
||||||
const firstNameB = employeeB.firstName ?? ''
|
|
||||||
return firstNameA.localeCompare(firstNameB, 'fr')
|
|
||||||
})
|
|
||||||
|
|
||||||
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.id)
|
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.id)
|
||||||
const toIndex = siteEmployees.findIndex((employee) => employee.id === dropEmployee.id)
|
const toIndex = siteEmployees.findIndex((employee) => employee.id === dropEmployee.id)
|
||||||
@@ -726,7 +678,7 @@ const handleReorder = async (payload: { dragId: number; dropId: number }) => {
|
|||||||
siteEmployees.forEach((employee, index) => {
|
siteEmployees.forEach((employee, index) => {
|
||||||
const nextOrder = index + 1
|
const nextOrder = index + 1
|
||||||
if ((employee.displayOrder ?? 0) !== nextOrder) {
|
if ((employee.displayOrder ?? 0) !== nextOrder) {
|
||||||
updates.push({ id: employee.id, displayOrder: nextOrder })
|
updates.push({id: employee.id, displayOrder: nextOrder})
|
||||||
}
|
}
|
||||||
employee.displayOrder = nextOrder
|
employee.displayOrder = nextOrder
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,10 +19,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||||
<div class="grid grid-cols-[120px_1fr_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
<div class="grid grid-cols-[120px_1fr_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
|
||||||
<span class="text-left">Prénom</span>
|
<span class="text-left">Prénom</span>
|
||||||
<span class="text-left">Nom</span>
|
<span class="text-left">Nom</span>
|
||||||
<span class="text-left">Site</span>
|
<span class="text-left">Site</span>
|
||||||
|
<span class="text-left">Contrat</span>
|
||||||
<span class="text-right">Actions</span>
|
<span class="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||||
@@ -32,11 +33,12 @@
|
|||||||
<div
|
<div
|
||||||
v-for="employee in employees"
|
v-for="employee in employees"
|
||||||
:key="employee.id"
|
:key="employee.id"
|
||||||
class="grid grid-cols-[120px_1fr_1fr_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
class="grid grid-cols-[120px_1fr_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
>
|
>
|
||||||
<span>{{ employee.firstName }}</span>
|
<span>{{ employee.firstName }}</span>
|
||||||
<span>{{ employee.lastName }}</span>
|
<span>{{ employee.lastName }}</span>
|
||||||
<span>{{ employee.site?.name ?? '-' }}</span>
|
<span>{{ employee.site?.name ?? '-' }}</span>
|
||||||
|
<span>{{ employee.contract?.name ?? '-' }}</span>
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -105,6 +107,24 @@
|
|||||||
Le site est obligatoire.
|
Le site est obligatoire.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="contract">
|
||||||
|
Contrat <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="contract"
|
||||||
|
v-model="form.contractId"
|
||||||
|
:class="contractFieldClass"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un contrat</option>
|
||||||
|
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
|
||||||
|
{{ contract.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
|
||||||
|
Le contrat est obligatoire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,8 +147,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Contract } from '~/services/dto/contract'
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -142,24 +164,28 @@ const drawerTitle = computed(() =>
|
|||||||
|
|
||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
|
const contracts = ref<Contract[]>([])
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
siteId: '' as number | ''
|
siteId: '' as number | '',
|
||||||
|
contractId: '' as number | ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
firstName: false,
|
firstName: false,
|
||||||
lastName: false,
|
lastName: false,
|
||||||
siteId: false
|
siteId: false,
|
||||||
|
contractId: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
||||||
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
||||||
const isSiteValid = computed(() => form.siteId !== '')
|
const isSiteValid = computed(() => form.siteId !== '')
|
||||||
|
const isContractValid = computed(() => form.contractId !== '')
|
||||||
const isFormValid = computed(
|
const isFormValid = computed(
|
||||||
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
|
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value && isContractValid.value
|
||||||
)
|
)
|
||||||
|
|
||||||
const showFirstNameError = computed(
|
const showFirstNameError = computed(
|
||||||
@@ -171,9 +197,12 @@ const showLastNameError = computed(
|
|||||||
const showSiteError = computed(
|
const showSiteError = computed(
|
||||||
() => validationTouched.siteId && !isSiteValid.value
|
() => validationTouched.siteId && !isSiteValid.value
|
||||||
)
|
)
|
||||||
|
const showContractError = computed(
|
||||||
|
() => validationTouched.contractId && !isContractValid.value
|
||||||
|
)
|
||||||
|
|
||||||
const baseInputClass =
|
const baseInputClass =
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
|
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||||
const firstNameFieldClass = computed(() => {
|
const firstNameFieldClass = computed(() => {
|
||||||
if (showFirstNameError.value) {
|
if (showFirstNameError.value) {
|
||||||
return `${baseInputClass} border-red-500`
|
return `${baseInputClass} border-red-500`
|
||||||
@@ -194,6 +223,14 @@ const siteFieldClass = computed(() => {
|
|||||||
}
|
}
|
||||||
return `${baseSelectClass} border-neutral-300`
|
return `${baseSelectClass} border-neutral-300`
|
||||||
})
|
})
|
||||||
|
const contractFieldClass = computed(() => {
|
||||||
|
const baseClass =
|
||||||
|
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
||||||
|
if (showContractError.value) {
|
||||||
|
return `${baseClass} border-red-500`
|
||||||
|
}
|
||||||
|
return `${baseClass} border-neutral-300`
|
||||||
|
})
|
||||||
|
|
||||||
const submitButtonClass = computed(() => {
|
const submitButtonClass = computed(() => {
|
||||||
if (isSubmitting.value || !isFormValid.value) {
|
if (isSubmitting.value || !isFormValid.value) {
|
||||||
@@ -215,8 +252,12 @@ const loadSites = async () => {
|
|||||||
sites.value = await listSites()
|
sites.value = await listSites()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadContracts = async () => {
|
||||||
|
contracts.value = await listContracts()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadEmployees(), loadSites()])
|
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -224,6 +265,7 @@ const handleSubmit = async () => {
|
|||||||
validationTouched.firstName = true
|
validationTouched.firstName = true
|
||||||
validationTouched.lastName = true
|
validationTouched.lastName = true
|
||||||
validationTouched.siteId = true
|
validationTouched.siteId = true
|
||||||
|
validationTouched.contractId = true
|
||||||
if (!isFormValid.value) return
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
@@ -232,19 +274,22 @@ const handleSubmit = async () => {
|
|||||||
await updateEmployee(editingEmployee.value.id, {
|
await updateEmployee(editingEmployee.value.id, {
|
||||||
firstName: form.firstName,
|
firstName: form.firstName,
|
||||||
lastName: form.lastName,
|
lastName: form.lastName,
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId)
|
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||||
|
contractId: Number(form.contractId)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await createEmployee({
|
await createEmployee({
|
||||||
firstName: form.firstName,
|
firstName: form.firstName,
|
||||||
lastName: form.lastName,
|
lastName: form.lastName,
|
||||||
siteId: form.siteId === '' ? null : Number(form.siteId)
|
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||||
|
contractId: Number(form.contractId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
form.firstName = ''
|
form.firstName = ''
|
||||||
form.lastName = ''
|
form.lastName = ''
|
||||||
form.siteId = ''
|
form.siteId = ''
|
||||||
|
form.contractId = ''
|
||||||
editingEmployee.value = null
|
editingEmployee.value = null
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
@@ -258,6 +303,7 @@ watch(isDrawerOpen, (isOpen) => {
|
|||||||
validationTouched.firstName = false
|
validationTouched.firstName = false
|
||||||
validationTouched.lastName = false
|
validationTouched.lastName = false
|
||||||
validationTouched.siteId = false
|
validationTouched.siteId = false
|
||||||
|
validationTouched.contractId = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,6 +312,7 @@ const openEdit = (employee: Employee) => {
|
|||||||
form.firstName = employee.firstName
|
form.firstName = employee.firstName
|
||||||
form.lastName = employee.lastName
|
form.lastName = employee.lastName
|
||||||
form.siteId = employee.site?.id ?? ''
|
form.siteId = employee.site?.id ?? ''
|
||||||
|
form.contractId = employee.contract?.id ?? ''
|
||||||
isDrawerOpen.value = true
|
isDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
106
frontend/pages/hours.vue
Normal file
106
frontend/pages/hours.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-hidden flex flex-col">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HoursToolbar
|
||||||
|
v-model:selected-date="selectedDate"
|
||||||
|
v-model:view-mode="viewMode"
|
||||||
|
v-model:selected-site-ids="selectedSiteIds"
|
||||||
|
v-model:employee-filter="employeeFilter"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:sites="sites"
|
||||||
|
:formatted-selected-date="formattedSelectedDate"
|
||||||
|
:shortcut-button-class="shortcutButtonClass"
|
||||||
|
@set-yesterday="setYesterday"
|
||||||
|
@set-today="setToday"
|
||||||
|
@set-tomorrow="setTomorrow"
|
||||||
|
@shift-date="shiftDate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="employees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
|
Aucun employé accessible.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-1 min-h-0 flex-col gap-4">
|
||||||
|
<HoursDayView
|
||||||
|
v-if="viewMode === 'day'"
|
||||||
|
v-model:rows="rows"
|
||||||
|
:employees="visibleEmployees"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:day-grid-cols="dayGridCols"
|
||||||
|
:contract-label="contractLabel"
|
||||||
|
:is-time-tracking="isTimeTracking"
|
||||||
|
:is-presence-tracking="isPresenceTracking"
|
||||||
|
:is-row-locked="isRowLocked"
|
||||||
|
:is-validation-pending="isValidationPending"
|
||||||
|
:on-toggle-validation="toggleValidation"
|
||||||
|
:get-row-metrics="getRowMetrics"
|
||||||
|
:format-minutes="formatMinutes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HoursWeekView
|
||||||
|
v-else-if="isAdmin && viewMode === 'week'"
|
||||||
|
:is-week-loading="isWeekLoading"
|
||||||
|
:week-grid-cols="weekGridCols"
|
||||||
|
:weekly-summary="filteredWeeklySummary"
|
||||||
|
:week-day-headers="weekDayHeaders"
|
||||||
|
:format-minutes="formatMinutes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||||
|
:class="saveButtonClass"
|
||||||
|
:disabled="isSubmitting || visibleEmployees.length === 0"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
isAdmin,
|
||||||
|
viewMode,
|
||||||
|
selectedDate,
|
||||||
|
employeeFilter,
|
||||||
|
sites,
|
||||||
|
selectedSiteIds,
|
||||||
|
employees,
|
||||||
|
visibleEmployees,
|
||||||
|
rows,
|
||||||
|
filteredWeeklySummary,
|
||||||
|
isLoading,
|
||||||
|
isWeekLoading,
|
||||||
|
isSubmitting,
|
||||||
|
dayGridCols,
|
||||||
|
weekGridCols,
|
||||||
|
saveButtonClass,
|
||||||
|
formattedSelectedDate,
|
||||||
|
weekDayHeaders,
|
||||||
|
shortcutButtonClass,
|
||||||
|
setToday,
|
||||||
|
setYesterday,
|
||||||
|
setTomorrow,
|
||||||
|
shiftDate,
|
||||||
|
contractLabel,
|
||||||
|
isTimeTracking,
|
||||||
|
isPresenceTracking,
|
||||||
|
isRowLocked,
|
||||||
|
isValidationPending,
|
||||||
|
toggleValidation,
|
||||||
|
getRowMetrics,
|
||||||
|
formatMinutes,
|
||||||
|
handleSave
|
||||||
|
} = useHoursPage()
|
||||||
|
</script>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
v-model="username"
|
v-model="username"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@
|
|||||||
v-model="password"
|
v-model="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
|
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
|
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
>
|
||||||
Se connecter
|
Se connecter
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ const isFormValid = computed(() => isNameValid.value)
|
|||||||
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
|
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
|
||||||
|
|
||||||
const baseInputClass =
|
const baseInputClass =
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
|
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||||
const nameFieldClass = computed(() => {
|
const nameFieldClass = computed(() => {
|
||||||
if (showNameError.value) {
|
if (showNameError.value) {
|
||||||
return `${baseInputClass} border-red-500`
|
return `${baseInputClass} border-red-500`
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
||||||
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
|
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
|
||||||
@click="selectAccessMode('admin')"
|
@click="selectAccessMode('admin')"
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
||||||
:class="form.accessMode === 'self' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
|
:class="form.accessMode === 'self' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
|
||||||
@click="selectAccessMode('self')"
|
@click="selectAccessMode('self')"
|
||||||
>
|
>
|
||||||
Accès personnel
|
Accès personnel
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
class="rounded-full border px-3 py-1 text-sm font-semibold"
|
||||||
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
|
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
|
||||||
@click="selectAccessMode('sites')"
|
@click="selectAccessMode('sites')"
|
||||||
>
|
>
|
||||||
Sites
|
Sites
|
||||||
@@ -288,7 +288,7 @@ const getSiteLabels = (user: User) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseInputClass =
|
const baseInputClass =
|
||||||
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
|
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
|
||||||
const usernameFieldClass = computed(() => {
|
const usernameFieldClass = computed(() => {
|
||||||
if (showUsernameError.value) {
|
if (showUsernameError.value) {
|
||||||
return `${baseInputClass} border-red-500`
|
return `${baseInputClass} border-red-500`
|
||||||
|
|||||||
13
frontend/services/contracts.ts
Normal file
13
frontend/services/contracts.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Contract } from './dto/contract'
|
||||||
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
|
export const listContracts = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<Contract[] | { 'hydra:member'?: Contract[] }>(
|
||||||
|
'/contracts',
|
||||||
|
{},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return extractItems<Contract>(data)
|
||||||
|
}
|
||||||
7
frontend/services/dto/contract.ts
Normal file
7
frontend/services/dto/contract.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type Contract = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
trackingMode: 'TIME' | 'PRESENCE'
|
||||||
|
weeklyHours?: number | null
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
|
import type { Contract } from './contract'
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
id: number
|
id: number
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
site: Site
|
site: Site
|
||||||
|
contract?: Contract | null
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
|
|||||||
59
frontend/services/dto/work-hour.ts
Normal file
59
frontend/services/dto/work-hour.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Employee } from './employee'
|
||||||
|
|
||||||
|
export type WorkHour = {
|
||||||
|
id: number
|
||||||
|
employee: Employee
|
||||||
|
workDate: string
|
||||||
|
morningFrom?: string | null
|
||||||
|
morningTo?: string | null
|
||||||
|
afternoonFrom?: string | null
|
||||||
|
afternoonTo?: string | null
|
||||||
|
eveningFrom?: string | null
|
||||||
|
eveningTo?: string | null
|
||||||
|
isPresentMorning?: boolean
|
||||||
|
isPresentAfternoon?: boolean
|
||||||
|
isValid?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkHourEntryPayload = {
|
||||||
|
employeeId: number
|
||||||
|
morningFrom?: string | null
|
||||||
|
morningTo?: string | null
|
||||||
|
afternoonFrom?: string | null
|
||||||
|
afternoonTo?: string | null
|
||||||
|
eveningFrom?: string | null
|
||||||
|
eveningTo?: string | null
|
||||||
|
isPresentMorning?: boolean
|
||||||
|
isPresentAfternoon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeeklyWorkHourDailySummary = {
|
||||||
|
date: string
|
||||||
|
dayMinutes: number
|
||||||
|
nightMinutes: number
|
||||||
|
totalMinutes: number
|
||||||
|
present?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeeklyWorkHourRowSummary = {
|
||||||
|
employeeId: number
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
siteName?: string | null
|
||||||
|
contractName?: string | null
|
||||||
|
trackingMode?: 'TIME' | 'PRESENCE' | null
|
||||||
|
daily: WeeklyWorkHourDailySummary[]
|
||||||
|
weeklyDayMinutes: number
|
||||||
|
weeklyNightMinutes: number
|
||||||
|
weeklyTotalMinutes: number
|
||||||
|
weeklyPresenceCount?: number
|
||||||
|
weeklyOvertime25Minutes?: number
|
||||||
|
weeklyOvertime50Minutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WeeklyWorkHourSummary = {
|
||||||
|
weekStart: string
|
||||||
|
weekEnd: string
|
||||||
|
days: string[]
|
||||||
|
rows: WeeklyWorkHourRowSummary[]
|
||||||
|
}
|
||||||
@@ -11,16 +11,28 @@ export const listEmployees = async () => {
|
|||||||
return extractItems<Employee>(data)
|
return extractItems<Employee>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const listScopedEmployees = async () => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<Employee[] | { 'hydra:member'?: Employee[] }>(
|
||||||
|
'/employees/scoped',
|
||||||
|
{},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
return extractItems<Employee>(data)
|
||||||
|
}
|
||||||
|
|
||||||
export const createEmployee = async (payload: {
|
export const createEmployee = async (payload: {
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
|
contractId: number
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Employee>('/employees', {
|
return api.post<Employee>('/employees', {
|
||||||
firstName: payload.firstName,
|
firstName: payload.firstName,
|
||||||
lastName: payload.lastName,
|
lastName: payload.lastName,
|
||||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
|
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
||||||
|
contract: `/api/contracts/${payload.contractId}`
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.create',
|
toastSuccessKey: 'success.employee.create',
|
||||||
toastErrorKey: 'errors.employee.create'
|
toastErrorKey: 'errors.employee.create'
|
||||||
@@ -33,6 +45,7 @@ export const updateEmployee = async (
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
siteId?: number | null
|
siteId?: number | null
|
||||||
|
contractId: number
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
@@ -41,6 +54,7 @@ export const updateEmployee = async (
|
|||||||
firstName: payload.firstName,
|
firstName: payload.firstName,
|
||||||
lastName: payload.lastName,
|
lastName: payload.lastName,
|
||||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
||||||
|
contract: `/api/contracts/${payload.contractId}`,
|
||||||
displayOrder: payload.displayOrder
|
displayOrder: payload.displayOrder
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.update',
|
toastSuccessKey: 'success.employee.update',
|
||||||
|
|||||||
61
frontend/services/work-hours.ts
Normal file
61
frontend/services/work-hours.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type {
|
||||||
|
WorkHour,
|
||||||
|
WorkHourEntryPayload,
|
||||||
|
WeeklyWorkHourSummary
|
||||||
|
} from './dto/work-hour'
|
||||||
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
|
export const listWorkHoursByDate = async (workDate: string) => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<WorkHour[] | { 'hydra:member'?: WorkHour[] }>(
|
||||||
|
'/work_hours',
|
||||||
|
{
|
||||||
|
'workDate[after]': workDate,
|
||||||
|
'workDate[before]': workDate
|
||||||
|
},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
return extractItems<WorkHour>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bulkUpsertWorkHours = async (payload: {
|
||||||
|
workDate: string
|
||||||
|
entries: WorkHourEntryPayload[]
|
||||||
|
}) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.post<{
|
||||||
|
processed: number
|
||||||
|
created: number
|
||||||
|
updated: number
|
||||||
|
deleted: number
|
||||||
|
}>(
|
||||||
|
'/work-hours/bulk-upsert',
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
toastSuccessMessage: 'Horaires enregistrés.',
|
||||||
|
toastErrorMessage: "Impossible d'enregistrer les horaires."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateWorkHourValidation = async (id: number, isValid: boolean) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.patch<WorkHour>(
|
||||||
|
`/work_hours/${id}`,
|
||||||
|
{ isValid },
|
||||||
|
{
|
||||||
|
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
|
||||||
|
toastErrorMessage: 'Impossible de mettre à jour la validation.'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.get<WeeklyWorkHourSummary>(
|
||||||
|
'/work-hours/weekly-summary',
|
||||||
|
{ weekStart },
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,74 @@ export const toYmd = (year: number, month: number, day: number) => {
|
|||||||
|
|
||||||
export const normalizeDate = (value: string) => value.slice(0, 10)
|
export const normalizeDate = (value: string) => value.slice(0, 10)
|
||||||
|
|
||||||
|
export const parseYmd = (value: string) => {
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
if (!year || !month || !day) return null
|
||||||
|
return new Date(year, month - 1, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateLongFr = (date: Date) => {
|
||||||
|
const label = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(date)
|
||||||
|
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatWeekDayHeaderFr = (dateYmd: string) => {
|
||||||
|
const parsed = parseYmd(dateYmd)
|
||||||
|
if (!parsed) return dateYmd
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
}).format(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWeekStartDate = (date: Date) => {
|
||||||
|
const copy = new Date(date)
|
||||||
|
const day = copy.getDay()
|
||||||
|
const diff = day === 0 ? -6 : 1 - day
|
||||||
|
copy.setDate(copy.getDate() + diff)
|
||||||
|
copy.setHours(0, 0, 0, 0)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTodayYmd = () => {
|
||||||
|
const date = new Date()
|
||||||
|
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOffsetFromTodayYmd = (offset: number) => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() + offset)
|
||||||
|
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shiftYmd = (value: string, days: number) => {
|
||||||
|
const parsed = parseYmd(value)
|
||||||
|
if (!parsed) return null
|
||||||
|
parsed.setDate(parsed.getDate() + days)
|
||||||
|
return toYmd(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatWeekRangeFr = (date: Date) => {
|
||||||
|
const start = getWeekStartDate(date)
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setDate(start.getDate() + 6)
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
|
||||||
|
return `Semaine du ${formatter.format(start)} au ${formatter.format(end)}`
|
||||||
|
}
|
||||||
|
|
||||||
export const getDaysInMonth = (year: number, month: number) => {
|
export const getDaysInMonth = (year: number, month: number) => {
|
||||||
const total = new Date(year, month + 1, 0).getDate()
|
const total = new Date(year, month + 1, 0).getDate()
|
||||||
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
|
||||||
|
|||||||
31
frontend/utils/employee.ts
Normal file
31
frontend/utils/employee.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
|
||||||
|
export const compareEmployeesInSite = (employeeA: Employee, employeeB: Employee) => {
|
||||||
|
const orderA = employeeA.displayOrder ?? 0
|
||||||
|
const orderB = employeeB.displayOrder ?? 0
|
||||||
|
if (orderA !== orderB) return orderA - orderB
|
||||||
|
|
||||||
|
const lastNameA = employeeA.lastName ?? ''
|
||||||
|
const lastNameB = employeeB.lastName ?? ''
|
||||||
|
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
|
||||||
|
|
||||||
|
const firstNameA = employeeA.firstName ?? ''
|
||||||
|
const firstNameB = employeeB.firstName ?? ''
|
||||||
|
return firstNameA.localeCompare(firstNameB, 'fr')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const compareEmployeesBySiteAndOrder = (employeeA: Employee, employeeB: Employee) => {
|
||||||
|
const siteOrderA = employeeA.site?.displayOrder ?? 0
|
||||||
|
const siteOrderB = employeeB.site?.displayOrder ?? 0
|
||||||
|
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
|
||||||
|
|
||||||
|
const siteNameA = employeeA.site?.name ?? ''
|
||||||
|
const siteNameB = employeeB.site?.name ?? ''
|
||||||
|
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
|
||||||
|
|
||||||
|
return compareEmployeesInSite(employeeA, employeeB)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortEmployeesBySiteAndOrder = (employees: Employee[]) => {
|
||||||
|
return [...employees].sort(compareEmployeesBySiteAndOrder)
|
||||||
|
}
|
||||||
34
migrations/Version20260217161000.php
Normal file
34
migrations/Version20260217161000.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260217161000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add contract_hours and is_forfait to employees';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Nettoie l'ancien modele "contracts" s'il a deja ete applique.
|
||||||
|
$this->addSql('ALTER TABLE employees DROP CONSTRAINT IF EXISTS FK_EMPLOYEES_CONTRACT');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS IDX_EMPLOYEES_CONTRACT');
|
||||||
|
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_id');
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS contracts');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS contract_hours INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_hours');
|
||||||
|
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS is_forfait');
|
||||||
|
}
|
||||||
|
}
|
||||||
28
migrations/Version20260217162000.php
Normal file
28
migrations/Version20260217162000.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260217162000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add is_present and is_valid columns to work_hours';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_present BOOLEAN DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_valid BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_valid');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
migrations/Version20260218120000.php
Normal file
54
migrations/Version20260218120000.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260218120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Replace employee contract_hours/is_forfait with contracts table and employee.contract_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE contracts (id SERIAL NOT NULL, name VARCHAR(120) NOT NULL, tracking_mode VARCHAR(20) NOT NULL, weekly_hours INT DEFAULT NULL, is_active BOOLEAN DEFAULT TRUE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_CONTRACTS_TRACKING_MODE ON contracts (tracking_mode)');
|
||||||
|
|
||||||
|
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) VALUES ('Forfait', 'PRESENCE', NULL, TRUE)");
|
||||||
|
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT DISTINCT CONCAT(contract_hours::text, 'h'), 'TIME', contract_hours, TRUE FROM employees WHERE contract_hours IS NOT NULL");
|
||||||
|
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT '35h', 'TIME', 35, TRUE WHERE NOT EXISTS (SELECT 1 FROM contracts WHERE tracking_mode = 'TIME' AND weekly_hours = 35)");
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE employees ADD contract_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('CREATE INDEX IDX_EMPLOYEES_CONTRACT ON employees (contract_id)');
|
||||||
|
|
||||||
|
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = TRUE AND c.tracking_mode = 'PRESENCE'");
|
||||||
|
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = FALSE AND e.contract_hours IS NOT NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = e.contract_hours");
|
||||||
|
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.contract_id IS NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = 35");
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE employees ALTER COLUMN contract_id SET NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employees ADD CONSTRAINT FK_EMPLOYEES_CONTRACT FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE employees DROP COLUMN contract_hours');
|
||||||
|
$this->addSql('ALTER TABLE employees DROP COLUMN is_forfait');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees ADD contract_hours INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employees ADD is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
|
||||||
|
$this->addSql("UPDATE employees e SET is_forfait = CASE WHEN c.tracking_mode = 'PRESENCE' THEN TRUE ELSE FALSE END, contract_hours = CASE WHEN c.tracking_mode = 'TIME' THEN c.weekly_hours ELSE NULL END FROM contracts c WHERE e.contract_id = c.id");
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE employees DROP CONSTRAINT FK_EMPLOYEES_CONTRACT');
|
||||||
|
$this->addSql('DROP INDEX IDX_EMPLOYEES_CONTRACT');
|
||||||
|
$this->addSql('ALTER TABLE employees DROP COLUMN contract_id');
|
||||||
|
|
||||||
|
$this->addSql('DROP INDEX IDX_CONTRACTS_TRACKING_MODE');
|
||||||
|
$this->addSql('DROP TABLE contracts');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
migrations/Version20260218183000.php
Normal file
30
migrations/Version20260218183000.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260218183000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Replace work_hours.is_present with is_present_morning and is_present_afternoon';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_morning BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_afternoon BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_morning');
|
||||||
|
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_afternoon');
|
||||||
|
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present BOOLEAN DEFAULT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/ApiResource/ScopedEmployee.php
Normal file
22
src/ApiResource/ScopedEmployee.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\State\ScopedEmployeeProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/employees/scoped',
|
||||||
|
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: ScopedEmployeeProvider::class,
|
||||||
|
paginationEnabled: false
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class ScopedEmployee {}
|
||||||
@@ -30,7 +30,9 @@ final class WorkHourBulkUpsert
|
|||||||
* afternoonFrom?:?string,
|
* afternoonFrom?:?string,
|
||||||
* afternoonTo?:?string,
|
* afternoonTo?:?string,
|
||||||
* eveningFrom?:?string,
|
* eveningFrom?:?string,
|
||||||
* eveningTo?:?string
|
* eveningTo?:?string,
|
||||||
|
* isPresentMorning?:bool,
|
||||||
|
* isPresentAfternoon?:bool
|
||||||
* }>
|
* }>
|
||||||
*/
|
*/
|
||||||
public array $entries = [];
|
public array $entries = [];
|
||||||
|
|||||||
53
src/ApiResource/WorkHourWeeklySummary.php
Normal file
53
src/ApiResource/WorkHourWeeklySummary.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\State\WorkHourWeeklySummaryProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/work-hours/weekly-summary',
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: WorkHourWeeklySummaryProvider::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class WorkHourWeeklySummary
|
||||||
|
{
|
||||||
|
public string $weekStart = '';
|
||||||
|
public string $weekEnd = '';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $days = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* employeeId:int,
|
||||||
|
* firstName:string,
|
||||||
|
* lastName:string,
|
||||||
|
* siteName:?string,
|
||||||
|
* contractName:?string,
|
||||||
|
* trackingMode:?string,
|
||||||
|
* daily:list<array{
|
||||||
|
* date:string,
|
||||||
|
* dayMinutes:int,
|
||||||
|
* nightMinutes:int,
|
||||||
|
* totalMinutes:int,
|
||||||
|
* present:?float
|
||||||
|
* }>,
|
||||||
|
* weeklyDayMinutes:int,
|
||||||
|
* weeklyNightMinutes:int,
|
||||||
|
* weeklyTotalMinutes:int,
|
||||||
|
* weeklyPresenceCount:float,
|
||||||
|
* weeklyOvertime25Minutes:int,
|
||||||
|
* weeklyOvertime50Minutes:int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public array $rows = [];
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|||||||
use ApiPlatform\Metadata\ApiFilter;
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Repository\AbsenceRepository;
|
||||||
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;
|
||||||
@@ -26,7 +27,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
)]
|
)]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
|
||||||
#[ORM\Table(name: 'absences')]
|
#[ORM\Table(name: 'absences')]
|
||||||
class Absence
|
class Absence
|
||||||
{
|
{
|
||||||
|
|||||||
103
src/Entity/Contract.php
Normal file
103
src/Entity/Contract.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
normalizationContext: ['groups' => ['contract:read']],
|
||||||
|
denormalizationContext: ['groups' => ['contract:write']],
|
||||||
|
paginationEnabled: false,
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
)]
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'contracts')]
|
||||||
|
class Contract
|
||||||
|
{
|
||||||
|
public const string TRACKING_TIME = 'TIME';
|
||||||
|
public const string TRACKING_PRESENCE = 'PRESENCE';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['contract:read', 'employee:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 120)]
|
||||||
|
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 20)]
|
||||||
|
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
|
||||||
|
private string $trackingMode = self::TRACKING_TIME;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
#[Groups(['contract:read', 'contract:write', 'employee:read'])]
|
||||||
|
private ?int $weeklyHours = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||||
|
#[Groups(['contract:read', 'contract:write'])]
|
||||||
|
private bool $isActive = true;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): self
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTrackingMode(): string
|
||||||
|
{
|
||||||
|
return $this->trackingMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTrackingMode(string $trackingMode): self
|
||||||
|
{
|
||||||
|
$this->trackingMode = $trackingMode;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeeklyHours(): ?int
|
||||||
|
{
|
||||||
|
return $this->weeklyHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setWeeklyHours(?int $weeklyHours): self
|
||||||
|
{
|
||||||
|
$this->weeklyHours = $weeklyHours;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsActive(): bool
|
||||||
|
{
|
||||||
|
return $this->isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsActive(bool $isActive): self
|
||||||
|
{
|
||||||
|
$this->isActive = $isActive;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
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 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
paginationEnabled: false,
|
paginationEnabled: false,
|
||||||
security: "is_granted('ROLE_ADMIN')"
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
)]
|
)]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
||||||
#[ORM\Table(name: 'employees')]
|
#[ORM\Table(name: 'employees')]
|
||||||
class Employee
|
class Employee
|
||||||
{
|
{
|
||||||
@@ -33,12 +35,18 @@ class Employee
|
|||||||
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
|
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
|
||||||
private string $lastName = '';
|
private string $lastName = '';
|
||||||
|
|
||||||
#[ApiPlatform\Metadata\ApiProperty(readableLink: true)]
|
#[ApiProperty(readableLink: true)]
|
||||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||||
#[ORM\JoinColumn(nullable: true)]
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
#[Groups(['employee:read', 'employee:write'])]
|
#[Groups(['employee:read', 'employee:write'])]
|
||||||
private ?Site $site = null;
|
private ?Site $site = null;
|
||||||
|
|
||||||
|
#[ApiProperty(readableLink: true)]
|
||||||
|
#[ORM\ManyToOne(targetEntity: Contract::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[Groups(['employee:read', 'employee:write'])]
|
||||||
|
private ?Contract $contract = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
||||||
#[Groups(['employee:read', 'employee:write'])]
|
#[Groups(['employee:read', 'employee:write'])]
|
||||||
private int $displayOrder = 0;
|
private int $displayOrder = 0;
|
||||||
@@ -92,6 +100,18 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getContract(): ?Contract
|
||||||
|
{
|
||||||
|
return $this->contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContract(?Contract $contract): self
|
||||||
|
{
|
||||||
|
$this->contract = $contract;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCreatedAt(): DateTimeImmutable
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->createdAt;
|
return $this->createdAt;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use ApiPlatform\Metadata\ApiProperty;
|
|||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
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;
|
||||||
@@ -26,11 +28,16 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
||||||
security: "is_granted('WORK_HOUR_VIEW', object)"
|
security: "is_granted('WORK_HOUR_VIEW', object)"
|
||||||
),
|
),
|
||||||
|
new Patch(
|
||||||
|
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
||||||
|
denormalizationContext: ['groups' => ['work_hour:validate']],
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
|
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
|
||||||
#[ORM\Entity]
|
#[ORM\Entity(repositoryClass: WorkHourRepository::class)]
|
||||||
#[ORM\Table(name: 'work_hours')]
|
#[ORM\Table(name: 'work_hours')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])]
|
#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])]
|
||||||
class WorkHour
|
class WorkHour
|
||||||
@@ -75,6 +82,18 @@ class WorkHour
|
|||||||
#[Groups(['work_hour:read'])]
|
#[Groups(['work_hour:read'])]
|
||||||
private ?string $eveningTo = null;
|
private ?string $eveningTo = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private bool $isPresentMorning = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read'])]
|
||||||
|
private bool $isPresentAfternoon = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['work_hour:read', 'work_hour:validate'])]
|
||||||
|
private bool $isValid = false;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -175,4 +194,55 @@ class WorkHour
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isPresentMorning(): bool
|
||||||
|
{
|
||||||
|
return $this->isPresentMorning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsPresentMorning(): bool
|
||||||
|
{
|
||||||
|
return $this->isPresentMorning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsPresentMorning(bool $isPresentMorning): self
|
||||||
|
{
|
||||||
|
$this->isPresentMorning = $isPresentMorning;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPresentAfternoon(): bool
|
||||||
|
{
|
||||||
|
return $this->isPresentAfternoon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsPresentAfternoon(): bool
|
||||||
|
{
|
||||||
|
return $this->isPresentAfternoon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsPresentAfternoon(bool $isPresentAfternoon): self
|
||||||
|
{
|
||||||
|
$this->isPresentAfternoon = $isPresentAfternoon;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isValid(): bool
|
||||||
|
{
|
||||||
|
return $this->isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsValid(): bool
|
||||||
|
{
|
||||||
|
return $this->isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsValid(bool $isValid): self
|
||||||
|
{
|
||||||
|
$this->isValid = $isValid;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/Repository/AbsenceRepository.php
Normal file
49
src/Repository/AbsenceRepository.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Absence>
|
||||||
|
*/
|
||||||
|
final class AbsenceRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Absence::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<Absence>
|
||||||
|
*/
|
||||||
|
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||||
|
{
|
||||||
|
if ([] === $employees) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('a')
|
||||||
|
->leftJoin('a.employee', 'e')
|
||||||
|
->leftJoin('a.type', 't')
|
||||||
|
->addSelect('e', 't')
|
||||||
|
->andWhere('a.startDate <= :to')
|
||||||
|
->andWhere('a.endDate >= :from')
|
||||||
|
->andWhere('a.employee IN (:employees)')
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->setParameter('employees', $employees)
|
||||||
|
;
|
||||||
|
|
||||||
|
/** @var list<Absence> $absences */
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/Repository/EmployeeRepository.php
Normal file
103
src/Repository/EmployeeRepository.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Security\EmployeeScopeService;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Employee>
|
||||||
|
*/
|
||||||
|
final class EmployeeRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
ManagerRegistry $registry,
|
||||||
|
private readonly EmployeeScopeService $employeeScopeService,
|
||||||
|
) {
|
||||||
|
parent::__construct($registry, Employee::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $employeeIds
|
||||||
|
*
|
||||||
|
* @return array<int, Employee>
|
||||||
|
*/
|
||||||
|
public function findAccessibleByIds(array $employeeIds, User $user): array
|
||||||
|
{
|
||||||
|
if ([] === $employeeIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('e')
|
||||||
|
->andWhere('e.id IN (:ids)')
|
||||||
|
->setParameter('ids', $employeeIds)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_repo_scope', $user);
|
||||||
|
|
||||||
|
/** @var list<Employee> $employees */
|
||||||
|
$employees = $qb->getQuery()->getResult();
|
||||||
|
|
||||||
|
$byId = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if ($employeeId) {
|
||||||
|
$byId[$employeeId] = $employee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Employee>
|
||||||
|
*/
|
||||||
|
public function findScoped(User $user): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('e')
|
||||||
|
->leftJoin('e.site', 's')
|
||||||
|
->addSelect('s')
|
||||||
|
->orderBy('s.name', 'ASC')
|
||||||
|
->addOrderBy('e.displayOrder', 'ASC')
|
||||||
|
->addOrderBy('e.lastName', 'ASC')
|
||||||
|
->addOrderBy('e.firstName', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user);
|
||||||
|
|
||||||
|
/** @var list<Employee> $employees */
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $siteIds
|
||||||
|
*
|
||||||
|
* @return list<Employee>
|
||||||
|
*/
|
||||||
|
public function findForPrintBySiteIds(array $siteIds): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('e')
|
||||||
|
->leftJoin('e.site', 's')
|
||||||
|
->addSelect('s')
|
||||||
|
->orderBy('s.displayOrder', 'ASC')
|
||||||
|
->addOrderBy('s.name', 'ASC')
|
||||||
|
->addOrderBy('e.displayOrder', 'ASC')
|
||||||
|
->addOrderBy('e.lastName', 'ASC')
|
||||||
|
->addOrderBy('e.firstName', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if ([] !== $siteIds) {
|
||||||
|
$qb->andWhere('s.id IN (:siteIds)')
|
||||||
|
->setParameter('siteIds', $siteIds)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<Employee> $employees */
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/Repository/WorkHourRepository.php
Normal file
82
src/Repository/WorkHourRepository.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<WorkHour>
|
||||||
|
*/
|
||||||
|
final class WorkHourRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, WorkHour::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return array<int, WorkHour>
|
||||||
|
*/
|
||||||
|
public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array
|
||||||
|
{
|
||||||
|
if ([] === $employees) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('w')
|
||||||
|
->leftJoin('w.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->andWhere('w.workDate = :workDate')
|
||||||
|
->andWhere('w.employee IN (:employees)')
|
||||||
|
->setParameter('workDate', $workDate)
|
||||||
|
->setParameter('employees', $employees)
|
||||||
|
;
|
||||||
|
|
||||||
|
/** @var list<WorkHour> $workHours */
|
||||||
|
$workHours = $qb->getQuery()->getResult();
|
||||||
|
|
||||||
|
$byEmployeeId = [];
|
||||||
|
foreach ($workHours as $workHour) {
|
||||||
|
$employeeId = $workHour->getEmployee()?->getId();
|
||||||
|
if ($employeeId) {
|
||||||
|
$byEmployeeId[$employeeId] = $workHour;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byEmployeeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<WorkHour>
|
||||||
|
*/
|
||||||
|
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||||
|
{
|
||||||
|
if ([] === $employees) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('w')
|
||||||
|
->leftJoin('w.employee', 'e')
|
||||||
|
->addSelect('e')
|
||||||
|
->andWhere('w.workDate >= :from')
|
||||||
|
->andWhere('w.workDate <= :to')
|
||||||
|
->andWhere('w.employee IN (:employees)')
|
||||||
|
->setParameter('from', $from)
|
||||||
|
->setParameter('to', $to)
|
||||||
|
->setParameter('employees', $employees)
|
||||||
|
;
|
||||||
|
|
||||||
|
/** @var list<WorkHour> $workHours */
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,11 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Entity\Absence;
|
use App\Repository\AbsenceRepository;
|
||||||
use App\Entity\Employee;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Service\PublicHolidayServiceInterface;
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
@@ -27,7 +26,8 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private EntityManagerInterface $entityManager,
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private AbsenceRepository $absenceRepository,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -109,50 +109,12 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
|
|
||||||
private function loadEmployees(array $siteIds): array
|
private function loadEmployees(array $siteIds): array
|
||||||
{
|
{
|
||||||
$qb = $this->entityManager
|
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
|
||||||
->getRepository(Employee::class)
|
|
||||||
->createQueryBuilder('e')
|
|
||||||
->leftJoin('e.site', 's')
|
|
||||||
->addSelect('s')
|
|
||||||
->orderBy('s.displayOrder', 'ASC')
|
|
||||||
->addOrderBy('s.name', 'ASC')
|
|
||||||
->addOrderBy('e.displayOrder', 'ASC')
|
|
||||||
->addOrderBy('e.lastName', 'ASC')
|
|
||||||
->addOrderBy('e.firstName', 'ASC')
|
|
||||||
;
|
|
||||||
|
|
||||||
if ([] !== $siteIds) {
|
|
||||||
$qb->andWhere('s.id IN (:siteIds)')
|
|
||||||
->setParameter('siteIds', $siteIds)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @var list<Employee> $result
|
|
||||||
return $qb->getQuery()->getResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||||
{
|
{
|
||||||
if ([] === $employees) {
|
return $this->absenceRepository->findForPrint($from, $to, $employees);
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb = $this->entityManager
|
|
||||||
->getRepository(Absence::class)
|
|
||||||
->createQueryBuilder('a')
|
|
||||||
->leftJoin('a.employee', 'e')
|
|
||||||
->leftJoin('a.type', 't')
|
|
||||||
->addSelect('e', 't')
|
|
||||||
->andWhere('a.startDate <= :to')
|
|
||||||
->andWhere('a.endDate >= :from')
|
|
||||||
->andWhere('a.employee IN (:employees)')
|
|
||||||
->setParameter('from', $from)
|
|
||||||
->setParameter('to', $to)
|
|
||||||
->setParameter('employees', $employees)
|
|
||||||
;
|
|
||||||
|
|
||||||
// @var list<Absence> $result
|
|
||||||
return $qb->getQuery()->getResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
|||||||
29
src/State/ScopedEmployeeProvider.php
Normal file
29
src/State/ScopedEmployeeProvider.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
final readonly class ScopedEmployeeProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->employeeRepository->findScoped($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\ApiResource\WorkHourBulkUpsert;
|
use App\ApiResource\WorkHourBulkUpsert;
|
||||||
use App\ApiResource\WorkHourBulkUpsertResult;
|
use App\ApiResource\WorkHourBulkUpsertResult;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Contract;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -24,7 +25,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private EmployeeScopeService $employeeScopeService,
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(
|
public function process(
|
||||||
@@ -54,13 +56,15 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
// Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant.
|
// Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant.
|
||||||
$employeeIds = $this->extractEmployeeIds($data->entries);
|
$employeeIds = $this->extractEmployeeIds($data->entries);
|
||||||
$employeesById = $this->loadAccessibleEmployees($employeeIds, $user);
|
$employeesById = $this->employeeRepository->findAccessibleByIds($employeeIds, $user);
|
||||||
|
|
||||||
if (count($employeesById) !== count($employeeIds)) {
|
if (count($employeesById) !== count($employeeIds)) {
|
||||||
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
|
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingByEmployeeId = $this->loadExistingWorkHours($workDate, array_values($employeesById));
|
$existingByEmployeeId = $this->workHourRepository
|
||||||
|
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
|
||||||
|
;
|
||||||
|
|
||||||
$result = new WorkHourBulkUpsertResult();
|
$result = new WorkHourBulkUpsertResult();
|
||||||
|
|
||||||
@@ -71,8 +75,22 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalized = $this->normalizeEntry($entry, $employeeId);
|
$isPresenceTracking = Contract::TRACKING_PRESENCE === $employee->getContract()?->getTrackingMode();
|
||||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||||
|
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||||
|
|
||||||
|
if ($existing?->isValid()) {
|
||||||
|
if (!$this->isSameAsExisting($existing, $normalized)) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf(
|
||||||
|
'Employee %d: 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.
|
||||||
@@ -136,76 +154,6 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
return array_values($ids);
|
return array_values($ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<int> $employeeIds
|
|
||||||
*
|
|
||||||
* @return array<int, Employee>
|
|
||||||
*/
|
|
||||||
private function loadAccessibleEmployees(array $employeeIds, User $user): array
|
|
||||||
{
|
|
||||||
if ([] === $employeeIds) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb = $this->entityManager
|
|
||||||
->getRepository(Employee::class)
|
|
||||||
->createQueryBuilder('e')
|
|
||||||
->andWhere('e.id IN (:ids)')
|
|
||||||
->setParameter('ids', $employeeIds)
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'bulk_scope', $user);
|
|
||||||
|
|
||||||
/** @var list<Employee> $employees */
|
|
||||||
$employees = $qb->getQuery()->getResult();
|
|
||||||
|
|
||||||
$byId = [];
|
|
||||||
foreach ($employees as $employee) {
|
|
||||||
$employeeId = $employee->getId();
|
|
||||||
if ($employeeId) {
|
|
||||||
$byId[$employeeId] = $employee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $byId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*
|
|
||||||
* @return array<int, WorkHour>
|
|
||||||
*/
|
|
||||||
private function loadExistingWorkHours(DateTimeImmutable $workDate, array $employees): array
|
|
||||||
{
|
|
||||||
if ([] === $employees) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb = $this->entityManager
|
|
||||||
->getRepository(WorkHour::class)
|
|
||||||
->createQueryBuilder('w')
|
|
||||||
->leftJoin('w.employee', 'e')
|
|
||||||
->addSelect('e')
|
|
||||||
->andWhere('w.workDate = :workDate')
|
|
||||||
->andWhere('w.employee IN (:employees)')
|
|
||||||
->setParameter('workDate', $workDate)
|
|
||||||
->setParameter('employees', $employees)
|
|
||||||
;
|
|
||||||
|
|
||||||
/** @var list<WorkHour> $workHours */
|
|
||||||
$workHours = $qb->getQuery()->getResult();
|
|
||||||
|
|
||||||
$byEmployeeId = [];
|
|
||||||
foreach ($workHours as $workHour) {
|
|
||||||
$employeeId = $workHour->getEmployee()?->getId();
|
|
||||||
if ($employeeId) {
|
|
||||||
$byEmployeeId[$employeeId] = $workHour;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $byEmployeeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $entry
|
* @param array<string, mixed> $entry
|
||||||
*
|
*
|
||||||
@@ -215,23 +163,36 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
* afternoonFrom:?string,
|
* afternoonFrom:?string,
|
||||||
* afternoonTo:?string,
|
* afternoonTo:?string,
|
||||||
* eveningFrom:?string,
|
* eveningFrom:?string,
|
||||||
* eveningTo:?string
|
* eveningTo:?string,
|
||||||
|
* isPresentMorning:bool,
|
||||||
|
* isPresentAfternoon:bool
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private function normalizeEntry(array $entry, int $employeeId): array
|
private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking): array
|
||||||
{
|
{
|
||||||
$normalized = [
|
if ($isPresenceTracking) {
|
||||||
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
return [
|
||||||
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
'morningFrom' => null,
|
||||||
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
'morningTo' => null,
|
||||||
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
'afternoonFrom' => null,
|
||||||
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
'afternoonTo' => null,
|
||||||
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
'eveningFrom' => null,
|
||||||
|
'eveningTo' => null,
|
||||||
|
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
|
||||||
|
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
||||||
|
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
||||||
|
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
||||||
|
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
||||||
|
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
||||||
|
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
||||||
|
'isPresentMorning' => false,
|
||||||
|
'isPresentAfternoon' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->validateRanges($normalized, $employeeId);
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeTime(mixed $value, int $employeeId, string $field): ?string
|
private function normalizeTime(mixed $value, int $employeeId, string $field): ?string
|
||||||
@@ -260,77 +221,17 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
return $time;
|
return $time;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
|
||||||
* @param array{
|
|
||||||
* morningFrom:?string,
|
|
||||||
* morningTo:?string,
|
|
||||||
* afternoonFrom:?string,
|
|
||||||
* afternoonTo:?string,
|
|
||||||
* eveningFrom:?string,
|
|
||||||
* eveningTo:?string
|
|
||||||
* } $entry
|
|
||||||
*/
|
|
||||||
private function validateRanges(array $entry, int $employeeId): void
|
|
||||||
{
|
{
|
||||||
$ranges = [
|
if (!is_bool($value)) {
|
||||||
'morning' => [$entry['morningFrom'], $entry['morningTo']],
|
throw new UnprocessableEntityHttpException(sprintf(
|
||||||
'afternoon' => [$entry['afternoonFrom'], $entry['afternoonTo']],
|
'Employee %d: %s must be a boolean.',
|
||||||
'evening' => [$entry['eveningFrom'], $entry['eveningTo']],
|
$employeeId,
|
||||||
];
|
$field
|
||||||
|
));
|
||||||
$normalizedRanges = [];
|
|
||||||
|
|
||||||
foreach ($ranges as $label => [$from, $to]) {
|
|
||||||
// On force des paires from/to complètes par créneau.
|
|
||||||
if ((null === $from) xor (null === $to)) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s range must contain both from and to.',
|
|
||||||
$employeeId,
|
|
||||||
$label
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $from || null === $to) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fromMinutes = $this->toMinutes($from);
|
|
||||||
$toMinutes = $this->toMinutes($to);
|
|
||||||
|
|
||||||
if ($fromMinutes >= $toMinutes) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s from must be earlier than to.',
|
|
||||||
$employeeId,
|
|
||||||
$label
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedRanges[] = [
|
|
||||||
'label' => $label,
|
|
||||||
'from' => $fromMinutes,
|
|
||||||
'to' => $toMinutes,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usort(
|
return $value;
|
||||||
$normalizedRanges,
|
|
||||||
static fn (array $rangeA, array $rangeB): int => $rangeA['from'] <=> $rangeB['from']
|
|
||||||
);
|
|
||||||
|
|
||||||
$previous = null;
|
|
||||||
foreach ($normalizedRanges as $range) {
|
|
||||||
// Empêche deux créneaux qui se chevauchent sur une même journée.
|
|
||||||
if (null !== $previous && $range['from'] < $previous['to']) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s overlaps %s.',
|
|
||||||
$employeeId,
|
|
||||||
$range['label'],
|
|
||||||
$previous['label']
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $range;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,7 +241,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
* afternoonFrom:?string,
|
* afternoonFrom:?string,
|
||||||
* afternoonTo:?string,
|
* afternoonTo:?string,
|
||||||
* eveningFrom:?string,
|
* eveningFrom:?string,
|
||||||
* eveningTo:?string
|
* eveningTo:?string,
|
||||||
|
* isPresentMorning:bool,
|
||||||
|
* isPresentAfternoon:bool
|
||||||
* } $entry
|
* } $entry
|
||||||
*/
|
*/
|
||||||
private function isEntryEmpty(array $entry): bool
|
private function isEntryEmpty(array $entry): bool
|
||||||
@@ -350,7 +253,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
&& null === $entry['afternoonFrom']
|
&& null === $entry['afternoonFrom']
|
||||||
&& null === $entry['afternoonTo']
|
&& null === $entry['afternoonTo']
|
||||||
&& null === $entry['eveningFrom']
|
&& null === $entry['eveningFrom']
|
||||||
&& null === $entry['eveningTo'];
|
&& null === $entry['eveningTo']
|
||||||
|
&& false === $entry['isPresentMorning']
|
||||||
|
&& false === $entry['isPresentAfternoon'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -360,7 +265,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
* afternoonFrom:?string,
|
* afternoonFrom:?string,
|
||||||
* afternoonTo:?string,
|
* afternoonTo:?string,
|
||||||
* eveningFrom:?string,
|
* eveningFrom:?string,
|
||||||
* eveningTo:?string
|
* eveningTo:?string,
|
||||||
|
* isPresentMorning:bool,
|
||||||
|
* isPresentAfternoon:bool
|
||||||
* } $entry
|
* } $entry
|
||||||
*/
|
*/
|
||||||
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
|
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
|
||||||
@@ -372,13 +279,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
->setAfternoonTo($entry['afternoonTo'])
|
->setAfternoonTo($entry['afternoonTo'])
|
||||||
->setEveningFrom($entry['eveningFrom'])
|
->setEveningFrom($entry['eveningFrom'])
|
||||||
->setEveningTo($entry['eveningTo'])
|
->setEveningTo($entry['eveningTo'])
|
||||||
|
->setIsPresentMorning($entry['isPresentMorning'])
|
||||||
|
->setIsPresentAfternoon($entry['isPresentAfternoon'])
|
||||||
|
// Toute modification utilisateur repasse la ligne en attente de validation RH.
|
||||||
|
->setIsValid(false)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toMinutes(string $time): int
|
/**
|
||||||
|
* @param array{
|
||||||
|
* morningFrom:?string,
|
||||||
|
* morningTo:?string,
|
||||||
|
* afternoonFrom:?string,
|
||||||
|
* afternoonTo:?string,
|
||||||
|
* eveningFrom:?string,
|
||||||
|
* eveningTo:?string,
|
||||||
|
* isPresentMorning:bool,
|
||||||
|
* isPresentAfternoon:bool
|
||||||
|
* } $entry
|
||||||
|
*/
|
||||||
|
private function isSameAsExisting(WorkHour $workHour, array $entry): bool
|
||||||
{
|
{
|
||||||
[$hours, $minutes] = array_map('intval', explode(':', $time, 2));
|
return $workHour->getMorningFrom() === $entry['morningFrom']
|
||||||
|
&& $workHour->getMorningTo() === $entry['morningTo']
|
||||||
return ($hours * 60) + $minutes;
|
&& $workHour->getAfternoonFrom() === $entry['afternoonFrom']
|
||||||
|
&& $workHour->getAfternoonTo() === $entry['afternoonTo']
|
||||||
|
&& $workHour->getEveningFrom() === $entry['eveningFrom']
|
||||||
|
&& $workHour->getEveningTo() === $entry['eveningTo']
|
||||||
|
&& $workHour->getIsPresentMorning() === $entry['isPresentMorning']
|
||||||
|
&& $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
293
src/State/WorkHourWeeklySummaryProvider.php
Normal file
293
src/State/WorkHourWeeklySummaryProvider.php
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\WorkHourWeeklySummary;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$anchorDate = $this->resolveAnchorDate();
|
||||||
|
[$weekStart, $weekEnd, $days] = $this->resolveWeek($anchorDate);
|
||||||
|
|
||||||
|
$employees = $this->employeeRepository->findScoped($user);
|
||||||
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
||||||
|
|
||||||
|
$summary = new WorkHourWeeklySummary();
|
||||||
|
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||||
|
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||||
|
$summary->days = $days;
|
||||||
|
$summary->rows = $this->buildRows($employees, $workHours, $days);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAnchorDate(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$query = $this->requestStack->getCurrentRequest()?->query;
|
||||||
|
$raw = (string) ($query?->get('weekStart') ?? '');
|
||||||
|
|
||||||
|
if ('' === $raw) {
|
||||||
|
return new DateTimeImmutable('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
|
||||||
|
if (!$date || $date->format('Y-m-d') !== $raw) {
|
||||||
|
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{DateTimeImmutable, DateTimeImmutable, list<string>}
|
||||||
|
*/
|
||||||
|
private function resolveWeek(DateTimeImmutable $anchorDate): array
|
||||||
|
{
|
||||||
|
$dayOfWeek = (int) $anchorDate->format('N');
|
||||||
|
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
|
||||||
|
$weekEnd = $weekStart->modify('+6 days');
|
||||||
|
|
||||||
|
$days = [];
|
||||||
|
for ($i = 0; $i < 7; ++$i) {
|
||||||
|
$days[] = $weekStart->modify(sprintf('+%d days', $i))->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$weekStart, $weekEnd, $days];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
* @param list<WorkHour> $workHours
|
||||||
|
* @param list<string> $days
|
||||||
|
*
|
||||||
|
* @return list<array{
|
||||||
|
* employeeId:int,
|
||||||
|
* firstName:string,
|
||||||
|
* lastName:string,
|
||||||
|
* siteName:?string,
|
||||||
|
* contractName:?string,
|
||||||
|
* trackingMode:?string,
|
||||||
|
* daily:list<array{date:string, dayMinutes:int, nightMinutes:int, totalMinutes:int, present:?float}>,
|
||||||
|
* weeklyDayMinutes:int,
|
||||||
|
* weeklyNightMinutes:int,
|
||||||
|
* weeklyTotalMinutes:int,
|
||||||
|
* weeklyPresenceCount:float,
|
||||||
|
* weeklyOvertime25Minutes:int,
|
||||||
|
* weeklyOvertime50Minutes:int
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function buildRows(array $employees, array $workHours, array $days): array
|
||||||
|
{
|
||||||
|
$metricsByEmployeeDate = [];
|
||||||
|
foreach ($workHours as $workHour) {
|
||||||
|
$employeeId = $workHour->getEmployee()?->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateKey = $workHour->getWorkDate()->format('Y-m-d');
|
||||||
|
$metricsByEmployeeDate[$employeeId][$dateKey] = [
|
||||||
|
'metrics' => $this->computeMetrics($workHour),
|
||||||
|
'isPresentMorning' => $workHour->getIsPresentMorning(),
|
||||||
|
'isPresentAfternoon' => $workHour->getIsPresentAfternoon(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeklyDayMinutes = 0;
|
||||||
|
$weeklyNightMinutes = 0;
|
||||||
|
$weeklyTotalMinutes = 0;
|
||||||
|
$weeklyPresenceCount = 0.0;
|
||||||
|
$daily = [];
|
||||||
|
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode();
|
||||||
|
|
||||||
|
foreach ($days as $date) {
|
||||||
|
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
|
$metrics = $entry['metrics'] ?? [
|
||||||
|
'dayMinutes' => 0,
|
||||||
|
'nightMinutes' => 0,
|
||||||
|
'totalMinutes' => 0,
|
||||||
|
];
|
||||||
|
$present = null;
|
||||||
|
if ($isPresenceTracking) {
|
||||||
|
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
|
||||||
|
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
|
||||||
|
$present = $morning + $afternoon;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeklyDayMinutes += $metrics['dayMinutes'];
|
||||||
|
$weeklyNightMinutes += $metrics['nightMinutes'];
|
||||||
|
$weeklyTotalMinutes += $metrics['totalMinutes'];
|
||||||
|
if (null !== $present) {
|
||||||
|
$weeklyPresenceCount += $present;
|
||||||
|
}
|
||||||
|
|
||||||
|
$daily[] = [
|
||||||
|
'date' => $date,
|
||||||
|
'dayMinutes' => $metrics['dayMinutes'],
|
||||||
|
'nightMinutes' => $metrics['nightMinutes'],
|
||||||
|
'totalMinutes' => $metrics['totalMinutes'],
|
||||||
|
'present' => $present,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'employeeId' => $employeeId,
|
||||||
|
'firstName' => $employee->getFirstName(),
|
||||||
|
'lastName' => $employee->getLastName(),
|
||||||
|
'siteName' => $employee->getSite()?->getName(),
|
||||||
|
'contractName' => $employee->getContract()?->getName(),
|
||||||
|
'trackingMode' => $employee->getContract()?->getTrackingMode(),
|
||||||
|
'daily' => $daily,
|
||||||
|
'weeklyDayMinutes' => $weeklyDayMinutes,
|
||||||
|
'weeklyNightMinutes' => $weeklyNightMinutes,
|
||||||
|
'weeklyTotalMinutes' => $weeklyTotalMinutes,
|
||||||
|
'weeklyPresenceCount' => $weeklyPresenceCount,
|
||||||
|
'weeklyOvertime25Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime25Minutes($weeklyTotalMinutes),
|
||||||
|
'weeklyOvertime50Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime50Minutes($weeklyTotalMinutes),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{dayMinutes:int, nightMinutes:int, totalMinutes:int}
|
||||||
|
*/
|
||||||
|
private function computeMetrics(WorkHour $workHour): array
|
||||||
|
{
|
||||||
|
$ranges = [
|
||||||
|
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||||
|
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||||
|
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalMinutes = 0;
|
||||||
|
$nightMinutes = 0;
|
||||||
|
|
||||||
|
foreach ($ranges as [$from, $to]) {
|
||||||
|
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||||
|
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'dayMinutes' => $dayMinutes,
|
||||||
|
'nightMinutes' => $nightMinutes,
|
||||||
|
'totalMinutes' => $totalMinutes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null|array{int, int}
|
||||||
|
*/
|
||||||
|
private function resolveInterval(?string $from, ?string $to): ?array
|
||||||
|
{
|
||||||
|
$fromMinutes = $this->toMinutes($from);
|
||||||
|
$toMinutes = $this->toMinutes($to);
|
||||||
|
if (null === $fromMinutes || null === $toMinutes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||||
|
|
||||||
|
return [$fromMinutes, $end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toMinutes(?string $time): ?int
|
||||||
|
{
|
||||||
|
if (null === $time || '' === $time) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||||
|
|
||||||
|
return ($hours * 60) + $minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intervalMinutes(?string $from, ?string $to): int
|
||||||
|
{
|
||||||
|
$interval = $this->resolveInterval($from, $to);
|
||||||
|
if (null === $interval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $interval;
|
||||||
|
|
||||||
|
return max(0, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||||
|
{
|
||||||
|
$interval = $this->resolveInterval($from, $to);
|
||||||
|
if (null === $interval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $interval;
|
||||||
|
$windows = [[0, 360], [1260, 1440]];
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||||
|
$shift = $dayOffset * 1440;
|
||||||
|
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||||
|
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||||
|
{
|
||||||
|
$start = max($startA, $startB);
|
||||||
|
$end = min($endA, $endB);
|
||||||
|
|
||||||
|
return max(0, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeOvertime25Minutes(int $weeklyTotalMinutes): int
|
||||||
|
{
|
||||||
|
return max(0, min($weeklyTotalMinutes, 43 * 60) - (35 * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeOvertime50Minutes(int $weeklyTotalMinutes): int
|
||||||
|
{
|
||||||
|
return max(0, $weeklyTotalMinutes - (43 * 60));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user