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>
310 lines
11 KiB
Vue
310 lines
11 KiB
Vue
<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 (Jan–Mar)' },
|
||
{ value: '2', label: 'T2 (Avr–Juin)' },
|
||
{ value: '3', label: 'T3 (Juil–Sep)' },
|
||
{ value: '4', label: 'T4 (Oct–Dé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>
|