From 1e2eecdeba1337be8b752d7411a7572e75e68328 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 18 May 2026 17:17:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(invoices):=20dialog=20facture=20demande=20l?= =?UTF-8?q?'email=20du=20client=20=C3=A0=20la=20vol=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug UX rapporté par Arthur : quand on crée une facture pour un nouveau client via le combobox du dialog "+ Saisir" (option "Créer le client « X »"), aucun champ email n'apparaissait. Au submit, l'API renvoyait 422 client_email_required mais le toast affiché était générique ("Création impossible. Vérifiez les champs.") sans guidance sur le champ manquant. L'utilisateur se retrouvait coincé sans savoir qu'il devait quitter le dialog et créer le client d'abord via /clients. Friction quotidienne, jamais visible en démo (où on sélectionne un client existant). Fix : - Nouveau champ `clientEmail` (string, default "") dans FormValues - Validator zod email + max 254 char - Render conditionnel via form.Subscribe : visible UNIQUEMENT quand `clientId === null && clientName.trim().length >= 2` (= création à la volée). Disparait dès que l'user sélectionne un client existant ou vide le combobox. - Validation finale : requis seulement si clientId null - mutationFn envoie `clientEmail || null` au backend uniquement en mode création à la volée (pour client existant, l'email est déjà en DB) TDD : le test E2E "client inconnu — fournit l'email" a été écrit AVANT le fix, échouait sur `getByLabel(/email du client/i) not found` (champ inexistant), passe maintenant en 3.9 s. Empêche la régression future. Le test vérifie aussi la chaîne complète : - Facture créée + apparaît dans /factures - Client créé en même temps + apparaît dans /clients avec son email visible État après ce commit : 27/27 tests Playwright verts. Co-Authored-By: Claude Opus 4.7 --- .../factures/ManualInvoiceDialog.tsx | 64 ++++++++++++++++++- e2e/tests/factures.spec.ts | 62 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/factures/ManualInvoiceDialog.tsx b/apps/web/src/components/factures/ManualInvoiceDialog.tsx index 98a8ca7..05d0fb8 100644 --- a/apps/web/src/components/factures/ManualInvoiceDialog.tsx +++ b/apps/web/src/components/factures/ManualInvoiceDialog.tsx @@ -55,6 +55,12 @@ const RELATIVE_DUE_LABELS: Record = { type FormValues = { clientName: string; clientId: string | null; + /** + * Email du client. Requis UNIQUEMENT quand l'user crée le client à la + * volée (clientId === null + clientName non vide). Sans ça l'API renvoyait + * un 422 client_email_required avec un toast générique — impasse UX. + */ + clientEmail: string; numero: string; amountTtcCents: number; issueDate: string; @@ -69,6 +75,10 @@ const validators = { .string() .min(2, "Au moins 2 caractères") .max(120, "120 caractères max"), + clientEmail: z + .string() + .email("Email invalide") + .max(254, "254 caractères max"), numero: z .string() .min(1, "Numéro requis") @@ -103,7 +113,9 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP return api.post(`/api/v1/invoices`, { clientId: input.clientId ?? undefined, clientName: input.clientName, - clientEmail: null, + // Email envoyé uniquement quand on crée le client à la volée + // (clientId null). Pour un client existant, l'email est déjà en DB. + clientEmail: input.clientId ? null : input.clientEmail || null, numero: input.numero, amountTtcCents: input.amountTtcCents, issueDate: issueDate.toISOString(), @@ -130,6 +142,7 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP const initialValues: FormValues = { clientName: "", clientId: null, + clientEmail: "", numero: "", amountTtcCents: 0, issueDate: today, @@ -148,6 +161,11 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP ["amountTtcCents", validators.amountTtcCents], ["planId", validators.planId], ]; + // Email du client : requis SEULEMENT pour création à la volée + // (clientId null). Pour un client existant, l'email est déjà en DB. + if (!value.clientId) { + checks.push(["clientEmail", validators.clientEmail]); + } for (const [key, schema] of checks) { const r = schema.safeParse(value[key]); if (!r.success) { @@ -206,6 +224,9 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP onChange={({ value, clientId }) => { field.handleChange(value); idField.handleChange(clientId); + // Si l'user revient sur un client existant, on + // n'a plus besoin du champ email — il sera vidé + // par le reset implicite côté form.Field. }} /> @@ -214,6 +235,47 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP )} + {/* === Email du client — visible UNIQUEMENT en création à la volée. + Sans ce champ l'API renvoyait 422 client_email_required + toast + générique, ce qui mettait l'user en cul-de-sac (cf. test E2E + "client inconnu — fournit l'email"). === */} + ({ + clientId: state.values.clientId, + clientName: state.values.clientName, + })} + > + {({ clientId, clientName }) => { + const showEmail = + !clientId && clientName.trim().length >= 2; + if (!showEmail) return null; + return ( + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + ); + }} + + {/* === N° + Date d'émission === */}
{ page.getByText(/aucune facture|pas encore.*facture/i).first(), ).toBeVisible({ timeout: 5_000 }) }) + + test('crée une facture avec un client inconnu (à la volée) — fournit l\'email', async ({ + page, + }) => { + // Cas user réel : pas envie de quitter le dialog de création de facture + // pour aller créer le client d'abord. Le combobox propose "Créer le + // client X" → on clique, on remplit l'email du client juste après, on + // soumet la facture. Le client est créé en DB en même temps que la + // facture, en un seul flow. + // + // Avant le fix UX (2026-05-18) : le dialog ne demandait PAS l'email + // du client → POST /invoices renvoyait 422 client_email_required → + // toast générique "Création impossible. Vérifiez les champs." → l'user + // ne pouvait pas comprendre ni s'en sortir sans fermer le dialog. Ce + // test l'a démontré et a guidé le fix. + await signupAndOnboard(page, 'fac-newclient') + + // Pas de client préexistant dans la DB. + await page.getByRole('button', { name: /^saisir$/i }).first().click() + await expect(page.getByRole('dialog')).toBeVisible() + + // Taper un nom de client inconnu → la dropdown propose "Créer le client". + const combobox = page.getByPlaceholder(/rechercher.*créer un client/i) + await combobox.fill('Café du Coin Test') + + // L'option "Créer le client « Café du Coin Test »" apparaît. + const createOption = page.getByRole('button', { + name: /créer le client.*café du coin test/i, + }) + await expect(createOption).toBeVisible({ timeout: 3_000 }) + await createOption.click() + + // Après le fix, un champ "Email du client" devient visible dans le + // dialog (conditionnel sur `clientId === null && clientName non vide`). + const emailField = page.getByLabel(/email du client/i) + await expect(emailField).toBeVisible({ timeout: 3_000 }) + await emailField.fill('contact@cafeducoin.test') + + // Reste du form + await page.getByLabel(/N° de facture/i).fill('F-NEWCLI-001') + await page.getByLabel(/Montant TTC/i).fill('850') + + await page.getByLabel(/Plan de relance/i).click() + await page.getByRole('option').first().click() + + await page.getByRole('button', { name: /^créer la facture$/i }).click() + + // Toast success + dialog fermé + await expect(page.getByText(/facture créée/i)).toBeVisible({ timeout: 5_000 }) + + // La facture est visible dans /factures avec le nouveau client + await page.goto('/factures') + await expect(page.getByText('F-NEWCLI-001').first()).toBeVisible({ + timeout: 5_000, + }) + await expect(page.getByText(/café du coin test/i).first()).toBeVisible() + + // Le client a aussi été créé en DB → visible sur /clients + await page.goto('/clients') + await expect(page.getByText(/café du coin test/i).first()).toBeVisible() + await expect(page.getByText('contact@cafeducoin.test').first()).toBeVisible() + }) })