diff --git a/apps/web/src/components/clients/ClientCreateDialog.tsx b/apps/web/src/components/clients/ClientCreateDialog.tsx
new file mode 100644
index 0000000..9a2aa72
--- /dev/null
+++ b/apps/web/src/components/clients/ClientCreateDialog.tsx
@@ -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("/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 (
+
+
+
+ {(field) => (
+
+ field.handleChange(e.target.value)}
+ />
+
+ )}
+
+
+
+ {(field) => (
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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;
+}
diff --git a/apps/web/src/components/ui/Field.tsx b/apps/web/src/components/ui/Field.tsx
index dce3472..5949ac9 100644
--- a/apps/web/src/components/ui/Field.tsx
+++ b/apps/web/src/components/ui/Field.tsx
@@ -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 (
-
-
- {label}
-
- {hint && !error && (
-
- {hint}
-
- )}
-
- {children}
-
- {error && (
-
+
+
- {error}
-
- )}
+ {label}
+
+ {hint && !error && (
+
+ {hint}
+
+ )}
+
+
+
+ {children}
+
+ {error && (
+
+ {error}
+
+ )}
+
);
}
diff --git a/apps/web/src/mocks/handlers/clients.ts b/apps/web/src/mocks/handlers/clients.ts
index a3622e1..627323f 100644
--- a/apps/web/src/mocks/handlers/clients.ts
+++ b/apps/web/src/mocks/handlers/clients.ts
@@ -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"));
diff --git a/apps/web/src/mocks/handlers/invoices.ts b/apps/web/src/mocks/handlers/invoices.ts
index 35f7d18..565bca3 100644
--- a/apps/web/src/mocks/handlers/invoices.ts
+++ b/apps/web/src/mocks/handlers/invoices.ts
@@ -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;
diff --git a/apps/web/src/mocks/seed.ts b/apps/web/src/mocks/seed.ts
index ce3996c..85dbd98 100644
--- a/apps/web/src/mocks/seed.ts
+++ b/apps/web/src/mocks/seed.ts
@@ -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),
diff --git a/apps/web/src/routes/_app/clients.tsx b/apps/web/src/routes/_app/clients.tsx
index c1bb0d8..0d5f406 100644
--- a/apps/web/src/routes/_app/clients.tsx
+++ b/apps/web/src/routes/_app/clients.tsx
@@ -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() {
-