diff --git a/src/lib/pdf/templates/parent-company-expense.tsx b/src/lib/pdf/templates/parent-company-expense.tsx new file mode 100644 index 00000000..1eac035b --- /dev/null +++ b/src/lib/pdf/templates/parent-company-expense.tsx @@ -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 ( + +
+ + {!rateAvailable ? ( +
+ +
+ ) : null} +
+ +
+ + 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." + /> +
+
+ ); +} diff --git a/src/lib/services/expense-export.ts b/src/lib/services/expense-export.tsx similarity index 72% rename from src/lib/services/expense-export.ts rename to src/lib/services/expense-export.tsx index cca80dc1..d12d32b8 100644 --- a/src/lib/services/expense-export.ts +++ b/src/lib/services/expense-export.tsx @@ -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 { +): Promise { // 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( + , ); - - 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[0], inputs); } diff --git a/src/lib/services/expense-pdf.service.ts b/src/lib/services/expense-pdf.service.ts index a60ab631..467ed92e 100644 --- a/src/lib/services/expense-pdf.service.ts +++ b/src/lib/services/expense-pdf.service.ts @@ -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( diff --git a/tests/unit/parent-company-export.test.tsx b/tests/unit/parent-company-export.test.tsx new file mode 100644 index 00000000..a25606ab --- /dev/null +++ b/tests/unit/parent-company-export.test.tsx @@ -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( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); + + it('renders the rate-unavailable footnote', async () => { + const bytes = await renderPdf( + , + ); + expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, 30_000); +});