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>
437 lines
17 KiB
Vue
437 lines
17 KiB
Vue
<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';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
|
|
export type ClientContactData = {
|
|
id?: number;
|
|
full_name: string;
|
|
job_title: string;
|
|
email: string;
|
|
phone: string;
|
|
is_principal: boolean;
|
|
};
|
|
|
|
export type ClientFormData = {
|
|
company_name: string;
|
|
legal_form: string;
|
|
ice: string;
|
|
fiscal_id: string;
|
|
rc: string;
|
|
cnss: string;
|
|
patente: string;
|
|
contacts: ClientContactData[];
|
|
internal_responsible_id: number | string | '';
|
|
status: string;
|
|
internal_notes: string;
|
|
};
|
|
|
|
type WorkspaceUser = {
|
|
id: number;
|
|
name: string;
|
|
email: string;
|
|
};
|
|
|
|
type Props = {
|
|
form: ClientFormData & {
|
|
processing: boolean;
|
|
errors: Record<string, string>;
|
|
};
|
|
legalForms: Record<string, string>;
|
|
clientStatusLabels?: Record<string, string>;
|
|
workspaceUsers?: WorkspaceUser[];
|
|
submitLabel?: string;
|
|
};
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
submitLabel: 'Enregistrer',
|
|
clientStatusLabels: () => ({}),
|
|
workspaceUsers: () => [],
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
submit: [];
|
|
}>();
|
|
|
|
const internalResponsibleSelect = computed({
|
|
get: () =>
|
|
props.form.internal_responsible_id === '' ||
|
|
props.form.internal_responsible_id == null
|
|
? '__none__'
|
|
: String(props.form.internal_responsible_id),
|
|
set: (v: string) => {
|
|
props.form.internal_responsible_id = v === '__none__' ? '' : v;
|
|
},
|
|
});
|
|
|
|
function addContact() {
|
|
props.form.contacts.push({
|
|
full_name: '',
|
|
job_title: '',
|
|
email: '',
|
|
phone: '',
|
|
is_principal: props.form.contacts.length === 0,
|
|
});
|
|
}
|
|
|
|
function removeContact(index: number) {
|
|
const wasPrincipal = props.form.contacts[index].is_principal;
|
|
props.form.contacts.splice(index, 1);
|
|
if (wasPrincipal && props.form.contacts.length > 0) {
|
|
props.form.contacts[0].is_principal = true;
|
|
}
|
|
}
|
|
|
|
function setPrincipal(index: number) {
|
|
props.form.contacts.forEach((c, i) => {
|
|
c.is_principal = i === index;
|
|
});
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (props.form.internal_responsible_id === '__none__') {
|
|
props.form.internal_responsible_id = '';
|
|
}
|
|
emit('submit');
|
|
}
|
|
|
|
const inputClass =
|
|
'h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
|
|
</script>
|
|
|
|
<template>
|
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
|
<!-- Société -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2 text-base">
|
|
<Building2 class="size-4" />
|
|
Informations société
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Identité légale et données fiscales de l'entreprise
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="space-y-2 sm:col-span-2">
|
|
<Label for="company_name">Raison sociale</Label>
|
|
<Input
|
|
id="company_name"
|
|
v-model="form.company_name"
|
|
type="text"
|
|
required
|
|
placeholder="Nom officiel de l'entreprise"
|
|
:class="inputClass"
|
|
:aria-invalid="!!form.errors.company_name"
|
|
/>
|
|
<InputError :message="form.errors.company_name" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="legal_form">Forme juridique</Label>
|
|
<Select
|
|
v-model="form.legal_form"
|
|
required
|
|
:aria-invalid="!!form.errors.legal_form"
|
|
>
|
|
<SelectTrigger :class="['w-full', inputClass]">
|
|
<SelectValue placeholder="Sélectionner..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem
|
|
v-for="(label, value) in legalForms"
|
|
:key="value"
|
|
:value="value"
|
|
>
|
|
{{ label }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError :message="form.errors.legal_form" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="ice">ICE</Label>
|
|
<Input
|
|
id="ice"
|
|
v-model="form.ice"
|
|
type="text"
|
|
placeholder="Identifiant Commun de l'Entreprise"
|
|
:class="inputClass"
|
|
:aria-invalid="!!form.errors.ice"
|
|
/>
|
|
<InputError :message="form.errors.ice" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="fiscal_id">IF (Identifiant Fiscal)</Label>
|
|
<Input
|
|
id="fiscal_id"
|
|
v-model="form.fiscal_id"
|
|
type="text"
|
|
placeholder="Identifiant Fiscal"
|
|
:class="inputClass"
|
|
:aria-invalid="!!form.errors.fiscal_id"
|
|
/>
|
|
<InputError :message="form.errors.fiscal_id" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="rc">RC (Registre de Commerce)</Label>
|
|
<Input
|
|
id="rc"
|
|
v-model="form.rc"
|
|
type="text"
|
|
placeholder="Numéro RC"
|
|
:class="inputClass"
|
|
:aria-invalid="!!form.errors.rc"
|
|
/>
|
|
<InputError :message="form.errors.rc" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="cnss">CNSS</Label>
|
|
<Input
|
|
id="cnss"
|
|
v-model="form.cnss"
|
|
type="text"
|
|
placeholder="Numéro d'affiliation"
|
|
:class="inputClass"
|
|
:aria-invalid="!!form.errors.cnss"
|
|
/>
|
|
<InputError :message="form.errors.cnss" />
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="patente">Patente</Label>
|
|
<Input
|
|
id="patente"
|
|
v-model="form.patente"
|
|
type="text"
|
|
placeholder="Taxe professionnelle"
|
|
:class="inputClass"
|
|
:aria-invalid="!!form.errors.patente"
|
|
/>
|
|
<InputError :message="form.errors.patente" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Responsables -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2 text-base">
|
|
<User class="size-4" />
|
|
Responsables
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Personnes à contacter pour les échanges et déclarations
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<InputError :message="form.errors.contacts" />
|
|
<div
|
|
v-for="(contact, index) in form.contacts"
|
|
:key="index"
|
|
class="space-y-4 rounded-lg border border-sidebar-border/70 p-4"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="radio"
|
|
:checked="contact.is_principal"
|
|
name="principal_contact"
|
|
class="accent-primary"
|
|
@change="setPrincipal(index)"
|
|
/>
|
|
Principal
|
|
</label>
|
|
</div>
|
|
<Button
|
|
v-if="form.contacts.length > 1"
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="text-destructive"
|
|
@click="removeContact(index)"
|
|
>
|
|
<Trash2 class="size-4" />
|
|
</Button>
|
|
</div>
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="space-y-2 sm:col-span-2">
|
|
<Label :for="`contact_full_name_${index}`"
|
|
>Nom complet</Label
|
|
>
|
|
<Input
|
|
:id="`contact_full_name_${index}`"
|
|
v-model="contact.full_name"
|
|
type="text"
|
|
required
|
|
placeholder="Prénom Nom"
|
|
:class="inputClass"
|
|
:aria-invalid="
|
|
!!form.errors[`contacts.${index}.full_name`]
|
|
"
|
|
/>
|
|
<InputError
|
|
:message="
|
|
form.errors[`contacts.${index}.full_name`]
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label :for="`contact_job_title_${index}`"
|
|
>Fonction</Label
|
|
>
|
|
<Input
|
|
:id="`contact_job_title_${index}`"
|
|
v-model="contact.job_title"
|
|
type="text"
|
|
placeholder="Ex. Directeur financier"
|
|
:class="inputClass"
|
|
/>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label :for="`contact_email_${index}`">Email</Label>
|
|
<Input
|
|
:id="`contact_email_${index}`"
|
|
v-model="contact.email"
|
|
type="email"
|
|
placeholder="email@exemple.com"
|
|
:class="inputClass"
|
|
/>
|
|
</div>
|
|
<div class="space-y-2 sm:col-span-2">
|
|
<Label :for="`contact_phone_${index}`"
|
|
>Téléphone</Label
|
|
>
|
|
<Input
|
|
:id="`contact_phone_${index}`"
|
|
v-model="contact.phone"
|
|
type="tel"
|
|
placeholder="+212 6 00 00 00 00"
|
|
:class="inputClass"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="addContact"
|
|
>
|
|
<Plus class="mr-1 size-4" />
|
|
Ajouter un responsable
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Suivi interne -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2 text-base">
|
|
<Settings class="size-4" />
|
|
Suivi interne
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Attribution et notes réservées au cabinet
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
<div class="space-y-2">
|
|
<Label for="internal_responsible_id"
|
|
>Responsable interne</Label
|
|
>
|
|
<Select v-model="internalResponsibleSelect">
|
|
<SelectTrigger :class="['w-full', inputClass]">
|
|
<SelectValue
|
|
placeholder="Sélectionner un responsable"
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__"
|
|
>— Aucun —</SelectItem
|
|
>
|
|
<SelectItem
|
|
v-for="user in workspaceUsers"
|
|
:key="user.id"
|
|
:value="String(user.id)"
|
|
>
|
|
{{ user.name }}
|
|
<span class="text-muted-foreground">
|
|
· {{ user.email }}</span
|
|
>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError
|
|
:message="form.errors.internal_responsible_id"
|
|
/>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="status">Statut</Label>
|
|
<Select v-model="form.status">
|
|
<SelectTrigger :class="['w-full', inputClass]">
|
|
<SelectValue
|
|
placeholder="Sélectionner un statut"
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem
|
|
v-for="(label, value) in clientStatusLabels"
|
|
:key="value"
|
|
:value="value"
|
|
>
|
|
{{ label }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<InputError :message="form.errors.status" />
|
|
</div>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<Label for="internal_notes">Notes internes</Label>
|
|
<textarea
|
|
id="internal_notes"
|
|
v-model="form.internal_notes"
|
|
rows="3"
|
|
:class="[inputClass, 'min-h-[80px] resize-y']"
|
|
placeholder="Notes confidentielles sur le client..."
|
|
:aria-invalid="!!form.errors.internal_notes"
|
|
/>
|
|
<InputError :message="form.errors.internal_notes" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<Button
|
|
type="submit"
|
|
:disabled="form.processing"
|
|
data-test="client-form-submit"
|
|
>
|
|
<Spinner v-if="form.processing" class="mr-2 size-4" />
|
|
{{ submitLabel }}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</template>
|