Compare commits

...

4 Commits

Author SHA1 Message Date
tristan fc2b184c50 feat : ajout de la clôture de contrat et de la création de contrat 2026-03-03 11:59:41 +01:00
tristan 0b01e7772c Merge branch 'develop' into feat/ajout-notifications 2026-03-03 09:42:16 +01:00
gitea-actions ae42c70d50 chore: bump version to v0.1.18
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-03-03 08:35:44 +00:00
tristan 812215f5f6 fix : validation bulk des heures. Moins de lag et de bug
Auto Tag Develop / tag (push) Has been cancelled
2026-03-03 09:33:53 +01:00
23 changed files with 1286 additions and 150 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.17'
app.version: '0.1.18'
+10 -1
View File
@@ -28,7 +28,8 @@ Ce document centralise les règles métier actuellement implémentées dans l'ap
### Règles de période
- `CDI`:
- `endDate` doit être vide
- à la création d'une période: `endDate` doit être vide
- en clôture d'un contrat en cours: `endDate` peut être renseignée
- `CDD` / `INTERIM`:
- `endDate` obligatoire
- `endDate` ne peut pas être antérieure à `startDate`
@@ -134,6 +135,14 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Détail employé:
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
- action `Clôturer`:
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
- seule la date de fin est saisissable (préremplie à aujourd'hui)
- backend: en mode clôture, seule `contractEndDate` est acceptée
- action `Ajouter`:
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
## 10) Notifications
@@ -30,7 +30,6 @@
type="checkbox"
class="h-4 w-4"
/>
<span :style="{ backgroundColor: site.color }" class="h-3 w-3 rounded" />
<span class="text-md text-neutral-800">{{ site.name }}</span>
</label>
</div>
@@ -0,0 +1,222 @@
<template>
<section class="mt-8">
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
<p>Contrat</p>
<p>Heures</p>
<p>Date de début</p>
<p>Date de fin</p>
</div>
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
Aucun historique de contrat.
</div>
<div v-else>
<div
v-for="item in contractHistory"
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
>
<p>{{ contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
</div>
</div>
</div>
<div class="mt-8 flex justify-center gap-12">
<button
type="button"
class="w-[200px] rounded-md bg-blue-500 py-2 text-white disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isContractSubmitting || !canCloseCurrentContract"
@click="onOpenCloseContractDrawer"
>
Clôturer
</button>
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isCreateContractSubmitting || contracts.length === 0 || !canCreateContract"
@click="onOpenCreateContractDrawer"
>
<Icon name="mdi:plus-thick" size="16" />
Ajouter
</button>
</div>
<AppDrawer :model-value="isContractDrawerOpen" title="Clôturer le contrat" @update:model-value="onUpdateContractDrawerOpen">
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat
</label>
<input id="contract-nature" :value="contractNatureLabel(contractForm.contractNature)" type="text" :class="readonlyFieldClass" readonly />
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail
</label>
<input id="contract" :value="closeContractWorkedHoursLabel" type="text" :class="readonlyFieldClass" readonly />
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat
</label>
<input
id="contract-start-date"
:value="contractForm.startDate"
type="date"
:class="readonlyFieldClass"
readonly
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat <span class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
v-model="contractForm.endDate"
type="date"
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
:disabled="isContractSubmitting"
@click="onUpdateContractDrawerOpen(false)"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isContractSubmitting || !isContractEndDateValid"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select id="create-contract-nature" v-model="createContractForm.contractNature" :class="createContractNatureFieldClass">
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
Temps de travail <span class="text-red-600">*</span>
</label>
<select id="create-contract-id" v-model="createContractForm.contractId" :class="createContractFieldClass">
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-start-date">
Début contrat <span class="text-red-600">*</span>
</label>
<input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" />
</div>
<div v-if="requiresCreateContractEndDate">
<label class="text-md font-semibold text-neutral-700" for="create-contract-end-date">
Fin contrat <span class="text-red-600">*</span>
</label>
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
:disabled="isCreateContractSubmitting"
@click="onUpdateCreateContractDrawerOpen(false)"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
type ContractForm = {
contractId: number | ''
contractName: string
weeklyHours: number | null
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
}
type CreateContractForm = {
contractId: number | ''
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
}
defineProps<{
contractHistory: ContractHistoryItem[]
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
contractHistoryLabel: (item: ContractHistoryItem) => string
formatDate: (value?: string | null) => string
isContractSubmitting: boolean
canCloseCurrentContract: boolean
isCreateContractSubmitting: boolean
contracts: Contract[]
canCreateContract: boolean
isContractDrawerOpen: boolean
contractForm: ContractForm
readonlyFieldClass: string
closeContractWorkedHoursLabel: string
contractEndDateFieldClass: string
showContractEndDateError: boolean
isContractEndDateValid: boolean
isCreateContractDrawerOpen: boolean
createContractForm: CreateContractForm
createContractNatureFieldClass: string
createContractFieldClass: string
createContractStartDateFieldClass: string
requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string
isCreateContractFormValid: boolean
onOpenCloseContractDrawer: () => void
onOpenCreateContractDrawer: () => void
onUpdateContractDrawerOpen: (open: boolean) => void
onUpdateCreateContractDrawerOpen: (open: boolean) => void
onSubmitCloseContract: () => void
onSubmitCreateContract: () => void
}>()
</script>
@@ -0,0 +1,7 @@
<template>
<section class="mt-8">
<div class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Bloc Congé (à implémenter)
</div>
</section>
</template>
+7
View File
@@ -0,0 +1,7 @@
<template>
<section class="mt-8">
<div class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Bloc RTT (à implémenter)
</div>
</section>
</template>
+31 -1
View File
@@ -26,6 +26,18 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
@@ -173,6 +185,7 @@ import type { HourRow } from './types'
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[]
@@ -193,9 +206,13 @@ const props = defineProps<{
canToggleSiteValidation: (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) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
@@ -208,6 +225,10 @@ 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)
}
@@ -220,4 +241,13 @@ watch(
},
{ immediate: true }
)
watch(
() => props.isBulkSiteValidationIndeterminate,
(isIndeterminate) => {
if (!bulkSiteValidationInput.value) return
bulkSiteValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script>
@@ -0,0 +1,296 @@
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listContracts } from '~/services/contracts'
import { getEmployee, updateEmployee } from '~/services/employees'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
export const useEmployeeDetailPage = () => {
const route = useRoute()
const toast = useToast()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const contracts = ref<Contract[]>([])
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
return contract.name || '-'
})
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.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-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const loadEmployee = async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
if (!Number.isInteger(employeeId) || employeeId <= 0) {
return
}
isLoading.value = true
try {
employee.value = await getEmployee(employeeId)
} finally {
isLoading.value = false
}
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null
})
isContractDrawerOpen.value = false
await loadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
})
isCreateContractDrawerOpen.value = false
await loadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
watch(requiresCreateContractEndDate, (required) => {
if (!required) {
createContractForm.endDate = ''
}
})
onMounted(async () => {
contracts.value = await listContracts()
await loadEmployee()
})
return {
employee,
isLoading,
activeTab,
contracts,
contractHistory,
employeeContractWorkLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract
}
}
+193 -27
View File
@@ -12,6 +12,8 @@ import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import { listPublicHolidays } from '~/services/public-holidays'
import {
bulkUpdateWorkHourSiteValidation,
bulkUpdateWorkHourValidation,
bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary,
@@ -136,25 +138,61 @@ export const useHoursPage = () => {
return true
}
const validatableEmployeeIds = computed(() => {
return employees.value
const canCreateValidationRowFromAbsence = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
}
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
}
const bulkValidatableEmployeeIds = computed(() => {
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleValidation(employeeId))
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
})
const isBulkValidationChecked = computed(() => {
const ids = validatableEmployeeIds.value
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
})
const isBulkValidationIndeterminate = computed(() => {
const ids = validatableEmployeeIds.value
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const bulkSiteValidatableEmployeeIds = computed(() => {
if (!isSiteManager.value) return []
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleSiteValidation(employeeId) || canCreateSiteValidationRowFromAbsence(employeeId))
})
const isBulkSiteValidationChecked = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isSiteValid ?? false)
})
const isBulkSiteValidationIndeterminate = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isSiteValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const canBulkToggleSiteValidation = computed(() => bulkSiteValidatableEmployeeIds.value.length > 0)
const dayContextByEmployeeId = computed(() => {
const map = new Map<number, WorkHourDayContext['rows'][number]>()
for (const row of dayContext.value?.rows ?? []) {
@@ -748,44 +786,169 @@ export const useHoursPage = () => {
}
const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = validatableEmployeeIds.value
const employeeIds = bulkValidatableEmployeeIds.value
if (employeeIds.length === 0) return
let successCount = 0
let failedCount = 0
const pendingIds = new Set(validatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
for (const employeeId of employeeIds) {
if (isValidationPending(employeeId)) continue
try {
await toggleValidation(employeeId, checked, { toast: false })
successCount += 1
} catch {
failedCount += 1
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => ({
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}))
}, { toast: false })
await loadWorkHours()
}
}
if (failedCount === 0) {
toast.success({
title: 'Succès',
message: checked
? `${successCount} ligne(s) validée(s).`
: `${successCount} validation(s) retirée(s).`
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée.'
})
return
}
if (successCount === 0) {
validatingRowIds.value = Array.from(new Set([...validatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourValidation({
workDate: selectedDate.value,
isValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({
title: 'Erreur',
message: 'Aucune ligne mise à jour.'
})
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} ligne(s) validée(s).`
: `${result.updated} validation(s) retirée(s).`
})
} catch {
toast.error({
title: 'Erreur',
message: 'Impossible de mettre à jour les validations.'
})
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const toggleSiteValidationBulk = async (checked: boolean) => {
if (!isSiteManager.value) return
const employeeIds = bulkSiteValidatableEmployeeIds.value
if (employeeIds.length === 0) return
const pendingIds = new Set(siteValidatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateSiteValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => ({
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}))
}, { toast: false })
await loadWorkHours()
}
}
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleSiteValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée côté site.'
})
return
}
toast.error({
title: 'Erreur',
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
})
siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourSiteValidation({
workDate: selectedDate.value,
isSiteValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({
title: 'Erreur',
message: 'Aucune ligne site mise à jour.'
})
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} validation(s) site enregistrée(s).`
: `${result.updated} validation(s) site retirée(s).`
})
} catch {
toast.error({
title: 'Erreur',
message: 'Impossible de mettre à jour les validations site.'
})
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const loadEmployees = async () => {
@@ -965,12 +1128,15 @@ export const useHoursPage = () => {
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
validatableEmployeeIds,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
+72 -83
View File
@@ -14,7 +14,7 @@
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
<div class="text-right">
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employee.contract?.weeklyHours ?? '-' }} heures</p>
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
@@ -53,97 +53,86 @@
</div>
</div>
<section v-if="activeTab === 'contract'" class="mt-8">
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
<p>Contrat</p>
<p>Heures</p>
<p>Date de début</p>
<p>Date de fin</p>
</div>
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
Aucun historique de contrat.
</div>
<div v-else>
<div
v-for="item in contractHistory"
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
>
<p>{{ contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
</div>
</div>
</div>
<div class="flex justify-center mt-8 gap-12">
<button class="bg-blue-500 text-white rounded-md w-[200px]">Modifier</button>
<button class="bg-primary-500 px-4 py-2 text-white text-md rounded-md flex justify-center items-center gap-2 w-[200px]">
<Icon name="mdi:plus-thick" size="16" />
Ajouter
</button>
</div>
</section>
<section v-else-if="activeTab === 'leave'" class="mt-8">
<!-- Bloc Congé -->
</section>
<section v-else class="mt-8">
<!-- Bloc RTT -->
</section>
<EmployeesContractTab
v-if="activeTab === 'contract'"
:contract-history="contractHistory"
:contract-nature-label="contractNatureLabel"
:contract-history-label="contractHistoryLabel"
:format-date="formatDate"
:is-contract-submitting="isContractSubmitting"
:can-close-current-contract="canCloseCurrentContract"
:is-create-contract-submitting="isCreateContractSubmitting"
:contracts="contracts"
:can-create-contract="canCreateContract"
:is-contract-drawer-open="isContractDrawerOpen"
:contract-form="contractForm"
:readonly-field-class="readonlyFieldClass"
:close-contract-worked-hours-label="closeContractWorkedHoursLabel"
:contract-end-date-field-class="contractEndDateFieldClass"
:show-contract-end-date-error="showContractEndDateError"
:is-contract-end-date-valid="isContractEndDateValid"
:is-create-contract-drawer-open="isCreateContractDrawerOpen"
:create-contract-form="createContractForm"
:create-contract-nature-field-class="createContractNatureFieldClass"
:create-contract-field-class="createContractFieldClass"
:create-contract-start-date-field-class="createContractStartDateFieldClass"
:requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid"
:on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen"
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
:on-submit-close-contract="submitContractUpdate"
:on-submit-create-contract="submitCreateContract"
/>
<EmployeesLeaveTab v-else-if="activeTab === 'leave'" />
<EmployeesRttTab v-else />
</div>
</div>
</template>
<script setup lang="ts">
import type {ContractHistoryItem, Employee} from '~/services/dto/employee'
import {getEmployee} from '~/services/employees'
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const {
employee,
isLoading,
activeTab,
contracts,
contractHistory,
employeeContractWorkLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract
} = useEmployeeDetailPage()
useHead(() => ({
title: employee.value
? `${employee.value.firstName} ${employee.value.lastName}`
: 'Détail employé'
}))
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const formatDate = (value?: string | null) => {
if (!value) return 'En cours'
const [year, month, day] = value.split('-')
if (!year || !month || !day) return value
return `${day}/${month}/${year}`
}
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
onMounted(async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
if (!Number.isInteger(employeeId) || employeeId <= 0) {
return
}
isLoading.value = true
try {
employee.value = await getEmployee(employeeId)
} finally {
isLoading.value = false
}
})
</script>
+8 -13
View File
@@ -154,10 +154,10 @@
La date de début est obligatoire.
</p>
</div>
<div v-if="requiresContractEndDate">
<div v-if="requiresContractEndDateComputed">
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat
<span v-if="requiresContractEndDate" class="text-red-600">*</span>
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
@@ -199,6 +199,7 @@ import { listContracts } from '~/services/contracts'
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
import { listSites } from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract'
useHead({
title: 'Employés'
})
@@ -236,12 +237,6 @@ const filteredEmployees = computed<Employee[]>(() => {
})
})
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
const form = reactive({
firstName: '',
lastName: '',
@@ -266,11 +261,11 @@ const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature))
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM')
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
const isContractEndDateValid = computed(() => {
if (!requiresContractEndDate.value) return true
if (!requiresContractEndDateComputed.value) return true
return form.contractEndDate !== ''
})
const isFormValid = computed(
@@ -433,7 +428,7 @@ const handleSubmit = async () => {
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null
contractEndDate: requiresContractEndDateComputed.value ? form.contractEndDate : null
})
}
@@ -464,7 +459,7 @@ watch(isDrawerOpen, (isOpen) => {
}
})
watch(requiresContractEndDate, (required) => {
watch(requiresContractEndDateComputed, (required) => {
if (!required) {
form.contractEndDate = ''
}
+8
View File
@@ -56,9 +56,13 @@
:can-toggle-site-validation="canToggleSiteValidation"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
:on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
@@ -159,9 +163,13 @@ const {
canToggleSiteValidation,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
+18 -7
View File
@@ -56,7 +56,7 @@ export const updateEmployee = async (
firstName: string
lastName: string
siteId?: number | null
contractId: number
contractId?: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
@@ -64,16 +64,27 @@ export const updateEmployee = async (
}
) => {
const api = useApi()
return api.patch<Employee>(`/employees/${id}`, {
const body: Record<string, unknown> = {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null,
displayOrder: payload.displayOrder
}, {
}
if (payload.contractId !== undefined) {
body.contract = `/api/contracts/${payload.contractId}`
}
if (payload.contractNature !== undefined) {
body.contractNature = payload.contractNature
}
if (payload.contractStartDate !== undefined) {
body.contractStartDate = payload.contractStartDate
}
if (payload.contractEndDate !== undefined) {
body.contractEndDate = payload.contractEndDate ?? null
}
return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',
toastErrorKey: 'errors.employee.update'
})
+46
View File
@@ -58,6 +58,29 @@ export const updateWorkHourValidation = async (
)
}
export const bulkUpdateWorkHourValidation = async (payload: {
workDate: string
isValid: boolean
employeeIds: number[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
requested: number
updated: number
skipped: number
updatedEmployeeIds: number[]
skippedEmployeeIds: number[]
}>(
'/work-hours/bulk-validation',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: payload.isValid ? 'Validations enregistrées.' : 'Validations retirées.',
toastErrorMessage: "Impossible de mettre à jour les validations."
}
)
}
export const updateWorkHourSiteValidation = async (
id: number,
isSiteValid: boolean,
@@ -75,6 +98,29 @@ export const updateWorkHourSiteValidation = async (
)
}
export const bulkUpdateWorkHourSiteValidation = async (payload: {
workDate: string
isSiteValid: boolean
employeeIds: number[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
requested: number
updated: number
skipped: number
updatedEmployeeIds: number[]
skippedEmployeeIds: number[]
}>(
'/work-hours/site-bulk-validation',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: payload.isSiteValid ? 'Validations site enregistrées.' : 'Validations site retirées.',
toastErrorMessage: "Impossible de mettre à jour les validations site."
}
)
}
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
const api = useApi()
return api.get<WeeklyWorkHourSummary>(
+17
View File
@@ -0,0 +1,17 @@
export const CONTRACT_NATURES = ['CDI', 'CDD', 'INTERIM'] as const
export type ContractNature = (typeof CONTRACT_NATURES)[number]
export const contractNatureLabel = (value?: ContractNature) => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
export const requiresContractEndDate = (nature: ContractNature) => {
return nature === 'CDD' || nature === 'INTERIM'
}
export const isContractNature = (value: string): value is ContractNature => {
return (CONTRACT_NATURES as readonly string[]).includes(value)
}
+11
View File
@@ -6,6 +6,17 @@ export const toYmd = (year: number, month: number, day: number) => {
export const normalizeDate = (value: string) => value.slice(0, 10)
export const formatYmdToFr = (value: string) => {
const [year, month, day] = value.split('-')
if (!year || !month || !day) return value
return `${day}/${month}/${year}`
}
export const formatNullableYmdToFr = (value?: string | null, fallback = 'En cours') => {
if (!value) return fallback
return formatYmdToFr(value)
}
export const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\WorkHourBulkSiteValidationProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/work-hours/site-bulk-validation',
security: "is_granted('ROLE_USER')",
output: WorkHourBulkValidationResult::class,
processor: WorkHourBulkSiteValidationProcessor::class
),
]
)]
final class WorkHourBulkSiteValidation
{
public string $workDate = '';
public bool $isSiteValid = false;
/**
* @var list<int>
*/
public array $employeeIds = [];
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\WorkHourBulkValidationProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/work-hours/bulk-validation',
security: "is_granted('ROLE_ADMIN')",
output: WorkHourBulkValidationResult::class,
processor: WorkHourBulkValidationProcessor::class
),
]
)]
final class WorkHourBulkValidation
{
public string $workDate = '';
public bool $isValid = false;
/**
* @var list<int>
*/
public array $employeeIds = [];
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
final class WorkHourBulkValidationResult
{
public int $requested = 0;
public int $updated = 0;
public int $skipped = 0;
/**
* @var list<int>
*/
public array $updatedEmployeeIds = [];
/**
* @var list<int>
*/
public array $skippedEmployeeIds = [];
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\ApiResource\WorkHourBulkValidationResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourBulkValidationExecutor
{
public function __construct(
private EntityManagerInterface $entityManager,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
) {}
/**
* @param list<mixed> $employeeIds
* @param callable(?WorkHour, int): bool $shouldSkip
* @param callable(WorkHour, int): void $applyUpdate
*/
public function execute(
User $user,
string $workDateValue,
array $employeeIds,
callable $shouldSkip,
callable $applyUpdate
): WorkHourBulkValidationResult {
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $workDateValue);
if (!$workDate || $workDate->format('Y-m-d') !== $workDateValue) {
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
}
$normalizedEmployeeIds = $this->normalizeEmployeeIds($employeeIds);
if ([] === $normalizedEmployeeIds) {
throw new UnprocessableEntityHttpException('employeeIds must contain at least one employee.');
}
$employeesById = $this->employeeRepository->findAccessibleByIds($normalizedEmployeeIds, $user);
if (count($employeesById) !== count($normalizedEmployeeIds)) {
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
}
$existingByEmployeeId = $this->workHourRepository
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
;
$result = new WorkHourBulkValidationResult();
$result->requested = count($normalizedEmployeeIds);
foreach ($normalizedEmployeeIds as $employeeId) {
$workHour = $existingByEmployeeId[$employeeId] ?? null;
if (null === $workHour || $shouldSkip($workHour, $employeeId)) {
++$result->skipped;
$result->skippedEmployeeIds[] = $employeeId;
continue;
}
$applyUpdate($workHour, $employeeId);
++$result->updated;
$result->updatedEmployeeIds[] = $employeeId;
}
if ($result->updated > 0) {
$this->entityManager->flush();
}
return $result;
}
/**
* @param list<mixed> $employeeIds
*
* @return list<int>
*/
private function normalizeEmployeeIds(array $employeeIds): array
{
$normalized = [];
foreach ($employeeIds as $index => $rawId) {
$employeeId = (int) $rawId;
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException(sprintf('employeeIds[%d] must be a positive integer.', $index));
}
if (isset($normalized[$employeeId])) {
throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in payload.', $employeeId));
}
$normalized[$employeeId] = $employeeId;
}
return array_values($normalized);
}
}
+44 -16
View File
@@ -70,26 +70,53 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $result;
}
$startDate = $requestedStartDate ?? $today;
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
$endDate = $requestedEndDate;
$this->assertPeriodDates($startDate, $endDate, $nature);
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
$currentPeriodContract = $todayPeriod?->getContract();
$contractChanged = $currentPeriodContract instanceof Contract
? $currentPeriodContract->getId() !== $currentContract->getId()
: true;
$isCloseOnlyRequest = !$contractChanged
&& null === $requestedStartDate
&& null === $requestedContractNature
&& null !== $requestedEndDate;
if (
null !== $todayPeriod
&& null === $todayPeriod->getEndDate()
&& $todayPeriod->getStartDate()->format('Y-m-d') === $startDate->format('Y-m-d')
) {
$todayPeriod->setContract($currentContract);
$todayPeriod->setContractNature($nature);
$todayPeriod->setEndDate($endDate);
if ($isCloseOnlyRequest) {
if (null === $todayPeriod) {
throw new UnprocessableEntityHttpException('No active contract period to close.');
}
$currentNature = $todayPeriod->getContractNatureEnum();
$this->assertPeriodDates($todayPeriod->getStartDate(), $requestedEndDate, $currentNature, true);
$currentEndDate = $todayPeriod->getEndDate();
if (null !== $currentEndDate && $requestedEndDate > $currentEndDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be increased on current contract.');
}
$todayPeriod->setEndDate($requestedEndDate);
$this->entityManager->flush();
return $result;
}
$this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
$startDate = $requestedStartDate ?? $today;
$nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
$endDate = $requestedEndDate;
$this->assertPeriodDates($startDate, $endDate, $nature);
if (null !== $todayPeriod) {
$currentEndDate = $todayPeriod->getEndDate();
if (null === $currentEndDate) {
if ($startDate <= $todayPeriod->getStartDate()) {
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract start date.');
}
$todayPeriod->setEndDate($startDate->modify('-1 day'));
} elseif ($startDate <= $currentEndDate) {
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
}
}
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
$this->entityManager->flush();
@@ -179,7 +206,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
private function assertPeriodDates(
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature
ContractNature $nature,
bool $allowCdiEndDate = false
): void {
if (null !== $endDate && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
@@ -189,7 +217,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
}
if (ContractNature::CDI === $nature && null !== $endDate) {
if (!$allowCdiEndDate && ContractNature::CDI === $nature && null !== $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkSiteValidation;
use App\ApiResource\WorkHourBulkValidationResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private WorkHourBulkValidationExecutor $executor,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): WorkHourBulkValidationResult {
if (!$data instanceof WorkHourBulkSiteValidation) {
throw new BadRequestHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
if (in_array('ROLE_ADMIN', $user->getRoles(), true) || in_array('ROLE_SELF', $user->getRoles(), true)) {
throw new AccessDeniedHttpException('Only site managers can bulk update site validation.');
}
return $this->executor->execute(
user: $user,
workDateValue: $data->workDate,
employeeIds: $data->employeeIds,
shouldSkip: static fn (WorkHour $workHour): bool => $workHour->isValid() || $workHour->isSiteValid() === $data->isSiteValid,
applyUpdate: static function (WorkHour $workHour) use ($data): void {
$workHour->setIsSiteValid($data->isSiteValid);
}
);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkValidation;
use App\ApiResource\WorkHourBulkValidationResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class WorkHourBulkValidationProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private WorkHourBulkValidationExecutor $executor,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): WorkHourBulkValidationResult {
if (!$data instanceof WorkHourBulkValidation) {
throw new BadRequestHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
if (!in_array('ROLE_ADMIN', $user->getRoles(), true)) {
throw new AccessDeniedHttpException('Only admins can bulk validate work hours.');
}
return $this->executor->execute(
user: $user,
workDateValue: $data->workDate,
employeeIds: $data->employeeIds,
shouldSkip: static fn (WorkHour $workHour): bool => $workHour->isValid() === $data->isValid,
applyUpdate: static function (WorkHour $workHour) use ($data): void {
$workHour->setIsValid($data->isValid);
}
);
}
}