166 lines
5.3 KiB
TypeScript
166 lines
5.3 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { FileText, AlertCircle } from "lucide-react";
|
|
import { api, ApiError } from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* Aperçu du fichier importé (PDF / image) — utilisé sur :
|
|
* - la review OCR (volet gauche, source = batch + draft)
|
|
* - la fiche facture (source = invoice id direct)
|
|
*
|
|
* Fetch via api.fetchBlob (Bearer auto-injecté) → object URL → <iframe>
|
|
* pour les PDF (viewer Chrome/Safari natif), <img> pour les images.
|
|
* Fallback "barres" si pdfAvailable=false.
|
|
*/
|
|
type PdfPreviewProps = {
|
|
filename: string;
|
|
/** Source 1 : draft d'un import en cours (batchId + draftId). */
|
|
batchId?: string;
|
|
draftId?: string;
|
|
/** Source 2 : facture validée (invoiceId direct). Prioritaire. */
|
|
invoiceId?: string;
|
|
/** Indique si le backend a effectivement un fichier (sinon fallback). */
|
|
pdfAvailable?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
export function PdfPreview({
|
|
filename,
|
|
batchId,
|
|
draftId,
|
|
invoiceId,
|
|
pdfAvailable = true,
|
|
className,
|
|
}: PdfPreviewProps) {
|
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
|
const [contentType, setContentType] = useState<string>("application/pdf");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!pdfAvailable) {
|
|
setObjectUrl(null);
|
|
return;
|
|
}
|
|
// Source : invoiceId prioritaire, sinon batch+draft.
|
|
const path = invoiceId
|
|
? `/api/v1/invoices/${invoiceId}/pdf`
|
|
: batchId && draftId
|
|
? `/api/v1/invoices/import-batch/${batchId}/drafts/${draftId}/pdf`
|
|
: null;
|
|
if (!path) {
|
|
setObjectUrl(null);
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
let url: string | null = null;
|
|
setError(null);
|
|
|
|
api
|
|
.fetchBlob(path, controller.signal)
|
|
.then(({ blob, contentType: ct }) => {
|
|
url = URL.createObjectURL(blob);
|
|
setObjectUrl(url);
|
|
setContentType(ct);
|
|
})
|
|
.catch((err) => {
|
|
if (controller.signal.aborted) return;
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
setError("Aperçu indisponible.");
|
|
} else {
|
|
setError("Impossible de charger l'aperçu.");
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
controller.abort();
|
|
if (url) URL.revokeObjectURL(url);
|
|
};
|
|
}, [batchId, draftId, invoiceId, pdfAvailable]);
|
|
|
|
const isImage = contentType.startsWith("image/");
|
|
|
|
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>
|
|
|
|
<div className="flex-1 bg-cream/60 min-h-[420px] relative">
|
|
{objectUrl ? (
|
|
isImage ? (
|
|
<img
|
|
src={objectUrl}
|
|
alt={`Aperçu ${filename}`}
|
|
className="w-full h-full max-h-[680px] object-contain bg-white"
|
|
/>
|
|
) : (
|
|
<iframe
|
|
src={objectUrl}
|
|
title={`Aperçu ${filename}`}
|
|
className="w-full h-full min-h-[680px] border-0"
|
|
/>
|
|
)
|
|
) : error ? (
|
|
<div className="flex flex-col items-center justify-center gap-2 h-full p-7 text-ink-3">
|
|
<AlertCircle size={20} className="text-rubis-deep" />
|
|
<p className="text-[13px]">{error}</p>
|
|
<p className="text-[11.5px] italic">Vous pouvez quand même valider à partir des champs OCR.</p>
|
|
</div>
|
|
) : pdfAvailable && batchId && draftId ? (
|
|
<div className="flex items-center justify-center h-full p-7">
|
|
<div
|
|
className="size-6 animate-spin rounded-full border-2 border-rubis-glow border-t-rubis"
|
|
aria-label="Chargement de l'aperçu"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<PdfPlaceholder />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Placeholder utilisé quand aucun PDF n'est dispo (mocks MSW, ou
|
|
* import sans fichier réel). Garde l'identité visuelle.
|
|
*/
|
|
function PdfPlaceholder() {
|
|
return (
|
|
<div className="p-7">
|
|
<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>
|
|
<p className="mt-7 text-[11px] italic text-ink-3 text-center">
|
|
Aperçu non disponible pour cette source.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|