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
This commit is contained in:
2026-03-17 21:18:27 +01:00
parent 571019d34b
commit 49b1a3b363
7 changed files with 269 additions and 0 deletions

View File

@@ -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
) {
}

View File

@@ -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<SystemPricing, String> {
}

View File

@@ -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<SystemPricing> getCurrentPricing() {
return systemPricingRepository.findById(PRICING_ID);
}
public SystemPricing savePricing(Double monthlyPrice) {
LocalDateTime now = LocalDateTime.now();
Optional<SystemPricing> existing = systemPricingRepository.findById(PRICING_ID);
SystemPricing pricing = new SystemPricing(
PRICING_ID,
monthlyPrice,
existing.map(SystemPricing::createdAt).orElse(now),
now
);
return systemPricingRepository.save(pricing);
}
}

View File

@@ -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<SystemPricing> 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) {
}
}

View File

@@ -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() {
<Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} />
<Route path="/admin/antibiogramm" element={<AdministrationPage />} />
<Route path="/admin/preistabelle" element={<PricingPage />} />
<Route path="/admin/rechnung/verwalten" element={<InvoiceManagementPage />} />
<Route path="/admin/rechnung/template" element={<InvoiceTemplatePage />} />
<Route path="/search" element={<Navigate to="/search/probe" replace />} />

View File

@@ -8,6 +8,7 @@ const PAGE_TITLES: Record<string, string> = {
"/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() {
</div>
</div>
<NavLink
to="/admin/preistabelle"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Preistabelle
</NavLink>
<div className="nav-group">
<div className="nav-group__label">Rechnung</div>
<div className="nav-subnav">

View File

@@ -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<string>("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
useEffect(() => {
async function loadPricing() {
try {
const response = await apiGet<PricingResponse>("/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<PricingResponse>("/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 (
<div className="page-stack">
<section className="section-card">
<div className="empty-state">Preistabelle wird geladen...</div>
</section>
</div>
);
}
return (
<div className="page-stack">
{/* Header */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Preistabelle</p>
<h3>Monatlichen Systempreis festlegen</h3>
<p className="muted-text">
Legen Sie hier den monatlichen Preis für die Nutzung des Systems fest.
Dieser Preis wird für die Rechnungsstellung verwendet.
</p>
</div>
</section>
{/* Status-Meldung */}
{message && (
<div
className={
message.includes("erfolgreich")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
)}
{/* Preis-Formular */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Systempreis</p>
<h3>Monatlicher Preis</h3>
</div>
</div>
<form onSubmit={handleSubmit} className="field-grid field-grid--2col">
<label className="field">
<span>Preis pro Monat () *</span>
<input
type="text"
value={monthlyPrice}
onChange={(e) => setMonthlyPrice(e.target.value)}
placeholder="z.B. 49,99"
required
disabled={saving}
/>
</label>
<div className="field" style={{ display: "flex", alignItems: "flex-end" }}>
<button
type="submit"
className="accent-button"
disabled={saving}
style={{ width: "100%" }}
>
{saving ? "Wird gespeichert..." : "Preis speichern"}
</button>
</div>
</form>
{lastUpdated && (
<div style={{ marginTop: "1rem", fontSize: "0.875rem", color: "var(--muted-text)" }}>
<strong>Zuletzt aktualisiert:</strong> {formatDate(lastUpdated)}
</div>
)}
</section>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
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.
</p>
</div>
</section>
</div>
);
}