From 58c78bbbbdc4424f349d11fe79c7d7f269fa9be8 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Wed, 18 Mar 2026 16:29:10 +0100 Subject: [PATCH] =?UTF-8?q?Rechnungstemplate:=20Kontoverbindung=20aus=20St?= =?UTF-8?q?ammdaten,=20verbesserte=20Tabellenformatierung,=20einspaltige?= =?UTF-8?q?=20Palette,=20Delete-Taste=20zum=20L=C3=B6schen,=20UI-Optimieru?= =?UTF-8?q?ngen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/InvoiceTemplatePage.tsx | 710 ++++++++++++++++----- frontend/src/styles/global.css | 67 +- 2 files changed, 632 insertions(+), 145 deletions(-) diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx index 4c72b7d..229e8ee 100644 --- a/frontend/src/pages/InvoiceTemplatePage.tsx +++ b/frontend/src/pages/InvoiceTemplatePage.tsx @@ -1,4 +1,5 @@ import { + useCallback, useEffect, useState, useRef, @@ -30,6 +31,8 @@ const MIN_LINE_THICKNESS = 1; const MIN_LINE_LENGTH = 40; const MIN_FONT_SIZE = 12; const MAX_FONT_SIZE = 44; +const MUH_INVOICE_DEFAULT_HEIGHT = 160; +const MUH_INVOICE_MIN_HEIGHT = 110; const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([ "invoice-number", "invoice-date", @@ -52,6 +55,7 @@ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([ "issuer-postal-code", "issuer-city", "issuer-contact", + "invoice-items-muh", ]); type ElementKind = "text" | "line" | "image"; @@ -60,6 +64,14 @@ type TextAlign = "left" | "center" | "right"; type FontWeight = 400 | 500 | 600 | 700; type PaletteCategory = string; +function isMuhInvoicePaletteId(paletteId?: string) { + return paletteId === "invoice-items-muh"; +} + +function supportsCustomElementHeight(kind: ElementKind, paletteId?: string) { + return kind !== "text" || isMuhInvoicePaletteId(paletteId); +} + interface PaletteItem { id: string; category: PaletteCategory; @@ -115,6 +127,13 @@ interface UploadedImageAsset { width: number; } +interface MuhInvoiceRow { + amount: string; + label: string; + labelPlacement?: "default" | "amount-side"; + strong?: boolean; +} + interface InvoiceTemplateResponse { elements: unknown; stored: boolean; @@ -297,8 +316,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ { id: "issuer-name", category: "issuer-data", - label: "Aussteller-Name", - description: "Name des Rechnungsausstellers", + label: "Rechnungssteller-Name", + description: "Name des Rechnungsstellers", width: 280, fontSize: 16, fontWeight: 700, @@ -308,8 +327,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ { id: "issuer-street", category: "issuer-data", - label: "Aussteller-Straße", - description: "Straße des Rechnungsausstellers", + label: "Rechnungssteller-Straße", + description: "Straße des Rechnungsstellers", width: 200, fontSize: 14, fontWeight: 400, @@ -319,8 +338,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ { id: "issuer-house-number", category: "issuer-data", - label: "Aussteller-Hausnummer", - description: "Hausnummer des Rechnungsausstellers", + label: "Rechnungssteller-Hausnummer", + description: "Hausnummer des Rechnungsstellers", width: 80, fontSize: 14, fontWeight: 400, @@ -330,8 +349,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ { id: "issuer-postal-code", category: "issuer-data", - label: "Aussteller-PLZ", - description: "Postleitzahl des Rechnungsausstellers", + label: "Rechnungssteller-PLZ", + description: "Postleitzahl des Rechnungsstellers", width: 80, fontSize: 14, fontWeight: 400, @@ -341,8 +360,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ { id: "issuer-city", category: "issuer-data", - label: "Aussteller-Ort", - description: "Ort des Rechnungsausstellers", + label: "Rechnungssteller-Ort", + description: "Ort des Rechnungsstellers", width: 200, fontSize: 14, fontWeight: 400, @@ -352,8 +371,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ { id: "issuer-contact", category: "issuer-data", - label: "Aussteller-Kontakt", - description: "Kontaktdaten des Rechnungsausstellers", + label: "Rechnungssteller-Kontakt", + description: "Kontaktdaten des Rechnungsstellers", width: 280, fontSize: 14, fontWeight: 400, @@ -366,12 +385,11 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ category: "invoice-items", label: "Rechnungspositionen MUH", description: "Monatliche Systemgebühr mit Preis aus Preistabelle", - width: 500, + width: 646, fontSize: 14, fontWeight: 400, textAlign: "left", - defaultContent: () => - "Monatliche Systemgebühr MUH 0,00 €\nzzgl. Umsatzsteuer (19%) 0,00 €\nGesamtsumme 0,00 €", + defaultContent: () => createMuhInvoiceContent(null, 646), }, { id: "payment-terms", @@ -393,8 +411,22 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ fontSize: 14, fontWeight: 400, textAlign: "left", - defaultContent: () => - "Bankverbindung:\nIBAN: DE12 3456 7890 1234 5678 90\nBIC: ABCDEFGHXXX\nBank: Musterbank", + defaultContent: (user) => { + const lines = ["Bankverbindung:"]; + if (user?.accountHolder) { + lines.push(`Kontoinhaber: ${user.accountHolder}`); + } + if (user?.iban) { + lines.push(`IBAN: ${user.iban}`); + } + if (user?.bic) { + lines.push(`BIC: ${user.bic}`); + } + if (user?.bankName) { + lines.push(`Bank: ${user.bankName}`); + } + return lines.join("\n") || "Bankverbindung:"; + }, }, { id: "free-text", @@ -439,7 +471,7 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ const INVOICE_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [ { category: "invoice-header", title: "Rechnungskopf" }, { category: "customer-data", title: "Kundendaten" }, - { category: "issuer-data", title: "Aussteller" }, + { category: "issuer-data", title: "Rechnungssteller" }, { category: "invoice-items", title: "Positionen" }, { category: "invoice-footer", title: "Fußbereich" }, { category: "free-elements", title: "Freie Elemente" }, @@ -558,6 +590,7 @@ function getDefaultElementHeight( kind: ElementKind, fontSize: number, lineOrientation?: LineOrientation, + paletteId?: string, ) { if (kind === "line") { return lineOrientation === "vertical" ? 180 : 3; @@ -565,6 +598,9 @@ function getDefaultElementHeight( if (kind === "image") { return 120; } + if (isMuhInvoicePaletteId(paletteId)) { + return Math.max(MUH_INVOICE_DEFAULT_HEIGHT, fontSize * 10); + } return Math.max(fontSize + 16, 28); } @@ -582,6 +618,7 @@ function getMinimumHeightForElement( kind: ElementKind, fontSize: number, lineOrientation?: LineOrientation, + paletteId?: string, ) { if (kind === "line") { return lineOrientation === "vertical" ? MIN_LINE_LENGTH : MIN_LINE_THICKNESS; @@ -589,20 +626,57 @@ function getMinimumHeightForElement( if (kind === "image") { return MIN_MEDIA_HEIGHT; } - return getDefaultElementHeight(kind, fontSize, lineOrientation); + if (isMuhInvoicePaletteId(paletteId)) { + return Math.max(MUH_INVOICE_MIN_HEIGHT, fontSize * 7); + } + return getDefaultElementHeight(kind, fontSize, lineOrientation, paletteId); } function getElementHeight( - element: Pick, + element: Pick, ) { return Math.max( 1, Math.round( - element.height ?? getDefaultElementHeight(element.kind, element.fontSize, element.lineOrientation), + element.height ?? + getDefaultElementHeight( + element.kind, + element.fontSize, + element.lineOrientation, + element.paletteId, + ), ), ); } +function getCanvasLineStyle( + element: Pick, + canvasScale: number, +) { + const safeScale = Math.max(canvasScale, 0.001); + const minimumCanvasSize = 1 / safeScale; + const elementHeight = getElementHeight(element); + const isVertical = (element.lineOrientation ?? "horizontal") === "vertical"; + + if (isVertical) { + const renderedWidth = Math.max(element.width, minimumCanvasSize); + return { + height: `${elementHeight}px`, + left: `${(element.width - renderedWidth) / 2}px`, + top: "0px", + width: `${renderedWidth}px`, + }; + } + + const renderedHeight = Math.max(elementHeight, minimumCanvasSize); + return { + height: `${renderedHeight}px`, + left: "0px", + top: `${(elementHeight - renderedHeight) / 2}px`, + width: `${element.width}px`, + }; +} + function fitContainedSize( containerWidth: number, containerHeight: number, @@ -686,13 +760,162 @@ endstream`, pixelWidth: Math.max(1, Math.round(element.imageNaturalWidth ?? element.width)), }); - return resources; + return resources; }, []); } +function pushPdfRect( + commands: string[], + x: number, + top: number, + width: number, + height: number, +) { + if (width <= 0 || height <= 0) { + return; + } + + const pdfX = x * PDF_SCALE; + const pdfY = PDF_PAGE_HEIGHT - (top + height) * PDF_SCALE; + const pdfWidth = width * PDF_SCALE; + const pdfHeight = height * PDF_SCALE; + + commands.push( + `${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} ${formatPdfNumber(pdfWidth)} ${formatPdfNumber(pdfHeight)} re f`, + ); +} + +function pushPdfText( + commands: string[], + text: string, + x: number, + baselineY: number, + fontSize: number, + fontWeight: FontWeight, +) { + if (!text) { + return; + } + + const fontName = fontWeight >= 600 ? "/F2" : "/F1"; + const pdfX = x * PDF_SCALE; + const pdfY = PDF_PAGE_HEIGHT - baselineY * PDF_SCALE; + + commands.push("BT"); + commands.push(`${fontName} ${formatPdfNumber(fontSize * PDF_SCALE)} Tf`); + commands.push(`1 0 0 1 ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} Tm`); + commands.push(`<${encodePdfText(text)}> Tj`); + commands.push("ET"); +} + +function appendMuhInvoicePdfCommands( + commands: string[], + element: TemplateElement, + monthlyPrice: number | null, +) { + const rows = getMuhInvoiceRows(monthlyPrice); + const strongWeight = getMuhInvoiceStrongWeight(element.fontWeight); + const elementHeight = getElementHeight(element); + const innerHeight = Math.max(0, elementHeight - PDF_TEXT_PADDING_Y * 2); + const innerWidth = Math.max(80, element.width - PDF_TEXT_PADDING_X * 2); + const labelX = element.x + PDF_TEXT_PADDING_X; + const amountRightX = element.x + element.width - PDF_TEXT_PADDING_X; + const gapWidth = Math.max(16, element.fontSize); + const lineHeight = element.fontSize * PDF_LINE_HEIGHT; + const rowGap = Math.max(8, element.fontSize * 0.7); + const separatorThickness = Math.max(2, element.fontSize * 0.1); + + const amountColumnWidth = rows.reduce((maximum, row) => { + const rowWeight = row.strong ? strongWeight : element.fontWeight; + return Math.max(maximum, measureTextWidth(row.amount, element.fontSize, rowWeight)); + }, 0); + const amountLeftX = amountRightX - amountColumnWidth; + const labelColumnWidth = Math.max(60, innerWidth - amountColumnWidth - gapWidth); + const preparedRows = rows.map((row) => { + const rowWeight = row.strong ? strongWeight : element.fontWeight; + const amountSideLabelWidth = Math.max( + 60, + Math.min(240, innerWidth - amountColumnWidth - gapWidth), + ); + const labelMaxWidth = + row.labelPlacement === "amount-side" ? amountSideLabelWidth : labelColumnWidth; + const labelLines = wrapTextLines(row.label, labelMaxWidth, element.fontSize, rowWeight); + const measuredLabelWidth = labelLines.reduce((maximum, line) => { + return Math.max(maximum, measureTextWidth(line, element.fontSize, rowWeight)); + }, 0); + const labelDrawX = + row.labelPlacement === "amount-side" + ? Math.max(labelX, amountLeftX - gapWidth - measuredLabelWidth) + : labelX; + const amountWidth = measureTextWidth(row.amount, element.fontSize, rowWeight); + + return { + amountWidth, + labelDrawX, + labelLines, + row, + rowHeight: Math.max(lineHeight, labelLines.length * lineHeight), + rowWeight, + }; + }); + + const [headerRow, primaryRow, secondaryRow, totalRow] = preparedRows; + const fixedHeight = + headerRow.rowHeight + + separatorThickness + + primaryRow.rowHeight + + secondaryRow.rowHeight + + separatorThickness + + totalRow.rowHeight + + rowGap * 6; + const spacerHeight = Math.max(0, innerHeight - fixedHeight); + + function drawRow( + preparedRow: (typeof preparedRows)[number], + top: number, + ) { + const baselineY = top + element.fontSize; + + preparedRow.labelLines.forEach((line, lineIndex) => { + pushPdfText( + commands, + line, + preparedRow.labelDrawX, + baselineY + lineIndex * lineHeight, + element.fontSize, + preparedRow.rowWeight, + ); + }); + + pushPdfText( + commands, + preparedRow.row.amount, + amountRightX - preparedRow.amountWidth, + baselineY, + element.fontSize, + preparedRow.rowWeight, + ); + } + + let cursorTop = element.y + PDF_TEXT_PADDING_Y; + + drawRow(headerRow, cursorTop); + cursorTop += headerRow.rowHeight + rowGap; + pushPdfRect(commands, labelX, cursorTop, innerWidth, separatorThickness); + cursorTop += separatorThickness + rowGap; + drawRow(primaryRow, cursorTop); + cursorTop += primaryRow.rowHeight + rowGap + spacerHeight + rowGap; + drawRow(secondaryRow, cursorTop); + cursorTop += secondaryRow.rowHeight + rowGap; + pushPdfRect(commands, labelX, cursorTop, innerWidth, separatorThickness); + cursorTop += separatorThickness + rowGap; + drawRow(totalRow, cursorTop); +} + function createPdfContentStream( elements: TemplateElement[], imageResources: Map, + monthlyPrice: number | null, ) { const commands = ["0 g"]; @@ -738,6 +961,11 @@ function createPdfContentStream( continue; } + if (element.paletteId === "invoice-items-muh") { + appendMuhInvoicePdfCommands(commands, element, monthlyPrice); + continue; + } + const fontName = element.fontWeight >= 600 ? "/F2" : "/F1"; const fontSize = element.fontSize * PDF_SCALE; const lineHeight = element.fontSize * PDF_LINE_HEIGHT; @@ -778,10 +1006,10 @@ function createPdfContentStream( return commands.join("\n"); } -function createPdfBlob(elements: TemplateElement[]) { +function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) { const imageResources = createPdfImageResources(elements); const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource])); - const contentStream = createPdfContentStream(elements, imageResourceMap); + const contentStream = createPdfContentStream(elements, imageResourceMap, monthlyPrice); const imageObjectNumberStart = 6; const contentObjectNumber = imageObjectNumberStart + imageResources.length; const xObjectResources = imageResources.length @@ -847,30 +1075,48 @@ function normalizeElement(element: TemplateElement): TemplateElement { const kind = normalizeElementKind(element.kind); const lineOrientation = kind === "line" ? normalizeLineOrientation(element.lineOrientation) : undefined; const fontSize = clamp(Math.round(element.fontSize), MIN_FONT_SIZE, MAX_FONT_SIZE); + const keepHeight = supportsCustomElementHeight(kind, element.paletteId); let width = Math.round(element.width); - let height = getElementHeight({ - fontSize, - height: element.height, - kind, - lineOrientation, - }); + let height = Math.max( + 1, + Math.round( + keepHeight + ? element.height ?? + getDefaultElementHeight(kind, fontSize, lineOrientation, element.paletteId) + : getDefaultElementHeight(kind, fontSize, lineOrientation, element.paletteId), + ), + ); if (kind === "line") { width = clamp(width, getMinimumWidthForElement(kind, lineOrientation), CANVAS_WIDTH); - height = clamp(height, getMinimumHeightForElement(kind, fontSize, lineOrientation), CANVAS_HEIGHT); + height = clamp( + height, + getMinimumHeightForElement(kind, fontSize, lineOrientation, element.paletteId), + CANVAS_HEIGHT, + ); } else if (kind === "image") { width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); - height = clamp(height, getMinimumHeightForElement(kind, fontSize), CANVAS_HEIGHT - 32); + height = clamp( + height, + getMinimumHeightForElement(kind, fontSize, undefined, element.paletteId), + CANVAS_HEIGHT - 32, + ); } else { width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); - height = getDefaultElementHeight(kind, fontSize, lineOrientation); + height = keepHeight + ? clamp( + height, + getMinimumHeightForElement(kind, fontSize, lineOrientation, element.paletteId), + CANVAS_HEIGHT, + ) + : getDefaultElementHeight(kind, fontSize, lineOrientation, element.paletteId); } const x = clamp(snapToGrid(element.x), 0, CANVAS_WIDTH - width); const y = clamp( snapToGrid(element.y), 0, - kind === "text" ? CANVAS_HEIGHT - 40 : CANVAS_HEIGHT - height, + kind === "text" && !keepHeight ? CANVAS_HEIGHT - 40 : CANVAS_HEIGHT - height, ); const imageNaturalWidth = kind === "image" && typeof element.imageNaturalWidth === "number" && element.imageNaturalWidth > 0 @@ -885,7 +1131,7 @@ function normalizeElement(element: TemplateElement): TemplateElement { ...element, kind, width, - height: kind === "text" ? undefined : height, + height: kind === "text" && !keepHeight ? undefined : height, fontSize, x, y, @@ -917,6 +1163,9 @@ function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null if (paletteItem.kind === "line") { return "Linie"; } + if (paletteItem.id === "invoice-items-muh") { + return "Rechnungspositionen"; + } return paletteItem.defaultContent(user); } @@ -957,16 +1206,77 @@ function formatPrice(price: number | null): string { return price.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } -function createMuhInvoiceContent(monthlyPrice: number | null): string { +function getMuhInvoiceStrongWeight(fontWeight: FontWeight): FontWeight { + return fontWeight >= 600 ? fontWeight : 600; +} + +function getMuhInvoiceRows(monthlyPrice: number | null): MuhInvoiceRow[] { const netto = monthlyPrice || 0; const ust = netto * 0.19; const brutto = netto + ust; - - const nettoStr = formatPrice(netto).padStart(10, " "); - const ustStr = formatPrice(ust).padStart(10, " "); - const bruttoStr = formatPrice(brutto).padStart(10, " "); - - return `Monatliche Systemgebühr MUH ${nettoStr} €\nzzgl. Umsatzsteuer (19%) ${ustStr} €\nGesamtsumme ${bruttoStr} €`; + + return [ + { label: "Bezeichnung", amount: "Betrag", strong: true }, + { label: "Monatliche Systemgebühr MUH", amount: `${formatPrice(netto)} €` }, + { + label: "zzgl. Umsatzsteuer (19%)", + amount: `${formatPrice(ust)} €`, + labelPlacement: "amount-side", + }, + { label: "Gesamtsumme", amount: `${formatPrice(brutto)} €`, strong: true }, + ]; +} + +function createMuhInvoiceContent(monthlyPrice: number | null, _elementWidth: number = 646): string { + return getMuhInvoiceRows(monthlyPrice) + .map((row) => `${row.label} | ${row.amount}`) + .join("\n"); +} + +function MuhInvoiceCanvasPreview({ + element, + monthlyPrice, +}: { + element: Pick; + monthlyPrice: number | null; +}) { + const rows = getMuhInvoiceRows(monthlyPrice); + const strongWeight = getMuhInvoiceStrongWeight(element.fontWeight); + + const renderRow = (row: MuhInvoiceRow, className?: string) => ( + + + {row.label} + + + {row.amount} + + + ); + + return ( + + {renderRow(rows[0], "invoice-template__muh-items-row--header")} +