feat : modification des notifications + correction de bug

This commit is contained in:
2026-03-10 10:01:36 +01:00
parent 701dd9faf3
commit 53255dba43
25 changed files with 932 additions and 519 deletions

View File

@@ -1,142 +1,233 @@
<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"
>
<header ref="headerRef" 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-6 text-xl text-white">
<div v-if="isAdmin" ref="bellRoot" class="relative">
<button type="button" class="relative self-center cursor-pointer flex items-center" @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>
</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
v-if="isNotificationsOpen"
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
:style="{ top: `${navbarBottom + 20}px` }"
>
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
Notifications
</div>
<div class="flex gap-6 px-3 pb-2 border-b border-black">
<button
type="button"
class="border-b-2 cursor-pointer text-[18px]"
:class="activeNotifTab === 'unread' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
@click="switchNotifTab('unread')"
>
Non lues
</button>
<button
type="button"
class="border-b-2 cursor-pointer text-[18px]"
:class="activeNotifTab === 'history' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
@click="switchNotifTab('history')"
>
Historique
</button>
</div>
<div v-if="isLoadingNotifications" class="px-3 py-3 text-sm text-neutral-500">
Chargement...
</div>
<div v-else-if="displayedNotifications.length === 0" class="px-3 py-3 text-sm text-neutral-500">
Aucune notification.
</div>
<div v-else class="max-h-80 overflow-auto">
<NuxtLink
:to="notification.target"
v-for="notification in displayedNotifications"
:key="notification.id"
class="flex gap-5 items-center border-b border-black px-3 py-4 last:border-b-0 relative hover:bg-tertiary-500"
:class="notification.isRead ? '' : 'bg-tertiary-500'"
>
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
<div class="flex flex-col min-w-0 text-[16px]">
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
</div>
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
</NuxtLink>
</div>
</div>
</div>
<div ref="userMenuRoot" class="relative flex gap-4">
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<p class="self-center">{{ user?.username }}</p>
</button>
<div
v-if="isUserMenuOpen"
class="fixed right-[20px] z-30 w-60 rounded-md border border-neutral-200 bg-white text-[16px] text-black font-semibold shadow-lg"
:style="{ top: `${navbarBottom + 20}px` }"
>
<button
type="button"
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 border-b border-black"
>
Mon profil
</button>
<button
type="button"
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 flex justify-between items-center"
@click="handleLogout"
>
<p>Déconnexion</p>
<Icon name="mdi:logout-variant" size="20"/>
</button>
</div>
</div>
</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>
</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'
import type {User} from '~/services/dto/user'
import type {NotificationItem} from '~/services/dto/notification'
import {listUnreadNotifications, listTodayNotifications, listHistoryNotifications, markAllNotificationsRead} from '~/services/notifications'
defineProps<{
user?: User
user?: User
}>()
const formatTimeAgo = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) return "À l'instant"
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `${diffHours} heure${diffHours > 1 ? 's' : ''}`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays} jour${diffDays > 1 ? 's' : ''}`
}
const auth = useAuthStore()
const route = useRoute()
const headerRef = ref<HTMLElement | null>(null)
const bellRoot = ref<HTMLElement | null>(null)
const notifications = ref<NotificationItem[]>([])
const userMenuRoot = ref<HTMLElement | null>(null)
const isUserMenuOpen = ref(false)
const navbarBottom = ref(0)
const updateNavbarBottom = () => {
if (headerRef.value) {
navbarBottom.value = headerRef.value.getBoundingClientRect().bottom
}
}
const todayNotifications = ref<NotificationItem[]>([])
const historyNotifications = ref<NotificationItem[]>([])
const isNotificationsOpen = ref(false)
const isLoadingNotifications = ref(false)
const unreadCount = computed(() => notifications.value.length)
const activeNotifTab = ref<'unread' | 'history'>('unread')
const unreadCount = computed(() => todayNotifications.value.length)
const displayedNotifications = computed(() => activeNotifTab.value === 'unread' ? todayNotifications.value : historyNotifications.value)
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const toggleUserMenu = () => {
updateNavbarBottom()
isUserMenuOpen.value = !isUserMenuOpen.value
}
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
await auth.logout()
await navigateTo('/login')
}
const loadTodayNotifications = async () => {
todayNotifications.value = await listTodayNotifications()
}
const loadHistoryNotifications = async () => {
historyNotifications.value = await listHistoryNotifications()
}
const loadNotifications = async () => {
isLoadingNotifications.value = true
try {
notifications.value = await listUnreadNotifications()
} finally {
isLoadingNotifications.value = false
}
isLoadingNotifications.value = true
try {
await loadTodayNotifications()
} finally {
isLoadingNotifications.value = false
}
}
const switchNotifTab = async (tab: 'unread' | 'history') => {
activeNotifTab.value = tab
isLoadingNotifications.value = true
try {
if (tab === 'history') {
await loadHistoryNotifications()
} else {
await loadTodayNotifications()
}
} finally {
isLoadingNotifications.value = false
}
}
const closeNotifications = async () => {
if (!isNotificationsOpen.value) return
isNotificationsOpen.value = false
if (notifications.value.length > 0) {
await markAllNotificationsRead()
notifications.value = []
}
if (!isNotificationsOpen.value) return
isNotificationsOpen.value = false
if (todayNotifications.value.length > 0) {
await markAllNotificationsRead()
todayNotifications.value = []
}
}
const toggleNotifications = async () => {
if (isNotificationsOpen.value) {
await closeNotifications()
return
}
if (isNotificationsOpen.value) {
await closeNotifications()
return
}
isNotificationsOpen.value = true
await loadNotifications()
updateNavbarBottom()
activeNotifTab.value = 'unread'
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()
}
const target = event.target as Node | null
if (!target) return
if (bellRoot.value && !bellRoot.value.contains(target)) {
await closeNotifications()
}
if (userMenuRoot.value && !userMenuRoot.value.contains(target)) {
isUserMenuOpen.value = false
}
}
onMounted(async () => {
if (isAdmin.value) {
await loadNotifications()
}
document.addEventListener('click', handleClickOutside)
updateNavbarBottom()
if (isAdmin.value) {
await loadNotifications()
}
document.addEventListener('click', handleClickOutside)
})
watch(
() => route.fullPath,
async () => {
if (!isAdmin.value) return
await loadNotifications()
}
() => route.fullPath,
async () => {
if (!isAdmin.value) return
await loadNotifications()
}
)
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -2,7 +2,7 @@
<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"
class="inline-flex w-[320px] 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>

View File

@@ -39,8 +39,7 @@
:disabled="isCreateContractSubmitting || contracts.length === 0 || !canCreateContract"
@click="onOpenCreateContractDrawer"
>
<Icon name="mdi:plus-thick" size="16" />
Ajouter
+ Ajouter
</button>
</div>

View File

@@ -20,8 +20,7 @@
class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px]"
@click="openFractionedDrawer"
>
<Icon name="mdi:plus-thick" size="16" />
{{ summary?.fractionedDays === 0 ? 'Ajouter' : 'Modifier' }}</button>
{{ summary?.fractionedDays === 0 ? '+ Ajouter' : 'Modifier' }}</button>
</div>
<div class="flex flex-col jutify-center gap-2 items-center py-3">
<p><span class="uppercase font-semibold">En cours d'acquisition :</span></p>

View File

@@ -1,10 +1,9 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5 text-[20px]">
<p><span class="font-semibold uppercase">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
<button class="bg-white rounded-md text-primary-500 font-bold px-6 py-1" @click="openNewPayment">
<Icon name="mdi:plus-thick" size="16"/>
Payer les RTT
<div class="flex gap-10 justify-center items-center bg-primary-500 rounded-md text-white py-5">
<p class="text-[20px]"><span class="font-semibold">RTT à la date du jour :</span> {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
<button class="flex justify-center items-center gap-2 bg-white text-primary-500 font-bold w-[150px] rounded-md py-[1px] text-md" @click="openNewPayment">
+ Payer les RTT
</button>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
@@ -17,7 +16,7 @@
<div class="flex justify-center rounded-t-md bg-primary-500 py-3 font-bold text-white text-[18px]">
{{ month.label }}
</div>
<div class="grid grid-cols-[60%_40%] text-[18px] border border-primary-500">
<div class="grid grid-cols-[70%_30%] text-[18px] border border-primary-500">
<template v-for="week in month.weeks" :key="week.key">
<div class="py-[6px] pl-3 border-r border-b border-primary-500">
<span v-if="week.isEmpty">&nbsp;</span>
@@ -36,7 +35,9 @@
title="Modifier les heures payées"
>
<p>{{ formatMinutes(getMonthPaid25(month.month)) }}</p>
<Icon name="mdi:pencil" size="16" class="self-center"/>
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
</div>
</div>
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
<div class="py-[6px] px-3 flex gap-3 items-center cursor-pointer hover:bg-primary-500/10"
@@ -44,7 +45,9 @@
title="Modifier les heures payées"
>
<p>{{ formatMinutes(getMonthPaid50(month.month)) }}</p>
<Icon name="mdi:pencil" size="16" class="self-center"/>
<div class="flex justify-center items-center bg-primary-500 rounded-md p-1">
<Icon name="mdi:pencil" size="16" class="self-center text-white" />
</div>
</div>
</div>
</div>

View File

@@ -16,7 +16,7 @@
<span class="pl-2">Jour</span>
<span>Nuit</span>
<span>Total</span>
<span v-if="isAdmin" class="inline-flex items-center gap-2">
<span v-if="isAdmin" class="flex justify-between items-center">
<span>Valider</span>
<input
ref="bulkValidationInput"
@@ -152,7 +152,7 @@
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
</div>
<div v-if="isAdmin">
<div v-if="isAdmin" class="text-right">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
@@ -160,13 +160,13 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!canToggleSiteValidation(employee.id) || isSiteValidationPending(employee.id)"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
@@ -207,6 +207,7 @@ const props = defineProps<{
isSiteValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean
canToggleSiteValidation: (employeeId: number) => boolean
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
isBulkSiteValidationChecked: boolean

View File

@@ -1,6 +1,11 @@
<template>
<div class="py-6 flex flex-col gap-3">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div class="flex gap-4">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
@@ -99,10 +104,6 @@
</div>
</div>
<div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
<div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6"