319 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|