diff --git a/apps/web/src/components/factures/Dropzone.tsx b/apps/web/src/components/factures/Dropzone.tsx
index cf9f3b9..e183296 100644
--- a/apps/web/src/components/factures/Dropzone.tsx
+++ b/apps/web/src/components/factures/Dropzone.tsx
@@ -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 lit vos factures…
+ >
+ ) : isDragging ? (
<>
Lâchez ici, on s'occupe du reste.
>
@@ -136,10 +164,12 @@ export function Dropzone({
size="sm"
onClick={() => inputRef.current?.click()}
type="button"
+ loading={isUploading}
+ disabled={isUploading}
>
Parcourir mes fichiers
- {isFull && (
+ {isFull && !isUploading && (
diff --git a/apps/web/src/components/factures/PdfPreview.tsx b/apps/web/src/components/factures/PdfPreview.tsx
new file mode 100644
index 0000000..6509bed
--- /dev/null
+++ b/apps/web/src/components/factures/PdfPreview.tsx
@@ -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 (
+
+
+
+
+ {filename}
+
+
+
+ {/* Pseudo-page A4 ratio. Les "barres" simulent du contenu OCRisé. */}
+
+
+
+ Aperçu simplifié — le rendu PDF complet arrivera avec le vrai pipeline
+ d'import.
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/Select.tsx b/apps/web/src/components/ui/Select.tsx
new file mode 100644
index 0000000..6157ff2
--- /dev/null
+++ b/apps/web/src/components/ui/Select.tsx
@@ -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 :
+ *
+ */
+export const Select = SelectPrimitive.Root;
+export const SelectGroup = SelectPrimitive.Group;
+export const SelectValue = SelectPrimitive.Value;
+
+export const SelectTrigger = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+export const SelectContent = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+ {children}
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+export const SelectItem = forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
diff --git a/apps/web/src/mocks/db.ts b/apps/web/src/mocks/db.ts
index c7d8df8..4058411 100644
--- a/apps/web/src/mocks/db.ts
+++ b/apps/web/src/mocks/db.ts
@@ -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>;
+ 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 été 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 {
+ 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;
+ 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 {
+ 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 {
+ 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[],
+ ): 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 | 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);
diff --git a/apps/web/src/mocks/handlers/invoices.ts b/apps/web/src/mocks/handlers/invoices.ts
index f394a68..a9bb85e 100644
--- a/apps/web/src/mocks/handlers/invoices.ts
+++ b/apps/web/src/mocks/handlers/invoices.ts
@@ -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(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>;
+} {
+ 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"));
diff --git a/apps/web/src/routes/_app/factures.tsx b/apps/web/src/routes/_app/factures.tsx
index 21a9bb3..bcb41ed 100644
--- a/apps/web/src/routes/_app/factures.tsx
+++ b/apps/web/src/routes/_app/factures.tsx
@@ -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("/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) => {
+ if (e.dataTransfer.types.includes("Files")) e.preventDefault();
+ };
+ const onPageDrop = (e: React.DragEvent) => {
+ 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("/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 ;
+ return (
+
+ upload.mutate(files)}
+ isUploading={upload.isPending}
+ />
+
+ );
}
const filterOptions: ReadonlyArray> = [
@@ -112,7 +164,11 @@ function FacturesPage() {
};
return (
-
+
Factures{" "}
@@ -144,6 +200,16 @@ function FacturesPage() {
+
+ {/* Compact dropzone en bas — toujours là pour drag-and-drop rapide
+ sans avoir à vider la liste. */}
+
+ upload.mutate(files)}
+ isUploading={upload.isPending}
+ />
+
>
)}
@@ -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 (
@@ -163,7 +235,7 @@ function FacturesEmpty() {
en 30 secondes.
-
+
);
}
diff --git a/apps/web/src/routes/_app/factures_.import.$batchId.tsx b/apps/web/src/routes/_app/factures_.import.$batchId.tsx
new file mode 100644
index 0000000..bc6419b
--- /dev/null
+++ b/apps/web/src/routes/_app/factures_.import.$batchId.tsx
@@ -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
>;
+ 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(`/api/v1/invoices/import-batch/${params.batchId}`),
+ });
+ void context.queryClient.prefetchQuery({
+ queryKey: queryKeys.plans.all(),
+ queryFn: () => api.get("/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(`/api/v1/invoices/import-batch/${batchId}`),
+ });
+
+ const { data: plans = [] } = useQuery({
+ queryKey: queryKeys.plans.all(),
+ queryFn: () => api.get("/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(null);
+ useEffect(() => {
+ setDraft(currentDraft ? { ...currentDraft.edited } : null);
+ }, [currentDraft?.id]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const validateMutation = useMutation({
+ mutationFn: (input: DraftFields) =>
+ api.post(
+ `/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(
+ `/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(`/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 (
+
+
+ Lot d'import introuvable.
+
+
+ ← Retour aux factures
+
+
+ );
+ }
+
+ if (isPending || !batch || !currentDraft || !draft) {
+ return ;
+ }
+
+ const indexInBatch =
+ batch.drafts.findIndex((d) => d.id === currentDraft.id) + 1;
+
+ const update = (patch: Partial) =>
+ setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
+
+ const isLowConfidence = (key: keyof DraftFields) =>
+ (currentDraft.confidence[key] ?? 1) < LOW_CONFIDENCE;
+
+ return (
+
+ {/* === Header === */}
+
+
+
+ Import OCR · facture {indexInBatch} sur {totalCount}
+
+
+ Vérifiez les infos extraites
+
+
+ Modifiez si l'OCR s'est trompé, puis validez. On continue
+ avec la suivante automatiquement.
+
+
+
+
+
+
+ {/* Progression du batch */}
+
+
+
+ {completedCount}/{totalCount}
+
+
+
+ {/* === Body : 2 cols === */}
+
+
+ {/* === Actions footer === */}
+
+
+
+
+
+
+ );
+}
+
+function ImportSkeleton() {
+ return (
+
+ );
+}