Files
Mutual_Fund/frontend/components/admin/sections/overview.tsx
T

319 lines
13 KiB
TypeScript

"use client";
import { Activity, AlertTriangle, BarChart3, CheckCircle2, FileText, Users, Wallet } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { adminApi, errMessage, type DashboardData, type PersonalReport, type RiskAlert, type WorkOrder } from "@/lib/admin-api";
import { isAdvisor, isRiskOfficer } from "@/lib/roles";
import type { UserPayload } from "@/types/api";
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
LoadingBlock,
StatCard,
formatMoney,
formatPercent,
formatTime,
isOverdue,
} from "@/components/admin/ui";
const LEVEL_KEYS = ["C1", "C2", "C3", "C4", "C5", "unknown"];
const LEVEL_LABEL: Record<string, string> = {
C1: "C1 保守型",
C2: "C2 稳健型",
C3: "C3 平衡型",
C4: "C4 成长型",
C5: "C5 进取型",
unknown: "未测评",
};
export function OverviewSection({
user,
onNavigate,
}: {
user: UserPayload;
onNavigate: (section: "todos" | "customers" | "risk" | "workorders") => void;
}) {
const advisor = isAdvisor(user);
const risk = isRiskOfficer(user);
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [personal, setPersonal] = useState<PersonalReport | null>(null);
const [alerts, setAlerts] = useState<RiskAlert[]>([]);
const [orders, setOrders] = useState<WorkOrder[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const tasks: Promise<unknown>[] = [];
if (advisor) {
tasks.push(
adminApi.dashboard().then(setDashboard).catch(() => setDashboard(null)),
adminApi.personalReport().then(setPersonal).catch(() => setPersonal(null))
);
}
if (risk) {
tasks.push(
adminApi
.alerts({ page: 1, page_size: 5 })
.then((result) => setAlerts(result.items ?? []))
.catch(() => setAlerts([])),
adminApi
.workOrders({ page: 1, page_size: 5 })
.then((result) => setOrders(result.items ?? []))
.catch(() => setOrders([]))
);
}
await Promise.all(tasks);
} catch (e) {
setError(errMessage(e, "工作台数据加载失败"));
} finally {
setLoading(false);
}
}, [advisor, risk]);
useEffect(() => {
void load();
}, [load]);
if (loading) return <LoadingBlock text="正在加载工作台数据..." />;
if (error) return <ErrorState message={error} onRetry={() => void load()} />;
const overview = dashboard?.overview;
const pendingTodos = dashboard?.todos?.items ?? [];
const levels = dashboard?.customer_levels ?? {};
const maxLevel = Math.max(1, ...LEVEL_KEYS.map((key) => levels[key] ?? 0));
return (
<div className="space-y-6">
{advisor ? (
<>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard
icon={<Users className="h-5 w-5" />}
label="名下客户"
value={overview?.total_customers ?? "--"}
hint={`已签约 ${overview?.signed_customers ?? 0} · 已结束 ${overview?.closed_customers ?? 0}`}
/>
<StatCard
icon={<Wallet className="h-5 w-5" />}
label="管理资产"
value={overview?.total_aum ? formatMoney(overview.total_aum) : "--"}
hint="名下客户持有中市值合计"
/>
<StatCard
icon={<BarChart3 className="h-5 w-5" />}
label="客户流失率"
value={overview?.churn_rate !== undefined ? formatPercent(overview.churn_rate) : "--"}
hint="已结束 / 总客户"
/>
<StatCard
icon={<Activity className="h-5 w-5" />}
label="待办事项"
value={dashboard?.todos?.pending ?? "--"}
hint={
pendingTodos.some((item) => isOverdue(item.due_at))
? `${pendingTodos.filter((item) => isOverdue(item.due_at)).length} 项已逾期`
: "暂无逾期"
}
hintTone={pendingTodos.some((item) => isOverdue(item.due_at)) ? "danger" : "muted"}
/>
</div>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard label="报告已发送" value={overview?.reports_sent ?? "--"} />
<StatCard label="报告草稿" value={overview?.reports_draft ?? "--"} hint="待发送" />
<StatCard label="累计回访" value={overview?.visits_count ?? "--"} />
<StatCard
label="个人绩效 AUM"
value={personal?.total_aum ? formatMoney(personal.total_aum) : "--"}
hint={`覆盖 ${personal?.total_customers ?? 0} 名客户`}
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Card
title="客户风险分层"
description="按客户风险等级统计人数"
className="lg:col-span-1"
>
{Object.keys(levels).length === 0 ? (
<EmptyState text="暂无分层数据" />
) : (
<div className="space-y-3">
{LEVEL_KEYS.filter((key) => (levels[key] ?? 0) > 0 || key === "unknown").map((key) => (
<div key={key}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-slate-600">{LEVEL_LABEL[key] ?? key}</span>
<span className="font-medium text-slate-900">{levels[key] ?? 0}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-indigo-500"
style={{ width: `${((levels[key] ?? 0) / maxLevel) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</Card>
<Card
title="待办提醒"
description="按到期时间排序"
className="lg:col-span-2"
actions={
<Button size="sm" variant="ghost" onClick={() => onNavigate("todos")}>
全部待办
</Button>
}
>
{pendingTodos.length === 0 ? (
<EmptyState text="暂无待办事项" />
) : (
<ul className="divide-y divide-slate-100">
{pendingTodos.slice(0, 6).map((todo) => (
<li key={todo.id} className="flex flex-wrap items-center justify-between gap-3 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{todo.todo_type}</p>
<p className="mt-0.5 text-xs text-slate-500">
{todo.customer_id ? `客户 #${todo.customer_id}` : "无关联客户"} · 截止{" "}
{formatTime(todo.due_at)}
</p>
</div>
<div className="flex items-center gap-2">
{isOverdue(todo.due_at) && <Badge tone="danger">已逾期</Badge>}
<Badge tone={todo.priority === "高" ? "danger" : todo.priority === "中" ? "warning" : "neutral"}>
{todo.priority ?? "普通"}
</Badge>
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</>
) : (
<Card title="投顾概览">
<EmptyState text="当前账号无投顾权限,投顾概览不可用" />
</Card>
)}
{risk ? (
<div className="grid gap-4 lg:grid-cols-2">
<Card
title="最新风险预警"
actions={
<Button size="sm" variant="ghost" onClick={() => onNavigate("risk")}>
进入风控
</Button>
}
>
{alerts.length === 0 ? (
<EmptyState text="暂无风险预警" />
) : (
<ul className="divide-y divide-slate-100">
{alerts.map((alert) => (
<li key={alert.id} className="flex items-center justify-between gap-3 py-3 text-sm">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span className="text-slate-900">{alert.alert_type}</span>
<span className="text-xs text-slate-500">客户 #{alert.customer_id}</span>
</div>
<Badge tone={alert.alert_level === "高" ? "danger" : alert.alert_level === "中" ? "warning" : "neutral"}>
{alert.alert_level}
</Badge>
</li>
))}
</ul>
)}
</Card>
<Card
title="待处理工单"
actions={
<Button size="sm" variant="ghost" onClick={() => onNavigate("workorders")}>
进入工单
</Button>
}
>
{orders.length === 0 ? (
<EmptyState text="暂无待处理工单" />
) : (
<ul className="divide-y divide-slate-100">
{orders.map((order) => (
<li key={order.id} className="flex items-center justify-between gap-3 py-3 text-sm">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-indigo-500" />
<span className="text-slate-900">{order.order_type}</span>
<span className="text-xs text-slate-400">{order.work_order_no}</span>
</div>
<Badge tone={order.priority === "特急" ? "danger" : order.priority === "紧急" ? "warning" : "neutral"}>
{order.priority}
</Badge>
</li>
))}
</ul>
)}
</Card>
</div>
) : (
<Card title="风控概览">
<EmptyState text="当前账号无风控权限,风控概览不可用" />
</Card>
)}
<Card title="快捷入口" description="常用系统与数据页面">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Link
href="/products"
className="group rounded-xl border border-slate-200 p-4 transition hover:border-indigo-500 hover:shadow-sm"
>
<BarChart3 className="h-5 w-5 text-indigo-600" />
<p className="mt-3 text-sm font-semibold">基金产品</p>
<p className="mt-1 text-xs leading-5 text-slate-500">查看在售基金与历史净值</p>
</Link>
<Link
href="/tools/nl2sql"
className="group rounded-xl border border-slate-200 p-4 transition hover:border-indigo-500 hover:shadow-sm"
>
<Activity className="h-5 w-5 text-indigo-600" />
<p className="mt-3 text-sm font-semibold">NL2SQL 查询</p>
<p className="mt-1 text-xs leading-5 text-slate-500">自然语言查询业务数据</p>
</Link>
<Link
href="/tools/knowledge"
className="group rounded-xl border border-slate-200 p-4 transition hover:border-indigo-500 hover:shadow-sm"
>
<FileText className="h-5 w-5 text-indigo-600" />
<p className="mt-3 text-sm font-semibold">知识库管理</p>
<p className="mt-1 text-xs leading-5 text-slate-500">维护 RAG 文档与检索数据</p>
</Link>
<button
onClick={() => onNavigate("customers")}
className="group rounded-xl border border-slate-200 p-4 text-left transition hover:border-indigo-500 hover:shadow-sm"
>
<Users className="h-5 w-5 text-indigo-600" />
<p className="mt-3 text-sm font-semibold">客户管理</p>
<p className="mt-1 text-xs leading-5 text-slate-500">查看名下客户与持仓诊断</p>
</button>
</div>
{!isAdvisor(user) && (
<p className="mt-4 flex items-center gap-2 text-xs text-amber-600">
<CheckCircle2 className="h-3.5 w-3.5" />
部分入口需要对应角色权限,无权限时会在目标模块给出提示
</p>
)}
</Card>
</div>
);
}