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

@@ -0,0 +1,309 @@
<script setup lang="ts">
import type { Form } from '@inertiajs/vue3';
import { watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
export type DeclarationFormData = {
client_id: number | '';
title: string;
type: string;
period_year: number | string;
period_month: number | string;
period_quarter: number | string;
due_date: string;
status: string;
priority: string;
assigned_to: number | '';
notes_internal: string;
notes_client: string;
};
type Client = {
id: number;
company_name: string;
};
type WorkspaceUser = {
id: number;
name: string;
email: string;
};
type Props = {
form: Form<DeclarationFormData>;
declarationTypeLabels: Record<string, string>;
declarationStatusLabels: Record<string, string>;
declarationPriorityLabels: Record<string, string>;
clients: Client[];
workspaceUsers: WorkspaceUser[];
submitLabel?: string;
};
const props = withDefaults(defineProps<Props>(), {
submitLabel: 'Enregistrer',
});
const emit = defineEmits<{
submit: [];
}>();
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 10 }, (_, i) => currentYear - 2 + i);
const months = [
{ value: '', label: '—' },
...Array.from({ length: 12 }, (_, i) => ({
value: (i + 1).toString(),
label: `${i + 1}`,
})),
];
const quarters = [
{ value: '', label: '—' },
{ value: '1', label: 'T1 (JanMar)' },
{ value: '2', label: 'T2 (AvrJuin)' },
{ value: '3', label: 'T3 (JuilSep)' },
{ value: '4', label: 'T4 (OctDéc)' },
];
const isVatMonthly = () => props.form.type === 'vat_monthly';
const isVatQuarterly = () => props.form.type === 'vat_quarterly';
// Clear both period fields when type changes
watch(
() => props.form.type,
() => {
props.form.period_month = '';
props.form.period_quarter = '';
},
);
</script>
<template>
<form @submit.prevent="emit('submit')" class="flex flex-col space-y-6">
<div class="grid gap-2">
<Label for="client_id">Client</Label>
<select
id="client_id"
v-model="form.client_id"
required
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>
<option
v-for="client in clients"
:key="client.id"
:value="client.id"
>
{{ client.company_name }}
</option>
</select>
<InputError :message="form.errors.client_id" />
</div>
<div class="grid gap-2">
<Label for="title">Titre</Label>
<Input
id="title"
v-model="form.title"
type="text"
required
placeholder="Ex. Déclaration TVA - T1 2026"
aria-invalid="!!form.errors.title"
/>
<InputError :message="form.errors.title" />
</div>
<div class="grid gap-2">
<Label for="type">Type</Label>
<select
id="type"
v-model="form.type"
required
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 declarationTypeLabels"
:key="value"
:value="value"
>
{{ label }}
</option>
</select>
<InputError :message="form.errors.type" />
</div>
<div class="grid gap-2 sm:grid-cols-3">
<div class="grid gap-2">
<Label for="period_year">Année</Label>
<select
id="period_year"
v-model="form.period_year"
required
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">
{{ y }}
</option>
</select>
<InputError :message="form.errors.period_year" />
</div>
<template v-if="isVatMonthly()">
<div class="grid gap-2">
<Label for="period_month">Mois</Label>
<select
id="period_month"
v-model="form.period_month"
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
v-for="m in months"
:key="m.value"
:value="m.value"
>
{{ m.label }}
</option>
</select>
<InputError :message="form.errors.period_month" />
</div>
</template>
<template v-if="isVatQuarterly()">
<div class="grid gap-2">
<Label for="period_quarter">Trimestre</Label>
<select
id="period_quarter"
v-model="form.period_quarter"
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
v-for="q in quarters"
:key="q.value"
:value="q.value"
>
{{ q.label }}
</option>
</select>
<InputError :message="form.errors.period_quarter" />
</div>
</template>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div class="grid gap-2">
<Label for="due_date">Date limite</Label>
<Input
id="due_date"
v-model="form.due_date"
type="date"
aria-invalid="!!form.errors.due_date"
/>
<InputError :message="form.errors.due_date" />
</div>
<div class="grid gap-2">
<Label for="status">Statut</Label>
<select
id="status"
v-model="form.status"
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 declarationStatusLabels"
:key="value"
:value="value"
>
{{ label }}
</option>
</select>
<InputError :message="form.errors.status" />
</div>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<div class="grid gap-2">
<Label for="priority">Priorité</Label>
<select
id="priority"
v-model="form.priority"
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 declarationPriorityLabels"
:key="value"
:value="value"
>
{{ label }}
</option>
</select>
<InputError :message="form.errors.priority" />
</div>
<div class="grid gap-2">
<Label for="assigned_to">Assigné à</Label>
<select
id="assigned_to"
v-model="form.assigned_to"
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>
<option
v-for="user in workspaceUsers"
:key="user.id"
:value="user.id"
>
{{ user.name }} ({{ user.email }})
</option>
</select>
<InputError :message="form.errors.assigned_to" />
</div>
</div>
<div class="grid gap-2">
<Label for="notes_internal">Notes internes</Label>
<textarea
id="notes_internal"
v-model="form.notes_internal"
rows="3"
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"
/>
<InputError :message="form.errors.notes_internal" />
</div>
<div class="grid gap-2">
<Label for="notes_client">Notes client</Label>
<textarea
id="notes_client"
v-model="form.notes_client"
rows="3"
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"
/>
<InputError :message="form.errors.notes_client" />
</div>
<div class="flex items-center gap-4">
<Button
type="submit"
:disabled="form.processing"
data-test="declaration-form-submit"
>
<Spinner v-if="form.processing" />
{{ submitLabel }}
</Button>
</div>
</form>
</template>