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

@@ -9,7 +9,6 @@ import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/admin/pricing") @RequestMapping("/api/admin/pricing")
@PreAuthorize("hasRole('ADMIN')")
public class SystemPricingController { public class SystemPricingController {
private final SystemPricingService systemPricingService; private final SystemPricingService systemPricingService;
@@ -19,13 +18,23 @@ public class SystemPricingController {
} }
@GetMapping @GetMapping
@PreAuthorize("hasRole('ADMIN')")
public PricingResponse getPricing() { public PricingResponse getPricing() {
Optional<SystemPricing> pricing = systemPricingService.getCurrentPricing(); Optional<SystemPricing> pricing = systemPricingService.getCurrentPricing();
return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString())) return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString()))
.orElseGet(() -> new PricingResponse(null, null)); .orElseGet(() -> new PricingResponse(null, null));
} }
@GetMapping("/current")
@PreAuthorize("isAuthenticated()")
public PricingResponse getCurrentPricing() {
Optional<SystemPricing> pricing = systemPricingService.getCurrentPricing();
return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString()))
.orElseGet(() -> new PricingResponse(null, null));
}
@PostMapping @PostMapping
@PreAuthorize("hasRole('ADMIN')")
public PricingResponse savePricing(@RequestBody PricingRequest request) { public PricingResponse savePricing(@RequestBody PricingRequest request) {
SystemPricing saved = systemPricingService.savePricing(request.monthlyPrice()); SystemPricing saved = systemPricingService.savePricing(request.monthlyPrice());
return new PricingResponse(saved.monthlyPrice(), saved.updatedAt().toString()); return new PricingResponse(saved.monthlyPrice(), saved.updatedAt().toString());

View File

@@ -361,6 +361,18 @@ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
defaultContent: (user) => defaultContent: (user) =>
`Tel.: ${user?.phoneNumber ?? ""}\nE-Mail: ${user?.email ?? ""}`, `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", id: "payment-terms",
category: "invoice-footer", category: "invoice-footer",
@@ -428,7 +440,7 @@ 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: "Aussteller" },
{ 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" },
]; ];
@@ -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) => const horizontalLine = (x: number, y: number, width: number) =>
normalizeElement({ normalizeElement({
...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }), ...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-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 }),
content: createMuhInvoiceContent(monthlyPrice),
}),
horizontalLine(56, 560, 646), horizontalLine(56, 560, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 360 }), createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 420 }),
createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 400 }), createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 460 }),
]; ];
} }
@@ -1188,16 +1221,22 @@ export default function InvoiceTemplatePage() {
setTemplateError(null); setTemplateError(null);
setIsTemplateLoading(true); setIsTemplateLoading(true);
void apiGet<InvoiceTemplateResponse>("/session/invoice-template") // Load pricing first, then load template
.then((response) => { void Promise.all([
apiGet<InvoiceTemplateResponse>("/session/invoice-template"),
apiGet<{ monthlyPrice: number | null }>("/admin/pricing/current").catch(() => ({ monthlyPrice: null })),
])
.then(([response, pricing]) => {
if (cancelled) { if (cancelled) {
return; return;
} }
const monthlyPrice = pricing.monthlyPrice;
setIsTemplateApiAvailable(true); setIsTemplateApiAvailable(true);
setTemplateUpdatedAt(response.updatedAt); setTemplateUpdatedAt(response.updatedAt);
if (!response.stored) { if (!response.stored) {
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS); const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS, monthlyPrice);
setElements(starterLayout); setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null); setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null); setResizingElementId(null);
@@ -1472,9 +1511,16 @@ export default function InvoiceTemplatePage() {
moveCanvasElement(payload.elementId, event.clientX, event.clientY); moveCanvasElement(payload.elementId, event.clientX, event.clientY);
} }
function handleResetToStarter() { async function handleResetToStarter() {
mouseDragCleanupRef.current?.(); 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); setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null); setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null); setResizingElementId(null);