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:
parent
b5b67056aa
commit
86dae64eb4
@ -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'<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>
|
||||
|
||||
69
apps/web/src/components/factures/PdfPreview.tsx
Normal file
69
apps/web/src/components/factures/PdfPreview.tsx
Normal 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'import.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/components/ui/Select.tsx
Normal file
94
apps/web/src/components/ui/Select.tsx
Normal 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;
|
||||
@ -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 é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>): 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);
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
420
apps/web/src/routes/_app/factures_.import.$batchId.tsx
Normal file
420
apps/web/src/routes/_app/factures_.import.$batchId.tsx
Normal 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'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'OCR s'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'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'OCR n'é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'OCR n'était
|
||||
pas confiant. Un coup d'œil rapide et c'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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user