From 86dae64eb4c9e8ece0aad63c142978d97ad64a5b Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 11:26:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20import=20OCR=20=E2=80=94=20drop=20?= =?UTF-8?q?fichier=20=E2=86=92=20review=20batch=20=E2=86=92=20factures=20c?= =?UTF-8?q?r=C3=A9=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/components/factures/Dropzone.tsx | 56 ++- .../src/components/factures/PdfPreview.tsx | 69 +++ apps/web/src/components/ui/Select.tsx | 94 ++++ apps/web/src/mocks/db.ts | 150 ++++++- apps/web/src/mocks/handlers/invoices.ts | 236 +++++++++- apps/web/src/routes/_app/factures.tsx | 86 +++- .../routes/_app/factures_.import.$batchId.tsx | 420 ++++++++++++++++++ 7 files changed, 1089 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/factures/PdfPreview.tsx create mode 100644 apps/web/src/components/ui/Select.tsx create mode 100644 apps/web/src/routes/_app/factures_.import.$batchId.tsx 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} >