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

View File

@@ -874,7 +874,7 @@ a {
.invoice-template__palette-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
}
.invoice-template__tile {
@@ -1021,10 +1021,71 @@ a {
word-break: break-word;
}
.invoice-template__canvas-line {
display: block;
.invoice-template__muh-items {
display: grid;
width: 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;
background: rgba(37, 49, 58, 0.82);
pointer-events: none;