Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #17 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
445 lines
24 KiB
Vue
445 lines
24 KiB
Vue
<template>
|
|
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
|
<!-- Mobile card layout -->
|
|
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
|
|
<div
|
|
v-for="employee in employees"
|
|
:key="'m-' + employee.id"
|
|
class="rounded-md border border-primary-500 bg-white p-4"
|
|
>
|
|
<!-- Employee name + site -->
|
|
<div class="mb-3">
|
|
<p class="text-md font-bold text-primary-500 truncate">
|
|
{{ employee.firstName }} {{ employee.lastName }}
|
|
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
|
|
</p>
|
|
<p class="text-sm text-neutral-500 truncate">
|
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Absence / Holiday / Formation pills -->
|
|
<div class="mb-3 flex flex-col gap-1">
|
|
<p
|
|
v-if="getRowAbsenceLabel(employee.id)"
|
|
class="rounded-md px-2 py-1 text-xs text-white truncate"
|
|
:style="getRowAbsenceStyle(employee.id)"
|
|
>
|
|
{{ getRowAbsenceLabel(employee.id) }}
|
|
</p>
|
|
<p
|
|
v-else
|
|
class="text-xs text-neutral-400"
|
|
>
|
|
Aucune absence
|
|
</p>
|
|
<p
|
|
v-if="isHoliday"
|
|
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
|
|
style="background-color: #b3e5fc"
|
|
>
|
|
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
|
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
|
</p>
|
|
<p
|
|
v-if="hasRowFormation(employee.id)"
|
|
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
|
|
>
|
|
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
|
|
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
|
|
</p>
|
|
<button
|
|
v-if="!hasRowFormation(employee.id)"
|
|
type="button"
|
|
class="self-start text-xs font-semibold underline"
|
|
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
|
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
|
@click="onAbsenceClick(employee.id)"
|
|
>
|
|
Modifier
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Time inputs (TIME tracking) -->
|
|
<div v-if="isTimeTracking(employee)" class="space-y-2">
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label class="text-xs text-neutral-500">Début matin</label>
|
|
<TimeSelect
|
|
v-model="rows[employee.id].morningFrom"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-neutral-500">Fin matin</label>
|
|
<TimeSelect
|
|
v-model="rows[employee.id].morningTo"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label class="text-xs text-neutral-500">Début après-midi</label>
|
|
<TimeSelect
|
|
v-model="rows[employee.id].afternoonFrom"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-neutral-500">Fin après-midi</label>
|
|
<TimeSelect
|
|
v-model="rows[employee.id].afternoonTo"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label class="text-xs text-neutral-500">Début soir</label>
|
|
<TimeSelect
|
|
v-model="rows[employee.id].eveningFrom"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label class="text-xs text-neutral-500">Fin soir</label>
|
|
<TimeSelect
|
|
v-model="rows[employee.id].eveningTo"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
|
|
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
|
|
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
|
|
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Presence tracking -->
|
|
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
|
|
<div class="flex items-center gap-4">
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input
|
|
v-model="rows[employee.id].isPresentMorning"
|
|
type="checkbox"
|
|
class="h-4 w-4 cursor-pointer"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
/>
|
|
Matin
|
|
</label>
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input
|
|
v-model="rows[employee.id].isPresentAfternoon"
|
|
type="checkbox"
|
|
class="h-4 w-4 cursor-pointer"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
/>
|
|
Après-midi
|
|
</label>
|
|
</div>
|
|
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
|
|
</div>
|
|
|
|
<!-- Validation status (non-admin) -->
|
|
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
|
|
<span v-if="!isSiteManager" class="flex items-center gap-1">
|
|
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
|
|
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
|
|
</span>
|
|
<span class="flex items-center gap-1">
|
|
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
|
|
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Validation checkbox (admin) -->
|
|
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
|
|
<input
|
|
:checked="rows[employee.id]?.isValid ?? false"
|
|
type="checkbox"
|
|
class="h-4 w-4 cursor-pointer"
|
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
|
/>
|
|
<span class="text-neutral-700 font-semibold">Valider</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Desktop table layout -->
|
|
<div class="overflow-y-auto min-h-0 hidden lg:block">
|
|
<div
|
|
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
|
:style="{ gridTemplateColumns: dayGridCols }"
|
|
>
|
|
<span>Nom</span>
|
|
<span class="pl-2">Statut</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">Jour</span>
|
|
<span>Nuit</span>
|
|
<span>Total</span>
|
|
<span v-if="isAdmin" class="flex justify-between items-center">
|
|
<span>Valider</span>
|
|
<input
|
|
ref="bulkValidationInput"
|
|
:checked="isBulkValidationChecked"
|
|
type="checkbox"
|
|
class="h-4 w-4 cursor-pointer"
|
|
@change="onBulkValidationChange"
|
|
/>
|
|
</span>
|
|
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
|
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
|
</div>
|
|
|
|
<div class="border-x border-b border-primary-500 rounded-b-md">
|
|
<div
|
|
v-for="employee in employees"
|
|
:key="employee.id"
|
|
class="grid w-full min-w-0 items-center gap-1 border-b border-primary-500 px-4 py-2 text-sm font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
|
:style="{ gridTemplateColumns: dayGridCols }"
|
|
>
|
|
<div class="text-neutral-900 min-w-0">
|
|
<p class="font-semibold truncate">
|
|
{{ employee.firstName }} {{ employee.lastName }}
|
|
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
|
</p>
|
|
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
|
<span>
|
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
|
</span>
|
|
<span
|
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
|
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
|
title="Validation site"
|
|
>
|
|
<Icon name="mdi:check"/>
|
|
</span>
|
|
</p>
|
|
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
|
|
Modifié le {{ getRowUpdatedAt(employee.id) }}
|
|
</p>
|
|
</div>
|
|
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
|
<div class="flex flex-col gap-1 min-w-0">
|
|
<p
|
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
|
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
|
:title="getRowAbsenceLabel(employee.id) || ''"
|
|
:style="getRowAbsenceStyle(employee.id)"
|
|
>
|
|
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
|
</p>
|
|
<p
|
|
v-if="isHoliday"
|
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
|
|
style="background-color: #b3e5fc"
|
|
:title="holidayLabel || 'Férié'"
|
|
>
|
|
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
|
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
|
</p>
|
|
<p
|
|
v-if="hasRowFormation(employee.id)"
|
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
|
|
:title="getRowFormationLabel(employee.id)"
|
|
>
|
|
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
|
|
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
|
|
</p>
|
|
</div>
|
|
<button
|
|
v-if="!hasRowFormation(employee.id)"
|
|
type="button"
|
|
class="self-start text-left text-xs font-semibold underline"
|
|
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
|
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
|
@click="onAbsenceClick(employee.id)"
|
|
>
|
|
Modifier
|
|
</button>
|
|
</div>
|
|
<div class="pl-4">
|
|
<TimeSelect
|
|
v-if="isTimeTracking(employee)"
|
|
v-model="rows[employee.id].morningFrom"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
/>
|
|
<input
|
|
v-else-if="isPresenceTracking(employee)"
|
|
v-model="rows[employee.id].isPresentMorning"
|
|
type="checkbox"
|
|
class="cursor-pointer h-4 w-4"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
/>
|
|
</div>
|
|
<div class="pr-2">
|
|
<TimeSelect
|
|
v-if="isTimeTracking(employee)"
|
|
v-model="rows[employee.id].morningTo"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
|
/>
|
|
</div>
|
|
<div class="pl-2">
|
|
<TimeSelect
|
|
v-if="isTimeTracking(employee)"
|
|
v-model="rows[employee.id].afternoonFrom"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
/>
|
|
<input
|
|
v-else-if="isPresenceTracking(employee)"
|
|
v-model="rows[employee.id].isPresentAfternoon"
|
|
type="checkbox"
|
|
class="cursor-pointer h-4 w-4"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
/>
|
|
</div>
|
|
<div class="pr-2">
|
|
<TimeSelect
|
|
v-if="isTimeTracking(employee)"
|
|
v-model="rows[employee.id].afternoonTo"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
|
/>
|
|
</div>
|
|
<div class="pl-2">
|
|
<TimeSelect
|
|
v-if="isTimeTracking(employee)"
|
|
v-model="rows[employee.id].eveningFrom"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
/>
|
|
</div>
|
|
<div class="pr-2">
|
|
<TimeSelect
|
|
v-if="isTimeTracking(employee)"
|
|
v-model="rows[employee.id].eveningTo"
|
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
|
/>
|
|
</div>
|
|
<div class="pl-2 text-sm font-semibold">
|
|
<div v-if="isTimeTracking(employee)">{{
|
|
formatMinutes(getRowMetrics(employee.id).dayMinutes)
|
|
}}
|
|
</div>
|
|
</div>
|
|
<div class="text-sm font-semibold">
|
|
<div v-if="isTimeTracking(employee)">{{
|
|
formatMinutes(getRowMetrics(employee.id).nightMinutes)
|
|
}}
|
|
</div>
|
|
</div>
|
|
<div class="text-sm font-semibold">
|
|
<div v-if="isTimeTracking(employee)">{{
|
|
formatMinutes(getRowMetrics(employee.id).totalMinutes)
|
|
}}
|
|
</div>
|
|
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
|
|
</div>
|
|
<div v-if="isAdmin" class="text-right">
|
|
<input
|
|
:checked="rows[employee.id]?.isValid ?? false"
|
|
type="checkbox"
|
|
class="h-4 w-4 cursor-pointer"
|
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
|
/>
|
|
</div>
|
|
<div v-else-if="!isSiteManager" class="text-right p-5">
|
|
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
|
<span v-else class="text-xs text-neutral-500">-</span>
|
|
</div>
|
|
<div v-if="!isAdmin">
|
|
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
|
<span v-else class="text-xs text-neutral-500">-</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type {Employee} from '~/services/dto/employee'
|
|
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
|
import type {HourRow} from './types'
|
|
import { contractNatureLabel } from '~/utils/contract'
|
|
|
|
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
|
|
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
|
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
|
|
|
|
const props = defineProps<{
|
|
employees: Employee[]
|
|
isAdmin: boolean
|
|
isSiteManager: boolean
|
|
dayGridCols: string
|
|
isHoliday: boolean
|
|
holidayLabel: string
|
|
contractLabel: (employee: Employee) => string
|
|
isTimeTracking: (employee: Employee) => boolean
|
|
isPresenceTracking: (employee: Employee) => boolean
|
|
isRowLocked: (employeeId: number) => boolean
|
|
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
|
|
isEveningLockedByAbsence: (employeeId: number) => boolean
|
|
hasContractAtSelectedDate: (employeeId: number) => boolean
|
|
isValidationPending: (employeeId: number) => boolean
|
|
isSiteValidationPending: (employeeId: number) => boolean
|
|
canToggleValidation: (employeeId: number) => boolean
|
|
canToggleSiteValidation: (employeeId: number) => boolean
|
|
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
|
|
isBulkValidationChecked: boolean
|
|
isBulkValidationIndeterminate: boolean
|
|
isBulkSiteValidationChecked: boolean
|
|
isBulkSiteValidationIndeterminate: boolean
|
|
canBulkToggleSiteValidation: boolean
|
|
onToggleValidation: (employeeId: number, checked: boolean) => void
|
|
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
|
|
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
|
|
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
|
|
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
|
|
getRowAbsenceLabel: (employeeId: number) => string
|
|
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
|
|
hasRowFormation: (employeeId: number) => boolean
|
|
getRowFormationLabel: (employeeId: number) => string
|
|
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
|
|
getRowUpdatedAt: (employeeId: number) => string
|
|
getPresenceDayValue: (employeeId: number) => string
|
|
onAbsenceClick: (employeeId: number) => void
|
|
formatMinutes: (minutes: number) => string
|
|
}>()
|
|
|
|
const onBulkValidationChange = (event: Event) => {
|
|
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
|
|
}
|
|
|
|
const onBulkSiteValidationChange = (event: Event) => {
|
|
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
|
|
}
|
|
|
|
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
|
|
props.onToggleSiteValidation(employeeId, checked)
|
|
}
|
|
|
|
watch(
|
|
() => props.isBulkValidationIndeterminate,
|
|
(isIndeterminate) => {
|
|
if (!bulkValidationInput.value) return
|
|
bulkValidationInput.value.indeterminate = isIndeterminate
|
|
},
|
|
{immediate: true}
|
|
)
|
|
|
|
watch(
|
|
() => props.isBulkSiteValidationIndeterminate,
|
|
(isIndeterminate) => {
|
|
if (!bulkSiteValidationInput.value) return
|
|
bulkSiteValidationInput.value.indeterminate = isIndeterminate
|
|
},
|
|
{immediate: true}
|
|
)
|
|
</script>
|