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:
91
src/lib/pdf/templates/parent-company-expense.tsx
Normal file
91
src/lib/pdf/templates/parent-company-expense.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { DataTable, DocumentShell, KeyValueGrid, Section } from '@/lib/pdf/brand-kit';
|
||||||
|
|
||||||
|
export interface ParentCompanyRow {
|
||||||
|
date: string;
|
||||||
|
establishment: string;
|
||||||
|
category: string;
|
||||||
|
amountEur: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentCompanyExpensePdfProps {
|
||||||
|
portName: string;
|
||||||
|
logoBuffer: Buffer | null;
|
||||||
|
rows: ParentCompanyRow[];
|
||||||
|
subtotal: number;
|
||||||
|
managementFee: number;
|
||||||
|
total: number;
|
||||||
|
/** Optional ISO date range for the meta line. */
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
/** Whether the EUR rate lookup succeeded. Renders a small footnote if false. */
|
||||||
|
rateAvailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtEur(n: number): string {
|
||||||
|
return `EUR ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParentCompanyExpensePdf({
|
||||||
|
portName,
|
||||||
|
logoBuffer,
|
||||||
|
rows,
|
||||||
|
subtotal,
|
||||||
|
managementFee,
|
||||||
|
total,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
rateAvailable = true,
|
||||||
|
}: ParentCompanyExpensePdfProps) {
|
||||||
|
const meta =
|
||||||
|
dateFrom || dateTo
|
||||||
|
? `Range: ${dateFrom ?? '—'} → ${dateTo ?? 'today'} · ${rows.length} entries`
|
||||||
|
: `${rows.length} entries`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentShell
|
||||||
|
portName={portName}
|
||||||
|
docTitle="Parent Company Expense Report (EUR)"
|
||||||
|
docMeta={meta}
|
||||||
|
logoBuffer={logoBuffer}
|
||||||
|
>
|
||||||
|
<Section title="Summary">
|
||||||
|
<KeyValueGrid
|
||||||
|
rows={[
|
||||||
|
{ label: 'Entries', value: rows.length },
|
||||||
|
{ label: 'Subtotal', value: fmtEur(subtotal) },
|
||||||
|
{ label: 'Management fee (5%)', value: fmtEur(managementFee) },
|
||||||
|
{ label: 'Total', value: fmtEur(total) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{!rateAvailable ? (
|
||||||
|
<Section title="Note">
|
||||||
|
<KeyValueGrid
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
label: 'Currency conversion',
|
||||||
|
value:
|
||||||
|
'EUR exchange rate unavailable at generation time — amounts shown at 1:1 USD:EUR fallback.',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
layout="stacked"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Entries">
|
||||||
|
<DataTable<ParentCompanyRow>
|
||||||
|
columns={[
|
||||||
|
{ header: 'Date', flex: 1, render: (r) => r.date },
|
||||||
|
{ header: 'Establishment', flex: 3, render: (r) => r.establishment },
|
||||||
|
{ header: 'Category', flex: 2, render: (r) => r.category },
|
||||||
|
{ header: 'Amount', flex: 2, align: 'right', render: (r) => fmtEur(r.amountEur) },
|
||||||
|
]}
|
||||||
|
rows={rows}
|
||||||
|
totals={['Total', '', '', fmtEur(total)]}
|
||||||
|
emptyMessage="No expenses in the selected range."
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</DocumentShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import { eq, and, gte, lte, isNull, or, ilike } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { expenses } from '@/lib/db/schema/financial';
|
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 { getRate } from '@/lib/services/currency';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import type { ListExpensesInput } from '@/lib/validators/expenses';
|
import type { ListExpensesInput } from '@/lib/validators/expenses';
|
||||||
@@ -100,7 +103,7 @@ export async function exportCsv(portId: string, query: ListExpensesInput): Promi
|
|||||||
export async function exportParentCompany(
|
export async function exportParentCompany(
|
||||||
portId: string,
|
portId: string,
|
||||||
query: ListExpensesInput,
|
query: ListExpensesInput,
|
||||||
): Promise<Uint8Array> {
|
): Promise<Buffer> {
|
||||||
// BR-043: Convert all amounts to EUR, add 5% management fee
|
// BR-043: Convert all amounts to EUR, add 5% management fee
|
||||||
const rows = await fetchAllExpenses(portId, query);
|
const rows = await fetchAllExpenses(portId, query);
|
||||||
const eurRate = await getRate('USD', 'EUR');
|
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 amountUsd = r.amountUsd ? Number(r.amountUsd) : Number(r.amount);
|
||||||
const amountEur = Number((amountUsd * rate).toFixed(2));
|
const amountEur = Number((amountUsd * rate).toFixed(2));
|
||||||
return {
|
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 ?? '-',
|
establishment: r.establishmentName ?? '-',
|
||||||
category: r.category ?? '-',
|
category: r.category ?? '-',
|
||||||
amountEur,
|
amountEur,
|
||||||
@@ -126,58 +129,22 @@ export async function exportParentCompany(
|
|||||||
const fee = Number((subtotal * 0.05).toFixed(2));
|
const fee = Number((subtotal * 0.05).toFixed(2));
|
||||||
const total = Number((subtotal + fee).toFixed(2));
|
const total = Number((subtotal + fee).toFixed(2));
|
||||||
|
|
||||||
const template = {
|
const [port, logo] = await Promise.all([
|
||||||
basePdf: { width: 210, height: 297, padding: [10, 10, 10, 10] },
|
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
|
||||||
schemas: [
|
resolvePortLogo(portId),
|
||||||
[
|
]);
|
||||||
{
|
|
||||||
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 lines = convertedRows.map(
|
return renderPdf(
|
||||||
(r) => `${r.date} | ${r.establishment} | ${r.category} | EUR ${r.amountEur.toFixed(2)}`,
|
<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);
|
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,9 @@ import sharp from 'sharp';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { expenses } from '@/lib/db/schema/financial';
|
import { expenses } from '@/lib/db/schema/financial';
|
||||||
import { files } from '@/lib/db/schema/documents';
|
import { files } from '@/lib/db/schema/documents';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { getRate } from '@/lib/services/currency';
|
import { getRate } from '@/lib/services/currency';
|
||||||
|
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
|
||||||
import { formatCurrency } from '@/lib/utils/currency';
|
import { formatCurrency } from '@/lib/utils/currency';
|
||||||
import { getStorageBackend } from '@/lib/storage';
|
import { getStorageBackend } from '@/lib/storage';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
@@ -487,6 +489,12 @@ export async function streamExpensePdf(
|
|||||||
const processed = await processExpenses(rawRows, opts.targetCurrency);
|
const processed = await processExpenses(rawRows, opts.targetCurrency);
|
||||||
const totals = computeTotals(processed, opts.targetCurrency, opts.includeProcessingFee);
|
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).
|
// Bulk-resolve receipt file metadata (one DB round-trip vs N).
|
||||||
const allFileIds = processed
|
const allFileIds = processed
|
||||||
.flatMap((r) => r.receiptFileIds ?? [])
|
.flatMap((r) => r.receiptFileIds ?? [])
|
||||||
@@ -513,7 +521,7 @@ export async function streamExpensePdf(
|
|||||||
// / doc.emit('error') and surface to the consumer of the stream.
|
// / doc.emit('error') and surface to the consumer of the stream.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
addHeader(doc, opts);
|
addHeader(doc, { ...opts, portName: port?.name, logoBuffer: logo.buffer });
|
||||||
if (opts.includeSummary) addSummaryBox(doc, totals, opts);
|
if (opts.includeSummary) addSummaryBox(doc, totals, opts);
|
||||||
if (opts.includeDetails) addExpenseTable(doc, processed, opts);
|
if (opts.includeDetails) addExpenseTable(doc, processed, opts);
|
||||||
|
|
||||||
@@ -544,15 +552,64 @@ export async function streamExpensePdf(
|
|||||||
|
|
||||||
// ─── Page sections ──────────────────────────────────────────────────────────
|
// ─── 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
|
doc
|
||||||
.fontSize(24)
|
.fontSize(13)
|
||||||
.font('Helvetica-Bold')
|
.font('Helvetica-Bold')
|
||||||
.fillColor('#000000')
|
.text(opts.documentName, pageWidth / 2, (bandHeight - 13) / 2, {
|
||||||
.text(opts.documentName, { align: 'center' });
|
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()}`;
|
const subheader = opts.subheader ?? `Generated on ${new Date().toLocaleDateString()}`;
|
||||||
doc.fontSize(12).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
|
doc.fontSize(11).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
|
||||||
doc.fillColor('#000000').moveDown(1);
|
doc.fillColor('#000000').moveDown(0.8);
|
||||||
|
// Suppress unused-warning on startY (kept as a documentation anchor).
|
||||||
|
void startY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSummaryBox(
|
function addSummaryBox(
|
||||||
|
|||||||
41
tests/unit/parent-company-export.test.tsx
Normal file
41
tests/unit/parent-company-export.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { renderPdf } from '@/lib/pdf/render';
|
||||||
|
import { ParentCompanyExpensePdf } from '@/lib/pdf/templates/parent-company-expense';
|
||||||
|
|
||||||
|
describe('parent-company expense template', () => {
|
||||||
|
it('renders with multiple rows + totals', async () => {
|
||||||
|
const bytes = await renderPdf(
|
||||||
|
<ParentCompanyExpensePdf
|
||||||
|
portName="Port Test"
|
||||||
|
logoBuffer={null}
|
||||||
|
rows={[
|
||||||
|
{ date: '2026-04-01', establishment: 'Shell', category: 'Fuel', amountEur: 200 },
|
||||||
|
{ date: '2026-04-03', establishment: 'BP', category: 'Fuel', amountEur: 150 },
|
||||||
|
{ date: '2026-04-05', establishment: 'Marina café', category: 'Misc', amountEur: 12.5 },
|
||||||
|
]}
|
||||||
|
subtotal={362.5}
|
||||||
|
managementFee={18.13}
|
||||||
|
total={380.63}
|
||||||
|
dateFrom="2026-04-01"
|
||||||
|
dateTo="2026-04-30"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('renders the rate-unavailable footnote', async () => {
|
||||||
|
const bytes = await renderPdf(
|
||||||
|
<ParentCompanyExpensePdf
|
||||||
|
portName="Port Test"
|
||||||
|
logoBuffer={null}
|
||||||
|
rows={[]}
|
||||||
|
subtotal={0}
|
||||||
|
managementFee={0}
|
||||||
|
total={0}
|
||||||
|
rateAvailable={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user