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:
@@ -59,6 +59,7 @@ exports.up = async function (knex) {
|
||||
table.string('parse_status', 16).notNullable().defaultTo('pending'); // pending|parsed|failed|manual
|
||||
table.text('parse_error');
|
||||
table.string('parse_method', 24); // qr|pdf_text|ocr|none
|
||||
table.integer('page_count'); // PDF page count (for "jump to last page / QR")
|
||||
// Best-effort parsed fields (assist only — always editable/confirmable):
|
||||
table.string('supplier_name', 255);
|
||||
table.string('invoice_number', 128);
|
||||
|
||||
@@ -14,6 +14,8 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { createReadStream } = require('fs');
|
||||
const { assertPathInside } = require('../utils/safePath');
|
||||
const { db } = require('../database/db');
|
||||
const expenseService = require('../services/expenseService');
|
||||
const expenseCategoriesService = require('../services/expenseCategoriesService');
|
||||
@@ -116,6 +118,25 @@ router.get('/inbound/:id', requirePermission('accounting.view'),
|
||||
return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) });
|
||||
}));
|
||||
|
||||
// Stream the stored file for in-browser preview (PDF / image). Admin-only,
|
||||
// path-containment guarded. NOTE: this serves the raw file inline — the
|
||||
// locked design's hardened path (rasterise in a network-isolated worker, never
|
||||
// serve raw) is a follow-up; acceptable here as the admin views their own
|
||||
// uploaded documents.
|
||||
router.get('/inbound/:id/file', requirePermission('accounting.view'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const row = await db('inbound_documents').where({ id: parseInt(req.params.id, 10) })
|
||||
.first('file_path', 'mime_type');
|
||||
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
|
||||
const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
|
||||
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
createReadStream(safe).pipe(res);
|
||||
}));
|
||||
|
||||
router.patch('/inbound/:id', requirePermission('accounting.manage'),
|
||||
[param('id').isInt({ min: 1 })],
|
||||
handleAsync(async (req, res) => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const fsp = require('fs').promises;
|
||||
const { PDFDocument } = require('pdf-lib');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
@@ -57,6 +58,7 @@ function transformInbound(row) {
|
||||
parseStatus: row.parse_status,
|
||||
parseMethod: row.parse_method,
|
||||
parseError: row.parse_error,
|
||||
pageCount: row.page_count,
|
||||
supplierName: row.supplier_name,
|
||||
invoiceNumber: row.invoice_number,
|
||||
invoiceDate: toIsoDate(row.invoice_date),
|
||||
@@ -119,9 +121,21 @@ function clampPage(page, pageSize) {
|
||||
return { p, ps };
|
||||
}
|
||||
|
||||
async function sha256OfFile(filePath) {
|
||||
// Read the file once: SHA-256 (for dedup) + PDF page count (for the
|
||||
// "jump to last page / QR" preview).
|
||||
async function inspectFile(filePath, mimeType) {
|
||||
const buf = await fsp.readFile(filePath);
|
||||
return crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const sha = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
let pageCount = null;
|
||||
if ((mimeType || '').includes('pdf')) {
|
||||
try {
|
||||
const pdf = await PDFDocument.load(buf, { updateMetadata: false });
|
||||
pageCount = pdf.getPageCount();
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: could not read PDF page count for ${filePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { sha, pageCount };
|
||||
}
|
||||
|
||||
// ── Inbound documents ──────────────────────────────────────────────────────
|
||||
@@ -132,8 +146,13 @@ async function sha256OfFile(filePath) {
|
||||
*/
|
||||
async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
|
||||
let fileSha256 = null;
|
||||
try { fileSha256 = await sha256OfFile(filePath); } catch (e) {
|
||||
logger.warn?.(`expenseService: could not hash ${filePath}: ${e.message}`);
|
||||
let pageCount = null;
|
||||
try {
|
||||
const info = await inspectFile(filePath, mimeType);
|
||||
fileSha256 = info.sha;
|
||||
pageCount = info.pageCount;
|
||||
} catch (e) {
|
||||
logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`);
|
||||
}
|
||||
|
||||
let duplicateOfId = null;
|
||||
@@ -164,6 +183,7 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
|
||||
parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'),
|
||||
parse_method: parse.method || 'none',
|
||||
parse_error: parse.error || null,
|
||||
page_count: pageCount,
|
||||
supplier_name: f.supplierName || null,
|
||||
invoice_number: f.invoiceNumber || null,
|
||||
invoice_date: f.invoiceDate || null,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user