Files
Lesstime/frontend/components/notification/NotificationBell.vue
T
Matthieu 0cce586a1f feat(client-portal) : phase 3 — ticket notifications
LST-69 (3.2) phase 3. Wires the existing notification system to client-ticket
events (the bell/useNotifications/endpoints already existed).

- Notification.relatedTicket (ManyToOne ClientTicketInterface, SET NULL) +
  additive migration + notification:read group.
- NotifierInterface::notify() gains a backward-compatible optional
  relatedTicket param (existing callers unchanged).
- ClientTicketNumberProcessor (POST): notifies all ROLE_ADMIN users
  (ticket_created), tolerant try/catch after flush. ClientTicketStatusProcessor
  (PATCH): notifies submittedBy on status change (ticket_status_changed).
- Front: notification DTO relatedTicket; NotificationBell navigates to /admin
  (admin) or /portal (client) on ticket notifications.

180 tests green (178 + 2), nuxt build passes, cs-fixer clean.
2026-06-21 01:15:05 +02:00

174 lines
5.9 KiB
Vue

<template>
<div ref="bellRef" class="relative">
<div class="relative">
<MalioButtonIcon
icon="mdi:bell-outline"
aria-label="Notifications"
variant="ghost"
icon-size="24"
button-class="text-white hover:bg-primary-600"
@click="toggleDropdown"
/>
<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 pointer-events-none"
>
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</div>
<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()
}
}
const auth = useAuthStore()
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
function handleClick(notif: Notification) {
if (!notif.isRead) {
markAsRead(notif.id)
}
isOpen.value = false
// Deep-link to the related ticket when present. The notification payload does
// not carry the ticket's project, so we route to the relevant list view:
// admins to the client-tickets admin tab, clients to their portal.
if (notif.relatedTicket) {
navigateTo(isAdmin.value ? '/admin' : '/portal')
}
}
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>