Files
happy-export/frontend/src/pages/CustomerDetail.jsx
T
Drsakura 13d67c804f feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
2026-09-17 14:53:02 +08:00

934 lines
39 KiB
React
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.
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 (
<form className={`quick-follow${open ? ' is-open' : ''}`} onSubmit={submit}>
<div className="quick-follow-head">
<span className="quick-follow-icon"><PlusCircleIcon size={15} /></span>
<input
type="text"
value={form.content}
placeholder="快速记录一条跟进,回车保存"
aria-label="快速记录跟进内容"
onFocus={() => setOpen(true)}
onChange={event => setForm({ ...form, content: event.target.value })}
/>
{!open && canEdit && <button type="submit" className="btn btn-primary btn-sm" disabled={saving}>记录</button>}
</div>
{open && (
<div className="quick-follow-body">
<div className="form-row">
<div className="form-group">
<label>跟进方式</label>
<select
value={form.activity_type}
onChange={event => setForm({ ...form, activity_type: event.target.value })}
>
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="form-group">
<label>对接联系人</label>
<select
value={form.contact_id}
onChange={event => setForm({ ...form, contact_id: event.target.value })}
>
<option value="">不指定</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>下一次跟进动作</label>
<input
type="text"
value={form.next_action}
placeholder="例如:电话确认包装要求"
onChange={event => setForm({ ...form, next_action: event.target.value })}
/>
</div>
<div className="form-group">
<label>下一次跟进日期</label>
<input
type="date"
value={form.next_action_date}
onChange={event => setForm({ ...form, next_action_date: event.target.value })}
/>
</div>
</div>
{inquiries.length > 0 && (
<div className="form-group">
<label>关联询盘</label>
<select
value={form.inquiry_id}
onChange={event => setForm({ ...form, inquiry_id: event.target.value })}
>
<option value="">不关联</option>
{inquiries.map(inquiry => (
<option key={inquiry.id} value={inquiry.id}>
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
</option>
))}
</select>
</div>
)}
{error && <p className="form-error">{error}</p>}
<div className="quick-follow-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={() => { setOpen(false); setError('') }}>
收起
</button>
{canEdit && (
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? '保存中…' : '保存跟进'}
</button>
)}
</div>
</div>
)}
{!open && error && <p className="form-error">{error}</p>}
</form>
)
}
/** 编辑一条跟进记录 —— 字段与快速记录保持一致,避免两套表单口径不一 */
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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑跟进记录</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<form onSubmit={submit}>
<div className="modal-body">
{error && <p className="form-error">{error}</p>}
<div className="form-row">
<div className="form-group">
<label>跟进方式</label>
<select value={form.activity_type} onChange={e => setForm({ ...form, activity_type: e.target.value })}>
{ACTIVITY_TYPES.map(type => <option key={type} value={type}>{type}</option>)}
</select>
</div>
<div className="form-group">
<label>对接联系人</label>
<select value={form.contact_id} onChange={e => setForm({ ...form, contact_id: e.target.value })}>
<option value="">不指定</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>
{contact.name}{contact.position ? ` · ${contact.position}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="form-group">
<label>跟进内容 *</label>
<textarea rows="3" value={form.content} onChange={e => setForm({ ...form, content: e.target.value })} />
</div>
<div className="form-row">
<div className="form-group">
<label>下一次跟进动作</label>
<input
type="text" value={form.next_action}
placeholder="例如:电话确认包装要求"
onChange={e => setForm({ ...form, next_action: e.target.value })}
/>
</div>
<div className="form-group">
<label>下一次跟进日期</label>
<input
type="date" value={form.next_action_date}
onChange={e => setForm({ ...form, next_action_date: e.target.value })}
/>
</div>
</div>
{inquiries.length > 0 && (
<div className="form-group">
<label>关联询盘</label>
<select value={form.inquiry_id} onChange={e => setForm({ ...form, inquiry_id: e.target.value })}>
<option value="">不关联</option>
{inquiries.map(inquiry => (
<option key={inquiry.id} value={inquiry.id}>
{inquiry.inquiry_no} · {inquiry.title || '未命名'}
</option>
))}
</select>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
)
}
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content modal-content-wide" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>编辑客户信息</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭"><CloseIcon size={16} /></button>
</div>
<form onSubmit={submit}>
<div className="modal-body">
{error && <p className="form-error">{error}</p>}
<div className="form-row">
<div className="form-group">
<label>公司名称 *</label>
<input type="text" value={form.company} onChange={e => set('company', e.target.value)} />
</div>
<div className="form-group">
<label>英文名称</label>
<input type="text" value={form.company_en} onChange={e => set('company_en', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>国家 / 地区</label>
<input type="text" value={form.country} onChange={e => set('country', e.target.value)} />
</div>
<div className="form-group">
<label>行业</label>
<input type="text" value={form.industry} onChange={e => set('industry', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>网站</label>
<input type="text" value={form.website} onChange={e => set('website', e.target.value)} placeholder="https://" />
</div>
<div className="form-group">
<label>地址</label>
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} placeholder="报价单抬头会用到" />
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input type="email" value={form.email} onChange={e => set('email', e.target.value)} />
</div>
<div className="form-group">
<label>电话</label>
<input type="text" value={form.phone} onChange={e => set('phone', e.target.value)} />
</div>
</div>
<div className="form-group">
<label>客户状态</label>
<select value={form.status} onChange={e => set('status', e.target.value)}>
<option value="active">活跃(可正常跟进)</option>
<option value="inactive">非活跃</option>
</select>
</div>
<div className="form-group">
<label>备注</label>
<textarea rows="2" value={form.notes} onChange={e => set('notes', e.target.value)} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? '保存中…' : '保存修改'}
</button>
</div>
</form>
</div>
</div>
)
}
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 <div className="loading">加载客户详情…</div>
if (!customer) {
return (
<div className="page-container">
<p className="notice-inline">{error || '客户不存在'}</p>
<Link className="btn btn-secondary btn-sm" to="/customers">返回客户列表</Link>
</div>
)
}
const nextDue = nextFollowUp ? dueState(String(nextFollowUp.next_action_date).slice(0, 10)) : null
return (
<div className="page-container">
<div className="breadcrumb">
<Link to="/customers">客户管理</Link>
<span>/</span>
<span>{customer.company}</span>
</div>
<div className="page-header">
<div>
<h1 className="page-title">{customer.company}</h1>
<p className="page-sub">
{[customer.country, customer.industry].filter(Boolean).join(' · ') || '未填写国家与行业'}
{' · '}
<span className={`pill pill-${customer.status === 'active' ? 'ok' : 'mute'}`}>
{STATUS_LABELS[customer.status] || customer.status || '未标记'}
</span>
</p>
</div>
<div className="header-actions">
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
<RefreshIcon size={14} /> 刷新
</button>
<Link className="btn btn-primary btn-sm" to="/inquiries">
<InquiryIcon size={15} /> 去询盘报价
</Link>
</div>
</div>
{error && <p className="notice-inline">{error}</p>}
<div className="stat-rail inquiry-rail">
<div className="stat-line">
<span className="stat-line-icon"><ClockIcon size={17} /></span>
<span>最近联系</span>
<strong>{customer.last_contact_at ? formatMoment(customer.last_contact_at).slice(0, 16) : '—'}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><TargetIcon size={17} /></span>
<span>下次跟进</span>
<strong>
{nextFollowUp
? <>{String(nextFollowUp.next_action_date).slice(0, 10)} <span className={`pill pill-${nextDue?.tone || 'mute'}`}>{nextDue?.label}</span></>
: '未安排'}
</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
<span>询盘</span><strong>{inquiries.length}</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><QuoteIcon size={17} /></span>
<span>报价单</span>
<strong>
{data.quoteOverview?.count || 0}
{Number(data.quoteOverview?.total_amount) > 0 && ` · ${money(data.quoteOverview.total_amount)}`}
</strong>
</div>
<div className="stat-line">
<span className="stat-line-icon"><ClipboardIcon size={17} /></span>
<span>订单</span>
<strong>
{data.orderOverview?.count || 0}
{Number(data.orderOverview?.total_amount) > 0 && ` · ${money(data.orderOverview.total_amount)}`}
</strong>
</div>
</div>
<div className="detail-grid">
<div className="detail-main">
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><ClockIcon size={17} /> 跟进时间线</h2>
</div>
<span className="panel-period">{followups.length} 条记录</span>
</div>
<QuickFollowUp
customer={customer}
contacts={contacts}
inquiries={inquiries}
onSaved={load}
canEdit={canEdit}
/>
{followups.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">还没有跟进记录</p>
<p className="empty-state-hint">上面那一行就是快速记录入口,写一句话按回车即可。</p>
</div>
) : (
<ol className="timeline">
{followups.map(record => {
const due = dueState(String(record.next_action_date || '').slice(0, 10))
return (
<li className="timeline-item" key={record.id}>
<span className="timeline-dot" />
<div className="timeline-body">
<div className="timeline-head">
<span className="timeline-type">{record.activity_type}</span>
<span className="timeline-time">{formatMoment(record.created_at)}</span>
{canEdit && (
<button
type="button"
className="timeline-edit"
onClick={() => setFollowUpEditing(record)}
title="编辑这条跟进"
aria-label="编辑这条跟进"
>
<PencilIcon size={13} />
</button>
)}
</div>
<p className="timeline-content">{record.content}</p>
{(record.next_action || record.next_action_date) && (
<div className="timeline-next">
<TargetIcon size={13} />
<span>
下次:{record.next_action || '跟进'}
{record.next_action_date ? ` · ${String(record.next_action_date).slice(0, 10)}` : ''}
</span>
{due && <span className={`pill pill-${due.tone}`}>{due.label}</span>}
</div>
)}
{(record.contact_name || record.inquiry_id) && (
<div className="timeline-meta">
{record.contact_name && <span>对接:{record.contact_name}</span>}
{record.inquiry_id && (
<Link to={`/inquiries/${record.inquiry_id}`}>关联询盘 #{record.inquiry_id}</Link>
)}
</div>
)}
</div>
</li>
)
})}
</ol>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><InquiryIcon size={17} /> 关联询盘</h2>
</div>
<Link className="btn btn-secondary btn-sm" to="/inquiries">全部询盘</Link>
</div>
{inquiries.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">还没有询盘</p>
<p className="empty-state-hint">在「询盘管理」里新建一条,把它挂到当前客户名下。</p>
</div>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>询盘号</th><th>标题</th><th>日期</th><th>币种</th><th>状态</th><th className="col-actions">操作</th>
</tr>
</thead>
<tbody>
{inquiries.map(inquiry => (
<tr key={inquiry.id}>
<td className="mono bold">{inquiry.inquiry_no}</td>
<td>{inquiry.title || <span className="muted-note">未命名</span>}</td>
<td className="mono">{inquiry.inquiry_date}</td>
<td>{inquiry.currency || '—'}</td>
<td>{INQUIRY_STATUS_LABELS[inquiry.status] || inquiry.status}</td>
<td className="col-actions">
<Link className="btn-link" to={`/inquiries/${inquiry.id}`}>查看报价</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><QuoteIcon size={17} /> 报价单与订单</h2>
</div>
</div>
<div className="split-lists">
<div>
<h4 className="split-title">客户报价单</h4>
{(data.quoteItems || []).length === 0 ? (
<p className="muted-note">还没有正式报价单。</p>
) : (
<ul className="mini-list">
{data.quoteItems.map(quote => (
<li key={quote.id}>
<span className="mono">{quote.quote_no}</span>
<span className="mini-list-sub">{quote.quote_date}</span>
<b>{money(quote.total_amount, quote.currency)}</b>
</li>
))}
</ul>
)}
</div>
<div>
<h4 className="split-title">订单</h4>
{(data.orderItems || []).length === 0 ? (
<p className="muted-note">还没有成交订单。</p>
) : (
<ul className="mini-list">
{data.orderItems.map(order => (
<li key={order.id}>
<span className="mono">{order.order_no}</span>
<span className="mini-list-sub">
{order.order_date} · {ORDER_STATUS_LABELS[order.status] || order.status}
</span>
<b>{money(order.total_amount, order.currency)}</b>
</li>
))}
</ul>
)}
</div>
</div>
</section>
</div>
<aside className="detail-side">
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2>基本信息</h2>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setProfileOpen(true)}>
<PencilIcon size={14} /> 编辑
</button>
)}
</div>
<div className="kv-grid kv-grid-single">
<div className="kv"><span className="kv-label">公司名称</span><span className="kv-value">{customer.company}</span></div>
<div className="kv"><span className="kv-label">国家 / 地区</span><span className={`kv-value${customer.country ? '' : ' is-empty'}`}>{customer.country || '未填写'}</span></div>
<div className="kv"><span className="kv-label">行业</span><span className={`kv-value${customer.industry ? '' : ' is-empty'}`}>{customer.industry || '未填写'}</span></div>
<div className="kv"><span className="kv-label">网站</span><span className="kv-value">
{customer.website
? <a href={customer.website} target="_blank" rel="noopener noreferrer">{customer.website}</a>
: <span className="is-empty">未填写</span>}
</span></div>
<div className="kv"><span className="kv-label">邮箱</span><span className={`kv-value${customer.email ? '' : ' is-empty'}`}>{customer.email || '未填写'}</span></div>
<div className="kv"><span className="kv-label">电话</span><span className={`kv-value${customer.phone ? '' : ' is-empty'}`}>{customer.phone || '未填写'}</span></div>
<div className="kv"><span className="kv-label">负责人</span><span className={`kv-value${customer.owner_name ? '' : ' is-empty'}`}>{customer.owner_name || '未指派'}</span></div>
<div className="kv"><span className="kv-label">建档时间</span><span className="kv-value">{formatMoment(customer.created_at).slice(0, 10)}</span></div>
</div>
{customer.notes && <p className="notes-box">{customer.notes}</p>}
</section>
<section className="dashboard-panel">
<div className="dashboard-panel-header">
<div>
<h2><UsersIcon size={17} /> 联系人</h2>
</div>
{canEdit && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openContact()}>
<PlusCircleIcon size={14} /> 新增
</button>
)}
</div>
{contacts.length === 0 ? (
<p className="muted-note">还没有联系人。报价和跟进都挂在具体的人身上,建议先补一个。</p>
) : (
<ul className="contact-list">
{contacts.map(contact => (
<li className="contact-card" key={contact.id}>
<div className="contact-head">
<span className="contact-avatar">{(contact.name || '?').slice(0, 1)}</span>
<div>
<div className="contact-name">
{contact.name}
{contact.is_primary ? <span className="pill pill-ok">主要</span> : null}
</div>
<div className="contact-role">
{[contact.position, contact.department].filter(Boolean).join(' · ') || '未填写职位'}
</div>
</div>
{canEdit && (
<button
type="button"
className="contact-edit"
onClick={() => openContact(contact)}
title="编辑联系人"
aria-label={`编辑联系人 ${contact.name}`}
>
<PencilIcon size={13} />
</button>
)}
</div>
<div className="contact-lines">
{contact.email && <span>✉ {contact.email}</span>}
{contact.phone && <span>☎ {contact.phone}</span>}
{contact.whatsapp && <span>WA {contact.whatsapp}</span>}
{contact.wechat && <span>微信 {contact.wechat}</span>}
</div>
</li>
))}
</ul>
)}
</section>
</aside>
</div>
{contactOpen && (
<div className="modal-overlay" onClick={closeContact}>
<div className="modal-content" onClick={event => event.stopPropagation()}>
<div className="modal-header">
<h2>{contactEditing ? '编辑联系人' : '新增联系人'}</h2>
<button type="button" className="icon-btn" onClick={closeContact} aria-label="关闭">×</button>
</div>
<form onSubmit={submitContact}>
<div className="form-row">
<div className="form-group">
<label>姓名 *</label>
<input
type="text" required value={contactForm.name}
onChange={event => setContactForm({ ...contactForm, name: event.target.value })}
/>
</div>
<div className="form-group">
<label>英文名</label>
<input
type="text" value={contactForm.name_en}
onChange={event => setContactForm({ ...contactForm, name_en: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>职位</label>
<input
type="text" value={contactForm.position}
onChange={event => setContactForm({ ...contactForm, position: event.target.value })}
/>
</div>
<div className="form-group">
<label>部门</label>
<input
type="text" value={contactForm.department}
onChange={event => setContactForm({ ...contactForm, department: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>邮箱</label>
<input
type="email" value={contactForm.email}
onChange={event => setContactForm({ ...contactForm, email: event.target.value })}
/>
</div>
<div className="form-group">
<label>电话</label>
<input
type="text" value={contactForm.phone}
onChange={event => setContactForm({ ...contactForm, phone: event.target.value })}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>WhatsApp</label>
<input
type="text" value={contactForm.whatsapp}
onChange={event => setContactForm({ ...contactForm, whatsapp: event.target.value })}
/>
</div>
<div className="form-group">
<label>微信</label>
<input
type="text" value={contactForm.wechat}
onChange={event => setContactForm({ ...contactForm, wechat: event.target.value })}
/>
</div>
</div>
<div className="form-group">
<label>备注</label>
<textarea
rows={2} value={contactForm.notes}
onChange={event => setContactForm({ ...contactForm, notes: event.target.value })}
/>
</div>
<label className="check-inline">
<input
type="checkbox" checked={contactForm.is_primary}
onChange={event => setContactForm({ ...contactForm, is_primary: event.target.checked })}
/>
设为主要联系人
</label>
{contactError && <p className="form-error">{contactError}</p>}
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={closeContact}>取消</button>
<button type="submit" className="btn btn-primary" disabled={savingContact}>
{savingContact ? '保存中…' : (contactEditing ? '保存修改' : '保存')}
</button>
</div>
</form>
</div>
</div>
)}
{profileOpen && (
<CustomerProfileModal
customer={customer}
onClose={() => setProfileOpen(false)}
onSaved={async () => { setProfileOpen(false); await load() }}
/>
)}
{followUpEditing && (
<FollowUpEditModal
record={followUpEditing}
contacts={contacts}
inquiries={inquiries}
onClose={() => setFollowUpEditing(null)}
onSaved={async () => { setFollowUpEditing(null); await load() }}
/>
)}
</div>
)
}
export default CustomerDetail