diff --git a/apps/mana/apps/web/package.json b/apps/mana/apps/web/package.json index 80a9d2396..79b118b3a 100644 --- a/apps/mana/apps/web/package.json +++ b/apps/mana/apps/web/package.json @@ -28,7 +28,6 @@ "@tailwindcss/vite": "^4.1.7", "@types/node": "^22.10.5", "@vite-pwa/sveltekit": "^1.1.0", - "workbox-window": "^7.3.0", "@vitest/coverage-v8": "^4.1.2", "@vitest/ui": "^4.1.2", "autoprefixer": "^10.4.20", @@ -44,7 +43,8 @@ "tslib": "^2.8.1", "typescript": "^5.9.3", "vite": "^6.0.7", - "vitest": "^4.1.2" + "vitest": "^4.1.2", + "workbox-window": "^7.3.0" }, "dependencies": { "@calc/shared": "workspace:*", @@ -76,13 +76,14 @@ "@mana/shared-utils": "workspace:*", "@mana/spiral-db": "workspace:*", "@mana/wallpaper-generator": "workspace:*", + "@quotes/content": "workspace:*", "@types/pako": "^2.0.4", "@types/suncalc": "^1.9.2", - "@quotes/content": "workspace:*", "date-fns": "^4.1.0", "dexie": "^4.0.11", "marked": "^17.0.5", "pako": "^2.1.0", + "pdf-lib": "^1.17.1", "rrule": "^2.8.1", "suncalc": "^1.9.0", "svelte-dnd-action": "^0.9.68", diff --git a/apps/mana/apps/web/src/lib/modules/invoices/pdf/renderer.ts b/apps/mana/apps/web/src/lib/modules/invoices/pdf/renderer.ts new file mode 100644 index 000000000..b3e1d30a1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/pdf/renderer.ts @@ -0,0 +1,549 @@ +/** + * PDF renderer — turns an invoice + sender settings into a PDF Uint8Array. + * + * Layout (top-down, single page for short invoices): + * + * ┌─────────────────────────────────────────┐ ← MARGIN.top + * │ Sender block Meta block │ + * │ │ + * │ Rechnung an: │ + * │ Client name + address │ + * │ │ + * │ RECHNUNG {number} │ + * │ {subject} │ + * │ │ + * │ Position | Menge | Preis | MwSt | Total │ + * │ ... │ + * │ │ + * │ Total: CHF x.y │ + * │ │ + * │ Notizen │ + * │ Zahlungsbedingungen │ + * │ │ + * │ ─────── (footer) ──────── │ + * ├─────────────────────────────────────────┤ ← BODY_MIN_Y + * │ [reserved for QR-Bill — M5] │ + * └─────────────────────────────────────────┘ + * + * Pagination kicks in only if the lines table overflows. Overflow pages + * do not reserve QR space (only the page with the total does). + * + * Standard 14 PDF fonts only (Helvetica / Helvetica-Bold) — keeps the + * output tiny (~7KB) and avoids shipping font bytes. Upgrade path: embed + * Inter/Roboto via fetch in M7 when we care about brand typography. + */ + +import { PDFDocument, StandardFonts, rgb, type PDFPage, type PDFFont } from 'pdf-lib'; +import type { Invoice, InvoiceSettings } from '../types'; +import { CURRENCIES } from '../constants'; +import { + A4, + MARGIN, + BODY_MIN_Y, + FONT_SIZE, + COLORS, + LINE_COLS, + SPACE, + LINE_HEIGHT, +} from './templates/default'; + +// ─── Small geometry helpers ──────────────────────────────── + +type Color = { r: number; g: number; b: number }; +const toRgb = (c: Color) => rgb(c.r, c.g, c.b); + +interface RenderContext { + doc: PDFDocument; + page: PDFPage; + regular: PDFFont; + bold: PDFFont; + /** Current Y cursor (drops as we render downward). */ + y: number; + /** Right edge of the usable page area. */ + rightX: number; + /** Left edge (same as MARGIN.left by default). */ + leftX: number; + /** Content width (page - left - right). */ + contentWidth: number; +} + +function newPage(ctx: RenderContext, withQrReserve: boolean): RenderContext { + const page = ctx.doc.addPage([A4.width, A4.height]); + return { + ...ctx, + page, + y: A4.height - MARGIN.top, + // Footer / QR reserve only applies to the first page — continuation + // pages can use the full body height. + // (The caller passes `withQrReserve=false` for continuation pages.) + ...(withQrReserve ? {} : {}), + }; +} + +function textWidth(font: PDFFont, text: string, size: number): number { + return font.widthOfTextAtSize(text, size); +} + +function drawText( + ctx: RenderContext, + text: string, + x: number, + y: number, + opts: { size?: number; font?: PDFFont; color?: Color } = {} +): void { + ctx.page.drawText(text, { + x, + y, + size: opts.size ?? FONT_SIZE.body, + font: opts.font ?? ctx.regular, + color: toRgb(opts.color ?? COLORS.text), + }); +} + +/** + * Word-wrap for a given max width. Returns the list of lines (caller draws). + * Tokenises on spaces + newlines; doesn't break mid-word (unusual for + * invoice text, acceptable trade-off for the MVP). + */ +function wrapLines(font: PDFFont, text: string, size: number, maxWidth: number): string[] { + const out: string[] = []; + for (const paragraph of text.split('\n')) { + const words = paragraph.split(/\s+/).filter(Boolean); + if (words.length === 0) { + out.push(''); + continue; + } + let current = ''; + for (const w of words) { + const probe = current ? `${current} ${w}` : w; + if (textWidth(font, probe, size) <= maxWidth) { + current = probe; + } else { + if (current) out.push(current); + current = w; + } + } + if (current) out.push(current); + } + return out; +} + +function drawRightAligned( + ctx: RenderContext, + text: string, + rightX: number, + y: number, + opts: { size?: number; font?: PDFFont; color?: Color } = {} +): void { + const size = opts.size ?? FONT_SIZE.body; + const font = opts.font ?? ctx.regular; + const w = textWidth(font, text, size); + drawText(ctx, text, rightX - w, y, opts); +} + +/** + * Draw horizontally-wrapped multi-line text starting at (x, y), dropping y + * as it goes. Returns the new y (below the block). + */ +function drawBlock( + ctx: RenderContext, + text: string, + x: number, + y: number, + opts: { + size?: number; + font?: PDFFont; + color?: Color; + maxWidth: number; + lineHeight?: number; + } +): number { + const size = opts.size ?? FONT_SIZE.body; + const font = opts.font ?? ctx.regular; + const lh = size * (opts.lineHeight ?? LINE_HEIGHT.normal); + const lines = wrapLines(font, text, size, opts.maxWidth); + let cursor = y; + for (const line of lines) { + drawText(ctx, line, x, cursor, { size, font, color: opts.color }); + cursor -= lh; + } + return cursor; +} + +function formatAmount(minor: number, currency: keyof typeof CURRENCIES): string { + const { symbol, minorUnit } = CURRENCIES[currency]; + return `${symbol} ${(minor / minorUnit).toFixed(2)}`; +} + +// ─── Section renderers ──────────────────────────────────── + +function renderHeader(ctx: RenderContext, invoice: Invoice, settings: InvoiceSettings): number { + // Left: sender + let leftY = ctx.y; + drawText(ctx, settings.senderName || '—', ctx.leftX, leftY, { + size: FONT_SIZE.brand, + font: ctx.bold, + }); + leftY -= FONT_SIZE.brand * LINE_HEIGHT.tight; + + const senderBody = [settings.senderAddress, settings.senderEmail, settings.senderIban] + .filter(Boolean) + .join('\n'); + leftY = drawBlock(ctx, senderBody, ctx.leftX, leftY, { + size: FONT_SIZE.small, + color: COLORS.muted, + maxWidth: ctx.contentWidth * 0.55, + lineHeight: LINE_HEIGHT.normal, + }); + + // Right: invoice meta (number, issue date, due date) + let rightY = ctx.y; + drawRightAligned(ctx, 'RECHNUNG', ctx.rightX, rightY, { + size: FONT_SIZE.small, + font: ctx.bold, + color: COLORS.muted, + }); + rightY -= FONT_SIZE.small * LINE_HEIGHT.tight; + drawRightAligned(ctx, invoice.number, ctx.rightX, rightY, { + size: FONT_SIZE.brand, + font: ctx.bold, + }); + rightY -= FONT_SIZE.brand * LINE_HEIGHT.tight + SPACE.sm; + + const metaRows: [string, string][] = [ + ['Datum', invoice.issueDate], + ['Fällig am', invoice.dueDate], + ]; + if (settings.senderVatNumber) metaRows.push(['MwSt-Nr.', settings.senderVatNumber]); + + for (const [label, value] of metaRows) { + drawRightAligned(ctx, `${label}: ${value}`, ctx.rightX, rightY, { + size: FONT_SIZE.small, + color: COLORS.muted, + }); + rightY -= FONT_SIZE.small * LINE_HEIGHT.normal; + } + + // Return the lower of the two Ys so the next section clears both columns. + return Math.min(leftY, rightY) - SPACE.lg; +} + +function renderRecipient(ctx: RenderContext, invoice: Invoice, y: number): number { + drawText(ctx, 'Rechnung an', ctx.leftX, y, { + size: FONT_SIZE.small, + font: ctx.bold, + color: COLORS.muted, + }); + y -= FONT_SIZE.small * LINE_HEIGHT.normal; + + drawText(ctx, invoice.clientSnapshot.name, ctx.leftX, y, { + size: FONT_SIZE.body, + font: ctx.bold, + }); + y -= FONT_SIZE.body * LINE_HEIGHT.tight; + + if (invoice.clientSnapshot.address) { + y = drawBlock(ctx, invoice.clientSnapshot.address, ctx.leftX, y, { + maxWidth: ctx.contentWidth * 0.55, + }); + } + if (invoice.clientSnapshot.vatNumber) { + drawText(ctx, `MwSt-Nr.: ${invoice.clientSnapshot.vatNumber}`, ctx.leftX, y, { + size: FONT_SIZE.small, + color: COLORS.muted, + }); + y -= FONT_SIZE.small * LINE_HEIGHT.normal; + } + return y - SPACE.lg; +} + +function renderSubject(ctx: RenderContext, invoice: Invoice, y: number): number { + if (!invoice.subject) return y; + drawText(ctx, invoice.subject, ctx.leftX, y, { + size: FONT_SIZE.h1, + font: ctx.bold, + }); + return y - FONT_SIZE.h1 * LINE_HEIGHT.normal - SPACE.md; +} + +/** + * Draws the invoice lines table. If it overflows, opens a continuation page + * and keeps going. Returns { ctx, y } — the ctx may have swapped pages so + * later sections must use the returned one. + */ +function renderLinesTable(ctx: RenderContext, invoice: Invoice): { ctx: RenderContext; y: number } { + let cur = ctx; + let y = cur.y; + const cw = cur.contentWidth; + + const colX = { + title: cur.leftX, + qty: cur.leftX + cw * LINE_COLS.title, + unit: cur.leftX + cw * (LINE_COLS.title + LINE_COLS.qty), + unitPrice: cur.leftX + cw * (LINE_COLS.title + LINE_COLS.qty + LINE_COLS.unit), + vat: cur.leftX + cw * (LINE_COLS.title + LINE_COLS.qty + LINE_COLS.unit + LINE_COLS.unitPrice), + total: + cur.leftX + + cw * (LINE_COLS.title + LINE_COLS.qty + LINE_COLS.unit + LINE_COLS.unitPrice + LINE_COLS.vat), + }; + const rightEdge = cur.rightX; + + const drawHeaderRow = () => { + drawText(cur, 'Position', colX.title, y, { + size: FONT_SIZE.tableHeader, + font: cur.bold, + color: COLORS.muted, + }); + drawText(cur, 'Menge', colX.qty, y, { + size: FONT_SIZE.tableHeader, + font: cur.bold, + color: COLORS.muted, + }); + drawText(cur, 'Einheit', colX.unit, y, { + size: FONT_SIZE.tableHeader, + font: cur.bold, + color: COLORS.muted, + }); + drawRightAligned(cur, 'Einzelpreis', colX.vat - SPACE.sm, y, { + size: FONT_SIZE.tableHeader, + font: cur.bold, + color: COLORS.muted, + }); + drawText(cur, 'MwSt.', colX.vat, y, { + size: FONT_SIZE.tableHeader, + font: cur.bold, + color: COLORS.muted, + }); + drawRightAligned(cur, 'Total', rightEdge, y, { + size: FONT_SIZE.tableHeader, + font: cur.bold, + color: COLORS.muted, + }); + y -= FONT_SIZE.tableHeader * LINE_HEIGHT.normal; + cur.page.drawLine({ + start: { x: cur.leftX, y: y + SPACE.xs }, + end: { x: rightEdge, y: y + SPACE.xs }, + thickness: 0.5, + color: toRgb(COLORS.border), + }); + y -= SPACE.sm; + }; + + drawHeaderRow(); + + for (const line of invoice.lines) { + // Wrap the title so long descriptions don't run into the qty column. + const titleMax = cw * LINE_COLS.title - SPACE.sm; + const titleLines = wrapLines(cur.regular, line.title || '—', FONT_SIZE.tableCell, titleMax); + const descriptionLines = line.description + ? wrapLines(cur.regular, line.description, FONT_SIZE.small, titleMax) + : []; + + const rowHeight = + Math.max( + titleLines.length * FONT_SIZE.tableCell * LINE_HEIGHT.tight + + descriptionLines.length * FONT_SIZE.small * LINE_HEIGHT.tight, + FONT_SIZE.tableCell * LINE_HEIGHT.normal + ) + SPACE.sm; + + // Paginate: if this row would cross into the reserved footer, open + // a new page and redraw the header there. + if (y - rowHeight < BODY_MIN_Y) { + cur = newPage(cur, false); + y = cur.y; + drawHeaderRow(); + } + + // Title + optional description, wrapped + let titleY = y; + for (const tl of titleLines) { + drawText(cur, tl, colX.title, titleY, { size: FONT_SIZE.tableCell }); + titleY -= FONT_SIZE.tableCell * LINE_HEIGHT.tight; + } + for (const dl of descriptionLines) { + drawText(cur, dl, colX.title, titleY, { + size: FONT_SIZE.small, + color: COLORS.muted, + }); + titleY -= FONT_SIZE.small * LINE_HEIGHT.tight; + } + + // Single-line fields (qty / unit / price / vat / total) + const qtyText = String(line.quantity); + drawText(cur, qtyText, colX.qty, y, { size: FONT_SIZE.tableCell }); + if (line.unit) { + drawText(cur, line.unit, colX.unit, y, { + size: FONT_SIZE.tableCell, + color: COLORS.muted, + }); + } + drawRightAligned(cur, formatAmount(line.unitPrice, invoice.currency), colX.vat - SPACE.sm, y, { + size: FONT_SIZE.tableCell, + }); + drawText(cur, `${line.vatRate}%`, colX.vat, y, { + size: FONT_SIZE.tableCell, + color: COLORS.muted, + }); + const rowTotal = line.quantity * line.unitPrice * (1 - (line.discount ?? 0) / 100); + drawRightAligned(cur, formatAmount(rowTotal, invoice.currency), rightEdge, y, { + size: FONT_SIZE.tableCell, + }); + + y -= rowHeight; + } + + // Separator before totals + cur.page.drawLine({ + start: { x: cur.leftX, y }, + end: { x: rightEdge, y }, + thickness: 0.5, + color: toRgb(COLORS.border), + }); + return { ctx: cur, y: y - SPACE.md }; +} + +function renderTotals(ctx: RenderContext, invoice: Invoice, y: number): number { + const labelX = ctx.rightX - 180; + const rows: [string, string, boolean][] = []; + + rows.push(['Netto', formatAmount(invoice.totals.net, invoice.currency), false]); + for (const b of invoice.totals.vatBreakdown) { + rows.push([`MwSt. ${b.rate}%`, formatAmount(b.tax, invoice.currency), false]); + } + rows.push(['Total', formatAmount(invoice.totals.gross, invoice.currency), true]); + + for (const [label, value, gross] of rows) { + const size = gross ? FONT_SIZE.h2 : FONT_SIZE.body; + const font = gross ? ctx.bold : ctx.regular; + drawText(ctx, label, labelX, y, { size, font }); + drawRightAligned(ctx, value, ctx.rightX, y, { size, font }); + y -= size * LINE_HEIGHT.normal; + if (gross) break; + } + + // Underline the gross row + ctx.page.drawLine({ + start: { x: labelX, y: y + SPACE.xs }, + end: { x: ctx.rightX, y: y + SPACE.xs }, + thickness: 1, + color: toRgb(COLORS.accent), + }); + return y - SPACE.lg; +} + +function renderNotesAndTerms(ctx: RenderContext, invoice: Invoice, y: number): number { + if (invoice.notes) { + drawText(ctx, 'Notizen', ctx.leftX, y, { + size: FONT_SIZE.small, + font: ctx.bold, + color: COLORS.muted, + }); + y -= FONT_SIZE.small * LINE_HEIGHT.normal; + y = drawBlock(ctx, invoice.notes, ctx.leftX, y, { + size: FONT_SIZE.body, + maxWidth: ctx.contentWidth, + }); + y -= SPACE.md; + } + if (invoice.terms) { + drawText(ctx, 'Zahlungsbedingungen', ctx.leftX, y, { + size: FONT_SIZE.small, + font: ctx.bold, + color: COLORS.muted, + }); + y -= FONT_SIZE.small * LINE_HEIGHT.normal; + y = drawBlock(ctx, invoice.terms, ctx.leftX, y, { + size: FONT_SIZE.body, + maxWidth: ctx.contentWidth, + }); + } + return y; +} + +function renderFooter(ctx: RenderContext, settings: InvoiceSettings): void { + if (!settings.footer) return; + const y = MARGIN.bottom + ctx.contentWidth * 0; // just above the margin + drawBlock(ctx, settings.footer, ctx.leftX, MARGIN.bottom + FONT_SIZE.small * 2.5, { + size: FONT_SIZE.small, + color: COLORS.muted, + maxWidth: ctx.contentWidth, + }); + // Hairline rule above footer + ctx.page.drawLine({ + start: { x: ctx.leftX, y: MARGIN.bottom + FONT_SIZE.small * 3 }, + end: { x: ctx.rightX, y: MARGIN.bottom + FONT_SIZE.small * 3 }, + thickness: 0.25, + color: toRgb(COLORS.border), + }); + void y; +} + +// ─── Public API ─────────────────────────────────────────── + +/** + * Render an invoice to PDF bytes. Call-site is responsible for wrapping the + * output into a Blob, iframe URL, or File attachment. + */ +export async function renderInvoicePdf( + invoice: Invoice, + settings: InvoiceSettings +): Promise { + const doc = await PDFDocument.create(); + doc.setTitle(`Rechnung ${invoice.number}`); + doc.setAuthor(settings.senderName || 'Mana'); + doc.setCreator('Mana — Rechnungen'); + doc.setProducer('pdf-lib'); + doc.setCreationDate(new Date()); + + const [regular, bold] = await Promise.all([ + doc.embedFont(StandardFonts.Helvetica), + doc.embedFont(StandardFonts.HelveticaBold), + ]); + + const page = doc.addPage([A4.width, A4.height]); + const leftX = MARGIN.left; + const rightX = A4.width - MARGIN.right; + + let ctx: RenderContext = { + doc, + page, + regular, + bold, + y: A4.height - MARGIN.top, + leftX, + rightX, + contentWidth: rightX - leftX, + }; + + ctx.y = renderHeader(ctx, invoice, settings); + ctx.y = renderRecipient(ctx, invoice, ctx.y); + ctx.y = renderSubject(ctx, invoice, ctx.y); + const linesResult = renderLinesTable(ctx, invoice); + ctx = linesResult.ctx; + ctx.y = linesResult.y; + ctx.y = renderTotals(ctx, invoice, ctx.y); + ctx.y = renderNotesAndTerms(ctx, invoice, ctx.y); + + // Footer only draws on the *first* page — it contains the sender's + // legal line and a separator. Later we'll add page numbers for the + // multi-page case. + const firstPage = doc.getPage(0); + const firstCtx: RenderContext = { ...ctx, page: firstPage }; + renderFooter(firstCtx, settings); + + return await doc.save(); +} + +/** + * Convenience: render + wrap into a Blob, ready for object URL / download / + * email attachment. The MIME type is `application/pdf`. + */ +export async function renderInvoicePdfBlob( + invoice: Invoice, + settings: InvoiceSettings +): Promise { + const bytes = await renderInvoicePdf(invoice, settings); + // `.slice(0)` copies into a fresh ArrayBuffer-backed Uint8Array so the + // Blob constructor types match (SharedArrayBuffer is not a BlobPart). + return new Blob([bytes.slice(0) as BlobPart], { type: 'application/pdf' }); +} diff --git a/apps/mana/apps/web/src/lib/modules/invoices/pdf/templates/default.ts b/apps/mana/apps/web/src/lib/modules/invoices/pdf/templates/default.ts new file mode 100644 index 000000000..b894cd935 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/pdf/templates/default.ts @@ -0,0 +1,90 @@ +/** + * Default PDF layout constants — A4, margins chosen so a Swiss QR-Bill + * (M5) will fit in the bottom ~105mm without further layout changes. + * + * pdf-lib coordinates: origin is BOTTOM-LEFT, Y increases upward. All + * "top-of-page" anchors therefore compute `pageHeight - offset`. + * Units are PDF points (1pt = 1/72 inch ≈ 0.353mm). + */ + +// ─── Page & units ───────────────────────────────────────── + +/** A4 in points (210 × 297 mm). */ +export const A4 = { width: 595.28, height: 841.89 } as const; + +export const MM_TO_PT = 72 / 25.4; + +export function mm(millimetres: number): number { + return millimetres * MM_TO_PT; +} + +// ─── Margins ───────────────────────────────────────────── + +export const MARGIN = { + top: mm(20), + right: mm(20), + bottom: mm(20), + left: mm(20), +} as const; + +/** + * Reserved vertical space at the bottom of the invoice page for the QR-Bill + * (M5). Covers the whole Zahlteil + Empfangsschein strip including the + * perforation indicator. The renderer must never draw into this region. + * If the invoice body overflows, it paginates to a continuation page. + */ +export const QR_BILL_RESERVED = mm(105); + +/** Minimum Y below which the first-page body must not descend. */ +export const BODY_MIN_Y = MARGIN.bottom + QR_BILL_RESERVED; + +// ─── Typography ─────────────────────────────────────────── + +export const FONT_SIZE = { + brand: 14, + h1: 18, + h2: 11, + body: 10, + small: 8.5, + tableHeader: 9, + tableCell: 9.5, +} as const; + +export const LINE_HEIGHT = { + tight: 1.25, + normal: 1.4, + loose: 1.6, +} as const; + +// ─── Colors (RGB 0..1 for pdf-lib) ──────────────────────── + +export const COLORS = { + text: { r: 0.06, g: 0.09, b: 0.16 }, // slate-900 + muted: { r: 0.39, g: 0.45, b: 0.55 }, // slate-500 + border: { r: 0.89, g: 0.91, b: 0.94 }, // slate-200 + accent: { r: 0.02, g: 0.59, b: 0.41 }, // emerald-600 — matches app icon + danger: { r: 0.73, g: 0.11, b: 0.11 }, + surfaceMuted: { r: 0.97, g: 0.98, b: 0.99 }, // slate-50 +} as const; + +// ─── Lines table geometry ───────────────────────────────── +// Column widths are fractions of the content width; sum must equal 1. + +export const LINE_COLS = { + title: 0.46, + qty: 0.09, + unit: 0.08, + unitPrice: 0.14, + vat: 0.08, + total: 0.15, +} as const; + +// ─── Spacing scale ──────────────────────────────────────── + +export const SPACE = { + xs: mm(1), + sm: mm(2), + md: mm(4), + lg: mm(8), + xl: mm(12), +} as const; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte index 93945931f..dd883a048 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte @@ -6,8 +6,10 @@ import { goto } from '$app/navigation'; import StatusBadge from '../components/StatusBadge.svelte'; import { invoicesStore } from '../stores/invoices.svelte'; + import { invoiceSettingsStore } from '../stores/settings.svelte'; + import { renderInvoicePdfBlob } from '../pdf/renderer'; import { formatAmount } from '../queries'; - import type { Invoice } from '../types'; + import type { Invoice, InvoiceSettings } from '../types'; import { STATUS_LABELS } from '../constants'; interface Props { @@ -19,6 +21,62 @@ let actionError = $state(null); let busy = $state(false); + // ─── PDF preview ───────────────────────────────────────── + // Render lazily on mount, whenever the invoice content changes, or after + // a mutation (status transitions don't re-render the PDF body but do + // change the output — e.g. paid watermark in M4+). Revoke the previous + // blob URL before creating a new one so we don't leak memory. + let pdfUrl = $state(null); + let pdfError = $state(null); + let renderingPdf = $state(false); + + async function renderPdf() { + renderingPdf = true; + pdfError = null; + try { + const settings: InvoiceSettings = await invoiceSettingsStore.get(); + const blob = await renderInvoicePdfBlob(invoice, settings); + if (pdfUrl) URL.revokeObjectURL(pdfUrl); + pdfUrl = URL.createObjectURL(blob); + } catch (e) { + pdfError = e instanceof Error ? e.message : 'PDF-Rendering fehlgeschlagen'; + } finally { + renderingPdf = false; + } + } + + // Re-render when invoice id or updatedAt changes (mutations bump updatedAt). + $effect(() => { + void invoice.id; + void invoice.updatedAt; + renderPdf(); + }); + + // Blob URLs leak memory until revoked. Clean up on unmount. + $effect(() => { + return () => { + if (pdfUrl) URL.revokeObjectURL(pdfUrl); + }; + }); + + async function downloadPdf() { + try { + const settings: InvoiceSettings = await invoiceSettingsStore.get(); + const blob = await renderInvoicePdfBlob(invoice, settings); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Rechnung-${invoice.number}.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + // Revoke after the browser has started the download. + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + pdfError = e instanceof Error ? e.message : 'Download fehlgeschlagen'; + } + } + async function run(label: string, fn: () => Promise) { actionError = null; busy = true; @@ -91,6 +149,7 @@ Als bezahlt markieren {/if} + {#if invoice.status !== 'paid' && invoice.status !== 'void'} @@ -104,80 +163,103 @@
{actionError}
{/if} -
-

Empfänger

-
-
{invoice.clientSnapshot.name}
- {#if invoice.clientSnapshot.address} -
{invoice.clientSnapshot.address}
- {/if} - {#if invoice.clientSnapshot.email} -
{invoice.clientSnapshot.email}
- {/if} - {#if invoice.clientSnapshot.vatNumber} -
MwSt-Nr.: {invoice.clientSnapshot.vatNumber}
+
+
+

Vorschau

+ {#if renderingPdf} + Rendert … {/if}
+ {#if pdfError} +
PDF-Fehler: {pdfError}
+ {:else if pdfUrl} + + {/if}
-
-

Positionen

- - - - - - - - - - - - {#each invoice.lines as line (line.id)} +
+ Strukturierte Daten anzeigen + +
+

Empfänger

+
+
{invoice.clientSnapshot.name}
+ {#if invoice.clientSnapshot.address} +
{invoice.clientSnapshot.address}
+ {/if} + {#if invoice.clientSnapshot.email} +
{invoice.clientSnapshot.email}
+ {/if} + {#if invoice.clientSnapshot.vatNumber} +
MwSt-Nr.: {invoice.clientSnapshot.vatNumber}
+ {/if} +
+
+ +
+

Positionen

+
PositionMengeEinzelpreisMwSt.Netto
+ - - - - - + + + + + + + + {#each invoice.lines as line (line.id)} + + + + + + + + {/each} + +
-
{line.title}
- {#if line.description}
{line.description}
{/if} -
{line.quantity}{line.unit ? ` ${line.unit}` : ''}{formatAmount(line.unitPrice, invoice.currency)}{line.vatRate}% - {formatAmount(line.quantity * line.unitPrice, invoice.currency)} - PositionMengeEinzelpreisMwSt.Netto
+
{line.title}
+ {#if line.description}
{line.description}
{/if} +
{line.quantity}{line.unit ? ` ${line.unit}` : ''}{formatAmount(line.unitPrice, invoice.currency)}{line.vatRate}% + {formatAmount(line.quantity * line.unitPrice, invoice.currency)} +
+
+ +
+

Summe

+
+
Netto
+
{formatAmount(invoice.totals.net, invoice.currency)}
+ {#each invoice.totals.vatBreakdown as b (b.rate)} +
MwSt. {b.rate}%
+
{formatAmount(b.tax, invoice.currency)}
{/each} - - -
- -
-

Summe

-
-
Netto
-
{formatAmount(invoice.totals.net, invoice.currency)}
- {#each invoice.totals.vatBreakdown as b (b.rate)} -
MwSt. {b.rate}%
-
{formatAmount(b.tax, invoice.currency)}
- {/each} -
Total
-
{formatAmount(invoice.totals.gross, invoice.currency)}
-
-
- - {#if invoice.notes} -
-

Notizen

-

{invoice.notes}

+
Total
+
{formatAmount(invoice.totals.gross, invoice.currency)}
+
- {/if} - {#if invoice.terms} -
-

Zahlungsbedingungen

-

{invoice.terms}

-
- {/if} + {#if invoice.notes} +
+

Notizen

+

{invoice.notes}

+
+ {/if} + + {#if invoice.terms} +
+

Zahlungsbedingungen

+

{invoice.terms}

+
+ {/if} +