feat(web): création client (modale) + email required + SIRET optionnel

Réflexion produit : email required vs optionnel.

Le coeur de Rubis = relances email automatiques. Sans email client →
aucune relance ne peut partir → la fiche client est inutilisable pour
le coeur du produit. Décision : email REQUIRED partout, plutôt que
laisser créer des fiches mortes.

Type Client (packages/shared) :
- email: string (était string | null)
- siret: string | null ajouté (optionnel mais recommandé pour mises
  en demeure formelles + intégrations comptables V2 type Pennylane)

ClientCreateDialog (modale "+ Nouveau client" sur /clients) :
- Email required avec validator Zod min(1).email()
- SIRET ajouté côte-à-côte avec Téléphone (validator 14 chiffres
  ou vide, inputMode='numeric', espaces tolérés à la frappe)
- Adresse postale déplacée full-width (lisibilité)
- Hints éducatifs : 'Préférez compta@/facturation@ à une nominative',
  'Recommandé pour les mises en demeure', 'Requise pour les mises en
  demeure formelles'

Field component aligned :
- Label/hint en haut, input en bas (mt-auto sur le wrapper input)
- Quand 2 Fields sont côte-à-côte avec hints de longueur différente,
  les inputs restent alignés au bas — le hint plus long étire le haut
- Erreur reste collée sous l'input (pas en bas de la cellule)

MSW :
- POST /clients schema strict : email required, siret 14 chiffres si fourni
- Détection doublon par nom (409) conservée
- Handlers création de client implicites (saisie facture, OCR review)
  refusent maintenant la création quand email manquant : 422 ciblé
  'Email du client requis — Rubis en a besoin pour envoyer les relances.'
  Si l'user pick un client existant via le combobox → email déjà en
  DB, pas demandé.

Migration mockDb :
- Anciens clients sans siret → null
- Anciens clients avec email null (cas test) → placeholder dérivé du
  slug du nom (contact@boulangerie-martin.fr) — éditable, juste évite
  un crash au load. slugifyClientName() supprime SARL/SAS/EURL et accents.

Détail /clients/$id :
- SIRET ajouté dans la barre meta du header (Hash icon Lucide +
  tabular-nums) — affiché seulement si rempli
- Email plus conditionnel (toujours présent maintenant)

Seeds :
- Boulangerie Martin SARL : SIRET 82345678900012
- Cabinet Rousseau : SIRET 53412987600028
- Atelier Durand, Garage Lemoine, Studio Lefèvre : siret null
  (pour tester les deux cas dans la liste)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 12:25:37 +02:00
parent f34cc97327
commit 16120ed3e0
9 changed files with 517 additions and 48 deletions

View File

@ -0,0 +1,312 @@
import { useEffect } from "react";
import { useForm } from "@tanstack/react-form";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { z } from "zod";
import type { Client } from "@rubis/shared";
import { api, ApiError } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/Button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/Dialog";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
/**
* Création manuelle d'un client depuis /clients.
*
* Différent de la création implicite via la saisie de facture ( on crée
* un client à la volée à partir du nom tapé) : ici l'utilisateur peut
* pré-remplir tous les champs en amont.
*
* Validation :
* - name requis ( 2 chars)
* - email REQUIS (Rubis sans email = produit cassé : aucune relance ne part)
* - siret optionnel mais validé si fourni (14 chiffres)
* - phone, address, notes optionnels
*
* Le serveur renvoie 409 si un client de même nom existe déjà on toast
* un message contextuel sans bloquer brutalement.
*/
type FormValues = {
name: string;
email: string;
phone: string;
address: string;
siret: string;
notes: string;
};
const validators = {
name: z.string().min(2, "Au moins 2 caractères").max(120, "120 caractères max"),
// Email REQUIS : sans canal de communication actif, Rubis ne peut pas
// tenir sa promesse de relances automatiques.
email: z
.string()
.min(1, "Email requis")
.max(120)
.email("Format d'email invalide"),
// SIRET optionnel — vide accepté, sinon 14 chiffres exactement.
siret: z
.string()
.refine(
(v) => v === "" || /^\d{14}$/u.test(v),
"14 chiffres exactement (ou laissez vide)",
),
};
type ClientCreateDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Pré-remplit le nom (utile si le user a déjà tapé qq chose ailleurs). */
defaultName?: string;
/** Callback optionnel après création réussie. */
onCreated?: (client: Client) => void;
};
export function ClientCreateDialog({
open,
onOpenChange,
defaultName = "",
onCreated,
}: ClientCreateDialogProps) {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (input: FormValues) =>
api.post<Client>("/api/v1/clients", {
name: input.name.trim(),
email: input.email.trim(),
phone: input.phone.trim() === "" ? null : input.phone.trim(),
address: input.address.trim() === "" ? null : input.address.trim(),
siret: input.siret.trim() === "" ? null : input.siret.trim(),
notes: input.notes.trim() === "" ? null : input.notes.trim(),
}),
onSuccess: (client) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.clients.all() });
toast.success(`${client.name} ajouté à votre carnet.`);
onCreated?.(client);
onOpenChange(false);
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.status === 409) {
// Doublon — le serveur le mentionne dans le message
toast.error(error.message);
return;
}
toast.error("Création impossible. Vérifiez les champs.");
},
});
const initialValues: FormValues = {
name: defaultName,
email: "",
phone: "",
address: "",
siret: "",
notes: "",
};
const form = useForm({
defaultValues: initialValues,
onSubmit: async ({ value }) => {
// Validation finale avant submit (les validators par champ couvrent
// déjà l'inline mais on re-check au cas où).
const checks: Array<[z.ZodTypeAny, string]> = [
[validators.name, value.name],
[validators.email, value.email],
[validators.siret, value.siret],
];
for (const [schema, val] of checks) {
const r = schema.safeParse(val);
if (!r.success) {
toast.error(r.error.issues[0]?.message ?? "Champ invalide");
return;
}
}
await createMutation.mutateAsync(value);
},
});
// Reset à chaque ouverture pour avoir un form propre.
useEffect(() => {
if (open) form.reset({ ...initialValues, name: defaultName });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, defaultName]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxWidth={520}>
<DialogHeader>
<DialogTitle>Nouveau client</DialogTitle>
<DialogDescription>
Ajoutez une fiche client pour préparer le terrain avant la
prochaine facture.
</DialogDescription>
</DialogHeader>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex flex-col gap-5"
>
<form.Field name="name" validators={{ onChange: validators.name }}>
{(field) => (
<Field
label="Nom du client"
htmlFor={field.name}
error={firstError(field.state.meta.errors)}
>
<Input
id={field.name}
autoFocus
placeholder="Boulangerie Martin SARL"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<form.Field name="email" validators={{ onChange: validators.email }}>
{(field) => (
<Field
label="Email du contact"
htmlFor={field.name}
hint="Destinataire des relances. Préférez une adresse générique (compta@, facturation@) à une nominative."
error={firstError(field.state.meta.errors)}
>
<Input
id={field.name}
type="email"
autoComplete="off"
placeholder="compta@entreprise.fr"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<form.Field name="phone">
{(field) => (
<Field label="Téléphone" htmlFor={field.name} hint="Optionnel.">
<Input
id={field.name}
type="tel"
autoComplete="off"
placeholder="+33 …"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<form.Field
name="siret"
validators={{ onChange: validators.siret }}
>
{(field) => (
<Field
label="SIRET"
htmlFor={field.name}
hint="14 chiffres. Recommandé pour les mises en demeure."
error={firstError(field.state.meta.errors)}
>
<Input
id={field.name}
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="123 456 789 00012"
value={field.state.value}
onChange={(e) =>
field.handleChange(e.target.value.replace(/\s/gu, ""))
}
/>
</Field>
)}
</form.Field>
</div>
<form.Field name="address">
{(field) => (
<Field
label="Adresse postale"
htmlFor={field.name}
hint="Optionnelle, mais requise pour les mises en demeure formelles."
>
<Input
id={field.name}
autoComplete="off"
placeholder="12 rue …"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<form.Field name="notes">
{(field) => (
<Field
label="Notes internes"
htmlFor={field.name}
hint="Visible uniquement par vous, jamais envoyé."
>
<Textarea
id={field.name}
rows={3}
placeholder="Préférences de paiement, contexte, anecdotes…"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary" size="sm" type="button">
Annuler
</Button>
</DialogClose>
<Button
size="sm"
type="submit"
loading={createMutation.isPending}
>
Créer le client
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function firstError(errors: unknown[]): string | undefined {
const first = errors[0];
if (!first) return undefined;
if (typeof first === "string") return first;
if (typeof first === "object" && first !== null && "message" in first) {
const msg = (first as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return undefined;
}

View File

@ -43,34 +43,45 @@ export function Field({
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
// Layout : label + hint en haut, input en bas. `mt-auto` sur le wrapper
// de l'input le pousse au bas de la cellule. Quand plusieurs Fields sont
// côte-à-côte dans une grille (ex. Téléphone + SIRET), un hint plus long
// sur l'un n'écrase plus l'alignement des inputs : le label/hint d'un
// côté étire le haut, l'input reste aligné avec ses voisins en bas.
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<LabelPrimitive.Root
htmlFor={id}
className={cn(
"font-sans text-[13px] font-semibold text-ink",
srOnlyLabel && "sr-only",
)}
>
{label}
</LabelPrimitive.Root>
{hint && !error && (
<p id={hintId} className="text-[12.5px] text-ink-3 leading-snug">
{hint}
</p>
)}
<FieldContext.Provider value={{ id, describedBy: errorId ?? hintId, invalid: !!error }}>
{children}
</FieldContext.Provider>
{error && (
<p
id={errorId}
role="alert"
className="text-[12.5px] font-medium text-rubis-deep leading-snug"
<div className={cn("flex flex-col h-full", className)}>
<div className="flex flex-col gap-1.5">
<LabelPrimitive.Root
htmlFor={id}
className={cn(
"font-sans text-[13px] font-semibold text-ink",
srOnlyLabel && "sr-only",
)}
>
{error}
</p>
)}
{label}
</LabelPrimitive.Root>
{hint && !error && (
<p id={hintId} className="text-[12.5px] text-ink-3 leading-snug">
{hint}
</p>
)}
</div>
<div className="mt-auto pt-1.5">
<FieldContext.Provider
value={{ id, describedBy: errorId ?? hintId, invalid: !!error }}
>
{children}
</FieldContext.Provider>
{error && (
<p
id={errorId}
role="alert"
className="mt-1.5 text-[12.5px] font-medium text-rubis-deep leading-snug"
>
{error}
</p>
)}
</div>
</div>
);
}

View File

@ -73,12 +73,34 @@ function computeStats(invoices: StoredInvoice[], now = new Date()) {
const updateClientSchema = z.object({
name: z.string().min(1).max(120).optional(),
email: z.string().email().nullable().optional(),
email: z.string().email("Email invalide").min(1).optional(),
phone: z.string().max(40).nullable().optional(),
address: z.string().max(500).nullable().optional(),
siret: z
.string()
.regex(/^\d{14}$/u, "Le SIRET doit contenir 14 chiffres")
.nullable()
.optional(),
notes: z.string().max(2000).nullable().optional(),
});
const createClientSchema = z.object({
name: z.string().min(2, "Au moins 2 caractères").max(120),
// Email REQUIS : sans email, Rubis ne peut pas envoyer les relances
// automatiques. C'est le pivot du produit, on n'accepte pas de fiche
// client sans canal de communication actif.
email: z.string().min(1, "Email requis").email("Format d'email invalide"),
phone: z.string().max(40).nullable().optional().default(null),
address: z.string().max(500).nullable().optional().default(null),
siret: z
.string()
.regex(/^\d{14}$/u, "Le SIRET doit contenir 14 chiffres")
.nullable()
.optional()
.default(null),
notes: z.string().max(2000).nullable().optional().default(null),
});
export const clientHandlers = [
// GET /api/v1/clients?withStats=1&q=
// Sans `withStats`, retour à plat (utilisé par le combobox).
@ -98,7 +120,7 @@ export const clientHandlers = [
clients = clients.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.email?.toLowerCase().includes(q) ?? false),
c.email.toLowerCase().includes(q),
);
}
@ -125,6 +147,59 @@ export const clientHandlers = [
return HttpResponse.json({ data: enriched });
}),
// POST /api/v1/clients — création manuelle (depuis /clients · "+ Nouveau client")
http.post(`${apiBase}/clients`, async ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const json = await request.json();
const parsed = createClientSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
// Détection de doublon par nom (case-insensitive). Pas de blocage dur :
// on renvoie un 409 informatif que le SPA peut gérer pour proposer "voir
// le client existant" plutôt que créer.
const existing = mockDb
.listClientsForOrg(orgId)
.find((c) => c.name.toLowerCase() === parsed.data.name.toLowerCase());
if (existing) {
return HttpResponse.json(
{
errors: [
{
code: "duplicate_client",
message: `Un client nommé « ${existing.name} » existe déjà.`,
field: "name",
},
],
existing,
},
{ status: 409 },
);
}
const created = mockDb.createClient(orgId, {
name: parsed.data.name,
email: parsed.data.email,
phone: parsed.data.phone,
address: parsed.data.address,
siret: parsed.data.siret,
notes: parsed.data.notes,
});
return HttpResponse.json({ data: created }, { status: 201 });
}),
// GET /api/v1/clients/:id — détail enrichi avec invoices
http.get(`${apiBase}/clients/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));

View File

@ -349,7 +349,9 @@ export const invoiceHandlers = [
const fields = parsed.data;
// Résolution client : si clientId fourni → utilise. Sinon match par nom
// (case-insensitive). Sinon crée un nouveau client à la volée.
// (case-insensitive). Sinon crée un nouveau client à la volée — mais
// dans ce cas l'email est OBLIGATOIRE car sans email Rubis ne peut
// pas envoyer les relances (cf. décision produit).
let clientId = fields.clientId;
let clientName = fields.clientName;
if (!clientId) {
@ -362,11 +364,27 @@ export const invoiceHandlers = [
clientId = existing.id;
clientName = existing.name;
} else {
if (!fields.clientEmail || fields.clientEmail.length === 0) {
return HttpResponse.json(
{
errors: [
{
code: "client_email_required",
message:
"Email du client requis pour créer une nouvelle fiche — Rubis en a besoin pour envoyer les relances.",
field: "clientEmail",
},
],
},
{ status: 422 },
);
}
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail ?? null,
email: fields.clientEmail,
phone: null,
address: null,
siret: null,
notes: null,
});
clientId = created.id;
@ -470,9 +488,29 @@ export const invoiceHandlers = [
// Résolution client (priorité descendante) :
// 1. clientId explicite envoyé depuis le combobox → utilise direct
// 2. match par nom (case-insensitive) sur les clients existants
// 3. création à la volée si rien ne matche
// 3. création à la volée si rien ne matche — email obligatoire
// puisqu'on n'accepte pas de fiche client sans canal de relance.
let clientId: string;
let clientName: string;
const requireEmailForCreation = () => {
if (!fields.clientEmail || fields.clientEmail.length === 0) {
return HttpResponse.json(
{
errors: [
{
code: "client_email_required",
message:
"Email du client requis — Rubis en a besoin pour envoyer les relances.",
field: "clientEmail",
},
],
},
{ status: 422 },
);
}
return null;
};
if (fields.clientId) {
const c = mockDb.findClientById(orgId, fields.clientId);
if (c) {
@ -480,11 +518,14 @@ export const invoiceHandlers = [
clientName = c.name;
} else {
// clientId fourni mais introuvable — fallback création
const err = requireEmailForCreation();
if (err) return err;
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail,
email: fields.clientEmail!,
phone: null,
address: null,
siret: null,
notes: null,
});
clientId = created.id;
@ -500,11 +541,14 @@ export const invoiceHandlers = [
clientId = matched.id;
clientName = matched.name;
} else {
const err = requireEmailForCreation();
if (err) return err;
const created = mockDb.createClient(orgId, {
name: fields.clientName,
email: fields.clientEmail,
email: fields.clientEmail!,
phone: null,
address: null,
siret: null,
notes: null,
});
clientId = created.id;

View File

@ -26,6 +26,7 @@ export const SEED_CLIENTS: Client[] = [
email: "compta@boulangerie-martin.fr",
phone: "+33 1 23 45 67 89",
address: "12 rue du Pain, 75011 Paris",
siret: "82345678900012",
notes: null,
createdAt: isoFromOffset(-90),
updatedAt: isoFromOffset(-2),
@ -37,6 +38,7 @@ export const SEED_CLIENTS: Client[] = [
email: "contact@atelier-durand.fr",
phone: null,
address: null,
siret: null,
notes: "Le client a confirmé la réception le 14/04 par téléphone — relance ferme inutile.",
createdAt: isoFromOffset(-120),
updatedAt: isoFromOffset(-3),
@ -48,6 +50,7 @@ export const SEED_CLIENTS: Client[] = [
email: "facturation@cabinet-rousseau.fr",
phone: "+33 4 56 78 90 12",
address: "8 place de la République, 69002 Lyon",
siret: "53412987600028",
notes: null,
createdAt: isoFromOffset(-200),
updatedAt: isoFromOffset(-1),
@ -59,6 +62,7 @@ export const SEED_CLIENTS: Client[] = [
email: "admin@garage-lemoine.fr",
phone: null,
address: null,
siret: null,
notes: null,
createdAt: isoFromOffset(-60),
updatedAt: isoFromOffset(-5),
@ -70,6 +74,7 @@ export const SEED_CLIENTS: Client[] = [
email: "hello@studio-lefevre.com",
phone: null,
address: null,
siret: null,
notes: null,
createdAt: isoFromOffset(-30),
updatedAt: isoFromOffset(-1),

View File

@ -14,6 +14,7 @@ import {
type ClientWithStats,
} from "@/components/clients/ClientTable";
import { ClientCardList } from "@/components/clients/ClientCardList";
import { ClientCreateDialog } from "@/components/clients/ClientCreateDialog";
const searchSchema = z.object({
q: z.string().optional(),
@ -36,6 +37,7 @@ function ClientsPage() {
// keystroke. Le param URL synchronisé via Route.useSearch() viendra V2 quand
// on aura besoin de partager le filtre.
const [searchInput, setSearchInput] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const { data: clients = [], isPending } = useQuery({
queryKey: queryKeys.clients.list({ q: searchInput }),
@ -78,9 +80,12 @@ function ClientsPage() {
</p>
</div>
<Button variant="secondary" size="sm" disabled>
<Button
variant="secondary"
size="sm"
onClick={() => setCreateOpen(true)}
>
<Plus size={14} aria-hidden="true" /> Nouveau client
<span className="ml-1 text-[11px] italic text-ink-3">(bientôt)</span>
</Button>
</div>
@ -132,6 +137,8 @@ function ClientsPage() {
</div>
</>
)}
<ClientCreateDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@ -10,6 +10,7 @@ import {
Phone,
MapPin,
AlertCircle,
Hash,
} from "lucide-react";
import { toast } from "sonner";
@ -111,23 +112,27 @@ function ClientDetailPage() {
{client.name}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] text-ink-2">
{client.email && (
<span className="inline-flex items-center gap-1.5">
<Mail size={13} className="text-ink-3" aria-hidden="true" />
<a
href={`mailto:${client.email}`}
className="hover:text-rubis hover:underline underline-offset-4"
>
{client.email}
</a>
</span>
)}
<span className="inline-flex items-center gap-1.5">
<Mail size={13} className="text-ink-3" aria-hidden="true" />
<a
href={`mailto:${client.email}`}
className="hover:text-rubis hover:underline underline-offset-4"
>
{client.email}
</a>
</span>
{client.phone && (
<span className="inline-flex items-center gap-1.5">
<Phone size={13} className="text-ink-3" aria-hidden="true" />
{client.phone}
</span>
)}
{client.siret && (
<span className="inline-flex items-center gap-1.5">
<Hash size={13} className="text-ink-3" aria-hidden="true" />
<span className="tabular-nums">{client.siret}</span>
</span>
)}
{client.address && (
<span className="inline-flex items-center gap-1.5">
<MapPin size={13} className="text-ink-3" aria-hidden="true" />

View File

@ -2,9 +2,14 @@ import { z } from "zod";
export const createClientSchema = z.object({
name: z.string().min(1, "Le nom du client est requis").max(120),
email: z.string().email("Email invalide").nullable().optional(),
email: z.string().email("Email invalide").min(1, "Email requis"),
phone: z.string().max(40).nullable().optional(),
address: z.string().max(500).nullable().optional(),
siret: z
.string()
.regex(/^\d{14}$/u, "Le SIRET doit contenir 14 chiffres")
.nullable()
.optional(),
notes: z.string().max(2000).nullable().optional(),
});

View File

@ -5,10 +5,15 @@ export type Client = {
id: string;
organizationId: string;
name: string;
email: string | null;
/** Email de contact requis : sans email, Rubis ne peut pas envoyer
* les relances automatiques. C'est le pivot du produit. */
email: string;
phone: string | null;
/** Adresse postale (LME : requise pour mise en demeure formelle). */
address: string | null;
/** SIRET de l'établissement (14 chiffres). Optionnel mais recommandé pour
* les mises en demeure formelles et les intégrations comptables (V2). */
siret: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;