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:
2026-03-11 23:33:10 +00:00
commit 35545c2a8f
1517 changed files with 246774 additions and 0 deletions

View 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>