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) })
|
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
|
* POST /invoices/import-batch/:id/drafts/:draftId/validate
|
||||||
*
|
*
|
||||||
|
|||||||
@ -167,6 +167,14 @@ router
|
|||||||
.get('import-batch/:id', [controllers.ImportBatches, 'show'])
|
.get('import-batch/:id', [controllers.ImportBatches, 'show'])
|
||||||
.as('import-batch.show')
|
.as('import-batch.show')
|
||||||
.where('id', router.matchers.uuid())
|
.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
|
router
|
||||||
.post('import-batch/:id/drafts/:draftId/validate', [
|
.post('import-batch/:id/drafts/:draftId/validate', [
|
||||||
controllers.ImportBatches,
|
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";
|
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
|
* V1 = placeholder hardcoded (`<PdfPreview filename={...} />` sans batchId)
|
||||||
* suggèrent un document. Le vrai render PDF (via react-pdf, pdf.js ou un
|
* → on n'avait jamais branché le streaming MinIO. Maintenant on fetch
|
||||||
* iframe avec object URL) viendra quand le backend stockera réellement
|
* GET /invoices/import-batch/:id/drafts/:draftId/pdf via api.fetchBlob
|
||||||
* les fichiers dans MinIO.
|
* (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
|
* Le fallback "barres" reste si le draft n'a pas de pdfStorageKey
|
||||||
* des barres rubis-glow pour suggérer le contenu et garder l'identité.
|
* (cas rare : démo MSW, ou import par URL plus tard).
|
||||||
*/
|
*/
|
||||||
type PdfPreviewProps = {
|
type PdfPreviewProps = {
|
||||||
filename: string;
|
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;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -32,37 +87,72 @@ export function PdfPreview({ filename, className }: PdfPreviewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pseudo-page A4 ratio. Les "barres" simulent du contenu OCRisé. */}
|
<div className="flex-1 bg-cream/60 min-h-[420px] relative">
|
||||||
<div className="flex-1 bg-cream/60 p-7 min-h-[420px]">
|
{objectUrl ? (
|
||||||
<div className="space-y-2.5">
|
isImage ? (
|
||||||
<div className="h-3 w-1/2 rounded-sharp bg-ink/15" />
|
<img
|
||||||
<div className="h-3 w-1/3 rounded-sharp bg-ink/8" />
|
src={objectUrl}
|
||||||
</div>
|
alt={`Aperçu ${filename}`}
|
||||||
<div className="mt-7 space-y-2">
|
className="w-full h-full max-h-[680px] object-contain bg-white"
|
||||||
<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" />
|
<iframe
|
||||||
</div>
|
src={objectUrl}
|
||||||
<div className="mt-9 grid grid-cols-2 gap-3">
|
title={`Aperçu ${filename}`}
|
||||||
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
|
className="w-full h-full min-h-[680px] border-0"
|
||||||
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
|
/>
|
||||||
</div>
|
)
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
) : error ? (
|
||||||
<div className="h-7 rounded-sharp bg-cream-2" />
|
<div className="flex flex-col items-center justify-center gap-2 h-full p-7 text-ink-3">
|
||||||
<div className="h-7 rounded-sharp bg-cream-2" />
|
<AlertCircle size={20} className="text-rubis-deep" />
|
||||||
</div>
|
<p className="text-[13px]">{error}</p>
|
||||||
<div className="mt-9 space-y-2">
|
<p className="text-[11.5px] italic">Vous pouvez quand même valider à partir des champs OCR.</p>
|
||||||
<div className="h-2.5 w-2/3 rounded-sharp bg-ink/10" />
|
</div>
|
||||||
<div className="h-2.5 w-1/2 rounded-sharp bg-ink/10" />
|
) : pdfAvailable && batchId && draftId ? (
|
||||||
</div>
|
<div className="flex items-center justify-center h-full p-7">
|
||||||
<div className="mt-7 flex justify-end">
|
<div
|
||||||
<div className="h-9 w-1/3 rounded-sharp bg-ink/15" />
|
className="size-6 animate-spin rounded-full border-2 border-rubis-glow border-t-rubis"
|
||||||
</div>
|
aria-label="Chargement de l'aperçu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PdfPlaceholder />
|
||||||
|
)}
|
||||||
</div>
|
</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
|
* Placeholder utilisé quand aucun PDF n'est dispo (mocks MSW, ou
|
||||||
d'import.
|
* 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -181,4 +181,23 @@ export const api = {
|
|||||||
): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
|
): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
|
||||||
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
|
||||||
request<T>(path, { ...options, method: "DELETE" }),
|
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 = {
|
type ImportDraft = {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
/** Clé de stockage S3/MinIO. null en mode démo MSW (pas de vrai fichier). */
|
||||||
|
pdfStorageKey: string | null;
|
||||||
extracted: DraftFields;
|
extracted: DraftFields;
|
||||||
edited: DraftFields;
|
edited: DraftFields;
|
||||||
confidence: Partial<Record<keyof DraftFields, number>>;
|
confidence: Partial<Record<keyof DraftFields, number>>;
|
||||||
@ -241,7 +243,12 @@ function ImportReviewPage() {
|
|||||||
|
|
||||||
{/* === Body : 2 cols === */}
|
{/* === Body : 2 cols === */}
|
||||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.1fr]">
|
<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">
|
<Card padding="md" className="flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user