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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user