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