import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import {
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
} from '../components/Icons'
import { useAuth, hasPermission } from '../auth'
const ACTIVITY_TYPES = ['邮件', '电话', '微信', 'WhatsApp', '拜访', '样品', '展会', '其他']
/* 联系人表单的空值模板:新增和「清空重置」共用一份,避免字段改动漏掉某处 */
const EMPTY_CONTACT = {
name: '', name_en: '', position: '', department: '',
email: '', phone: '', whatsapp: '', wechat: '', is_primary: false, notes: ''
}
const STATUS_LABELS = { active: '活跃', inactive: '非活跃', lead: '潜在' }
const INQUIRY_STATUS_LABELS = {
draft: '草稿', quoting: '报价中', quoted: '已报价', won: '已成交', lost: '已流失', closed: '已关闭'
}
const ORDER_STATUS_LABELS = {
pending: '待处理', confirmed: '已确认', producing: '生产中', shipped: '已发货',
completed: '已完成', cancelled: '已取消'
}
function todayKey() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
function money(value, currency) {
const number = Number(value)
if (!Number.isFinite(number)) return '—'
return `${number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${currency ? ` ${currency}` : ''}`
}
function formatMoment(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
/** 下次跟进日期的语义化提示 —— 业务上「逾期」和「今天」要一眼能看出来 */
function dueState(dateKey) {
if (!dateKey) return null
const today = todayKey()
if (dateKey < today) return { tone: 'danger', label: '已逾期' }
if (dateKey === today) return { tone: 'warn', label: '今天' }
return { tone: 'ok', label: '待跟进' }
}
/** 快速记录跟进:默认收起成一个输入框,聚焦或输入时展开完整字段 */
function QuickFollowUp({ customer, contacts, inquiries, onSaved, canEdit }) {
const [open, setOpen] = useState(false)
const [form, setForm] = useState({
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
next_action_date: '', contact_id: '', inquiry_id: ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const submit = async (event) => {
event.preventDefault()
const content = form.content.trim()
if (!content) { setError('写点什么吧,至少留下一句跟进内容'); return }
setSaving(true)
setError('')
try {
await axios.post(`/api/customers/${customer.id}/follow-ups`, {
activity_type: form.activity_type,
content,
next_action: form.next_action.trim() || null,
next_action_date: form.next_action_date || null,
contact_id: form.contact_id ? Number(form.contact_id) : null,
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
})
setForm({
activity_type: ACTIVITY_TYPES[0], content: '', next_action: '',
next_action_date: '', contact_id: '', inquiry_id: ''
})
setOpen(false)
onSaved()
} catch (submitError) {
setError(submitError?.response?.data?.error || '记录失败,请稍后重试')
} finally {
setSaving(false)
}
}
return (
)
}
/** 编辑一条跟进记录 —— 字段与快速记录保持一致,避免两套表单口径不一 */
function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) {
const [form, setForm] = useState({
activity_type: record.activity_type || ACTIVITY_TYPES[0],
content: record.content || '',
next_action: record.next_action || '',
next_action_date: record.next_action_date ? String(record.next_action_date).slice(0, 10) : '',
contact_id: record.contact_id || '',
inquiry_id: record.inquiry_id || ''
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const submit = async event => {
event.preventDefault()
const content = form.content.trim()
if (!content) { setError('跟进内容不能为空'); return }
setSaving(true)
setError('')
try {
await axios.patch(`/api/customers/${record.customer_id}/follow-ups/${record.id}`, {
activity_type: form.activity_type,
content,
next_action: form.next_action.trim() || null,
next_action_date: form.next_action_date || null,
contact_id: form.contact_id ? Number(form.contact_id) : null,
inquiry_id: form.inquiry_id ? Number(form.inquiry_id) : null
})
onSaved()
} catch (err) {
setError(err?.response?.data?.error || '保存失败,请稍后重试')
setSaving(false)
}
}
return (
event.stopPropagation()}>
编辑跟进记录
)
}
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
function CustomerProfileModal({ customer, onClose, onSaved }) {
const [form, setForm] = useState({
company: customer.company || '',
company_en: customer.company_en || '',
country: customer.country || '',
industry: customer.industry || '',
website: customer.website || '',
address: customer.address || '',
email: customer.email || '',
phone: customer.phone || '',
notes: customer.notes || '',
status: customer.status === 'inactive' ? 'inactive' : 'active'
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
const submit = async event => {
event.preventDefault()
if (!form.company.trim()) { setError('公司名称不能为空'); return }
setSaving(true)
setError('')
try {
await axios.patch(`/api/customers/${customer.id}`, { ...form, company: form.company.trim() })
onSaved()
} catch (err) {
setError(err?.response?.data?.error || '保存失败,请稍后重试')
setSaving(false)
}
}
return (
event.stopPropagation()}>
编辑客户信息
)
}
function CustomerDetail() {
const { id } = useParams()
const { user } = useAuth()
const canEdit = hasPermission(user, 'customer.edit')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [contactOpen, setContactOpen] = useState(false)
/* 为 null = 新增联系人;有值 = 正在编辑这条联系人 */
const [contactEditing, setContactEditing] = useState(null)
const [contactForm, setContactForm] = useState({ ...EMPTY_CONTACT })
const [contactError, setContactError] = useState('')
const [savingContact, setSavingContact] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
/* 正在编辑的跟进记录;null = 没打开 */
const [followUpEditing, setFollowUpEditing] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const response = await axios.get(`/api/customers/${id}`)
setData(response.data)
} catch (loadError) {
setError(loadError?.response?.data?.error || '读取客户详情失败')
} finally {
setLoading(false)
}
}, [id])
useEffect(() => { load() }, [load])
/* 打开联系人弹窗:不传参数是新增,传了联系人就是编辑 */
const openContact = (contact = null) => {
setContactEditing(contact)
setContactError('')
setContactForm(contact ? {
name: contact.name || '',
name_en: contact.name_en || '',
position: contact.position || '',
department: contact.department || '',
email: contact.email || '',
phone: contact.phone || '',
whatsapp: contact.whatsapp || '',
wechat: contact.wechat || '',
is_primary: !!contact.is_primary,
notes: contact.notes || ''
} : { ...EMPTY_CONTACT })
setContactOpen(true)
}
const closeContact = () => {
setContactOpen(false)
setContactEditing(null)
setContactError('')
setContactForm({ ...EMPTY_CONTACT })
}
const submitContact = async (event) => {
event.preventDefault()
if (!contactForm.name.trim()) { setContactError('联系人姓名不能为空'); return }
setSavingContact(true)
setContactError('')
try {
const payload = { ...contactForm, name: contactForm.name.trim() }
if (contactEditing) await axios.patch(`/api/customers/${id}/contacts/${contactEditing.id}`, payload)
else await axios.post(`/api/customers/${id}/contacts`, payload)
closeContact()
await load()
} catch (submitError) {
setContactError(submitError?.response?.data?.error || (contactEditing ? '保存联系人失败' : '新增联系人失败'))
} finally {
setSavingContact(false)
}
}
const customer = data?.customer
const followups = data?.followups || []
const contacts = data?.contacts || []
const inquiries = data?.inquiries || []
// 下一次跟进 = 还没过期或被跳过的记录里最近的那一条
const nextFollowUp = useMemo(() => {
const dated = followups.filter(record => record.next_action_date)
if (!dated.length) return null
return [...dated].sort((a, b) => String(a.next_action_date).localeCompare(String(b.next_action_date)))
.find(record => record.next_action_date >= todayKey()) || dated[0]
}, [followups])
if (loading) return 加载客户详情…
if (!customer) {
return (
{error || '客户不存在'}
返回客户列表
)
}
const nextDue = nextFollowUp ? dueState(String(nextFollowUp.next_action_date).slice(0, 10)) : null
return (
客户管理
/
{customer.company}
{customer.company}
{[customer.country, customer.industry].filter(Boolean).join(' · ') || '未填写国家与行业'}
{' · '}
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
去询盘报价
{error &&
{error}
}
最近联系
{customer.last_contact_at ? formatMoment(customer.last_contact_at).slice(0, 16) : '—'}
下次跟进
{nextFollowUp
? <>{String(nextFollowUp.next_action_date).slice(0, 10)} {nextDue?.label}>
: '未安排'}
询盘{inquiries.length}
报价单
{data.quoteOverview?.count || 0}
{Number(data.quoteOverview?.total_amount) > 0 && ` · ${money(data.quoteOverview.total_amount)}`}
订单
{data.orderOverview?.count || 0}
{Number(data.orderOverview?.total_amount) > 0 && ` · ${money(data.orderOverview.total_amount)}`}
跟进时间线
{followups.length} 条记录
{followups.length === 0 ? (
还没有跟进记录
上面那一行就是快速记录入口,写一句话按回车即可。
) : (
{followups.map(record => {
const due = dueState(String(record.next_action_date || '').slice(0, 10))
return (
-
{record.activity_type}
{formatMoment(record.created_at)}
{canEdit && (
)}
{record.content}
{(record.next_action || record.next_action_date) && (
下次:{record.next_action || '跟进'}
{record.next_action_date ? ` · ${String(record.next_action_date).slice(0, 10)}` : ''}
{due && {due.label}}
)}
{(record.contact_name || record.inquiry_id) && (
{record.contact_name && 对接:{record.contact_name}}
{record.inquiry_id && (
关联询盘 #{record.inquiry_id}
)}
)}
)
})}
)}
{inquiries.length === 0 ? (
还没有询盘
在「询盘管理」里新建一条,把它挂到当前客户名下。
) : (
| 询盘号 | 标题 | 日期 | 币种 | 状态 | 操作 |
{inquiries.map(inquiry => (
| {inquiry.inquiry_no} |
{inquiry.title || 未命名} |
{inquiry.inquiry_date} |
{inquiry.currency || '—'} |
{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status} |
查看报价
|
))}
)}
客户报价单
{(data.quoteItems || []).length === 0 ? (
还没有正式报价单。
) : (
{data.quoteItems.map(quote => (
-
{quote.quote_no}
{quote.quote_date}
{money(quote.total_amount, quote.currency)}
))}
)}
订单
{(data.orderItems || []).length === 0 ? (
还没有成交订单。
) : (
{data.orderItems.map(order => (
-
{order.order_no}
{order.order_date} · {ORDER_STATUS_LABELS[order.status] || order.status}
{money(order.total_amount, order.currency)}
))}
)}
{contactOpen && (
event.stopPropagation()}>
{contactEditing ? '编辑联系人' : '新增联系人'}
)}
{profileOpen && (
setProfileOpen(false)}
onSaved={async () => { setProfileOpen(false); await load() }}
/>
)}
{followUpEditing && (
setFollowUpEditing(null)}
onSaved={async () => { setFollowUpEditing(null); await load() }}
/>
)}
)
}
export default CustomerDetail