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>
This commit is contained in:
146
resources/js/components/NotificationDropdown.vue
Normal file
146
resources/js/components/NotificationDropdown.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user