fix : wip
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
|
||||
<div class="min-w-[900px]">
|
||||
<div class="grid" :style="gridStyle">
|
||||
<div class="grid" :style="gridStyle" @mouseleave="clearHoveredCell">
|
||||
<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"
|
||||
>
|
||||
@@ -10,16 +10,23 @@
|
||||
<div
|
||||
v-for="day in daysInMonth"
|
||||
: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 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>
|
||||
|
||||
<template v-for="employee in visibleEmployees" :key="employee.id">
|
||||
<div
|
||||
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer"
|
||||
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
|
||||
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"
|
||||
:class="isHoveredRow(employee.id) ? 'bg-primary-500 text-white ring-2 ring-inset ring-primary-500/40' : ''"
|
||||
:style="rowHeaderStyle(employee)"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, employee)"
|
||||
@dragover="handleDragOver"
|
||||
@@ -30,12 +37,14 @@
|
||||
<div
|
||||
v-for="day in daysInMonth"
|
||||
: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)">
|
||||
<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' : ''"
|
||||
:style="getCellStyle(employee.id, day.date)"
|
||||
:disabled="isHolidayDate(day.date)"
|
||||
@@ -63,7 +72,7 @@
|
||||
<template v-else>
|
||||
<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' : ''"
|
||||
:style="getCellStyle(employee.id, day.date)"
|
||||
:disabled="isHolidayDate(day.date)"
|
||||
@@ -89,7 +98,7 @@ type DayInfo = {
|
||||
weekday: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
daysInMonth: DayInfo[]
|
||||
visibleEmployees: Employee[]
|
||||
gridStyle: Record<string, string>
|
||||
@@ -124,4 +133,56 @@ const handleDrop = (event: DragEvent, employee: Employee) => {
|
||||
if (!dragId || dragId === employee.id) return
|
||||
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>
|
||||
|
||||
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>
|
||||
<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">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -6,46 +6,53 @@
|
||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||
</div>
|
||||
<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">
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-primary-50 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-tertiary-500 text-primary-500"
|
||||
>
|
||||
Tableau de bord
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-primary-50 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-tertiary-500 text-primary-500"
|
||||
>
|
||||
Calendrier
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-primary-50 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-tertiary-500 text-primary-500"
|
||||
>
|
||||
Employés
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-primary-50 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-tertiary-500 text-primary-500"
|
||||
>
|
||||
Sites
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-primary-50 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-tertiary-500 text-primary-500"
|
||||
>
|
||||
Types d'absence
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
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"
|
||||
active-class="bg-primary-50 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-tertiary-500 text-primary-500"
|
||||
>
|
||||
Utilisateurs
|
||||
</NuxtLink>
|
||||
|
||||
@@ -6,10 +6,11 @@ export default defineNuxtConfig({
|
||||
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
|
||||
},
|
||||
modules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
'nuxt-toast',
|
||||
'@nuxtjs/i18n'
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
'nuxt-toast',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxt/icon'
|
||||
],
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
@@ -36,4 +37,4 @@ export default defineNuxtConfig({
|
||||
typescript: {
|
||||
strict: true
|
||||
}
|
||||
})
|
||||
})
|
||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"name": "frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.3.0",
|
||||
@@ -32,6 +33,19 @@
|
||||
"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": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -1220,6 +1234,47 @@
|
||||
"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": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
|
||||
@@ -2372,6 +2427,28 @@
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"nuxt": "^4.3.0",
|
||||
|
||||
@@ -171,7 +171,7 @@ const showLabelError = computed(() => validationTouched.label && !isLabelValid.v
|
||||
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
|
||||
|
||||
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(() => {
|
||||
if (showCodeError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
|
||||
@@ -3,38 +3,10 @@
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
|
||||
</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 gap-4">
|
||||
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 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"></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>
|
||||
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
@@ -53,24 +25,34 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-3">
|
||||
<div class="flex items-center gap-4 w-80">
|
||||
<input
|
||||
v-model="employeeFilter"
|
||||
type="text"
|
||||
placeholder="Chercher un employé (nom ou prénom)"
|
||||
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 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 class="flex justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||
<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 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 class="flex-1 min-h-0">
|
||||
@@ -119,10 +101,13 @@ import {listEmployees, updateEmployeeOrder} from '~/services/employees'
|
||||
import {listAbsenceTypes} from '~/services/absence-types'
|
||||
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
|
||||
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 AbsenceFormDrawer from '~/components/AbsenceFormDrawer.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.
|
||||
const employees = ref<Employee[]>([])
|
||||
@@ -149,27 +134,11 @@ watch(sites, (next) => {
|
||||
if (sitesInitialized.value || next.length === 0) return
|
||||
selectedSiteIds.value = next.map((site) => site.id)
|
||||
sitesInitialized.value = true
|
||||
}, { immediate: true })
|
||||
}, {immediate: true})
|
||||
|
||||
// Tri stable: site -> nom -> prénom.
|
||||
const sortedEmployees = computed(() => {
|
||||
return [...employees.value].sort((employeeA, employeeB) => {
|
||||
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')
|
||||
})
|
||||
return sortEmployeesBySiteAndOrder(employees.value)
|
||||
})
|
||||
|
||||
// Employés visibles selon le filtre de sites.
|
||||
@@ -281,13 +250,6 @@ const closePrint = () => {
|
||||
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.
|
||||
const getHalfForDate = (
|
||||
startDate: string,
|
||||
@@ -681,7 +643,7 @@ const formatEmployeeName = (employee: Employee) => {
|
||||
}
|
||||
|
||||
// Impression PDF de l'intervalle sélectionné.
|
||||
const { printPdf } = usePdfPrinter()
|
||||
const {printPdf} = usePdfPrinter()
|
||||
const handlePrint = async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('from', printForm.from)
|
||||
@@ -703,17 +665,7 @@ const handleReorder = async (payload: { dragId: number; dropId: number }) => {
|
||||
|
||||
const siteEmployees = [...employees.value]
|
||||
.filter((employee) => employee.site?.id === dragSiteId)
|
||||
.sort((employeeA, employeeB) => {
|
||||
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')
|
||||
})
|
||||
.sort(compareEmployeesInSite)
|
||||
|
||||
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.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) => {
|
||||
const nextOrder = index + 1
|
||||
if ((employee.displayOrder ?? 0) !== nextOrder) {
|
||||
updates.push({ id: employee.id, displayOrder: nextOrder })
|
||||
updates.push({id: employee.id, displayOrder: nextOrder})
|
||||
}
|
||||
employee.displayOrder = nextOrder
|
||||
})
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
</div>
|
||||
|
||||
<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">Nom</span>
|
||||
<span class="text-left">Site</span>
|
||||
<span class="text-left">Contrat</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||
@@ -32,11 +33,12 @@
|
||||
<div
|
||||
v-for="employee in employees"
|
||||
: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.lastName }}</span>
|
||||
<span>{{ employee.site?.name ?? '-' }}</span>
|
||||
<span>{{ employee.contract?.name ?? '-' }}</span>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -105,6 +107,24 @@
|
||||
Le site est obligatoire.
|
||||
</p>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,8 +147,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Contract } from '~/services/dto/contract'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import type { Site } from '~/services/dto/site'
|
||||
import { listContracts } from '~/services/contracts'
|
||||
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
|
||||
import { listSites } from '~/services/sites'
|
||||
|
||||
@@ -142,24 +164,28 @@ const drawerTitle = computed(() =>
|
||||
|
||||
const employees = ref<Employee[]>([])
|
||||
const sites = ref<Site[]>([])
|
||||
const contracts = ref<Contract[]>([])
|
||||
|
||||
const form = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
siteId: '' as number | ''
|
||||
siteId: '' as number | '',
|
||||
contractId: '' as number | ''
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
firstName: false,
|
||||
lastName: false,
|
||||
siteId: false
|
||||
siteId: false,
|
||||
contractId: false
|
||||
})
|
||||
|
||||
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
|
||||
const isLastNameValid = computed(() => form.lastName.trim() !== '')
|
||||
const isSiteValid = computed(() => form.siteId !== '')
|
||||
const isContractValid = computed(() => form.contractId !== '')
|
||||
const isFormValid = computed(
|
||||
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
|
||||
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value && isContractValid.value
|
||||
)
|
||||
|
||||
const showFirstNameError = computed(
|
||||
@@ -171,9 +197,12 @@ const showLastNameError = computed(
|
||||
const showSiteError = computed(
|
||||
() => validationTouched.siteId && !isSiteValid.value
|
||||
)
|
||||
const showContractError = computed(
|
||||
() => validationTouched.contractId && !isContractValid.value
|
||||
)
|
||||
|
||||
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(() => {
|
||||
if (showFirstNameError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
@@ -194,6 +223,14 @@ const siteFieldClass = computed(() => {
|
||||
}
|
||||
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(() => {
|
||||
if (isSubmitting.value || !isFormValid.value) {
|
||||
@@ -215,8 +252,12 @@ const loadSites = async () => {
|
||||
sites.value = await listSites()
|
||||
}
|
||||
|
||||
const loadContracts = async () => {
|
||||
contracts.value = await listContracts()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadEmployees(), loadSites()])
|
||||
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -224,6 +265,7 @@ const handleSubmit = async () => {
|
||||
validationTouched.firstName = true
|
||||
validationTouched.lastName = true
|
||||
validationTouched.siteId = true
|
||||
validationTouched.contractId = true
|
||||
if (!isFormValid.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
@@ -232,19 +274,22 @@ const handleSubmit = async () => {
|
||||
await updateEmployee(editingEmployee.value.id, {
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
siteId: form.siteId === '' ? null : Number(form.siteId)
|
||||
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||
contractId: Number(form.contractId)
|
||||
})
|
||||
} else {
|
||||
await createEmployee({
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
siteId: form.siteId === '' ? null : Number(form.siteId)
|
||||
siteId: form.siteId === '' ? null : Number(form.siteId),
|
||||
contractId: Number(form.contractId)
|
||||
})
|
||||
}
|
||||
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.siteId = ''
|
||||
form.contractId = ''
|
||||
editingEmployee.value = null
|
||||
isDrawerOpen.value = false
|
||||
await loadEmployees()
|
||||
@@ -258,6 +303,7 @@ watch(isDrawerOpen, (isOpen) => {
|
||||
validationTouched.firstName = false
|
||||
validationTouched.lastName = false
|
||||
validationTouched.siteId = false
|
||||
validationTouched.contractId = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -266,6 +312,7 @@ const openEdit = (employee: Employee) => {
|
||||
form.firstName = employee.firstName
|
||||
form.lastName = employee.lastName
|
||||
form.siteId = employee.site?.id ?? ''
|
||||
form.contractId = employee.contract?.id ?? ''
|
||||
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"
|
||||
type="text"
|
||||
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>
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
v-model="password"
|
||||
type="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>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Se connecter
|
||||
|
||||
@@ -150,7 +150,7 @@ const isFormValid = computed(() => isNameValid.value)
|
||||
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
|
||||
|
||||
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(() => {
|
||||
if (showNameError.value) {
|
||||
return `${baseInputClass} border-red-500`
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<button
|
||||
type="button"
|
||||
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')"
|
||||
>
|
||||
Admin
|
||||
@@ -111,7 +111,7 @@
|
||||
<button
|
||||
type="button"
|
||||
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')"
|
||||
>
|
||||
Accès personnel
|
||||
@@ -119,7 +119,7 @@
|
||||
<button
|
||||
type="button"
|
||||
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')"
|
||||
>
|
||||
Sites
|
||||
@@ -288,7 +288,7 @@ const getSiteLabels = (user: User) => {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (showUsernameError.value) {
|
||||
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 { Contract } from './contract'
|
||||
|
||||
export type Employee = {
|
||||
id: number
|
||||
firstName: string
|
||||
lastName: string
|
||||
site: Site
|
||||
contract?: Contract | null
|
||||
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)
|
||||
}
|
||||
|
||||
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: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
siteId?: number | null
|
||||
contractId: number
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.post<Employee>('/employees', {
|
||||
firstName: payload.firstName,
|
||||
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',
|
||||
toastErrorKey: 'errors.employee.create'
|
||||
@@ -33,6 +45,7 @@ export const updateEmployee = async (
|
||||
firstName: string
|
||||
lastName: string
|
||||
siteId?: number | null
|
||||
contractId: number
|
||||
displayOrder?: number
|
||||
}
|
||||
) => {
|
||||
@@ -41,6 +54,7 @@ export const updateEmployee = async (
|
||||
firstName: payload.firstName,
|
||||
lastName: payload.lastName,
|
||||
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
|
||||
contract: `/api/contracts/${payload.contractId}`,
|
||||
displayOrder: payload.displayOrder
|
||||
}, {
|
||||
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 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) => {
|
||||
const total = new Date(year, month + 1, 0).getDate()
|
||||
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,
|
||||
* afternoonTo?:?string,
|
||||
* eveningFrom?:?string,
|
||||
* eveningTo?:?string
|
||||
* eveningTo?:?string,
|
||||
* isPresentMorning?:bool,
|
||||
* isPresentAfternoon?:bool
|
||||
* }>
|
||||
*/
|
||||
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\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -26,7 +27,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
)]
|
||||
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: AbsenceRepository::class)]
|
||||
#[ORM\Table(name: 'absences')]
|
||||
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;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
@@ -15,7 +17,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: EmployeeRepository::class)]
|
||||
#[ORM\Table(name: 'employees')]
|
||||
class Employee
|
||||
{
|
||||
@@ -33,12 +35,18 @@ class Employee
|
||||
#[Groups(['absence:read', 'employee:read', 'employee:write'])]
|
||||
private string $lastName = '';
|
||||
|
||||
#[ApiPlatform\Metadata\ApiProperty(readableLink: true)]
|
||||
#[ApiProperty(readableLink: true)]
|
||||
#[ORM\ManyToOne(targetEntity: Site::class)]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
#[Groups(['employee:read', 'employee:write'])]
|
||||
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])]
|
||||
#[Groups(['employee:read', 'employee:write'])]
|
||||
private int $displayOrder = 0;
|
||||
@@ -92,6 +100,18 @@ class Employee
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContract(): ?Contract
|
||||
{
|
||||
return $this->contract;
|
||||
}
|
||||
|
||||
public function setContract(?Contract $contract): self
|
||||
{
|
||||
$this->contract = $contract;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
|
||||
@@ -11,6 +11,8 @@ use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
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']],
|
||||
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(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Entity(repositoryClass: WorkHourRepository::class)]
|
||||
#[ORM\Table(name: 'work_hours')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])]
|
||||
class WorkHour
|
||||
@@ -75,6 +82,18 @@ class WorkHour
|
||||
#[Groups(['work_hour:read'])]
|
||||
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
|
||||
{
|
||||
return $this->id;
|
||||
@@ -175,4 +194,55 @@ class WorkHour
|
||||
|
||||
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\State\ProviderInterface;
|
||||
use App\Entity\Absence;
|
||||
use App\Entity\Employee;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
@@ -27,7 +26,8 @@ class AbsencePrintProvider implements ProviderInterface
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private readonly RequestStack $requestStack,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private AbsenceRepository $absenceRepository,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
) {}
|
||||
|
||||
@@ -109,50 +109,12 @@ class AbsencePrintProvider implements ProviderInterface
|
||||
|
||||
private function loadEmployees(array $siteIds): array
|
||||
{
|
||||
$qb = $this->entityManager
|
||||
->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();
|
||||
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
|
||||
}
|
||||
|
||||
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
|
||||
{
|
||||
if ([] === $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();
|
||||
return $this->absenceRepository->findForPrint($from, $to, $employees);
|
||||
}
|
||||
|
||||
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 App\ApiResource\WorkHourBulkUpsert;
|
||||
use App\ApiResource\WorkHourBulkUpsertResult;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -24,7 +25,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
) {}
|
||||
|
||||
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.
|
||||
$employeeIds = $this->extractEmployeeIds($data->entries);
|
||||
$employeesById = $this->loadAccessibleEmployees($employeeIds, $user);
|
||||
$employeesById = $this->employeeRepository->findAccessibleByIds($employeeIds, $user);
|
||||
|
||||
if (count($employeesById) !== count($employeeIds)) {
|
||||
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();
|
||||
|
||||
@@ -71,8 +75,22 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeEntry($entry, $employeeId);
|
||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||
$isPresenceTracking = Contract::TRACKING_PRESENCE === $employee->getContract()?->getTrackingMode();
|
||||
$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)) {
|
||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||
@@ -136,76 +154,6 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
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
|
||||
*
|
||||
@@ -215,23 +163,36 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
* afternoonFrom:?string,
|
||||
* afternoonTo:?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 = [
|
||||
'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'),
|
||||
if ($isPresenceTracking) {
|
||||
return [
|
||||
'morningFrom' => null,
|
||||
'morningTo' => null,
|
||||
'afternoonFrom' => null,
|
||||
'afternoonTo' => null,
|
||||
'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
|
||||
@@ -260,77 +221,17 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
return $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* morningFrom:?string,
|
||||
* morningTo:?string,
|
||||
* afternoonFrom:?string,
|
||||
* afternoonTo:?string,
|
||||
* eveningFrom:?string,
|
||||
* eveningTo:?string
|
||||
* } $entry
|
||||
*/
|
||||
private function validateRanges(array $entry, int $employeeId): void
|
||||
private function normalizePresence(mixed $value, int $employeeId, string $field): bool
|
||||
{
|
||||
$ranges = [
|
||||
'morning' => [$entry['morningFrom'], $entry['morningTo']],
|
||||
'afternoon' => [$entry['afternoonFrom'], $entry['afternoonTo']],
|
||||
'evening' => [$entry['eveningFrom'], $entry['eveningTo']],
|
||||
];
|
||||
|
||||
$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,
|
||||
];
|
||||
if (!is_bool($value)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Employee %d: %s must be a boolean.',
|
||||
$employeeId,
|
||||
$field
|
||||
));
|
||||
}
|
||||
|
||||
usort(
|
||||
$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;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,7 +241,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
* afternoonFrom:?string,
|
||||
* afternoonTo:?string,
|
||||
* eveningFrom:?string,
|
||||
* eveningTo:?string
|
||||
* eveningTo:?string,
|
||||
* isPresentMorning:bool,
|
||||
* isPresentAfternoon:bool
|
||||
* } $entry
|
||||
*/
|
||||
private function isEntryEmpty(array $entry): bool
|
||||
@@ -350,7 +253,9 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
&& null === $entry['afternoonFrom']
|
||||
&& null === $entry['afternoonTo']
|
||||
&& 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,
|
||||
* afternoonTo:?string,
|
||||
* eveningFrom:?string,
|
||||
* eveningTo:?string
|
||||
* eveningTo:?string,
|
||||
* isPresentMorning:bool,
|
||||
* isPresentAfternoon:bool
|
||||
* } $entry
|
||||
*/
|
||||
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
|
||||
@@ -372,13 +279,34 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
->setAfternoonTo($entry['afternoonTo'])
|
||||
->setEveningFrom($entry['eveningFrom'])
|
||||
->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 ($hours * 60) + $minutes;
|
||||
return $workHour->getMorningFrom() === $entry['morningFrom']
|
||||
&& $workHour->getMorningTo() === $entry['morningTo']
|
||||
&& $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