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(null); const inputRef = useRef(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 (

{isUploading ? ( <> On lit vos factures… ) : isDragging ? ( <> Lâchez ici, on s'occupe du reste. ) : ( <> Glissez vos factures ici. )}

PDF, PNG, JPG · jusqu'à {maxFiles} fichiers en simultané · 10 Mo par fichier

{isFull && !isUploading && ( )}
{error && (

{error}

)} handleFiles(e.target.files)} /> {isFull && (

L'OCR fait le reste — vérifiez en 30 secondes et lancez la relance.

)}
); }