diff --git a/backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java b/backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java index 573f8ed..e00e398 100644 --- a/backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java +++ b/backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java @@ -9,7 +9,6 @@ import java.util.Optional; @RestController @RequestMapping("/api/admin/pricing") -@PreAuthorize("hasRole('ADMIN')") public class SystemPricingController { private final SystemPricingService systemPricingService; @@ -19,13 +18,23 @@ public class SystemPricingController { } @GetMapping + @PreAuthorize("hasRole('ADMIN')") public PricingResponse getPricing() { Optional pricing = systemPricingService.getCurrentPricing(); return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString())) .orElseGet(() -> new PricingResponse(null, null)); } + @GetMapping("/current") + @PreAuthorize("isAuthenticated()") + public PricingResponse getCurrentPricing() { + Optional pricing = systemPricingService.getCurrentPricing(); + return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString())) + .orElseGet(() -> new PricingResponse(null, null)); + } + @PostMapping + @PreAuthorize("hasRole('ADMIN')") public PricingResponse savePricing(@RequestBody PricingRequest request) { SystemPricing saved = systemPricingService.savePricing(request.monthlyPrice()); return new PricingResponse(saved.monthlyPrice(), saved.updatedAt().toString()); diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx index 6c2ac72..4c72b7d 100644 --- a/frontend/src/pages/InvoiceTemplatePage.tsx +++ b/frontend/src/pages/InvoiceTemplatePage.tsx @@ -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("/session/invoice-template") - .then((response) => { + // Load pricing first, then load template + void Promise.all([ + apiGet("/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);