467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { Eye, PieChart, Search, Users } from "lucide-react";
|
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
import {
|
|
adminApi,
|
|
errMessage,
|
|
type Customer,
|
|
type CustomerDetail,
|
|
type Diagnosis,
|
|
type Holding,
|
|
type ReportItem,
|
|
} from "@/lib/admin-api";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
EmptyState,
|
|
ErrorState,
|
|
LoadingBlock,
|
|
Modal,
|
|
Pagination,
|
|
SearchInput,
|
|
Select,
|
|
TableWrap,
|
|
Td,
|
|
Th,
|
|
formatMoney,
|
|
formatPercent,
|
|
formatTime,
|
|
statusTone,
|
|
useToast,
|
|
} from "@/components/admin/ui";
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: "", label: "全部状态" },
|
|
{ value: "已分配", label: "已分配" },
|
|
{ value: "已签约", label: "已签约" },
|
|
{ value: "已结束", label: "已结束" },
|
|
];
|
|
|
|
const REPORT_STATUS: Record<string, string> = {
|
|
draft: "草稿",
|
|
sent: "已发送",
|
|
discarded: "已废弃",
|
|
};
|
|
|
|
export function CustomersSection() {
|
|
const toast = useToast();
|
|
const [items, setItems] = useState<Customer[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [keyword, setKeyword] = useState("");
|
|
const [status, setStatus] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
|
|
const [activeId, setActiveId] = useState<number | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const result = await adminApi.customers({
|
|
status: status || undefined,
|
|
keyword: keyword || undefined,
|
|
page,
|
|
page_size: 10,
|
|
});
|
|
setItems(result.items ?? []);
|
|
setTotal(result.total ?? 0);
|
|
setTotalPages(result.total_pages ?? 0);
|
|
} catch (e) {
|
|
setError(errMessage(e, "客户列表加载失败"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [status, keyword, page]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
return (
|
|
<>
|
|
<Card title="客户管理" description="仅展示名下客户,敏感信息由后端脱敏" bodyClassName="p-0">
|
|
<div className="flex flex-wrap items-center gap-3 border-b border-slate-100 p-4">
|
|
<SearchInput
|
|
className="w-64"
|
|
placeholder="搜索姓名或手机号"
|
|
value={keyword}
|
|
onChange={(event) => {
|
|
setKeyword(event.target.value);
|
|
setPage(1);
|
|
}}
|
|
/>
|
|
<Select value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }}>
|
|
{STATUS_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Button size="sm" className="ml-auto" onClick={() => void load()} loading={loading}>
|
|
<Search className="h-3.5 w-3.5" />
|
|
查询
|
|
</Button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<LoadingBlock />
|
|
) : error ? (
|
|
<ErrorState message={error} onRetry={() => void load()} />
|
|
) : items.length === 0 ? (
|
|
<EmptyState text="没有匹配的客户" />
|
|
) : (
|
|
<>
|
|
<TableWrap>
|
|
<thead>
|
|
<tr>
|
|
<Th>客户</Th>
|
|
<Th>客户等级</Th>
|
|
<Th>风险等级</Th>
|
|
<Th>总资产</Th>
|
|
<Th>关系状态</Th>
|
|
<Th className="text-right">操作</Th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-50">
|
|
{items.map((customer) => (
|
|
<tr key={customer.customer_id} className="transition-colors hover:bg-slate-50">
|
|
<Td>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100 text-sm font-medium text-slate-600">
|
|
<Users className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-slate-900">{customer.real_name ?? "匿名客户"}</p>
|
|
<p className="text-xs text-slate-400">{customer.phone ?? "--"}</p>
|
|
</div>
|
|
</div>
|
|
</Td>
|
|
<Td>{customer.customer_level ?? "--"}</Td>
|
|
<Td>{customer.risk_level ?? "--"}</Td>
|
|
<Td>{customer.total_assets !== null ? formatMoney(customer.total_assets) : "--"}</Td>
|
|
<Td>
|
|
<Badge tone={statusTone(customer.relation_status)}>{customer.relation_status}</Badge>
|
|
</Td>
|
|
<Td>
|
|
<div className="flex justify-end gap-2">
|
|
<Button size="sm" onClick={() => setActiveId(customer.customer_id)}>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
详情
|
|
</Button>
|
|
</div>
|
|
</Td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</TableWrap>
|
|
<Pagination page={page} totalPages={totalPages} total={total} onChange={setPage} />
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
{activeId !== null && (
|
|
<CustomerDetailModal
|
|
customerId={activeId}
|
|
onClose={() => setActiveId(null)}
|
|
onChanged={() => void load()}
|
|
onToast={(text, tone) => toast(text, tone)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CustomerDetailModal({
|
|
customerId,
|
|
onClose,
|
|
onChanged,
|
|
onToast,
|
|
}: {
|
|
customerId: number;
|
|
onClose: () => void;
|
|
onChanged: () => void;
|
|
onToast: (text: string, tone?: "success" | "error" | "info") => void;
|
|
}) {
|
|
const [tab, setTab] = useState<"profile" | "holdings" | "diagnosis" | "reports">("profile");
|
|
const [detail, setDetail] = useState<CustomerDetail | null>(null);
|
|
const [holdings, setHoldings] = useState<Holding[]>([]);
|
|
const [diagnosis, setDiagnosis] = useState<Diagnosis | null>(null);
|
|
const [reports, setReports] = useState<ReportItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [unmasked, setUnmasked] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const [base, holdingList, diag, reportPage] = await Promise.all([
|
|
adminApi.customer(customerId),
|
|
adminApi.holdings(customerId).catch(() => []),
|
|
adminApi.diagnosis(customerId).catch(() => null),
|
|
adminApi.reports(customerId).catch(() => null),
|
|
]);
|
|
setDetail(base);
|
|
setHoldings(holdingList ?? []);
|
|
setDiagnosis(diag);
|
|
setReports(reportPage?.items ?? []);
|
|
} catch (e) {
|
|
setError(errMessage(e, "客户详情加载失败"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
})();
|
|
}, [customerId]);
|
|
|
|
async function unmaskPhone() {
|
|
setBusy(true);
|
|
try {
|
|
const result = await adminApi.customer(customerId, true);
|
|
setDetail(result);
|
|
setUnmasked(true);
|
|
onToast("已查看完整手机号,操作已记入审计台账", "info");
|
|
} catch (e) {
|
|
onToast(errMessage(e, "查看失败"), "error");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function changeRelation(action: "sign" | "close") {
|
|
const text = action === "sign" ? "确认签约该客户?" : "确认结束对该客户的服务?结束後不可恢复。";
|
|
if (!window.confirm(text)) return;
|
|
setBusy(true);
|
|
try {
|
|
await adminApi.setRelation(customerId, action);
|
|
onToast(action === "sign" ? "已签约" : "已结束服务");
|
|
const refreshed = await adminApi.customer(customerId, unmasked);
|
|
setDetail(refreshed);
|
|
onChanged();
|
|
} catch (e) {
|
|
onToast(errMessage(e, "操作失败"), "error");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const tabs = [
|
|
{ key: "profile", label: "基本资料" },
|
|
{ key: "holdings", label: `持仓 (${holdings.length})` },
|
|
{ key: "diagnosis", label: "组合诊断" },
|
|
{ key: "reports", label: `历史报告 (${reports.length})` },
|
|
] as const;
|
|
|
|
return (
|
|
<Modal
|
|
open
|
|
title={`客户 #${customerId} 详情`}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
{detail?.relation_status === "已签约" ? (
|
|
<Button variant="danger" loading={busy} onClick={() => void changeRelation("close")}>
|
|
结束服务
|
|
</Button>
|
|
) : (
|
|
<Button variant="primary" loading={busy} onClick={() => void changeRelation("sign")}>
|
|
签约
|
|
</Button>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<LoadingBlock />
|
|
) : error ? (
|
|
<ErrorState message={error} />
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-2 border-b border-slate-100 pb-3">
|
|
{tabs.map((item) => (
|
|
<button
|
|
key={item.key}
|
|
onClick={() => setTab(item.key)}
|
|
className={
|
|
tab === item.key
|
|
? "rounded-lg bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-600"
|
|
: "rounded-lg px-3 py-1.5 text-xs text-slate-500 hover:bg-slate-100"
|
|
}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{tab === "profile" && detail && (
|
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
|
<Field label="姓名" value={detail.real_name ?? "--"} />
|
|
<Field
|
|
label="手机号"
|
|
value={
|
|
<span className="flex items-center gap-2">
|
|
{detail.phone ?? "--"}
|
|
{!unmasked && (
|
|
<button
|
|
onClick={() => void unmaskPhone()}
|
|
disabled={busy}
|
|
className="text-xs text-indigo-600 hover:underline disabled:opacity-50"
|
|
>
|
|
查看完整号码
|
|
</button>
|
|
)}
|
|
</span>
|
|
}
|
|
/>
|
|
<Field label="客户等级" value={detail.customer_level ?? "--"} />
|
|
<Field label="风险等级" value={detail.risk_level ?? "--"} />
|
|
<Field label="风险评分" value={detail.risk_score?.toString() ?? "--"} />
|
|
<Field label="总资产" value={formatMoney(detail.total_assets)} />
|
|
<Field
|
|
label="关系状态"
|
|
value={<Badge tone={statusTone(detail.relation_status)}>{detail.relation_status}</Badge>}
|
|
/>
|
|
<Field label="签约时间" value={formatTime(detail.signed_time)} />
|
|
<Field label="分配时间" value={formatTime(detail.assigned_time)} />
|
|
</dl>
|
|
)}
|
|
|
|
{tab === "holdings" &&
|
|
(holdings.length === 0 ? (
|
|
<EmptyState text="该客户暂无持仓" />
|
|
) : (
|
|
<div className="max-h-80 overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-slate-500">
|
|
<th className="py-2 text-left">产品</th>
|
|
<th className="py-2 text-right">市值</th>
|
|
<th className="py-2 text-right">浮动盈亏</th>
|
|
<th className="py-2 text-right">收益率</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{holdings.map((holding) => (
|
|
<tr key={holding.product_id}>
|
|
<td className="py-2">
|
|
<p className="text-slate-900">{holding.product_name ?? "--"}</p>
|
|
<p className="text-xs text-slate-400">{holding.product_code ?? ""}</p>
|
|
</td>
|
|
<td className="py-2 text-right">{formatMoney(holding.current_value)}</td>
|
|
<td
|
|
className={`py-2 text-right ${
|
|
Number(holding.profit_loss) >= 0 ? "text-red-600" : "text-emerald-600"
|
|
}`}
|
|
>
|
|
{formatMoney(holding.profit_loss)}
|
|
</td>
|
|
<td
|
|
className={`py-2 text-right ${
|
|
Number(holding.profit_ratio) >= 0 ? "text-red-600" : "text-emerald-600"
|
|
}`}
|
|
>
|
|
{formatPercent(Number(holding.profit_ratio), 2)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
))}
|
|
|
|
{tab === "diagnosis" &&
|
|
(diagnosis ? (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="rounded-lg bg-slate-50 p-4">
|
|
<p className="text-xs text-slate-500">持仓总市值</p>
|
|
<p className="mt-1 text-lg font-semibold">{formatMoney(diagnosis.total_value)}</p>
|
|
</div>
|
|
<div className="rounded-lg bg-slate-50 p-4">
|
|
<p className="text-xs text-slate-500">最大单一产品占比</p>
|
|
<p className="mt-1 text-lg font-semibold">
|
|
{formatPercent(diagnosis.concentration?.top_product_ratio ?? 0)}
|
|
</p>
|
|
<p className="mt-1 truncate text-xs text-slate-400">
|
|
{diagnosis.concentration?.top_product?.product_name ?? "--"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="mb-2 flex items-center gap-2 text-xs font-medium text-slate-500">
|
|
<PieChart className="h-3.5 w-3.5" />
|
|
资产配置分布
|
|
</p>
|
|
{Object.keys(diagnosis.allocation ?? {}).length === 0 ? (
|
|
<EmptyState text="暂无配置数据" />
|
|
) : (
|
|
<div className="space-y-2">
|
|
{Object.entries(diagnosis.allocation).map(([type, value]) => {
|
|
const totalValue = Number(diagnosis.total_value) || 1;
|
|
const ratio = Number(value) / totalValue;
|
|
return (
|
|
<div key={type}>
|
|
<div className="mb-1 flex justify-between text-xs">
|
|
<span className="text-slate-600">{type}</span>
|
|
<span className="text-slate-900">
|
|
{formatMoney(value)} · {formatPercent(ratio)}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
|
<div
|
|
className="h-full rounded-full bg-indigo-500"
|
|
style={{ width: `${Math.min(ratio * 100, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<EmptyState text="暂无诊断数据" />
|
|
))}
|
|
|
|
{tab === "reports" &&
|
|
(reports.length === 0 ? (
|
|
<EmptyState text="暂无历史建议报告" />
|
|
) : (
|
|
<ul className="max-h-80 divide-y divide-slate-100 overflow-y-auto">
|
|
{reports.map((report) => (
|
|
<li key={report.report_id} className="flex items-center justify-between gap-3 py-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm text-slate-900">{report.title ?? "未命名报告"}</p>
|
|
<p className="text-xs text-slate-400">
|
|
{report.intent ?? "--"} · {formatTime(report.send_time)}
|
|
</p>
|
|
</div>
|
|
<Badge tone={statusTone(report.send_status)}>
|
|
{REPORT_STATUS[report.send_status] ?? report.send_status}
|
|
</Badge>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value }: { label: string; value: ReactNode }) {
|
|
return (
|
|
<div>
|
|
<dt className="text-xs text-slate-500">{label}</dt>
|
|
<dd className="mt-0.5 text-slate-900">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|