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>
147 lines
5.0 KiB
Vue
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>
|