rubis/apps/web/src/components/factures/PdfPreview.tsx
ordinarthur 1633fb9bf0
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 59s
Build & Deploy API / build-and-deploy (push) Successful in 1m37s
add factories
2026-05-07 11:34:00 +02:00

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>
);
}