Add superadmin audit and Kita management
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user