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
cf20aec68c
+16
-1
@@ -256,7 +256,22 @@ function ensureColumns() {
|
|||||||
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
|
// created_by 单独记「谁建的」:公海被领取后 owner_id 会换人,创建人这一笔仍要留痕
|
||||||
['created_by', 'INTEGER'],
|
['created_by', 'INTEGER'],
|
||||||
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
|
// pool_status:owned 名下有主 / public 已丢入公海(同组织可见可领)
|
||||||
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT']
|
['pool_status', "TEXT DEFAULT 'owned'"], ['pool_at', 'TEXT'], ['pool_note', 'TEXT'],
|
||||||
|
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。
|
||||||
|
全部选填 —— 这些是「了解到多少写多少」的东西,不是业务必填项。
|
||||||
|
|
||||||
|
employee_count / annual_revenue 用 TEXT 而不是数字:实际拿到的几乎都是
|
||||||
|
「50-200 人」「€5-10M」这种区间带单位的表述,硬塞进 INTEGER 要么丢精度
|
||||||
|
要么存不进去,还得再加个单位列。这里的用途是「给人看」,不参与计算。 */
|
||||||
|
['founded_year', 'INTEGER'],
|
||||||
|
['employee_count', 'TEXT'],
|
||||||
|
['annual_revenue', 'TEXT'],
|
||||||
|
['profile', 'TEXT'],
|
||||||
|
/* 背调留痕:什么时候查的、信息来自哪些页面。
|
||||||
|
没有来源的「AI 说这家公司有 200 人」是不能用来做决策的,
|
||||||
|
所以来源 URL 必须跟结论一起存,界面上要能点开核对。 */
|
||||||
|
['research_at', 'TEXT'],
|
||||||
|
['research_sources', 'TEXT']
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
for (const [table, columns] of Object.entries(columnMigrations)) {
|
for (const [table, columns] of Object.entries(columnMigrations)) {
|
||||||
|
|||||||
@@ -207,6 +207,16 @@ CREATE TABLE IF NOT EXISTS customers (
|
|||||||
pool_status TEXT DEFAULT 'owned',
|
pool_status TEXT DEFAULT 'owned',
|
||||||
pool_at TEXT,
|
pool_at TEXT,
|
||||||
pool_note TEXT,
|
pool_note TEXT,
|
||||||
|
/* 公司概况(详情页「公司简介」卡片 + AI 背调的落点)。全部选填。
|
||||||
|
employee_count / annual_revenue 用 TEXT 而非数字:实际拿到的几乎都是
|
||||||
|
「50-200 人」「€5-10M」这种带单位的区间表述,用途是给人看、不参与计算。 */
|
||||||
|
founded_year INTEGER,
|
||||||
|
employee_count TEXT,
|
||||||
|
annual_revenue TEXT,
|
||||||
|
profile TEXT,
|
||||||
|
/* 背调留痕:没有来源的结论不能拿来做决策,来源 URL 必须跟结论一起存 */
|
||||||
|
research_at TEXT,
|
||||||
|
research_sources TEXT,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
tags TEXT,
|
tags TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trade-management-backend",
|
"name": "trade-management-backend",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"description": "外贸供应链管理系统 - 后端",
|
"description": "外贸供应链管理系统 - 后端",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+53
-11
@@ -364,23 +364,47 @@ app.post('/api/customers', auth.requirePermission('customer.edit'), (req, res) =
|
|||||||
只有拿到 customer.view_all 的管理账号才允许代别人建档。 */
|
只有拿到 customer.view_all 的管理账号才允许代别人建档。 */
|
||||||
const requestedOwner = customerAccess.seesAll(req.user) ? numberOrNull(req.body?.owner_id) : null;
|
const requestedOwner = customerAccess.seesAll(req.user) ? numberOrNull(req.body?.owner_id) : null;
|
||||||
const ownerId = requestedOwner || (req.user ? req.user.id : null);
|
const ownerId = requestedOwner || (req.user ? req.user.id : null);
|
||||||
const result = db.prepare(`
|
|
||||||
INSERT INTO customers (company, company_en, country, industry, website, address, email, phone, notes,
|
/* 主要联系人:建档时一并填,不用「先存公司 → 进详情页 → 再加联系人」三步。
|
||||||
owner_id, created_by, org_id, source, status, pool_status, created_at)
|
联系人在另一张表,所以必须跟客户放进**同一个事务** —— 分两次写的话,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'owned', ?)
|
客户写成功、联系人写失败就留下一条没人能联系的半成品客户。 */
|
||||||
`).run(company, company_en || null, country || null, industry || null, website || null, address || null,
|
const pc = req.body?.primary_contact || {};
|
||||||
email || null, phone || null, notes || null, ownerId,
|
const contactName = String(pc.name || '').trim();
|
||||||
req.user ? req.user.id : null, req.user ? (req.user.org_id || null) : null,
|
|
||||||
source || null, status, nowIso());
|
const create = db.transaction(() => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO customers (company, company_en, country, industry, website, address, email, phone, notes,
|
||||||
|
owner_id, created_by, org_id, source, status, pool_status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'owned', ?)
|
||||||
|
`).run(company, company_en || null, country || null, industry || null, website || null, address || null,
|
||||||
|
email || null, phone || null, notes || null, ownerId,
|
||||||
|
req.user ? req.user.id : null, req.user ? (req.user.org_id || null) : null,
|
||||||
|
source || null, status, nowIso());
|
||||||
|
|
||||||
|
const customerId = result.lastInsertRowid;
|
||||||
|
if (contactName) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO contacts (customer_id, name, name_en, position, email, phone, whatsapp, wechat, is_primary, notes, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
|
`).run(customerId, contactName,
|
||||||
|
pc.name_en || null, pc.position || null, pc.email || null, pc.phone || null,
|
||||||
|
pc.whatsapp || null, pc.wechat || null, pc.notes || null, nowIso());
|
||||||
|
}
|
||||||
|
return customerId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerId = create();
|
||||||
|
|
||||||
auth.audit({
|
auth.audit({
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
action: 'customer_create',
|
action: 'customer_create',
|
||||||
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}`,
|
detail: `新建客户「${company}」,归属 ${req.user?.display_name || '—'}${contactName ? `,主联系人 ${contactName}` : ''}`,
|
||||||
req
|
req
|
||||||
});
|
});
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
id: result.lastInsertRowid,
|
id: customerId,
|
||||||
owner_id: ownerId,
|
owner_id: ownerId,
|
||||||
|
contact_created: !!contactName,
|
||||||
message: status === 'active' ? '客户已创建并启用' : '客户已保存(未启用)'
|
message: status === 'active' ? '客户已创建并启用' : '客户已保存(未启用)'
|
||||||
});
|
});
|
||||||
} catch (error) { jsonError(res, error); }
|
} catch (error) { jsonError(res, error); }
|
||||||
@@ -396,13 +420,31 @@ app.patch('/api/customers/:id', auth.requirePermission('customer.edit'), (req, r
|
|||||||
}
|
}
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone', 'notes', 'source']) {
|
/* employee_count / annual_revenue / profile 是「公司简介」卡片的字段,
|
||||||
|
走同一条通道 —— 它们跟基本信息一样是人填的文本,没有特殊校验。 */
|
||||||
|
for (const key of ['company', 'company_en', 'country', 'industry', 'website', 'address', 'email', 'phone',
|
||||||
|
'notes', 'source', 'employee_count', 'annual_revenue', 'profile']) {
|
||||||
if (req.body?.[key] === undefined) continue;
|
if (req.body?.[key] === undefined) continue;
|
||||||
const value = req.body[key] === null ? '' : String(req.body[key]).trim();
|
const value = req.body[key] === null ? '' : String(req.body[key]).trim();
|
||||||
if (key === 'company' && !value) throw badRequest('公司名称不能为空');
|
if (key === 'company' && !value) throw badRequest('公司名称不能为空');
|
||||||
fields.push(`${key} = ?`);
|
fields.push(`${key} = ?`);
|
||||||
values.push(value || null);
|
values.push(value || null);
|
||||||
}
|
}
|
||||||
|
/* 成立年份单独处理:它是数字,且范围要卡住 —— 见过 AI 背调把「2024 年营收」
|
||||||
|
当成立年份填进来的,也见过手滑打成 20225。不合理的值宁可拒掉也别存。 */
|
||||||
|
if (req.body?.founded_year !== undefined) {
|
||||||
|
const raw = req.body.founded_year;
|
||||||
|
if (raw === null || raw === '') {
|
||||||
|
fields.push('founded_year = ?'); values.push(null);
|
||||||
|
} else {
|
||||||
|
const year = Number(raw);
|
||||||
|
const thisYear = new Date().getFullYear();
|
||||||
|
if (!Number.isInteger(year) || year < 1800 || year > thisYear) {
|
||||||
|
throw badRequest(`成立年份应在 1800 ~ ${thisYear} 之间`);
|
||||||
|
}
|
||||||
|
fields.push('founded_year = ?'); values.push(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (req.body?.owner_id !== undefined) {
|
if (req.body?.owner_id !== undefined) {
|
||||||
/* 改归属 = 把别人的客户划走,只留给最高级别管理账号 */
|
/* 改归属 = 把别人的客户划走,只留给最高级别管理账号 */
|
||||||
if (!customerAccess.seesAll(req.user)) throw badRequest('只有最高级别系统管理账号可以变更客户归属');
|
if (!customerAccess.seesAll(req.user)) throw badRequest('只有最高级别系统管理账号可以变更客户归属');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trade-management-frontend",
|
"name": "trade-management-frontend",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"description": "外贸供应链管理系统 - 前端",
|
"description": "外贸供应链管理系统 - 前端",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link, useParams } from 'react-router-dom'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {
|
import {
|
||||||
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
|
UsersIcon, ClockIcon, PlusCircleIcon, InquiryIcon, QuoteIcon,
|
||||||
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon
|
ClipboardIcon, RefreshIcon, TargetIcon, PencilIcon, CloseIcon, BuildingIcon
|
||||||
} from '../components/Icons'
|
} from '../components/Icons'
|
||||||
import { useAuth, hasPermission } from '../auth'
|
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 }) {
|
function CustomerProfileModal({ customer, onClose, onSaved }) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
company: customer.company || '',
|
company: customer.company || '',
|
||||||
@@ -416,6 +504,7 @@ function CustomerDetail() {
|
|||||||
const [contactError, setContactError] = useState('')
|
const [contactError, setContactError] = useState('')
|
||||||
const [savingContact, setSavingContact] = useState(false)
|
const [savingContact, setSavingContact] = useState(false)
|
||||||
const [profileOpen, setProfileOpen] = useState(false)
|
const [profileOpen, setProfileOpen] = useState(false)
|
||||||
|
const [companyOpen, setCompanyOpen] = useState(false)
|
||||||
/* 正在编辑的跟进记录;null = 没打开 */
|
/* 正在编辑的跟进记录;null = 没打开 */
|
||||||
const [followUpEditing, setFollowUpEditing] = useState(null)
|
const [followUpEditing, setFollowUpEditing] = useState(null)
|
||||||
|
|
||||||
@@ -483,6 +572,13 @@ function CustomerDetail() {
|
|||||||
const contacts = data?.contacts || []
|
const contacts = data?.contacts || []
|
||||||
const inquiries = data?.inquiries || []
|
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 nextFollowUp = useMemo(() => {
|
||||||
const dated = followups.filter(record => record.next_action_date)
|
const dated = followups.filter(record => record.next_action_date)
|
||||||
@@ -757,6 +853,38 @@ function CustomerDetail() {
|
|||||||
{customer.notes && <p className="notes-box">{customer.notes}</p>}
|
{customer.notes && <p className="notes-box">{customer.notes}</p>}
|
||||||
</section>
|
</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">
|
<section className="dashboard-panel">
|
||||||
<div className="dashboard-panel-header">
|
<div className="dashboard-panel-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -909,6 +1037,14 @@ function CustomerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{companyOpen && (
|
||||||
|
<CompanyProfileModal
|
||||||
|
customer={customer}
|
||||||
|
onClose={() => setCompanyOpen(false)}
|
||||||
|
onSaved={async () => { setCompanyOpen(false); await load() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{profileOpen && (
|
{profileOpen && (
|
||||||
<CustomerProfileModal
|
<CustomerProfileModal
|
||||||
customer={customer}
|
customer={customer}
|
||||||
|
|||||||
@@ -28,15 +28,16 @@ function CustomerManagement() {
|
|||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [formError, setFormError] = useState('')
|
const [formError, setFormError] = useState('')
|
||||||
const [formData, setFormData] = useState({
|
/* 建档时能填的东西尽量都放这儿 —— 原来只有公司名能填,联系人要等保存完
|
||||||
company: '',
|
进详情页再加一遍,等于把一件事拆成三步。主联系人在另一张表,由后端在
|
||||||
country: '',
|
同一个事务里一并建(分两个请求的话中间失败会留下没人能联系的半成品)。 */
|
||||||
industry: '',
|
const EMPTY_FORM = {
|
||||||
website: '',
|
company: '', company_en: '', country: '', industry: '', website: '',
|
||||||
email: '',
|
email: '', phone: '', address: '', source: '', notes: '',
|
||||||
phone: '',
|
primary_contact: { name: '', position: '', email: '', phone: '', whatsapp: '' }
|
||||||
notes: ''
|
}
|
||||||
})
|
|
||||||
|
const [formData, setFormData] = useState(EMPTY_FORM)
|
||||||
|
|
||||||
const fetchCustomers = useCallback(async (nextScope = scope) => {
|
const fetchCustomers = useCallback(async (nextScope = scope) => {
|
||||||
setError('')
|
setError('')
|
||||||
@@ -69,9 +70,13 @@ function CustomerManagement() {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setFormError('')
|
setFormError('')
|
||||||
try {
|
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)
|
setShowAddModal(false)
|
||||||
setFormData({ company: '', country: '', industry: '', website: '', email: '', phone: '', notes: '' })
|
setFormData(EMPTY_FORM)
|
||||||
const newId = response.data?.id
|
const newId = response.data?.id
|
||||||
await fetchCustomers(scope)
|
await fetchCustomers(scope)
|
||||||
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
|
// 新建后直接进详情页 —— 下一步九成是记第一条跟进,不该让用户再自己找
|
||||||
@@ -344,6 +349,88 @@ function CustomerManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="form-group">
|
||||||
<label>备注</label>
|
<label>备注</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1722,3 +1722,49 @@ body { -webkit-font-smoothing: auto; }
|
|||||||
.oe-summary { flex-direction: column; }
|
.oe-summary { flex-direction: column; }
|
||||||
.oe-grid { grid-template-columns: 1fr 1fr; }
|
.oe-grid { grid-template-columns: 1fr 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------- 客户建档表单:分组小标题 ---------------------- */
|
||||||
|
|
||||||
|
/* 新增客户表单里把「主要联系人」跟公司字段分开 —— 混在一起会让人以为
|
||||||
|
联系人也是必填的,而它是「填了才建,留空无所谓」。 */
|
||||||
|
.form-subhead {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.form-subhead small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------- 客户详情:公司简介卡片 ---------------------- */
|
||||||
|
|
||||||
|
.company-profile-text {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--surface-deep);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.85;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 一个字段都没填时的引导语。比摆一排「未填写」有用:
|
||||||
|
它说明白这些信息拿来干什么,人才有动力去填。 */
|
||||||
|
.panel-empty-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 16px 14px;
|
||||||
|
border: 1px dashed var(--border-light);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "happy-export",
|
"name": "happy-export",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
|
"description": "happy 出口通 —— 一体化外贸出口工具系统(SKU 管理 / 知识库 / 客户 CRM / 邮件开发 / AI 回复 / AI 生成)",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Reference in New Issue
Block a user