Files
Mutual_Fund/frontend/components/admin/sections/visits.tsx
T
2026-09-14 19:00:48 +08:00

230 lines
8.3 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.
"use client";
import { MessageSquareQuote, PhoneCall } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { adminApi, errMessage, type Customer, type TalkTemplate, type Visit } from "@/lib/admin-api";
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
LoadingBlock,
Pagination,
Select,
TextInput,
formatTime,
useToast,
} from "@/components/admin/ui";
const VISIT_TYPES = ["电话回访", "线上沟通", "线下拜访"];
export function VisitsSection() {
const toast = useToast();
const [customers, setCustomers] = useState<Customer[]>([]);
const [items, setItems] = useState<Visit[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [page, setPage] = useState(1);
const [templates, setTemplates] = useState<TalkTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [customerId, setCustomerId] = useState("");
const [visitType, setVisitType] = useState(VISIT_TYPES[0]);
const [visitTime, setVisitTime] = useState("");
const [summary, setSummary] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await adminApi.visits({ page, page_size: 10 });
setItems(result.items ?? []);
setTotal(result.total ?? 0);
setTotalPages(result.total_pages ?? 0);
} catch (e) {
setError(errMessage(e, "回访记录加载失败"));
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
void (async () => {
try {
const [customerPage, templateList] = await Promise.all([
adminApi.customers({ page: 1, page_size: 100 }),
adminApi.talkTemplates().catch(() => []),
]);
setCustomers(customerPage.items ?? []);
setTemplates(templateList ?? []);
if (!customerId && customerPage.items?.length) {
setCustomerId(String(customerPage.items[0].customer_id));
}
} catch {
/* 客户列表失败时用户仍可手动选择 */
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void load();
}, [load]);
function resetForm() {
setEditingId(null);
setVisitType(VISIT_TYPES[0]);
setVisitTime("");
setSummary("");
}
async function submit() {
if (!customerId) {
toast("请先选择客户", "error");
return;
}
if (!visitTime) {
toast("请选择回访时间", "error");
return;
}
setBusy(true);
try {
const body = {
customer_id: Number(customerId),
visit_type: visitType,
visit_time: new Date(visitTime).toISOString(),
summary: summary || undefined,
};
if (editingId) await adminApi.updateVisit(editingId, body);
else await adminApi.createVisit(body);
toast(editingId ? "回访已更新" : "回访已新增");
resetForm();
await load();
} catch (e) {
toast(errMessage(e, "保存失败"), "error");
} finally {
setBusy(false);
}
}
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card title={editingId ? "编辑回访" : "新增回访"} description="人工回访仅留痕,不触发客户记忆更新">
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs text-slate-500">客户</label>
<Select value={customerId} onChange={(event) => setCustomerId(event.target.value)} className="w-full">
<option value="">请选择客户</option>
{customers.map((customer) => (
<option key={customer.customer_id} value={customer.customer_id}>
{customer.real_name ?? "匿名客户"}(#{customer.customer_id})
</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-slate-500">回访类型</label>
<Select value={visitType} onChange={(event) => setVisitType(event.target.value)} className="w-full">
{VISIT_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-slate-500">回访时间</label>
<TextInput type="datetime-local" value={visitTime} onChange={(event) => setVisitTime(event.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs text-slate-500">回访摘要</label>
<textarea
value={summary}
onChange={(event) => setSummary(event.target.value)}
className="min-h-32 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm leading-6 text-slate-700 transition-colors focus:border-indigo-400 focus:outline-none"
placeholder="记录沟通要点"
/>
</div>
<div className="flex gap-3">
<Button variant="primary" loading={busy} onClick={() => void submit()}>
{editingId ? "保存修改" : "保存回访"}
</Button>
{editingId && (
<Button onClick={resetForm} disabled={busy}>
取消编辑
</Button>
)}
</div>
</div>
</Card>
<div className="space-y-4">
<Card title="历史回访" description={`共 ${total} 条记录`} bodyClassName="p-0">
{loading ? (
<LoadingBlock />
) : error ? (
<ErrorState message={error} onRetry={() => void load()} />
) : items.length === 0 ? (
<EmptyState text="暂无回访记录" />
) : (
<>
<ul className="max-h-[420px] divide-y divide-slate-100 overflow-y-auto">
{items.map((visit) => (
<li key={visit.id} className="px-5 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-2 text-sm font-medium text-slate-900">
<PhoneCall className="h-3.5 w-3.5 text-indigo-500" />
{visit.visit_type}
<Badge tone="neutral">客户 #{visit.customer_id}</Badge>
</p>
<p className="mt-1 text-xs text-slate-400">{formatTime(visit.visit_time)}</p>
<p className="mt-2 text-sm leading-6 text-slate-600">{visit.summary ?? "暂无摘要"}</p>
</div>
<Button
size="sm"
onClick={() => {
setEditingId(visit.id);
setCustomerId(String(visit.customer_id));
setVisitType(visit.visit_type ?? VISIT_TYPES[0]);
setVisitTime(visit.visit_time?.slice(0, 16) ?? "");
setSummary(visit.summary ?? "");
}}
>
编辑
</Button>
</div>
</li>
))}
</ul>
<Pagination page={page} totalPages={totalPages} total={total} onChange={setPage} />
</>
)}
</Card>
<Card title="合规话术库" description="仅作参考,正式发送走草稿流程">
{templates.length === 0 ? (
<EmptyState text="暂无话术模板" />
) : (
<ul className="space-y-3">
{templates.map((template) => (
<li key={template.scene} className="rounded-lg bg-slate-50 p-3">
<p className="flex items-center gap-2 text-sm font-medium text-slate-900">
<MessageSquareQuote className="h-3.5 w-3.5 text-indigo-500" />
{template.scene}
</p>
<p className="mt-1 text-xs leading-6 text-slate-600">{template.content}</p>
</li>
))}
</ul>
)}
</Card>
</div>
</div>
);
}