feat: redesign cashbox PDF report with styled tables and receivables section

The PDF export was an unformatted list of doc.text() lines and only
showed real cash movements (payment type). Rebuilds it as a proper
two-section report: a branded header band, a bordered/zebra-striped
table with colored amounts and bold running balance for cash
movements, and a second "Forderungen" section listing fine/levy/fee
entries created in the period with their own total. Tables paginate
across pages and every page gets a footer with page numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-04 12:20:33 +02:00
parent 7b499b361f
commit 02a4d2e59d
2 changed files with 371 additions and 22 deletions

View File

@@ -141,34 +141,330 @@ export function buildCsv(rows: CashboxExportRow[]): string {
return lines.join('\r\n');
}
const PAGE_MARGIN = 40;
const COLORS = {
headerBand: '#4f8f46',
headerText: '#ffffff',
sectionTitle: '#3f7f3c',
tableHeaderBg: '#e8f2e4',
tableHeaderText: '#20251f',
zebra: '#f7f8f2',
border: '#dde3d8',
text: '#20251f',
muted: '#5b6357',
positive: '#2e7d32',
negative: '#c1121f',
receivable: '#9e9e9e',
footerText: '#8a9186',
};
interface Column {
label: string;
width: number;
align?: 'left' | 'right';
}
const CASH_COLUMNS: Column[] = [
{ label: 'Datum', width: 60 },
{ label: 'Typ', width: 60 },
{ label: 'Wer', width: 110 },
{ label: 'Notiz', width: 150 },
{ label: 'Betrag', width: 65, align: 'right' },
{ label: 'Saldo', width: 65, align: 'right' },
];
const RECEIVABLE_COLUMNS: Column[] = [
{ label: 'Datum', width: 60 },
{ label: 'Typ', width: 70 },
{ label: 'Wer', width: 130 },
{ label: 'Notiz', width: 190 },
{ label: 'Betrag', width: 65, align: 'right' },
];
const ROW_HEIGHT = 20;
const HEADER_ROW_HEIGHT = 22;
const CELL_PADDING = 5;
interface TableRow {
cells: string[];
cellColors?: (string | undefined)[];
boldCells?: boolean[];
}
function tableWidth(columns: Column[]): number {
return columns.reduce((sum, col) => sum + col.width, 0);
}
function formatAmount(value: number): string {
return `${formatGermanAmount(value)}`;
}
// Character-count heuristic instead of doc.widthOfString: keeps row height
// fixed at one line without coupling truncation to the exact font metrics
// used at draw time.
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength - 1)}`;
}
function drawTableHeaderRow(doc: PDFKit.PDFDocument, columns: Column[], y: number): void {
const width = tableWidth(columns);
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).fill(COLORS.tableHeaderBg);
let colX = PAGE_MARGIN;
for (const column of columns) {
doc
.fillColor(COLORS.tableHeaderText)
.font('Helvetica-Bold')
.fontSize(9)
.text(column.label, colX + CELL_PADDING, y + 6, {
width: column.width - CELL_PADDING * 2,
align: column.align ?? 'left',
lineBreak: false,
});
colX += column.width;
}
doc.rect(PAGE_MARGIN, y, width, HEADER_ROW_HEIGHT).stroke(COLORS.border);
}
function drawTable(
doc: PDFKit.PDFDocument,
columns: Column[],
rows: TableRow[],
startY: number,
pageBottom: number,
): number {
const width = tableWidth(columns);
let y = startY;
drawTableHeaderRow(doc, columns, y);
y += HEADER_ROW_HEIGHT;
rows.forEach((row, index) => {
if (y + ROW_HEIGHT > pageBottom) {
doc.addPage();
y = PAGE_MARGIN;
drawTableHeaderRow(doc, columns, y);
y += HEADER_ROW_HEIGHT;
}
if (index % 2 === 1) {
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.zebra);
}
let colX = PAGE_MARGIN;
row.cells.forEach((cellText, colIndex) => {
const column = columns[colIndex];
doc
.fillColor(row.cellColors?.[colIndex] ?? COLORS.text)
.font(row.boldCells?.[colIndex] ? 'Helvetica-Bold' : 'Helvetica')
.fontSize(9)
.text(cellText, colX + CELL_PADDING, y + 5, {
width: column.width - CELL_PADDING * 2,
align: column.align ?? 'left',
lineBreak: false,
});
colX += column.width;
});
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
y += ROW_HEIGHT;
});
return y;
}
function drawSummaryRow(
doc: PDFKit.PDFDocument,
columns: Column[],
label: string,
value: string,
startY: number,
pageBottom: number,
valueColor: string,
): number {
let y = startY;
if (y + ROW_HEIGHT > pageBottom) {
doc.addPage();
y = PAGE_MARGIN;
}
const width = tableWidth(columns);
const valueColumnWidth = columns[columns.length - 1].width;
const labelWidth = width - valueColumnWidth - CELL_PADDING * 2;
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).fill(COLORS.tableHeaderBg);
doc
.fillColor(COLORS.tableHeaderText)
.font('Helvetica-Bold')
.fontSize(9)
.text(label, PAGE_MARGIN + CELL_PADDING, y + 5, { width: labelWidth, lineBreak: false });
doc
.fillColor(valueColor)
.font('Helvetica-Bold')
.fontSize(9)
.text(value, PAGE_MARGIN + width - valueColumnWidth + CELL_PADDING, y + 5, {
width: valueColumnWidth - CELL_PADDING * 2,
align: 'right',
lineBreak: false,
});
doc.rect(PAGE_MARGIN, y, width, ROW_HEIGHT).stroke(COLORS.border);
return y + ROW_HEIGHT;
}
function addFooters(doc: PDFKit.PDFDocument, teamName: string): void {
const range = doc.bufferedPageRange();
const generatedAt = new Date().toLocaleDateString('de-DE');
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
const footerY = doc.page.height - 25;
doc
.fontSize(8)
.font('Helvetica')
.fillColor(COLORS.footerText)
.text(`${teamName} Kassenbuch-Report, erstellt am ${generatedAt}`, PAGE_MARGIN, footerY, {
width: doc.page.width - PAGE_MARGIN * 2 - 60,
lineBreak: false,
});
doc
.fontSize(8)
.fillColor(COLORS.footerText)
.text(`Seite ${i - range.start + 1} von ${range.count}`, doc.page.width - PAGE_MARGIN - 60, footerY, {
width: 60,
align: 'right',
lineBreak: false,
});
}
}
export function buildPdf(
team: Pick<Team, 'name'>,
rows: CashboxExportRow[],
receivableRows: CashboxReceivableRow[],
from: string,
to: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 40 });
const doc = new PDFDocument({ margin: PAGE_MARGIN, bufferPages: true, size: 'A4' });
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.fontSize(16).text(`Kassenbuch ${team.name}`);
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`);
doc.moveDown();
const pageWidth = doc.page.width;
const pageBottom = doc.page.height - PAGE_MARGIN - 30;
doc.rect(0, 0, pageWidth, 90).fill(COLORS.headerBand);
doc
.fillColor(COLORS.headerText)
.font('Helvetica-Bold')
.fontSize(20)
.text(team.name, PAGE_MARGIN, 28, { width: pageWidth - PAGE_MARGIN * 2, lineBreak: false });
doc.font('Helvetica').fontSize(11).text('Kassenbuch-Report', PAGE_MARGIN, 55);
doc.fontSize(10).text(`Zeitraum: ${from} bis ${to}`, PAGE_MARGIN, 70);
let y = 110;
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
doc.text('Kassenbewegungen', PAGE_MARGIN, y);
y += 20;
doc
.fontSize(9)
.font('Helvetica')
.fillColor(COLORS.muted)
.text('Buchungen, die den tatsächlichen Kassenstand verändern.', PAGE_MARGIN, y);
y += 18;
if (rows.length === 0) {
doc.text('Keine Buchungen im gewählten Zeitraum.');
doc
.fontSize(10)
.font('Helvetica-Oblique')
.fillColor(COLORS.muted)
.text('Keine Buchungen im gewählten Zeitraum.', PAGE_MARGIN, y);
y += 24;
} else {
for (const row of rows) {
doc.text(
`${row.date.slice(0, 10)} ${TYPE_LABELS[row.type] ?? row.type} ${row.who} ${row.note} ` +
`${formatGermanAmount(row.amount)} € Saldo: ${formatGermanAmount(row.runningTotal)}`,
);
}
const cashTableRows: TableRow[] = rows.map((row) => ({
cells: [
row.date.slice(0, 10),
TYPE_LABELS[row.type] ?? row.type,
truncate(row.who, 20),
truncate(row.note, 26),
formatAmount(row.amount),
formatAmount(row.runningTotal),
],
cellColors: [
undefined,
undefined,
undefined,
undefined,
row.amount < 0 ? COLORS.negative : COLORS.positive,
undefined,
],
boldCells: [false, false, false, false, false, true],
}));
y = drawTable(doc, CASH_COLUMNS, cashTableRows, y, pageBottom);
const endBalance = rows[rows.length - 1].runningTotal;
y = drawSummaryRow(
doc,
CASH_COLUMNS,
'Endsaldo Kassenbewegungen',
formatAmount(endBalance),
y,
pageBottom,
endBalance < 0 ? COLORS.negative : COLORS.positive,
);
y += 20;
}
y += 10;
if (y + 70 > pageBottom) {
doc.addPage();
y = PAGE_MARGIN;
}
doc.fontSize(13).font('Helvetica-Bold').fillColor(COLORS.sectionTitle);
doc.text('Forderungen (Strafen, Beiträge, Umlagen)', PAGE_MARGIN, y);
y += 20;
doc
.fontSize(9)
.font('Helvetica')
.fillColor(COLORS.muted)
.text(
'Im Zeitraum angelegte Forderungen gegen Mitglieder. Diese verändern den tatsächlichen Kassenstand nicht, solange sie nicht bezahlt wurden.',
PAGE_MARGIN,
y,
{ width: tableWidth(RECEIVABLE_COLUMNS) },
);
y += 28;
if (receivableRows.length === 0) {
doc
.fontSize(10)
.font('Helvetica-Oblique')
.fillColor(COLORS.muted)
.text('Keine Forderungen im gewählten Zeitraum.', PAGE_MARGIN, y);
y += 24;
} else {
const receivableTableRows: TableRow[] = receivableRows.map((row) => ({
cells: [
row.date.slice(0, 10),
TYPE_LABELS[row.type] ?? row.type,
truncate(row.who, 24),
truncate(row.note, 34),
formatAmount(row.amount),
],
cellColors: [undefined, undefined, undefined, undefined, COLORS.receivable],
}));
y = drawTable(doc, RECEIVABLE_COLUMNS, receivableTableRows, y, pageBottom);
const total = receivableRows.reduce((sum, row) => sum + row.amount, 0);
y = drawSummaryRow(
doc,
RECEIVABLE_COLUMNS,
'Summe Forderungen',
formatAmount(total),
y,
pageBottom,
COLORS.receivable,
);
}
addFooters(doc, team.name);
doc.end();
});
}