From 6de2711aa82a81ca8978393a7739f474165c4366 Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Wed, 6 May 2026 11:55:12 +0200
Subject: [PATCH] feat(web): OCR review utilise ClientCombobox au lieu d'un
Input libre
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
L'écran de review OCR avait un Input texte libre pour le nom du client,
ce qui faisait qu'on créait un nouveau client à chaque validation même
quand le nom matchait un client existant — doublons assurés.
Maintenant l'OCR fait le matching en amont :
- L'extraction côté MSW (fakeOcrExtract) cherche un client existant par
nom case-insensitive et pré-remplit clientId dans extracted/edited.
Confidence clientName = 1 quand match (vs 0.95 sinon).
- DraftFields type ajoute clientId: string | null
- draftFieldsSchema (validation) ajoute clientId nullable
Côté UI :
- L'Input clientName devient un ClientCombobox (le même que pour la
saisie manuelle — chunk mutualisé 26 KB gzip)
- Border rubis quand un client existant est sélectionné
- Hint contextuel sur le Field :
· clientId set → "Lié à un client existant ✓"
· clientId null + nom ≥ 2 chars → "Nouveau client — sera créé à la validation."
· Sinon → "Tapez pour rechercher ou créer un client."
Validate handler MSW (résolution client en cascade) :
1. clientId explicite (combobox) → utilise direct, zéro lookup
2. Match par nom case-insensitive sur les clients existants → utilise si match
3. Création à la volée si rien ne matche
Fallback création si clientId fourni mais introuvable.
Migration mockDb : les batches d'import seedés avant l'ajout du champ
sont patchés à load() avec clientId ?? null (spread des données stockées
d'abord pour ne pas écraser les snapshots récents).
Co-Authored-By: Claude Opus 4.7
---
apps/web/src/mocks/db.ts | 21 ++++-
apps/web/src/mocks/handlers/invoices.ts | 86 ++++++++++++++-----
.../_app/factures_.import_.$batchId.tsx | 25 ++++--
3 files changed, 103 insertions(+), 29 deletions(-)
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 })
+ }
/>