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 {
|
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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user