feat: complete Epic 0 — foundation migration & infrastructure setup

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>
This commit is contained in:
2026-03-12 18:25:32 +00:00
parent d380df4074
commit fd43a6f429
105 changed files with 3899 additions and 1558 deletions

View File

@@ -1,6 +1,14 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { BookOpen, Briefcase, Building2, Folder, HelpCircle, LayoutGrid, Users } from 'lucide-vue-next';
import {
BookOpen,
Briefcase,
Building2,
FileStack,
HelpCircle,
LayoutGrid,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
@@ -14,9 +22,9 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import type { NavItem } from '@/types';
import AppLogo from './AppLogo.vue';
import { dashboard } from '@/routes';
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
const page = usePage();
@@ -36,9 +44,9 @@ const mainNavItems = computed<NavItem[]>(() => {
icon: Briefcase,
},
{
title: 'Dossiers',
href: '/folders',
icon: Folder,
title: 'Déclarations',
href: '/declarations',
icon: FileStack,
},
);
}
@@ -90,9 +98,16 @@ const footerNavItems: NavItem[] = [
<SidebarContent>
<NavMain :items="mainNavItems" />
<template
v-if="['admin', 'superadmin'].includes(String($page.props.auth.user?.group ?? ''))"
v-if="
['admin', 'superadmin'].includes(
String($page.props.auth.user?.group ?? ''),
)
"
>
<NavMain :items="administrationNavItems" label="Administration" />
<NavMain
:items="administrationNavItems"
label="Administration"
/>
</template>
</SidebarContent>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Building2, Plus, Settings, Trash2, User } from 'lucide-vue-next';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
@@ -19,7 +20,6 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Spinner } from '@/components/ui/spinner';
import { Building2, Plus, Settings, Trash2, User } from 'lucide-vue-next';
export type ClientContactData = {
id?: number;
@@ -180,9 +180,7 @@ const inputClass =
<InputError :message="form.errors.ice" />
</div>
<div class="space-y-2">
<Label for="fiscal_id"
>IF (Identifiant Fiscal)</Label
>
<Label for="fiscal_id">IF (Identifiant Fiscal)</Label>
<Input
id="fiscal_id"
v-model="form.fiscal_id"
@@ -241,7 +239,7 @@ const inputClass =
Responsables
</CardTitle>
<CardDescription>
Personnes à contacter pour les échanges et dossiers
Personnes à contacter pour les échanges et déclarations
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
@@ -288,16 +286,12 @@ const inputClass =
placeholder="Prénom Nom"
:class="inputClass"
:aria-invalid="
!!form.errors[
`contacts.${index}.full_name`
]
!!form.errors[`contacts.${index}.full_name`]
"
/>
<InputError
:message="
form.errors[
`contacts.${index}.full_name`
]
form.errors[`contacts.${index}.full_name`]
"
/>
</div>
@@ -314,9 +308,7 @@ const inputClass =
/>
</div>
<div class="space-y-2">
<Label :for="`contact_email_${index}`"
>Email</Label
>
<Label :for="`contact_email_${index}`">Email</Label>
<Input
:id="`contact_email_${index}`"
v-model="contact.email"

View File

@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
export type FolderFormData = {
export type DeclarationFormData = {
client_id: number | '';
title: string;
type: string;
@@ -34,10 +34,10 @@ type WorkspaceUser = {
};
type Props = {
form: Form<FolderFormData>;
folderTypeLabels: Record<string, string>;
folderStatusLabels: Record<string, string>;
folderPriorityLabels: Record<string, string>;
form: Form<DeclarationFormData>;
declarationTypeLabels: Record<string, string>;
declarationStatusLabels: Record<string, string>;
declarationPriorityLabels: Record<string, string>;
clients: Client[];
workspaceUsers: WorkspaceUser[];
submitLabel?: string;
@@ -89,7 +89,7 @@ watch(
id="client_id"
v-model="form.client_id"
required
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.client_id"
>
<option value="" disabled>Sélectionner un client</option>
@@ -123,12 +123,12 @@ watch(
id="type"
v-model="form.type"
required
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.type"
>
<option value="" disabled>Sélectionner un type</option>
<option
v-for="(label, value) in folderTypeLabels"
v-for="(label, value) in declarationTypeLabels"
:key="value"
:value="value"
>
@@ -145,7 +145,7 @@ watch(
id="period_year"
v-model="form.period_year"
required
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.period_year"
>
<option v-for="y in years" :key="y" :value="y">
@@ -160,7 +160,7 @@ watch(
<select
id="period_month"
v-model="form.period_month"
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.period_month"
>
<option
@@ -180,7 +180,7 @@ watch(
<select
id="period_quarter"
v-model="form.period_quarter"
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.period_quarter"
>
<option
@@ -212,12 +212,12 @@ watch(
<select
id="status"
v-model="form.status"
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.status"
>
<option value="" disabled>Sélectionner un statut</option>
<option
v-for="(label, value) in folderStatusLabels"
v-for="(label, value) in declarationStatusLabels"
:key="value"
:value="value"
>
@@ -234,12 +234,12 @@ watch(
<select
id="priority"
v-model="form.priority"
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.priority"
>
<option value=""></option>
<option
v-for="(label, value) in folderPriorityLabels"
v-for="(label, value) in declarationPriorityLabels"
:key="value"
:value="value"
>
@@ -253,7 +253,7 @@ watch(
<select
id="assigned_to"
v-model="form.assigned_to"
class="border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
:aria-invalid="!!form.errors.assigned_to"
>
<option :value="''"></option>
@@ -275,7 +275,7 @@ watch(
id="notes_internal"
v-model="form.notes_internal"
rows="3"
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Notes confidentielles"
:aria-invalid="!!form.errors.notes_internal"
/>
@@ -288,7 +288,7 @@ watch(
id="notes_client"
v-model="form.notes_client"
rows="3"
class="border-input bg-background w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Notes partagées avec le client"
:aria-invalid="!!form.errors.notes_client"
/>
@@ -299,7 +299,7 @@ watch(
<Button
type="submit"
:disabled="form.processing"
data-test="folder-form-submit"
data-test="declaration-form-submit"
>
<Spinner v-if="form.processing" />
{{ submitLabel }}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import { Bell } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -16,8 +16,8 @@ type NotificationItem = {
id: string;
type: string;
data: {
folder_id?: number;
folder_title?: string;
declaration_id?: number;
declaration_title?: string;
mentioned_by_name?: string;
message?: string;
url?: string;
@@ -36,7 +36,8 @@ type UserNotifications = {
const page = usePage();
const userNotifications = computed<UserNotifications>(() => {
return (page.props as Record<string, unknown>).userNotifications as UserNotifications;
return (page.props as Record<string, unknown>)
.userNotifications as UserNotifications;
});
const unreadCount = computed(() => userNotifications.value?.unread_count ?? 0);
@@ -63,7 +64,7 @@ function navigateToNotification(notification: NotificationItem) {
router.visit(targetUrl, {
onError: () => {
// Folder may have been deleted — mark as read anyway
// Declaration may have been deleted — mark as read anyway
if (!notification.read_at) {
markAsRead(notification);
}
@@ -86,7 +87,7 @@ function markAllAsRead() {
<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"
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>
@@ -96,11 +97,17 @@ function markAllAsRead() {
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
<div v-if="isLoading" class="px-2 py-4 text-center text-sm text-muted-foreground">
<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">
<div
v-else-if="!items.length"
class="px-2 py-4 text-center text-sm text-muted-foreground"
>
Aucune notification.
</div>
@@ -114,17 +121,27 @@ function markAllAsRead() {
>
<div class="flex w-full items-center justify-between gap-2">
<span class="text-xs font-medium">
{{ notification.data?.mentioned_by_name ?? 'Système' }}
{{
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
v-if="notification.data?.declaration_title"
class="font-medium text-foreground"
>
{{ notification.data.declaration_title }}
</span>
{{ notification.data?.message ? ` ${notification.data.message}` : '' }}
{{
notification.data?.message
? `${notification.data.message}`
: ''
}}
</p>
<span
v-if="!notification.read_at"

View File

@@ -1,42 +1,53 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
type Folder = {
type Declaration = {
id: number;
due_date: string | null;
};
type Props = {
folders: Folder[];
declarations: Declaration[];
};
const props = defineProps<Props>();
const monthNames = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre',
];
const dayNames = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
const current = ref(new Date());
const monthLabel = computed(() =>
`${monthNames[current.value.getMonth()]} ${current.value.getFullYear()}`,
const monthLabel = computed(
() =>
`${monthNames[current.value.getMonth()]} ${current.value.getFullYear()}`,
);
const datesWithFolders = computed(() => {
const datesWithDeclarations = computed(() => {
const set = new Set<string>();
props.folders.forEach((f) => {
props.declarations.forEach((f) => {
if (f.due_date) set.add(f.due_date);
});
return set;
});
const foldersByDate = computed(() => {
const declarationsByDate = computed(() => {
const map = new Map<string, number>();
props.folders.forEach((f) => {
props.declarations.forEach((f) => {
if (f.due_date) {
map.set(f.due_date, (map.get(f.due_date) ?? 0) + 1);
}
@@ -52,7 +63,11 @@ const calendarDays = computed(() => {
const startDay = (first.getDay() + 6) % 7;
const daysInMonth = last.getDate();
const days: Array<{ date: Date | null; dateStr: string | null; count: number }> = [];
const days: Array<{
date: Date | null;
dateStr: string | null;
count: number;
}> = [];
for (let i = 0; i < startDay; i++) {
days.push({ date: null, dateStr: null, count: 0 });
@@ -63,18 +78,24 @@ const calendarDays = computed(() => {
days.push({
date,
dateStr,
count: foldersByDate.value.get(dateStr) ?? 0,
count: declarationsByDate.value.get(dateStr) ?? 0,
});
}
return days;
});
function prevMonth() {
current.value = new Date(current.value.getFullYear(), current.value.getMonth() - 1);
current.value = new Date(
current.value.getFullYear(),
current.value.getMonth() - 1,
);
}
function nextMonth() {
current.value = new Date(current.value.getFullYear(), current.value.getMonth() + 1);
current.value = new Date(
current.value.getFullYear(),
current.value.getMonth() + 1,
);
}
</script>
@@ -111,11 +132,8 @@ function nextMonth() {
>
<template v-if="cell.date">
{{ cell.date.getDate() }}
<span
v-if="cell.count > 0"
class="mt-0.5 text-[10px]"
>
{{ cell.count }} dossier{{ cell.count > 1 ? 's' : '' }}
<span v-if="cell.count > 0" class="mt-0.5 text-[10px]">
{{ cell.count }} décl.
</span>
</template>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button';
import { CheckCircle2, Download, FileText } from 'lucide-vue-next';
import { Button } from '@/components/ui/button';
type Props = {
message: {
@@ -27,13 +27,19 @@ const props = defineProps<Props>();
const typeColors: Record<string, string> = {
invite: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
situation: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
file_request: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
confirmation: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
situation:
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
file_request:
'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
confirmation:
'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
text: 'bg-slate-100 text-slate-700 dark:bg-slate-800/50 dark:text-slate-300',
};
const confirmationStatusLabels: Record<string, { label: string; class: string }> = {
const confirmationStatusLabels: Record<
string,
{ label: string; class: string }
> = {
pending: {
label: 'En attente',
class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
@@ -80,12 +86,18 @@ function getTypeColor(type: string): string {
<span
v-if="message.confirmation_status"
class="inline-flex rounded px-2 py-0.5 text-xs font-medium"
:class="confirmationStatusLabels[message.confirmation_status]?.class ?? ''"
:class="
confirmationStatusLabels[message.confirmation_status]
?.class ?? ''
"
>
{{ confirmationStatusLabels[message.confirmation_status]?.label ?? message.confirmation_status }}
{{
confirmationStatusLabels[message.confirmation_status]
?.label ?? message.confirmation_status
}}
</span>
</div>
<p class="mt-2 whitespace-pre-wrap text-sm">{{ message.body }}</p>
<p class="mt-2 text-sm whitespace-pre-wrap">{{ message.body }}</p>
<div
v-if="message.attachments?.length"
class="mt-3 flex flex-wrap gap-2"
@@ -95,31 +107,30 @@ function getTypeColor(type: string): string {
:key="att.id"
class="flex items-center gap-2 rounded-lg border border-sidebar-border/70 bg-background/50 p-2"
>
<div class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted">
<div
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded bg-muted"
>
<img
v-if="isImageMime(att.mime_type)"
:src="att.downloadUrl"
:alt="att.file_name"
class="size-10 object-cover"
/>
<FileText
v-else
class="size-5 text-muted-foreground"
/>
<FileText v-else class="size-5 text-muted-foreground" />
</div>
<div class="min-w-0 flex-1">
<p class="inline-flex items-center gap-1.5 truncate text-xs font-medium">
<p
class="inline-flex items-center gap-1.5 truncate text-xs font-medium"
>
{{ att.file_name }}
<CheckCircle2 v-if="att.is_downloaded" class="size-3.5 text-green-500" />
<CheckCircle2
v-if="att.is_downloaded"
class="size-3.5 text-green-500"
/>
</p>
<p class="text-xs text-muted-foreground">{{ att.size }}</p>
</div>
<Button
variant="ghost"
size="sm"
as-child
class="shrink-0"
>
<Button variant="ghost" size="sm" as-child class="shrink-0">
<a
:href="att.downloadUrl"
target="_blank"