本地长期开发成果的整体入库快照,包含后端鉴权/权限、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
602 lines
24 KiB
React
602 lines
24 KiB
React
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { Link, useParams } from 'react-router-dom'
|
||
import axios from 'axios'
|
||
import Drawer from '../components/Drawer'
|
||
import SkuPicker from '../components/SkuPicker'
|
||
import { SkuThumb, useSkuThumbs } from '../components/SkuThumb'
|
||
import {
|
||
PlusCircleIcon, PencilIcon, TrashIcon, PackageIcon, ClockIcon,
|
||
InquiryIcon, DollarIcon, RefreshIcon
|
||
} from '../components/Icons'
|
||
import { useAuth, hasPermission } from '../auth'
|
||
|
||
const STATUS_LABELS = {
|
||
draft: '草稿',
|
||
quoting: '报价中',
|
||
quoted: '已报价',
|
||
won: '已成交',
|
||
lost: '已流失',
|
||
closed: '已关闭'
|
||
}
|
||
const STATUS_TONE = {
|
||
draft: 'mute',
|
||
quoting: 'warn',
|
||
quoted: 'ok',
|
||
won: 'ok',
|
||
lost: 'danger',
|
||
closed: 'mute'
|
||
}
|
||
|
||
const CURRENCIES = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD']
|
||
|
||
/**
|
||
* 报价合计。
|
||
* 口径说明:inquiry_items.currency 存的是「采购币种」,而 EXW 是拿采购价除以汇率算出来的,
|
||
* 所以 EXW / 小计 / 合计都落在「报价币种」上,也就是 inquiry.currency。
|
||
* 这里不做跨币种换算 —— 一张询盘的报价币种是固定的(汇率那一栏的分母就是它)。
|
||
*/
|
||
function summarize(items) {
|
||
return items.reduce((sum, item) => sum + Number(item.exw || 0) * Number(item.quantity || 0), 0)
|
||
}
|
||
|
||
function money(value, digits = 2) {
|
||
const number = Number(value)
|
||
if (!Number.isFinite(number)) return '—'
|
||
return number.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })
|
||
}
|
||
|
||
/** 包装快照读出来做「一行摘要」,让报价行不用展开就知道箱规 */
|
||
function packagingBrief(snapshot) {
|
||
if (!snapshot) return null
|
||
let data = null
|
||
try { data = JSON.parse(snapshot) } catch { return null }
|
||
if (!data) return null
|
||
const parts = []
|
||
if (data.carton_qty) parts.push(`${data.carton_qty} pcs/箱`)
|
||
if (data.carton_length && data.carton_width && data.carton_height) {
|
||
parts.push(`${data.carton_length}×${data.carton_width}×${data.carton_height} cm`)
|
||
}
|
||
if (data.carton_weight) parts.push(`毛重 ${data.carton_weight} kg`)
|
||
if (data.packaging_material) parts.push(data.packaging_material)
|
||
return parts.length ? parts : null
|
||
}
|
||
|
||
/**
|
||
* 询盘明细编辑器。
|
||
* 两种输入方式:
|
||
* 自动 —— 填加价率,EXW 由「采购价 ÷ 汇率 ×(1+加价率)」算出(这是报价的标准姿势)
|
||
* 手动 —— 直接给 EXW,反算加价率(客户先给目标价时用)
|
||
* 两者互斥,切换时保留当前能推导的量,避免把用户已经填好的数字清掉。
|
||
*/
|
||
function ItemEditor({ inquiry, item, existingSkus = [], onClose, onSaved, canEdit }) {
|
||
const isNew = !item
|
||
const [skuChoice, setSkuChoice] = useState(isNew ? null : { sku: item.sku, display_sku: item.sku, source: 'existing' })
|
||
const [pickerOpen, setPickerOpen] = useState(isNew)
|
||
const [mode, setMode] = useState('auto')
|
||
const [form, setForm] = useState(() => ({
|
||
purchase_price: item ? String(item.purchase_price ?? '') : '',
|
||
// 采购币种默认取人民币 —— 供应商报价绝大多数是 CNY,拿询盘的报价币种当默认值会把人带偏
|
||
currency: item?.currency || 'CNY',
|
||
exchange_rate: item ? String(item.exchange_rate ?? '') : '',
|
||
markup_rate: item ? String(((Number(item.markup_rate) || 0) * 100).toFixed(2).replace(/\.?0+$/, '')) : '25',
|
||
manual_exw: '',
|
||
quantity: item ? String(item.quantity ?? '') : '1'
|
||
}))
|
||
const [error, setError] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
const purchasePrice = Number(form.purchase_price)
|
||
const exchangeRate = Number(form.exchange_rate)
|
||
const markupRate = Number(form.markup_rate) / 100
|
||
const manualExw = Number(form.manual_exw)
|
||
|
||
const base = Number.isFinite(purchasePrice) && Number.isFinite(exchangeRate) && exchangeRate > 0
|
||
? purchasePrice / exchangeRate
|
||
: null
|
||
|
||
const preview = useMemo(() => {
|
||
if (base === null) return null
|
||
if (mode === 'manual') {
|
||
if (!Number.isFinite(manualExw) || manualExw <= 0) return null
|
||
return { exw: manualExw, markup: manualExw / base - 1 }
|
||
}
|
||
if (!Number.isFinite(markupRate) || markupRate < -1) return null
|
||
return { exw: base * (1 + markupRate), markup: markupRate }
|
||
}, [mode, base, manualExw, markupRate])
|
||
|
||
const pickSku = async (choice) => {
|
||
setSkuChoice(choice)
|
||
setPickerOpen(false)
|
||
setError('')
|
||
// 命中 SKU 服务时返回的是完整货号,顺手把采购价/币种带出来,省一次手输
|
||
if (choice.source === 'manual') return
|
||
setForm(current => ({
|
||
...current,
|
||
purchase_price: choice.price === null || choice.price === undefined ? current.purchase_price : String(choice.price),
|
||
currency: choice.currency || current.currency
|
||
}))
|
||
}
|
||
|
||
const submit = async (event) => {
|
||
event.preventDefault()
|
||
if (!skuChoice?.sku) { setError('请先选择货号'); return }
|
||
const quantity = Number(form.quantity)
|
||
if (!Number.isInteger(quantity) || quantity <= 0) { setError('数量必须是大于 0 的整数'); return }
|
||
if (!Number.isFinite(purchasePrice) || purchasePrice <= 0) { setError('采购价必须是大于 0 的数字'); return }
|
||
if (!Number.isFinite(exchangeRate) || exchangeRate <= 0) { setError('汇率必须是大于 0 的数字'); return }
|
||
|
||
const payload = {
|
||
sku: skuChoice.sku,
|
||
purchase_price: purchasePrice,
|
||
currency: form.currency,
|
||
exchange_rate: exchangeRate,
|
||
quantity
|
||
}
|
||
if (mode === 'manual') {
|
||
if (!Number.isFinite(manualExw) || manualExw <= 0) { setError('手动 EXW 必须是大于 0 的数字'); return }
|
||
payload.manual_exw = manualExw
|
||
} else {
|
||
payload.markup_rate = markupRate
|
||
}
|
||
|
||
setSaving(true)
|
||
setError('')
|
||
try {
|
||
if (isNew) await axios.post(`/api/inquiries/${inquiry.id}/items`, payload)
|
||
else await axios.patch(`/api/inquiry-items/${item.id}`, payload)
|
||
onSaved()
|
||
} catch (saveError) {
|
||
setError(saveError?.response?.data?.error || '保存失败,请稍后重试')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const brief = item ? packagingBrief(item.packaging_snapshot) : null
|
||
|
||
return (
|
||
<>
|
||
<Drawer
|
||
wide
|
||
title={isNew ? '添加报价明细' : `编辑明细 · ${item.sku}`}
|
||
sub={isNew ? `询盘 ${inquiry?.inquiry_no || ''}` : item.product_name}
|
||
onClose={onClose}
|
||
footer={(
|
||
<>
|
||
<button type="button" className="btn btn-secondary" onClick={onClose}>取消</button>
|
||
{canEdit && (
|
||
<button type="submit" form="inquiry-item-form" className="btn btn-primary" disabled={saving}>
|
||
{saving ? '保存中…' : '保存明细'}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
>
|
||
<form id="inquiry-item-form" onSubmit={submit}>
|
||
<div className="panel-title">货号</div>
|
||
<div className="sku-chosen">
|
||
{skuChoice ? (
|
||
<>
|
||
<div className="sku-chosen-main">
|
||
<SkuThumb sku={skuChoice.sku} filename={skuChoice.thumb} size={36} />
|
||
<b>{skuChoice.display_sku || skuChoice.sku}</b>
|
||
<span>{skuChoice.name || skuChoice.spec || (skuChoice.source === 'manual' ? '手工输入货号' : '')}</span>
|
||
</div>
|
||
{canEdit && (
|
||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPickerOpen(true)}>
|
||
更换货号
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="muted-note">还没有选择货号。</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="panel-title">报价参数</div>
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>采购价(含税单价)</label>
|
||
<input
|
||
type="number" min="0" step="0.01" inputMode="decimal"
|
||
value={form.purchase_price}
|
||
onChange={event => setForm({ ...form, purchase_price: event.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>采购币种</label>
|
||
<select value={form.currency} onChange={event => setForm({ ...form, currency: event.target.value })}>
|
||
{CURRENCIES.map(currency => <option key={currency} value={currency}>{currency}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-group">
|
||
<label>汇率(1 {inquiry?.currency || '报价币'} = ? {form.currency})</label>
|
||
<input
|
||
type="number" min="0" step="0.0001" inputMode="decimal"
|
||
value={form.exchange_rate}
|
||
placeholder="例如 6.7"
|
||
onChange={event => setForm({ ...form, exchange_rate: event.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>数量</label>
|
||
<input
|
||
type="number" min="1" step="1" inputMode="numeric"
|
||
value={form.quantity}
|
||
onChange={event => setForm({ ...form, quantity: event.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel-title">定价方式</div>
|
||
<div className="mode-switch" role="tablist">
|
||
<button
|
||
type="button" role="tab" aria-selected={mode === 'auto'}
|
||
className={mode === 'auto' ? 'is-active' : ''}
|
||
onClick={() => setMode('auto')}
|
||
>
|
||
按加价率算 EXW
|
||
</button>
|
||
<button
|
||
type="button" role="tab" aria-selected={mode === 'manual'}
|
||
className={mode === 'manual' ? 'is-active' : ''}
|
||
onClick={() => setMode('manual')}
|
||
>
|
||
手填 EXW 反算加价率
|
||
</button>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
{mode === 'auto' ? (
|
||
<div className="form-group">
|
||
<label>加价率</label>
|
||
<span className="pkg-input">
|
||
<input
|
||
type="number" step="0.1" inputMode="decimal"
|
||
value={form.markup_rate}
|
||
placeholder="例如 25"
|
||
onChange={event => setForm({ ...form, markup_rate: event.target.value })}
|
||
/>
|
||
<em>%</em>
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="form-group">
|
||
<label>目标 EXW 单价</label>
|
||
<input
|
||
type="number" min="0" step="0.01" inputMode="decimal"
|
||
value={form.manual_exw}
|
||
placeholder="客户给的目标价"
|
||
onChange={event => setForm({ ...form, manual_exw: event.target.value })}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="form-group">
|
||
<label>{mode === 'auto' ? '加价系数' : '反算加价系数'}</label>
|
||
<div className="readout">
|
||
{preview ? (preview.markup + 1).toFixed(4) : '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="exw-preview">
|
||
<div className="exw-preview-formula">
|
||
EXW = 采购价 ÷ 汇率 ×(1 + 加价率)
|
||
</div>
|
||
<div className="exw-preview-main">
|
||
<span>
|
||
EXW 单价
|
||
<em className="exw-preview-currency">报价币种 {inquiry?.currency || '—'}</em>
|
||
</span>
|
||
<strong>{preview ? `${money(preview.exw, 4)} ${inquiry?.currency || ''}` : '—'}</strong>
|
||
</div>
|
||
<div className="exw-preview-sub">
|
||
{base === null ? (
|
||
<span>填好采购价和汇率后自动计算。</span>
|
||
) : (
|
||
<>
|
||
<span>采购价折报价币 <b>{money(base, 4)}</b></span>
|
||
<span>加价率 <b>{preview ? `${(preview.markup * 100).toFixed(2)}%` : '—'}</b></span>
|
||
<span>小计 <b>{preview ? `${money(preview.exw * Number(form.quantity || 0), 2)} ${inquiry?.currency || ''}` : '—'}</b></span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{brief && (
|
||
<>
|
||
<div className="panel-title">包装(本次报价时的快照)</div>
|
||
<ul className="snapshot-list">
|
||
{brief.map(text => <li key={text}><PackageIcon size={13} /> {text}</li>)}
|
||
</ul>
|
||
<p className="muted-note">快照在报价那一刻定格,之后产品包装信息再改也不会动这行报价。</p>
|
||
</>
|
||
)}
|
||
|
||
{error && <p className="form-error">{error}</p>}
|
||
</form>
|
||
</Drawer>
|
||
|
||
{pickerOpen && (
|
||
<div className="modal-overlay" onClick={() => (isNew ? onClose() : setPickerOpen(false))}>
|
||
<div className="modal-content sku-picker-modal" onClick={event => event.stopPropagation()}>
|
||
<SkuPicker
|
||
excludeSkus={existingSkus}
|
||
onClose={() => (isNew ? onClose() : setPickerOpen(false))}
|
||
onPick={pickSku}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function InquiryDetail() {
|
||
const { id } = useParams()
|
||
const { user } = useAuth()
|
||
const canEdit = hasPermission(user, 'inquiry.edit')
|
||
const [data, setData] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [editor, setEditor] = useState(null) // { item } 或 { item: null } 表示新增
|
||
const [expandedPackaging, setExpandedPackaging] = useState(null)
|
||
|
||
/* 明细里的货号只在本地库里,图片得按货号批量问 SKU 服务要 */
|
||
const itemThumbs = useSkuThumbs((data?.items || []).map(row => row.sku))
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const response = await axios.get(`/api/inquiries/${id}`)
|
||
setData(response.data)
|
||
} catch (loadError) {
|
||
setError(loadError?.response?.data?.error || '读取询盘失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [id])
|
||
|
||
useEffect(() => { load() }, [load])
|
||
|
||
const updateStatus = async (status) => {
|
||
try {
|
||
await axios.patch(`/api/inquiries/${id}`, { status })
|
||
await load()
|
||
} catch (statusError) {
|
||
setError(statusError?.response?.data?.error || '更新状态失败')
|
||
}
|
||
}
|
||
|
||
const removeItem = async (item) => {
|
||
if (!window.confirm(`删除明细「${item.product_name || item.sku}」?删除后不可恢复。`)) return
|
||
try {
|
||
await axios.delete(`/api/inquiry-items/${item.id}`)
|
||
await load()
|
||
} catch (removeError) {
|
||
setError(removeError?.response?.data?.error || '删除失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
const inquiry = data?.inquiry
|
||
const items = data?.items || []
|
||
const totals = useMemo(() => summarize(items), [items])
|
||
|
||
if (loading) return <div className="loading">加载询盘…</div>
|
||
if (!inquiry) {
|
||
return (
|
||
<div className="page-container">
|
||
<p className="notice-inline">{error || '询盘不存在'}</p>
|
||
<Link className="btn btn-secondary btn-sm" to="/inquiries">返回询盘列表</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div className="breadcrumb">
|
||
<Link to="/inquiries">询盘管理</Link>
|
||
<span>/</span>
|
||
<span className="mono">{inquiry.inquiry_no}</span>
|
||
</div>
|
||
|
||
<div className="page-header">
|
||
<div>
|
||
<h1 className="page-title">{inquiry.title || '未命名询盘'}</h1>
|
||
<p className="page-sub">
|
||
<Link to={`/customers/${inquiry.customer_id}`}>{inquiry.customer_name || '未知客户'}</Link>
|
||
{' · '}
|
||
{inquiry.inquiry_date}
|
||
{' · '}
|
||
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
|
||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
<div className="header-actions">
|
||
<button type="button" className="btn btn-secondary btn-sm" onClick={load}>
|
||
<RefreshIcon size={14} /> 刷新
|
||
</button>
|
||
{canEdit && (
|
||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setEditor({ item: null })}>
|
||
<PlusCircleIcon size={15} /> 添加货号
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="stat-rail inquiry-rail">
|
||
<div className="stat-line">
|
||
<span className="stat-line-icon"><InquiryIcon size={17} /></span>
|
||
<span>报价明细</span><strong>{items.length}</strong>
|
||
</div>
|
||
<div className="stat-line">
|
||
<span className="stat-line-icon"><PackageIcon size={17} /></span>
|
||
<span>总数量</span>
|
||
<strong>{items.reduce((sum, item) => sum + Number(item.quantity || 0), 0).toLocaleString('zh-CN')}</strong>
|
||
</div>
|
||
<div className="stat-line">
|
||
<span className="stat-line-icon"><DollarIcon size={17} /></span>
|
||
<span>报价合计</span>
|
||
<strong>
|
||
{items.length ? `${money(totals, 2)} ${inquiry.currency || ''}` : '—'}
|
||
</strong>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <p className="notice-inline">{error}</p>}
|
||
|
||
<section className="dashboard-panel">
|
||
<div className="dashboard-panel-header">
|
||
<div>
|
||
<h2><DollarIcon size={17} /> 报价明细</h2>
|
||
</div>
|
||
<div className="header-actions">
|
||
{canEdit ? (
|
||
<label className="inline-field">
|
||
<span>询盘状态</span>
|
||
<select value={inquiry.status} onChange={event => updateStatus(event.target.value)}>
|
||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||
<option key={value} value={value}>{label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
) : (
|
||
<span className={`pill pill-${STATUS_TONE[inquiry.status] || 'mute'}`}>
|
||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{items.length === 0 ? (
|
||
<div className="empty-state">
|
||
<p className="empty-state-title">这次询盘还没有报价行</p>
|
||
<p className="empty-state-hint">点右上角「添加货号」,从已有 SKU 里选,或直接敲完整货号。</p>
|
||
</div>
|
||
) : (
|
||
<div className="table-wrap">
|
||
<table className="data-table quote-table">
|
||
<thead>
|
||
<tr>
|
||
<th>货号</th>
|
||
<th>产品 / 供应商</th>
|
||
<th className="num">采购价</th>
|
||
<th className="num">汇率</th>
|
||
<th className="num">加价率</th>
|
||
<th className="num">加价系数</th>
|
||
<th className="num">EXW<span className="th-unit">{inquiry.currency}</span></th>
|
||
<th className="num">数量</th>
|
||
<th className="num">小计<span className="th-unit">{inquiry.currency}</span></th>
|
||
<th>包装</th>
|
||
<th className="col-actions">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map(item => {
|
||
const brief = packagingBrief(item.packaging_snapshot)
|
||
const open = expandedPackaging === item.id
|
||
const subtotal = Number(item.exw || 0) * Number(item.quantity || 0)
|
||
return (
|
||
<React.Fragment key={item.id}>
|
||
<tr>
|
||
<td className="mono bold">
|
||
<span className="sku-cell">
|
||
<SkuThumb sku={item.sku} filename={itemThumbs[item.sku]} size={30} />
|
||
{item.sku}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div>{item.product_name || '—'}</div>
|
||
<div className="cell-sub">{item.supplier_name || '未指派供应商'}</div>
|
||
</td>
|
||
<td className="num">{money(item.purchase_price, 2)} <span className="unit">{item.currency}</span></td>
|
||
<td className="num">{money(item.exchange_rate, 4)}</td>
|
||
<td className="num">{(Number(item.markup_rate || 0) * 100).toFixed(2)}%</td>
|
||
<td className="num">{money(item.markup_factor, 4)}</td>
|
||
<td className="num strong-cell">{money(item.exw, 4)}</td>
|
||
<td className="num">{Number(item.quantity || 0).toLocaleString('zh-CN')}</td>
|
||
<td className="num">{money(subtotal, 2)}</td>
|
||
<td>
|
||
{brief ? (
|
||
<button
|
||
type="button"
|
||
className={`packaging-chip${open ? ' is-open' : ''}`}
|
||
onClick={() => setExpandedPackaging(open ? null : item.id)}
|
||
title="展开包装快照"
|
||
>
|
||
<PackageIcon size={13} /> {brief[0]}
|
||
</button>
|
||
) : (
|
||
<span className="muted-note">未维护</span>
|
||
)}
|
||
</td>
|
||
<td className="col-actions">
|
||
{canEdit && (
|
||
<button type="button" className="icon-btn" title="编辑明细" onClick={() => setEditor({ item })}>
|
||
<PencilIcon size={14} />
|
||
</button>
|
||
)}
|
||
{canEdit && (
|
||
<button type="button" className="icon-btn" title="删除明细" onClick={() => removeItem(item)}>
|
||
<TrashIcon size={14} />
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
{open && brief && (
|
||
<tr className="snapshot-row">
|
||
<td colSpan={11}>
|
||
<div className="snapshot-detail">
|
||
<span className="snapshot-detail-title"><PackageIcon size={13} /> 报价时的包装快照</span>
|
||
<ul className="snapshot-list">
|
||
{brief.map(text => <li key={text}>{text}</li>)}
|
||
</ul>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</React.Fragment>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="dashboard-panel">
|
||
<div className="dashboard-panel-header">
|
||
<div>
|
||
<h2><ClockIcon size={17} /> 跟进记录</h2>
|
||
</div>
|
||
<Link className="btn btn-secondary btn-sm" to={`/customers/${inquiry.customer_id}`}>
|
||
去客户详情记录跟进
|
||
</Link>
|
||
</div>
|
||
<p className="muted-note">
|
||
跟进记录统一挂在客户身上,记录时可以在「关联询盘」里选中本次询盘({inquiry.inquiry_no}),
|
||
这样客户时间线和工作台待跟进都能对上。
|
||
</p>
|
||
</section>
|
||
|
||
{editor && (
|
||
<ItemEditor
|
||
inquiry={inquiry}
|
||
item={editor.item}
|
||
existingSkus={items.filter(row => row.id !== editor.item?.id).map(row => row.sku)}
|
||
onClose={() => setEditor(null)}
|
||
onSaved={() => { setEditor(null); load() }}
|
||
canEdit={canEdit}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default InquiryDetail
|