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
36 changed files with 669 additions and 1065 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'
-150
View File
@@ -1,150 +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
- 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")
## 10) Notifications
- Icône cloche en topbar:
- badge = nombre de notifications non lues
- ouverture panneau = liste des non lues
- fermeture panneau = marquage "lu" en masse
### Règle métier de déclenchement
- Les notifications de validation site ne sont pas envoyées ligne par ligne.
- Une notification est créée uniquement quand un chef de site termine la validation complète:
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
- destinataires: utilisateurs `ROLE_ADMIN`
-142
View File
@@ -1,142 +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">
<div v-if="isAdmin" ref="bellRoot" class="relative">
<button type="button" class="relative self-center cursor-pointer" @click="toggleNotifications">
<Icon name="mdi:bell-plus" size="36" />
<span
v-if="unreadCount > 0"
class="absolute -right-1 -top-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount }}
</span>
</button>
<div
v-if="isNotificationsOpen"
class="absolute right-0 top-full z-30 mt-2 w-80 rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
>
<div class="border-b border-neutral-200 px-3 py-2 text-sm font-semibold">
Notifications
</div>
<div v-if="isLoadingNotifications" class="px-3 py-3 text-sm text-neutral-500">
Chargement...
</div>
<div v-else-if="notifications.length === 0" class="px-3 py-3 text-sm text-neutral-500">
Aucune notification.
</div>
<div v-else class="max-h-80 overflow-auto">
<div
v-for="notification in notifications"
:key="notification.id"
class="border-b border-neutral-100 px-3 py-2 last:border-b-0"
>
<p class="text-sm font-semibold text-neutral-900">{{ notification.title }}</p>
<p class="text-xs text-neutral-600">{{ notification.message }}</p>
</div>
</div>
</div>
</div>
<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'
import type { NotificationItem } from '~/services/dto/notification'
import { listUnreadNotifications, markAllNotificationsRead } from '~/services/notifications'
defineProps<{
user?: User
}>()
const auth = useAuthStore()
const route = useRoute()
const bellRoot = ref<HTMLElement | null>(null)
const notifications = ref<NotificationItem[]>([])
const isNotificationsOpen = ref(false)
const isLoadingNotifications = ref(false)
const unreadCount = computed(() => notifications.value.length)
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
const loadNotifications = async () => {
isLoadingNotifications.value = true
try {
notifications.value = await listUnreadNotifications()
} finally {
isLoadingNotifications.value = false
}
}
const closeNotifications = async () => {
if (!isNotificationsOpen.value) return
isNotificationsOpen.value = false
if (notifications.value.length > 0) {
await markAllNotificationsRead()
notifications.value = []
}
}
const toggleNotifications = async () => {
if (isNotificationsOpen.value) {
await closeNotifications()
return
}
isNotificationsOpen.value = true
await loadNotifications()
}
const handleClickOutside = async (event: MouseEvent) => {
const target = event.target as Node | null
if (!target || !bellRoot.value) return
if (!bellRoot.value.contains(target)) {
await closeNotifications()
}
}
onMounted(async () => {
if (isAdmin.value) {
await loadNotifications()
}
document.addEventListener('click', handleClickOutside)
})
watch(
() => route.fullPath,
async () => {
if (!isAdmin.value) return
await loadNotifications()
}
)
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</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,
+10 -6
View File
@@ -62,16 +62,20 @@
</nav>
<div class="flex flex-col gap-2 items-center p-4">
<button
type="button"
class="w-full rounded-lg px-4 py-2 text-md font-semibold text-white bg-primary-500"
@click="handleLogout"
>
Déconnexion
</button>
<p class="font-bold">v{{ version }}</p>
</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 -1
View File
@@ -22,7 +22,7 @@ export default defineNuxtConfig({
devServer: {port: 3001},
toast: {
settings: {
timeout: 2000,
timeout: 10000,
closeOnClick: true,
progressBar: false
}
@@ -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()
-149
View File
@@ -1,149 +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>
<div class="text-right">
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employee.contract?.weeklyHours ?? '-' }} heures</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
<div class="mt-12 border-b border-primary-500">
<div class="flex justify-center gap-16 text-2xl font-bold">
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'contract'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'contract'"
>
<Icon name="mdi:magnify" size="24" class="align-self"/>
Suivi contrat
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'leave'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'leave'"
>
<Icon name="mdi:magnify" size="24" class="align-self"/>
Congé
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'rtt'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'rtt'"
>
<Icon name="mdi:magnify" size="24" class="align-self"/>
RTT
</button>
</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>
</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')
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
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,
-10
View File
@@ -1,15 +1,6 @@
import type { Site } from './site'
import type { Contract } from './contract'
export type ContractHistoryItem = {
contractId?: number | null
contractName?: string | null
weeklyHours?: number | null
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate?: string | null
}
export type Employee = {
id: number
firstName: string
@@ -19,6 +10,5 @@ export type Employee = {
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
contractHistory?: ContractHistoryItem[]
displayOrder?: number
}
-7
View File
@@ -1,7 +0,0 @@
export type NotificationItem = {
id: number
title: string
message: string
isRead: boolean
createdAt: string
}
-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
-18
View File
@@ -1,18 +0,0 @@
import type { NotificationItem } from './dto/notification'
import { extractItems } from '~/utils/api'
export const listUnreadNotifications = async () => {
const api = useApi()
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
'/notifications/unread',
{},
{ toast: false }
)
return extractItems<NotificationItem>(data)
}
export const markAllNotificationsRead = async () => {
const api = useApi()
return api.post('/notifications/mark-all-read', {}, { toast: false })
}
+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>(
-3
View File
@@ -15,9 +15,6 @@ export default <Partial<Config>>{
},
tertiary: {
500: '#F3F4F8'
},
blue: {
500: '#056CF2'
}
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260302110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add notifications table for user notification center';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE notifications (id SERIAL NOT NULL, recipient_id INT NOT NULL, title VARCHAR(120) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_notifications_recipient_read_created ON notifications (recipient_id, is_read, created_at)');
$this->addSql('CREATE INDEX IDX_6000B0D0E92F8F78 ON notifications (recipient_id)');
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D0E92F8F78 FOREIGN KEY (recipient_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D0E92F8F78');
$this->addSql('DROP TABLE notifications');
}
}
@@ -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 = [];
}
-25
View File
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Dto\Employees;
use Symfony\Component\Serializer\Attribute\Groups;
final class ContractHistoryItem
{
public function __construct(
#[Groups(['employee:read'])]
public ?int $contractId,
#[Groups(['employee:read'])]
public ?string $contractName,
#[Groups(['employee:read'])]
public ?float $weeklyHours,
#[Groups(['employee:read'])]
public string $contractNature,
#[Groups(['employee:read'])]
public string $startDate,
#[Groups(['employee:read'])]
public ?string $endDate,
) {}
}
-30
View File
@@ -6,7 +6,6 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Dto\Employees\ContractHistoryItem;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
@@ -205,35 +204,6 @@ class Employee
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
/**
* @return list<ContractHistoryItem>
*/
#[Groups(['employee:read'])]
public function getContractHistory(): array
{
$periods = $this->contractPeriods->toArray();
usort(
$periods,
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $b->getStartDate() <=> $a->getStartDate()
);
return array_map(
static function (EmployeeContractPeriod $period): ContractHistoryItem {
$contract = $period->getContract();
return new ContractHistoryItem(
contractId: $contract?->getId(),
contractName: $contract?->getName(),
weeklyHours: $contract?->getWeeklyHours(),
contractNature: $period->getContractNatureEnum()->value,
startDate: $period->getStartDate()->format('Y-m-d'),
endDate: $period->getEndDate()?->format('Y-m-d'),
);
},
$periods
);
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');
-134
View File
@@ -1,134 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Repository\NotificationRepository;
use App\State\MarkAllNotificationsReadProcessor;
use App\State\UnreadNotificationsProvider;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/notifications/unread',
normalizationContext: ['groups' => ['notification:read']],
security: "is_granted('ROLE_USER')",
provider: UnreadNotificationsProvider::class,
paginationEnabled: false
),
new Post(
uriTemplate: '/notifications/mark-all-read',
security: "is_granted('ROLE_USER')",
input: false,
output: false,
read: false,
processor: MarkAllNotificationsReadProcessor::class
),
]
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Table(name: 'notifications')]
#[ORM\Index(columns: ['recipient_id', 'is_read', 'created_at'], name: 'idx_notifications_recipient_read_created')]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $recipient = null;
#[ORM\Column(type: 'string', length: 120)]
#[Groups(['notification:read'])]
private string $title = '';
#[ORM\Column(type: 'text')]
#[Groups(['notification:read'])]
private string $message = '';
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['notification:read'])]
private bool $isRead = false;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['notification:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getRecipient(): ?User
{
return $this->recipient;
}
public function setRecipient(?User $recipient): self
{
$this->recipient = $recipient;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function getMessage(): string
{
return $this->message;
}
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
}
public function getIsRead(): bool
{
return $this->isRead;
}
public function setIsRead(bool $isRead): self
{
$this->isRead = $isRead;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
+1 -2
View File
@@ -10,7 +10,6 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\UserRepository;
use App\State\CurrentUserProvider;
use App\State\UserPasswordHasherProcessor;
use Doctrine\Common\Collections\ArrayCollection;
@@ -53,7 +52,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
),
]
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Entity]
#[ORM\Table(name: 'users')]
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
final class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
/**
* @return list<Notification>
*/
public function findUnreadByRecipient(User $recipient): array
{
return $this->createQueryBuilder('n')
->andWhere('n.recipient = :recipient')
->andWhere('n.isRead = :isRead')
->setParameter('recipient', $recipient)
->setParameter('isRead', false)
->orderBy('n.createdAt', 'DESC')
->setMaxResults(50)
->getQuery()
->getResult()
;
}
public function markAllReadByRecipient(User $recipient): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', ':isRead')
->andWhere('n.recipient = :recipient')
->andWhere('n.isRead = :current')
->setParameter('isRead', true)
->setParameter('current', false)
->setParameter('recipient', $recipient)
->getQuery()
->execute()
;
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
final class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* @return list<User>
*/
public function findAllAdmins(): array
{
/** @var list<User> $users */
$users = $this->createQueryBuilder('u')
->orderBy('u.id', 'ASC')
->getQuery()
->getResult()
;
return array_values(array_filter(
$users,
static fn (User $user): bool => in_array('ROLE_ADMIN', $user->getRoles(), true)
));
}
}
-19
View File
@@ -137,23 +137,4 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
// @var null|WorkHour $workHour
return $qb->getQuery()->getOneOrNullResult();
}
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
{
$workDate = DateTimeImmutable::createFromInterface($date);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->leftJoin('w.employee', 'e')
->leftJoin('e.site', 's')
->andWhere('s.id = :siteId')
->andWhere('w.workDate = :workDate')
->andWhere('w.isSiteValid = :isSiteValid')
->setParameter('siteId', $siteId)
->setParameter('workDate', $workDate)
->setParameter('isSiteValid', false)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
}
@@ -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);
}
}
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class MarkAllNotificationsReadProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$this->notificationRepository->markAllReadByRecipient($user);
return null;
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class UnreadNotificationsProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
return $this->notificationRepository->findUnreadByRecipient($user);
}
}
@@ -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);
}
);
}
}
@@ -6,11 +6,8 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Notification;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Repository\UserRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -21,8 +18,6 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
private WorkHourRepository $workHourRepository,
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
) {}
@@ -52,38 +47,8 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
throw new AccessDeniedHttpException('Employee is outside your site scope.');
}
$uow = $this->entityManager->getUnitOfWork();
$uow->computeChangeSets();
$changeSet = $uow->getEntityChangeSet($data);
$isSiteValidationChangedToTrue = isset($changeSet['isSiteValid'])
&& false === $changeSet['isSiteValid'][0]
&& true === $changeSet['isSiteValid'][1];
$this->entityManager->flush();
// Notification uniquement quand la dernière ligne du site est validée pour la date.
if ($isSiteValidationChangedToTrue) {
$workDate = $data->getWorkDate();
$hasPending = $this->workHourRepository->hasPendingSiteValidationForSiteAndDate($siteId, $workDate);
if (!$hasPending) {
$siteName = $data->getEmployee()?->getSite()?->getName() ?? 'Site';
$dateLabel = $workDate->format('d/m/Y');
$title = sprintf('%s validé', $siteName);
$message = sprintf('Le site %s a terminé la validation du %s.', $siteName, $dateLabel);
foreach ($this->userRepository->findAllAdmins() as $admin) {
$notification = new Notification()
->setRecipient($admin)
->setTitle($title)
->setMessage($message)
;
$this->entityManager->persist($notification);
}
$this->entityManager->flush();
}
}
return $data;
}
}