Stories 0.2-0.5: rename folders→declarations (backend+frontend), configure Redis for cache/queue/sessions, add foundation database migrations (permissions, archived_at), replace DeclarationStatus enum with architecture lifecycle values, create DeclarationObserver for status transition validation and auto-archive, fix controller status transitions to respect observer rules. 93 tests pass (240 assertions). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
164 lines
5.4 KiB
Vue
164 lines
5.4 KiB
Vue
<script setup lang="ts">
|
|
import { router, usePage } from '@inertiajs/vue3';
|
|
import { Bell } from 'lucide-vue-next';
|
|
import { computed } from 'vue';
|
|
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: {
|
|
declaration_id?: number;
|
|
declaration_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: () => {
|
|
// Declaration 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 -top-0.5 -right-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?.declaration_title"
|
|
class="font-medium text-foreground"
|
|
>
|
|
{{ notification.data.declaration_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>
|