Files
BossChat/dashboard/components/customer-link-or-create-dialog.tsx
T
2026-04-15 17:20:57 +08:00

272 lines
8.2 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 { useEffect, useState } from "react"
import { toast } from "sonner"
import { CustomerForm, type CustomerFormSavePayload } from "@/components/customer-form"
import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { linkConversationToCustomer } from "@/lib/api/agent"
import { fetchCustomers, saveCustomerProfile, type AdminCustomer } from "@/lib/api/customer"
import { linkTicketToCustomer } from "@/lib/api/ticket"
export type CustomerLinkOrCreateDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** 传入时会话侧:关联已有或新建后绑定该会话 */
conversationId?: number | null
/** 传入时工单侧:关联已有或新建后绑定该工单 */
ticketId?: number | null
/** 绑定成功或仅新建成功后的回调 */
onSuccess?: () => void | Promise<void>
}
const createFormId = "customer-link-or-create-form"
export function CustomerLinkOrCreateDialog({
open,
onOpenChange,
conversationId,
ticketId,
onSuccess,
}: CustomerLinkOrCreateDialogProps) {
const [searchText, setSearchText] = useState("")
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<AdminCustomer[]>([])
const [showCreate, setShowCreate] = useState(false)
const [linkingId, setLinkingId] = useState<number | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
return
}
setSearchText("")
setResults([])
setShowCreate(false)
setLinkingId(null)
}, [open])
const runSearch = async () => {
const q = searchText.trim()
if (!q) {
toast.error("请输入关键词(姓名、手机、邮箱、公司、联系方式等)")
return
}
setSearching(true)
try {
const data = await fetchCustomers({
keyword: q,
page: 1,
limit: 50,
status: 0,
})
setResults(data.results)
if (data.results.length === 0) {
toast.message("未找到匹配客户,可点击下方填写新客户")
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "搜索失败")
} finally {
setSearching(false)
}
}
const handleLinkExisting = async (customer: AdminCustomer) => {
if (!conversationId && !ticketId) {
toast.success(`已选择客户:${customer.name || `#${customer.id}`}`)
onOpenChange(false)
await onSuccess?.()
return
}
setLinkingId(customer.id)
try {
if (conversationId) {
await linkConversationToCustomer({
conversationId,
customerId: customer.id,
})
} else if (ticketId) {
await linkTicketToCustomer({
ticketId,
customerId: customer.id,
})
}
toast.success("已关联客户")
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : "关联失败")
} finally {
setLinkingId(null)
}
}
const onCreateSave = async (payload: CustomerFormSavePayload) => {
setSaving(true)
try {
const created = await saveCustomerProfile(payload)
if (conversationId) {
await linkConversationToCustomer({
conversationId,
customerId: created.id,
})
toast.success("已创建客户并关联当前会话")
} else if (ticketId) {
await linkTicketToCustomer({
ticketId,
customerId: created.id,
})
toast.success("已创建客户并关联当前工单")
} else {
toast.success("已创建客户")
}
onOpenChange(false)
await onSuccess?.()
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败")
} finally {
setSaving(false)
}
}
const description = (
<>
先搜索已有客户;
{conversationId
? "选中即可关联当前会话。"
: ticketId
? "选中即可关联当前工单。"
: "未接入上下文时仅创建或定位客户。"}
若无结果,可填写下方新客户
{conversationId
? ",保存后将自动关联会话。"
: ticketId
? ",保存后将自动关联工单。"
: "。"}
</>
)
return (
<ProjectDialog
open={open}
onOpenChange={(nextOpen) => onOpenChange(nextOpen)}
title="关联或创建客户"
description={description}
allowFullscreen
size="xl"
footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
关闭
</Button>
{showCreate ? (
<Button type="submit" form={createFormId} disabled={saving}>
{saving
? "提交中…"
: conversationId
? "创建并关联会话"
: ticketId
? "创建并关联工单"
: "创建客户"}
</Button>
) : null}
</div>
}
>
<div className="flex flex-col gap-4">
<div className="flex gap-2">
<Input
placeholder="姓名 / 手机 / 邮箱 / 公司 / 联系方式"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void runSearch();
}
}}
/>
<Button
type="button"
variant="secondary"
disabled={searching}
onClick={() => void runSearch()}
>
{searching ? "搜索中…" : "搜索"}
</Button>
</div>
{results.length > 0 ? (
<ul className="max-h-48 space-y-1.5 overflow-y-auto rounded-md border border-border p-2 text-sm">
{results.map((row) => (
<li
key={row.id}
className="flex items-center justify-between gap-2 rounded border border-transparent px-2 py-1.5 hover:bg-muted/40"
>
<div className="min-w-0">
<div className="truncate font-medium flex items-center gap-2">
<span>{row.name || `客户 #${row.id}`}</span>
<span className="text-muted-foreground">
{row.primaryMobile}
</span>
<span className="text-muted-foreground">
{row.primaryEmail}
</span>
</div>
{row.company?.name ? (
<div className="truncate text-muted-foreground text-xs">
{row.company.name}
</div>
) : null}
</div>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0"
disabled={linkingId !== null}
onClick={() => void handleLinkExisting(row)}
>
{linkingId === row.id
? "处理中…"
: conversationId
? "关联"
: ticketId
? "关联"
: "选用"}
</Button>
</li>
))}
</ul>
) : null}
<div className="border-t border-border pt-2">
<button
type="button"
className="text-sm text-primary underline-offset-4 hover:underline"
onClick={() => setShowCreate((v) => !v)}
>
{showCreate ? "收起新建表单" : "未找到?填写新客户"}
</button>
</div>
{showCreate ? (
<CustomerForm
formId={createFormId}
onSave={onCreateSave}
fieldIdPrefix="link-or-create"
remarkRows={2}
className="flex flex-col gap-3 rounded-lg border border-border bg-muted/10 p-3"
/>
) : null}
</div>
</ProjectDialog>
);
}