Files
L-Ami-Fiduciaire/resources/js/components/NotificationDropdown.vue
Saad Ibn-Ezzoubayr 35545c2a8f feat: L'Ami Fiduciaire V1.0.0 — full codebase with Story 0.1 complete
Initial commit of the L'Ami Fiduciaire SaaS platform built on Laravel 12,
Vue 3, Inertia.js 2, and Tailwind CSS 4.

Story 0.1 (rename folders to declarations in database) is implemented and
code-reviewed: migration, rollback, and 6 Pest tests all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:33:10 +00:00

147 lines
5.0 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import { Bell } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
type NotificationItem = {
id: string;
type: string;
data: {
folder_id?: number;
folder_title?: string;
mentioned_by_name?: string;
message?: string;
url?: string;
};
read_at: string | null;
created_at: string;
};
type UserNotifications = {
unread_count: number;
readUrl: string | null;
readAllUrl: string | null;
items: NotificationItem[] | undefined;
};
const page = usePage();
const userNotifications = computed<UserNotifications>(() => {
return (page.props as Record<string, unknown>).userNotifications as UserNotifications;
});
const unreadCount = computed(() => userNotifications.value?.unread_count ?? 0);
const items = computed(() => userNotifications.value?.items ?? []);
const isLoading = computed(() => userNotifications.value?.items === undefined);
function markAsRead(notification: NotificationItem) {
const readUrl = userNotifications.value?.readUrl;
if (!readUrl) return;
// Replace __ID__ placeholder with actual notification ID
// This is a convention: the server provides a URL template with __ID__ as placeholder
const url = readUrl.replace('__ID__', notification.id);
router.post(url, {}, { preserveScroll: true });
}
function navigateToNotification(notification: NotificationItem) {
const targetUrl = notification.data?.url;
if (!targetUrl) return;
if (!notification.read_at) {
markAsRead(notification);
}
router.visit(targetUrl, {
onError: () => {
// Folder may have been deleted — mark as read anyway
if (!notification.read_at) {
markAsRead(notification);
}
},
});
}
function markAllAsRead() {
const readAllUrl = userNotifications.value?.readAllUrl;
if (!readAllUrl) return;
router.post(readAllUrl, {}, { preserveScroll: true });
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="relative">
<Bell class="size-4" />
<span
v-if="unreadCount > 0"
class="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-80">
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
<div v-if="isLoading" class="px-2 py-4 text-center text-sm text-muted-foreground">
Chargement...
</div>
<div v-else-if="!items.length" class="px-2 py-4 text-center text-sm text-muted-foreground">
Aucune notification.
</div>
<template v-else>
<DropdownMenuItem
v-for="notification in items"
:key="notification.id"
class="flex cursor-pointer flex-col items-start gap-1 p-3"
:class="{ 'opacity-50': notification.read_at }"
@click="navigateToNotification(notification)"
>
<div class="flex w-full items-center justify-between gap-2">
<span class="text-xs font-medium">
{{ notification.data?.mentioned_by_name ?? 'Système' }}
</span>
<span class="text-xs text-muted-foreground">
{{ notification.created_at }}
</span>
</div>
<p class="text-xs text-muted-foreground">
<span v-if="notification.data?.folder_title" class="font-medium text-foreground">
{{ notification.data.folder_title }}
</span>
{{ notification.data?.message ? ` ${notification.data.message}` : '' }}
</p>
<span
v-if="!notification.read_at"
class="size-1.5 rounded-full bg-primary"
/>
</DropdownMenuItem>
</template>
<DropdownMenuSeparator v-if="items.length > 0" />
<DropdownMenuItem
v-if="unreadCount > 0"
class="justify-center text-xs text-muted-foreground"
@click="markAllAsRead"
>
Marquer tout comme lu
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>