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

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>
);
}