feat(crm): 建档一步到位 + 客户详情加公司简介
痛点:原来建客户只能填公司名等 7 个字段,联系人必须先保存、进详情页、 再加一遍,一件事拆成三步;详情页也没地方记公司背景。 建档表单 · 补上后端本来就收但表单没暴露的字段:公司英文名、地址、来源 · 新增「主要联系人」一组(姓名/职位/邮箱/电话/WhatsApp),自动标为主联系人 · 联系人与客户在**同一个事务**里创建 —— 分两个请求的话,客户写成功、 联系人写失败会留下一条没人能联系的半成品客户 · 姓名留空则整块不创建,「只想先记个公司名」的用法完全不受影响 公司简介(customers 新增 5 列) · founded_year / employee_count / annual_revenue / profile,外加复用 address · employee_count 与 annual_revenue 用 TEXT 而非数字:实际拿到的都是 「50-200 人」「€5-10M」这种带单位的区间,用途是给人看、不参与计算 · founded_year 卡 1800~今年:见过把「2024 年营收」当成立年份填进来的, 也见过手滑打成 20225,不合理的值宁可拒掉 · 一个字段都没填时显示引导语而不是一排「未填写」—— 说明白这些信息拿来 干什么,人才有动力填 · 跟「编辑客户信息」分成两个弹窗:基本信息是成交必须的,公司简介是 了解多少写多少,混在一起会让人以为后者也得填 为 AI 背调预留:research_at / research_sources 两列已建,背调结果将落到 上述字段,并要求每条结论带来源 URL。 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7281c41434
commit
241fb229c6
@@ -3,7 +3,7 @@ import { Link, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
|
||||
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
|
||||
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon, BuildingIcon
|
||||
} from '../components/Icons'
|
||||
import { useAuth, hasPermission } from '../auth'
|
||||
|
||||
@@ -298,6 +298,94 @@ function FollowUpEditModal({ record, contacts, inquiries, onClose, onSaved }) {
|
||||
}
|
||||
|
||||
/** 编辑客户基本信息(右侧「基本信息」面板的编辑按钮) */
|
||||
/**
|
||||
* 公司简介编辑。
|
||||
*
|
||||
* 跟「编辑客户信息」分成两个弹窗,因为这两组字段的性质不同:
|
||||
* 基本信息是成交必须的(公司名、国家、联系方式),公司简介是了解到多少写多少
|
||||
* 的背景资料。混在一个表单里会让人以为后面这些也得填。
|
||||
*/
|
||||
function CompanyProfileModal({ customer, onClose, onSaved }) {
|
||||
const [form, setForm] = useState({
|
||||
founded_year: customer.founded_year || '',
|
||||
employee_count: customer.employee_count || '',
|
||||
annual_revenue: customer.annual_revenue || '',
|
||||
address: customer.address || '',
|
||||
profile: customer.profile || ''
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const set = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||||
|
||||
const submit = async event => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
/* 成立年份空字符串要转成 null,否则后端会拿 '' 去做数字校验然后报错。
|
||||
其余字段后端会把空串当 null 处理,直接传即可。 */
|
||||
await axios.patch(`/api/customers/${customer.id}`, {
|
||||
...form,
|
||||
founded_year: String(form.founded_year).trim() === '' ? null : Number(form.founded_year)
|
||||
})
|
||||
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">
|
||||
<p className="form-hint">全部选填。谈判和定价时,这些背景常常比联系方式更有用。</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>成立年份</label>
|
||||
<input type="number" min="1800" max={new Date().getFullYear()} placeholder="1998"
|
||||
value={form.founded_year} onChange={e => set('founded_year', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>员工规模</label>
|
||||
<input type="text" placeholder="50-200 人"
|
||||
value={form.employee_count} onChange={e => set('employee_count', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>年营收</label>
|
||||
<input type="text" placeholder="€5-10M"
|
||||
value={form.annual_revenue} onChange={e => set('annual_revenue', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>办公地址</label>
|
||||
<input type="text" value={form.address} onChange={e => set('address', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>公司简介</label>
|
||||
<textarea rows="5" placeholder="主营业务、渠道、规模、合作过的品牌……"
|
||||
value={form.profile} onChange={e => set('profile', e.target.value)} />
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
</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 || '',
|
||||
@@ -416,6 +504,7 @@ function CustomerDetail() {
|
||||
const [contactError, setContactError] = useState('')
|
||||
const [savingContact, setSavingContact] = useState(false)
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
const [companyOpen, setCompanyOpen] = useState(false)
|
||||
/* 正在编辑的跟进记录;null = 没打开 */
|
||||
const [followUpEditing, setFollowUpEditing] = useState(null)
|
||||
|
||||
@@ -483,6 +572,13 @@ function CustomerDetail() {
|
||||
const contacts = data?.contacts || []
|
||||
const inquiries = data?.inquiries || []
|
||||
|
||||
/* 公司简介卡片:一个字段都没填就整块显示引导语,而不是摆一排「未填写」。
|
||||
address 也算进来 —— 它虽然存在基本信息里,但在这张卡上是「办公地址」。 */
|
||||
const hasCompanyProfile = !!(customer && (
|
||||
customer.founded_year || customer.employee_count ||
|
||||
customer.annual_revenue || customer.profile || customer.address
|
||||
))
|
||||
|
||||
// 下一次跟进 = 还没过期或被跳过的记录里最近的那一条
|
||||
const nextFollowUp = useMemo(() => {
|
||||
const dated = followups.filter(record => record.next_action_date)
|
||||
@@ -757,6 +853,38 @@ function CustomerDetail() {
|
||||
{customer.notes && <p className="notes-box">{customer.notes}</p>}
|
||||
</section>
|
||||
|
||||
{/* 公司简介:了解到多少写多少,全部选填。
|
||||
也是 AI 背调结果的落点 —— 查到的成立年份/规模/营收会填进这里。 */}
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
<h2><BuildingIcon size={17} /> 公司简介</h2>
|
||||
<p className="panel-sub">了解到多少写多少,都不是必填</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCompanyOpen(true)}>
|
||||
<PencilIcon size={14} /> 编辑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasCompanyProfile ? (
|
||||
<>
|
||||
<div className="kv-grid">
|
||||
<div className="kv"><span className="kv-label">成立于</span><span className={`kv-value${customer.founded_year ? '' : ' is-empty'}`}>{customer.founded_year || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">员工规模</span><span className={`kv-value${customer.employee_count ? '' : ' is-empty'}`}>{customer.employee_count || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">年营收</span><span className={`kv-value${customer.annual_revenue ? '' : ' is-empty'}`}>{customer.annual_revenue || '未填写'}</span></div>
|
||||
<div className="kv"><span className="kv-label">办公地址</span><span className={`kv-value${customer.address ? '' : ' is-empty'}`}>{customer.address || '未填写'}</span></div>
|
||||
</div>
|
||||
{customer.profile && <p className="company-profile-text">{customer.profile}</p>}
|
||||
</>
|
||||
) : (
|
||||
<p className="panel-empty-hint">
|
||||
还没填公司信息。成立年份、员工规模、年营收、业务简介这些,
|
||||
在谈判和定价时常常比联系方式更有用。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-panel">
|
||||
<div className="dashboard-panel-header">
|
||||
<div>
|
||||
@@ -909,6 +1037,14 @@ function CustomerDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{companyOpen && (
|
||||
<CompanyProfileModal
|
||||
customer={customer}
|
||||
onClose={() => setCompanyOpen(false)}
|
||||
onSaved={async () => { setCompanyOpen(false); await load() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileOpen && (
|
||||
<CustomerProfileModal
|
||||
customer={customer}
|
||||
|
||||
@@ -28,15 +28,16 @@ function CustomerManagement() {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [formData, setFormData] = useState({
|
||||
company: '',
|
||||
country: '',
|
||||
industry: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: ''
|
||||
})
|
||||
/* 建档时能填的东西尽量都放这儿 —— 原来只有公司名能填,联系人要等保存完
|
||||
进详情页再加一遍,等于把一件事拆成三步。主联系人在另一张表,由后端在
|
||||
同一个事务里一并建(分两个请求的话中间失败会留下没人能联系的半成品)。 */
|
||||
const EMPTY_FORM = {
|
||||
company: '', company_en: '', country: '', industry: '', website: '',
|
||||
email: '', phone: '', address: '', source: '', notes: '',
|
||||
primary_contact: { name: '', position: '', email: '', phone: '', whatsapp: '' }
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState(EMPTY_FORM)
|
||||
|
||||
const fetchCustomers = useCallback(async (nextScope = scope) => {
|
||||
setError('')
|
||||
@@ -69,9 +70,13 @@ function CustomerManagement() {
|
||||
setSaving(true)
|
||||
setFormError('')
|
||||
try {
|
||||
const response = await axios.post('/api/customers', formData)
|
||||
/* 联系人没填姓名就不提交这一块 —— 后端只认 name 非空才建联系人,
|
||||
但显式不传更清楚,也省得将来后端改了判断条件就冒出空记录 */
|
||||
const payload = { ...formData }
|
||||
if (!payload.primary_contact?.name?.trim()) delete payload.primary_contact
|
||||
const response = await axios.post('/api/customers', payload)
|
||||
setShowAddModal(false)
|
||||
setFormData({ company: '', country: '', industry: '', website: '', email: '', phone: '', notes: '' })
|
||||
setFormData(EMPTY_FORM)
|
||||
const newId = response.data?.id
|
||||
await fetchCustomers(scope)
|
||||
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
|
||||
@@ -344,6 +349,88 @@ function CustomerManagement() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>公司英文名</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="单证上用的抬头"
|
||||
value={formData.company_en}
|
||||
onChange={e => setFormData({ ...formData, company_en: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>来源</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="展会 / 阿里询盘 / 老客介绍"
|
||||
value={formData.source}
|
||||
onChange={e => setFormData({ ...formData, source: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主要联系人:建档时顺手填掉,不用保存完再进详情页加一遍。
|
||||
姓名留空则整块不创建,所以「只想先记个公司名」的场景不受影响。 */}
|
||||
<div className="form-subhead">
|
||||
主要联系人
|
||||
<small>填了姓名才会创建,自动标为主联系人;留空可以之后再加</small>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>姓名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.primary_contact.name}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, name: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>职位</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="采购经理"
|
||||
value={formData.primary_contact.position}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, position: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>联系人邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.primary_contact.email}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, email: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>联系人电话</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.primary_contact.phone}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, phone: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>WhatsApp</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+49 176 1234567"
|
||||
value={formData.primary_contact.whatsapp}
|
||||
onChange={e => setFormData({ ...formData, primary_contact: { ...formData.primary_contact, whatsapp: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user