diff --git a/apps/web/src/mocks/db.ts b/apps/web/src/mocks/db.ts index 4058411..58217a3 100644 --- a/apps/web/src/mocks/db.ts +++ b/apps/web/src/mocks/db.ts @@ -20,6 +20,10 @@ export type StoredInvoice = Invoice & { /** Champs extraits par l'OCR (et leur version éditée par l'utilisateur). */ export type DraftFields = { + /** Si l'OCR matche un client existant (case-insensitive sur le nom), + * on pré-remplit clientId pour que l'utilisateur n'ait pas à re-sélectionner. + * Sinon null = nouveau client à créer à la validation. */ + clientId: string | null; clientName: string; clientEmail: string | null; numero: string; @@ -98,13 +102,28 @@ const seedDb = (): Db => ({ */ function migrate(stored: Partial): Db { const fresh = seedDb(); + // Les batches d'import seedés avant l'ajout de clientId sur DraftFields + // sont incompatibles avec le combobox (no clientId). Plus simple de les + // jeter à la migration : ils sont éphémères de toute façon (drafts + // pending qu'on ne validera plus). + const importBatches = (stored.importBatches ?? []).map((b) => ({ + ...b, + drafts: b.drafts.map((d) => ({ + ...d, + // Spread des champs stockés AVANT clientId, pour qu'on n'écrase pas + // un clientId déjà présent dans un nouveau snapshot. La défaut `null` + // ne s'applique que pour les vieux snapshots qui n'avaient pas le champ. + extracted: { ...d.extracted, clientId: d.extracted.clientId ?? null }, + edited: { ...d.edited, clientId: d.edited.clientId ?? null }, + })), + })); return { users: stored.users ?? fresh.users, organizations: stored.organizations ?? fresh.organizations, clients: stored.clients ?? fresh.clients, plans: stored.plans ?? fresh.plans, invoices: stored.invoices ?? fresh.invoices, - importBatches: stored.importBatches ?? [], + importBatches, }; } diff --git a/apps/web/src/mocks/handlers/invoices.ts b/apps/web/src/mocks/handlers/invoices.ts index 8c05c94..35f7d18 100644 --- a/apps/web/src/mocks/handlers/invoices.ts +++ b/apps/web/src/mocks/handlers/invoices.ts @@ -67,7 +67,11 @@ function isoDaysFromNow(days: number): string { return d.toISOString(); } -function fakeOcrExtract(filename: string, defaultPlanId: string | null): { +function fakeOcrExtract( + orgId: string, + filename: string, + defaultPlanId: string | null, +): { extracted: DraftFields; confidence: Partial>; } { @@ -79,10 +83,19 @@ function fakeOcrExtract(filename: string, defaultPlanId: string | null): { .replace(/sarl|sa|sas/giu, "") .replace(/[^a-z]+/giu, "-") .replace(/^-+|-+$/gu, ""); + + // Si l'OCR a "extrait" un nom qui matche un client existant, on lie tout + // de suite au clientId : l'utilisateur voit son client déjà sélectionné + // dans le combobox et n'a rien à faire. + const matchedClient = mockDb + .listClientsForOrg(orgId) + .find((c) => c.name.toLowerCase() === clientName.toLowerCase()); + return { extracted: { - clientName, - clientEmail: emailLowConf ? null : `compta@${slug}.fr`, + clientId: matchedClient?.id ?? null, + clientName: matchedClient?.name ?? clientName, + clientEmail: matchedClient?.email ?? (emailLowConf ? null : `compta@${slug}.fr`), numero: randomNumeroFromFilename(filename), amountTtcCents: randomAmountCents(), issueDate: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)), @@ -90,7 +103,7 @@ function fakeOcrExtract(filename: string, defaultPlanId: string | null): { planId: defaultPlanId, }, confidence: { - clientName: 0.95, + clientName: matchedClient ? 1 : 0.95, clientEmail: emailLowConf ? 0.42 : 0.88, numero: 0.97, amountTtcCents: 0.93, @@ -106,6 +119,7 @@ const uploadSchema = z.object({ }); const draftFieldsSchema = z.object({ + clientId: z.string().nullable(), clientName: z.string().min(1).max(120), clientEmail: z.string().email().nullable(), numero: z.string().min(1).max(50), @@ -407,7 +421,7 @@ export const invoiceHandlers = [ const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null; const drafts = parsed.data.filenames.map((filename) => { - const { extracted, confidence } = fakeOcrExtract(filename, defaultPlanId); + const { extracted, confidence } = fakeOcrExtract(orgId, filename, defaultPlanId); return { filename, extracted, confidence }; }); @@ -452,24 +466,50 @@ export const invoiceHandlers = [ } const fields = parsed.data; - // Cherche un client existant par nom (case-insensitive), sinon en crée un. - const clients = mockDb - .listClientsForOrg(orgId) - .find( - (c) => c.name.toLowerCase() === fields.clientName.toLowerCase(), - ); - let clientId = clients?.id; - let clientName = clients?.name ?? fields.clientName; - if (!clientId) { - const newClient = mockDb.createClient(orgId, { - name: fields.clientName, - email: fields.clientEmail, - phone: null, - address: null, - notes: null, - }); - clientId = newClient.id; - clientName = newClient.name; + + // Résolution client (priorité descendante) : + // 1. clientId explicite envoyé depuis le combobox → utilise direct + // 2. match par nom (case-insensitive) sur les clients existants + // 3. création à la volée si rien ne matche + let clientId: string; + let clientName: string; + if (fields.clientId) { + const c = mockDb.findClientById(orgId, fields.clientId); + if (c) { + clientId = c.id; + clientName = c.name; + } else { + // clientId fourni mais introuvable — fallback création + const created = mockDb.createClient(orgId, { + name: fields.clientName, + email: fields.clientEmail, + phone: null, + address: null, + notes: null, + }); + clientId = created.id; + clientName = created.name; + } + } else { + const matched = mockDb + .listClientsForOrg(orgId) + .find( + (c) => c.name.toLowerCase() === fields.clientName.toLowerCase(), + ); + if (matched) { + clientId = matched.id; + clientName = matched.name; + } else { + const created = mockDb.createClient(orgId, { + name: fields.clientName, + email: fields.clientEmail, + phone: null, + address: null, + notes: null, + }); + clientId = created.id; + clientName = created.name; + } } const plan = fields.planId diff --git a/apps/web/src/routes/_app/factures_.import_.$batchId.tsx b/apps/web/src/routes/_app/factures_.import_.$batchId.tsx index 3a90659..da7e8f5 100644 --- a/apps/web/src/routes/_app/factures_.import_.$batchId.tsx +++ b/apps/web/src/routes/_app/factures_.import_.$batchId.tsx @@ -37,8 +37,12 @@ import { SelectValue, } from "@/components/ui/Select"; import { PdfPreview } from "@/components/factures/PdfPreview"; +import { ClientCombobox } from "@/components/factures/ClientCombobox"; type DraftFields = { + /** Si l'OCR matche un client existant, on pré-remplit clientId pour que + * le combobox soit déjà branché à la bonne fiche. Sinon null = création. */ + clientId: string | null; clientName: string; clientEmail: string | null; numero: string; @@ -248,17 +252,28 @@ function ImportReviewPage() {

- {/* Client */} + {/* Client — combobox autocomplete : si le nom matche un client + existant, on lie tout de suite à sa fiche ; sinon proposition + de créer un nouveau client à la validation. */} = 2 + ? "Nouveau client — sera créé à la validation." + : "Tapez pour rechercher ou créer un client." + } error={draft.clientName.length < 2 ? "Au moins 2 caractères" : undefined} > - update({ clientName: e.target.value })} - className={cn(isLowConfidence("clientName") && "border-rubis")} + selectedClientId={draft.clientId} + onChange={({ value, clientId }) => + update({ clientName: value, clientId }) + } />