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:
parent
2c2724c634
commit
4113cb56d3
@ -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
|
||||
*
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'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>
|
||||
);
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user