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:
444
resources/js/components/ClientForm.vue
Normal file
444
resources/js/components/ClientForm.vue
Normal file
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
import { Building2, Plus, Settings, Trash2, User } from 'lucide-vue-next';
|
||||
|
||||
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 dossiers
|
||||
</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>
|
||||
Reference in New Issue
Block a user