ordinarthur 86dae64eb4 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>
2026-05-06 11:26:31 +02:00

202 lines
6.4 KiB
TypeScript

import { useState, useRef, useCallback } from "react";
import { UploadCloud, FilePlus, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { ACCEPTED_INVOICE_MIME_TYPES, MAX_INVOICE_FILE_SIZE_BYTES } from "@rubis/shared";
import { cn } from "@/lib/utils";
/**
* Dropzone — zone de drag & drop multi-fichiers pour les factures.
*
* Cf. wireframe 2.1 et CLAUDE.md (principe "3 clics maximum"). C'est le geste
* principal d'import — le clic "parcourir" est le fallback discret.
*
* En V1, la primitive ne fait que recevoir les File[] et les passer à
* `onFiles`. L'upload, l'OCR et la création des invoices se passent en aval
* (backend Adonis ou MSW).
*/
type DropzoneProps = {
/** Variant pleine page (empty state) vs compact (dans un coin). */
variant?: "full" | "compact";
/** Max files acceptés en un drop. Default 20 (cf. wireframe). */
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(",");
/**
* 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);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleFiles = useCallback(
(fileList: FileList | null) => {
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 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, isUploading],
);
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFiles(e.dataTransfer.files);
};
const isFull = variant === "full";
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn(
"rounded-card border-2 border-dashed bg-cream-2/40 transition-colors duration-150",
"flex flex-col items-center justify-center text-center",
isFull ? "py-16 px-8 min-h-[360px]" : "py-8 px-6 min-h-[160px]",
isDragging
? "border-rubis bg-rubis-glow/30"
: "border-line hover:border-ink-3",
className,
)}
>
<div
className={cn(
"flex size-14 items-center justify-center rounded-full bg-white",
"border border-line shadow-soft",
isDragging && "border-rubis text-rubis",
)}
>
<UploadCloud
size={26}
strokeWidth={1.75}
className={isDragging ? "text-rubis" : "text-ink-2"}
aria-hidden="true"
/>
</div>
<p
className={cn(
"mt-5 font-display tracking-[-0.018em] text-ink",
isFull ? "text-[22px] font-semibold" : "text-[17px] font-semibold",
)}
>
{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.
</>
) : (
<>
Glissez vos factures <em className="text-rubis">ici</em>.
</>
)}
</p>
<p className="mt-2 text-[13px] text-ink-2">
PDF, PNG, JPG · jusqu&apos;à {maxFiles} fichiers en simultané · 10 Mo par fichier
</p>
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row">
<Button
variant="secondary"
size="sm"
onClick={() => inputRef.current?.click()}
type="button"
loading={isUploading}
disabled={isUploading}
>
<FolderOpen size={14} aria-hidden="true" /> Parcourir mes fichiers
</Button>
{isFull && !isUploading && (
<Button variant="ghost" size="sm" type="button">
<FilePlus size={14} aria-hidden="true" /> Saisir manuellement
</Button>
)}
</div>
{error && (
<p className="mt-4 text-[13px] font-medium text-rubis-deep" role="alert">
{error}
</p>
)}
<input
ref={inputRef}
type="file"
accept={ACCEPT_ATTR}
multiple
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
{isFull && (
<p className="mt-8 text-[12px] text-ink-3 max-w-md">
L&apos;OCR fait le reste vérifiez en 30 secondes et lancez la relance.
</p>
)}
</div>
);
}