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