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 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)}>
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<LabelPrimitive.Root
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
@ -59,19 +65,24 @@ export function Field({
|
||||
{hint}
|
||||
</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}
|
||||
</FieldContext.Provider>
|
||||
{error && (
|
||||
<p
|
||||
id={errorId}
|
||||
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}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
Phone,
|
||||
MapPin,
|
||||
AlertCircle,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@ -111,7 +112,6 @@ 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
|
||||
@ -121,13 +121,18 @@ function ClientDetailPage() {
|
||||
{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" />
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user