Rechnungstemplate: Kontoverbindung aus Stammdaten, verbesserte Tabellenformatierung, einspaltige Palette, Delete-Taste zum Löschen, UI-Optimierungen

This commit is contained in:
2026-03-18 16:29:10 +01:00
parent dc35995e64
commit 58c78bbbbd
2 changed files with 632 additions and 145 deletions

View File

@@ -1,4 +1,5 @@
import { import {
useCallback,
useEffect, useEffect,
useState, useState,
useRef, useRef,
@@ -30,6 +31,8 @@ const MIN_LINE_THICKNESS = 1;
const MIN_LINE_LENGTH = 40; const MIN_LINE_LENGTH = 40;
const MIN_FONT_SIZE = 12; const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 44; 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([ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
"invoice-number", "invoice-number",
"invoice-date", "invoice-date",
@@ -52,6 +55,7 @@ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
"issuer-postal-code", "issuer-postal-code",
"issuer-city", "issuer-city",
"issuer-contact", "issuer-contact",
"invoice-items-muh",
]); ]);
type ElementKind = "text" | "line" | "image"; type ElementKind = "text" | "line" | "image";
@@ -60,6 +64,14 @@ type TextAlign = "left" | "center" | "right";
type FontWeight = 400 | 500 | 600 | 700; type FontWeight = 400 | 500 | 600 | 700;
type PaletteCategory = string; 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 { interface PaletteItem {
id: string; id: string;
category: PaletteCategory; category: PaletteCategory;
@@ -115,6 +127,13 @@ interface UploadedImageAsset {
width: number; width: number;
} }
interface MuhInvoiceRow {
amount: string;
label: string;
labelPlacement?: "default" | "amount-side";
strong?: boolean;
}
interface InvoiceTemplateResponse { interface InvoiceTemplateResponse {
elements: unknown; elements: unknown;
stored: boolean; stored: boolean;
@@ -297,8 +316,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{ {
id: "issuer-name", id: "issuer-name",
category: "issuer-data", category: "issuer-data",
label: "Aussteller-Name", label: "Rechnungssteller-Name",
description: "Name des Rechnungsausstellers", description: "Name des Rechnungsstellers",
width: 280, width: 280,
fontSize: 16, fontSize: 16,
fontWeight: 700, fontWeight: 700,
@@ -308,8 +327,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{ {
id: "issuer-street", id: "issuer-street",
category: "issuer-data", category: "issuer-data",
label: "Aussteller-Straße", label: "Rechnungssteller-Straße",
description: "Straße des Rechnungsausstellers", description: "Straße des Rechnungsstellers",
width: 200, width: 200,
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
@@ -319,8 +338,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{ {
id: "issuer-house-number", id: "issuer-house-number",
category: "issuer-data", category: "issuer-data",
label: "Aussteller-Hausnummer", label: "Rechnungssteller-Hausnummer",
description: "Hausnummer des Rechnungsausstellers", description: "Hausnummer des Rechnungsstellers",
width: 80, width: 80,
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
@@ -330,8 +349,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{ {
id: "issuer-postal-code", id: "issuer-postal-code",
category: "issuer-data", category: "issuer-data",
label: "Aussteller-PLZ", label: "Rechnungssteller-PLZ",
description: "Postleitzahl des Rechnungsausstellers", description: "Postleitzahl des Rechnungsstellers",
width: 80, width: 80,
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
@@ -341,8 +360,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{ {
id: "issuer-city", id: "issuer-city",
category: "issuer-data", category: "issuer-data",
label: "Aussteller-Ort", label: "Rechnungssteller-Ort",
description: "Ort des Rechnungsausstellers", description: "Ort des Rechnungsstellers",
width: 200, width: 200,
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
@@ -352,8 +371,8 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{ {
id: "issuer-contact", id: "issuer-contact",
category: "issuer-data", category: "issuer-data",
label: "Aussteller-Kontakt", label: "Rechnungssteller-Kontakt",
description: "Kontaktdaten des Rechnungsausstellers", description: "Kontaktdaten des Rechnungsstellers",
width: 280, width: 280,
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
@@ -366,12 +385,11 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
category: "invoice-items", category: "invoice-items",
label: "Rechnungspositionen MUH", label: "Rechnungspositionen MUH",
description: "Monatliche Systemgebühr mit Preis aus Preistabelle", description: "Monatliche Systemgebühr mit Preis aus Preistabelle",
width: 500, width: 646,
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
textAlign: "left", textAlign: "left",
defaultContent: () => defaultContent: () => createMuhInvoiceContent(null, 646),
"Monatliche Systemgebühr MUH 0,00 €\nzzgl. Umsatzsteuer (19%) 0,00 €\nGesamtsumme 0,00 €",
}, },
{ {
id: "payment-terms", id: "payment-terms",
@@ -393,8 +411,22 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
fontSize: 14, fontSize: 14,
fontWeight: 400, fontWeight: 400,
textAlign: "left", textAlign: "left",
defaultContent: () => defaultContent: (user) => {
"Bankverbindung:\nIBAN: DE12 3456 7890 1234 5678 90\nBIC: ABCDEFGHXXX\nBank: Musterbank", 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", id: "free-text",
@@ -439,7 +471,7 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
const INVOICE_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [ const INVOICE_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [
{ category: "invoice-header", title: "Rechnungskopf" }, { category: "invoice-header", title: "Rechnungskopf" },
{ category: "customer-data", title: "Kundendaten" }, { category: "customer-data", title: "Kundendaten" },
{ category: "issuer-data", title: "Aussteller" }, { category: "issuer-data", title: "Rechnungssteller" },
{ category: "invoice-items", title: "Positionen" }, { category: "invoice-items", title: "Positionen" },
{ category: "invoice-footer", title: "Fußbereich" }, { category: "invoice-footer", title: "Fußbereich" },
{ category: "free-elements", title: "Freie Elemente" }, { category: "free-elements", title: "Freie Elemente" },
@@ -558,6 +590,7 @@ function getDefaultElementHeight(
kind: ElementKind, kind: ElementKind,
fontSize: number, fontSize: number,
lineOrientation?: LineOrientation, lineOrientation?: LineOrientation,
paletteId?: string,
) { ) {
if (kind === "line") { if (kind === "line") {
return lineOrientation === "vertical" ? 180 : 3; return lineOrientation === "vertical" ? 180 : 3;
@@ -565,6 +598,9 @@ function getDefaultElementHeight(
if (kind === "image") { if (kind === "image") {
return 120; return 120;
} }
if (isMuhInvoicePaletteId(paletteId)) {
return Math.max(MUH_INVOICE_DEFAULT_HEIGHT, fontSize * 10);
}
return Math.max(fontSize + 16, 28); return Math.max(fontSize + 16, 28);
} }
@@ -582,6 +618,7 @@ function getMinimumHeightForElement(
kind: ElementKind, kind: ElementKind,
fontSize: number, fontSize: number,
lineOrientation?: LineOrientation, lineOrientation?: LineOrientation,
paletteId?: string,
) { ) {
if (kind === "line") { if (kind === "line") {
return lineOrientation === "vertical" ? MIN_LINE_LENGTH : MIN_LINE_THICKNESS; return lineOrientation === "vertical" ? MIN_LINE_LENGTH : MIN_LINE_THICKNESS;
@@ -589,20 +626,57 @@ function getMinimumHeightForElement(
if (kind === "image") { if (kind === "image") {
return MIN_MEDIA_HEIGHT; 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( function getElementHeight(
element: Pick<TemplateElement, "fontSize" | "height" | "kind" | "lineOrientation">, element: Pick<TemplateElement, "fontSize" | "height" | "kind" | "lineOrientation" | "paletteId">,
) { ) {
return Math.max( return Math.max(
1, 1,
Math.round( 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<TemplateElement, "fontSize" | "height" | "kind" | "lineOrientation" | "paletteId" | "width">,
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( function fitContainedSize(
containerWidth: number, containerWidth: number,
containerHeight: number, containerHeight: number,
@@ -690,9 +764,158 @@ endstream`,
}, []); }, []);
} }
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( function createPdfContentStream(
elements: TemplateElement[], elements: TemplateElement[],
imageResources: Map<string, PdfImageResource>, imageResources: Map<string, PdfImageResource>,
monthlyPrice: number | null,
) { ) {
const commands = ["0 g"]; const commands = ["0 g"];
@@ -738,6 +961,11 @@ function createPdfContentStream(
continue; continue;
} }
if (element.paletteId === "invoice-items-muh") {
appendMuhInvoicePdfCommands(commands, element, monthlyPrice);
continue;
}
const fontName = element.fontWeight >= 600 ? "/F2" : "/F1"; const fontName = element.fontWeight >= 600 ? "/F2" : "/F1";
const fontSize = element.fontSize * PDF_SCALE; const fontSize = element.fontSize * PDF_SCALE;
const lineHeight = element.fontSize * PDF_LINE_HEIGHT; const lineHeight = element.fontSize * PDF_LINE_HEIGHT;
@@ -778,10 +1006,10 @@ function createPdfContentStream(
return commands.join("\n"); return commands.join("\n");
} }
function createPdfBlob(elements: TemplateElement[]) { function createPdfBlob(elements: TemplateElement[], monthlyPrice: number | null) {
const imageResources = createPdfImageResources(elements); const imageResources = createPdfImageResources(elements);
const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource])); 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 imageObjectNumberStart = 6;
const contentObjectNumber = imageObjectNumberStart + imageResources.length; const contentObjectNumber = imageObjectNumberStart + imageResources.length;
const xObjectResources = imageResources.length const xObjectResources = imageResources.length
@@ -847,30 +1075,48 @@ function normalizeElement(element: TemplateElement): TemplateElement {
const kind = normalizeElementKind(element.kind); const kind = normalizeElementKind(element.kind);
const lineOrientation = kind === "line" ? normalizeLineOrientation(element.lineOrientation) : undefined; const lineOrientation = kind === "line" ? normalizeLineOrientation(element.lineOrientation) : undefined;
const fontSize = clamp(Math.round(element.fontSize), MIN_FONT_SIZE, MAX_FONT_SIZE); 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 width = Math.round(element.width);
let height = getElementHeight({ let height = Math.max(
fontSize, 1,
height: element.height, Math.round(
kind, keepHeight
lineOrientation, ? element.height ??
}); getDefaultElementHeight(kind, fontSize, lineOrientation, element.paletteId)
: getDefaultElementHeight(kind, fontSize, lineOrientation, element.paletteId),
),
);
if (kind === "line") { if (kind === "line") {
width = clamp(width, getMinimumWidthForElement(kind, lineOrientation), CANVAS_WIDTH); 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") { } else if (kind === "image") {
width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); 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 { } else {
width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32); 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 x = clamp(snapToGrid(element.x), 0, CANVAS_WIDTH - width);
const y = clamp( const y = clamp(
snapToGrid(element.y), snapToGrid(element.y),
0, 0,
kind === "text" ? CANVAS_HEIGHT - 40 : CANVAS_HEIGHT - height, kind === "text" && !keepHeight ? CANVAS_HEIGHT - 40 : CANVAS_HEIGHT - height,
); );
const imageNaturalWidth = const imageNaturalWidth =
kind === "image" && typeof element.imageNaturalWidth === "number" && element.imageNaturalWidth > 0 kind === "image" && typeof element.imageNaturalWidth === "number" && element.imageNaturalWidth > 0
@@ -885,7 +1131,7 @@ function normalizeElement(element: TemplateElement): TemplateElement {
...element, ...element,
kind, kind,
width, width,
height: kind === "text" ? undefined : height, height: kind === "text" && !keepHeight ? undefined : height,
fontSize, fontSize,
x, x,
y, y,
@@ -917,6 +1163,9 @@ function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null
if (paletteItem.kind === "line") { if (paletteItem.kind === "line") {
return "Linie"; return "Linie";
} }
if (paletteItem.id === "invoice-items-muh") {
return "Rechnungspositionen";
}
return paletteItem.defaultContent(user); return paletteItem.defaultContent(user);
} }
@@ -957,16 +1206,77 @@ function formatPrice(price: number | null): string {
return price.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 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 netto = monthlyPrice || 0;
const ust = netto * 0.19; const ust = netto * 0.19;
const brutto = netto + ust; const brutto = netto + ust;
const nettoStr = formatPrice(netto).padStart(10, " "); return [
const ustStr = formatPrice(ust).padStart(10, " "); { label: "Bezeichnung", amount: "Betrag", strong: true },
const bruttoStr = formatPrice(brutto).padStart(10, " "); { 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 },
];
}
return `Monatliche Systemgebühr MUH ${nettoStr}\nzzgl. Umsatzsteuer (19%) ${ustStr}\nGesamtsumme ${bruttoStr}`; 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<TemplateElement, "fontSize" | "fontWeight" | "textAlign">;
monthlyPrice: number | null;
}) {
const rows = getMuhInvoiceRows(monthlyPrice);
const strongWeight = getMuhInvoiceStrongWeight(element.fontWeight);
const renderRow = (row: MuhInvoiceRow, className?: string) => (
<span
key={`${row.label}-${row.amount}`}
className={`invoice-template__muh-items-row ${row.labelPlacement === "amount-side" ? "invoice-template__muh-items-row--amount-side" : ""}${className ? ` ${className}` : ""}`}
>
<span
className="invoice-template__muh-items-label"
style={{
fontWeight: row.strong ? strongWeight : element.fontWeight,
textAlign: element.textAlign,
}}
>
{row.label}
</span>
<span
className="invoice-template__muh-items-amount"
style={{ fontWeight: row.strong ? strongWeight : element.fontWeight }}
>
{row.amount}
</span>
</span>
);
return (
<span className="invoice-template__muh-items" style={{ fontSize: `${element.fontSize}px` }}>
{renderRow(rows[0], "invoice-template__muh-items-row--header")}
<span aria-hidden="true" className="invoice-template__muh-items-separator" />
{renderRow(rows[1], "invoice-template__muh-items-row--primary")}
<span aria-hidden="true" className="invoice-template__muh-items-spacer" />
{renderRow(rows[2], "invoice-template__muh-items-row--secondary")}
<span aria-hidden="true" className="invoice-template__muh-items-separator" />
{renderRow(rows[3], "invoice-template__muh-items-row--total")}
</span>
);
} }
function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[], monthlyPrice: number | null = null) { function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[], monthlyPrice: number | null = null) {
@@ -995,10 +1305,14 @@ function createInvoiceStarterLayout(user: UserOption | null, paletteItems: Palet
createElementFromPalette(requirePaletteItem(paletteItems, "customer-city"), user, { x: 140, y: 270 }), createElementFromPalette(requirePaletteItem(paletteItems, "customer-city"), user, { x: 140, y: 270 }),
createElementFromPalette(requirePaletteItem(paletteItems, "customer-number"), user, { x: 56, y: 290 }), createElementFromPalette(requirePaletteItem(paletteItems, "customer-number"), user, { x: 56, y: 290 }),
horizontalLine(56, 330, 646), horizontalLine(56, 330, 646),
normalizeElement({ (() => {
...createElementFromPalette(requirePaletteItem(paletteItems, "invoice-items-muh"), user, { x: 56, y: 350 }), const paletteItem = requirePaletteItem(paletteItems, "invoice-items-muh");
content: createMuhInvoiceContent(monthlyPrice), const element = createElementFromPalette(paletteItem, user, { x: 56, y: 350 });
}), return normalizeElement({
...element,
content: createMuhInvoiceContent(monthlyPrice, element.width),
});
})(),
horizontalLine(56, 560, 646), horizontalLine(56, 560, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 420 }), createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 420 }),
createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 460 }), createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 460 }),
@@ -1179,6 +1493,7 @@ export default function InvoiceTemplatePage() {
const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true); const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true);
const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null); const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null);
const [pageViewportHeight, setPageViewportHeight] = useState<number | null>(null); const [pageViewportHeight, setPageViewportHeight] = useState<number | null>(null);
const [monthlyPrice, setMonthlyPrice] = useState<number | null>(null);
const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({ const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({
height: CANVAS_HEIGHT, height: CANVAS_HEIGHT,
scale: 1, scale: 1,
@@ -1188,6 +1503,9 @@ export default function InvoiceTemplatePage() {
const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null; const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null;
const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null; const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null;
const selectedElementSupportsHeight = selectedElement
? supportsCustomElementHeight(selectedElement.kind, selectedElement.paletteId)
: false;
const selectedElementContentEditable = selectedElement const selectedElementContentEditable = selectedElement
? isElementContentEditable(selectedElement, INVOICE_LOCKED_TEXT_PALETTE_IDS) ? isElementContentEditable(selectedElement, INVOICE_LOCKED_TEXT_PALETTE_IDS)
: false; : false;
@@ -1199,6 +1517,7 @@ export default function InvoiceTemplatePage() {
selectedElement.kind, selectedElement.kind,
selectedElement.fontSize, selectedElement.fontSize,
selectedElement.lineOrientation, selectedElement.lineOrientation,
selectedElement.paletteId,
) )
: MIN_MEDIA_HEIGHT; : MIN_MEDIA_HEIGHT;
const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt); const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt);
@@ -1232,6 +1551,7 @@ export default function InvoiceTemplatePage() {
} }
const monthlyPrice = pricing.monthlyPrice; const monthlyPrice = pricing.monthlyPrice;
setMonthlyPrice(monthlyPrice);
setIsTemplateApiAvailable(true); setIsTemplateApiAvailable(true);
setTemplateUpdatedAt(response.updatedAt); setTemplateUpdatedAt(response.updatedAt);
@@ -1278,9 +1598,14 @@ export default function InvoiceTemplatePage() {
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
if (selectedElementId && elements.some((entry) => entry.id === selectedElementId)) { if (selectedElementId === null) {
return; return;
} }
if (elements.some((entry) => entry.id === selectedElementId)) {
return;
}
setSelectedElementId(elements[0]?.id ?? null); setSelectedElementId(elements[0]?.id ?? null);
}, [elements, selectedElementId]); }, [elements, selectedElementId]);
@@ -1290,6 +1615,30 @@ export default function InvoiceTemplatePage() {
}; };
}, []); }, []);
useEffect(() => {
setElements((current) => {
let hasChanges = false;
const nextElements = current.map((entry) => {
if (entry.paletteId !== "invoice-items-muh") {
return entry;
}
const nextContent = createMuhInvoiceContent(monthlyPrice, entry.width);
if (entry.content === nextContent) {
return entry;
}
hasChanges = true;
return normalizeElement({
...entry,
content: nextContent,
});
});
return hasChanges ? nextElements : current;
});
}, [monthlyPrice]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (pdfPreviewUrl) { if (pdfPreviewUrl) {
@@ -1416,28 +1765,62 @@ export default function InvoiceTemplatePage() {
setIsCanvasActive(false); setIsCanvasActive(false);
} }
function updateElement(elementId: string, patch: Partial<TemplateElement>) { const updateElement = useCallback((elementId: string, patch: Partial<TemplateElement>) => {
setElements((current) => setElements((current) =>
current.map((entry) => current.map((entry) => {
entry.id === elementId if (entry.id !== elementId) {
? normalizeElement({ return entry;
...entry,
...(!isElementContentEditable(entry, INVOICE_LOCKED_TEXT_PALETTE_IDS) &&
Object.prototype.hasOwnProperty.call(patch, "content")
? { ...patch, content: entry.content }
: patch),
})
: entry,
),
);
} }
function removeElement(elementId: string) { // Check if this is the invoice-items-muh element and width changed
const isInvoiceItems = entry.paletteId === "invoice-items-muh";
const widthChanged = Object.prototype.hasOwnProperty.call(patch, "width");
let updatedPatch = patch;
// Preserve content for locked text elements
if (!isElementContentEditable(entry, INVOICE_LOCKED_TEXT_PALETTE_IDS) &&
Object.prototype.hasOwnProperty.call(patch, "content")) {
updatedPatch = { ...patch, content: entry.content };
}
// Update content for invoice-items-muh when width changes
if (isInvoiceItems && widthChanged) {
const newWidth = (patch as { width: number }).width;
updatedPatch = {
...updatedPatch,
content: createMuhInvoiceContent(monthlyPrice, newWidth),
};
}
return normalizeElement({
...entry,
...updatedPatch,
});
}),
);
}, [monthlyPrice]);
const removeElement = useCallback((elementId: string) => {
mouseDragCleanupRef.current?.(); mouseDragCleanupRef.current?.();
setElements((current) => current.filter((entry) => entry.id !== elementId)); setElements((current) => current.filter((entry) => entry.id !== elementId));
setSelectedElementId((current) => (current === elementId ? null : current)); setSelectedElementId((current) => (current === elementId ? null : current));
setResizingElementId((current) => (current === elementId ? null : current)); setResizingElementId((current) => (current === elementId ? null : current));
}, []);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Delete" && selectedElementId) {
event.preventDefault();
removeElement(selectedElementId);
} }
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [selectedElementId, removeElement]);
function placePaletteElement(paletteId: string, clientX: number, clientY: number) { function placePaletteElement(paletteId: string, clientX: number, clientY: number) {
const paletteItem = findPaletteItem(INVOICE_PALETTE_ITEMS, paletteId); const paletteItem = findPaletteItem(INVOICE_PALETTE_ITEMS, paletteId);
@@ -1451,6 +1834,7 @@ export default function InvoiceTemplatePage() {
paletteItem.kind ?? "text", paletteItem.kind ?? "text",
paletteItem.fontSize, paletteItem.fontSize,
paletteItem.lineOrientation, paletteItem.lineOrientation,
paletteItem.id,
); );
const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2; const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2;
const y = (clientY - rect.top) / canvasViewport.scale - previewHeight / 2; const y = (clientY - rect.top) / canvasViewport.scale - previewHeight / 2;
@@ -1487,6 +1871,20 @@ export default function InvoiceTemplatePage() {
setIsCanvasActive(true); setIsCanvasActive(true);
} }
function handleCanvasBackgroundMouseDown(event: ReactMouseEvent<HTMLDivElement>) {
if (event.target !== event.currentTarget) {
return;
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
setSelectedElementId(null);
setDraggingElementId(null);
setResizingElementId(null);
}
function handleCanvasDragLeave(event: DragEvent<HTMLDivElement>) { function handleCanvasDragLeave(event: DragEvent<HTMLDivElement>) {
event.preventDefault(); event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
@@ -1539,7 +1937,7 @@ export default function InvoiceTemplatePage() {
function handleCreatePdfPreview() { function handleCreatePdfPreview() {
try { try {
const pdfBlob = createPdfBlob(elements); const pdfBlob = createPdfBlob(elements, monthlyPrice);
const nextPreviewUrl = URL.createObjectURL(pdfBlob); const nextPreviewUrl = URL.createObjectURL(pdfBlob);
setPdfPreviewUrl(nextPreviewUrl); setPdfPreviewUrl(nextPreviewUrl);
setPdfError(null); setPdfError(null);
@@ -1688,6 +2086,7 @@ export default function InvoiceTemplatePage() {
const startWidth = currentElement.width; const startWidth = currentElement.width;
const startHeight = getElementHeight(currentElement); const startHeight = getElementHeight(currentElement);
const elementKind = currentElement.kind; const elementKind = currentElement.kind;
const elementPaletteId = currentElement.paletteId;
const elementFontSize = currentElement.fontSize; const elementFontSize = currentElement.fontSize;
const elementLineOrientation = currentElement.lineOrientation; const elementLineOrientation = currentElement.lineOrientation;
const imageNaturalWidth = currentElement.imageNaturalWidth; const imageNaturalWidth = currentElement.imageNaturalWidth;
@@ -1707,6 +2106,7 @@ export default function InvoiceTemplatePage() {
elementKind, elementKind,
elementFontSize, elementFontSize,
elementLineOrientation, elementLineOrientation,
elementPaletteId,
), ),
snapToGrid(startHeight + deltaY), snapToGrid(startHeight + deltaY),
); );
@@ -1720,7 +2120,7 @@ export default function InvoiceTemplatePage() {
if (widthScale <= heightScale) { if (widthScale <= heightScale) {
updateElement(elementId, { updateElement(elementId, {
height: Math.max( height: Math.max(
getMinimumHeightForElement(elementKind, elementFontSize), getMinimumHeightForElement(elementKind, elementFontSize, undefined, elementPaletteId),
Math.floor(((nextWidth * assetHeight) / assetWidth) / GRID_SIZE) * GRID_SIZE, Math.floor(((nextWidth * assetHeight) / assetWidth) / GRID_SIZE) * GRID_SIZE,
), ),
width: nextWidth, width: nextWidth,
@@ -1740,7 +2140,9 @@ export default function InvoiceTemplatePage() {
updateElement(elementId, { updateElement(elementId, {
width: nextWidth, width: nextWidth,
...(elementKind === "text" ? {} : { height: nextHeight }), ...(elementKind === "text" && !supportsCustomElementHeight(elementKind, elementPaletteId)
? {}
: { height: nextHeight }),
}); });
} }
@@ -1908,13 +2310,17 @@ export default function InvoiceTemplatePage() {
ref={canvasRef} ref={canvasRef}
className={`invoice-template__canvas ${isCanvasActive ? "is-active" : ""}`} className={`invoice-template__canvas ${isCanvasActive ? "is-active" : ""}`}
style={{ transform: `scale(${canvasViewport.scale})` }} style={{ transform: `scale(${canvasViewport.scale})` }}
onMouseDown={handleCanvasBackgroundMouseDown}
onDragEnter={handleCanvasDragEnter} onDragEnter={handleCanvasDragEnter}
onDragOver={handleCanvasDragOver} onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave} onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop} onDrop={handleCanvasDrop}
> >
{elements.length ? ( {elements.length ? (
elements.map((element) => ( elements.map((element) => {
const isMuhInvoiceElement = element.paletteId === "invoice-items-muh";
return (
<button <button
key={element.id} key={element.id}
type="button" type="button"
@@ -1925,6 +2331,9 @@ export default function InvoiceTemplatePage() {
width: `${element.width}px`, width: `${element.width}px`,
...(element.kind === "text" ...(element.kind === "text"
? { ? {
...(supportsCustomElementHeight(element.kind, element.paletteId)
? { height: `${getElementHeight(element)}px` }
: {}),
fontSize: `${element.fontSize}px`, fontSize: `${element.fontSize}px`,
fontWeight: element.fontWeight, fontWeight: element.fontWeight,
textAlign: element.textAlign, textAlign: element.textAlign,
@@ -1937,14 +2346,19 @@ export default function InvoiceTemplatePage() {
onFocus={() => setSelectedElementId(element.id)} onFocus={() => setSelectedElementId(element.id)}
> >
{element.kind === "text" ? ( {element.kind === "text" ? (
isMuhInvoiceElement ? (
<MuhInvoiceCanvasPreview element={element} monthlyPrice={monthlyPrice} />
) : (
<span className="invoice-template__canvas-element-text"> <span className="invoice-template__canvas-element-text">
{element.content || "Text eingeben"} {element.content || "Text eingeben"}
</span> </span>
)
) : null} ) : null}
{element.kind === "line" ? ( {element.kind === "line" ? (
<span <span
aria-hidden="true" aria-hidden="true"
className={`invoice-template__canvas-line invoice-template__canvas-line--${element.lineOrientation ?? "horizontal"}`} className={`invoice-template__canvas-line invoice-template__canvas-line--${element.lineOrientation ?? "horizontal"}`}
style={getCanvasLineStyle(element, canvasViewport.scale)}
/> />
) : null} ) : null}
{element.kind === "image" ? ( {element.kind === "image" ? (
@@ -1968,7 +2382,8 @@ export default function InvoiceTemplatePage() {
/> />
) : null} ) : null}
</button> </button>
)) );
})
) : ( ) : (
<div className="invoice-template__canvas-empty">{emptyCanvasMessage}</div> <div className="invoice-template__canvas-empty">{emptyCanvasMessage}</div>
)} )}
@@ -1989,16 +2404,6 @@ export default function InvoiceTemplatePage() {
{selectedElement ? ( {selectedElement ? (
<div className="invoice-template__inspector"> <div className="invoice-template__inspector">
<label className="field">
<span>Elementname</span>
<input
value={selectedElement.label}
onChange={(event) =>
updateElement(selectedElement.id, { label: event.target.value })
}
/>
</label>
{selectedElement.kind === "text" ? ( {selectedElement.kind === "text" ? (
<label className="field"> <label className="field">
<span>Inhalt</span> <span>Inhalt</span>
@@ -2015,7 +2420,11 @@ export default function InvoiceTemplatePage() {
} }
/> />
{!selectedElementContentEditable ? ( {!selectedElementContentEditable ? (
<small>Dieser Inhalt ist fuer dieses Element gesperrt.</small> <small>
{selectedElement.paletteId === "invoice-items-muh"
? "Dieser Block wird automatisch aus dem aktuellen Monatspreis erzeugt."
: "Dieser Inhalt ist fuer dieses Element gesperrt."}
</small>
) : null} ) : null}
</label> </label>
) : null} ) : null}
@@ -2081,6 +2490,7 @@ export default function InvoiceTemplatePage() {
</label> </label>
{selectedElement.kind === "text" ? ( {selectedElement.kind === "text" ? (
<>
<label className="field"> <label className="field">
<span>Schriftgroesse</span> <span>Schriftgroesse</span>
<input <input
@@ -2093,6 +2503,22 @@ export default function InvoiceTemplatePage() {
} }
/> />
</label> </label>
{selectedElementSupportsHeight ? (
<label className="field">
<span>Hoehe</span>
<input
type="number"
value={selectedElementHeight ?? 0}
min={selectedElementMinimumHeight}
max={CANVAS_HEIGHT}
onChange={(event) =>
updateElement(selectedElement.id, { height: Number(event.target.value) })
}
/>
</label>
) : null}
</>
) : ( ) : (
<label className="field"> <label className="field">
<span>Hoehe</span> <span>Hoehe</span>

View File

@@ -874,7 +874,7 @@ a {
.invoice-template__palette-grid { .invoice-template__palette-grid {
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: 1fr;
} }
.invoice-template__tile { .invoice-template__tile {
@@ -1021,10 +1021,71 @@ a {
word-break: break-word; word-break: break-word;
} }
.invoice-template__canvas-line { .invoice-template__muh-items {
display: block; display: grid;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0;
grid-template-rows: auto 2px auto minmax(0, 1fr) auto 2px auto;
gap: 10px;
line-height: 1.35;
}
.invoice-template__muh-items-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 18px;
}
.invoice-template__muh-items-row--amount-side {
grid-template-columns: minmax(0, 1fr) fit-content(240px) auto;
}
.invoice-template__muh-items-row--amount-side .invoice-template__muh-items-label {
grid-column: 2;
justify-self: start;
}
.invoice-template__muh-items-row--amount-side .invoice-template__muh-items-amount {
grid-column: 3;
}
.invoice-template__muh-items-row--total {
align-self: end;
}
.invoice-template__muh-items-label {
display: block;
min-width: 0;
white-space: normal;
word-break: break-word;
}
.invoice-template__muh-items-amount {
display: block;
justify-self: end;
white-space: nowrap;
text-align: right;
}
.invoice-template__muh-items-separator {
display: block;
width: 100%;
height: 2px;
border-radius: 999px;
background: rgba(37, 49, 58, 0.82);
pointer-events: none;
}
.invoice-template__muh-items-spacer {
display: block;
min-height: 0;
}
.invoice-template__canvas-line {
position: absolute;
display: block;
border-radius: 999px; border-radius: 999px;
background: rgba(37, 49, 58, 0.82); background: rgba(37, 49, 58, 0.82);
pointer-events: none; pointer-events: none;