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:
2026-03-11 23:33:10 +00:00
commit 35545c2a8f
1517 changed files with 246774 additions and 0 deletions

View 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>