feat(web): import OCR — drop fichier → review batch → factures créées

Boucle import complète (cf. wireframe 2.2) :
1. Drop PDF/PNG/JPG sur /factures (dropzone full-page si vide, compact en
   bas si populée, OU drop n'importe où sur la page grâce au drop-catcher
   de route — évite que le browser ouvre le fichier dans un onglet)
2. POST /invoices/upload → MSW génère un batch avec drafts pré-remplis
   (OCR simulé : nom client aléatoire depuis 7 entreprises plausibles,
   montant random, dates calibrées, confidences variables) + délai 800ms
3. Toast "X factures extraites. Vérifions ensemble." + navigate vers
   /factures/import/$batchId
4. Page review step-by-step : PDF preview à gauche + form à droite,
   champs douteux (confidence < 0.7) surlignés border-rubis + hint
   inline, bandeau warning rubis-glow si plusieurs champs incertains
5. Valider & suivante → POST validate → crée la facture en mockDb
   (nouveau client si nom inconnu) + 1 rubis bonus → la suivante
   apparaît automatiquement
6. Skip ou Annuler le batch entier disponibles à tout moment
7. Fin de batch → toast bilan ("X validées · Y ignorées") → /factures

Composants ajoutés :
- PdfPreview : placeholder anti-générique (pas un viewer gris) — header
  mono filename + "page A4" simulée avec barres bg-ink/15 et bg-rubis-glow
- Select : wrapper Radix Select stylé (Trigger / Content / Item) cohérent
  avec Input (1px line, focus rubis-glow ring, item sélectionné rubis + ✓)

Dropzone amélioré :
- Filtre fichier plus tolérant : MIME OU extension (Finder/Explorer
  envoient parfois type === ""), erreur dédiée taille vs format
- Mode isUploading : titre devient "On lit vos factures…", spinner
  sur le bouton Parcourir

MSW handlers (invoices.ts) :
- POST /invoices/upload : crée batch + drafts avec OCR simulé
- GET /invoices/import-batch/:id
- POST /invoices/import-batch/:id/drafts/:draftId/validate
- POST /invoices/import-batch/:id/drafts/:draftId/skip
- DELETE /invoices/import-batch/:id

mockDb étendu :
- importBatches store + StoredImportDraft type
- createImportBatch / findImportBatch / updateImportDraft / deleteImportBatch
- createInvoice / createClient / listClientsForOrg

Bug fix migration :
- Le sessionStorage stockait des snapshots d'avant l'ajout du champ
  importBatches → db.importBatches undefined → push() crashait. Ajout
  d'une migration douce dans load() qui patche les champs manquants
  avec les défauts du seed (pas de perte de données existantes).

Bundle : 117.50 KB gzip core. Route factures_.import._batchId 10.26 KB
gzip — la plus grosse à cause de Radix Select + state form complexe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 11:26:31 +02:00
parent b5b67056aa
commit 86dae64eb4
7 changed files with 1089 additions and 22 deletions

View File

@ -22,22 +22,36 @@ type DropzoneProps = {
maxFiles?: number;
/** Callback quand des fichiers valides ont été sélectionnés. */
onFiles?: (files: File[]) => void;
/** Mode chargement : disable input + remplace le call to action par un spinner. */
isUploading?: boolean;
className?: string;
};
const ACCEPT_ATTR = ACCEPTED_INVOICE_MIME_TYPES.join(",");
function isAcceptableFile(file: File): boolean {
return (
(ACCEPTED_INVOICE_MIME_TYPES as readonly string[]).includes(file.type) &&
file.size <= MAX_INVOICE_FILE_SIZE_BYTES
);
/**
* Vérification souple : on accepte par MIME OU par extension. Certains
* navigateurs (ou drag depuis Finder/Explorer) renvoient un `type` vide,
* il ne faut pas rejeter pour autant. La taille trop grande renvoie un
* message dédié, pas le générique "format non supporté".
*/
type FileCheckResult = { ok: true } | { ok: false; reason: "format" | "size" };
function checkFile(file: File): FileCheckResult {
const acceptableMime =
file.type === "" ||
(ACCEPTED_INVOICE_MIME_TYPES as readonly string[]).includes(file.type);
const acceptableExt = /\.(pdf|png|jpe?g)$/iu.test(file.name);
if (!acceptableMime && !acceptableExt) return { ok: false, reason: "format" };
if (file.size > MAX_INVOICE_FILE_SIZE_BYTES) return { ok: false, reason: "size" };
return { ok: true };
}
export function Dropzone({
variant = "full",
maxFiles = 20,
onFiles,
isUploading = false,
className,
}: DropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
@ -46,22 +60,32 @@ export function Dropzone({
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList) return;
if (!fileList || isUploading) return;
const files = Array.from(fileList);
if (files.length === 0) return;
if (files.length > maxFiles) {
setError(`Maximum ${maxFiles} fichiers en un seul drop.`);
return;
}
const valid = files.filter(isAcceptableFile);
const rejected = files.length - valid.length;
if (rejected > 0 && valid.length === 0) {
setError("Format non supporté. PDF, PNG, JPG seulement.");
const checks = files.map((f) => ({ file: f, ...checkFile(f) }));
const valid = checks.filter((c) => c.ok).map((c) => c.file);
const tooBig = checks.filter((c) => !c.ok && c.reason === "size").length;
const wrongFormat = checks.filter(
(c) => !c.ok && c.reason === "format",
).length;
if (valid.length === 0) {
if (tooBig > 0) {
setError(`Fichier trop lourd · 10 Mo maximum par fichier.`);
} else if (wrongFormat > 0) {
setError("Format non supporté. PDF, PNG, JPG uniquement.");
}
return;
}
setError(null);
onFiles?.(valid);
},
[maxFiles, onFiles],
[maxFiles, onFiles, isUploading],
);
const onDragOver = (e: React.DragEvent) => {
@ -116,7 +140,11 @@ export function Dropzone({
isFull ? "text-[22px] font-semibold" : "text-[17px] font-semibold",
)}
>
{isDragging ? (
{isUploading ? (
<>
On <em className="text-rubis">lit</em> vos factures
</>
) : isDragging ? (
<>
Lâchez ici, on s&apos;<em className="text-rubis">occupe</em> du reste.
</>
@ -136,10 +164,12 @@ export function Dropzone({
size="sm"
onClick={() => inputRef.current?.click()}
type="button"
loading={isUploading}
disabled={isUploading}
>
<FolderOpen size={14} aria-hidden="true" /> Parcourir mes fichiers
</Button>
{isFull && (
{isFull && !isUploading && (
<Button variant="ghost" size="sm" type="button">
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
</Button>

View File

@ -0,0 +1,69 @@
import { FileText } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Aperçu PDF côté review OCR placeholder visuel.
*
* V1 : affiche le nom du fichier + une "preview" abstraite (barres) qui
* suggèrent un document. Le vrai render PDF (via react-pdf, pdf.js ou un
* iframe avec object URL) viendra quand le backend stockera réellement
* les fichiers dans MinIO.
*
* Anti-IA-look : pas un viewer générique gris/blanc fond cream-2 avec
* des barres rubis-glow pour suggérer le contenu et garder l'identité.
*/
type PdfPreviewProps = {
filename: string;
className?: string;
};
export function PdfPreview({ filename, className }: PdfPreviewProps) {
return (
<div
className={cn(
"flex flex-col rounded-card border border-line bg-white overflow-hidden",
className,
)}
>
<div className="flex items-center gap-2 border-b border-line bg-cream-2/60 px-4 py-2.5">
<FileText size={14} className="text-ink-3" aria-hidden="true" />
<span className="font-mono text-[12px] text-ink-2 truncate">
{filename}
</span>
</div>
{/* Pseudo-page A4 ratio. Les "barres" simulent du contenu OCRisé. */}
<div className="flex-1 bg-cream/60 p-7 min-h-[420px]">
<div className="space-y-2.5">
<div className="h-3 w-1/2 rounded-sharp bg-ink/15" />
<div className="h-3 w-1/3 rounded-sharp bg-ink/8" />
</div>
<div className="mt-7 space-y-2">
<div className="h-2.5 w-3/4 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-2/3 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-1/2 rounded-sharp bg-ink/10" />
</div>
<div className="mt-9 grid grid-cols-2 gap-3">
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="h-7 rounded-sharp bg-cream-2" />
<div className="h-7 rounded-sharp bg-cream-2" />
</div>
<div className="mt-9 space-y-2">
<div className="h-2.5 w-2/3 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-1/2 rounded-sharp bg-ink/10" />
</div>
<div className="mt-7 flex justify-end">
<div className="h-9 w-1/3 rounded-sharp bg-ink/15" />
</div>
</div>
<p className="border-t border-line bg-cream-2/40 px-4 py-2 text-[11px] italic text-ink-3">
Aperçu simplifié le rendu PDF complet arrivera avec le vrai pipeline
d&apos;import.
</p>
</div>
);
}

View File

@ -0,0 +1,94 @@
import { forwardRef } from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Select stylé maison à partir des primitives Radix.
* Style aligné sur Input (1px line, focus ring rubis-glow, radius default 6px).
*
* Usage minimaliste :
* <Select value={planId} onValueChange={setPlanId}>
* <SelectTrigger>
* <SelectValue placeholder="Choisir un plan…" />
* </SelectTrigger>
* <SelectContent>
* <SelectItem value="plan_x">Standard B2B</SelectItem>
* </SelectContent>
* </Select>
*/
export const Select = SelectPrimitive.Root;
export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-default border border-line bg-white",
"px-3.5 py-3 font-sans text-[15px] text-ink",
"transition-[border-color,box-shadow] duration-150",
"focus:outline-none focus:border-rubis focus:ring-4 focus:ring-rubis-glow",
"disabled:cursor-not-allowed disabled:bg-cream-2 disabled:text-ink-3",
"data-[placeholder]:text-ink-3",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown size={16} className="text-ink-3 shrink-0" aria-hidden="true" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
export const SelectContent = forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
sideOffset={6}
className={cn(
"relative z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden",
"rounded-card border border-line bg-white shadow-card",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export const SelectItem = forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-default",
"px-3 py-2 pr-8 text-[14px] text-ink outline-none",
"data-[highlighted]:bg-cream-2 data-[highlighted]:text-ink",
"data-[state=checked]:text-rubis data-[state=checked]:font-medium",
"data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
className,
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="absolute right-2.5 inline-flex">
<Check size={14} className="text-rubis" aria-hidden="true" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

View File

@ -18,12 +18,46 @@ export type StoredInvoice = Invoice & {
statusLabel?: string;
};
/** Champs extraits par l'OCR (et leur version éditée par l'utilisateur). */
export type DraftFields = {
clientName: string;
clientEmail: string | null;
numero: string;
amountTtcCents: number;
issueDate: string;
dueDate: string;
planId: string | null;
};
/** Brouillon d'une facture en cours de review. */
export type StoredImportDraft = {
id: string;
filename: string;
/** Champs initiaux extraits par "l'OCR" (notre mock). */
extracted: DraftFields;
/** Champs édités par l'utilisateur. Initialement = extracted. */
edited: DraftFields;
/** Confiance OCR par champ (0-1). Sert à signaler les champs douteux. */
confidence: Partial<Record<keyof DraftFields, number>>;
status: "pending" | "validated" | "skipped";
/** Si validé, l'id de l'invoice créée. */
invoiceId?: string;
};
export type StoredImportBatch = {
id: string;
organizationId: string;
drafts: StoredImportDraft[];
createdAt: string;
};
type Db = {
users: StoredUser[];
organizations: Organization[];
clients: Client[];
plans: Plan[];
invoices: StoredInvoice[];
importBatches: StoredImportBatch[];
};
const seedDb = (): Db => ({
@ -53,8 +87,27 @@ const seedDb = (): Db => ({
clients: SEED_CLIENTS,
plans: SEED_PLANS,
invoices: SEED_INVOICES,
importBatches: [],
});
/**
* Migration douce : si un champ top-level a é ajouté au schema entre
* deux runs (ex. importBatches arrivé tardivement), on patche le snapshot
* stocké avec le défaut du seed plutôt que de tout perdre. Évite les
* `TypeError: Cannot read properties of undefined` pendant le dev.
*/
function migrate(stored: Partial<Db>): Db {
const fresh = seedDb();
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 ?? [],
};
}
function load(): Db {
if (typeof sessionStorage === "undefined") return seedDb();
const raw = sessionStorage.getItem(STORAGE_KEY);
@ -64,7 +117,11 @@ function load(): Db {
return fresh;
}
try {
return JSON.parse(raw) as Db;
const stored = JSON.parse(raw) as Partial<Db>;
const migrated = migrate(stored);
// Persiste la version migrée pour ne pas refaire le merge à chaque load.
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(migrated));
return migrated;
} catch {
const fresh = seedDb();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh));
@ -157,6 +214,26 @@ export const mockDb = {
findClientById(orgId: string, id: string): Client | undefined {
return load().clients.find((c) => c.organizationId === orgId && c.id === id);
},
listClientsForOrg(orgId: string): Client[] {
return load().clients.filter((c) => c.organizationId === orgId);
},
createClient(
orgId: string,
input: Omit<Client, "id" | "organizationId" | "createdAt" | "updatedAt">,
): Client {
const db = load();
const now = new Date().toISOString();
const client: Client = {
...input,
id: `cli_${crypto.randomUUID()}`,
organizationId: orgId,
createdAt: now,
updatedAt: now,
};
db.clients.push(client);
save(db);
return client;
},
// === Plans ===
findPlanById(orgId: string, id: string): Plan | undefined {
@ -195,6 +272,20 @@ export const mockDb = {
listInvoicesForOrg(orgId: string): StoredInvoice[] {
return load().invoices.filter((i) => i.organizationId === orgId);
},
createInvoice(orgId: string, input: Omit<StoredInvoice, "id" | "organizationId" | "createdAt" | "updatedAt">): StoredInvoice {
const db = load();
const now = new Date().toISOString();
const invoice: StoredInvoice = {
...input,
id: `inv_${crypto.randomUUID()}`,
organizationId: orgId,
createdAt: now,
updatedAt: now,
};
db.invoices.push(invoice);
save(db);
return invoice;
},
findInvoiceById(orgId: string, id: string): StoredInvoice | undefined {
return load().invoices.find((i) => i.organizationId === orgId && i.id === id);
},
@ -219,6 +310,63 @@ export const mockDb = {
return updated;
},
// === Import batches (OCR review flow) ===
createImportBatch(
orgId: string,
drafts: Omit<StoredImportDraft, "id" | "status" | "edited">[],
): StoredImportBatch {
const db = load();
const batch: StoredImportBatch = {
id: `batch_${crypto.randomUUID()}`,
organizationId: orgId,
createdAt: new Date().toISOString(),
drafts: drafts.map((d) => ({
...d,
id: `draft_${crypto.randomUUID()}`,
edited: { ...d.extracted },
status: "pending" as const,
})),
};
db.importBatches.push(batch);
save(db);
return batch;
},
findImportBatch(orgId: string, id: string): StoredImportBatch | undefined {
return load().importBatches.find(
(b) => b.organizationId === orgId && b.id === id,
);
},
updateImportDraft(
orgId: string,
batchId: string,
draftId: string,
patch: Partial<StoredImportDraft>,
): StoredImportDraft | undefined {
const db = load();
const batch = db.importBatches.find(
(b) => b.organizationId === orgId && b.id === batchId,
);
if (!batch) return undefined;
const idx = batch.drafts.findIndex((d) => d.id === draftId);
if (idx === -1) return undefined;
const updated = { ...batch.drafts[idx]!, ...patch };
batch.drafts[idx] = updated;
save(db);
return updated;
},
deleteImportBatch(orgId: string, id: string): boolean {
const db = load();
const before = db.importBatches.length;
db.importBatches = db.importBatches.filter(
(b) => !(b.organizationId === orgId && b.id === id),
);
if (db.importBatches.length < before) {
save(db);
return true;
}
return false;
},
reset(): void {
if (typeof sessionStorage !== "undefined") {
sessionStorage.removeItem(STORAGE_KEY);

View File

@ -1,8 +1,9 @@
import { http, HttpResponse } from "msw";
import { z } from "zod";
import { invoiceListFiltersSchema } from "@rubis/shared";
import type { Client, InvoiceStatus, Plan, PlanStep } from "@rubis/shared";
import { mockDb, type StoredInvoice } from "../db";
import { mockDb, type DraftFields, type StoredInvoice } from "../db";
import { userIdFromAuthHeader } from "./auth";
const apiBase = "*/api/v1";
@ -27,6 +28,93 @@ function authedOrgId(authHeader: string | null): string | undefined {
return mockDb.findUserById(userId)?.organizationId;
}
/* ----------------------------------------------------------------------------
* OCR mock extraction "intelligente" depuis un nom de fichier.
* Pour faire vivre la démo, on génère des champs plausibles et on injecte
* volontairement quelques confidences basses pour signaler "champ douteux".
* -------------------------------------------------------------------------- */
const SAMPLE_CLIENT_NAMES = [
"Boulangerie Martin SARL",
"Atelier Durand",
"Cabinet Rousseau",
"Garage Lemoine",
"Studio Lefèvre",
"Pharmacie Bertrand",
"Imprimerie Moreau",
];
function rand<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
function randomAmountCents(): number {
// entre 80 € et 8 000 €, multiple de 50 cents pour rester crédible
return Math.floor((Math.random() * 7920 + 80) * 100 / 50) * 50;
}
function randomNumeroFromFilename(filename: string): string {
const match = filename.match(/(\d{2,5})/u);
const yr = new Date().getFullYear();
const seq = match?.[1] ?? Math.floor(Math.random() * 9000 + 1000).toString();
return `F-${yr}-${seq.padStart(4, "0")}`;
}
function isoDaysFromNow(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(9, 0, 0, 0);
return d.toISOString();
}
function fakeOcrExtract(filename: string, defaultPlanId: string | null): {
extracted: DraftFields;
confidence: Partial<Record<keyof DraftFields, number>>;
} {
const clientName = rand(SAMPLE_CLIENT_NAMES);
// 30 % de chance d'avoir un email douteux (low confidence)
const emailLowConf = Math.random() < 0.3;
const slug = clientName
.toLowerCase()
.replace(/sarl|sa|sas/giu, "")
.replace(/[^a-z]+/giu, "-")
.replace(/^-+|-+$/gu, "");
return {
extracted: {
clientName,
clientEmail: emailLowConf ? null : `compta@${slug}.fr`,
numero: randomNumeroFromFilename(filename),
amountTtcCents: randomAmountCents(),
issueDate: isoDaysFromNow(-15 - Math.floor(Math.random() * 10)),
dueDate: isoDaysFromNow(15 + Math.floor(Math.random() * 20)),
planId: defaultPlanId,
},
confidence: {
clientName: 0.95,
clientEmail: emailLowConf ? 0.42 : 0.88,
numero: 0.97,
amountTtcCents: 0.93,
issueDate: 0.9,
dueDate: emailLowConf ? 0.65 : 0.92,
planId: 1,
},
};
}
const uploadSchema = z.object({
filenames: z.array(z.string().min(1)).min(1).max(20),
});
const draftFieldsSchema = z.object({
clientName: z.string().min(1).max(120),
clientEmail: z.string().email().nullable(),
numero: z.string().min(1).max(50),
amountTtcCents: z.number().int().positive(),
issueDate: z.string().datetime(),
dueDate: z.string().datetime(),
planId: z.string().nullable(),
});
/**
* Construit la timeline d'une facture en composant les étapes du plan
* avec l'état courant. Très simplifié pour V1 :
@ -207,6 +295,152 @@ export const invoiceHandlers = [
});
}),
// POST /api/v1/invoices/upload — démarre un batch OCR
http.post(`${apiBase}/invoices/upload`, async ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const json = await request.json();
const parsed = uploadSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
})),
},
{ status: 422 },
);
}
// Plan par défaut = le premier "isDefault" disponible
const plans = mockDb.listPlansForOrg(orgId);
const defaultPlanId = plans.find((p) => p.isDefault)?.id ?? null;
const drafts = parsed.data.filenames.map((filename) => {
const { extracted, confidence } = fakeOcrExtract(filename, defaultPlanId);
return { filename, extracted, confidence };
});
const batch = mockDb.createImportBatch(orgId, drafts);
// Petit délai pour simuler le temps de l'OCR (perceptible UX-wise)
await new Promise((r) => setTimeout(r, 800));
return HttpResponse.json({ data: batch }, { status: 201 });
}),
// GET /api/v1/invoices/import-batch/:id
http.get(`${apiBase}/invoices/import-batch/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const batch = mockDb.findImportBatch(orgId, params.id as string);
if (!batch) return notFound();
return HttpResponse.json({ data: batch });
}),
// POST /api/v1/invoices/import-batch/:id/drafts/:draftId/validate
http.post(
`${apiBase}/invoices/import-batch/:id/drafts/:draftId/validate`,
async ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const json = await request.json();
const parsed = draftFieldsSchema.safeParse(json);
if (!parsed.success) {
return HttpResponse.json(
{
errors: parsed.error.issues.map((i) => ({
code: "validation_failed",
message: i.message,
field: i.path.join("."),
})),
},
{ status: 422 },
);
}
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;
}
const plan = fields.planId
? mockDb.findPlanById(orgId, fields.planId)
: null;
const invoice = mockDb.createInvoice(orgId, {
clientId,
clientName,
numero: fields.numero,
amountTtcCents: fields.amountTtcCents,
issueDate: fields.issueDate,
dueDate: fields.dueDate,
status: "pending",
planId: plan?.id ?? null,
planName: plan?.name ?? null,
pdfStorageKey: null,
notes: null,
rubisEarned: 1, // bonus rubis : 1 import = 1 rubis
});
mockDb.updateImportDraft(orgId, params.id as string, params.draftId as string, {
status: "validated",
edited: fields,
invoiceId: invoice.id,
});
return HttpResponse.json({ data: invoice }, { status: 201 });
},
),
// POST /api/v1/invoices/import-batch/:id/drafts/:draftId/skip
http.post(
`${apiBase}/invoices/import-batch/:id/drafts/:draftId/skip`,
({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const draft = mockDb.updateImportDraft(
orgId,
params.id as string,
params.draftId as string,
{ status: "skipped" },
);
if (!draft) return notFound();
return HttpResponse.json({ data: draft });
},
),
// DELETE /api/v1/invoices/import-batch/:id — annule le batch entier
http.delete(`${apiBase}/invoices/import-batch/:id`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const ok = mockDb.deleteImportBatch(orgId, params.id as string);
if (!ok) return notFound();
return new HttpResponse(null, { status: 204 });
}),
// POST /api/v1/invoices/:id/mark-paid
http.post(`${apiBase}/invoices/:id/mark-paid`, ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));

View File

@ -1,5 +1,6 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { z } from "zod";
import {
@ -17,6 +18,11 @@ import {
} from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
type ImportBatchResponse = {
id: string;
drafts: Array<{ id: string; filename: string }>;
};
/** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */
const FILTER_KEYS = [
"all",
@ -54,9 +60,50 @@ export const Route = createFileRoute("/_app/factures")({
},
});
function useUploadInvoices() {
const navigate = useNavigate();
return useMutation({
mutationFn: (files: File[]) =>
api.post<ImportBatchResponse>("/api/v1/invoices/upload", {
filenames: files.map((f) => f.name),
}),
onSuccess: (batch) => {
toast.success(
`${batch.drafts.length} facture${batch.drafts.length > 1 ? "s" : ""} extraite${
batch.drafts.length > 1 ? "s" : ""
}. Vérifions ensemble.`,
);
void navigate({
to: "/factures/import/$batchId",
params: { batchId: batch.id },
});
},
onError: () => {
toast.error("L'upload a échoué. Réessayez dans un instant.");
},
});
}
function FacturesPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const upload = useUploadInvoices();
// Drop-catcher global au niveau de la route : si l'utilisateur lâche un
// fichier hors de la dropzone (ailleurs sur /factures), on intercepte
// pour éviter que le navigateur ouvre le PDF dans un nouvel onglet, et
// on déclenche l'upload comme si le drop avait visé la dropzone.
const onPageDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
};
const onPageDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
const files = Array.from(e.dataTransfer.files).filter(
(f) => /\.(pdf|png|jpe?g)$/iu.test(f.name) || f.type !== "",
);
if (files.length > 0 && !upload.isPending) upload.mutate(files);
};
const { data: invoices = [], isPending } = useQuery({
queryKey: queryKeys.invoices.list({
@ -82,13 +129,18 @@ function FacturesPage() {
queryFn: () => api.get<StatusCounts>("/api/v1/invoices/counts"),
});
// Empty state global = aucune facture du tout (pas juste filtre vide).
// Pour l'instant on l'estime via counts.all === 0.
const totalInvoices = counts?.all ?? null;
const isFilteredEmpty = invoices.length === 0 && totalInvoices !== 0;
if (totalInvoices === 0) {
return <FacturesEmpty />;
return (
<div onDragOver={onPageDragOver} onDrop={onPageDrop}>
<FacturesEmpty
onFiles={(files) => upload.mutate(files)}
isUploading={upload.isPending}
/>
</div>
);
}
const filterOptions: ReadonlyArray<FilterOption<FilterKey>> = [
@ -112,7 +164,11 @@ function FacturesPage() {
};
return (
<div className="flex flex-col gap-5">
<div
className="flex flex-col gap-5"
onDragOver={onPageDragOver}
onDrop={onPageDrop}
>
<div>
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
Factures{" "}
@ -144,6 +200,16 @@ function FacturesPage() {
<div className="lg:hidden">
<InvoiceCardList invoices={invoices} />
</div>
{/* Compact dropzone en bas toujours là pour drag-and-drop rapide
sans avoir à vider la liste. */}
<div className="mt-4">
<Dropzone
variant="compact"
onFiles={(files) => upload.mutate(files)}
isUploading={upload.isPending}
/>
</div>
</>
)}
</div>
@ -151,7 +217,13 @@ function FacturesPage() {
}
/** Empty state global : pas une seule facture. La dropzone EST l'écran. */
function FacturesEmpty() {
function FacturesEmpty({
onFiles,
isUploading,
}: {
onFiles: (files: File[]) => void;
isUploading: boolean;
}) {
return (
<div className="flex flex-col gap-5">
<div>
@ -163,7 +235,7 @@ function FacturesEmpty() {
en 30 secondes.
</p>
</div>
<Dropzone variant="full" />
<Dropzone variant="full" onFiles={onFiles} isUploading={isUploading} />
</div>
);
}

View File

@ -0,0 +1,420 @@
import { useEffect, useMemo, useState } from "react";
import {
createFileRoute,
Link,
useNavigate,
} from "@tanstack/react-router";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
X,
Check,
ArrowRight,
AlertTriangle,
SkipForward,
} from "lucide-react";
import { toast } from "sonner";
import type { Invoice, Plan } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { formatEuros } from "@/lib/format";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/Select";
import { PdfPreview } from "@/components/factures/PdfPreview";
type DraftFields = {
clientName: string;
clientEmail: string | null;
numero: string;
amountTtcCents: number;
issueDate: string;
dueDate: string;
planId: string | null;
};
type ImportDraft = {
id: string;
filename: string;
extracted: DraftFields;
edited: DraftFields;
confidence: Partial<Record<keyof DraftFields, number>>;
status: "pending" | "validated" | "skipped";
};
type ImportBatch = {
id: string;
drafts: ImportDraft[];
createdAt: string;
};
export const Route = createFileRoute("/_app/factures_/import/$batchId")({
component: ImportReviewPage,
loader: ({ context, params }) => {
void context.queryClient.prefetchQuery({
queryKey: ["import-batch", params.batchId] as const,
queryFn: () => api.get<ImportBatch>(`/api/v1/invoices/import-batch/${params.batchId}`),
});
void context.queryClient.prefetchQuery({
queryKey: queryKeys.plans.all(),
queryFn: () => api.get<Plan[]>("/api/v1/plans"),
});
},
});
const LOW_CONFIDENCE = 0.7;
function ImportReviewPage() {
const { batchId } = Route.useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: batch, isPending, isError } = useQuery({
queryKey: ["import-batch", batchId] as const,
queryFn: () => api.get<ImportBatch>(`/api/v1/invoices/import-batch/${batchId}`),
});
const { data: plans = [] } = useQuery({
queryKey: queryKeys.plans.all(),
queryFn: () => api.get<Plan[]>("/api/v1/plans"),
});
// Le brouillon courant à éditer = le 1er draft pending. Quand l'utilisateur
// valide ou skip, MSW met à jour le statut, et le prochain pending devient
// automatiquement actif au refetch.
const pendingDrafts = useMemo(
() => batch?.drafts.filter((d) => d.status === "pending") ?? [],
[batch],
);
const currentDraft = pendingDrafts[0];
const completedCount =
batch?.drafts.filter((d) => d.status !== "pending").length ?? 0;
const totalCount = batch?.drafts.length ?? 0;
// État local pour les éditions du draft courant. Reset quand le draft change.
const [draft, setDraft] = useState<DraftFields | null>(null);
useEffect(() => {
setDraft(currentDraft ? { ...currentDraft.edited } : null);
}, [currentDraft?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const validateMutation = useMutation({
mutationFn: (input: DraftFields) =>
api.post<Invoice>(
`/api/v1/invoices/import-batch/${batchId}/drafts/${currentDraft?.id}/validate`,
input,
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["import-batch", batchId] });
void queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() });
void queryClient.invalidateQueries({ queryKey: ["invoices", "counts"] });
toast.success("Facture validée. + 1 rubis.");
},
onError: () => {
toast.error("Impossible de valider la facture. Vérifiez les champs.");
},
});
const skipMutation = useMutation({
mutationFn: () =>
api.post<unknown>(
`/api/v1/invoices/import-batch/${batchId}/drafts/${currentDraft?.id}/skip`,
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["import-batch", batchId] });
toast("Facture ignorée.");
},
});
const cancelBatchMutation = useMutation({
mutationFn: () => api.delete<void>(`/api/v1/invoices/import-batch/${batchId}`),
onSuccess: () => {
toast("Import annulé.");
void navigate({ to: "/factures" });
},
});
// Quand tous les drafts sont processed, on file vers /factures avec un toast
// bilan. On utilise un effect pour ne déclencher qu'une fois.
const allProcessed =
!!batch && batch.drafts.length > 0 && pendingDrafts.length === 0;
useEffect(() => {
if (!allProcessed || !batch) return;
const validated = batch.drafts.filter((d) => d.status === "validated").length;
const skipped = batch.drafts.filter((d) => d.status === "skipped").length;
toast.success(
validated > 0
? `${validated} facture${validated > 1 ? "s" : ""} ajoutée${
validated > 1 ? "s" : ""
}${skipped > 0 ? ` · ${skipped} ignorée${skipped > 1 ? "s" : ""}` : ""}.`
: "Import terminé sans facture validée.",
);
void navigate({ to: "/factures" });
}, [allProcessed]); // eslint-disable-line react-hooks/exhaustive-deps
if (isError) {
return (
<div className="text-center py-12">
<p className="font-display text-[20px] font-semibold text-ink">
Lot d&apos;import introuvable.
</p>
<Link
to="/factures"
className="mt-3 inline-block text-[13px] text-rubis underline-offset-4 hover:underline"
>
Retour aux factures
</Link>
</div>
);
}
if (isPending || !batch || !currentDraft || !draft) {
return <ImportSkeleton />;
}
const indexInBatch =
batch.drafts.findIndex((d) => d.id === currentDraft.id) + 1;
const update = (patch: Partial<DraftFields>) =>
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
const isLowConfidence = (key: keyof DraftFields) =>
(currentDraft.confidence[key] ?? 1) < LOW_CONFIDENCE;
return (
<div className="flex flex-col gap-5">
{/* === Header === */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<Eyebrow>
Import OCR · facture {indexInBatch} sur {totalCount}
</Eyebrow>
<h1 className="mt-2 font-display text-[24px] font-bold tracking-[-0.022em] text-ink lg:text-[26px]">
Vérifiez les <em className="text-rubis">infos extraites</em>
</h1>
<p className="mt-1 text-[13px] text-ink-3">
Modifiez si l&apos;OCR s&apos;est trompé, puis validez. On continue
avec la suivante automatiquement.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => cancelBatchMutation.mutate()}
loading={cancelBatchMutation.isPending}
>
<X size={14} aria-hidden="true" /> Annuler l&apos;import
</Button>
</div>
{/* Progression du batch */}
<div className="flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-cream-2">
<div
className="h-full bg-rubis transition-[width] duration-300"
style={{ width: `${(completedCount / totalCount) * 100}%` }}
/>
</div>
<span className="text-[11.5px] tabular-nums text-ink-3">
{completedCount}/{totalCount}
</span>
</div>
{/* === Body : 2 cols === */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.1fr]">
<PdfPreview filename={currentDraft.filename} />
<Card padding="md" className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<Eyebrow tone="ink">Champs extraits</Eyebrow>
<p className="text-[12px] text-ink-3">
Les champs en rubis sont à <em className="not-italic font-medium">vérifier en priorité</em>{" "}
l&apos;OCR n&apos;était pas sûr.
</p>
</div>
{/* Client */}
<Field
label="Nom du client"
htmlFor="clientName"
error={draft.clientName.length < 2 ? "Au moins 2 caractères" : undefined}
>
<Input
id="clientName"
value={draft.clientName}
onChange={(e) => update({ clientName: e.target.value })}
className={cn(isLowConfidence("clientName") && "border-rubis")}
/>
</Field>
{/* Email + Numéro côte-à-côte */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field
label="Email du contact"
htmlFor="clientEmail"
hint={
isLowConfidence("clientEmail")
? "OCR peu sûr — vérifiez."
: undefined
}
>
<Input
id="clientEmail"
type="email"
placeholder="compta@…"
value={draft.clientEmail ?? ""}
onChange={(e) =>
update({ clientEmail: e.target.value || null })
}
className={cn(isLowConfidence("clientEmail") && "border-rubis")}
/>
</Field>
<Field label="Numéro de facture" htmlFor="numero">
<Input
id="numero"
value={draft.numero}
onChange={(e) => update({ numero: e.target.value })}
className={cn(isLowConfidence("numero") && "border-rubis")}
/>
</Field>
</div>
{/* Montant + Échéance */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field
label="Montant TTC"
htmlFor="amount"
hint={`= ${formatEuros(draft.amountTtcCents)}`}
>
<Input
id="amount"
type="number"
step={0.01}
min={0}
value={(draft.amountTtcCents / 100).toFixed(2)}
onChange={(e) =>
update({
amountTtcCents: Math.round(
parseFloat(e.target.value || "0") * 100,
),
})
}
className={cn(isLowConfidence("amountTtcCents") && "border-rubis")}
/>
</Field>
<Field label="Date d'échéance" htmlFor="dueDate">
<Input
id="dueDate"
type="date"
value={draft.dueDate.slice(0, 10)}
onChange={(e) =>
update({
dueDate: new Date(e.target.value).toISOString(),
})
}
className={cn(isLowConfidence("dueDate") && "border-rubis")}
/>
</Field>
</div>
{/* Plan */}
<Field
label="Plan de relance"
hint="Vous pouvez changer plus tard depuis la fiche facture."
>
<Select
value={draft.planId ?? ""}
onValueChange={(v) => update({ planId: v || null })}
>
<SelectTrigger>
<SelectValue placeholder="Choisir un plan…" />
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{/* Hint global si plusieurs champs douteux */}
{Object.entries(currentDraft.confidence).some(
([, c]) => (c ?? 1) < LOW_CONFIDENCE,
) && (
<div className="flex items-start gap-2 rounded-default bg-rubis-glow/60 px-3 py-2 text-[12.5px] text-rubis-deep">
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
<p>
Quelques champs sont surlignés en rubis l&apos;OCR n&apos;était
pas confiant. Un coup d&apos;œil rapide et c&apos;est bon.
</p>
</div>
)}
</Card>
</div>
{/* === Actions footer === */}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => skipMutation.mutate()}
loading={skipMutation.isPending}
>
<SkipForward size={14} aria-hidden="true" /> Ignorer cette facture
</Button>
<Button
size="md"
loading={validateMutation.isPending}
onClick={() => validateMutation.mutate(draft)}
disabled={draft.clientName.length < 2 || draft.numero.length < 1}
>
{indexInBatch < totalCount ? (
<>
<Check size={15} aria-hidden="true" /> Valider & suivante{" "}
<ArrowRight size={14} aria-hidden="true" />
</>
) : (
<>
<Check size={15} aria-hidden="true" /> Valider & terminer
</>
)}
</Button>
</div>
</div>
);
}
function ImportSkeleton() {
return (
<div className="flex flex-col gap-5 animate-pulse">
<div className="space-y-2">
<div className="h-3 w-32 rounded bg-cream-2" />
<div className="h-7 w-1/2 rounded bg-cream-2" />
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.1fr]">
<div className="h-[460px] rounded-card bg-cream-2" />
<div className="h-[460px] rounded-card bg-cream-2" />
</div>
</div>
);
}