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) => ( + +