feat(accounting): PDF/image preview in triage, opened at the QR-bill (no OCR)

Instead of OCR, let the admin read the payment slip directly: the triage modal
now embeds the captured document and, for PDFs, opens at the LAST page scrolled
to the Swiss QR-bill area so IBAN/amount/reference are visible while typing.

- backend: capture PDF page count at upload via pdf-lib (new
  inbound_documents.page_count, added to in-flight migration 124); new
  GET /api/admin/expenses/inbound/:id/file streams the stored file inline
  (safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own
  uploads); the hardened rasterise-in-isolated-worker path stays a follow-up.
- frontend: getInboundFileBlob fetches the file with Bearer auth as a blob;
  the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300,
  <img> for camera photos) in a two-column layout next to the form.
- i18n: accounting.inbox.previewLoading / qrHint (EN + DE).

Verified: node -c, require-graph, migration-124 harness (page_count), npm run
build green.
This commit is contained in:
Luca
2026-06-11 00:38:37 +02:00
parent 2b5efebaff
commit 502fbad5a8
7 changed files with 101 additions and 7 deletions
+2
View File
@@ -3428,6 +3428,8 @@
"untitled": "Unbenanntes Dokument",
"noAmount": "Betrag nicht erfasst",
"rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.",
"previewLoading": "Vorschau wird geladen…",
"qrHint": "An der letzten Seite geöffnet — der Schweizer QR-Einzahlschein sitzt meist unten.",
"status": {
"unsorted": "Neu",
"categorized": "Kategorisiert",
+2
View File
@@ -3428,6 +3428,8 @@
"untitled": "Untitled document",
"noAmount": "amount not entered",
"rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.",
"previewLoading": "Loading preview…",
"qrHint": "Opened at the last page — the Swiss QR-bill usually sits at the bottom.",
"status": {
"unsorted": "New",
"categorized": "Categorized",
@@ -9,7 +9,7 @@
* Parsing is assist-only and currently a no-op on the backend (extractionService
* scaffold) fields are entered/confirmed manually until OCR lands.
*/
import React, { useRef, useState } from 'react';
import React, { useRef, useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
@@ -57,6 +57,25 @@ const TriageModal: React.FC<{
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
// Authenticated file preview: fetch as a blob (Bearer auth) and embed.
// PDFs open at the last page scrolled into the QR-bill area (no OCR — the
// admin reads the payment slip and types the fields).
const isPdf = (doc.mimeType || '').includes('pdf');
const [fileUrl, setFileUrl] = useState<string | null>(null);
useEffect(() => {
let url: string | null = null;
let cancelled = false;
accountingService.getInboundFileBlob(doc.id)
.then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setFileUrl(url); } })
.catch(() => { if (!cancelled) setFileUrl(null); });
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [doc.id]);
// #page=<last> jumps to the last page; view=FitH,300 fits the width and
// positions ~300pt from the bottom — the Swiss QR-bill payment part.
const previewSrc = fileUrl
? (isPdf ? `${fileUrl}#page=${doc.pageCount || 1}&view=FitH,300` : fileUrl)
: null;
const save = useMutation({
mutationFn: async () => {
// 1) Confirm the document's fields (assist is never blind-trusted).
@@ -102,7 +121,7 @@ const TriageModal: React.FC<{
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
<div className="mt-10 w-full max-w-lg rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="mt-10 w-full max-w-4xl rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('accounting.inbox.triageTitle', 'Categorize document')}
@@ -112,7 +131,29 @@ const TriageModal: React.FC<{
</button>
</div>
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Document preview — PDFs open at the last page (QR-bill area). */}
<div className="order-2 lg:order-1">
{previewSrc ? (
isPdf ? (
<iframe title="document preview" src={previewSrc} className="w-full h-[60vh] rounded-md border border-neutral-200 dark:border-neutral-700" />
) : (
<img src={previewSrc} alt="document" className="w-full max-h-[60vh] object-contain rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" />
)
) : (
<div className="flex h-[60vh] items-center justify-center rounded-md border border-dashed border-neutral-300 dark:border-neutral-700 text-sm text-neutral-500">
{t('accounting.inbox.previewLoading', 'Loading preview…')}
</div>
)}
{isPdf && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.inbox.qrHint', 'Opened at the last page — the Swiss QR-bill usually sits at the bottom.')}
</p>
)}
</div>
{/* Triage form */}
<div className="order-1 lg:order-2 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.supplier', 'Supplier')}</label>
@@ -198,6 +239,7 @@ const TriageModal: React.FC<{
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.rebillHint', 'Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.')}</p>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
@@ -15,6 +15,7 @@ export interface InboundDocument {
status: InboundStatus;
parseStatus: 'pending' | 'parsed' | 'failed' | 'manual';
parseMethod: string | null;
pageCount: number | null;
supplierName: string | null;
invoiceNumber: string | null;
invoiceDate: string | null;
@@ -114,6 +115,11 @@ export const accountingService = {
return data.document;
},
async getInboundFileBlob(id: number): Promise<Blob> {
const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' });
return data;
},
async updateInbound(id: number, fields: Partial<Pick<InboundDocument,
'supplierName' | 'invoiceNumber' | 'invoiceDate' | 'dueDate' | 'currency' |
'netAmountMinor' | 'vatAmountMinor' | 'totalAmountMinor' | 'iban' | 'paymentReference'>>): Promise<InboundDocument> {