Compare commits

...

12 Commits

Author SHA1 Message Date
eb0f921464 feat: Add invoice management menu and template editor for admin
- Add 'Rechnung' menu with sub-items 'Verwalten' and 'Template' in admin sidebar
- Create InvoiceTemplatePage with drag-and-drop editor for invoice templates
  - Includes invoice-specific elements (header, customer data, issuer info,
    invoice items, totals, payment terms, bank details)
  - Supports PDF preview and download
  - API integration for saving/loading templates (/admin/invoice-template)
- Create InvoiceManagementPage as placeholder for invoice overview
- Add routes for /admin/rechnung/verwalten and /admin/rechnung/template
- Update page titles in AppShell for new routes
2026-03-16 20:30:45 +01:00
cbabe13162 Admin Dashboard: Verwaltungsmodul-Bereich entfernt 2026-03-16 20:19:26 +01:00
538ec2419d AdminStatisticsService korrigiert: Proben werden jetzt basierend auf ownerAccountId und createdByUserCode korrekt zugeordnet 2026-03-16 20:17:27 +01:00
19fda276b0 Admin Dashboard mit Chart.js: Balkendiagramm zeigt Proben pro Tierarzt 2026-03-16 17:14:17 +01:00
1df2d8276c Admin Dashboard mit Statistiken: Tierärzte-Anzahl, Gesamtproben und Proben pro Tierarzt 2026-03-16 17:11:17 +01:00
2fd101565e Admin wird in der Benutzerverwaltung nicht mehr angezeigt 2026-03-16 17:04:27 +01:00
021730b90b UserManagementPage für Admin vereinfacht: Nur Hauptnutzer anzeigen mit Freigabe/Sperre-Funktion 2026-03-16 17:02:19 +01:00
2f9b12250f Admin Dashboard weiter reduziert: Statistik-Karten entfernt, nur noch Header und Benutzerverwaltung 2026-03-16 16:58:56 +01:00
89d6651af2 Admin Dashboard reduziert: Nur noch Statistiken und Benutzerverwaltung-Kachel 2026-03-16 16:56:55 +01:00
118e6431da Admin-Menü weiter reduziert: Nur noch Dashboard und Benutzerverwaltung (Freigabe/Sperre) 2026-03-16 16:54:57 +01:00
477fcb69c4 Admin-Menü reduziert: Nur noch Dashboard, Neue Probe, Benutzerverwaltung (Freigabe/Sperre) und Portal 2026-03-16 16:53:50 +01:00
40de46588e Admin Dashboard hinzugefügt: Modernes Dashboard für Administratoren mit Statistiken, Verwaltungsmodulen und Schnellzugriffen 2026-03-16 16:51:15 +01:00
13 changed files with 3154 additions and 441 deletions

View File

@@ -0,0 +1,70 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.Sample;
import de.svencarstensen.muh.domain.UserRole;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.SampleRepository;
import de.svencarstensen.muh.web.dto.AdminStatistics;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AdminStatisticsService {
private final AppUserRepository appUserRepository;
private final SampleRepository sampleRepository;
public AdminStatisticsService(AppUserRepository appUserRepository, SampleRepository sampleRepository) {
this.appUserRepository = appUserRepository;
this.sampleRepository = sampleRepository;
}
public AdminStatistics getStatistics() {
// Alle Hauptnutzer (Tierärzte) laden - primaryUser=true und role=CUSTOMER
List<AppUser> vets = appUserRepository.findAll().stream()
.filter(u -> Boolean.TRUE.equals(u.primaryUser()))
.filter(u -> u.role() == UserRole.CUSTOMER)
.toList();
// Alle Proben laden
List<Sample> allSamples = sampleRepository.findAll();
// Proben pro Tierarzt zählen (basierend auf ownerAccountId oder createdByUserCode)
List<AdminStatistics.VetSampleStats> samplesPerVet = vets.stream()
.map(vet -> {
String vetId = vet.id();
String accountId = vet.accountId();
long sampleCount = allSamples.stream()
.filter(s -> {
// Prüfe sowohl ownerAccountId als auch createdByUserCode
String ownerId = s.ownerAccountId();
String creatorId = s.createdByUserCode();
// Vergleiche mit vet.id() oder vet.accountId()
return vetId.equals(ownerId) ||
vetId.equals(creatorId) ||
accountId != null && accountId.equals(ownerId) ||
accountId != null && accountId.equals(creatorId);
})
.count();
return new AdminStatistics.VetSampleStats(
vet.id(),
vet.displayName(),
vet.companyName(),
sampleCount
);
})
.filter(s -> s.sampleCount() > 0)
.sorted((a, b) -> Long.compare(b.sampleCount(), a.sampleCount()))
.toList();
return new AdminStatistics(
vets.size(),
allSamples.size(),
samplesPerVet
);
}
}

View File

@@ -0,0 +1,25 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.AdminStatisticsService;
import de.svencarstensen.muh.web.dto.AdminStatistics;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')")
public class AdminController {
private final AdminStatisticsService adminStatisticsService;
public AdminController(AdminStatisticsService adminStatisticsService) {
this.adminStatisticsService = adminStatisticsService;
}
@GetMapping("/statistics")
public AdminStatistics getStatistics() {
return adminStatisticsService.getStatistics();
}
}

View File

@@ -0,0 +1,17 @@
package de.svencarstensen.muh.web.dto;
import java.util.List;
public record AdminStatistics(
long totalVets,
long totalSamples,
List<VetSampleStats> samplesPerVet
) {
public record VetSampleStats(
String userId,
String displayName,
String companyName,
long sampleCount
) {
}
}

View File

@@ -8,7 +8,9 @@
"name": "muh-frontend",
"version": "0.0.1",
"dependencies": {
"chart.js": "^4.5.1",
"react": "18.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.2.0",
"react-router-dom": "6.23.1"
},
@@ -745,6 +747,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz",
@@ -1290,6 +1298,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1538,6 +1558,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",

View File

@@ -9,7 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"chart.js": "^4.5.1",
"react": "18.2.0",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.2.0",
"react-router-dom": "6.23.1"
},

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { SessionProvider, useSession } from "./lib/session";
import AppShell from "./layout/AppShell";
import HomePage from "./pages/HomePage";
import AdminDashboardPage from "./pages/AdminDashboardPage";
import LoginPage from "./pages/LoginPage";
import SampleRegistrationPage from "./pages/SampleRegistrationPage";
import AnamnesisPage from "./pages/AnamnesisPage";
@@ -14,6 +15,8 @@ import SearchFarmerPage from "./pages/SearchFarmerPage";
import SearchCalendarPage from "./pages/SearchCalendarPage";
import UserManagementPage from "./pages/UserManagementPage";
import ReportTemplatePage from "./pages/ReportTemplatePage";
import InvoiceTemplatePage from "./pages/InvoiceTemplatePage";
import InvoiceManagementPage from "./pages/InvoiceManagementPage";
function ProtectedRoutes() {
const { user, ready } = useSession();
@@ -30,7 +33,8 @@ function ProtectedRoutes() {
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/home" element={<HomePage />} />
<Route path="/home" element={isAdmin ? <AdminDashboardPage /> : <HomePage />} />
<Route path="/admin/dashboard" element={<AdminDashboardPage />} />
<Route path="/samples/new" element={<SampleRegistrationPage />} />
<Route path="/samples/:sampleId/registration" element={<SampleRegistrationPage />} />
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
@@ -43,6 +47,8 @@ function ProtectedRoutes() {
<Route path="/admin/medikamente" element={<AdministrationPage />} />
<Route path="/admin/erreger" element={<AdministrationPage />} />
<Route path="/admin/antibiogramm" element={<AdministrationPage />} />
<Route path="/admin/rechnung/verwalten" element={<InvoiceManagementPage />} />
<Route path="/admin/rechnung/template" element={<InvoiceTemplatePage />} />
<Route path="/search" element={<Navigate to="/search/probe" replace />} />
<Route path="/search/probe" element={<SearchPage />} />
<Route path="/search/landwirt" element={<SearchFarmerPage />} />

View File

@@ -3,12 +3,15 @@ import { useSession } from "../lib/session";
const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite",
"/admin/dashboard": "Admin Dashboard",
"/samples/new": "Neuanlage einer Probe",
"/portal": "MUH-Portal",
"/report-template": "Bericht",
"/admin/rechnung/verwalten": "Rechnungsverwaltung",
"/admin/rechnung/template": "Rechnungsvorlage",
};
function resolvePageTitle(pathname: string) {
function resolvePageTitle(pathname: string, isAdmin: boolean) {
if (pathname.includes("/anamnesis")) {
return "Anamnese";
}
@@ -21,29 +24,8 @@ function resolvePageTitle(pathname: string) {
if (pathname.includes("/registration")) {
return "Probe bearbeiten";
}
if (pathname.startsWith("/admin/landwirte")) {
return "Die Verwaltung der Landwirte";
}
if (pathname.startsWith("/admin/benutzer")) {
return "Verwaltung | Benutzer";
}
if (pathname.startsWith("/admin/medikamente")) {
return "Die Verwaltung der Medikamente";
}
if (pathname.startsWith("/admin/erreger")) {
return "Die Verwaltung der Erreger";
}
if (pathname.startsWith("/admin/antibiogramm")) {
return "Die Verwaltung der Antibiogramme";
}
if (pathname.startsWith("/search/landwirt")) {
return "Suche | Landwirt";
}
if (pathname.startsWith("/search/probe")) {
return "Suche | Probe";
}
if (pathname.startsWith("/search/kalendar")) {
return "Suche | Kalendar";
return isAdmin ? "Benutzerfreigabe" : "Verwaltung | Benutzer";
}
return PAGE_TITLES[pathname] ?? "MUH App";
}
@@ -62,12 +44,35 @@ export default function AppShell() {
<nav className="sidebar__nav">
{user?.role === "ADMIN" ? (
<NavLink
to="/portal"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Benutzerverwaltung
</NavLink>
<>
<NavLink
to="/admin/dashboard"
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
>
Dashboard
</NavLink>
<div className="nav-group">
<div className="nav-group__label">Benutzerverwaltung</div>
<div className="nav-subnav">
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Freigabe / Sperre
</NavLink>
</div>
</div>
<div className="nav-group">
<div className="nav-group__label">Rechnung</div>
<div className="nav-subnav">
<NavLink to="/admin/rechnung/verwalten" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Verwalten
</NavLink>
<NavLink to="/admin/rechnung/template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Template
</NavLink>
</div>
</div>
</>
) : (
<>
<NavLink to="/home" className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}>
@@ -149,7 +154,7 @@ export default function AppShell() {
<div className="shell-main">
<header className="topbar">
<div className="topbar__headline">
<h2>{resolvePageTitle(location.pathname)}</h2>
<h2>{resolvePageTitle(location.pathname, user?.role === "ADMIN")}</h2>
</div>
</header>

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { apiGet } from "../lib/api";
// Chart.js Komponenten registrieren
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
interface VetSampleStats {
userId: string;
displayName: string;
companyName: string | null;
sampleCount: number;
}
interface AdminStatistics {
totalVets: number;
totalSamples: number;
samplesPerVet: VetSampleStats[];
}
export default function AdminDashboardPage() {
const [stats, setStats] = useState<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadStats() {
try {
const response = await apiGet<AdminStatistics>("/admin/statistics");
setStats(response);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
void loadStats();
}, []);
// Chart Daten vorbereiten
const chartData = {
labels: stats?.samplesPerVet.map((vet) => vet.displayName) || [],
datasets: [
{
label: "Anzahl Proben",
data: stats?.samplesPerVet.map((vet) => vet.sampleCount) || [],
backgroundColor: "rgba(90, 123, 168, 0.8)",
borderColor: "rgba(90, 123, 168, 1)",
borderWidth: 1,
borderRadius: 8,
borderSkipped: false,
},
],
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
title: {
display: true,
text: "Proben pro Tierarzt",
font: {
size: 16,
weight: "bold" as const,
},
padding: {
top: 10,
bottom: 20,
},
color: "#1d2428",
},
tooltip: {
backgroundColor: "rgba(29, 36, 40, 0.9)",
padding: 12,
cornerRadius: 8,
titleFont: {
size: 14,
},
bodyFont: {
size: 13,
},
callbacks: {
label: (context: { parsed: { y: number } }) => {
return `${context.parsed.y} Proben`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: "#666",
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
title: {
display: true,
text: "Anzahl Proben",
color: "#666",
font: {
size: 12,
},
},
},
x: {
ticks: {
color: "#666",
maxRotation: 45,
minRotation: 45,
},
grid: {
display: false,
},
title: {
display: true,
text: "Tierarzt",
color: "#666",
font: {
size: 12,
},
},
},
},
};
return (
<div className="page-stack">
{/* Header Bereich */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Administration</p>
<h3>Administrator Dashboard</h3>
<p className="muted-text">
Übersicht über Tierärzte und Proben im System.
</p>
</div>
</section>
{error ? (
<div className="alert alert--error">{error}</div>
) : null}
{/* Statistik-Karten */}
<section className="metrics-grid admin-metrics">
<article className="metric-card metric-card--primary">
<span className="metric-card__label">Tierärzte</span>
<strong className="metric-card__value--large">
{loading ? "..." : stats?.totalVets ?? 0}
</strong>
</article>
<article className="metric-card metric-card--secondary">
<span className="metric-card__label">Proben insgesamt</span>
<strong className="metric-card__value--large">
{loading ? "..." : stats?.totalSamples ?? 0}
</strong>
</article>
</section>
{/* Chart Bereich */}
<section className="section-card">
<div className="chart-container">
{loading ? (
<div className="empty-state">Chart wird geladen...</div>
) : stats?.samplesPerVet.length === 0 ? (
<div className="empty-state">Noch keine Proben vorhanden.</div>
) : (
<Bar data={chartData} options={chartOptions} />
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import { apiGet } from "../lib/api";
interface InvoiceSummary {
id: string;
invoiceNumber: string;
customerName: string;
invoiceDate: string;
dueDate: string;
totalAmount: number;
status: "DRAFT" | "SENT" | "PAID" | "OVERDUE" | "CANCELLED";
}
interface InvoiceOverview {
invoices: InvoiceSummary[];
}
const STATUS_LABELS: Record<InvoiceSummary["status"], string> = {
DRAFT: "Entwurf",
SENT: "Versendet",
PAID: "Bezahlt",
OVERDUE: "Überfällig",
CANCELLED: "Storniert",
};
const STATUS_CLASSES: Record<InvoiceSummary["status"], string> = {
DRAFT: "status-badge--draft",
SENT: "status-badge--sent",
PAID: "status-badge--success",
OVERDUE: "status-badge--error",
CANCELLED: "status-badge--neutral",
};
export default function InvoiceManagementPage() {
const [invoices, setInvoices] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
apiGet<InvoiceOverview>("/admin/invoices")
.then((response) => {
if (!cancelled) {
setInvoices(response.invoices);
}
})
.catch((err) => {
if (!cancelled) {
// Für den Moment zeigen wir einfach eine leere Liste an
// bis das Backend implementiert ist
setInvoices([]);
setError(null);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
}).format(new Date(dateString));
};
return (
<div className="page-stack">
<section className="section-card section-card--hero">
<div>
<p className="eyebrow">Rechnungsverwaltung</p>
<h3>Übersicht aller Rechnungen</h3>
<p className="muted-text">
Hier können Sie alle erstellten Rechnungen einsehen, deren Status verfolgen
und geplante Rechnungen verwalten.
</p>
</div>
{error ? <div className="alert alert--error">{error}</div> : null}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Rechnungen</p>
<h3>Rechnungsliste</h3>
</div>
<div className="page-actions">
<button type="button" className="accent-button" disabled>
Neue Rechnung
</button>
</div>
</div>
{loading ? (
<div className="empty-state">Rechnungen werden geladen...</div>
) : invoices.length === 0 ? (
<div className="empty-state">
<p>Noch keine Rechnungen vorhanden.</p>
<p className="muted-text">
Die Rechnungsverwaltung wird in Kürze verfügbar sein.
</p>
</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Rechnungsnr.</th>
<th>Kunde</th>
<th>Rechnungsdatum</th>
<th>Fällig am</th>
<th>Betrag</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id}>
<td>{invoice.invoiceNumber}</td>
<td>{invoice.customerName}</td>
<td>{formatDate(invoice.invoiceDate)}</td>
<td>{formatDate(invoice.dueDate)}</td>
<td>{formatCurrency(invoice.totalAmount)}</td>
<td>
<span className={`status-badge ${STATUS_CLASSES[invoice.status]}`}>
{STATUS_LABELS[invoice.status]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Zusammenfassung</p>
<h3>Statistik</h3>
</div>
</div>
<div className="stats-grid">
<div className="stat-card">
<span className="stat-card__value">{invoices.length}</span>
<span className="stat-card__label">Gesamtrechnungen</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "PAID").length}
</span>
<span className="stat-card__label">Bezahlt</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{invoices.filter((i) => i.status === "OVERDUE").length}
</span>
<span className="stat-card__label">Überfällig</span>
</div>
<div className="stat-card">
<span className="stat-card__value">
{formatCurrency(
invoices
.filter((i) => i.status === "PAID")
.reduce((sum, i) => sum + i.totalAmount, 0)
)}
</span>
<span className="stat-card__label">Gesamtumsatz</span>
</div>
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { SessionResponse } from "../lib/types";
@@ -28,6 +29,7 @@ export default function LoginPage() {
});
const [feedback, setFeedback] = useState<FeedbackState>(null);
const { setSession } = useSession();
const navigate = useNavigate();
function unlockLoginInputs() {
setLoginInputsUnlocked(true);
@@ -50,6 +52,8 @@ export default function LoginPage() {
password,
});
setSession(response);
// Admin zum Dashboard, Kunden zur Startseite
navigate(response.user.role === "ADMIN" ? "/admin/dashboard" : "/home");
} catch (loginError) {
setFeedback({ type: "error", text: (loginError as Error).message });
}
@@ -88,6 +92,8 @@ export default function LoginPage() {
text: `Registrierung erfolgreich. Willkommen ${response.user.companyName ?? response.user.displayName}.`,
});
setSession(response);
// Admin zum Dashboard, Kunden zur Startseite
navigate(response.user.role === "ADMIN" ? "/admin/dashboard" : "/home");
} catch (registrationError) {
setFeedback({ type: "error", text: (registrationError as Error).message });
}

View File

@@ -1,470 +1,178 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { apiDelete, apiGet, apiPost } from "../lib/api";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption, UserRole, UserRow } from "../lib/types";
import type { UserRow } from "../lib/types";
type UserDraft = UserRow & {
password: string;
passwordRepeat: string;
};
function toDraft(user: UserRow): UserDraft {
return {
...user,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
};
}
function emptyUser(): UserDraft {
return {
id: "",
primaryUser: false,
displayName: "",
companyName: "",
address: "",
street: "",
houseNumber: "",
postalCode: "",
city: "",
email: "",
phoneNumber: "",
password: "",
passwordRepeat: "",
active: true,
role: "CUSTOMER",
updatedAt: new Date().toISOString(),
};
}
function toDraftFromSession(user: UserOption): UserDraft {
return {
id: user.id,
primaryUser: user.primaryUser,
displayName: user.displayName,
companyName: user.companyName ?? "",
address: user.address ?? "",
street: user.street ?? "",
houseNumber: user.houseNumber ?? "",
postalCode: user.postalCode ?? "",
city: user.city ?? "",
email: user.email ?? "",
phoneNumber: user.phoneNumber ?? "",
password: "",
passwordRepeat: "",
active: true,
role: user.role,
updatedAt: new Date().toISOString(),
};
}
function isAccessDenied(error: unknown): boolean {
return error instanceof Error && error.message.trim().toLowerCase() === "access denied";
}
function toMutation(user: UserDraft) {
return {
id: user.id || null,
displayName: user.displayName,
companyName: user.companyName || null,
address: user.address || null,
street: user.street || null,
houseNumber: user.houseNumber || null,
postalCode: user.postalCode || null,
city: user.city || null,
email: user.email || null,
phoneNumber: user.phoneNumber || null,
password: user.password || null,
active: user.active,
role: user.role,
};
interface PrimaryUserRow {
id: string;
displayName: string;
companyName: string | null;
email: string | null;
active: boolean;
updatedAt: string;
}
export default function UserManagementPage() {
const { user } = useSession();
const [users, setUsers] = useState<UserDraft[]>([]);
const [newUser, setNewUser] = useState<UserDraft>(emptyUser());
const [users, setUsers] = useState<PrimaryUserRow[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [showValidation, setShowValidation] = useState(false);
const isAdmin = user?.role === "ADMIN";
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
setUsers(response.map(toDraft));
setMessage(null);
} catch (error) {
if (!isAdmin && user?.primaryUser && isAccessDenied(error)) {
setUsers([toDraftFromSession(user)]);
setMessage(null);
return;
}
throw error;
}
}
useEffect(() => {
void loadUsers().catch((error) => setMessage((error as Error).message));
async function loadUsers() {
try {
const response = await apiGet<UserRow[]>("/portal/users");
// Nur Hauptnutzer (primaryUser=true) anzeigen, aber Admin ausblenden
const primaryUsers = response
.filter((u) => u.primaryUser && u.role !== "ADMIN")
.map((u) => ({
id: u.id,
displayName: u.displayName,
companyName: u.companyName,
email: u.email,
active: u.active,
updatedAt: u.updatedAt,
}));
setUsers(primaryUsers);
} catch (error) {
setMessage((error as Error).message);
} finally {
setLoading(false);
}
}
void loadUsers();
}, []);
const primaryUser = useMemo(
() => users.find((entry) => entry.primaryUser) ?? null,
[users],
);
const secondaryUsers = useMemo(
() => users.filter((entry) => !entry.primaryUser),
[users],
);
function updateExistingUser(userId: string, patch: Partial<UserDraft>) {
setUsers((current) =>
current.map((entry) => (entry.id === userId ? { ...entry, ...patch } : entry)),
);
}
async function saveUser(draft: UserDraft) {
setShowValidation(true);
if (!draft.displayName.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
async function toggleUserStatus(userId: string, newStatus: boolean) {
try {
const saved = await apiPost<UserRow>("/portal/users", toMutation(draft));
const userToUpdate = users.find((u) => u.id === userId);
if (!userToUpdate) return;
await apiPost("/portal/users", {
id: userId,
active: newStatus,
});
setUsers((current) =>
current.map((entry) => (entry.id === draft.id ? toDraft(saved) : entry)),
current.map((u) => (u.id === userId ? { ...u, active: newStatus } : u))
);
setMessage(draft.primaryUser ? "Stammdaten gespeichert." : "Benutzer gespeichert.");
setMessage(
`Benutzer "${userToUpdate.displayName}" wurde ${
newStatus ? "freigegeben" : "gesperrt"
}.`
);
// Nachricht nach 3 Sekunden ausblenden
setTimeout(() => setMessage(null), 3000);
} catch (error) {
setMessage((error as Error).message);
}
}
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setShowValidation(true);
if (!newUser.displayName.trim() || !(newUser.email ?? "").trim() || !newUser.password.trim()) {
setMessage("Bitte alle Pflichtfelder ausfuellen.");
return;
}
if (newUser.password !== newUser.passwordRepeat) {
setMessage("Die Passwoerter stimmen nicht ueberein.");
return;
}
try {
await apiPost<UserRow>("/portal/users", toMutation(newUser));
setNewUser(emptyUser());
setMessage("Benutzer angelegt.");
await loadUsers();
} catch (error) {
setMessage((error as Error).message);
}
// Formatierungsfunktion für das Datum
function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
async function removeUser(userId: string) {
try {
await apiDelete(`/portal/users/${userId}`);
setUsers((current) => current.filter((entry) => entry.id !== userId));
setMessage("Benutzer geloescht.");
void loadUsers().catch(() => undefined);
} catch (error) {
setMessage((error as Error).message);
}
// Nicht-Admin Ansicht (sollte nicht passieren, da Route geschützt ist)
if (!isAdmin) {
return (
<div className="page-stack">
<section className="section-card">
<div className="alert alert--error">
Zugriff verweigert. Diese Seite ist nur für Administratoren.
</div>
</section>
</div>
);
}
return (
<div className="page-stack">
<section className="section-card section-card--hero">
{/* Header */}
<section className="hero-card admin-hero">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Benutzer und Stammdaten</h3>
<p className="eyebrow">Benutzerverwaltung</p>
<h3>Hauptnutzer freigeben oder sperren</h3>
<p className="muted-text">
Hier pflegen Sie den Hauptbenutzer Ihres Kontos und legen weitere Benutzer an.
Verwalten Sie den Zugriff von Hauptnutzern auf das System.
Gesperrte Benutzer können sich nicht mehr anmelden.
</p>
</div>
{message ? (
<div
className={
message.includes("gespeichert") || message.includes("angelegt") || message.includes("geloescht")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
) : null}
</section>
{primaryUser ? (
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Hauptbenutzer</p>
<h3>Stammdaten bearbeiten</h3>
</div>
<div className="info-chip">{primaryUser.email ?? primaryUser.displayName}</div>
</div>
<div className={`field-grid ${showValidation ? "show-validation" : ""}`}>
<label className="field field--required">
<span>Name</span>
<input
required
value={primaryUser.displayName}
onChange={(event) =>
updateExistingUser(primaryUser.id, { displayName: event.target.value })
}
/>
</label>
<label className="field">
<span>Firmenname</span>
<input
value={primaryUser.companyName ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { companyName: event.target.value })
}
/>
</label>
<label className="field">
<span>E-Mail</span>
<input
type="email"
value={primaryUser.email ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { email: event.target.value })}
/>
</label>
<label className="field">
<span>Strasse</span>
<input
value={primaryUser.street ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { street: event.target.value })}
/>
</label>
<label className="field">
<span>Hausnummer</span>
<input
value={primaryUser.houseNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { houseNumber: event.target.value })
}
/>
</label>
<label className="field">
<span>PLZ</span>
<input
value={primaryUser.postalCode ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { postalCode: event.target.value })
}
/>
</label>
<label className="field">
<span>Ort</span>
<input
value={primaryUser.city ?? ""}
onChange={(event) => updateExistingUser(primaryUser.id, { city: event.target.value })}
/>
</label>
<label className="field">
<span>Telefonnummer</span>
<input
value={primaryUser.phoneNumber ?? ""}
onChange={(event) =>
updateExistingUser(primaryUser.id, { phoneNumber: event.target.value })
}
/>
</label>
<label className="field field--wide">
<span>Neues Passwort</span>
<input
type="password"
value={primaryUser.password}
onChange={(event) => updateExistingUser(primaryUser.id, { password: event.target.value })}
/>
</label>
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={() => void saveUser(primaryUser)}>
Stammdaten speichern
</button>
</div>
</section>
{/* Status-Meldung */}
{message ? (
<div
className={
message.includes("freigegeben") || message.includes("gesperrt")
? "alert alert--success"
: "alert alert--error"
}
>
{message}
</div>
) : null}
{/* Tabelle mit Hauptnutzern */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzer</p>
<h3>Benutzer anlegen</h3>
<p className="eyebrow">Hauptnutzer</p>
<h3>Registrierte Hauptnutzer</h3>
</div>
</div>
<form
className={`field-grid ${showValidation ? "show-validation" : ""}`}
onSubmit={createUser}
autoComplete="off"
>
<label className="field field--required">
<span>Name</span>
<input
required
value={newUser.displayName}
onChange={(event) =>
setNewUser((current) => ({ ...current, displayName: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>E-Mail</span>
<input
required
type="email"
value={newUser.email ?? ""}
autoComplete="off"
onChange={(event) => setNewUser((current) => ({ ...current, email: event.target.value }))}
/>
</label>
<label className="field field--required">
<span>Passwort</span>
<input
required
type="password"
autoComplete="new-password"
value={newUser.password}
onChange={(event) =>
setNewUser((current) => ({ ...current, password: event.target.value }))
}
/>
</label>
<label className="field field--required">
<span>Passwort wiederholen</span>
<input
required
type="password"
autoComplete="new-password"
className={
showValidation && newUser.password !== newUser.passwordRepeat ? "is-invalid" : ""
}
value={newUser.passwordRepeat}
onChange={(event) =>
setNewUser((current) => ({ ...current, passwordRepeat: event.target.value }))
}
/>
</label>
{isAdmin ? (
<label className="field">
<span>Rolle</span>
<select
value={newUser.role}
onChange={(event) =>
setNewUser((current) => ({
...current,
role: event.target.value as UserRole,
}))
}
>
<option value="CUSTOMER">CUSTOMER</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
) : null}
<div className="page-actions">
<button type="submit" className="accent-button">
Benutzer anlegen
</button>
</div>
</form>
</section>
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Benutzerliste</p>
<h3>Bereits angelegte Benutzer</h3>
</div>
</div>
{secondaryUsers.length === 0 ? (
<div className="empty-state">Noch keine weiteren Benutzer vorhanden.</div>
{loading ? (
<div className="empty-state">Benutzer werden geladen...</div>
) : users.length === 0 ? (
<div className="empty-state">Keine Hauptnutzer vorhanden.</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th className="required-label">Name</th>
<th>Name</th>
<th>Firma</th>
<th>E-Mail</th>
<th>Passwort</th>
<th>Aktiv</th>
<th />
<th>Status</th>
<th>Letzte Änderung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{secondaryUsers.map((entry) => (
<tr key={entry.id}>
{users.map((entry) => (
<tr key={entry.id} className={!entry.active ? "table-row--inactive" : ""}>
<td>
<input
required
className={showValidation && !entry.displayName.trim() ? "is-invalid" : ""}
value={entry.displayName}
onChange={(event) =>
updateExistingUser(entry.id, { displayName: event.target.value })
}
/>
<strong>{entry.displayName}</strong>
</td>
<td>{entry.companyName ?? "-"}</td>
<td>{entry.email ?? "-"}</td>
<td>
<input
type="email"
value={entry.email ?? ""}
onChange={(event) => updateExistingUser(entry.id, { email: event.target.value })}
/>
</td>
<td>
<input
type="password"
value={entry.password}
onChange={(event) =>
updateExistingUser(entry.id, { password: event.target.value })
}
placeholder="Neues Passwort"
/>
</td>
<td>
<select
value={entry.active ? "true" : "false"}
onChange={(event) =>
updateExistingUser(entry.id, { active: event.target.value === "true" })
}
<span
className={`status-pill ${
entry.active ? "status-pill--active" : "status-pill--inactive"
}`}
>
<option value="true">aktiv</option>
<option value="false">inaktiv</option>
</select>
{entry.active ? "Freigegeben" : "Gesperrt"}
</span>
</td>
<td className="table-actions">
<td className="text-muted">{formatDate(entry.updatedAt)}</td>
<td>
<button
type="button"
className="table-link"
onClick={() => void saveUser(entry)}
className={`action-button ${
entry.active ? "action-button--danger" : "action-button--success"
}`}
onClick={() => toggleUserStatus(entry.id, !entry.active)}
>
Speichern
</button>
<button
type="button"
className="table-link table-link--danger"
onClick={() => void removeUser(entry.id)}
>
Loeschen
{entry.active ? "Sperren" : "Freigeben"}
</button>
</td>
</tr>
@@ -474,6 +182,18 @@ export default function UserManagementPage() {
</div>
)}
</section>
{/* Info-Box */}
<section className="section-card">
<div className="info-panel">
<strong>Hinweis</strong>
<p>
Hauptnutzer sind die primären Kontoinhaber. Wenn Sie einen Hauptnutzer sperren,
können sich dieser und alle zugehörigen Nebennutzer nicht mehr anmelden.
Die Daten bleiben erhalten und können durch Freigabe wieder aktiviert werden.
</p>
</div>
</section>
</div>
);
}

View File

@@ -1088,6 +1088,249 @@ a {
min-height: 88px;
}
/* Admin Dashboard Styles */
.admin-hero {
background: linear-gradient(135deg, rgba(90, 123, 168, 0.15) 0%, rgba(74, 124, 89, 0.1) 100%);
}
.admin-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1024px) {
.admin-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.admin-metrics {
grid-template-columns: 1fr;
}
}
.metric-card--primary {
background: linear-gradient(135deg, rgba(90, 123, 168, 0.25) 0%, rgba(90, 123, 168, 0.1) 100%);
border-color: rgba(90, 123, 168, 0.3);
}
.metric-card__value--large {
font-size: 2.5rem;
background: linear-gradient(135deg, var(--text) 0%, #5b7ba8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.admin-modules-section {
margin-top: 8px;
}
.admin-modules-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 20px;
}
@media (max-width: 1024px) {
.admin-modules-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.admin-modules-grid {
grid-template-columns: 1fr;
}
}
.admin-module-card {
display: flex;
align-items: center;
gap: 16px;
padding: 24px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: var(--radius-xl);
box-shadow: var(--shadow);
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
border-left: 4px solid var(--module-color, #5b7ba8);
}
.admin-module-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.9);
}
.admin-module-card__icon {
font-size: 2rem;
line-height: 1;
}
.admin-module-card__content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.admin-module-card__content strong {
font-size: 1.1rem;
color: var(--text);
}
.admin-module-card__content .muted-text {
font-size: 0.85rem;
}
.admin-module-card__arrow {
font-size: 1.5rem;
color: var(--muted);
opacity: 0.5;
transition: opacity 0.2s ease;
}
.admin-module-card:hover .admin-module-card__arrow {
opacity: 1;
}
.quick-actions-section {
margin-top: 8px;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 20px;
}
@media (max-width: 1024px) {
.quick-actions-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.quick-actions-grid {
grid-template-columns: 1fr;
}
}
.quick-action-button {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.95rem;
color: var(--text);
}
.quick-action-button:hover {
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow);
}
.quick-action-button span:first-child {
font-size: 1.25rem;
}
/* User Management Table Styles */
.table-row--inactive {
background-color: rgba(157, 60, 48, 0.05);
}
.status-pill--active {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-pill--inactive {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.action-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button--success {
background-color: rgba(74, 124, 89, 0.15);
color: #4a7c59;
}
.action-button--success:hover {
background-color: rgba(74, 124, 89, 0.25);
}
.action-button--danger {
background-color: rgba(157, 60, 48, 0.15);
color: #9d3c30;
}
.action-button--danger:hover {
background-color: rgba(157, 60, 48, 0.25);
}
.text-muted {
color: var(--muted);
}
.metric-card--secondary {
background: linear-gradient(135deg, rgba(139, 90, 124, 0.2) 0%, rgba(139, 90, 124, 0.05) 100%);
border-color: rgba(139, 90, 124, 0.25);
}
.sample-count {
display: inline-block;
min-width: 32px;
padding: 4px 12px;
background: rgba(90, 123, 168, 0.15);
border-radius: 16px;
font-weight: 600;
color: var(--text);
text-align: center;
}
/* Chart Container */
.chart-container {
position: relative;
height: 400px;
padding: 20px;
background: rgba(255, 255, 255, 0.5);
border-radius: var(--radius-xl);
}
@media (max-width: 768px) {
.chart-container {
height: 300px;
padding: 10px;
}
}
.dialog-backdrop {
position: fixed;
inset: 0;