feat: Add invoice items element with dynamic pricing from pricing table

- Add new 'invoice-items-muh' element to invoice template
- Add 'Positionen' category back to palette groups
- Create helper functions to format price and generate invoice content
- Add /current endpoint to SystemPricingController for authenticated users
- Load pricing when creating starter layout and display:
  - Monatliche Systemgebühr MUH with net price
  - Umsatzsteuer (19%)
  - Gesamtsumme (brutto)
- Update starter layout positioning for new element
This commit is contained in:
2026-03-18 12:02:38 +01:00
parent 3d9b807261
commit dc35995e64
2 changed files with 65 additions and 10 deletions

View File

@@ -361,6 +361,18 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
defaultContent: (user) =>
`Tel.: ${user?.phoneNumber ?? ""}\nE-Mail: ${user?.email ?? ""}`,
},
{
id: "invoice-items-muh",
category: "invoice-items",
label: "Rechnungspositionen MUH",
description: "Monatliche Systemgebühr mit Preis aus Preistabelle",
width: 500,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () =>
"Monatliche Systemgebühr MUH 0,00 €\nzzgl. Umsatzsteuer (19%) 0,00 €\nGesamtsumme 0,00 €",
},
{
id: "payment-terms",
category: "invoice-footer",
@@ -428,7 +440,7 @@ const INVOICE_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }
{ category: "invoice-header", title: "Rechnungskopf" },
{ category: "customer-data", title: "Kundendaten" },
{ category: "issuer-data", title: "Aussteller" },
{ category: "invoice-items", title: "Positionen" },
{ category: "invoice-footer", title: "Fußbereich" },
{ category: "free-elements", title: "Freie Elemente" },
];
@@ -940,7 +952,24 @@ function createElementFromPalette(
});
}
function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[]) {
function formatPrice(price: number | null): string {
if (price === null || price === undefined) return "0,00";
return price.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function createMuhInvoiceContent(monthlyPrice: number | null): string {
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 `Monatliche Systemgebühr MUH ${nettoStr}\nzzgl. Umsatzsteuer (19%) ${ustStr}\nGesamtsumme ${bruttoStr}`;
}
function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[], monthlyPrice: number | null = null) {
const horizontalLine = (x: number, y: number, width: number) =>
normalizeElement({
...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }),
@@ -966,9 +995,13 @@ 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),
}),
horizontalLine(56, 560, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 360 }),
createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 400 }),
createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 420 }),
createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 460 }),
];
}
@@ -1188,16 +1221,22 @@ export default function InvoiceTemplatePage() {
setTemplateError(null);
setIsTemplateLoading(true);
void apiGet<InvoiceTemplateResponse>("/session/invoice-template")
.then((response) => {
// Load pricing first, then load template
void Promise.all([
apiGet<InvoiceTemplateResponse>("/session/invoice-template"),
apiGet<{ monthlyPrice: number | null }>("/admin/pricing/current").catch(() => ({ monthlyPrice: null })),
])
.then(([response, pricing]) => {
if (cancelled) {
return;
}
const monthlyPrice = pricing.monthlyPrice;
setIsTemplateApiAvailable(true);
setTemplateUpdatedAt(response.updatedAt);
if (!response.stored) {
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS);
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS, monthlyPrice);
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);
@@ -1472,9 +1511,16 @@ export default function InvoiceTemplatePage() {
moveCanvasElement(payload.elementId, event.clientX, event.clientY);
}
function handleResetToStarter() {
async function handleResetToStarter() {
mouseDragCleanupRef.current?.();
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS);
let monthlyPrice: number | null = null;
try {
const pricing = await apiGet<{ monthlyPrice: number | null }>("/admin/pricing/current");
monthlyPrice = pricing.monthlyPrice;
} catch {
// Ignore error and use null
}
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS, monthlyPrice);
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);