Rechnungstemplate: Kontoverbindung aus Stammdaten, verbesserte Tabellenformatierung, einspaltige Palette, Delete-Taste zum Löschen, UI-Optimierungen
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user