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>
471 lines
13 KiB
TypeScript
471 lines
13 KiB
TypeScript
import { Team } from 'src/teams/entities/team.entity';
|
||
import PDFDocument = require('pdfkit');
|
||
|
||
export interface CashboxExportRow {
|
||
date: string;
|
||
type: string;
|
||
who: string;
|
||
note: string;
|
||
amount: number;
|
||
runningTotal: number;
|
||
}
|
||
|
||
interface RawRow {
|
||
date: string;
|
||
type: string;
|
||
who: string;
|
||
note: string;
|
||
amount: number;
|
||
}
|
||
|
||
export function buildRows(team: Team, from: string, to: string): CashboxExportRow[] {
|
||
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
||
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
||
|
||
const raw: RawRow[] = [];
|
||
|
||
for (const transaction of team.transactions ?? []) {
|
||
if (!transaction.type) continue;
|
||
raw.push({
|
||
date: transaction.date,
|
||
type: transaction.type.name,
|
||
who: 'Teamkasse',
|
||
note: transaction.note,
|
||
amount: Number(transaction.amount),
|
||
});
|
||
}
|
||
|
||
for (const player of team.players ?? []) {
|
||
for (const transaction of player.transactions ?? []) {
|
||
if (!transaction.type || transaction.type.name !== 'payment') continue;
|
||
raw.push({
|
||
date: transaction.date,
|
||
type: transaction.type.name,
|
||
who: `${player.firstName} ${player.lastName}`,
|
||
note: transaction.note,
|
||
amount: Number(transaction.amount),
|
||
});
|
||
}
|
||
}
|
||
|
||
const filtered = raw
|
||
.filter((row) => {
|
||
const time = new Date(row.date).getTime();
|
||
return time >= fromTime && time <= toTime;
|
||
})
|
||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||
|
||
let runningTotal = 0;
|
||
return filtered.map((row) => {
|
||
runningTotal += row.amount;
|
||
return { ...row, runningTotal };
|
||
});
|
||
}
|
||
|
||
export interface CashboxReceivableRow {
|
||
date: string;
|
||
type: string;
|
||
who: string;
|
||
note: string;
|
||
amount: number;
|
||
}
|
||
|
||
const RECEIVABLE_TYPES = new Set(['fine', 'levy', 'fee']);
|
||
|
||
export function buildReceivableRows(team: Team, from: string, to: string): CashboxReceivableRow[] {
|
||
const fromTime = new Date(`${from}T00:00:00.000Z`).getTime();
|
||
const toTime = new Date(`${to}T23:59:59.999Z`).getTime();
|
||
|
||
const raw: CashboxReceivableRow[] = [];
|
||
for (const player of team.players ?? []) {
|
||
for (const transaction of player.transactions ?? []) {
|
||
if (!transaction.type || !RECEIVABLE_TYPES.has(transaction.type.name)) continue;
|
||
raw.push({
|
||
date: transaction.date,
|
||
type: transaction.type.name,
|
||
who: `${player.firstName} ${player.lastName}`,
|
||
note: transaction.note,
|
||
amount: Number(transaction.amount),
|
||
});
|
||
}
|
||
}
|
||
|
||
return raw
|
||
.filter((row) => {
|
||
const time = new Date(row.date).getTime();
|
||
return time >= fromTime && time <= toTime;
|
||
})
|
||
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||
}
|
||
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
payment: 'Zahlung',
|
||
credit: 'Guthaben',
|
||
expense: 'Ausgabe',
|
||
fine: 'Strafe',
|
||
levy: 'Umlage',
|
||
fee: 'Gebühr',
|
||
};
|
||
|
||
function formatGermanAmount(value: number): string {
|
||
const rounded = Math.sign(value) * Math.round((Math.abs(value) + Number.EPSILON) * 100) / 100;
|
||
const normalized = rounded === 0 ? 0 : rounded;
|
||
return normalized.toFixed(2).replace('.', ',');
|
||
}
|
||
|
||
function escapeCsvField(value: string): string {
|
||
if (/[;"\n\r]/.test(value)) {
|
||
return `"${value.replace(/"/g, '""')}"`;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function buildCsv(rows: CashboxExportRow[]): string {
|
||
const lines = ['Datum;Typ;Wer;Notiz;Betrag;Periodensaldo'];
|
||
if (rows.length === 0) {
|
||
lines.push('Keine Buchungen im gewählten Zeitraum');
|
||
} else {
|
||
for (const row of rows) {
|
||
lines.push(
|
||
[
|
||
row.date.slice(0, 10),
|
||
TYPE_LABELS[row.type] ?? row.type,
|
||
escapeCsvField(row.who),
|
||
escapeCsvField(row.note),
|
||
formatGermanAmount(row.amount),
|
||
formatGermanAmount(row.runningTotal),
|
||
].join(';'),
|
||
);
|
||
}
|
||
}
|
||
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: 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);
|
||
|
||
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
|
||
.fontSize(10)
|
||
.font('Helvetica-Oblique')
|
||
.fillColor(COLORS.muted)
|
||
.text('Keine Buchungen im gewählten Zeitraum.', PAGE_MARGIN, y);
|
||
y += 24;
|
||
} else {
|
||
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();
|
||
});
|
||
}
|