feat(expense-export): parent-company react-pdf + pdfkit brand header

Phase 1 / commit 10 of 14 — migrates the pdfme-based parent-company
expense export to react-pdf and adds a shared brand header to the
pdfkit-based streaming expense PDF so both surfaces match the rest of
the internal-only PDF family.

parent-company-expense.tsx:
  Summary KV grid (entry count, subtotal, fee, total) + entries table
  with right-aligned EUR amounts and a totals row. Footnote rendered
  when the EUR rate lookup falls through to the 1:1 USD:EUR fallback.

expense-export.tsx (renamed .ts -> .tsx):
  - exportParentCompany now renders the react-pdf template via
    resolvePortLogo() + renderPdf()
  - dropped the inline pdfme template object (was the last pdfme caller
    in this file)
  - return type widened from Uint8Array to Buffer; caller already wraps
    in Buffer.from() so no API change downstream

expense-pdf.service.ts (the pdfkit streaming engine — unchanged):
  - addHeader() now draws a dark slate band matching the brand-kit
    header band, with the port logo letterboxed on the left and the
    document title right-aligned. Falls back to text port-name if the
    logo image is missing or can't be decoded by pdfkit
  - port + logo resolved once per export via Promise.all
  - subheader stays beneath the band in muted grey, same as before
  - streaming behavior + receipt embedding + sharp compression
    untouched — the only change is the visual treatment of the header

Old pdfme inline template deleted along with the generatePdf import.
After this commit, the only remaining pdfme imports are in:
  invoice-template.ts, tiptap-to-pdfme.ts, eoi-standard-inapp.ts, and
  document-templates.ts (lines 516-522). All four are removed in
  commits 11-12.

1319/1319 vitest green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:01:45 +02:00
parent 0e4a2d7396
commit b7e010ff80
4 changed files with 218 additions and 62 deletions

View File

@@ -2,7 +2,10 @@ import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm';
import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
import { generatePdf } from '@/lib/pdf/generate';
import { ports } from '@/lib/db/schema/ports';
import { renderPdf } from '@/lib/pdf/render';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { ParentCompanyExpensePdf } from '@/lib/pdf/templates/parent-company-expense';
import { getRate } from '@/lib/services/currency';
import { logger } from '@/lib/logger';
import type { ListExpensesInput } from '@/lib/validators/expenses';
@@ -100,7 +103,7 @@ export async function exportCsv(portId: string, query: ListExpensesInput): Promi
export async function exportParentCompany(
portId: string,
query: ListExpensesInput,
): Promise<Uint8Array> {
): Promise<Buffer> {
// BR-043: Convert all amounts to EUR, add 5% management fee
const rows = await fetchAllExpenses(portId, query);
const eurRate = await getRate('USD', 'EUR');
@@ -115,7 +118,7 @@ export async function exportParentCompany(
const amountUsd = r.amountUsd ? Number(r.amountUsd) : Number(r.amount);
const amountEur = Number((amountUsd * rate).toFixed(2));
return {
date: r.expenseDate ? new Date(r.expenseDate).toISOString().split('T')[0] : '',
date: r.expenseDate ? (new Date(r.expenseDate).toISOString().split('T')[0] ?? '') : '',
establishment: r.establishmentName ?? '-',
category: r.category ?? '-',
amountEur,
@@ -126,58 +129,22 @@ export async function exportParentCompany(
const fee = Number((subtotal * 0.05).toFixed(2));
const total = Number((subtotal + fee).toFixed(2));
const template = {
basePdf: { width: 210, height: 297, padding: [10, 10, 10, 10] },
schemas: [
[
{
name: 'title',
type: 'text',
position: { x: 10, y: 10 },
width: 190,
height: 10,
fontSize: 14,
fontColor: '#000000',
},
{
name: 'content',
type: 'text',
position: { x: 10, y: 25 },
width: 190,
height: 230,
fontSize: 8,
fontColor: '#000000',
},
{
name: 'summary',
type: 'text',
position: { x: 10, y: 260 },
width: 190,
height: 30,
fontSize: 10,
fontColor: '#000000',
},
],
],
};
const [port, logo] = await Promise.all([
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
resolvePortLogo(portId),
]);
const lines = convertedRows.map(
(r) => `${r.date} | ${r.establishment} | ${r.category} | EUR ${r.amountEur.toFixed(2)}`,
return renderPdf(
<ParentCompanyExpensePdf
portName={port?.name ?? 'Port Nimara'}
logoBuffer={logo.buffer}
rows={convertedRows}
subtotal={subtotal}
managementFee={fee}
total={total}
dateFrom={query.dateFrom}
dateTo={query.dateTo}
rateAvailable={Boolean(eurRate)}
/>,
);
const summary = [
`Subtotal: EUR ${subtotal.toFixed(2)}`,
`Management Fee (5%): EUR ${fee.toFixed(2)}`,
`Total: EUR ${total.toFixed(2)}`,
].join('\n');
const inputs = [
{
title: 'Parent Company Expense Report (EUR)',
content: lines.join('\n'),
summary,
},
];
return generatePdf(template as unknown as Parameters<typeof generatePdf>[0], inputs);
}

View File

@@ -41,7 +41,9 @@ import sharp from 'sharp';
import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { getRate } from '@/lib/services/currency';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { formatCurrency } from '@/lib/utils/currency';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';
@@ -487,6 +489,12 @@ export async function streamExpensePdf(
const processed = await processExpenses(rawRows, opts.targetCurrency);
const totals = computeTotals(processed, opts.targetCurrency, opts.includeProcessingFee);
// Brand assets for the header band — shared with the react-pdf surfaces.
const [port, logo] = await Promise.all([
db.query.ports.findFirst({ where: eq(ports.id, args.portId) }),
resolvePortLogo(args.portId),
]);
// Bulk-resolve receipt file metadata (one DB round-trip vs N).
const allFileIds = processed
.flatMap((r) => r.receiptFileIds ?? [])
@@ -513,7 +521,7 @@ export async function streamExpensePdf(
// / doc.emit('error') and surface to the consumer of the stream.
void (async () => {
try {
addHeader(doc, opts);
addHeader(doc, { ...opts, portName: port?.name, logoBuffer: logo.buffer });
if (opts.includeSummary) addSummaryBox(doc, totals, opts);
if (opts.includeDetails) addExpenseTable(doc, processed, opts);
@@ -544,15 +552,64 @@ export async function streamExpensePdf(
// ─── Page sections ──────────────────────────────────────────────────────────
function addHeader(doc: PDFKit.PDFDocument, opts: { documentName: string; subheader?: string }) {
function addHeader(
doc: PDFKit.PDFDocument,
opts: {
documentName: string;
subheader?: string;
portName?: string;
logoBuffer?: Buffer | null;
},
) {
// Brand band — shared visual language with the react-pdf surfaces.
// Dark slate matches PDF_TOKENS.colors.headerBand.
const pageWidth = doc.page.width;
const bandHeight = 56;
const bandPaddingX = 24;
const startY = doc.page.margins.top - 30;
doc.save();
doc.rect(0, 0, pageWidth, bandHeight).fill('#0f172a');
doc.fillColor('#ffffff');
if (opts.logoBuffer) {
try {
doc.image(opts.logoBuffer, bandPaddingX, (bandHeight - 32) / 2, {
fit: [120, 32],
});
} catch {
// Fall through to the text-name fallback if pdfkit can't decode.
if (opts.portName) {
doc
.fontSize(14)
.font('Helvetica-Bold')
.text(opts.portName, bandPaddingX, (bandHeight - 14) / 2, { lineBreak: false });
}
}
} else if (opts.portName) {
doc
.fontSize(14)
.font('Helvetica-Bold')
.text(opts.portName, bandPaddingX, (bandHeight - 14) / 2, { lineBreak: false });
}
doc
.fontSize(24)
.fontSize(13)
.font('Helvetica-Bold')
.fillColor('#000000')
.text(opts.documentName, { align: 'center' });
.text(opts.documentName, pageWidth / 2, (bandHeight - 13) / 2, {
align: 'right',
width: pageWidth - pageWidth / 2 - bandPaddingX,
lineBreak: false,
});
doc.restore();
// Move past the band and render the subheader below in black on white.
doc.y = bandHeight + 12;
const subheader = opts.subheader ?? `Generated on ${new Date().toLocaleDateString()}`;
doc.fontSize(12).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
doc.fillColor('#000000').moveDown(1);
doc.fontSize(11).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
doc.fillColor('#000000').moveDown(0.8);
// Suppress unused-warning on startY (kept as a documentation anchor).
void startY;
}
function addSummaryBox(