283 lines
9.4 KiB
TypeScript
283 lines
9.4 KiB
TypeScript
import Link from "next/link";
|
|
import type { Metadata } from "next";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
Building2,
|
|
CheckCircle2,
|
|
Clock,
|
|
Lock,
|
|
MailWarning,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { UserRole } from "@prisma/client";
|
|
|
|
import { requireRole } from "@/lib/auth-utils";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
getGlobalAdminHealth,
|
|
getKitaOverviewRows,
|
|
roleOrder,
|
|
} from "./_lib/data";
|
|
import { KitaDeleteDialog } from "./_components/kita-delete-dialog";
|
|
|
|
export const metadata: Metadata = {
|
|
title: "Superadmin · Kita-Planer",
|
|
};
|
|
|
|
const numberFormat = new Intl.NumberFormat("de-DE");
|
|
|
|
function formatDate(date: Date | null) {
|
|
if (!date) return "Nie";
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(date);
|
|
}
|
|
|
|
export default async function AdminPage() {
|
|
await requireRole([UserRole.SUPERADMIN]);
|
|
|
|
const [health, kitas] = await Promise.all([
|
|
getGlobalAdminHealth(),
|
|
getKitaOverviewRows(),
|
|
]);
|
|
|
|
const totalUsers = kitas.reduce((sum, kita) => sum + kita.counts.users, 0);
|
|
const totalChildren = kitas.reduce(
|
|
(sum, kita) => sum + kita.counts.activeChildren,
|
|
0,
|
|
);
|
|
const needsAttention =
|
|
health.lockedTenantUsers +
|
|
health.missingConsent +
|
|
health.openInvitations +
|
|
health.orphanTenantUsers;
|
|
|
|
return (
|
|
<div className="px-8 py-8">
|
|
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
Kita-Verwaltung
|
|
</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Systemweite Übersicht, read-only Diagnose und Support-Aktionen.
|
|
</p>
|
|
</div>
|
|
<Button asChild variant="outline">
|
|
<Link href="/admin/audit">
|
|
AuditLog ansehen
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
|
<MetricCard
|
|
icon={Building2}
|
|
label="Kitas"
|
|
value={health.kitas}
|
|
description="Mandanten im System"
|
|
/>
|
|
<MetricCard
|
|
icon={Users}
|
|
label="Benutzer"
|
|
value={totalUsers}
|
|
description={`${numberFormat.format(totalChildren)} aktive Kinder`}
|
|
/>
|
|
<MetricCard
|
|
icon={AlertTriangle}
|
|
label="Hinweise"
|
|
value={needsAttention}
|
|
description="Sperren, Einladungen, Consent"
|
|
attention={needsAttention > 0}
|
|
/>
|
|
<MetricCard
|
|
icon={Lock}
|
|
label="Gesperrt"
|
|
value={health.lockedTenantUsers}
|
|
description="Aktive Account-Sperren"
|
|
attention={health.lockedTenantUsers > 0}
|
|
/>
|
|
</div>
|
|
|
|
{health.orphanTenantUsers > 0 && (
|
|
<div className="mb-6 rounded-md border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
|
<div className="flex items-center gap-2 font-medium">
|
|
<MailWarning className="h-4 w-4" />
|
|
{health.orphanTenantUsers} tenant-lose Nicht-Superadmin-Benutzer
|
|
</div>
|
|
<p className="mt-1 text-amber-800 dark:text-amber-300">
|
|
Diese Konten sind keiner Kita zugeordnet und sollten separat
|
|
bereinigt werden. V1 verwaltet nur tenant-gebundene Benutzer.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle as="h2" className="text-xl">
|
|
Alle Kitas
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Diagnose-Links sind read-only; Löschungen benötigen eine exakte
|
|
Namensbestätigung.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{kitas.length === 0 ? (
|
|
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
Noch keine Kitas angelegt.
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Kita</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Counts</TableHead>
|
|
<TableHead>Rollen</TableHead>
|
|
<TableHead>Letzter Login</TableHead>
|
|
<TableHead className="text-right">Aktionen</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{kitas.map((kita) => {
|
|
const warnings =
|
|
kita.counts.lockedUsers +
|
|
kita.counts.missingConsent +
|
|
kita.counts.openInvitations +
|
|
kita.counts.familiesWithoutUsers;
|
|
return (
|
|
<TableRow key={kita.id}>
|
|
<TableCell>
|
|
<div className="font-medium">{kita.name}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{kita.slug}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{warnings === 0 ? (
|
|
<Badge variant="success">
|
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
|
Unauffällig
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="warning">
|
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
|
{warnings} Hinweise
|
|
</Badge>
|
|
)}
|
|
{kita.counts.lockedUsers > 0 && (
|
|
<Badge variant="outline">
|
|
{kita.counts.lockedUsers} gesperrt
|
|
</Badge>
|
|
)}
|
|
{kita.counts.openInvitations > 0 && (
|
|
<Badge variant="outline">
|
|
{kita.counts.openInvitations} Einladungen
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="grid min-w-48 grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
<span>{kita.counts.users} Benutzer</span>
|
|
<span>{kita.counts.families} Familien</span>
|
|
<span>{kita.counts.activeChildren} Kinder</span>
|
|
<span>{kita.counts.termine} Termine</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{roleOrder.map((role) =>
|
|
kita.roleCounts[role] > 0 ? (
|
|
<Badge key={role} variant="secondary">
|
|
{role}: {kita.roleCounts[role]}
|
|
</Badge>
|
|
) : null,
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
{formatDate(kita.lastLoginAt)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link
|
|
href={`/admin/kitas/${kita.id}`}
|
|
prefetch={false}
|
|
>
|
|
Diagnose
|
|
</Link>
|
|
</Button>
|
|
<KitaDeleteDialog
|
|
kitaId={kita.id}
|
|
kitaName={kita.name}
|
|
counts={kita.counts}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricCard({
|
|
icon: Icon,
|
|
label,
|
|
value,
|
|
description,
|
|
attention = false,
|
|
}: {
|
|
icon: typeof Building2;
|
|
label: string;
|
|
value: number;
|
|
description: string;
|
|
attention?: boolean;
|
|
}) {
|
|
return (
|
|
<Card className={attention ? "border-amber-200 bg-amber-50/70" : ""}>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardDescription>{label}</CardDescription>
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
<CardTitle className="text-3xl">{numberFormat.format(value)}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|