Files
booschat/web/app/dashboard/tickets/_components/ticket-customer-panel.tsx
T
2026-04-24 12:01:30 +08:00

530 lines
16 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 {
Building2Icon,
Link2Icon,
MailIcon,
PencilIcon,
PhoneIcon,
UserRoundIcon,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { type CustomerFormSavePayload } from "@/components/customer-form"
import { CustomerFormDialog } from "@/components/customer-form-dialog"
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { updateCompany, type AdminCompany } from "@/lib/api/company"
import {
fetchCustomer,
saveCustomerProfile,
type AdminCustomer,
} from "@/lib/api/customer"
import {
fetchCustomerContacts,
type AdminCustomerContact,
} from "@/lib/api/customer-contact"
import { Gender, GenderLabels, ContactType, ContactTypeLabels } from "@/lib/generated/enums"
import { cn, formatDateTime } from "@/lib/utils"
function contactTypeLabel(contactType: ContactType | string) {
return ContactTypeLabels[contactType as ContactType] ?? contactType
}
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
const cls = "size-3.5 shrink-0 text-muted-foreground"
switch (contactType) {
case ContactType.Mobile:
return <PhoneIcon className={cls} aria-hidden />
case ContactType.Email:
return <MailIcon className={cls} aria-hidden />
default:
return <Link2Icon className={cls} aria-hidden />
}
}
function DetailRow({
label,
value,
valueClassName,
}: {
label: string
value: string
valueClassName?: string
}) {
const empty = !value.trim()
return (
<div className="flex gap-2.5 text-sm leading-snug">
<span className="w-17 shrink-0 pt-px text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 flex-1 break-all text-foreground",
empty && "text-muted-foreground",
valueClassName,
)}
>
{empty ? "—" : value}
</span>
</div>
)
}
function SectionHeading({
children,
action,
}: {
children: React.ReactNode
action?: React.ReactNode
}) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-medium text-muted-foreground">{children}</h3>
{action}
</div>
)
}
function UnlinkedCustomerEmpty({
ticketId,
onSuccess,
}: {
ticketId: number
onSuccess: () => void | Promise<void>
}) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
return (
<div className="space-y-4 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground">尚未关联 CRM 客户</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
当前工单未绑定客户主档。绑定后可在此查看客户资料、公司信息与联系方式。
</p>
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
<Link2Icon className="size-4" />
关联或创建客户
</Button>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
ticketId={ticketId}
onSuccess={onSuccess}
/>
</div>
)
}
function MissingCustomerEmpty({
ticketId,
onSuccess,
}: {
ticketId: number
onSuccess: () => void | Promise<void>
}) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false)
return (
<div className="space-y-4 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground">客户已删除或不存在</p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
当前工单绑定的客户主档已不可用。你可以重新关联已有客户,或直接新建一个客户并绑定到当前工单。
</p>
<Button type="button" className="mt-4 gap-2" onClick={() => setLinkDialogOpen(true)}>
<Link2Icon className="size-4" />
重新关联或创建客户
</Button>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
ticketId={ticketId}
onSuccess={onSuccess}
/>
</div>
)
}
type TicketCustomerPanelProps = {
ticketId: number
customerId?: number
onRefresh: () => void | Promise<void>
}
type TicketLinkedCustomerPanelProps = {
ticketId: number
customerId: number
onRefresh: () => void | Promise<void>
}
export function TicketCustomerPanel({
ticketId,
customerId = 0,
onRefresh,
}: TicketCustomerPanelProps) {
if (customerId <= 0) {
return <UnlinkedCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
}
return (
<TicketLinkedCustomerPanel
ticketId={ticketId}
customerId={customerId}
onRefresh={onRefresh}
/>
)
}
function TicketLinkedCustomerPanel({
ticketId,
customerId,
onRefresh,
}: TicketLinkedCustomerPanelProps) {
const linkedCustomerId = customerId
const [loading, setLoading] = useState(true)
const [customer, setCustomer] = useState<AdminCustomer | null>(null)
const [contacts, setContacts] = useState<AdminCustomerContact[]>([])
const [customerEditOpen, setCustomerEditOpen] = useState(false)
const [customerEditSaving, setCustomerEditSaving] = useState(false)
const [companyEditOpen, setCompanyEditOpen] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const c = await fetchCustomer(linkedCustomerId)
setCustomer(c)
if (!c) {
setContacts([])
return
}
const list = await fetchCustomerContacts(linkedCustomerId)
setContacts(Array.isArray(list) ? list : [])
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载客户信息失败")
setCustomer(null)
setContacts([])
} finally {
setLoading(false)
}
}, [linkedCustomerId])
useEffect(() => {
void load()
}, [load])
if (loading && !customer) {
return <p className="pt-4 text-sm text-muted-foreground">加载客户信息…</p>
}
if (!customer) {
return <MissingCustomerEmpty ticketId={ticketId} onSuccess={onRefresh} />
}
const displayName = customer.name.trim() || "未填写姓名"
const company = customer.company ?? null
const genderLabel =
customer.gender === Gender.Male || customer.gender === Gender.Female
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
: null
const isProfileEmpty =
!customer.name.trim() &&
!customer.primaryMobile.trim() &&
!customer.primaryEmail.trim() &&
customer.companyId === 0 &&
!customer.remark.trim()
return (
<div className="space-y-4 pt-2">
{isProfileEmpty ? (
<div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100">
客户主档已关联,但基础信息尚未填写。请点击「编辑」补全资料。
</div>
) : null}
<section className="space-y-2">
<SectionHeading
action={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCustomerEditOpen(true)}
>
<PencilIcon className="size-3.5" />
编辑
</Button>
}
>
客户信息
</SectionHeading>
<div className="flex min-w-0 items-start gap-2 text-sm">
<UserRoundIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 leading-snug text-foreground">
<span className="font-medium">{displayName}</span>
{genderLabel ? (
<span className="font-normal text-muted-foreground"> · {genderLabel}</span>
) : null}
</p>
</div>
</div>
<div className="space-y-2">
<DetailRow label="手机" value={customer.primaryMobile || ""} />
<DetailRow label="邮箱" value={customer.primaryEmail || ""} />
<DetailRow
label="最近活跃"
value={customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""}
/>
<DetailRow
label="备注"
value={customer.remark.trim() ? customer.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
</section>
<section className="space-y-2">
<SectionHeading>联系方式</SectionHeading>
{contacts.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无联系方式</p>
) : (
<ul className="space-y-3">
{contacts.map((row) => {
const tags: string[] = []
if (row.isPrimary) tags.push("主")
if (row.isVerified) tags.push("已验证")
return (
<li key={row.id} className="text-sm">
<div className="flex items-center gap-2">
<ContactTypeIcon contactType={row.contactType} />
<div className="min-w-0 flex-1">
<p className="break-all font-medium leading-snug text-foreground">
{row.contactValue}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{contactTypeLabel(row.contactType)}
</span>
{tags.length > 0 ? (
<span className="ml-2 text-xs text-muted-foreground">
{tags.join(" · ")}
</span>
) : null}
</p>
{row.remark ? (
<p className="mt-1 line-clamp-3 break-all text-xs leading-relaxed text-muted-foreground">
{row.remark}
</p>
) : null}
</div>
</div>
</li>
)
})}
</ul>
)}
</section>
<section className="space-y-2 border-t pt-2">
<SectionHeading
action={
company ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCompanyEditOpen(true)}
>
<PencilIcon className="size-3.5" />
编辑
</Button>
) : null
}
>
公司信息
</SectionHeading>
{company ? (
<div className="space-y-2">
<div className="flex min-w-0 items-start gap-2 text-sm">
<Building2Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 font-medium leading-snug text-foreground">
{company.name}
</p>
{company.code ? (
<p className="font-mono text-xs text-muted-foreground">{company.code}</p>
) : null}
</div>
</div>
<div className="space-y-2 pt-1">
<DetailRow label="创建" value={formatDateTime(company.createdAt)} />
<DetailRow label="更新" value={formatDateTime(company.updatedAt)} />
<DetailRow
label="备注"
value={company.remark.trim() ? company.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
未关联公司。可通过编辑客户资料补充公司信息。
</p>
)}
</section>
<CustomerFormDialog
open={customerEditOpen}
onOpenChange={setCustomerEditOpen}
saving={customerEditSaving}
itemId={customer.id}
onSave={async (payload: CustomerFormSavePayload) => {
if (customerEditSaving) {
return
}
setCustomerEditSaving(true)
try {
await saveCustomerProfile({ ...payload, id: customer.id })
toast.success("已保存")
await load()
await onRefresh()
setCustomerEditOpen(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败")
} finally {
setCustomerEditSaving(false)
}
}}
/>
{company ? (
<CompanyEditDialog
open={companyEditOpen}
onOpenChange={setCompanyEditOpen}
company={company}
onSaved={async () => {
await load()
await onRefresh()
}}
/>
) : null}
</div>
)
}
type CompanyEditDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
company: AdminCompany
onSaved: () => void | Promise<void>
}
function CompanyEditDialog({
open,
onOpenChange,
company,
onSaved,
}: CompanyEditDialogProps) {
const [name, setName] = useState("")
const [code, setCode] = useState("")
const [remark, setRemark] = useState("")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
return
}
setName(company.name)
setCode(company.code)
setRemark(company.remark)
}, [open, company])
const handleSubmit = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error("公司名称不能为空")
return
}
setSaving(true)
try {
await updateCompany({
id: company.id,
name: trimmedName,
code: code.trim(),
remark: remark.trim(),
})
toast.success("已保存")
await onSaved()
onOpenChange(false)
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle>编辑公司</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-1">
<Field orientation="vertical">
<FieldLabel htmlFor="ticket-company-name">公司名称</FieldLabel>
<FieldContent>
<Input
id="ticket-company-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="ticket-company-code">公司编码</FieldLabel>
<FieldContent>
<Input
id="ticket-company-code"
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="ticket-company-remark">备注</FieldLabel>
<FieldContent>
<Textarea
id="ticket-company-remark"
value={remark}
onChange={(event) => setRemark(event.target.value)}
rows={3}
/>
</FieldContent>
</Field>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}