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