// PDF report for the cash-basis GuV / EÜR BI pages (/bi/profit-loss, /bi/income-surplus). // Client-only jsPDF (A4 landscape), built from the SAME aggregated rows the page // renders (BI/BankCashReport.vue hands over a plain payload — no re-aggregation // here). Pages: ① overview (KPI cards + monthly chart drawn natively, no amCharts // export), ② the month × position report table, ③ top payers / payees with // share bars. Helvetica (WinAnsi) covers umlauts, € and Ø; avoid Δ / arrows. export interface BankCashPdfRow { kind: 'section' | 'sub' | 'sum' | 'result' | 'row' label: string values?: number[] total?: number signed?: boolean muted?: boolean italic?: boolean lineNo?: string count?: number } export interface BankCashPdfCounterpart { name: string; total: number; count: number } export interface BankCashPdfPayload { variant: 'guv' | 'euer' title: string year: number scope: string orgName: string dataBasis: string locale: string generatedAt: Date monthShort: string[] monthHasData: boolean[] futureFrom: number | null // first month (1..12) that lies in the future, null = none kpis: { income: number; expense: number; net: number; avgNet: number; activeMonths: number; count: number prev: { income: number; expense: number; net: number } | null } chart: { income: number; expense: number; net: number | null; prevNet: number | null }[] rows: BankCashPdfRow[] payers: { top: BankCashPdfCounterpart[]; other: BankCashPdfCounterpart | null } payees: { top: BankCashPdfCounterpart[]; other: BankCashPdfCounterpart | null } transfers: { included: boolean; count: number; amount: number } truncated: boolean labels: Record } const PAGE_W = 297 const PAGE_H = 210 const MARGIN = 12 const CONTENT_W = PAGE_W - 2 * MARGIN // Same palette as BankCashMonthlyChart.vue (light theme) + slate neutrals. const C = { income: [42, 120, 214] as const, incomeSoft: [226, 237, 250] as const, expense: [235, 104, 52] as const, expenseSoft: [252, 234, 226] as const, ink: [11, 11, 11] as const, text: [52, 51, 48] as const, muted: [120, 119, 114] as const, grid: [226, 225, 221] as const, band: [245, 244, 241] as const, accent: [30, 41, 59] as const, resultSoft: [236, 240, 246] as const, negative: [185, 28, 28] as const, white: [255, 255, 255] as const } type RGB = readonly [number, number, number] const fill = (doc: any, c: RGB) => doc.setFillColor(c[0], c[1], c[2]) const stroke = (doc: any, c: RGB) => doc.setDrawColor(c[0], c[1], c[2]) const ink = (doc: any, c: RGB) => doc.setTextColor(c[0], c[1], c[2]) const font = (doc: any, size: number, style: 'normal' | 'bold' | 'italic' | 'bolditalic' = 'normal') => { doc.setFont('helvetica', style) doc.setFontSize(size) } /** Truncate with an ellipsis so the text fits `maxW` mm at the current font. */ const fitText = (doc: any, text: string, maxW: number): string => { const s = String(text ?? '') if (doc.getTextWidth(s) <= maxW) return s let lo = 0 let hi = s.length while (lo < hi) { const mid = Math.ceil((lo + hi) / 2) if (doc.getTextWidth(s.slice(0, mid) + '…') <= maxW) lo = mid else hi = mid - 1 } return s.slice(0, Math.max(0, lo)).trimEnd() + '…' } const loadImage = (url: string): Promise<{ dataUrl: string; w: number; h: number } | null> => new Promise(resolve => { if (!import.meta.client || typeof Image === 'undefined') return resolve(null) const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { try { const canvas = document.createElement('canvas') canvas.width = img.naturalWidth canvas.height = img.naturalHeight canvas.getContext('2d')?.drawImage(img, 0, 0) resolve({ dataUrl: canvas.toDataURL('image/png'), w: img.naturalWidth, h: img.naturalHeight }) } catch { resolve(null) } } img.onerror = () => resolve(null) img.src = url }) export const bankCashReportPdfFileName = (variant: string, year: number, accountId?: string | number) => `${variant === 'guv' ? 'guv' : 'euer'}-${year}${accountId ? '-' + accountId : ''}.pdf` export const buildBankCashReportPdf = async (p: BankCashPdfPayload) => { if (!import.meta.client) throw new Error('PDF generation is client-only') const { jsPDF } = await import('jspdf') const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4', compress: true }) const L = p.labels const money = (v: number | null | undefined, blankZero = false) => { const n = Number(v) || 0 if (blankZero && n === 0) return '' return new Intl.NumberFormat(p.locale, { style: 'currency', currency: 'EUR' }).format(n) } const percent = (v: number, total: number) => total ? new Intl.NumberFormat(p.locale, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v / total) : '—' const dateTime = new Intl.DateTimeFormat(p.locale, { dateStyle: 'medium', timeStyle: 'short' }).format(p.generatedAt) const logo = await loadImage('/assets/logyou-logo-color.png') // --------------------------------------------------------------------------- // Chrome: header band + footer on every page (page numbers filled at the end) const HEADER_H = 22 const FOOTER_Y = PAGE_H - 9 const pages: { subtitle: string }[] = [] const header = (subtitle: string) => { pages.push({ subtitle }) fill(doc, C.accent) doc.rect(0, 0, PAGE_W, HEADER_H, 'F') fill(doc, C.income) doc.rect(0, HEADER_H, PAGE_W, 1.2, 'F') ink(doc, C.white) font(doc, 15, 'bold') doc.text(`${p.title} ${p.year}`, MARGIN, 10) font(doc, 9, 'normal') doc.setTextColor(200, 208, 222) doc.text(subtitle, MARGIN, 16.5) let rightEdge = PAGE_W - MARGIN if (logo) { const h = 9 const w = h * (logo.w / logo.h) // white plate so the colour logo sits cleanly on the dark band fill(doc, C.white) doc.roundedRect(rightEdge - w - 3, 4.5, w + 6, h + 4, 1.5, 1.5, 'F') try { doc.addImage(logo.dataUrl, 'PNG', rightEdge - w, 6.5, w, h, undefined, 'FAST') } catch {} rightEdge -= w + 10 } font(doc, 9, 'bold') ink(doc, C.white) doc.text(fitText(doc, p.orgName || '', 90), rightEdge, 10, { align: 'right' }) font(doc, 8, 'normal') doc.setTextColor(200, 208, 222) doc.text(fitText(doc, p.scope, 90), rightEdge, 16.5, { align: 'right' }) } const footers = () => { const total = doc.getNumberOfPages() for (let i = 1; i <= total; i++) { doc.setPage(i) stroke(doc, C.grid) doc.setLineWidth(0.25) doc.line(MARGIN, FOOTER_Y - 3.5, PAGE_W - MARGIN, FOOTER_Y - 3.5) font(doc, 7, 'normal') ink(doc, C.muted) doc.text(fitText(doc, `${L.generated || 'Generated'} ${dateTime} · ${p.dataBasis}`, 200), MARGIN, FOOTER_Y) doc.text(`${L.page || 'Page'} ${i} / ${total}`, PAGE_W - MARGIN, FOOTER_Y, { align: 'right' }) } } // --------------------------------------------------------------------------- // Page 1 — overview header(L.overview || 'Overview') let y = HEADER_H + 10 // KPI cards const cards = [ { label: `${L.income} ${p.year}`, value: money(p.kpis.income), sub: p.kpis.prev ? `${p.year - 1}: ${money(p.kpis.prev.income)}` : '', color: C.income, soft: C.incomeSoft }, { label: `${L.expense} ${p.year}`, value: money(p.kpis.expense), sub: p.kpis.prev ? `${p.year - 1}: ${money(p.kpis.prev.expense)}` : '', color: C.expense, soft: C.expenseSoft }, { label: `${L.result} ${p.year}`, value: money(p.kpis.net), sub: p.kpis.prev ? `${p.year - 1}: ${money(p.kpis.prev.net)}` : '', color: p.kpis.net < 0 ? C.negative : C.accent, soft: C.resultSoft, signed: true, v: p.kpis.net }, { label: L.avgMonth || 'Ø result per month', value: money(p.kpis.avgNet), sub: `${p.kpis.activeMonths} ${L.months || 'months'} · ${p.kpis.count} ${L.movements || 'movements'}`, color: p.kpis.avgNet < 0 ? C.negative : C.accent, soft: C.band, signed: true, v: p.kpis.avgNet } ] const gap = 6 const cardW = (CONTENT_W - gap * 3) / 4 const cardH = 26 cards.forEach((card, i) => { const x = MARGIN + i * (cardW + gap) fill(doc, card.soft) doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F') fill(doc, card.color) doc.rect(x, y + 3, 1.4, cardH - 6, 'F') font(doc, 8, 'normal') ink(doc, C.muted) doc.text(fitText(doc, card.label, cardW - 10), x + 6, y + 7) font(doc, 15, 'bold') ink(doc, card.signed && (card.v ?? 0) < 0 ? C.negative : C.ink) doc.text(card.value, x + 6, y + 15.5) font(doc, 7.5, 'normal') ink(doc, C.muted) doc.text(fitText(doc, card.sub, cardW - 10), x + 6, y + 21.5) }) y += cardH + 9 // Chart (native): income columns up, expense columns down, result line, dashed previous year. font(doc, 10, 'bold') ink(doc, C.ink) doc.text(L.chartTitle || 'Monthly development', MARGIN, y) y += 3 const chartTop = y + 3 const chartBottom = FOOTER_Y - 18 const axisW = 26 const plotX = MARGIN + axisW const plotW = CONTENT_W - axisW const plotH = chartBottom - chartTop const maxAbs = Math.max(1, ...p.chart.flatMap(r => [r.income, r.expense, Math.abs(r.net ?? 0), Math.abs(r.prevNet ?? 0)])) // "nice" axis step const rawStep = maxAbs / 4 const mag = Math.pow(10, Math.floor(Math.log10(rawStep))) const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s >= rawStep) || rawStep const axisMax = Math.ceil(maxAbs / step) * step const hasNegative = p.chart.some(r => r.expense > 0 || (r.net ?? 0) < 0 || (r.prevNet ?? 0) < 0) const axisMin = hasNegative ? -axisMax : 0 const yOf = (v: number) => chartTop + (axisMax - v) / (axisMax - axisMin) * plotH const zeroY = yOf(0) stroke(doc, C.grid) doc.setLineWidth(0.2) font(doc, 7, 'normal') ink(doc, C.muted) const compact = (v: number) => new Intl.NumberFormat(p.locale, { maximumFractionDigits: 0 }).format(v) for (let v = axisMin; v <= axisMax + 1e-9; v += step) { const gy = yOf(v) doc.line(plotX, gy, plotX + plotW, gy) doc.text(compact(v), plotX - 2, gy + 1, { align: 'right' }) } stroke(doc, C.text) doc.setLineWidth(0.35) doc.line(plotX, zeroY, plotX + plotW, zeroY) const slot = plotW / 12 const barW = slot * 0.56 p.chart.forEach((r, i) => { const cx = plotX + slot * i + slot / 2 if (r.income > 0) { fill(doc, C.income) doc.roundedRect(cx - barW / 2, yOf(r.income), barW, Math.max(0.3, zeroY - yOf(r.income)), 0.6, 0.6, 'F') } if (r.expense > 0) { fill(doc, C.expense) doc.roundedRect(cx - barW / 2, zeroY, barW, Math.max(0.3, yOf(-r.expense) - zeroY), 0.6, 0.6, 'F') } font(doc, 7.5, 'normal') ink(doc, C.text) doc.text(p.monthShort[i] || '', cx, chartBottom + 4.5, { align: 'center' }) }) const polyline = (field: 'net' | 'prevNet', color: RGB, dashed: boolean) => { stroke(doc, color) doc.setLineWidth(0.6) if (dashed) doc.setLineDashPattern([1.6, 1.2], 0) let prev: { x: number; y: number } | null = null p.chart.forEach((r, i) => { const v = r[field] if (v === null || v === undefined) { prev = null; return } const pt = { x: plotX + slot * i + slot / 2, y: yOf(v) } if (prev) doc.line(prev.x, prev.y, pt.x, pt.y) prev = pt }) if (dashed) doc.setLineDashPattern([], 0) if (!dashed) { p.chart.forEach((r, i) => { const v = r[field] if (v === null || v === undefined) return const x = plotX + slot * i + slot / 2 fill(doc, color) stroke(doc, C.white) doc.setLineWidth(0.4) doc.circle(x, yOf(v), 1.1, 'FD') }) } } const showPrev = p.chart.some(r => r.prevNet !== null && r.prevNet !== undefined) if (showPrev) polyline('prevNet', C.muted, true) polyline('net', C.ink, false) // legend let lx = plotX const ly = chartBottom + 10 const legend = (label: string, color: RGB, kind: 'box' | 'line' | 'dash') => { if (kind === 'box') { fill(doc, color); doc.rect(lx, ly - 2.2, 3.2, 3.2, 'F') } else { stroke(doc, color); doc.setLineWidth(0.6) if (kind === 'dash') doc.setLineDashPattern([1.2, 1], 0) doc.line(lx, ly - 0.6, lx + 5, ly - 0.6) if (kind === 'dash') doc.setLineDashPattern([], 0) } font(doc, 7.5, 'normal') ink(doc, C.text) doc.text(label, lx + 6.5, ly) lx += doc.getTextWidth(label) + 14 } legend(L.income || 'Income', C.income, 'box') legend(L.expense || 'Expenses', C.expense, 'box') legend(L.result || 'Result', C.ink, 'line') if (showPrev) legend(`${L.result || 'Result'} ${p.year - 1}`, C.muted, 'dash') // --------------------------------------------------------------------------- // Page 2+ — report table const isEuer = p.variant === 'euer' const lineColW = isEuer ? 9 : 0 const totalColW = 24 const monthColW = 16.5 const labelColW = CONTENT_W - lineColW - totalColW - 12 * monthColW const rowH = 5 const colX = (i: number) => MARGIN + lineColW + labelColW + i * monthColW // left edge of month i (0..11) const totalX = MARGIN + lineColW + labelColW + 12 * monthColW const tableHead = () => { fill(doc, C.band) doc.rect(MARGIN, y, CONTENT_W, rowH + 1, 'F') font(doc, 7.5, 'bold') ink(doc, C.text) const by = y + rowH - 1 if (isEuer) doc.text(L.line || 'Line', MARGIN + 1.5, by) doc.text(L.position || 'Position', MARGIN + lineColW + 1.5, by) p.monthShort.forEach((m, i) => { const future = p.futureFrom !== null && i + 1 >= p.futureFrom ink(doc, future ? C.muted : C.text) doc.text(m, colX(i) + monthColW - 1.2, by, { align: 'right' }) }) ink(doc, C.text) doc.text(`${L.yearTotal || 'Year'} ${p.year}`, totalX + totalColW - 1.2, by, { align: 'right' }) y += rowH + 1 } const startTablePage = () => { doc.addPage() header(`${L.reportTable || 'Report'} · ${p.scope}`) y = HEADER_H + 9 tableHead() } startTablePage() const cellText = (row: BankCashPdfRow, i: number) => { const v = row.values?.[i] ?? 0 const future = p.futureFrom !== null && i + 1 >= p.futureFrom if (!v && (future || !p.monthHasData[i])) return '' return money(v, !row.signed) } p.rows.forEach((row, idx) => { // Keep the closing block (result + the rows after it) together: break early // instead of leaving a lone row on the next page. const tail = p.rows.length - idx const needed = tail <= 4 ? tail * rowH : rowH if (y + needed > FOOTER_Y - 8) startTablePage() const base = y + rowH - 1.4 if (row.kind === 'section') { y += 1.2 fill(doc, C.resultSoft) doc.rect(MARGIN, y, CONTENT_W, rowH, 'F') font(doc, 8, 'bold') ink(doc, C.accent) doc.text(fitText(doc, row.label, CONTENT_W - 4), MARGIN + 1.5, y + rowH - 1.4) y += rowH return } const strong = row.kind === 'sum' || row.kind === 'result' if (row.kind === 'result') { fill(doc, C.band) doc.rect(MARGIN, y, CONTENT_W, rowH, 'F') } if (strong) { stroke(doc, C.text) doc.setLineWidth(0.3) doc.line(MARGIN, y, PAGE_W - MARGIN, y) } else { stroke(doc, C.grid) doc.setLineWidth(0.15) doc.line(MARGIN, y + rowH, PAGE_W - MARGIN, y + rowH) } const style: any = strong ? 'bold' : row.italic ? 'italic' : 'normal' font(doc, 7.5, style) ink(doc, row.muted ? C.muted : C.text) if (isEuer) { font(doc, 7, 'normal') ink(doc, C.muted) doc.text(row.lineNo || '', MARGIN + 1.5, base) font(doc, 7.5, style) ink(doc, row.muted ? C.muted : C.text) } const indent = row.kind === 'sub' ? 4 : 1.5 const labelMaxW = labelColW - indent - 1 // "Name · n" when it fits, otherwise the (truncated) name alone. const withCount = row.count ? `${row.label} · ${row.count}` : row.label const label = doc.getTextWidth(withCount) <= labelMaxW ? withCount : fitText(doc, row.label, labelMaxW) doc.text(label, MARGIN + lineColW + indent, base) font(doc, 7, strong ? 'bold' : 'normal') for (let i = 0; i < 12; i++) { const v = row.values?.[i] ?? 0 ink(doc, row.signed && v < 0 ? C.negative : row.muted ? C.muted : C.text) doc.text(cellText(row, i), colX(i) + monthColW - 1.2, base, { align: 'right' }) } font(doc, 7.2, 'bold') ink(doc, row.signed && (row.total ?? 0) < 0 ? C.negative : row.muted ? C.muted : C.ink) doc.text(money(row.total ?? 0), totalX + totalColW - 1.2, base, { align: 'right' }) y += rowH }) stroke(doc, C.text) doc.setLineWidth(0.3) doc.line(MARGIN, y, PAGE_W - MARGIN, y) const notes = [L.disclaimer || ''] if (p.transfers.count) { notes.push(p.transfers.included ? `${L.transfersIncluded || 'Internal transfers between own accounts included'}: ${p.transfers.count} · ${money(p.transfers.amount)}` : `${L.transfersExcluded || 'Internal transfers between own accounts excluded'}: ${p.transfers.count} · ${money(p.transfers.amount)}`) } if (p.truncated) notes.push(L.truncated || '') // Notes go under the table when they fit, otherwise under the counterpart // tables — never alone on an extra page. const drawNotes = (yStart: number): number => { let yy = yStart font(doc, 7, 'italic') ink(doc, C.muted) for (const note of notes.filter(Boolean)) { const lines: string[] = doc.splitTextToSize(note, CONTENT_W) doc.text(lines, MARGIN, yy) yy += lines.length * 3.6 + 1 } return yy } const notesHeight = (): number => { font(doc, 7, 'italic') return notes.filter(Boolean).reduce((h, note) => h + (doc.splitTextToSize(note, CONTENT_W) as string[]).length * 3.6 + 1, 0) } let notesPending = true if (y + 5 + notesHeight() <= FOOTER_Y - 6) { drawNotes(y + 5) notesPending = false } // --------------------------------------------------------------------------- // Page 3 — counterparts doc.addPage() header(L.counterparts || 'Counterparts') y = HEADER_H + 10 const colGap = 10 const halfW = (CONTENT_W - colGap) / 2 const cpTable = (x: number, title: string, list: { top: BankCashPdfCounterpart[]; other: BankCashPdfCounterpart | null }, total: number, sumLabel: string, color: RGB, soft: RGB) => { let yy = y font(doc, 10, 'bold') ink(doc, C.ink) doc.text(title, x, yy) yy += 4 const nameW = halfW * 0.44 const countW = 12 const amountW = 28 const shareW = 14 const barW = halfW - nameW - countW - amountW - shareW - 4 const cx = { name: x + 1.5, count: x + nameW + countW, amount: x + nameW + countW + amountW, share: x + nameW + countW + amountW + shareW, bar: x + nameW + countW + amountW + shareW + 2 } const h = 6 fill(doc, C.band) doc.rect(x, yy, halfW, h, 'F') font(doc, 7.5, 'bold') ink(doc, C.text) doc.text(L.counterpart || 'Counterpart', cx.name, yy + 4.2) doc.text(L.count || 'Count', cx.count, yy + 4.2, { align: 'right' }) doc.text(L.amount || 'Amount', cx.amount, yy + 4.2, { align: 'right' }) doc.text(L.share || 'Share', cx.share, yy + 4.2, { align: 'right' }) yy += h const maxTotal = Math.max(1, ...list.top.map(r => r.total), list.other?.total || 0) const rows = [...list.top.map(r => ({ ...r, muted: false })), ...(list.other ? [{ ...list.other, muted: true }] : [])] for (const r of rows) { stroke(doc, C.grid) doc.setLineWidth(0.15) doc.line(x, yy + h, x + halfW, yy + h) font(doc, 7.5, r.muted ? 'italic' : 'normal') ink(doc, r.muted ? C.muted : C.text) doc.text(fitText(doc, r.name, nameW - 3), cx.name, yy + 4.2) doc.text(String(r.count), cx.count, yy + 4.2, { align: 'right' }) font(doc, 7.5, r.muted ? 'italic' : 'bold') doc.text(money(r.total), cx.amount, yy + 4.2, { align: 'right' }) font(doc, 7.5, 'normal') doc.text(percent(r.total, total), cx.share, yy + 4.2, { align: 'right' }) fill(doc, soft) doc.rect(cx.bar, yy + 1.6, barW, h - 3.2, 'F') fill(doc, r.muted ? C.muted : color) doc.rect(cx.bar, yy + 1.6, Math.max(0.4, barW * r.total / maxTotal), h - 3.2, 'F') yy += h } stroke(doc, C.text) doc.setLineWidth(0.3) doc.line(x, yy, x + halfW, yy) font(doc, 8, 'bold') ink(doc, C.ink) doc.text(sumLabel, cx.name, yy + 4.4) doc.text(money(total), cx.amount, yy + 4.4, { align: 'right' }) return yy + h } const y1 = cpTable(MARGIN, L.topPayers || 'Top payers (income)', p.payers, p.kpis.income, L.sumIncome || 'Total revenue', C.income, C.incomeSoft) const y2 = cpTable(MARGIN + halfW + colGap, L.topPayees || 'Top payees (expenses)', p.payees, p.kpis.expense, L.sumExpense || 'Total expenses', C.expense, C.expenseSoft) if (notesPending) drawNotes(Math.max(y1, y2) + 8) footers() return doc }