Admin Dashboard mit Statistiken: Tierärzte-Anzahl, Gesamtproben und Proben pro Tierarzt

This commit is contained in:
2026-03-16 17:11:17 +01:00
parent 2fd101565e
commit 1df2d8276c
5 changed files with 223 additions and 29 deletions

View File

@@ -0,0 +1,57 @@
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 createdByUserCode)
List<AdminStatistics.VetSampleStats> samplesPerVet = vets.stream()
.map(vet -> {
long sampleCount = allSamples.stream()
.filter(s -> vet.id().equals(s.createdByUserCode()))
.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

@@ -1,17 +1,40 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { apiGet } from "../lib/api";
interface VetSampleStats {
userId: string;
displayName: string;
companyName: string | null;
sampleCount: number;
}
interface AdminStatistics {
totalVets: number;
totalSamples: number;
samplesPerVet: VetSampleStats[];
}
export default function AdminDashboardPage() {
const navigate = useNavigate();
const [stats, setStats] = useState<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const adminModules = [
{
title: "Benutzer",
description: "Freigabe und Sperre von Benutzerkonten",
icon: "👥",
route: "/admin/benutzer",
color: "#5b7ba8",
},
];
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();
}, []);
return (
<div className="page-stack">
@@ -21,41 +44,97 @@ export default function AdminDashboardPage() {
<p className="eyebrow">Administration</p>
<h3>Administrator Dashboard</h3>
<p className="muted-text">
Verwalten Sie die Freigabe und Sperre von Benutzerkonten.
Ü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>
{/* Tabelle: Proben pro Tierarzt */}
<section className="section-card">
<div className="section-card__header">
<div>
<p className="eyebrow">Statistik</p>
<h3>Proben pro Tierarzt</h3>
</div>
</div>
{loading ? (
<div className="empty-state">Statistiken werden geladen...</div>
) : stats?.samplesPerVet.length === 0 ? (
<div className="empty-state">Noch keine Proben vorhanden.</div>
) : (
<div className="table-shell">
<table className="data-table">
<thead>
<tr>
<th>Tierarzt</th>
<th>Firma</th>
<th style={{ textAlign: "right" }}>Anzahl Proben</th>
</tr>
</thead>
<tbody>
{stats?.samplesPerVet.map((vet) => (
<tr key={vet.userId}>
<td>
<strong>{vet.displayName}</strong>
</td>
<td>{vet.companyName ?? "-"}</td>
<td style={{ textAlign: "right" }}>
<span className="sample-count">{vet.sampleCount}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Admin Module Grid */}
<section className="admin-modules-section">
<div className="section-card__header">
<div>
<p className="eyebrow">Verwaltung</p>
<h3>Verwaltungsmodule</h3>
<h3>Verwaltungsmodul</h3>
</div>
</div>
<div className="admin-modules-grid">
{adminModules.map((module) => (
<button
key={module.route}
type="button"
className="admin-module-card"
onClick={() => navigate(module.route)}
style={{ "--module-color": module.color } as React.CSSProperties}
onClick={() => navigate("/admin/benutzer")}
style={{ "--module-color": "#5b7ba8" } as React.CSSProperties}
>
<span className="admin-module-card__icon">{module.icon}</span>
<span className="admin-module-card__icon">👥</span>
<div className="admin-module-card__content">
<strong>{module.title}</strong>
<span className="muted-text">{module.description}</span>
<strong>Benutzer</strong>
<span className="muted-text">Freigabe und Sperre von Benutzerkonten</span>
</div>
<span className="admin-module-card__arrow"></span>
</button>
))}
</div>
</section>
</div>
);
}

View File

@@ -1299,6 +1299,22 @@ a {
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;
}
.dialog-backdrop {
position: fixed;
inset: 0;