fix(invoices): dialog facture demande l'email du client à la volée
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 40s
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 40s
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 <noreply@anthropic.com>
This commit is contained in:
parent
5b8898ba9c
commit
1e2eecdeba
@ -55,6 +55,12 @@ const RELATIVE_DUE_LABELS: Record<RelativeDueDays, string> = {
|
||||
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.
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
@ -214,6 +235,47 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
{/* === 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"). === */}
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
clientId: state.values.clientId,
|
||||
clientName: state.values.clientName,
|
||||
})}
|
||||
>
|
||||
{({ clientId, clientName }) => {
|
||||
const showEmail =
|
||||
!clientId && clientName.trim().length >= 2;
|
||||
if (!showEmail) return null;
|
||||
return (
|
||||
<form.Field
|
||||
name="clientEmail"
|
||||
validators={{ onChange: validators.clientEmail }}
|
||||
>
|
||||
{(field) => (
|
||||
<Field
|
||||
label="Email du client"
|
||||
htmlFor={field.name}
|
||||
hint="Obligatoire — Rubis enverra les relances à cette adresse."
|
||||
error={firstError(field.state.meta.errors)}
|
||||
>
|
||||
<Input
|
||||
id={field.name}
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder="contact@entreprise.fr"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
|
||||
{/* === N° + Date d'émission === */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<form.Field
|
||||
|
||||
@ -98,4 +98,66 @@ test.describe('Factures — saisie manuelle via dialog', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user