230 lines
8.3 KiB
TypeScript
230 lines
8.3 KiB
TypeScript
"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>
|
|||
|
|
);
|
|||
|
|
}
|