172 lines
5.7 KiB
Vue
172 lines
5.7 KiB
Vue
<template>
|
|
<div ref="bellRef" class="relative">
|
|
<button
|
|
type="button"
|
|
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
|
@click="toggleDropdown"
|
|
>
|
|
<Icon name="mdi:bell-outline" size="24" />
|
|
<span
|
|
v-if="unreadCount > 0"
|
|
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
|
>
|
|
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
|
</span>
|
|
</button>
|
|
|
|
<Transition name="dropdown">
|
|
<div
|
|
v-if="isOpen"
|
|
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
|
|
>
|
|
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
|
<h3 class="text-sm font-semibold text-neutral-800">
|
|
{{ $t('notification.title') }}
|
|
</h3>
|
|
<button
|
|
v-if="unreadCount > 0"
|
|
type="button"
|
|
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
|
|
@click="handleMarkAllRead"
|
|
>
|
|
{{ $t('notification.markAllRead') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="max-h-96 overflow-y-auto">
|
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
|
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
|
|
</div>
|
|
|
|
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
|
|
{{ $t('notification.empty') }}
|
|
</div>
|
|
|
|
<template v-else>
|
|
<button
|
|
v-for="notif in notifications"
|
|
:key="notif.id"
|
|
type="button"
|
|
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
|
|
:class="{ 'bg-primary-50': !notif.isRead }"
|
|
@click="handleClick(notif)"
|
|
>
|
|
<div
|
|
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
|
|
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-neutral-800 truncate">
|
|
{{ notif.title }}
|
|
</p>
|
|
<p class="mt-0.5 text-xs text-neutral-500 truncate">
|
|
{{ notif.message }}
|
|
</p>
|
|
<p class="mt-1 text-xs text-neutral-400">
|
|
{{ formatRelativeDate(notif.createdAt) }}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Notification } from '~/services/dto/notification'
|
|
import { useNotifications } from '~/composables/useNotifications'
|
|
|
|
const {
|
|
unreadCount,
|
|
notifications,
|
|
isLoading,
|
|
fetchNotifications,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
startPolling,
|
|
stopPolling,
|
|
} = useNotifications()
|
|
|
|
const bellRef = ref<HTMLElement>()
|
|
const isOpen = ref(false)
|
|
|
|
function toggleDropdown() {
|
|
isOpen.value = !isOpen.value
|
|
if (isOpen.value) {
|
|
fetchNotifications()
|
|
}
|
|
}
|
|
|
|
function handleClick(notif: Notification) {
|
|
if (!notif.isRead) {
|
|
markAsRead(notif.id)
|
|
}
|
|
|
|
if (notif.relatedTicket) {
|
|
const auth = useAuthStore()
|
|
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
|
|
|
if (isClient) {
|
|
navigateTo(`/portal`)
|
|
} else {
|
|
navigateTo(`/admin?tab=tickets`)
|
|
}
|
|
|
|
isOpen.value = false
|
|
}
|
|
}
|
|
|
|
async function handleMarkAllRead() {
|
|
await markAllAsRead()
|
|
}
|
|
|
|
const { t } = useI18n()
|
|
|
|
function formatRelativeDate(dateStr: string): string {
|
|
const date = new Date(dateStr)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMin = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMin / 60)
|
|
const diffDays = Math.floor(diffHours / 24)
|
|
|
|
if (diffMin < 1) return t('notification.timeAgo.now')
|
|
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
|
|
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
|
|
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
|
|
|
|
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
|
}
|
|
|
|
// Close dropdown when clicking outside
|
|
function onClickOutside(event: MouseEvent) {
|
|
if (!bellRef.value?.contains(event.target as Node)) {
|
|
isOpen.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
startPolling()
|
|
document.addEventListener('click', onClickOutside)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopPolling()
|
|
document.removeEventListener('click', onClickOutside)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dropdown-enter-active,
|
|
.dropdown-leave-active {
|
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
}
|
|
.dropdown-enter-from,
|
|
.dropdown-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
</style>
|