feat(import): preview réelle du PDF/image dans le volet gauche

Pourquoi ce n'était pas visible jusqu'à maintenant :
PdfPreview était un placeholder V1 (barres rubis-glow + nom du fichier).
Le commentaire dans le composant le disait explicitement : "Le vrai
render PDF arrivera quand le backend stockera réellement les fichiers
dans MinIO." Le backend stocke bien les uploads (cf. drive.use().putStream
dans ImportBatches.upload, storageKey dans la table import_drafts), mais
on n'avait jamais wiré l'endpoint de streaming ni le viewer côté SPA.

Backend
- GET /invoices/import-batch/:id/drafts/:draftId/pdf : stream le binaire
  depuis MinIO via Drive, content-type adapté (PDF/PNG/JPG), Cache-Control
  privé 5min, Content-Disposition inline pour permettre le rendu dans
  un <iframe>/<embed> sans téléchargement forcé. Auth Bearer (vérification
  d'org via loadBatchOrFail).

Frontend
- api.fetchBlob() : helper pour fetch binaires avec Bearer auto-injecté
  (le JSON-only existant ne marche pas pour les PDF).
- PdfPreview accepte batchId+draftId+pdfAvailable, fetch le binaire au
  mount, crée un object URL, affiche :
  · <iframe> pour les PDF (viewer Chrome/Safari natif)
  · <img> pour les images (PNG/JPG)
  · Spinner pendant le chargement, fallback "barres" si pdfAvailable=false
    (ex. mode mock MSW), erreur visible si 404 / network down
- Cleanup URL.revokeObjectURL au unmount pour pas leaker la mémoire
- pdfStorageKey ajouté au type ImportDraft côté SPA

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 09:49:08 +02:00
parent 2c2724c634
commit 4113cb56d3
5 changed files with 205 additions and 39 deletions

View File

@ -120,6 +120,48 @@ export default class ImportBatchesController {
return response.json({ data: serializeBatch(batch) })
}
/**
* GET /invoices/import-batch/:id/drafts/:draftId/pdf
*
* Stream le PDF stocké dans MinIO. Auth via Bearer (le SPA fetch via
* api.ts puis crée un objectURL on ne peut pas mettre Authorization
* sur un <iframe src>). On vérifie l'ownership de l'org en chargeant
* le batch (loadBatchOrFail) puis on cherche le draft dedans.
*/
async draftPdf({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Draft introuvable', { status: 404, code: 'not_found' })
}
if (!draft.pdfStorageKey) {
throw new Exception('Aucun PDF stocké pour ce draft', {
status: 404,
code: 'pdf_not_available',
})
}
const ext = (draft.filename.split('.').pop() ?? '').toLowerCase()
const contentType =
ext === 'pdf'
? 'application/pdf'
: ext === 'png'
? 'image/png'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'application/octet-stream'
const buffer = Buffer.from(await drive.use().getArrayBuffer(draft.pdfStorageKey))
response.header('Content-Type', contentType)
response.header('Cache-Control', 'private, max-age=300')
// Inline = visualisable dans un <iframe>/<embed> sans télécharger.
response.header(
'Content-Disposition',
`inline; filename="${encodeURIComponent(draft.filename)}"`
)
return response.send(buffer)
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate
*

View File

@ -167,6 +167,14 @@ router
.get('import-batch/:id', [controllers.ImportBatches, 'show'])
.as('import-batch.show')
.where('id', router.matchers.uuid())
router
.get('import-batch/:id/drafts/:draftId/pdf', [
controllers.ImportBatches,
'draftPdf',
])
.as('import-batch.draft.pdf')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/validate', [
controllers.ImportBatches,

View File

@ -1,23 +1,78 @@
import { FileText } from "lucide-react";
import { useEffect, useState } from "react";
import { FileText, AlertCircle } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { cn } from "@/lib/utils";
/**
* Aperçu PDF côté review OCR placeholder visuel.
* Aperçu du fichier importé (PDF / image) côté review OCR.
*
* V1 : affiche le nom du fichier + une "preview" abstraite (barres) qui
* suggèrent un document. Le vrai render PDF (via react-pdf, pdf.js ou un
* iframe avec object URL) viendra quand le backend stockera réellement
* les fichiers dans MinIO.
* V1 = placeholder hardcoded (`<PdfPreview filename={...} />` sans batchId)
* on n'avait jamais branché le streaming MinIO. Maintenant on fetch
* GET /invoices/import-batch/:id/drafts/:draftId/pdf via api.fetchBlob
* (Bearer auto-injecté), on crée un object URL, et on l'affiche dans :
* - <iframe> pour les PDF (le viewer Chrome/Safari natif s'occupe du rendu)
* - <img> pour les PNG/JPG
*
* Anti-IA-look : pas un viewer générique gris/blanc fond cream-2 avec
* des barres rubis-glow pour suggérer le contenu et garder l'identité.
* Le fallback "barres" reste si le draft n'a pas de pdfStorageKey
* (cas rare : démo MSW, ou import par URL plus tard).
*/
type PdfPreviewProps = {
filename: string;
/** Nécessaires pour fetch le binaire. Si absents → fallback placeholder. */
batchId?: string;
draftId?: string;
/** Indique si le backend a effectivement un PDF stocké pour ce draft. */
pdfAvailable?: boolean;
className?: string;
};
export function PdfPreview({ filename, className }: PdfPreviewProps) {
export function PdfPreview({
filename,
batchId,
draftId,
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 (!batchId || !draftId || !pdfAvailable) {
setObjectUrl(null);
return;
}
const controller = new AbortController();
let url: string | null = null;
setError(null);
api
.fetchBlob(
`/api/v1/invoices/import-batch/${batchId}/drafts/${draftId}/pdf`,
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, pdfAvailable]);
const isImage = contentType.startsWith("image/");
return (
<div
className={cn(
@ -32,37 +87,72 @@ export function PdfPreview({ filename, className }: PdfPreviewProps) {
</span>
</div>
{/* Pseudo-page A4 ratio. Les "barres" simulent du contenu OCRisé. */}
<div className="flex-1 bg-cream/60 p-7 min-h-[420px]">
<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>
<div className="mt-7 flex justify-end">
<div className="h-9 w-1/3 rounded-sharp bg-ink/15" />
</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>
);
}
<p className="border-t border-line bg-cream-2/40 px-4 py-2 text-[11px] italic text-ink-3">
Aperçu simplifié le rendu PDF complet arrivera avec le vrai pipeline
d&apos;import.
/**
* 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>
);

View File

@ -181,4 +181,23 @@ export const api = {
): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "DELETE" }),
/**
* Fetch d'un binaire (PDF, image) avec le Bearer auto-injecté. Renvoie
* un Blob + le content-type. Pas de silent refresh sur 401 (cas rare
* pour des assets longue durée), si besoin re-fetch côté caller.
*/
fetchBlob: async (path: string, signal?: AbortSignal): Promise<{ blob: Blob; contentType: string }> => {
const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`;
const res = await fetch(url, {
headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {},
credentials: "include",
signal,
});
if (!res.ok) {
throw new ApiError(res.status, "blob_fetch_failed", `HTTP ${res.status} on ${path}`);
}
const blob = await res.blob();
return { blob, contentType: res.headers.get("content-type") ?? blob.type };
},
};

View File

@ -55,6 +55,8 @@ type DraftFields = {
type ImportDraft = {
id: string;
filename: string;
/** Clé de stockage S3/MinIO. null en mode démo MSW (pas de vrai fichier). */
pdfStorageKey: string | null;
extracted: DraftFields;
edited: DraftFields;
confidence: Partial<Record<keyof DraftFields, number>>;
@ -241,7 +243,12 @@ function ImportReviewPage() {
{/* === Body : 2 cols === */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.1fr]">
<PdfPreview filename={currentDraft.filename} />
<PdfPreview
filename={currentDraft.filename}
batchId={batchId}
draftId={currentDraft.id}
pdfAvailable={!!currentDraft.pdfStorageKey}
/>
<Card padding="md" className="flex flex-col gap-5">
<div className="flex flex-col gap-1">