Admin Dashboard mit Statistiken: Tierärzte-Anzahl, Gesamtproben und Proben pro Tierarzt
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user