From 49b1a3b3638d79610c95c0947a7b76345f0308f6 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Tue, 17 Mar 2026 21:18:27 +0100 Subject: [PATCH] feat: Add Preistabelle for admin to manage monthly system price - Add SystemPricing domain model to store monthly price in MongoDB - Add SystemPricingRepository for database access - Add SystemPricingService with get/save functionality - Add SystemPricingController with GET/POST endpoints (admin only) - Add PricingPage component for frontend - Add navigation menu item for Preistabelle (above Rechnung) - Add route /admin/preistabelle for the new page --- .../muh/domain/SystemPricing.java | 15 ++ .../repository/SystemPricingRepository.java | 7 + .../muh/service/SystemPricingService.java | 38 +++++ .../muh/web/SystemPricingController.java | 39 +++++ frontend/src/App.tsx | 2 + frontend/src/layout/AppShell.tsx | 8 + frontend/src/pages/PricingPage.tsx | 160 ++++++++++++++++++ 7 files changed, 269 insertions(+) create mode 100644 backend/src/main/java/de/svencarstensen/muh/domain/SystemPricing.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/repository/SystemPricingRepository.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/service/SystemPricingService.java create mode 100644 backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java create mode 100644 frontend/src/pages/PricingPage.tsx diff --git a/backend/src/main/java/de/svencarstensen/muh/domain/SystemPricing.java b/backend/src/main/java/de/svencarstensen/muh/domain/SystemPricing.java new file mode 100644 index 0000000..89b6125 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/domain/SystemPricing.java @@ -0,0 +1,15 @@ +package de.svencarstensen.muh.domain; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document("systemPricing") +public record SystemPricing( + @Id String id, + Double monthlyPrice, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/repository/SystemPricingRepository.java b/backend/src/main/java/de/svencarstensen/muh/repository/SystemPricingRepository.java new file mode 100644 index 0000000..ec550e5 --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/repository/SystemPricingRepository.java @@ -0,0 +1,7 @@ +package de.svencarstensen.muh.repository; + +import de.svencarstensen.muh.domain.SystemPricing; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface SystemPricingRepository extends MongoRepository { +} diff --git a/backend/src/main/java/de/svencarstensen/muh/service/SystemPricingService.java b/backend/src/main/java/de/svencarstensen/muh/service/SystemPricingService.java new file mode 100644 index 0000000..11e034f --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/service/SystemPricingService.java @@ -0,0 +1,38 @@ +package de.svencarstensen.muh.service; + +import de.svencarstensen.muh.domain.SystemPricing; +import de.svencarstensen.muh.repository.SystemPricingRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +public class SystemPricingService { + + private static final String PRICING_ID = "default"; + + private final SystemPricingRepository systemPricingRepository; + + public SystemPricingService(SystemPricingRepository systemPricingRepository) { + this.systemPricingRepository = systemPricingRepository; + } + + public Optional getCurrentPricing() { + return systemPricingRepository.findById(PRICING_ID); + } + + public SystemPricing savePricing(Double monthlyPrice) { + LocalDateTime now = LocalDateTime.now(); + Optional existing = systemPricingRepository.findById(PRICING_ID); + + SystemPricing pricing = new SystemPricing( + PRICING_ID, + monthlyPrice, + existing.map(SystemPricing::createdAt).orElse(now), + now + ); + + return systemPricingRepository.save(pricing); + } +} diff --git a/backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java b/backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java new file mode 100644 index 0000000..573f8ed --- /dev/null +++ b/backend/src/main/java/de/svencarstensen/muh/web/SystemPricingController.java @@ -0,0 +1,39 @@ +package de.svencarstensen.muh.web; + +import de.svencarstensen.muh.domain.SystemPricing; +import de.svencarstensen.muh.service.SystemPricingService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestController +@RequestMapping("/api/admin/pricing") +@PreAuthorize("hasRole('ADMIN')") +public class SystemPricingController { + + private final SystemPricingService systemPricingService; + + public SystemPricingController(SystemPricingService systemPricingService) { + this.systemPricingService = systemPricingService; + } + + @GetMapping + public PricingResponse getPricing() { + Optional pricing = systemPricingService.getCurrentPricing(); + return pricing.map(p -> new PricingResponse(p.monthlyPrice(), p.updatedAt().toString())) + .orElseGet(() -> new PricingResponse(null, null)); + } + + @PostMapping + public PricingResponse savePricing(@RequestBody PricingRequest request) { + SystemPricing saved = systemPricingService.savePricing(request.monthlyPrice()); + return new PricingResponse(saved.monthlyPrice(), saved.updatedAt().toString()); + } + + public record PricingRequest(Double monthlyPrice) { + } + + public record PricingResponse(Double monthlyPrice, String updatedAt) { + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ffd95c..cfc2f77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,6 +17,7 @@ import UserManagementPage from "./pages/UserManagementPage"; import ReportTemplatePage from "./pages/ReportTemplatePage"; import InvoiceTemplatePage from "./pages/InvoiceTemplatePage"; import InvoiceManagementPage from "./pages/InvoiceManagementPage"; +import PricingPage from "./pages/PricingPage"; function ProtectedRoutes() { const { user, ready } = useSession(); @@ -47,6 +48,7 @@ function ProtectedRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/layout/AppShell.tsx b/frontend/src/layout/AppShell.tsx index 6b1bfbd..88d447b 100644 --- a/frontend/src/layout/AppShell.tsx +++ b/frontend/src/layout/AppShell.tsx @@ -8,6 +8,7 @@ const PAGE_TITLES: Record = { "/samples/new": "Neuanlage einer Probe", "/portal": "MUH-Portal", "/report-template": "Bericht", + "/admin/preistabelle": "Preistabelle", "/admin/rechnung/verwalten": "Rechnungsverwaltung", "/admin/rechnung/template": "Rechnungsvorlage", }; @@ -64,6 +65,13 @@ export default function AppShell() { + `nav-link ${isActive ? "is-active" : ""}`} + > + Preistabelle + +
Rechnung
diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx new file mode 100644 index 0000000..ded5d61 --- /dev/null +++ b/frontend/src/pages/PricingPage.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from "react"; +import { apiGet, apiPost } from "../lib/api"; + +interface PricingResponse { + monthlyPrice: number | null; + updatedAt: string | null; +} + +export default function PricingPage() { + const [monthlyPrice, setMonthlyPrice] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + + useEffect(() => { + async function loadPricing() { + try { + const response = await apiGet("/admin/pricing"); + if (response.monthlyPrice !== null) { + setMonthlyPrice(response.monthlyPrice.toString()); + } + setLastUpdated(response.updatedAt); + } catch (error) { + setMessage((error as Error).message); + } finally { + setLoading(false); + } + } + + void loadPricing(); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const priceValue = parseFloat(monthlyPrice.replace(",", ".")); + if (isNaN(priceValue) || priceValue < 0) { + setMessage("Bitte geben Sie einen gültigen Preis ein."); + return; + } + + setSaving(true); + setMessage(null); + + try { + const response = await apiPost("/admin/pricing", { + monthlyPrice: priceValue, + }); + setMonthlyPrice(response.monthlyPrice?.toString() ?? ""); + setLastUpdated(response.updatedAt); + setMessage("Preis wurde erfolgreich gespeichert."); + setTimeout(() => setMessage(null), 3000); + } catch (error) { + setMessage((error as Error).message); + } finally { + setSaving(false); + } + } + + function formatDate(value: string | null): string { + if (!value) return "-"; + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); + } + + if (loading) { + return ( +
+
+
Preistabelle wird geladen...
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Preistabelle

+

Monatlichen Systempreis festlegen

+

+ Legen Sie hier den monatlichen Preis für die Nutzung des Systems fest. + Dieser Preis wird für die Rechnungsstellung verwendet. +

+
+
+ + {/* Status-Meldung */} + {message && ( +
+ {message} +
+ )} + + {/* Preis-Formular */} +
+
+
+

Systempreis

+

Monatlicher Preis

+
+
+ +
+ + +
+ +
+
+ + {lastUpdated && ( +
+ Zuletzt aktualisiert: {formatDate(lastUpdated)} +
+ )} +
+ + {/* Info-Box */} +
+
+ Hinweis +

+ Der hier festgelegte monatliche Preis wird als Grundlage für die Abrechnung + mit den Nutzern verwendet. Änderungen werden sofort wirksam und bei der + nächsten Rechnungsstellung berücksichtigt. +

+
+
+
+ ); +}