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:
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import UserManagementPage from "./pages/UserManagementPage";
|
|||||||
import ReportTemplatePage from "./pages/ReportTemplatePage";
|
import ReportTemplatePage from "./pages/ReportTemplatePage";
|
||||||
import InvoiceTemplatePage from "./pages/InvoiceTemplatePage";
|
import InvoiceTemplatePage from "./pages/InvoiceTemplatePage";
|
||||||
import InvoiceManagementPage from "./pages/InvoiceManagementPage";
|
import InvoiceManagementPage from "./pages/InvoiceManagementPage";
|
||||||
|
import PricingPage from "./pages/PricingPage";
|
||||||
|
|
||||||
function ProtectedRoutes() {
|
function ProtectedRoutes() {
|
||||||
const { user, ready } = useSession();
|
const { user, ready } = useSession();
|
||||||
@@ -47,6 +48,7 @@ function ProtectedRoutes() {
|
|||||||
<Route path="/admin/medikamente" element={<AdministrationPage />} />
|
<Route path="/admin/medikamente" element={<AdministrationPage />} />
|
||||||
<Route path="/admin/erreger" element={<AdministrationPage />} />
|
<Route path="/admin/erreger" element={<AdministrationPage />} />
|
||||||
<Route path="/admin/antibiogramm" 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/verwalten" element={<InvoiceManagementPage />} />
|
||||||
<Route path="/admin/rechnung/template" element={<InvoiceTemplatePage />} />
|
<Route path="/admin/rechnung/template" element={<InvoiceTemplatePage />} />
|
||||||
<Route path="/search" element={<Navigate to="/search/probe" replace />} />
|
<Route path="/search" element={<Navigate to="/search/probe" replace />} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const PAGE_TITLES: Record<string, string> = {
|
|||||||
"/samples/new": "Neuanlage einer Probe",
|
"/samples/new": "Neuanlage einer Probe",
|
||||||
"/portal": "MUH-Portal",
|
"/portal": "MUH-Portal",
|
||||||
"/report-template": "Bericht",
|
"/report-template": "Bericht",
|
||||||
|
"/admin/preistabelle": "Preistabelle",
|
||||||
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
|
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
|
||||||
"/admin/rechnung/template": "Rechnungsvorlage",
|
"/admin/rechnung/template": "Rechnungsvorlage",
|
||||||
};
|
};
|
||||||
@@ -64,6 +65,13 @@ export default function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/admin/preistabelle"
|
||||||
|
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
|
||||||
|
>
|
||||||
|
Preistabelle
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
<div className="nav-group">
|
<div className="nav-group">
|
||||||
<div className="nav-group__label">Rechnung</div>
|
<div className="nav-group__label">Rechnung</div>
|
||||||
<div className="nav-subnav">
|
<div className="nav-subnav">
|
||||||
|
|||||||
160
frontend/src/pages/PricingPage.tsx
Normal file
160
frontend/src/pages/PricingPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user