From 16120ed3e0c33e18e9fbeb8e413222b1a4cf0535 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 12:25:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20cr=C3=A9ation=20client=20(modale)?= =?UTF-8?q?=20+=20email=20required=20+=20SIRET=20optionnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/clients/ClientCreateDialog.tsx | 312 ++++++++++++++++++ apps/web/src/components/ui/Field.tsx | 63 ++-- apps/web/src/mocks/handlers/clients.ts | 79 ++++- apps/web/src/mocks/handlers/invoices.ts | 54 ++- apps/web/src/mocks/seed.ts | 5 + apps/web/src/routes/_app/clients.tsx | 11 +- apps/web/src/routes/_app/clients_.$id.tsx | 27 +- packages/shared/src/schemas/client.ts | 7 +- packages/shared/src/types/client.ts | 7 +- 9 files changed, 517 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/clients/ClientCreateDialog.tsx 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 ( + + + + Nouveau client + + Ajoutez une fiche client pour préparer le terrain avant la + prochaine facture. + + + +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + className="flex flex-col gap-5" + > + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + +
+ + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + {(field) => ( + + + field.handleChange(e.target.value.replace(/\s/gu, "")) + } + /> + + )} + +
+ + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + + {(field) => ( + +