Compare commits

..

2 Commits

Author SHA1 Message Date
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
20 changed files with 660 additions and 405 deletions
-7
View File
@@ -13,13 +13,6 @@ Arborescence clé:
- `tests/`: TU backend (PHPUnit)
- `frontend/`: app Nuxt (pages, composants, composables, services)
- `migrations/`: migrations Doctrine
- `doc/`: documentation fonctionnelle et règles métier de référence
## 1.1) Référentiel Fonctionnel (obligatoire)
- Référence principale des règles métier: `doc/functional-rules.md`
- Toute intervention doit commencer par une vérification de cohérence avec cette documentation.
- Règle permanente: à chaque développement qui modifie le fonctionnel, la documentation dans `doc/` doit être mise à jour automatiquement dans la même intervention (pas de report).
## 2) Commandes utiles
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.17'
app.version: '0.1.18'
-134
View File
@@ -1,134 +0,0 @@
# Règles Fonctionnelles SIRH
Ce document centralise les règles métier actuellement implémentées dans l'application.
## 1) Utilisateurs et accès
- `ROLE_ADMIN`
- accès complet aux écrans d'administration
- vue semaine des heures
- validation RH des lignes d'heures
- `ROLE_SELF`
- accès limité à son périmètre personnel
- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`)
- accès au périmètre des sites autorisés
- validation site des lignes d'heures
## 2) Contrats
- Le profil de temps de travail est porté par `Contract`:
- `trackingMode`: `TIME` ou `PRESENCE`
- `weeklyHours` (ex: 35, 39, 4, etc.)
- La nature RH est portée par période employé:
- `CDI`, `CDD`, `INTERIM`
- Historique des contrats employé:
- table `employee_contract_periods`
- un employé peut avoir plusieurs périodes
### Règles de période
- `CDI`:
- `endDate` doit être vide
- `CDD` / `INTERIM`:
- `endDate` obligatoire
- `endDate` ne peut pas être antérieure à `startDate`
## 3) Heures (vue jour)
- Saisie par salarié et par date:
- matin / après-midi / soir
- pour `PRESENCE`: demi-journées matin/après-midi
- Calculs affichés:
- `Jour`, `Nuit`, `Total`
- Heures de nuit:
- fenêtres `00:00-06:00` et `21:00-24:00`
## 4) Absences
- Les absences sont stockées par jour (découpage lors de l'écriture)
- Une absence peut être:
- journée complète
- demi-journée `AM` ou `PM`
- Colonne absence (vue jour):
- affiche le libellé
- fond coloré selon le type d'absence
- Si plusieurs absences de couleurs différentes sur le même jour:
- fallback rouge
### Effet absence sur les heures
- Absence `AM`:
- efface les heures du matin
- Absence `PM`:
- efface les heures d'après-midi et du soir
- Absence journée:
- efface toutes les plages horaires
### Absences "comptées comme travaillées"
- Si `countAsWorkedHours = true`:
- `TIME`: crédit de minutes selon contrat actif du jour
- `PRESENCE`: crédit d'unités (0.5 / demi-journée)
## 5) Validations des lignes d'heures
- Validation RH (`isValid`)
- action admin
- Validation site (`isSiteValid`)
- action chef de site
### Verrouillage
- Ligne validée RH:
- verrouillée pour modifications heures/absences
- Ligne validée site:
- verrouillée pour non-admin
- admin peut corriger
- Toute vraie modification d'une ligne:
- remet `isSiteValid = false`
- remet `isValid = false`
- Si aucun changement réel à l'enregistrement:
- les validations existantes ne sont pas altérées
## 6) Heures supplémentaires (vue semaine)
- Base de calcul:
- dépend du contrat actif par jour
- Tranche 25%:
- contrats <= 35h: de 35h à 43h
- contrats >= 39h: de 39h à 43h
- Tranche 50%:
- au-delà de 43h
- Nature `INTERIM`:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
## 8) Impression absences (PDF)
Filtres disponibles:
- période `from` / `to`
- sites
- nature de contrat (`CDI`, `CDD`, `INTERIM`)
- temps de travail (contrats de type Forfait, 35h, 39h, etc.)
Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
## 9) Employés
- Création employé:
- prénom, nom, site
- type de contrat (nature RH)
- temps de travail
- dates début/fin (selon règles nature)
- Modification employé:
- uniquement prénom, nom, site
- pas de modification de contrat depuis ce drawer
-43
View File
@@ -1,43 +0,0 @@
<template>
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-12 text-xl text-white">
<Icon name="mdi:bell-plus" class="self-center cursor-pointer" size="36" />
<div class="group relative flex gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="self-center cursor-pointer">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-20 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
>
Mon profil
</button>
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="handleLogout"
>
Déconnexion
</button>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import type { User } from '~/services/dto/user'
defineProps<{
user?: User
}>()
const auth = useAuthStore()
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
</script>
@@ -1,26 +1,18 @@
<template>
<div class="relative w-full max-w-[340px]">
<input
id="employee-search"
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
/>
<Icon
name="mdi:magnify"
size="18"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
/>
</div>
<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})
const model = defineModel<string>({ required: true })
withDefaults(defineProps<{
placeholder?: string
placeholder?: string
}>(), {
placeholder: "Recherche d'un employé"
placeholder: 'Chercher un employé (nom ou prénom)'
})
</script>
+11 -56
View File
@@ -1,70 +1,25 @@
<template>
<div ref="root" class="relative inline-block w-fit max-w-full">
<button
type="button"
class="inline-flex w-[280px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
@click="isOpen = !isOpen"
>
<span>Sites</span>
<span class="inline-flex items-center gap-2">
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
</span>
</button>
<div
v-if="isOpen"
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
>
<div class="flex flex-col gap-2">
<label
v-for="site in sites"
:key="site.id"
:for="`site-${site.id}`"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
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>
<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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Site } from '~/services/dto/site'
const selectedSiteIds = defineModel<number[]>({ required: true })
const isOpen = ref(false)
const root = ref<HTMLElement | null>(null)
defineProps<{
sites: Site[]
}>()
const selectedCount = computed(() => selectedSiteIds.value.length)
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (!root.value || !target) return
if (!root.value.contains(target)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
+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>
+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,
+3 -6
View File
@@ -73,12 +73,9 @@
</div>
</aside>
<div class="h-full flex-1 overflow-hidden flex flex-col">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto px-8 py-12">
<slot/>
</main>
</div>
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
<slot/>
</main>
</div>
</div>
</template>
@@ -1,22 +1,24 @@
<template>
<div class="flex-col">
<div class="h-full overflow-hidden flex flex-col">
<div class="shrink-0">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
<Icon name="mdi:plus-thick" size="16" class="text-white"/>
Ajouter un employé
</button>
</div>
<div class="flex gap-10 py-7">
<div class="flex flex-col gap-3 py-6">
<div class="flex justify-between">
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un employé
</button>
</div>
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
</div>
</div>
@@ -27,33 +29,57 @@
Aucun employé pour le moment.
</div>
<div v-else class="grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(260px,1fr))]">
<NuxtLink
v-for="employee in filteredEmployees"
:key="employee.id"
:to="`/employees/${employee.id}`"
target="_blank"
rel="noopener noreferrer"
class="group relative min-h-[328px] overflow-hidden rounded-lg bg-tertiary-500 p-4 transition-all duration-200 hover:shadow-md"
>
<div class="flex flex-col items-center gap-7 transition-opacity duration-200 group-hover:opacity-0">
<div class="rounded-full bg-neutral-300 h-[175px] w-[175px]"></div>
<div class="text-center text-[20px]">
<p class="text-primary-500 font-bold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p>Nom du poste occupé</p>
<p>Site ({{ employee.site?.name ?? '-' }})</p>
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div class="h-full overflow-auto">
<div class="min-w-[900px]">
<div class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
<span class="text-left">Prénom</span>
<span class="text-left">Nom</span>
<span class="text-left">Site</span>
<span class="text-left">Nature</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">
Chargement...
</div>
<div v-else>
<div
v-for="employee in filteredEmployees"
:key="employee.id"
class="grid grid-cols-[120px_1fr_1fr_180px_180px_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
class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
:style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
:class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
>
{{ employee.site?.name ?? '-' }}
</span>
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(employee)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(employee)"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
<div class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p>Type: {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p>Temps de travail: {{ employee.contract?.name ?? '-' }}</p>
<p>Site: {{ employee.site?.name ?? '-' }}</p>
</div>
</div>
</NuxtLink>
</div>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
@@ -218,7 +244,7 @@ const contracts = ref<Contract[]>([])
const employeeFilter = ref('')
const selectedSiteIds = ref<number[]>([])
const filteredEmployees = computed<Employee[]>(() => {
const filteredEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
-71
View File
@@ -1,71 +0,0 @@
<template>
<div class="h-full overflow-auto">
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="!employee"
class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Employé introuvable.
</div>
<div v-else>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
<NuxtLink
to="/employees"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
>
Retour
</NuxtLink>
</div>
<div class="mt-6 rounded-lg border border-neutral-200 bg-white p-6">
<p class="text-xl font-semibold text-neutral-900">{{ employee.firstName }} {{ employee.lastName }}</p>
<p class="mt-2 text-md text-neutral-700">Site: {{ employee.site?.name ?? '-' }}</p>
<p class="text-md text-neutral-700">Type de contrat: {{
contractNatureLabel(employee.currentContractNature)
}}</p>
<p class="text-md text-neutral-700">Temps de travail: {{ employee.contract?.name ?? '-' }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {Employee} from '~/services/dto/employee'
import {getEmployee} from '~/services/employees'
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
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'
}
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
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,
-5
View File
@@ -21,11 +21,6 @@ export const listScopedEmployees = async () => {
return extractItems<Employee>(data)
}
export const getEmployee = async (id: number) => {
const api = useApi()
return api.get<Employee>(`/employees/${id}`, {}, { toast: false })
}
export const createEmployee = async (payload: {
firstName: string
lastName: string
+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>(
@@ -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);
}
}
@@ -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);
}
);
}
}