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:
parent
f34cc97327
commit
16120ed3e0
312
apps/web/src/components/clients/ClientCreateDialog.tsx
Normal file
312
apps/web/src/components/clients/ClientCreateDialog.tsx
Normal 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 (où 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;
|
||||||
|
}
|
||||||
@ -43,8 +43,14 @@ export function Field({
|
|||||||
const hintId = hint ? `${id}-hint` : undefined;
|
const hintId = hint ? `${id}-hint` : undefined;
|
||||||
const errorId = error ? `${id}-error` : 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 (
|
return (
|
||||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -59,19 +65,24 @@ export function Field({
|
|||||||
{hint}
|
{hint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<FieldContext.Provider value={{ id, describedBy: errorId ?? hintId, invalid: !!error }}>
|
</div>
|
||||||
|
<div className="mt-auto pt-1.5">
|
||||||
|
<FieldContext.Provider
|
||||||
|
value={{ id, describedBy: errorId ?? hintId, invalid: !!error }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</FieldContext.Provider>
|
</FieldContext.Provider>
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
id={errorId}
|
id={errorId}
|
||||||
role="alert"
|
role="alert"
|
||||||
className="text-[12.5px] font-medium text-rubis-deep leading-snug"
|
className="mt-1.5 text-[12.5px] font-medium text-rubis-deep leading-snug"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -73,12 +73,34 @@ function computeStats(invoices: StoredInvoice[], now = new Date()) {
|
|||||||
|
|
||||||
const updateClientSchema = z.object({
|
const updateClientSchema = z.object({
|
||||||
name: z.string().min(1).max(120).optional(),
|
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(),
|
phone: z.string().max(40).nullable().optional(),
|
||||||
address: z.string().max(500).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(),
|
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 = [
|
export const clientHandlers = [
|
||||||
// GET /api/v1/clients?withStats=1&q=
|
// GET /api/v1/clients?withStats=1&q=
|
||||||
// Sans `withStats`, retour à plat (utilisé par le combobox).
|
// Sans `withStats`, retour à plat (utilisé par le combobox).
|
||||||
@ -98,7 +120,7 @@ export const clientHandlers = [
|
|||||||
clients = clients.filter(
|
clients = clients.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.name.toLowerCase().includes(q) ||
|
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 });
|
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
|
// GET /api/v1/clients/:id — détail enrichi avec invoices
|
||||||
http.get(`${apiBase}/clients/:id`, ({ request, params }) => {
|
http.get(`${apiBase}/clients/:id`, ({ request, params }) => {
|
||||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
|||||||
@ -349,7 +349,9 @@ export const invoiceHandlers = [
|
|||||||
const fields = parsed.data;
|
const fields = parsed.data;
|
||||||
|
|
||||||
// Résolution client : si clientId fourni → utilise. Sinon match par nom
|
// 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 clientId = fields.clientId;
|
||||||
let clientName = fields.clientName;
|
let clientName = fields.clientName;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
@ -362,11 +364,27 @@ export const invoiceHandlers = [
|
|||||||
clientId = existing.id;
|
clientId = existing.id;
|
||||||
clientName = existing.name;
|
clientName = existing.name;
|
||||||
} else {
|
} 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, {
|
const created = mockDb.createClient(orgId, {
|
||||||
name: fields.clientName,
|
name: fields.clientName,
|
||||||
email: fields.clientEmail ?? null,
|
email: fields.clientEmail,
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
|
siret: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
});
|
});
|
||||||
clientId = created.id;
|
clientId = created.id;
|
||||||
@ -470,9 +488,29 @@ export const invoiceHandlers = [
|
|||||||
// Résolution client (priorité descendante) :
|
// Résolution client (priorité descendante) :
|
||||||
// 1. clientId explicite envoyé depuis le combobox → utilise direct
|
// 1. clientId explicite envoyé depuis le combobox → utilise direct
|
||||||
// 2. match par nom (case-insensitive) sur les clients existants
|
// 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 clientId: string;
|
||||||
let clientName: 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) {
|
if (fields.clientId) {
|
||||||
const c = mockDb.findClientById(orgId, fields.clientId);
|
const c = mockDb.findClientById(orgId, fields.clientId);
|
||||||
if (c) {
|
if (c) {
|
||||||
@ -480,11 +518,14 @@ export const invoiceHandlers = [
|
|||||||
clientName = c.name;
|
clientName = c.name;
|
||||||
} else {
|
} else {
|
||||||
// clientId fourni mais introuvable — fallback création
|
// clientId fourni mais introuvable — fallback création
|
||||||
|
const err = requireEmailForCreation();
|
||||||
|
if (err) return err;
|
||||||
const created = mockDb.createClient(orgId, {
|
const created = mockDb.createClient(orgId, {
|
||||||
name: fields.clientName,
|
name: fields.clientName,
|
||||||
email: fields.clientEmail,
|
email: fields.clientEmail!,
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
|
siret: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
});
|
});
|
||||||
clientId = created.id;
|
clientId = created.id;
|
||||||
@ -500,11 +541,14 @@ export const invoiceHandlers = [
|
|||||||
clientId = matched.id;
|
clientId = matched.id;
|
||||||
clientName = matched.name;
|
clientName = matched.name;
|
||||||
} else {
|
} else {
|
||||||
|
const err = requireEmailForCreation();
|
||||||
|
if (err) return err;
|
||||||
const created = mockDb.createClient(orgId, {
|
const created = mockDb.createClient(orgId, {
|
||||||
name: fields.clientName,
|
name: fields.clientName,
|
||||||
email: fields.clientEmail,
|
email: fields.clientEmail!,
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
|
siret: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
});
|
});
|
||||||
clientId = created.id;
|
clientId = created.id;
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
email: "compta@boulangerie-martin.fr",
|
email: "compta@boulangerie-martin.fr",
|
||||||
phone: "+33 1 23 45 67 89",
|
phone: "+33 1 23 45 67 89",
|
||||||
address: "12 rue du Pain, 75011 Paris",
|
address: "12 rue du Pain, 75011 Paris",
|
||||||
|
siret: "82345678900012",
|
||||||
notes: null,
|
notes: null,
|
||||||
createdAt: isoFromOffset(-90),
|
createdAt: isoFromOffset(-90),
|
||||||
updatedAt: isoFromOffset(-2),
|
updatedAt: isoFromOffset(-2),
|
||||||
@ -37,6 +38,7 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
email: "contact@atelier-durand.fr",
|
email: "contact@atelier-durand.fr",
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
|
siret: null,
|
||||||
notes: "Le client a confirmé la réception le 14/04 par téléphone — relance ferme inutile.",
|
notes: "Le client a confirmé la réception le 14/04 par téléphone — relance ferme inutile.",
|
||||||
createdAt: isoFromOffset(-120),
|
createdAt: isoFromOffset(-120),
|
||||||
updatedAt: isoFromOffset(-3),
|
updatedAt: isoFromOffset(-3),
|
||||||
@ -48,6 +50,7 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
email: "facturation@cabinet-rousseau.fr",
|
email: "facturation@cabinet-rousseau.fr",
|
||||||
phone: "+33 4 56 78 90 12",
|
phone: "+33 4 56 78 90 12",
|
||||||
address: "8 place de la République, 69002 Lyon",
|
address: "8 place de la République, 69002 Lyon",
|
||||||
|
siret: "53412987600028",
|
||||||
notes: null,
|
notes: null,
|
||||||
createdAt: isoFromOffset(-200),
|
createdAt: isoFromOffset(-200),
|
||||||
updatedAt: isoFromOffset(-1),
|
updatedAt: isoFromOffset(-1),
|
||||||
@ -59,6 +62,7 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
email: "admin@garage-lemoine.fr",
|
email: "admin@garage-lemoine.fr",
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
|
siret: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
createdAt: isoFromOffset(-60),
|
createdAt: isoFromOffset(-60),
|
||||||
updatedAt: isoFromOffset(-5),
|
updatedAt: isoFromOffset(-5),
|
||||||
@ -70,6 +74,7 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
email: "hello@studio-lefevre.com",
|
email: "hello@studio-lefevre.com",
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
|
siret: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
createdAt: isoFromOffset(-30),
|
createdAt: isoFromOffset(-30),
|
||||||
updatedAt: isoFromOffset(-1),
|
updatedAt: isoFromOffset(-1),
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
type ClientWithStats,
|
type ClientWithStats,
|
||||||
} from "@/components/clients/ClientTable";
|
} from "@/components/clients/ClientTable";
|
||||||
import { ClientCardList } from "@/components/clients/ClientCardList";
|
import { ClientCardList } from "@/components/clients/ClientCardList";
|
||||||
|
import { ClientCreateDialog } from "@/components/clients/ClientCreateDialog";
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
@ -36,6 +37,7 @@ function ClientsPage() {
|
|||||||
// keystroke. Le param URL synchronisé via Route.useSearch() viendra V2 quand
|
// keystroke. Le param URL synchronisé via Route.useSearch() viendra V2 quand
|
||||||
// on aura besoin de partager le filtre.
|
// on aura besoin de partager le filtre.
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const { data: clients = [], isPending } = useQuery({
|
const { data: clients = [], isPending } = useQuery({
|
||||||
queryKey: queryKeys.clients.list({ q: searchInput }),
|
queryKey: queryKeys.clients.list({ q: searchInput }),
|
||||||
@ -78,9 +80,12 @@ function ClientsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="secondary" size="sm" disabled>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
<Plus size={14} aria-hidden="true" /> Nouveau client
|
<Plus size={14} aria-hidden="true" /> Nouveau client
|
||||||
<span className="ml-1 text-[11px] italic text-ink-3">(bientôt)</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -132,6 +137,8 @@ function ClientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ClientCreateDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
Phone,
|
Phone,
|
||||||
MapPin,
|
MapPin,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Hash,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@ -111,7 +112,6 @@ function ClientDetailPage() {
|
|||||||
{client.name}
|
{client.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] text-ink-2">
|
<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">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Mail size={13} className="text-ink-3" aria-hidden="true" />
|
<Mail size={13} className="text-ink-3" aria-hidden="true" />
|
||||||
<a
|
<a
|
||||||
@ -121,13 +121,18 @@ function ClientDetailPage() {
|
|||||||
{client.email}
|
{client.email}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
{client.phone && (
|
{client.phone && (
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Phone size={13} className="text-ink-3" aria-hidden="true" />
|
<Phone size={13} className="text-ink-3" aria-hidden="true" />
|
||||||
{client.phone}
|
{client.phone}
|
||||||
</span>
|
</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 && (
|
{client.address && (
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<MapPin size={13} className="text-ink-3" aria-hidden="true" />
|
<MapPin size={13} className="text-ink-3" aria-hidden="true" />
|
||||||
|
|||||||
@ -2,9 +2,14 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const createClientSchema = z.object({
|
export const createClientSchema = z.object({
|
||||||
name: z.string().min(1, "Le nom du client est requis").max(120),
|
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(),
|
phone: z.string().max(40).nullable().optional(),
|
||||||
address: z.string().max(500).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(),
|
notes: z.string().max(2000).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,15 @@ export type Client = {
|
|||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
name: 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;
|
phone: string | null;
|
||||||
/** Adresse postale (LME : requise pour mise en demeure formelle). */
|
/** Adresse postale (LME : requise pour mise en demeure formelle). */
|
||||||
address: string | null;
|
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;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user