Add superadmin audit and Kita management

This commit is contained in:
t.indorf
2026-05-16 12:02:59 +02:00
parent 0b80c62beb
commit 380c67963b
10 changed files with 2007 additions and 39 deletions
+58
View File
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Building2, ScrollText } from "lucide-react";
import { cn } from "@/lib/utils";
const items = [
{
href: "/admin",
label: "Kitas",
icon: Building2,
exact: true,
},
{
href: "/admin/audit",
label: "AuditLog",
icon: ScrollText,
exact: false,
},
];
export function AdminNav() {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1 px-3">
{items.map(({ href, label, icon: Icon, exact }) => {
const isActive = exact ? pathname === href : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
<div className="mt-3 border-t px-3 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Diagnose
</div>
<div className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground">
<Activity className="h-4 w-4 shrink-0" />
Read-only Support
</div>
</nav>
);
}
@@ -0,0 +1,129 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { deleteKita } from "../actions";
type DeleteCounts = {
users: number;
families: number;
activeChildren: number;
termine: number;
absences: number;
announcements: number;
};
export function KitaDeleteDialog({
kitaId,
kitaName,
counts,
}: {
kitaId: string;
kitaName: string;
counts: DeleteCounts;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [confirmationName, setConfirmationName] = useState("");
const [isPending, startTransition] = useTransition();
const canSubmit = confirmationName === kitaName && !isPending;
function onSubmit() {
startTransition(async () => {
const result = await deleteKita({ kitaId, confirmationName });
if (!result.ok) {
toast.error(result.error ?? "Kita konnte nicht gelöscht werden.");
return;
}
toast.success("Kita wurde gelöscht.");
setOpen(false);
router.push(result.redirectTo ?? "/admin");
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
Kita löschen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Kita endgültig löschen</DialogTitle>
<DialogDescription>
Diese Aktion löscht alle mandantengebundenen Daten dieser Kita
unwiderruflich. Das AuditLog bleibt erhalten.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-destructive/25 bg-destructive/5 p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-destructive">
<AlertTriangle className="h-4 w-4" />
Betroffene Daten
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<span>Benutzer: {counts.users}</span>
<span>Familien: {counts.families}</span>
<span>Aktive Kinder: {counts.activeChildren}</span>
<span>Termine: {counts.termine}</span>
<span>Abwesenheiten: {counts.absences}</span>
<span>News: {counts.announcements}</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`confirm-kita-${kitaId}`}>
Zur Bestätigung Kita-Namen eingeben
</Label>
<Input
id={`confirm-kita-${kitaId}`}
value={confirmationName}
onChange={(event) => setConfirmationName(event.target.value)}
placeholder={kitaName}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={onSubmit}
disabled={!canSubmit}
>
{isPending ? "Lösche..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,211 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Lock, RotateCcw, Trash2, Unlock } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
deleteUser,
lockUser,
resetFailedLoginAttempts,
unlockUser,
} from "../actions";
type UserActionResult = {
ok: boolean;
error?: string;
};
async function runSupportAction(
action: () => Promise<UserActionResult>,
successMessage: string,
router: ReturnType<typeof useRouter>,
) {
const result = await action();
if (!result.ok) {
toast.error(result.error ?? "Aktion konnte nicht ausgeführt werden.");
return;
}
toast.success(successMessage);
router.refresh();
}
export function UserSupportActions({
user,
}: {
user: {
id: string;
email: string;
name: string;
isLocked: boolean;
failedLoginAttempts: number;
};
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
return (
<div className="flex flex-wrap justify-end gap-1">
{user.isLocked ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
startTransition(() =>
runSupportAction(
() => unlockUser(user.id),
"Benutzer wurde entsperrt.",
router,
),
)
}
>
<Unlock className="h-4 w-4" />
Entsperren
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={() =>
startTransition(() =>
runSupportAction(
() => lockUser(user.id),
"Benutzer wurde gesperrt.",
router,
),
)
}
>
<Lock className="h-4 w-4" />
Sperren
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending || user.failedLoginAttempts === 0}
onClick={() =>
startTransition(() =>
runSupportAction(
() => resetFailedLoginAttempts(user.id),
"Fehlversuche wurden zurückgesetzt.",
router,
),
)
}
>
<RotateCcw className="h-4 w-4" />
Reset
</Button>
<UserDeleteDialog user={user} />
</div>
);
}
function UserDeleteDialog({
user,
}: {
user: {
id: string;
email: string;
name: string;
};
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [confirmationEmail, setConfirmationEmail] = useState("");
const [isPending, startTransition] = useTransition();
const canSubmit = confirmationEmail === user.email && !isPending;
function onSubmit() {
startTransition(async () => {
const result = await deleteUser({
userId: user.id,
confirmationEmail,
});
if (!result.ok) {
toast.error(result.error ?? "Benutzer konnte nicht gelöscht werden.");
return;
}
toast.success("Benutzer wurde gelöscht.");
setOpen(false);
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="destructive" size="sm">
<Trash2 className="h-4 w-4" />
Löschen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Benutzer endgültig löschen</DialogTitle>
<DialogDescription>
Löscht nur das Benutzerkonto von {user.name}. Familie und Kinder
bleiben erhalten.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor={`confirm-user-${user.id}`}>
Zur Bestätigung E-Mail-Adresse eingeben
</Label>
<Input
id={`confirm-user-${user.id}`}
value={confirmationEmail}
onChange={(event) => setConfirmationEmail(event.target.value)}
placeholder={user.email}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={onSubmit}
disabled={!canSubmit}
>
{isPending ? "Lösche..." : "Endgültig löschen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+438
View File
@@ -0,0 +1,438 @@
import "server-only";
import { AuditTargetType, InvitationStatus, UserRole } from "@prisma/client";
import { prisma } from "@/lib/prisma";
const roleOrder: UserRole[] = [
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
UserRole.ELTERN,
];
export type KitaOverviewRow = {
id: string;
name: string;
slug: string;
createdAt: Date;
updatedAt: Date;
modules: {
notdienst: boolean;
termine: boolean;
adressbuch: boolean;
};
counts: {
users: number;
families: number;
activeChildren: number;
termine: number;
absences: number;
announcements: number;
openInvitations: number;
lockedUsers: number;
missingConsent: number;
familiesWithoutUsers: number;
};
lastLoginAt: Date | null;
roleCounts: Record<UserRole, number>;
};
export type GlobalAdminHealth = {
orphanTenantUsers: number;
lockedTenantUsers: number;
openInvitations: number;
missingConsent: number;
kitas: number;
};
export async function getGlobalAdminHealth(): Promise<GlobalAdminHealth> {
const now = new Date();
const [
orphanTenantUsers,
lockedTenantUsers,
openInvitations,
missingConsent,
kitas,
] = await Promise.all([
prisma.user.count({
where: {
kitaId: null,
role: { not: UserRole.SUPERADMIN },
},
}),
prisma.user.count({
where: {
role: { not: UserRole.SUPERADMIN },
lockedUntil: { gt: now },
},
}),
prisma.invitation.count({
where: {
status: InvitationStatus.PENDING,
expiresAt: { gt: now },
},
}),
prisma.user.count({
where: {
role: { not: UserRole.SUPERADMIN },
privacyPolicyAcceptedAt: null,
},
}),
prisma.kita.count(),
]);
return {
orphanTenantUsers,
lockedTenantUsers,
openInvitations,
missingConsent,
kitas,
};
}
export async function getKitaOverviewRows(): Promise<KitaOverviewRow[]> {
const now = new Date();
const kitas = await prisma.kita.findMany({
select: {
id: true,
name: true,
slug: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
createdAt: true,
updatedAt: true,
users: {
select: {
role: true,
lastLoginAt: true,
lockedUntil: true,
privacyPolicyAcceptedAt: true,
},
},
families: {
select: {
users: {
select: { id: true },
},
},
},
children: {
select: { active: true },
},
invitations: {
select: {
status: true,
expiresAt: true,
},
},
_count: {
select: {
termine: true,
absences: true,
announcements: true,
},
},
},
orderBy: { name: "asc" },
});
return kitas.map((kita) => {
const roleCounts = Object.fromEntries(
Object.values(UserRole).map((role) => [role, 0]),
) as Record<UserRole, number>;
let lastLoginAt: Date | null = null;
let lockedUsers = 0;
let missingConsent = 0;
for (const user of kita.users) {
roleCounts[user.role] += 1;
if (user.lastLoginAt && (!lastLoginAt || user.lastLoginAt > lastLoginAt)) {
lastLoginAt = user.lastLoginAt;
}
if (user.lockedUntil && user.lockedUntil > now) {
lockedUsers += 1;
}
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
missingConsent += 1;
}
}
return {
id: kita.id,
name: kita.name,
slug: kita.slug,
createdAt: kita.createdAt,
updatedAt: kita.updatedAt,
modules: {
notdienst: kita.notdienstModuleEnabled,
termine: kita.terminModuleEnabled,
adressbuch: kita.adressbuchModuleEnabled,
},
counts: {
users: kita.users.length,
families: kita.families.length,
activeChildren: kita.children.filter((child) => child.active).length,
termine: kita._count.termine,
absences: kita._count.absences,
announcements: kita._count.announcements,
openInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt > now,
).length,
lockedUsers,
missingConsent,
familiesWithoutUsers: kita.families.filter(
(family) => family.users.length === 0,
).length,
},
lastLoginAt,
roleCounts,
};
});
}
export type KitaDiagnostics = {
kita: {
id: string;
name: string;
slug: string;
createdAt: Date;
updatedAt: Date;
modules: KitaOverviewRow["modules"];
};
counts: KitaOverviewRow["counts"] & {
inactiveChildren: number;
expiredPendingInvitations: number;
};
roleCounts: Record<UserRole, number>;
users: {
id: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
familyName: string | null;
emailVerifiedAt: Date | null;
privacyPolicyAcceptedAt: Date | null;
lastLoginAt: Date | null;
failedLoginAttempts: number;
lockedUntil: Date | null;
createdAt: Date;
}[];
diagnostics: {
lockedUsers: number;
missingConsent: number;
unverifiedUsers: number;
familiesWithoutUsers: number;
usersWithEmptyPassword: number;
};
};
export async function getKitaDiagnostics(
kitaId: string,
): Promise<KitaDiagnostics | null> {
const now = new Date();
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: {
id: true,
name: true,
slug: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
createdAt: true,
updatedAt: true,
users: {
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
passwordHash: true,
emailVerifiedAt: true,
privacyPolicyAcceptedAt: true,
lastLoginAt: true,
failedLoginAttempts: true,
lockedUntil: true,
createdAt: true,
family: {
select: { name: true },
},
},
orderBy: [
{ role: "asc" },
{ lastName: "asc" },
{ firstName: "asc" },
],
},
families: {
select: {
users: { select: { id: true } },
},
},
children: {
select: { active: true },
},
invitations: {
select: {
status: true,
expiresAt: true,
},
},
_count: {
select: {
termine: true,
absences: true,
announcements: true,
},
},
},
});
if (!kita) {
return null;
}
const roleCounts = Object.fromEntries(
Object.values(UserRole).map((role) => [role, 0]),
) as Record<UserRole, number>;
let lockedUsers = 0;
let missingConsent = 0;
let unverifiedUsers = 0;
let usersWithEmptyPassword = 0;
for (const user of kita.users) {
roleCounts[user.role] += 1;
if (user.lockedUntil && user.lockedUntil > now) {
lockedUsers += 1;
}
if (user.role !== UserRole.SUPERADMIN && !user.privacyPolicyAcceptedAt) {
missingConsent += 1;
}
if (!user.emailVerifiedAt) {
unverifiedUsers += 1;
}
if (!user.passwordHash) {
usersWithEmptyPassword += 1;
}
}
const activeChildren = kita.children.filter((child) => child.active).length;
const inactiveChildren = kita.children.length - activeChildren;
const familiesWithoutUsers = kita.families.filter(
(family) => family.users.length === 0,
).length;
return {
kita: {
id: kita.id,
name: kita.name,
slug: kita.slug,
createdAt: kita.createdAt,
updatedAt: kita.updatedAt,
modules: {
notdienst: kita.notdienstModuleEnabled,
termine: kita.terminModuleEnabled,
adressbuch: kita.adressbuchModuleEnabled,
},
},
counts: {
users: kita.users.length,
families: kita.families.length,
activeChildren,
inactiveChildren,
termine: kita._count.termine,
absences: kita._count.absences,
announcements: kita._count.announcements,
openInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt > now,
).length,
expiredPendingInvitations: kita.invitations.filter(
(invitation) =>
invitation.status === InvitationStatus.PENDING &&
invitation.expiresAt <= now,
).length,
lockedUsers,
missingConsent,
familiesWithoutUsers,
},
roleCounts,
users: kita.users.map((user) => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
familyName: user.family?.name ?? null,
emailVerifiedAt: user.emailVerifiedAt,
privacyPolicyAcceptedAt: user.privacyPolicyAcceptedAt,
lastLoginAt: user.lastLoginAt,
failedLoginAttempts: user.failedLoginAttempts,
lockedUntil: user.lockedUntil,
createdAt: user.createdAt,
})),
diagnostics: {
lockedUsers,
missingConsent,
unverifiedUsers,
familiesWithoutUsers,
usersWithEmptyPassword,
},
};
}
export async function recordKitaDiagnosticView({
actorUserId,
actorEmail,
kitaId,
kitaName,
kitaSlug,
}: {
actorUserId: string;
actorEmail: string;
kitaId: string;
kitaName: string;
kitaSlug: string;
}) {
await prisma.auditLog.create({
data: {
actorUserId,
actorEmail,
action: "KITA_DIAGNOSTIC_VIEW",
targetType: AuditTargetType.KITA,
targetId: kitaId,
targetLabel: `${kitaName} (${kitaSlug})`,
targetKitaId: kitaId,
},
});
}
export async function getAuditLogRows() {
return prisma.auditLog.findMany({
select: {
id: true,
actorEmail: true,
action: true,
targetType: true,
targetId: true,
targetLabel: true,
targetKitaId: true,
metadata: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
take: 200,
});
}
export { roleOrder };
+287
View File
@@ -0,0 +1,287 @@
"use server";
import { revalidatePath } from "next/cache";
import { AuditTargetType, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const deleteKitaSchema = z.object({
kitaId: z.string().min(1),
confirmationName: z.string().min(1).trim(),
});
const deleteUserSchema = z.object({
userId: z.string().min(1),
confirmationEmail: z.string().email().toLowerCase().trim(),
});
const userIdSchema = z.string().min(1);
type ActionResult = {
ok: boolean;
error?: string;
};
async function requireSuperadminActor() {
const session = await requireRole([UserRole.SUPERADMIN]);
return {
id: session.user.id,
email: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
};
}
async function getKitaDeleteCounts(kitaId: string) {
const [
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
] = await Promise.all([
prisma.user.count({ where: { kitaId } }),
prisma.family.count({ where: { kitaId } }),
prisma.child.count({ where: { kitaId } }),
prisma.termin.count({ where: { kitaId } }),
prisma.absence.count({ where: { kitaId } }),
prisma.announcement.count({ where: { kitaId } }),
prisma.invitation.count({ where: { kitaId } }),
prisma.eventParticipation.count({ where: { kitaId } }),
]);
return {
users,
families,
children,
termine,
absences,
announcements,
invitations,
eventParticipations,
};
}
export async function deleteKita(
rawPayload: unknown,
): Promise<ActionResult & { redirectTo?: string }> {
const actor = await requireSuperadminActor();
const parsed = deleteKitaSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const kita = await prisma.kita.findUnique({
where: { id: parsed.data.kitaId },
select: { id: true, name: true, slug: true },
});
if (!kita) {
return { ok: false, error: "Kita wurde nicht gefunden." };
}
if (parsed.data.confirmationName !== kita.name) {
return {
ok: false,
error: "Die Bestätigung muss exakt dem Kita-Namen entsprechen.",
};
}
const counts = await getKitaDeleteCounts(kita.id);
await prisma.$transaction(async (tx) => {
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "KITA_DELETE",
targetType: AuditTargetType.KITA,
targetId: kita.id,
targetLabel: `${kita.name} (${kita.slug})`,
targetKitaId: kita.id,
metadata: counts,
},
});
await tx.kita.delete({ where: { id: kita.id } });
});
revalidatePath("/admin");
revalidatePath("/admin/audit");
return { ok: true, redirectTo: "/admin" };
}
async function findMutableTenantUser(userId: string, actorId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
kitaId: true,
familyId: true,
failedLoginAttempts: true,
lockedUntil: true,
kita: {
select: {
name: true,
slug: true,
},
},
family: {
select: {
name: true,
_count: {
select: {
users: true,
},
},
},
},
},
});
if (!user) {
return { error: "Benutzer wurde nicht gefunden." } as const;
}
if (user.id === actorId) {
return { error: "Das eigene Superadmin-Konto kann hier nicht geändert werden." } as const;
}
if (user.role === UserRole.SUPERADMIN) {
return { error: "Andere Superadmins werden in V1 nicht verwaltet." } as const;
}
if (!user.kitaId) {
return { error: "Nur tenant-gebundene Benutzer können verwaltet werden." } as const;
}
return { user } as const;
}
export async function deleteUser(rawPayload: unknown): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = deleteUserSchema.safeParse(rawPayload);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data.userId, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
if (parsed.data.confirmationEmail !== user.email) {
return {
ok: false,
error: "Die Bestätigung muss exakt der Benutzer-E-Mail entsprechen.",
};
}
await prisma.$transaction(async (tx) => {
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action: "USER_DELETE",
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
kitaName: user.kita?.name ?? null,
kitaSlug: user.kita?.slug ?? null,
familyId: user.familyId,
familyName: user.family?.name ?? null,
familyUserCountBeforeDelete: user.family?._count.users ?? null,
},
},
});
await tx.user.delete({ where: { id: user.id } });
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
async function updateUserSupportState(
userId: string,
action: "USER_LOCK" | "USER_UNLOCK" | "USER_FAILED_LOGIN_RESET",
data: {
lockedUntil?: Date | null;
failedLoginAttempts?: number;
},
): Promise<ActionResult> {
const actor = await requireSuperadminActor();
const parsed = userIdSchema.safeParse(userId);
if (!parsed.success) {
return { ok: false, error: "Ungültige Eingabedaten." };
}
const lookup = await findMutableTenantUser(parsed.data, actor.id);
if ("error" in lookup) {
return { ok: false, error: lookup.error };
}
const { user } = lookup;
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data,
});
await tx.auditLog.create({
data: {
actorUserId: actor.id,
actorEmail: actor.email,
action,
targetType: AuditTargetType.USER,
targetId: user.id,
targetLabel: `${user.firstName} ${user.lastName} <${user.email}>`,
targetKitaId: user.kitaId,
metadata: {
role: user.role,
previousLockedUntil: user.lockedUntil?.toISOString() ?? null,
previousFailedLoginAttempts: user.failedLoginAttempts,
},
},
});
});
revalidatePath("/admin");
revalidatePath(`/admin/kitas/${user.kitaId}`);
revalidatePath("/admin/audit");
return { ok: true };
}
export async function lockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_LOCK", {
lockedUntil: new Date("2099-12-31T23:59:59.000Z"),
});
}
export async function unlockUser(userId: string): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_UNLOCK", {
lockedUntil: null,
failedLoginAttempts: 0,
});
}
export async function resetFailedLoginAttempts(
userId: string,
): Promise<ActionResult> {
return updateUserSupportState(userId, "USER_FAILED_LOGIN_RESET", {
failedLoginAttempts: 0,
});
}
+153
View File
@@ -0,0 +1,153 @@
import Link from "next/link";
import type { Metadata } from "next";
import { ArrowLeft, ScrollText } 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 { getAuditLogRows } from "../_lib/data";
export const metadata: Metadata = {
title: "AuditLog · Kita-Planer",
};
function formatDate(date: Date) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function actionLabel(action: string) {
switch (action) {
case "KITA_DIAGNOSTIC_VIEW":
return "Diagnose geöffnet";
case "KITA_DELETE":
return "Kita gelöscht";
case "USER_DELETE":
return "Benutzer gelöscht";
case "USER_LOCK":
return "Benutzer gesperrt";
case "USER_UNLOCK":
return "Benutzer entsperrt";
case "USER_FAILED_LOGIN_RESET":
return "Fehlversuche zurückgesetzt";
default:
return action;
}
}
function metadataPreview(metadata: unknown) {
if (!metadata) return "—";
const preview = JSON.stringify(metadata);
if (!preview) return "—";
return preview.length > 140 ? `${preview.slice(0, 140)}...` : preview;
}
export default async function AuditPage() {
await requireRole([UserRole.SUPERADMIN]);
const rows = await getAuditLogRows();
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<Button asChild variant="ghost" className="-ml-3 mb-3">
<Link href="/admin">
<ArrowLeft className="h-4 w-4" />
Zur Kita-Übersicht
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">AuditLog</h1>
<p className="mt-1 text-sm text-muted-foreground">
Die letzten 200 Superadmin-Diagnose- und Support-Aktionen.
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ScrollText className="h-5 w-5" />
</div>
</div>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Systemweite Aktionen
</CardTitle>
<CardDescription>
Audit-Einträge sind unabhängig von gelöschten Kitas und Benutzern.
</CardDescription>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Noch keine Audit-Einträge vorhanden.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zeitpunkt</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Akteur</TableHead>
<TableHead>Ziel</TableHead>
<TableHead>Metadaten</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap">
{formatDate(row.createdAt)}
</TableCell>
<TableCell>
<Badge
variant={
row.action.endsWith("_DELETE")
? "destructive"
: "secondary"
}
>
{actionLabel(row.action)}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm">{row.actorEmail}</span>
</TableCell>
<TableCell>
<div className="font-medium">{row.targetLabel}</div>
<div className="text-xs text-muted-foreground">
{row.targetType} · {row.targetId}
</div>
</TableCell>
<TableCell className="max-w-sm">
<code className="block truncate rounded bg-muted px-2 py-1 text-xs">
{metadataPreview(row.metadata)}
</code>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+359
View File
@@ -0,0 +1,359 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import {
AlertTriangle,
ArrowLeft,
Baby,
CheckCircle2,
Clock,
Lock,
MailCheck,
MailWarning,
ShieldAlert,
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 {
getKitaDiagnostics,
recordKitaDiagnosticView,
roleOrder,
} from "../../_lib/data";
import { KitaDeleteDialog } from "../../_components/kita-delete-dialog";
import { UserSupportActions } from "../../_components/user-support-actions";
type PageProps = {
params: Promise<{ kitaId: string }>;
};
export const metadata: Metadata = {
title: "Kita-Diagnose · Kita-Planer",
};
function formatDate(date: Date | null) {
if (!date) return "Nie";
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function isLocked(lockedUntil: Date | null) {
return !!lockedUntil && lockedUntil > new Date();
}
export default async function KitaDiagnosticsPage({ params }: PageProps) {
const [{ kitaId }, session] = await Promise.all([
params,
requireRole([UserRole.SUPERADMIN]),
]);
const diagnostics = await getKitaDiagnostics(kitaId);
if (!diagnostics) {
notFound();
}
await recordKitaDiagnosticView({
actorUserId: session.user.id,
actorEmail: session.user.email ?? session.user.name ?? "Unbekannter Superadmin",
kitaId: diagnostics.kita.id,
kitaName: diagnostics.kita.name,
kitaSlug: diagnostics.kita.slug,
});
const issueCards = [
{
label: "Gesperrte Konten",
value: diagnostics.diagnostics.lockedUsers,
icon: Lock,
},
{
label: "Fehlender Consent",
value: diagnostics.diagnostics.missingConsent,
icon: ShieldAlert,
},
{
label: "Unverifizierte E-Mails",
value: diagnostics.diagnostics.unverifiedUsers,
icon: MailWarning,
},
{
label: "Familien ohne Benutzer",
value: diagnostics.diagnostics.familiesWithoutUsers,
icon: Users,
},
];
return (
<div className="px-8 py-8">
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<Button asChild variant="ghost" className="-ml-3 mb-3">
<Link href="/admin">
<ArrowLeft className="h-4 w-4" />
Zur Kita-Übersicht
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">
{diagnostics.kita.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{diagnostics.kita.slug} · angelegt am{" "}
{formatDate(diagnostics.kita.createdAt)}
</p>
</div>
<KitaDeleteDialog
kitaId={diagnostics.kita.id}
kitaName={diagnostics.kita.name}
counts={diagnostics.counts}
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
<MetricCard
icon={Users}
label="Benutzer"
value={diagnostics.counts.users}
description={`${diagnostics.counts.families} Familien`}
/>
<MetricCard
icon={Baby}
label="Aktive Kinder"
value={diagnostics.counts.activeChildren}
description={`${diagnostics.counts.inactiveChildren} inaktiv`}
/>
<MetricCard
icon={Clock}
label="Termine"
value={diagnostics.counts.termine}
description={`${diagnostics.counts.absences} Abwesenheiten`}
/>
<MetricCard
icon={MailCheck}
label="Offene Einladungen"
value={diagnostics.counts.openInvitations}
description={`${diagnostics.counts.expiredPendingInvitations} abgelaufen`}
attention={diagnostics.counts.openInvitations > 0}
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-4">
{issueCards.map(({ label, value, icon: Icon }) => (
<Card
key={label}
className={value > 0 ? "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">{value}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="mb-6 grid gap-4 lg:grid-cols-[1fr_1.4fr]">
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Rollenverteilung
</CardTitle>
<CardDescription>
Nur tenant-gebundene Benutzer dieser Kita.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{roleOrder.map((role) => (
<Badge key={role} variant="secondary">
{role}: {diagnostics.roleCounts[role]}
</Badge>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Modulstatus
</CardTitle>
<CardDescription>
V1 zeigt Module nur read-only, keine Konfigurationsänderungen.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<ModuleBadge label="Notdienst" enabled={diagnostics.kita.modules.notdienst} />
<ModuleBadge label="Termine" enabled={diagnostics.kita.modules.termine} />
<ModuleBadge
label="Adressbuch"
enabled={diagnostics.kita.modules.adressbuch}
/>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle as="h2" className="text-xl">
Benutzer
</CardTitle>
<CardDescription>
Support-Aktionen sind auf Löschen, Sperren, Entsperren und
Fehlversuche zurücksetzen begrenzt.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Benutzer</TableHead>
<TableHead>Rolle / Familie</TableHead>
<TableHead>Status</TableHead>
<TableHead>Login</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{diagnostics.users.map((user) => {
const userIsLocked = isLocked(user.lockedUntil);
const userName = `${user.firstName} ${user.lastName}`.trim();
return (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{userName}</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<Badge variant="secondary">{user.role}</Badge>
<span className="text-xs text-muted-foreground">
{user.familyName ?? "Keine Familie"}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1.5">
{userIsLocked ? (
<Badge variant="destructive">
<Lock className="mr-1 h-3 w-3" />
Gesperrt
</Badge>
) : (
<Badge variant="success">
<CheckCircle2 className="mr-1 h-3 w-3" />
Aktiv
</Badge>
)}
{!user.emailVerifiedAt && (
<Badge variant="warning">E-Mail offen</Badge>
)}
{!user.privacyPolicyAcceptedAt && (
<Badge variant="warning">Consent fehlt</Badge>
)}
{user.failedLoginAttempts > 0 && (
<Badge variant="outline">
{user.failedLoginAttempts} Fehlversuche
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
<div>{formatDate(user.lastLoginAt)}</div>
{userIsLocked && (
<div className="text-xs text-muted-foreground">
bis {formatDate(user.lockedUntil)}
</div>
)}
</div>
</TableCell>
<TableCell className="text-right">
<UserSupportActions
user={{
id: user.id,
email: user.email,
name: userName,
isLocked: userIsLocked,
failedLoginAttempts: user.failedLoginAttempts,
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function MetricCard({
icon: Icon,
label,
value,
description,
attention = false,
}: {
icon: typeof Users;
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">{value}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function ModuleBadge({ label, enabled }: { label: string; enabled: boolean }) {
return (
<Badge variant={enabled ? "success" : "outline"}>
{enabled ? (
<CheckCircle2 className="mr-1 h-3 w-3" />
) : (
<AlertTriangle className="mr-1 h-3 w-3" />
)}
{label}: {enabled ? "aktiv" : "inaktiv"}
</Badge>
);
}
+80
View File
@@ -0,0 +1,80 @@
import type { Metadata } from "next";
import { LogOut, ShieldCheck } from "lucide-react";
import { UserRole } from "@prisma/client";
import { signOut } from "@/auth";
import { requireRole } from "@/lib/auth-utils";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { AdminNav } from "./_components/admin-nav";
export const metadata: Metadata = {
title: "Systemadmin · Kita-Planer",
robots: {
index: false,
follow: false,
},
};
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await requireRole([UserRole.SUPERADMIN]);
return (
<div className="flex min-h-screen bg-muted/30">
<aside className="flex w-64 shrink-0 flex-col border-r bg-background">
<div className="flex h-16 items-center gap-2.5 border-b px-5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ShieldCheck className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-none">
Systemadmin
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Kita-Planer
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto py-4">
<AdminNav />
</div>
<div className="border-t p-3">
<Separator className="mb-3" />
<div className="mb-2 px-3">
<p className="truncate text-xs font-medium">
{session.user.name ?? session.user.email}
</p>
<p className="truncate text-xs text-muted-foreground">
SUPERADMIN
</p>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
</div>
</aside>
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
);
}
+256 -39
View File
@@ -1,9 +1,19 @@
import Link from "next/link";
import type { Metadata } from "next";
import { LogOut, ShieldCheck } from "lucide-react";
import {
AlertTriangle,
ArrowRight,
Building2,
CheckCircle2,
Clock,
Lock,
MailWarning,
Users,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { signOut } from "@/auth";
import { requireRole } from "@/lib/auth-utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -12,54 +22,261 @@ import {
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: "Systemadmin · Kita-Planer",
robots: {
index: false,
follow: false,
},
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() {
const session = await requireRole([UserRole.SUPERADMIN]);
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="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-xl">
<CardHeader className="space-y-3">
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<CardTitle>Systemadmin-Bereich</CardTitle>
<CardDescription className="mt-2">
Du bist als Superadmin angemeldet. Eine zentrale Systemverwaltung
ist noch nicht umgesetzt, deshalb endet der Login hier statt auf
einer nicht vorhandenen Seite.
</CardDescription>
<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 className="space-y-4">
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
Angemeldet als{" "}
<span className="font-medium text-foreground">
{session.user.email ?? session.user.name ?? "Superadmin"}
</span>
</div>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button type="submit" variant="outline">
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</form>
<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>
);
}