From eb0f921464a10e2205ea0466d1133dcdbbf95c6f Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 16 Mar 2026 20:30:45 +0100 Subject: [PATCH] feat: Add invoice management menu and template editor for admin - Add 'Rechnung' menu with sub-items 'Verwalten' and 'Template' in admin sidebar - Create InvoiceTemplatePage with drag-and-drop editor for invoice templates - Includes invoice-specific elements (header, customer data, issuer info, invoice items, totals, payment terms, bank details) - Supports PDF preview and download - API integration for saving/loading templates (/admin/invoice-template) - Create InvoiceManagementPage as placeholder for invoice overview - Add routes for /admin/rechnung/verwalten and /admin/rechnung/template - Update page titles in AppShell for new routes --- frontend/src/App.tsx | 4 + frontend/src/layout/AppShell.tsx | 14 + frontend/src/pages/InvoiceManagementPage.tsx | 192 ++ frontend/src/pages/InvoiceTemplatePage.tsx | 2198 ++++++++++++++++++ 4 files changed, 2408 insertions(+) create mode 100644 frontend/src/pages/InvoiceManagementPage.tsx create mode 100644 frontend/src/pages/InvoiceTemplatePage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13b66b3..0ffd95c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,8 @@ import SearchFarmerPage from "./pages/SearchFarmerPage"; import SearchCalendarPage from "./pages/SearchCalendarPage"; import UserManagementPage from "./pages/UserManagementPage"; import ReportTemplatePage from "./pages/ReportTemplatePage"; +import InvoiceTemplatePage from "./pages/InvoiceTemplatePage"; +import InvoiceManagementPage from "./pages/InvoiceManagementPage"; function ProtectedRoutes() { const { user, ready } = useSession(); @@ -45,6 +47,8 @@ function ProtectedRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/layout/AppShell.tsx b/frontend/src/layout/AppShell.tsx index c3e613c..16b9129 100644 --- a/frontend/src/layout/AppShell.tsx +++ b/frontend/src/layout/AppShell.tsx @@ -7,6 +7,8 @@ const PAGE_TITLES: Record = { "/samples/new": "Neuanlage einer Probe", "/portal": "MUH-Portal", "/report-template": "Bericht", + "/admin/rechnung/verwalten": "Rechnungsverwaltung", + "/admin/rechnung/template": "Rechnungsvorlage", }; function resolvePageTitle(pathname: string, isAdmin: boolean) { @@ -58,6 +60,18 @@ export default function AppShell() { + +
+
Rechnung
+
+ `nav-sublink ${isActive ? "is-active" : ""}`}> + Verwalten + + `nav-sublink ${isActive ? "is-active" : ""}`}> + Template + +
+
) : ( <> diff --git a/frontend/src/pages/InvoiceManagementPage.tsx b/frontend/src/pages/InvoiceManagementPage.tsx new file mode 100644 index 0000000..2233363 --- /dev/null +++ b/frontend/src/pages/InvoiceManagementPage.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import { apiGet } from "../lib/api"; + +interface InvoiceSummary { + id: string; + invoiceNumber: string; + customerName: string; + invoiceDate: string; + dueDate: string; + totalAmount: number; + status: "DRAFT" | "SENT" | "PAID" | "OVERDUE" | "CANCELLED"; +} + +interface InvoiceOverview { + invoices: InvoiceSummary[]; +} + +const STATUS_LABELS: Record = { + DRAFT: "Entwurf", + SENT: "Versendet", + PAID: "Bezahlt", + OVERDUE: "Überfällig", + CANCELLED: "Storniert", +}; + +const STATUS_CLASSES: Record = { + DRAFT: "status-badge--draft", + SENT: "status-badge--sent", + PAID: "status-badge--success", + OVERDUE: "status-badge--error", + CANCELLED: "status-badge--neutral", +}; + +export default function InvoiceManagementPage() { + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + apiGet("/admin/invoices") + .then((response) => { + if (!cancelled) { + setInvoices(response.invoices); + } + }) + .catch((err) => { + if (!cancelled) { + // Für den Moment zeigen wir einfach eine leere Liste an + // bis das Backend implementiert ist + setInvoices([]); + setError(null); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat("de-DE", { + style: "currency", + currency: "EUR", + }).format(amount); + }; + + const formatDate = (dateString: string) => { + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + }).format(new Date(dateString)); + }; + + return ( +
+
+
+

Rechnungsverwaltung

+

Übersicht aller Rechnungen

+

+ Hier können Sie alle erstellten Rechnungen einsehen, deren Status verfolgen + und geplante Rechnungen verwalten. +

+
+ + {error ?
{error}
: null} +
+ +
+
+
+

Rechnungen

+

Rechnungsliste

+
+
+ +
+
+ + {loading ? ( +
Rechnungen werden geladen...
+ ) : invoices.length === 0 ? ( +
+

Noch keine Rechnungen vorhanden.

+

+ Die Rechnungsverwaltung wird in Kürze verfügbar sein. +

+
+ ) : ( +
+ + + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + + ))} + +
Rechnungsnr.KundeRechnungsdatumFällig amBetragStatus
{invoice.invoiceNumber}{invoice.customerName}{formatDate(invoice.invoiceDate)}{formatDate(invoice.dueDate)}{formatCurrency(invoice.totalAmount)} + + {STATUS_LABELS[invoice.status]} + +
+
+ )} +
+ +
+
+
+

Zusammenfassung

+

Statistik

+
+
+ +
+
+ {invoices.length} + Gesamtrechnungen +
+
+ + {invoices.filter((i) => i.status === "PAID").length} + + Bezahlt +
+
+ + {invoices.filter((i) => i.status === "OVERDUE").length} + + Überfällig +
+
+ + {formatCurrency( + invoices + .filter((i) => i.status === "PAID") + .reduce((sum, i) => sum + i.totalAmount, 0) + )} + + Gesamtumsatz +
+
+
+
+ ); +} diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx new file mode 100644 index 0000000..4922a4a --- /dev/null +++ b/frontend/src/pages/InvoiceTemplatePage.tsx @@ -0,0 +1,2198 @@ +import { + useEffect, + useState, + useRef, + type ChangeEvent as ReactChangeEvent, + type DragEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { ApiError, apiGet, apiPut } from "../lib/api"; +import { useSession } from "../lib/session"; +import type { UserOption } from "../lib/types"; + +const CANVAS_WIDTH = 794; +const CANVAS_HEIGHT = 1123; +const CANVAS_ASPECT_RATIO = CANVAS_WIDTH / CANVAS_HEIGHT; +const GRID_SIZE = 5; +const DRAG_DATA_TYPE = "application/x-muh-invoice-template"; +const PDF_PAGE_WIDTH = 595.28; +const PDF_PAGE_HEIGHT = 841.89; +const PDF_SCALE = PDF_PAGE_WIDTH / CANVAS_WIDTH; +const PDF_TEXT_PADDING_X = 12; +const PDF_TEXT_PADDING_Y = 10; +const PDF_LINE_HEIGHT = 1.35; +const CANVAS_BOTTOM_GAP = 10; +const MIN_ELEMENT_WIDTH = 100; +const MIN_MEDIA_WIDTH = 40; +const MIN_MEDIA_HEIGHT = 40; +const MIN_LINE_THICKNESS = 1; +const MIN_LINE_LENGTH = 40; +const MIN_FONT_SIZE = 12; +const MAX_FONT_SIZE = 44; +const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([ + "invoice-number", + "invoice-date", + "invoice-due-date", + "customer-number", + "customer-name", + "customer-address", + "customer-email", + "customer-phone", + "invoice-items-header", + "invoice-items-rows", + "invoice-subtotal", + "invoice-tax", + "invoice-total", + "invoice-tax-note", + "payment-terms", + "bank-details", + "issuer-name", + "issuer-address", + "issuer-contact", +]); + +type ElementKind = "text" | "line" | "image"; +type LineOrientation = "horizontal" | "vertical"; +type TextAlign = "left" | "center" | "right"; +type FontWeight = 400 | 500 | 600 | 700; +type PaletteCategory = string; + +interface PaletteItem { + id: string; + category: PaletteCategory; + label: string; + description: string; + kind?: ElementKind; + width: number; + height?: number; + fontSize: number; + fontWeight: FontWeight; + textAlign: TextAlign; + lineOrientation?: LineOrientation; + defaultContent: (user: UserOption | null) => string; +} + +interface TemplateElement { + id: string; + paletteId: string; + kind: ElementKind; + label: string; + content: string; + x: number; + y: number; + width: number; + height?: number; + fontSize: number; + fontWeight: FontWeight; + textAlign: TextAlign; + lineOrientation?: LineOrientation; + imageSrc?: string | null; + imageNaturalWidth?: number | null; + imageNaturalHeight?: number | null; +} + +interface CanvasViewport { + height: number; + scale: number; + syncPanelHeight: boolean; + width: number; +} + +interface PdfImageResource { + body: string; + elementId: string; + name: string; + pixelHeight: number; + pixelWidth: number; +} + +interface UploadedImageAsset { + dataUrl: string; + height: number; + width: number; +} + +interface InvoiceTemplateResponse { + elements: unknown; + stored: boolean; + updatedAt: string | null; +} + +type DragPayload = + | { + kind: "palette"; + paletteId: string; + } + | { + kind: "element"; + elementId: string; + }; + +const CP1252_MAP: Record = { + 338: 140, + 339: 156, + 352: 138, + 353: 154, + 376: 159, + 381: 142, + 382: 158, + 402: 131, + 710: 136, + 732: 152, + 8211: 150, + 8212: 151, + 8216: 145, + 8217: 146, + 8218: 130, + 8220: 147, + 8221: 148, + 8222: 132, + 8224: 134, + 8225: 135, + 8226: 149, + 8230: 133, + 8240: 137, + 8249: 139, + 8250: 155, + 8364: 128, + 8482: 153, +}; + +const INVOICE_PALETTE_ITEMS: PaletteItem[] = [ + { + id: "invoice-title", + category: "invoice-header", + label: "Rechnungstitel", + description: "Titel der Rechnung", + width: 200, + fontSize: 28, + fontWeight: 700, + textAlign: "left", + defaultContent: () => "Rechnung", + }, + { + id: "invoice-number", + category: "invoice-header", + label: "Rechnungsnummer", + description: "Automatische Rechnungsnummer", + width: 200, + fontSize: 16, + fontWeight: 600, + textAlign: "left", + defaultContent: () => "Rechnungsnr.: R-2026-001", + }, + { + id: "invoice-date", + category: "invoice-header", + label: "Rechnungsdatum", + description: "Datum der Rechnungserstellung", + width: 200, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: () => `Datum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date())}`, + }, + { + id: "invoice-due-date", + category: "invoice-header", + label: "Fälligkeitsdatum", + description: "Zahlungsfälligkeit", + width: 200, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Fällig bis: 15.04.2026", + }, + { + id: "customer-number", + category: "customer-data", + label: "Kundennummer", + description: "Kundennummer", + width: 200, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Kunden-Nr.: K-12345", + }, + { + id: "customer-name", + category: "customer-data", + label: "Kundenname", + description: "Name des Kunden", + width: 280, + fontSize: 16, + fontWeight: 600, + textAlign: "left", + defaultContent: () => "VoS, Dirk Schwissel", + }, + { + id: "customer-address", + category: "customer-data", + label: "Kundenadresse", + description: "Adresse des Kunden", + width: 280, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Musterstraße 123\n12345 Musterstadt", + }, + { + id: "customer-email", + category: "customer-data", + label: "Kunden-E-Mail", + description: "E-Mail-Adresse des Kunden", + width: 250, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "E-Mail: kunde@example.com", + }, + { + id: "customer-phone", + category: "customer-data", + label: "Kunden-Telefon", + description: "Telefonnummer des Kunden", + width: 200, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Tel.: +49 123 456789", + }, + { + id: "issuer-name", + category: "issuer-data", + label: "Aussteller-Name", + description: "Name des Rechnungsausstellers", + width: 280, + fontSize: 16, + fontWeight: 700, + textAlign: "left", + defaultContent: (user) => user?.companyName ?? "Ihr Unternehmen", + }, + { + id: "issuer-address", + category: "issuer-data", + label: "Aussteller-Adresse", + description: "Adresse des Rechnungsausstellers", + width: 280, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => + user?.address ?? + `${user?.street ?? ""} ${user?.houseNumber ?? ""}\n${user?.postalCode ?? ""} ${user?.city ?? ""}`, + }, + { + id: "issuer-contact", + category: "issuer-data", + label: "Aussteller-Kontakt", + description: "Kontaktdaten des Rechnungsausstellers", + width: 280, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => + `Tel.: ${user?.phoneNumber ?? ""}\nE-Mail: ${user?.email ?? ""}`, + }, + { + id: "invoice-items-header", + category: "invoice-items", + label: "Positionen-Überschrift", + description: "Überschrift für die Rechnungspositionen", + width: 620, + fontSize: 16, + fontWeight: 700, + textAlign: "left", + defaultContent: () => "Pos. | Beschreibung | Menge | Einzelpreis | Gesamt", + }, + { + id: "invoice-items-rows", + category: "invoice-items", + label: "Rechnungspositionen", + description: "Tabelle mit Rechnungspositionen", + width: 620, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => + "1 | Laboruntersuchung Probe #10307 | 1 | 45,00 € | 45,00 €\n2 | Milchprobenanalyse | 2 | 25,00 € | 50,00 €", + }, + { + id: "invoice-subtotal", + category: "invoice-totals", + label: "Zwischensumme", + description: "Nettobetrag", + width: 200, + fontSize: 16, + fontWeight: 600, + textAlign: "right", + defaultContent: () => "Zwischensumme: 95,00 €", + }, + { + id: "invoice-tax", + category: "invoice-totals", + label: "Mehrwertsteuer", + description: "MwSt-Betrag", + width: 200, + fontSize: 16, + fontWeight: 400, + textAlign: "right", + defaultContent: () => "MwSt. (19%): 18,05 €", + }, + { + id: "invoice-total", + category: "invoice-totals", + label: "Gesamtbetrag", + description: "Bruttobetrag", + width: 200, + fontSize: 20, + fontWeight: 700, + textAlign: "right", + defaultContent: () => "Gesamt: 113,05 €", + }, + { + id: "invoice-tax-note", + category: "invoice-totals", + label: "Steuerhinweis", + description: "Hinweis zur Steuerbefreiung oder -pflicht", + width: 400, + fontSize: 12, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "* Alle Preise verstehen sich zzgl. gesetzlicher MwSt.", + }, + { + id: "payment-terms", + category: "invoice-footer", + label: "Zahlungsbedingungen", + description: "Zahlungsziel und -bedingungen", + width: 400, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Zahlungsbedingungen: Zahlung innerhalb von 14 Tagen ohne Abzug.", + }, + { + id: "bank-details", + category: "invoice-footer", + label: "Bankverbindung", + description: "Bankdaten für die Zahlung", + width: 400, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => + "Bankverbindung:\nIBAN: DE12 3456 7890 1234 5678 90\nBIC: ABCDEFGHXXX\nBank: Musterbank", + }, + { + id: "free-text", + category: "free-elements", + label: "Freitext", + description: "Beliebig editierbarer Text", + width: 220, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Freitext", + }, + { + id: "line", + category: "free-elements", + label: "Linie", + description: "Linie mit umschaltbarer Ausrichtung", + kind: "line", + width: 260, + height: 3, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + lineOrientation: "horizontal", + defaultContent: () => "", + }, + { + id: "image", + category: "free-elements", + label: "Bild", + description: "Logo, Kopfgrafik oder Gestaltungselement", + kind: "image", + width: 180, + height: 120, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Bild", + }, +]; + +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: "invoice-items", title: "Positionen" }, + { category: "invoice-totals", title: "Beträge" }, + { category: "invoice-footer", title: "Fußbereich" }, + { category: "free-elements", title: "Freie Elemente" }, +]; + +function clamp(value: number, minimum: number, maximum: number) { + return Math.min(Math.max(value, minimum), maximum); +} + +function snapToGrid(value: number) { + return Math.round(value / GRID_SIZE) * GRID_SIZE; +} + +function formatPdfNumber(value: number) { + const normalized = Number(value.toFixed(2)); + if (Number.isInteger(normalized)) { + return String(normalized); + } + return normalized.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); +} + +function measureTextWidth(text: string, fontSize: number, fontWeight: FontWeight) { + if (typeof document === "undefined") { + return text.length * fontSize * 0.56; + } + + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) { + return text.length * fontSize * 0.56; + } + + context.font = `${fontWeight >= 600 ? 700 : 400} ${fontSize}px Arial`; + return context.measureText(text).width; +} + +function splitLongToken(token: string, maxWidth: number, fontSize: number, fontWeight: FontWeight) { + const segments: string[] = []; + let currentSegment = ""; + + for (const character of token) { + const candidate = `${currentSegment}${character}`; + if (currentSegment && measureTextWidth(candidate, fontSize, fontWeight) > maxWidth) { + segments.push(currentSegment); + currentSegment = character; + continue; + } + currentSegment = candidate; + } + + if (currentSegment) { + segments.push(currentSegment); + } + + return segments; +} + +function wrapTextLines(text: string, maxWidth: number, fontSize: number, fontWeight: FontWeight) { + const paragraphs = text.split(/\r?\n/); + const wrappedLines: string[] = []; + + for (const paragraph of paragraphs) { + const words = paragraph.trim().split(/\s+/).filter(Boolean); + if (!words.length) { + wrappedLines.push(""); + continue; + } + + let currentLine = ""; + for (const word of words) { + const fragments = + measureTextWidth(word, fontSize, fontWeight) > maxWidth + ? splitLongToken(word, maxWidth, fontSize, fontWeight) + : [word]; + + for (const fragment of fragments) { + const candidate = currentLine ? `${currentLine} ${fragment}` : fragment; + if (!currentLine || measureTextWidth(candidate, fontSize, fontWeight) <= maxWidth) { + currentLine = candidate; + continue; + } + + wrappedLines.push(currentLine); + currentLine = fragment; + } + } + + if (currentLine) { + wrappedLines.push(currentLine); + } + } + + return wrappedLines.length ? wrappedLines : [""]; +} + +function encodePdfText(text: string) { + const bytes: number[] = []; + + for (const character of text) { + const codePoint = character.codePointAt(0) ?? 63; + if (CP1252_MAP[codePoint]) { + bytes.push(CP1252_MAP[codePoint]); + continue; + } + if (codePoint >= 0 && codePoint <= 255) { + bytes.push(codePoint); + continue; + } + bytes.push(63); + } + + return bytes.map((value) => value.toString(16).padStart(2, "0")).join("").toUpperCase(); +} + +function getDefaultElementHeight( + kind: ElementKind, + fontSize: number, + lineOrientation?: LineOrientation, +) { + if (kind === "line") { + return lineOrientation === "vertical" ? 180 : 3; + } + if (kind === "image") { + return 120; + } + return Math.max(fontSize + 16, 28); +} + +function getMinimumWidthForElement(kind: ElementKind, lineOrientation?: LineOrientation) { + if (kind === "line") { + return lineOrientation === "vertical" ? MIN_LINE_THICKNESS : MIN_LINE_LENGTH; + } + if (kind === "image") { + return MIN_MEDIA_WIDTH; + } + return MIN_ELEMENT_WIDTH; +} + +function getMinimumHeightForElement( + kind: ElementKind, + fontSize: number, + lineOrientation?: LineOrientation, +) { + if (kind === "line") { + return lineOrientation === "vertical" ? MIN_LINE_LENGTH : MIN_LINE_THICKNESS; + } + if (kind === "image") { + return MIN_MEDIA_HEIGHT; + } + return getDefaultElementHeight(kind, fontSize, lineOrientation); +} + +function getElementHeight( + element: Pick, +) { + return Math.max( + 1, + Math.round( + element.height ?? getDefaultElementHeight(element.kind, element.fontSize, element.lineOrientation), + ), + ); +} + +function fitContainedSize( + containerWidth: number, + containerHeight: number, + assetWidth: number, + assetHeight: number, +) { + if (assetWidth <= 0 || assetHeight <= 0) { + return { + height: containerHeight, + offsetX: 0, + offsetY: 0, + width: containerWidth, + }; + } + + const scale = Math.min(containerWidth / assetWidth, containerHeight / assetHeight); + const width = assetWidth * scale; + const height = assetHeight * scale; + + return { + height, + offsetX: (containerWidth - width) / 2, + offsetY: (containerHeight - height) / 2, + width, + }; +} + +function decodeBase64DataUrl(dataUrl: string) { + const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!match) { + return null; + } + + const [, mimeType, base64Payload] = match; + const binary = atob(base64Payload); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return { + bytes, + mimeType, + }; +} + +function bytesToHex(bytes: Uint8Array) { + let hex = ""; + for (const value of bytes) { + hex += value.toString(16).padStart(2, "0").toUpperCase(); + } + return hex; +} + +function createPdfImageResources(elements: TemplateElement[]) { + const imageElements = elements.filter( + (element) => element.kind === "image" && typeof element.imageSrc === "string" && element.imageSrc, + ); + + return imageElements.reduce((resources, element, index) => { + const decoded = decodeBase64DataUrl(element.imageSrc ?? ""); + if (!decoded || !/image\/jpe?g/i.test(decoded.mimeType)) { + return resources; + } + + const hexPayload = `${bytesToHex(decoded.bytes)}>`; + resources.push({ + body: + `<< /Type /XObject /Subtype /Image /Width ${Math.max(1, Math.round(element.imageNaturalWidth ?? element.width))}` + + ` /Height ${Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element)))}` + + " /ColorSpace /DeviceRGB /BitsPerComponent 8" + + " /Filter [/ASCIIHexDecode /DCTDecode]" + + ` /Length ${hexPayload.length} >> +stream +${hexPayload} +endstream`, + elementId: element.id, + name: `/Im${index + 1}`, + pixelHeight: Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element))), + pixelWidth: Math.max(1, Math.round(element.imageNaturalWidth ?? element.width)), + }); + + return resources; + }, []); +} + +function createPdfContentStream( + elements: TemplateElement[], + imageResources: Map, +) { + const commands = ["0 g"]; + + for (const element of elements) { + if (element.kind === "line") { + const elementHeight = getElementHeight(element); + const pdfX = element.x * PDF_SCALE; + const pdfY = PDF_PAGE_HEIGHT - (element.y + elementHeight) * PDF_SCALE; + const pdfWidth = element.width * PDF_SCALE; + const pdfHeight = elementHeight * PDF_SCALE; + + commands.push( + `${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} ${formatPdfNumber(pdfWidth)} ${formatPdfNumber(pdfHeight)} re f`, + ); + continue; + } + + if (element.kind === "image") { + const imageResource = imageResources.get(element.id); + if (!imageResource) { + continue; + } + + const boxHeight = getElementHeight(element); + const fitted = fitContainedSize( + element.width, + boxHeight, + imageResource.pixelWidth, + imageResource.pixelHeight, + ); + const pdfX = (element.x + fitted.offsetX) * PDF_SCALE; + const pdfY = + PDF_PAGE_HEIGHT - (element.y + fitted.offsetY + fitted.height) * PDF_SCALE; + const pdfWidth = fitted.width * PDF_SCALE; + const pdfHeight = fitted.height * PDF_SCALE; + + commands.push("q"); + commands.push( + `${formatPdfNumber(pdfWidth)} 0 0 ${formatPdfNumber(pdfHeight)} ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} cm`, + ); + commands.push(`${imageResource.name} Do`); + commands.push("Q"); + continue; + } + + const fontName = element.fontWeight >= 600 ? "/F2" : "/F1"; + const fontSize = element.fontSize * PDF_SCALE; + const lineHeight = element.fontSize * PDF_LINE_HEIGHT; + const textStartX = element.x + PDF_TEXT_PADDING_X; + const textWidth = Math.max(24, element.width - PDF_TEXT_PADDING_X * 2); + const wrappedLines = wrapTextLines( + element.content || "", + textWidth, + element.fontSize, + element.fontWeight, + ); + + wrappedLines.forEach((line, index) => { + if (!line) { + return; + } + + const renderedLineWidth = measureTextWidth(line, element.fontSize, element.fontWeight); + let textX = textStartX; + if (element.textAlign === "center") { + textX += Math.max(0, (textWidth - renderedLineWidth) / 2); + } else if (element.textAlign === "right") { + textX += Math.max(0, textWidth - renderedLineWidth); + } + + const baselineY = element.y + PDF_TEXT_PADDING_Y + element.fontSize + index * lineHeight; + const pdfX = textX * PDF_SCALE; + const pdfY = PDF_PAGE_HEIGHT - baselineY * PDF_SCALE; + + commands.push("BT"); + commands.push(`${fontName} ${formatPdfNumber(fontSize)} Tf`); + commands.push(`1 0 0 1 ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} Tm`); + commands.push(`<${encodePdfText(line)}> Tj`); + commands.push("ET"); + }); + } + + return commands.join("\n"); +} + +function createPdfBlob(elements: TemplateElement[]) { + const imageResources = createPdfImageResources(elements); + const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource])); + const contentStream = createPdfContentStream(elements, imageResourceMap); + const imageObjectNumberStart = 6; + const contentObjectNumber = imageObjectNumberStart + imageResources.length; + const xObjectResources = imageResources.length + ? ` /XObject << ${imageResources + .map((resource, index) => `${resource.name} ${imageObjectNumberStart + index} 0 R`) + .join(" ")} >>` + : ""; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [3 0 R] >>", + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${formatPdfNumber(PDF_PAGE_WIDTH)} ${formatPdfNumber(PDF_PAGE_HEIGHT)}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >>${xObjectResources} >> /Contents ${contentObjectNumber} 0 R >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", + ...imageResources.map((resource) => resource.body), + `<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets = [0]; + + objects.forEach((body, index) => { + offsets.push(pdf.length); + pdf += `${index + 1} 0 obj\n${body}\nendobj\n`; + }); + + const xrefOffset = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + + for (let index = 1; index <= objects.length; index += 1) { + pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`; + } + + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + + return new Blob([pdf], { type: "application/pdf" }); +} + +function generateId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `invoice-template-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function normalizeFontWeight(value: unknown): FontWeight { + return value === 500 || value === 600 || value === 700 ? value : 400; +} + +function normalizeTextAlign(value: unknown): TextAlign { + return value === "center" || value === "right" ? value : "left"; +} + +function normalizeElementKind(value: unknown): ElementKind { + return value === "line" || value === "image" ? value : "text"; +} + +function normalizeLineOrientation(value: unknown): LineOrientation { + return value === "vertical" ? "vertical" : "horizontal"; +} + +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); + let width = Math.round(element.width); + let height = getElementHeight({ + fontSize, + height: element.height, + kind, + lineOrientation, + }); + + if (kind === "line") { + width = clamp(width, getMinimumWidthForElement(kind, lineOrientation), CANVAS_WIDTH); + height = clamp(height, getMinimumHeightForElement(kind, fontSize, lineOrientation), CANVAS_HEIGHT); + } else if (kind === "image") { + width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); + height = clamp(height, getMinimumHeightForElement(kind, fontSize), CANVAS_HEIGHT - 32); + } else { + width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); + height = getDefaultElementHeight(kind, fontSize, lineOrientation); + } + + 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, + ); + const imageNaturalWidth = + kind === "image" && typeof element.imageNaturalWidth === "number" && element.imageNaturalWidth > 0 + ? Math.round(element.imageNaturalWidth) + : null; + const imageNaturalHeight = + kind === "image" && typeof element.imageNaturalHeight === "number" && element.imageNaturalHeight > 0 + ? Math.round(element.imageNaturalHeight) + : null; + + return { + ...element, + kind, + width, + height: kind === "text" ? undefined : height, + fontSize, + x, + y, + fontWeight: normalizeFontWeight(element.fontWeight), + textAlign: normalizeTextAlign(element.textAlign), + lineOrientation, + imageSrc: kind === "image" ? element.imageSrc ?? null : undefined, + imageNaturalWidth, + imageNaturalHeight, + }; +} + +function findPaletteItem(paletteItems: PaletteItem[], paletteId: string) { + return paletteItems.find((entry) => entry.id === paletteId) ?? null; +} + +function requirePaletteItem(paletteItems: PaletteItem[], paletteId: string) { + const paletteItem = findPaletteItem(paletteItems, paletteId); + if (!paletteItem) { + throw new Error(`Palette item ${paletteId} is not configured.`); + } + return paletteItem; +} + +function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null) { + if (paletteItem.kind === "image") { + return "Bild auswaehlen"; + } + if (paletteItem.kind === "line") { + return "Linie"; + } + return paletteItem.defaultContent(user); +} + +function isElementContentEditable( + element: Pick, + lockedTextPaletteIds: ReadonlySet, +) { + return element.kind === "text" && !lockedTextPaletteIds.has(element.paletteId); +} + +function createElementFromPalette( + paletteItem: PaletteItem, + user: UserOption | null, + position: { x: number; y: number }, +): TemplateElement { + return normalizeElement({ + id: generateId(), + paletteId: paletteItem.id, + kind: paletteItem.kind ?? "text", + label: paletteItem.label, + content: paletteItem.defaultContent(user), + x: position.x, + y: position.y, + width: paletteItem.width, + height: paletteItem.height, + fontSize: paletteItem.fontSize, + fontWeight: paletteItem.fontWeight, + textAlign: paletteItem.textAlign, + lineOrientation: paletteItem.lineOrientation, + imageSrc: null, + imageNaturalWidth: null, + imageNaturalHeight: null, + }); +} + +function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[]) { + const horizontalLine = (x: number, y: number, width: number) => + normalizeElement({ + ...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }), + width, + }); + + return [ + createElementFromPalette(requirePaletteItem(paletteItems, "issuer-name"), user, { x: 56, y: 56 }), + createElementFromPalette(requirePaletteItem(paletteItems, "issuer-address"), user, { x: 56, y: 86 }), + createElementFromPalette(requirePaletteItem(paletteItems, "issuer-contact"), user, { x: 56, y: 130 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-title"), user, { x: 480, y: 56 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-number"), user, { x: 480, y: 96 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-date"), user, { x: 480, y: 122 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-due-date"), user, { x: 480, y: 148 }), + horizontalLine(56, 200, 646), + createElementFromPalette(requirePaletteItem(paletteItems, "customer-name"), user, { x: 56, y: 220 }), + createElementFromPalette(requirePaletteItem(paletteItems, "customer-address"), user, { x: 56, y: 246 }), + createElementFromPalette(requirePaletteItem(paletteItems, "customer-number"), user, { x: 56, y: 290 }), + horizontalLine(56, 330, 646), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-items-header"), user, { x: 56, y: 350 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-items-rows"), user, { x: 56, y: 380 }), + horizontalLine(56, 440, 646), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-subtotal"), user, { x: 500, y: 460 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-tax"), user, { x: 500, y: 486 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-total"), user, { x: 500, y: 520 }), + createElementFromPalette(requirePaletteItem(paletteItems, "invoice-tax-note"), user, { x: 56, y: 560 }), + horizontalLine(56, 600, 646), + createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 620 }), + createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 660 }), + ]; +} + +function readFileAsDataUrl(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Bild konnte nicht geladen werden.")); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Bild konnte nicht geladen werden.")); + }; + reader.readAsDataURL(file); + }); +} + +function loadImageAsset(dataUrl: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onerror = () => reject(new Error("Bild konnte nicht gelesen werden.")); + image.onload = () => + resolve({ + dataUrl, + height: image.naturalHeight, + width: image.naturalWidth, + }); + image.src = dataUrl; + }); +} + +async function convertImageFileToJpeg(file: File) { + const source = await readFileAsDataUrl(file); + const asset = await loadImageAsset(source); + const canvas = document.createElement("canvas"); + canvas.width = asset.width; + canvas.height = asset.height; + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Bild konnte nicht verarbeitet werden."); + } + + const image = await loadImageAsset(source); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + const renderImage = new Image(); + + await new Promise((resolve, reject) => { + renderImage.onerror = () => reject(new Error("Bild konnte nicht verarbeitet werden.")); + renderImage.onload = () => { + context.drawImage(renderImage, 0, 0, canvas.width, canvas.height); + resolve(); + }; + renderImage.src = image.dataUrl; + }); + + return { + dataUrl: canvas.toDataURL("image/jpeg", 0.92), + height: asset.height, + width: asset.width, + }; +} + +function normalizeTemplateElements(raw: unknown) { + if (!Array.isArray(raw)) { + return null; + } + + const elements = raw + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + + const candidate = entry as Partial; + if ( + typeof candidate.id !== "string" || + typeof candidate.paletteId !== "string" || + typeof candidate.label !== "string" || + typeof candidate.content !== "string" || + typeof candidate.x !== "number" || + typeof candidate.y !== "number" || + typeof candidate.width !== "number" || + typeof candidate.fontSize !== "number" + ) { + return null; + } + + return normalizeElement({ + id: candidate.id, + paletteId: candidate.paletteId, + kind: normalizeElementKind(candidate.kind), + label: candidate.label, + content: candidate.content, + x: candidate.x, + y: candidate.y, + width: candidate.width, + height: typeof candidate.height === "number" ? candidate.height : undefined, + fontSize: candidate.fontSize, + fontWeight: normalizeFontWeight(candidate.fontWeight), + textAlign: normalizeTextAlign(candidate.textAlign), + lineOrientation: normalizeLineOrientation(candidate.lineOrientation), + imageSrc: typeof candidate.imageSrc === "string" ? candidate.imageSrc : null, + imageNaturalWidth: + typeof candidate.imageNaturalWidth === "number" ? candidate.imageNaturalWidth : null, + imageNaturalHeight: + typeof candidate.imageNaturalHeight === "number" ? candidate.imageNaturalHeight : null, + }); + }) + .filter((entry): entry is TemplateElement => entry !== null); + + return elements; +} + +function formatTemplateTimestamp(value: string | null) { + if (!value) { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(parsed); +} + +function readDragPayload(event: DragEvent): DragPayload | null { + const raw = event.dataTransfer.getData(DRAG_DATA_TYPE); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as DragPayload; + if (parsed.kind === "palette" && typeof parsed.paletteId === "string") { + return parsed; + } + if (parsed.kind === "element" && typeof parsed.elementId === "string") { + return parsed; + } + return null; + } catch { + return null; + } +} + +export default function InvoiceTemplatePage() { + const { user } = useSession(); + const pageRef = useRef(null); + const cardRef = useRef(null); + const canvasRef = useRef(null); + const canvasShellRef = useRef(null); + const dragDepthRef = useRef(0); + const mouseDragCleanupRef = useRef<(() => void) | null>(null); + const moveOffsetRef = useRef<{ x: number; y: number } | null>(null); + + const [elements, setElements] = useState(() => + createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS), + ); + const [selectedElementId, setSelectedElementId] = useState(null); + const [isCanvasActive, setIsCanvasActive] = useState(false); + const [draggingElementId, setDraggingElementId] = useState(null); + const [resizingElementId, setResizingElementId] = useState(null); + const [pdfError, setPdfError] = useState(null); + const [templateError, setTemplateError] = useState(null); + const [templateUpdatedAt, setTemplateUpdatedAt] = useState(null); + const [isTemplateLoading, setIsTemplateLoading] = useState(false); + const [isTemplateSaving, setIsTemplateSaving] = useState(false); + const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true); + const [pdfPreviewUrl, setPdfPreviewUrl] = useState(null); + const [pageViewportHeight, setPageViewportHeight] = useState(null); + const [canvasViewport, setCanvasViewport] = useState({ + height: CANVAS_HEIGHT, + scale: 1, + syncPanelHeight: false, + width: CANVAS_WIDTH, + }); + + const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null; + const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null; + const selectedElementContentEditable = selectedElement + ? isElementContentEditable(selectedElement, INVOICE_LOCKED_TEXT_PALETTE_IDS) + : false; + const selectedElementMinimumWidth = selectedElement + ? getMinimumWidthForElement(selectedElement.kind, selectedElement.lineOrientation) + : MIN_ELEMENT_WIDTH; + const selectedElementMinimumHeight = selectedElement + ? getMinimumHeightForElement( + selectedElement.kind, + selectedElement.fontSize, + selectedElement.lineOrientation, + ) + : MIN_MEDIA_HEIGHT; + const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt); + const emptyCanvasMessage = "Ziehen Sie links ein Element auf diese Fläche, um Ihre Rechnungsvorlage zu starten."; + + useEffect(() => { + if (!user) { + const starterLayout = createInvoiceStarterLayout(null, INVOICE_PALETTE_ITEMS); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); + setIsTemplateApiAvailable(true); + setIsTemplateLoading(false); + setTemplateUpdatedAt(null); + setTemplateError(null); + return; + } + + let cancelled = false; + setTemplateError(null); + setIsTemplateLoading(true); + + void apiGet("/admin/invoice-template") + .then((response) => { + if (cancelled) { + return; + } + + setIsTemplateApiAvailable(true); + setTemplateUpdatedAt(response.updatedAt); + if (!response.stored) { + const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); + return; + } + + const loadedElements = normalizeTemplateElements(response.elements); + if (!loadedElements) { + throw new Error("Gespeichertes Template konnte nicht geladen werden."); + } + + setElements(loadedElements); + setSelectedElementId(loadedElements[0]?.id ?? null); + setResizingElementId(null); + }) + .catch((error) => { + if (!cancelled) { + if (error instanceof ApiError && error.status === 404) { + const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); + setTemplateUpdatedAt(null); + setTemplateError(null); + setIsTemplateApiAvailable(false); + return; + } + setTemplateError((error as Error).message); + } + }) + .finally(() => { + if (!cancelled) { + setIsTemplateLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [user]); + + useEffect(() => { + if (selectedElementId && elements.some((entry) => entry.id === selectedElementId)) { + return; + } + setSelectedElementId(elements[0]?.id ?? null); + }, [elements, selectedElementId]); + + useEffect(() => { + return () => { + mouseDragCleanupRef.current?.(); + }; + }, []); + + useEffect(() => { + return () => { + if (pdfPreviewUrl) { + URL.revokeObjectURL(pdfPreviewUrl); + } + }; + }, [pdfPreviewUrl]); + + useEffect(() => { + if (!pdfPreviewUrl) { + return; + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setPdfPreviewUrl(null); + } + } + + window.addEventListener("keydown", handleEscape); + return () => { + window.removeEventListener("keydown", handleEscape); + }; + }, [pdfPreviewUrl]); + + useEffect(() => { + let frameId = 0; + + function updateCanvasViewport() { + cancelAnimationFrame(frameId); + frameId = window.requestAnimationFrame(() => { + const pageElement = pageRef.current; + const cardElement = cardRef.current; + const canvasShellElement = canvasShellRef.current; + if (!pageElement || !cardElement || !canvasShellElement) { + return; + } + + const pageRect = pageElement.getBoundingClientRect(); + const nextPageHeight = Math.max(window.innerHeight - pageRect.top - 36, 420); + setPageViewportHeight((current) => (current === nextPageHeight ? current : nextPageHeight)); + + const cardStyles = window.getComputedStyle(cardElement); + const shellStyles = window.getComputedStyle(canvasShellElement); + const syncPanelHeight = window.innerWidth > 1200; + const cardPaddingBottom = Number.parseFloat(cardStyles.paddingBottom) || 0; + const horizontalPadding = + (Number.parseFloat(shellStyles.paddingLeft) || 0) + + (Number.parseFloat(shellStyles.paddingRight) || 0); + const verticalPadding = + (Number.parseFloat(shellStyles.paddingTop) || 0) + + (Number.parseFloat(shellStyles.paddingBottom) || 0); + const cardRect = cardElement.getBoundingClientRect(); + const shellRect = canvasShellElement.getBoundingClientRect(); + const availableWidth = Math.max( + Math.floor(canvasShellElement.clientWidth - horizontalPadding), + 320, + ); + const availableHeight = Math.max( + Math.floor(cardRect.bottom - shellRect.top - cardPaddingBottom - verticalPadding - CANVAS_BOTTOM_GAP), + 240, + ); + const nextWidth = Math.min( + CANVAS_WIDTH, + availableWidth, + Math.floor(availableHeight * CANVAS_ASPECT_RATIO), + ); + const nextHeight = Math.floor(nextWidth / CANVAS_ASPECT_RATIO); + const nextScale = nextWidth / CANVAS_WIDTH; + + setCanvasViewport((current) => { + if ( + current.height === nextHeight && + current.scale === nextScale && + current.syncPanelHeight === syncPanelHeight && + current.width === nextWidth + ) { + return current; + } + + return { + height: nextHeight, + scale: nextScale, + syncPanelHeight, + width: nextWidth, + }; + }); + }); + } + + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => { + updateCanvasViewport(); + }); + + if (resizeObserver) { + if (pageRef.current) { + resizeObserver.observe(pageRef.current); + } + if (cardRef.current) { + resizeObserver.observe(cardRef.current); + } + if (canvasShellRef.current) { + resizeObserver.observe(canvasShellRef.current); + } + } + + updateCanvasViewport(); + window.addEventListener("resize", updateCanvasViewport); + window.addEventListener("scroll", updateCanvasViewport, { passive: true }); + + return () => { + cancelAnimationFrame(frameId); + resizeObserver?.disconnect(); + window.removeEventListener("resize", updateCanvasViewport); + window.removeEventListener("scroll", updateCanvasViewport); + }; + }, [pdfPreviewUrl]); + + function resetCanvasDragState() { + dragDepthRef.current = 0; + setIsCanvasActive(false); + } + + function updateElement(elementId: string, patch: Partial) { + setElements((current) => + current.map((entry) => + entry.id === elementId + ? normalizeElement({ + ...entry, + ...(!isElementContentEditable(entry, INVOICE_LOCKED_TEXT_PALETTE_IDS) && + Object.prototype.hasOwnProperty.call(patch, "content") + ? { ...patch, content: entry.content } + : patch), + }) + : entry, + ), + ); + } + + function removeElement(elementId: string) { + mouseDragCleanupRef.current?.(); + setElements((current) => current.filter((entry) => entry.id !== elementId)); + setSelectedElementId((current) => (current === elementId ? null : current)); + setResizingElementId((current) => (current === elementId ? null : current)); + } + + function placePaletteElement(paletteId: string, clientX: number, clientY: number) { + const paletteItem = findPaletteItem(INVOICE_PALETTE_ITEMS, paletteId); + const canvasElement = canvasRef.current; + if (!paletteItem || !canvasElement) { + return; + } + + const rect = canvasElement.getBoundingClientRect(); + const previewHeight = getDefaultElementHeight( + paletteItem.kind ?? "text", + paletteItem.fontSize, + paletteItem.lineOrientation, + ); + const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2; + const y = (clientY - rect.top) / canvasViewport.scale - previewHeight / 2; + const nextElement = createElementFromPalette(paletteItem, user, { x, y }); + + setElements((current) => [...current, nextElement]); + setSelectedElementId(nextElement.id); + } + + function moveCanvasElement(elementId: string, clientX: number, clientY: number) { + const canvasElement = canvasRef.current; + if (!canvasElement) { + return; + } + + const rect = canvasElement.getBoundingClientRect(); + const moveOffset = moveOffsetRef.current; + const x = (clientX - rect.left) / canvasViewport.scale - (moveOffset?.x ?? 0); + const y = (clientY - rect.top) / canvasViewport.scale - (moveOffset?.y ?? 0); + + updateElement(elementId, { x, y }); + } + + function handleCanvasDragEnter(event: DragEvent) { + event.preventDefault(); + dragDepthRef.current += 1; + setIsCanvasActive(true); + } + + function handleCanvasDragOver(event: DragEvent) { + event.preventDefault(); + const payload = readDragPayload(event); + event.dataTransfer.dropEffect = payload?.kind === "element" ? "move" : "copy"; + setIsCanvasActive(true); + } + + function handleCanvasDragLeave(event: DragEvent) { + event.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsCanvasActive(false); + } + } + + function handleCanvasDrop(event: DragEvent) { + event.preventDefault(); + const payload = readDragPayload(event); + resetCanvasDragState(); + if (!payload) { + return; + } + + if (payload.kind === "palette") { + placePaletteElement(payload.paletteId, event.clientX, event.clientY); + return; + } + + moveCanvasElement(payload.elementId, event.clientX, event.clientY); + } + + function handleResetToStarter() { + mouseDragCleanupRef.current?.(); + const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + setResizingElementId(null); + } + + function handleClearCanvas() { + if (!window.confirm("Moechten Sie wirklich alle Elemente vom Canvas entfernen?")) { + return; + } + + mouseDragCleanupRef.current?.(); + setElements([]); + setSelectedElementId(null); + setResizingElementId(null); + } + + function handleCreatePdfPreview() { + try { + const pdfBlob = createPdfBlob(elements); + const nextPreviewUrl = URL.createObjectURL(pdfBlob); + setPdfPreviewUrl(nextPreviewUrl); + setPdfError(null); + } catch { + setPdfError("PDF konnte nicht erstellt werden."); + } + } + + async function handleSaveTemplate() { + if (!user) { + setTemplateError("Vorlagen koennen nur fuer angemeldete Benutzer gespeichert werden."); + return; + } + if (!isTemplateApiAvailable) { + setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar."); + return; + } + + setTemplateError(null); + setIsTemplateSaving(true); + + try { + const response = await apiPut("/admin/invoice-template", { + elements, + }); + const savedElements = normalizeTemplateElements(response.elements); + if (!savedElements) { + throw new Error("Gespeichertes Template konnte nicht gelesen werden."); + } + + setElements(savedElements); + setSelectedElementId((current) => + savedElements.some((entry) => entry.id === current) ? current : savedElements[0]?.id ?? null, + ); + setResizingElementId(null); + setTemplateUpdatedAt(response.updatedAt); + setIsTemplateApiAvailable(true); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + setIsTemplateApiAvailable(false); + setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar."); + } else { + setTemplateError((error as Error).message); + } + } finally { + setIsTemplateSaving(false); + } + } + + async function handleImageUpload( + event: ReactChangeEvent, + elementId: string, + ) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + + const currentElement = elements.find((entry) => entry.id === elementId); + if (!currentElement || currentElement.kind !== "image") { + return; + } + + try { + const image = await convertImageFileToJpeg(file); + const nextHeight = Math.max( + MIN_MEDIA_HEIGHT, + Math.round((currentElement.width * image.height) / image.width), + ); + + updateElement(elementId, { + height: nextHeight, + imageNaturalHeight: image.height, + imageNaturalWidth: image.width, + imageSrc: image.dataUrl, + }); + setPdfError(null); + } catch (error) { + setPdfError((error as Error).message); + } + } + + function handleElementMouseDown( + event: ReactMouseEvent, + elementId: string, + ) { + const canvasElement = canvasRef.current; + if (!canvasElement || event.button !== 0) { + return; + } + + mouseDragCleanupRef.current?.(); + + const rect = event.currentTarget.getBoundingClientRect(); + const startOffset = { + x: (event.clientX - rect.left) / canvasViewport.scale, + y: (event.clientY - rect.top) / canvasViewport.scale, + }; + + moveOffsetRef.current = startOffset; + setSelectedElementId(elementId); + setDraggingElementId(elementId); + setResizingElementId(null); + event.currentTarget.focus(); + event.preventDefault(); + + function handleMouseMove(moveEvent: MouseEvent) { + moveCanvasElement(elementId, moveEvent.clientX, moveEvent.clientY); + } + + function stopDragging() { + moveOffsetRef.current = null; + setDraggingElementId(null); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + mouseDragCleanupRef.current = null; + } + + function handleMouseUp() { + stopDragging(); + } + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + mouseDragCleanupRef.current = stopDragging; + } + + function handleResizeMouseDown( + event: ReactMouseEvent, + elementId: string, + ) { + const currentElement = elements.find((entry) => entry.id === elementId); + if (!currentElement || event.button !== 0) { + return; + } + + mouseDragCleanupRef.current?.(); + setSelectedElementId(elementId); + setDraggingElementId(null); + setResizingElementId(elementId); + event.currentTarget.parentElement?.focus(); + event.preventDefault(); + event.stopPropagation(); + + const startWidth = currentElement.width; + const startHeight = getElementHeight(currentElement); + const elementKind = currentElement.kind; + const elementFontSize = currentElement.fontSize; + const elementLineOrientation = currentElement.lineOrientation; + const imageNaturalWidth = currentElement.imageNaturalWidth; + const imageNaturalHeight = currentElement.imageNaturalHeight; + const startClientX = event.clientX; + const startClientY = event.clientY; + + function handleMouseMove(moveEvent: MouseEvent) { + const deltaX = (moveEvent.clientX - startClientX) / canvasViewport.scale; + const deltaY = (moveEvent.clientY - startClientY) / canvasViewport.scale; + const nextWidth = Math.max( + getMinimumWidthForElement(elementKind, elementLineOrientation), + snapToGrid(startWidth + deltaX), + ); + const nextHeight = Math.max( + getMinimumHeightForElement( + elementKind, + elementFontSize, + elementLineOrientation, + ), + snapToGrid(startHeight + deltaY), + ); + + if (elementKind === "image") { + const assetWidth = imageNaturalWidth ?? startWidth; + const assetHeight = imageNaturalHeight ?? startHeight; + const widthScale = nextWidth / assetWidth; + const heightScale = nextHeight / assetHeight; + + if (widthScale <= heightScale) { + updateElement(elementId, { + height: Math.max( + getMinimumHeightForElement(elementKind, elementFontSize), + Math.floor(((nextWidth * assetHeight) / assetWidth) / GRID_SIZE) * GRID_SIZE, + ), + width: nextWidth, + }); + return; + } + + updateElement(elementId, { + height: nextHeight, + width: Math.max( + getMinimumWidthForElement(elementKind), + Math.floor(((nextHeight * assetWidth) / assetHeight) / GRID_SIZE) * GRID_SIZE, + ), + }); + return; + } + + updateElement(elementId, { + width: nextWidth, + ...(elementKind === "text" ? {} : { height: nextHeight }), + }); + } + + function stopResizing() { + setResizingElementId(null); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + mouseDragCleanupRef.current = null; + } + + function handleMouseUp() { + stopResizing(); + } + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + mouseDragCleanupRef.current = stopResizing; + } + + function handleElementKeyDown( + event: ReactKeyboardEvent, + elementId: string, + ) { + const currentElement = elements.find((entry) => entry.id === elementId); + if (!currentElement) { + return; + } + + if (event.key === "Delete") { + event.preventDefault(); + removeElement(elementId); + return; + } + + let patch: Partial | null = null; + + if (event.key === "ArrowLeft") { + patch = { x: currentElement.x - GRID_SIZE }; + } else if (event.key === "ArrowRight") { + patch = { x: currentElement.x + GRID_SIZE }; + } else if (event.key === "ArrowUp") { + patch = { y: currentElement.y - GRID_SIZE }; + } else if (event.key === "ArrowDown") { + patch = { y: currentElement.y + GRID_SIZE }; + } + + if (!patch) { + return; + } + + event.preventDefault(); + setSelectedElementId(elementId); + updateElement(elementId, patch); + } + + const panelStyle = canvasViewport.syncPanelHeight + ? { height: `${canvasViewport.height}px` } + : undefined; + const pageStyle = + pageViewportHeight && canvasViewport.syncPanelHeight + ? { height: `${pageViewportHeight}px` } + : undefined; + + return ( +
+
+
+
+

Rechnungsvorlage bearbeiten

+
+ +
+ + + + +
+
+ + {pdfError ?
{pdfError}
: null} + {templateError ?
{templateError}
: null} + +
+ + +
+
+
+

Rechnungsvorlage

+ + {isTemplateLoading + ? "Gespeicherte Vorlage wird geladen..." + : !isTemplateApiAvailable + ? "Template-API auf diesem Server nicht verfuegbar" + : templateTimestampLabel + ? `In Datenbank gespeichert: ${templateTimestampLabel}` + : "Noch keine gespeicherte Vorlage vorhanden"} + +
+
+ +
+
+
+ {elements.length ? ( + elements.map((element) => ( + + )) + ) : ( +
{emptyCanvasMessage}
+ )} +
+
+
+
+ +