Files
teatea-pension/modules/settings/components/SettingsOverview.tsx

423 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Activity, Building2, Server, ShieldCheck, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type {
AuditLog,
JoinRequest,
Membership,
Organization,
PublicAccount,
RoleDefinition,
SystemIncident,
} from "@/modules/core/types";
import { ROLE_LABELS } from "@/modules/core/types";
import { UserManagementClient } from "@/modules/settings/components/UserManagementClient";
import type { RoleId } from "@/modules/core/types";
type SettingsOverviewProps = {
accounts: PublicAccount[];
roles: RoleDefinition[];
auditLogs: AuditLog[];
organizations: Organization[];
memberships: Membership[];
joinRequests: JoinRequest[];
incidents: SystemIncident[];
};
function formatRoleLabel(role: string): string {
if (role in ROLE_LABELS) {
return ROLE_LABELS[role as RoleId];
}
return role;
}
export function SettingsOverview({
accounts,
roles,
auditLogs,
organizations,
memberships,
joinRequests,
incidents,
}: SettingsOverviewProps): React.ReactElement {
const activeAccountCount = accounts.filter((account) => account.status === "active").length;
const disabledAccountCount = accounts.length - activeAccountCount;
const activeOrganizationCount = organizations.filter((organization) => organization.status === "active").length;
const openIncidentCount = incidents.filter((incident) => incident.status === "open").length;
const deniedAuditCount = auditLogs.filter((log) => log.result === "denied").length;
const failureAuditCount = auditLogs.filter((log) => log.result === "failure").length;
const successAuditCount = auditLogs.filter((log) => log.result === "success").length;
const totalPermissionCount = roles.reduce((sum, role) => sum + role.permissions.length, 0);
const actionCounts = auditLogs.reduce<Record<string, number>>((counts, log) => {
counts[log.action] = (counts[log.action] ?? 0) + 1;
return counts;
}, {});
const topActions = Object.entries(actionCounts)
.sort((left, right) => right[1] - left[1])
.slice(0, 4);
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="border-b pb-5">
<Badge variant="success">System configuration</Badge>
<h1 className="mt-3 text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</section>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{organizations.length}</CardTitle>
</div>
<Building2 className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground"> {activeOrganizationCount} </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{accounts.length}</CardTitle>
</div>
<Users className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground"> {activeAccountCount} {disabledAccountCount} </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{roles.length}</CardTitle>
</div>
<ShieldCheck className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground"> {totalPermissionCount} </p>
</CardContent>
</Card>
<Card className={openIncidentCount > 0 ? "border-red-200 bg-red-50/70" : undefined}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription className={openIncidentCount > 0 ? "text-red-700" : undefined}></CardDescription>
<CardTitle className={openIncidentCount > 0 ? "mt-2 text-3xl text-red-700" : "mt-2 text-3xl"}>
{openIncidentCount}
</CardTitle>
</div>
<Server className={openIncidentCount > 0 ? "size-5 text-red-600" : "size-5 text-primary"} aria-hidden="true" />
</CardHeader>
<CardContent className="pt-0">
<p className={openIncidentCount > 0 ? "text-sm text-red-700" : "text-sm text-muted-foreground"}>
{incidents.length} {openIncidentCount}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardDescription></CardDescription>
<CardTitle className="mt-2 text-3xl">{auditLogs.length}</CardTitle>
</div>
<Activity className="size-5 text-primary" aria-hidden="true" />
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-muted-foreground"> {successAuditCount} 100 </p>
</CardContent>
</Card>
</section>
<section className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<div className="flex items-center justify-between rounded-md border border-red-200 bg-red-50 p-3">
<span className="text-sm text-red-700">/</span>
<Badge variant="danger">{deniedAuditCount + failureAuditCount}</Badge>
</div>
<div className="flex items-center justify-between rounded-md border border-red-200 bg-red-50 p-3">
<span className="text-sm text-red-700"></span>
<Badge variant="danger">{openIncidentCount}</Badge>
</div>
<div className="flex items-center justify-between rounded-md border border-amber-200 bg-amber-50 p-3">
<span className="text-sm text-amber-700"></span>
<Badge variant="warning">{disabledAccountCount}</Badge>
</div>
<div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 p-3">
<span className="text-sm text-emerald-700">绿</span>
<Badge variant="success">{successAuditCount}</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
{topActions.map(([action, count]) => (
<div key={action} className="grid gap-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">{action}</span>
<span className="text-muted-foreground">{count} </span>
</div>
<div className="h-2 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary"
style={{ width: `${Math.max(10, Math.min(100, count * 18))}%` }}
/>
</div>
</div>
))}
{topActions.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
</section>
<UserManagementClient organizations={organizations} roles={roles} joinRequests={joinRequests} />
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[560px] text-sm">
<thead className="bg-secondary text-left text-xs text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium">Slug</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
{organizations.map((organization) => (
<tr key={organization.id}>
<td className="px-4 py-3 font-medium">{organization.name}</td>
<td className="px-4 py-3 text-muted-foreground">{organization.slug}</td>
<td className="px-4 py-3">
<Badge variant={organization.status === "active" ? "success" : "danger"}>
{organization.status === "active" ? "启用" : "停用"}
</Badge>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{new Date(organization.createdAt).toLocaleString("zh-CN")}
</td>
</tr>
))}
{organizations.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={4}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{incidents.slice(0, 6).map((incident) => (
<div key={incident.id} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{incident.title}</p>
<Badge
variant={incident.severity === "critical" ? "danger" : incident.severity === "warning" ? "warning" : "secondary"}
>
{incident.status}
</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">{incident.description}</p>
<p className="mt-2 text-xs text-muted-foreground">
{incident.source} / {new Date(incident.createdAt).toLocaleString("zh-CN")}
</p>
</div>
))}
{incidents.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{roles.map((role) => (
<div key={role.id} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{role.label}</p>
<Badge variant="secondary">{role.permissions.length} </Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">{role.description}</p>
<div className="mt-3 flex flex-wrap gap-2">
{role.permissions.map((permission) => (
<Badge key={permission} variant="outline">
{permission}
</Badge>
))}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[620px] text-sm">
<thead className="bg-secondary text-left text-xs text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
{accounts.map((account) => (
<tr key={account.id}>
<td className="px-4 py-3">
<p className="font-medium">{account.name}</p>
<p className="text-xs text-muted-foreground">{account.email}</p>
</td>
<td className="px-4 py-3">{formatRoleLabel(account.role)}</td>
<td className="px-4 py-3">
<Badge variant={account.status === "active" ? "success" : account.status === "pending" ? "warning" : "danger"}>
{account.status === "active" ? "启用" : account.status === "pending" ? "待审批" : "停用"}
</Badge>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{new Date(account.createdAt).toLocaleString("zh-CN")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[720px] text-sm">
<thead className="bg-secondary text-left text-xs text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
{memberships.map((membership) => (
<tr key={membership.id}>
<td className="px-4 py-3 text-muted-foreground">{membership.accountId.slice(0, 8)}</td>
<td className="px-4 py-3 text-muted-foreground">{membership.organizationId.slice(0, 8)}</td>
<td className="px-4 py-3">{membership.roleLabel}</td>
<td className="px-4 py-3">
<Badge variant={membership.status === "active" ? "success" : membership.status === "pending" ? "warning" : "danger"}>
{membership.status}
</Badge>
</td>
<td className="px-4 py-3 text-right text-muted-foreground">
{new Date(membership.createdAt).toLocaleString("zh-CN")}
</td>
</tr>
))}
{memberships.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[860px] text-sm">
<thead className="bg-secondary text-left text-xs text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y bg-card">
{auditLogs.map((log) => (
<tr key={log.id}>
<td className="px-4 py-3 text-muted-foreground">
{new Date(log.timestamp).toLocaleString("zh-CN")}
</td>
<td className="px-4 py-3">{log.actorEmail ?? "系统"}</td>
<td className="px-4 py-3">{log.action}</td>
<td className="px-4 py-3 text-muted-foreground">
{log.targetType}
{log.targetId ? ` / ${log.targetId.slice(0, 8)}` : ""}
</td>
<td className="px-4 py-3">
<Badge
variant={
log.result === "success" ? "success" : log.result === "denied" ? "warning" : "danger"
}
>
{log.result}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{log.reason}</td>
</tr>
))}
{auditLogs.length === 0 ? (
<tr>
<td className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}