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>
202 lines
6.4 KiB
TypeScript
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'<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'à {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'OCR fait le reste — vérifiez en 30 secondes et lancez la relance.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|