chore: happy 出口通初始提交(前端 React + 后端 Express,采购管理已原生化)
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
function CustomerManagement() {
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
company: '',
|
||||
country: '',
|
||||
industry: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/customers')
|
||||
setCustomers(response.data)
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await axios.post('/api/customers', formData)
|
||||
setShowAddModal(false)
|
||||
setFormData({
|
||||
company: '',
|
||||
country: '',
|
||||
industry: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: ''
|
||||
})
|
||||
fetchCustomers()
|
||||
} catch (error) {
|
||||
console.error('创建客户失败:', error)
|
||||
alert('创建失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">客户管理</h1>
|
||||
<button className="btn-primary" onClick={() => setShowAddModal(true)}>
|
||||
+ 新增客户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公司名称</th>
|
||||
<th>国家</th>
|
||||
<th>行业</th>
|
||||
<th>网站</th>
|
||||
<th>联系方式</th>
|
||||
<th>负责人</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="empty-state">暂无客户数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
customers.map(customer => (
|
||||
<tr key={customer.id}>
|
||||
<td><strong>{customer.company}</strong></td>
|
||||
<td>{customer.country || '-'}</td>
|
||||
<td>{customer.industry || '-'}</td>
|
||||
<td>
|
||||
{customer.website ? (
|
||||
<a href={customer.website} target="_blank" rel="noopener noreferrer">
|
||||
{customer.website}
|
||||
</a>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{customer.email && <div>{customer.email}</div>}
|
||||
{customer.phone && <div>{customer.phone}</div>}
|
||||
</td>
|
||||
<td>{customer.owner_name || '-'}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${customer.status}`}>
|
||||
{customer.status === 'active' ? '活跃' : '非活跃'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 新增客户模态框 */}
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>新增客户</h2>
|
||||
<button className="modal-close" onClick={() => setShowAddModal(false)}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>公司名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.company}
|
||||
onChange={e => setFormData({...formData, company: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>国家</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={e => setFormData({...formData, country: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>行业</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.industry}
|
||||
onChange={e => setFormData({...formData, industry: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>网站</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://"
|
||||
value={formData.website}
|
||||
onChange={e => setFormData({...formData, website: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({...formData, email: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>电话</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({...formData, phone: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData({...formData, notes: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowAddModal(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerManagement
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
ActivityIcon,
|
||||
BellIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
ClipboardIcon,
|
||||
DollarIcon,
|
||||
FactoryIcon,
|
||||
FlagIcon,
|
||||
PackageIcon,
|
||||
PinIcon,
|
||||
SparkIcon,
|
||||
UsersIcon
|
||||
} from '../components/Icons'
|
||||
|
||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
function toDateKey(date) {
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function CalendarBoard({ todos }) {
|
||||
const today = new Date()
|
||||
const [month, setMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||
const monthLabel = month.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long' })
|
||||
const monthStartDay = (month.getDay() + 6) % 7
|
||||
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate()
|
||||
const previousDays = new Date(month.getFullYear(), month.getMonth(), 0).getDate()
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const offset = index - monthStartDay + 1
|
||||
const date = new Date(month.getFullYear(), month.getMonth(), offset)
|
||||
return { date, currentMonth: date.getMonth() === month.getMonth(), key: toDateKey(date) }
|
||||
})
|
||||
}, [month, monthStartDay])
|
||||
|
||||
const events = useMemo(() => {
|
||||
const result = {}
|
||||
todos.forEach(todo => {
|
||||
if (!todo.due_date) return
|
||||
const key = todo.due_date.slice(0, 10)
|
||||
result[key] = [...(result[key] || []), { label: todo.title, tone: todo.priority === 'high' ? 'danger' : 'blue' }]
|
||||
})
|
||||
const year = month.getFullYear()
|
||||
const holidays = [
|
||||
{ date: `${year}-01-01`, label: '元旦 · 节日', tone: 'holiday' },
|
||||
{ date: `${year}-05-01`, label: '劳动节 · 节日', tone: 'holiday' },
|
||||
{ date: `${year}-10-01`, label: '国庆节 · 节日', tone: 'holiday' },
|
||||
{ date: `${year}-12-25`, label: '圣诞节 · 节日', tone: 'holiday' },
|
||||
]
|
||||
holidays.forEach(event => { result[event.date] = [...(result[event.date] || []), event] })
|
||||
return result
|
||||
}, [month, todos])
|
||||
|
||||
const shiftMonth = (delta) => setMonth(current => new Date(current.getFullYear(), current.getMonth() + delta, 1))
|
||||
const resetMonth = () => setMonth(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||
|
||||
return (
|
||||
<section className="calendar-board dashboard-panel">
|
||||
<div className="dashboard-panel-header calendar-board-header">
|
||||
<div><span className="section-label">[SCHEDULE]</span><h2><CalendarIcon size={18} /> 工作日历</h2></div>
|
||||
<div className="calendar-toolbar"><button className="calendar-today-btn" onClick={resetMonth}>今天</button><button onClick={() => shiftMonth(-1)} aria-label="上个月">‹</button><strong>{monthLabel}</strong><button onClick={() => shiftMonth(1)} aria-label="下个月">›</button><button className="calendar-view-btn">月</button></div>
|
||||
</div>
|
||||
<div className="calendar-week-row">{WEEKDAYS.map(day => <div key={day}>{day}</div>)}</div>
|
||||
<div className="calendar-grid">
|
||||
{calendarDays.map(({ date, currentMonth, key }) => {
|
||||
const dayEvents = events[key] || []
|
||||
const isToday = key === toDateKey(today)
|
||||
return <div className={`calendar-day ${currentMonth ? '' : 'is-outside'} ${isToday ? 'is-today' : ''}`} key={`${key}-${date.getTime()}`}>
|
||||
<span className="calendar-day-number">{date.getDate()}</span>
|
||||
<div className="calendar-events">{dayEvents.slice(0, 3).map((event, index) => <span className={`calendar-event ${event.tone}`} key={`${event.label}-${index}`} title={event.label}>{event.label}</span>)}</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/dashboard').then(response => setData(response.data)).catch(error => console.error('获取工作台数据失败:', error)).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading">加载工作台数据…</div>
|
||||
|
||||
const { stats, recentActivities, todos } = data
|
||||
const completion = todos.length ? Math.max(12, Math.round((todos.filter(todo => todo.status === 'completed').length / todos.length) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div><div className="dashboard-date-chip"><span>运行日期</span><strong>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</strong></div></div>
|
||||
|
||||
<CalendarBoard todos={todos} />
|
||||
|
||||
<div className="dashboard-content-grid">
|
||||
<div className="dashboard-primary-column">
|
||||
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><span className="section-label">[AI BRIEFING]</span><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{stats.activeDeals || 0}</b> 个进行中商机和 <b>{stats.orders || 0}</b> 笔进行中订单。打开右上角 AI 助手,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK QUEUE]</span><h2><PinIcon size={17} /> 待办事项</h2></div><button className="panel-link">查看全部</button></div>{todos.length === 0 ? <div className="dashboard-empty">暂无待办事项,今天可以保持从容。</div> : <ul className="todo-list">{todos.map(todo => <li key={todo.id} className="todo-item"><input type="checkbox" aria-label={`完成 ${todo.title}`} /><div className="todo-content"><div className="todo-title">{todo.title}</div><div className="todo-meta">{todo.customer_name ? `客户:${todo.customer_name}` : '内部任务'}{todo.due_date ? ` · 截止 ${new Date(todo.due_date).toLocaleDateString('zh-CN')}` : ''}</div></div><span className={`priority-badge priority-${todo.priority}`}>{todo.priority === 'high' ? '高' : todo.priority === 'medium' ? '中' : '低'}</span></li>)}</ul>}</section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[ACTIVITY LOG]</span><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
|
||||
</div>
|
||||
|
||||
<aside className="dashboard-secondary-column">
|
||||
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><span className="section-label">[SYSTEM PULSE]</span><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<DollarIcon size={17} />} label="进行中商机" value={stats.activeDeals || 0} /></section>
|
||||
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK HEALTH]</span><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
|
||||
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><span className="section-label">[QUICK ACCESS]</span><h2>快捷入口</h2></div></div><button><UsersIcon size={16} /> 新增客户</button><button><PackageIcon size={16} /> 添加产品</button><button><ClipboardIcon size={16} /> 新建订单</button></section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatLine({ icon, label, value }) { return <div className="stat-line"><span className="stat-line-icon">{icon}</span><span>{label}</span><strong>{value}</strong></div> }
|
||||
|
||||
export default Dashboard
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* SKU 功能嵌入页(整合过渡期方案)。
|
||||
*
|
||||
* 合并策略是「先嵌后重写」:SKU 的能力先以嵌入方式跑通,
|
||||
* 之后再逐步重写为原生 React 页面。
|
||||
*
|
||||
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
|
||||
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
|
||||
*/
|
||||
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
|
||||
|
||||
const VIEW_META = {
|
||||
search: {
|
||||
title: '产品管理',
|
||||
sub: '按货号、品名、规格、材质或参数检索产品与采购价'
|
||||
},
|
||||
suppliers: {
|
||||
title: '供应商管理',
|
||||
sub: '供应商档案、联系方式、工厂所在地与主营类目'
|
||||
},
|
||||
import: {
|
||||
title: '合同导入',
|
||||
sub: '批量导入采购合同,自动解析价格、产品与参数'
|
||||
}
|
||||
}
|
||||
|
||||
function EmbeddedSku({ view = 'search' }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const frameRef = useRef(null)
|
||||
const meta = VIEW_META[view] || VIEW_META.search
|
||||
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}`
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{meta.title}</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '6px' }}>
|
||||
{meta.sub}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.location.reload()}>
|
||||
刷新
|
||||
</button>
|
||||
<a className="btn btn-sm" href={src} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
|
||||
新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 'calc(100vh - 210px)',
|
||||
minHeight: '560px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
{!loaded && (
|
||||
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||
<div className="spinner"></div>
|
||||
<span style={{ marginLeft: '12px' }}>正在加载 SKU 模块…</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={meta.title}
|
||||
src={src}
|
||||
onLoad={() => setLoaded(true)}
|
||||
allow="clipboard-write"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 0,
|
||||
display: 'block',
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity .18s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmbeddedSku
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
function OrderManagement() {
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders()
|
||||
}, [])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/orders')
|
||||
setOrders(response.data)
|
||||
} catch (error) {
|
||||
console.error('获取订单列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
draft: '草稿',
|
||||
confirmed: '已确认',
|
||||
production: '生产中',
|
||||
shipped: '已发货',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">订单管理</h1>
|
||||
<button className="btn-primary">+ 新建订单</button>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>客户</th>
|
||||
<th>订单日期</th>
|
||||
<th>交货日期</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">暂无订单数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
orders.map(order => (
|
||||
<tr key={order.id}>
|
||||
<td><strong>{order.order_no}</strong></td>
|
||||
<td>{order.customer_name}</td>
|
||||
<td>{new Date(order.order_date).toLocaleDateString('zh-CN')}</td>
|
||||
<td>
|
||||
{order.delivery_date
|
||||
? new Date(order.delivery_date).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</td>
|
||||
<td>
|
||||
{order.total_amount
|
||||
? `${order.currency} ${order.total_amount.toFixed(2)}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${order.status}`}>
|
||||
{getStatusText(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderManagement
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* PI 合同管理(Proforma Invoice)
|
||||
* 数据来源:happy 后端 /api/pi-contracts(P1 后端合并阶段开放)
|
||||
*/
|
||||
function PiContractManagement() {
|
||||
const [contracts, setContracts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
axios.get('/api/pi-contracts')
|
||||
.then(res => { if (alive) setContracts(Array.isArray(res.data) ? res.data : res.data?.items || []) })
|
||||
.catch(() => { if (alive) setContracts([]) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return contracts
|
||||
return contracts.filter(c => [c.pi_no, c.customer_name, c.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
|
||||
}, [contracts, keyword])
|
||||
|
||||
const statusText = (s) => ({ draft: '草稿', signed: '已签署', deposited: '已收定金', producing: '生产中', shipped: '已出运', closed: '已结案' }[s] || s || '—')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">PI 合同管理</h1>
|
||||
<button className="btn btn-primary">+ 新建 PI</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="搜索 PI 编号 / 客户"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">{rows.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PI 编号</th>
|
||||
<th>客户</th>
|
||||
<th>签订日期</th>
|
||||
<th>金额</th>
|
||||
<th>收款条件</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<div className="empty-state-title">暂无 PI 合同</div>
|
||||
<div className="empty-state-hint">PI 合同接口待接入(P1 后端合并阶段开放)</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td><strong>{c.pi_no}</strong></td>
|
||||
<td>{c.customer_name || c.customer_company || '—'}</td>
|
||||
<td>{c.signed_date ? new Date(c.signed_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{c.total_amount ? `${c.currency || 'USD'} ${Number(c.total_amount).toFixed(2)}` : '—'}</td>
|
||||
<td>{c.payment_terms || '—'}</td>
|
||||
<td><span className={`status-badge status-${c.status}`}>{statusText(c.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">生成单据</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PiContractManagement
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
function ProspectManagement() {
|
||||
const [formData, setFormData] = useState({
|
||||
productKeywords: '',
|
||||
countries: '',
|
||||
customerTypes: '',
|
||||
excludeKeywords: '',
|
||||
resultLimit: 100
|
||||
})
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
alert('自动获客功能开发中...')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">新客开发(自动获客)</h1>
|
||||
</div>
|
||||
|
||||
<div className="prospect-container">
|
||||
{/* 搜索条件 */}
|
||||
<div className="card">
|
||||
<h2>搜索条件</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>产品关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:LED灯具, 五金工具"
|
||||
value={formData.productKeywords}
|
||||
onChange={e => setFormData({...formData, productKeywords: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>目标国家</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:德国, 英国, 美国"
|
||||
value={formData.countries}
|
||||
onChange={e => setFormData({...formData, countries: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>客户类型</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:经销商, 系统集成商, OEM"
|
||||
value={formData.customerTypes}
|
||||
onChange={e => setFormData({...formData, customerTypes: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>排除关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:招聘, 二手, 学校"
|
||||
value={formData.excludeKeywords}
|
||||
onChange={e => setFormData({...formData, excludeKeywords: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>结果数量</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.resultLimit}
|
||||
onChange={e => setFormData({...formData, resultLimit: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
开始搜索
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果 */}
|
||||
<div className="card">
|
||||
<h2>搜索结果</h2>
|
||||
<div className="empty-state">
|
||||
请配置搜索条件并点击"开始搜索"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<h3>功能说明</h3>
|
||||
<p>自动获客功能将帮助你从多个数据源自动搜索潜在客户:</p>
|
||||
<ul>
|
||||
<li>公开公司注册数据(GLEIF、Companies House)</li>
|
||||
<li>网页搜索引擎</li>
|
||||
<li>AI 智能解析和评分</li>
|
||||
<li>自动去重和质量过滤</li>
|
||||
</ul>
|
||||
<p><strong>此功能将在后续版本中完善。</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProspectManagement
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* 采购单管理
|
||||
* 数据来源:happy 后端 /api/purchase-orders(P1 后端合并阶段开放)
|
||||
*/
|
||||
function PurchaseOrderManagement() {
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
axios.get('/api/purchase-orders')
|
||||
.then(res => { if (alive) setOrders(Array.isArray(res.data) ? res.data : res.data?.items || []) })
|
||||
.catch(() => { if (alive) setOrders([]) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return orders
|
||||
return orders.filter(o => [o.po_no, o.supplier_name].filter(Boolean).join(' ').toLowerCase().includes(kw))
|
||||
}, [orders, keyword])
|
||||
|
||||
const statusText = (s) => ({ draft: '草稿', issued: '已下达', received: '已收货', partial: '部分到货', closed: '已关闭', cancelled: '已取消' }[s] || s || '—')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">采购单管理</h1>
|
||||
<button className="btn btn-primary">+ 新建采购单</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="搜索采购单号 / 供应商"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">{rows.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>采购单号</th>
|
||||
<th>供应商</th>
|
||||
<th>下单日期</th>
|
||||
<th>预计交期</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<div className="empty-state-title">暂无采购单</div>
|
||||
<div className="empty-state-hint">采购单接口待接入(P1 后端合并阶段开放)</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(o => (
|
||||
<tr key={o.id}>
|
||||
<td><strong>{o.po_no}</strong></td>
|
||||
<td>{o.supplier_name || '—'}</td>
|
||||
<td>{o.order_date ? new Date(o.order_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{o.expected_date ? new Date(o.expected_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{o.total_amount ? `${o.currency || 'CNY'} ${Number(o.total_amount).toFixed(2)}` : '—'}</td>
|
||||
<td><span className={`status-badge status-${o.status}`}>{statusText(o.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurchaseOrderManagement
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* 客户报价单管理
|
||||
* 数据来源:happy 后端 /api/quotes(P1 后端合并阶段开放)
|
||||
* 未接入前保持可用的空态,不做假数据。
|
||||
*/
|
||||
function QuoteManagement() {
|
||||
const [quotes, setQuotes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
axios.get('/api/quotes')
|
||||
.then(res => { if (alive) setQuotes(Array.isArray(res.data) ? res.data : res.data?.items || []) })
|
||||
.catch(() => { if (alive) setQuotes([]) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return quotes
|
||||
return quotes.filter(q => [q.quote_no, q.customer_name, q.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
|
||||
}, [quotes, keyword])
|
||||
|
||||
const statusText = (s) => ({ draft: '草稿', sent: '已发送', accepted: '已接受', rejected: '已拒绝', expired: '已过期' }[s] || s || '—')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">客户报价单管理</h1>
|
||||
<button className="btn btn-primary">+ 新建报价单</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="搜索报价单号 / 客户"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">{rows.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>报价单号</th>
|
||||
<th>客户</th>
|
||||
<th>报价日期</th>
|
||||
<th>有效期至</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<div className="empty-state-title">暂无报价单</div>
|
||||
<div className="empty-state-hint">报价单接口待接入(P1 后端合并阶段开放)</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(q => (
|
||||
<tr key={q.id}>
|
||||
<td><strong>{q.quote_no}</strong></td>
|
||||
<td>{q.customer_name || q.customer_company || '—'}</td>
|
||||
<td>{q.quote_date ? new Date(q.quote_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{q.valid_until ? new Date(q.valid_until).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{q.total_amount ? `${q.currency || 'USD'} ${Number(q.total_amount).toFixed(2)}` : '—'}</td>
|
||||
<td><span className={`status-badge status-${q.status}`}>{statusText(q.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">转 PI</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuoteManagement
|
||||
@@ -0,0 +1,596 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { GridIcon, ListIcon } from '../components/Icons'
|
||||
|
||||
/**
|
||||
* 供应商管理(原生 React 版)
|
||||
*
|
||||
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
|
||||
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
|
||||
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
|
||||
*/
|
||||
|
||||
const API = '/api/sku'
|
||||
|
||||
/* ------------------------------ 格式化工具 ------------------------------ */
|
||||
|
||||
const money = (n) => (n == null ? '—' : Number(n).toFixed(2))
|
||||
|
||||
function fmtDate(iso, withTime = true) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
return withTime ? `${base} ${pad(d.getHours())}:${pad(d.getMinutes())}` : base
|
||||
}
|
||||
|
||||
function splitCats(text) {
|
||||
return String(text || '')
|
||||
.split(/[、,,;;/|\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** 从完整地址里抽出省市,卡片上只显示大致方位 */
|
||||
function shortLocation(address) {
|
||||
const a = String(address || '').trim()
|
||||
if (!a) return ''
|
||||
const m = a.match(/^(.{2,4}?(?:省|市|自治区|特别行政区))?\s*(.{2,6}?(?:市|自治州|地区|县|区))?/)
|
||||
const parts = [m?.[1], m?.[2]].filter(Boolean)
|
||||
if (parts.length) return parts.join('')
|
||||
return a.length > 12 ? `${a.slice(0, 12)}…` : a
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
['name', '供应商名称', true],
|
||||
['short_name', '简称'],
|
||||
['contact_person', '联系人'],
|
||||
['phone', '电话'],
|
||||
['email', '邮箱'],
|
||||
['website', '网址'],
|
||||
['payment_terms', '付款条件'],
|
||||
['address', '地址']
|
||||
]
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
short_name: '',
|
||||
contact_person: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
payment_terms: '',
|
||||
address: '',
|
||||
main_categories: '',
|
||||
notes: ''
|
||||
}
|
||||
|
||||
const FILE_TYPE_LABEL = {
|
||||
excel: 'Excel',
|
||||
'pdf-text': 'PDF 文字版',
|
||||
'pdf-scan': 'PDF 扫描件',
|
||||
unknown: '无法识别'
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { success: '成功', empty: '未识别到数据', failed: '失败' }
|
||||
const STATUS_PILL = { success: 'pill pill-ok', empty: 'pill pill-mute', failed: 'pill pill-warn' }
|
||||
|
||||
/* -------------------------------- 小组件 -------------------------------- */
|
||||
|
||||
function CatChips({ text }) {
|
||||
const cats = splitCats(text)
|
||||
if (!cats.length) return <div className="chip-row"><span className="muted-note">未填主营类目</span></div>
|
||||
return (
|
||||
<div className="chip-row">
|
||||
{cats.map((c) => <span className="chip" key={c}>{c}</span>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kv({ label, value, mono }) {
|
||||
return (
|
||||
<div className="kv">
|
||||
<span className="kv-label">{label}</span>
|
||||
<span className={`kv-value${value ? '' : ' is-empty'}`} style={mono && value ? { fontFamily: 'var(--font-mono)' } : undefined}>
|
||||
{value || '未填写'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------- 主页面 -------------------------------- */
|
||||
|
||||
function SupplierManagement() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [mode, setMode] = useState('list') // list | detail | form
|
||||
const [suppliers, setSuppliers] = useState([])
|
||||
const [unassigned, setUnassigned] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
// 排列方式:卡片网格 / 横列表。选择记在本地,下次进来自动沿用。
|
||||
const [viewMode, setViewMode] = useState(() => {
|
||||
try { return localStorage.getItem('happy.supplierView') || 'card' } catch { return 'card' }
|
||||
})
|
||||
|
||||
const changeView = useCallback((next) => {
|
||||
setViewMode(next)
|
||||
try { localStorage.setItem('happy.supplierView', next) } catch { /* 隐私模式下忽略 */ }
|
||||
}, [])
|
||||
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
const [listRes, unassignedRes] = await Promise.all([
|
||||
axios.get(`${API}/suppliers`),
|
||||
axios.get(`${API}/suppliers/unassigned/count`).catch(() => ({ data: { count: 0 } }))
|
||||
])
|
||||
setSuppliers(Array.isArray(listRes.data) ? listRes.data : [])
|
||||
setUnassigned(unassignedRes.data?.count || 0)
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `加载供应商失败:${err.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadList() }, [loadList])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return suppliers
|
||||
return suppliers.filter((s) =>
|
||||
[s.name, s.short_name, s.contact_person, s.phone, s.address, s.main_categories]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(kw)
|
||||
)
|
||||
}, [suppliers, keyword])
|
||||
|
||||
const openDetail = useCallback(async (id) => {
|
||||
setMode('detail')
|
||||
setDetailLoading(true)
|
||||
setDetail(null)
|
||||
setNotice('')
|
||||
try {
|
||||
const { data } = await axios.get(`${API}/suppliers/${id}`)
|
||||
setDetail(data)
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `加载详情失败:${err.message}`)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startCreate = () => {
|
||||
setForm(EMPTY_FORM)
|
||||
setEditingId(null)
|
||||
setEditingName('')
|
||||
setNotice('')
|
||||
setMode('form')
|
||||
}
|
||||
|
||||
const startEdit = (supplier) => {
|
||||
setForm({ ...EMPTY_FORM, ...Object.fromEntries(Object.entries(supplier).map(([k, v]) => [k, v ?? ''])) })
|
||||
setEditingId(supplier.id)
|
||||
setEditingName(supplier.name)
|
||||
setNotice('')
|
||||
setMode('form')
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const payload = {}
|
||||
for (const [key] of FIELDS) payload[key] = form[key]?.trim() || null
|
||||
payload.main_categories = form.main_categories?.trim() || null
|
||||
payload.notes = form.notes?.trim() || null
|
||||
|
||||
if (!payload.name) {
|
||||
setNotice('供应商名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setNotice('')
|
||||
try {
|
||||
if (editingId) {
|
||||
await axios.put(`${API}/suppliers/${editingId}`, payload)
|
||||
await loadList()
|
||||
await openDetail(editingId)
|
||||
} else {
|
||||
const { data } = await axios.post(`${API}/suppliers`, payload)
|
||||
await loadList()
|
||||
await openDetail(data.id)
|
||||
}
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `保存失败:${err.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeSupplier = async (id, name) => {
|
||||
const ok = window.confirm(
|
||||
`删除供应商「${name}」?\n\n该供应商名下的产品和价格会保留,只是变成「未指定供应商」。`
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await axios.delete(`${API}/suppliers/${id}`)
|
||||
await loadList()
|
||||
setDetail(null)
|
||||
setMode('list')
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `删除失败:${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- 列表视图 ------------------------------- */
|
||||
|
||||
if (mode === 'list') {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">供应商管理</h1>
|
||||
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="按名称 / 联系人 / 电话 / 地址搜索"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">共 {filtered.length} 家</span>
|
||||
<div className="view-toggle" role="group" aria-label="切换排列方式">
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === 'card' ? 'active' : ''}
|
||||
onClick={() => changeView('card')}
|
||||
title="卡片排列"
|
||||
aria-pressed={viewMode === 'card'}
|
||||
>
|
||||
<GridIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === 'table' ? 'active' : ''}
|
||||
onClick={() => changeView('table')}
|
||||
title="列表排列"
|
||||
aria-pressed={viewMode === 'table'}
|
||||
>
|
||||
<ListIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">加载中…</div>
|
||||
) : suppliers.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏭</div>
|
||||
<div className="empty-state-title">还没有供应商档案</div>
|
||||
<div className="empty-state-hint">
|
||||
建好供应商后,导入合同时就能把价格归到对应供应商名下,查价时也能按供应商筛选。
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<div className="table-container">
|
||||
<table className="data-table supplier-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应商</th>
|
||||
<th>联系人</th>
|
||||
<th>电话</th>
|
||||
<th>工厂所在地</th>
|
||||
<th>主营类目</th>
|
||||
<th style={{ textAlign: 'right', width: 72 }}>产品</th>
|
||||
<th style={{ textAlign: 'right', width: 72 }}>货号</th>
|
||||
<th>付款条件</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unassigned > 0 && (
|
||||
<tr className="row-unassigned" onClick={() => navigate('/products')}>
|
||||
<td className="bold">未分类</td>
|
||||
<td className="secondary" colSpan={4}>还没归属供应商的产品</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{unassigned}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>—</td>
|
||||
<td className="secondary">—</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.id} onClick={() => openDetail(s.id)}>
|
||||
<td className="bold">{s.name}</td>
|
||||
<td className="secondary">{s.contact_person || '—'}</td>
|
||||
<td className="secondary mono">{s.phone || '—'}</td>
|
||||
<td className="secondary" title={s.address || ''}>
|
||||
{shortLocation(s.address) || '—'}
|
||||
</td>
|
||||
<td>
|
||||
{splitCats(s.main_categories).length
|
||||
? <div className="chip-row">{splitCats(s.main_categories).map((c) => <span className="chip" key={c}>{c}</span>)}</div>
|
||||
: <span className="muted-note">未填</span>}
|
||||
</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{s.group_count ?? s.sku_count ?? 0}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{s.sku_count ?? 0}</td>
|
||||
<td className="secondary">{s.payment_terms || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="supplier-grid">
|
||||
{unassigned > 0 && (
|
||||
<div className="supplier-card is-unassigned" onClick={() => navigate('/products')}>
|
||||
<div className="sc-name">未分类</div>
|
||||
<div className="sc-contact"><span className="muted-note">还没归属供应商的产品</span></div>
|
||||
<div className="sc-foot">
|
||||
<span className="sc-count"><b>{unassigned}</b> 个产品</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((s) => {
|
||||
const contact = [s.contact_person, s.phone].filter(Boolean).join(' · ')
|
||||
const loc = shortLocation(s.address)
|
||||
return (
|
||||
<div className="supplier-card" key={s.id} onClick={() => openDetail(s.id)}>
|
||||
<div className="sc-name">{s.name}</div>
|
||||
<div className="sc-contact">
|
||||
{contact || <span className="muted-note">未填联系人</span>}
|
||||
</div>
|
||||
<div className="sc-loc" title={s.address || ''}>
|
||||
{loc || <span className="muted-note">未填工厂所在地</span>}
|
||||
</div>
|
||||
<CatChips text={s.main_categories} />
|
||||
<div className="sc-foot">
|
||||
<span className="sc-count">
|
||||
<b>{s.group_count ?? s.sku_count}</b> 个产品 · {s.sku_count} 个货号
|
||||
</span>
|
||||
{s.payment_terms && <span className="chip">{s.payment_terms}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------- 详情视图 ------------------------------- */
|
||||
|
||||
if (mode === 'detail') {
|
||||
const s = detail?.supplier
|
||||
const products = detail?.products || []
|
||||
const contracts = detail?.contracts || []
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{s?.name || '供应商详情'}</h1>
|
||||
{s && (
|
||||
<p className="page-sub">
|
||||
{s.short_name ? `${s.short_name} · ` : ''}
|
||||
供货 {products.length} 个货号 · {contracts.length} 份合同记录
|
||||
</p>
|
||||
)}
|
||||
{s && <CatChips text={s.main_categories} />}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
|
||||
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
|
||||
{detailLoading || !s ? (
|
||||
<div className="loading">加载中…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>联系方式</h2></div>
|
||||
<div className="kv-grid">
|
||||
<Kv label="联系人" value={s.contact_person} />
|
||||
<Kv label="电话" value={s.phone} mono />
|
||||
<Kv label="邮箱" value={s.email} mono />
|
||||
<Kv label="付款条件" value={s.payment_terms} />
|
||||
<Kv label="网址" value={s.website} mono />
|
||||
<Kv label="地址" value={s.address} />
|
||||
</div>
|
||||
{s.notes && (
|
||||
<div className="kv-grid" style={{ marginTop: 14 }}>
|
||||
<Kv label="备注" value={s.notes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>供货清单</h2>
|
||||
<span className="secondary">{products.length} 条</span>
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 12px' }}>
|
||||
<div className="empty-state-hint">
|
||||
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container" style={{ maxHeight: 460 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}></th>
|
||||
<th>货号</th>
|
||||
<th>所属产品</th>
|
||||
<th style={{ textAlign: 'right' }}>采购价</th>
|
||||
<th style={{ textAlign: 'right' }}>起订量</th>
|
||||
<th>更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
|
||||
<td>
|
||||
{p.thumb
|
||||
? <img className="thumb" src={`/product-images/${p.thumb}`} alt="" loading="lazy" />
|
||||
: <span className="thumb thumb-empty" />}
|
||||
</td>
|
||||
<td className="mono">{p.display_sku || p.sku}</td>
|
||||
<td>{p.group_name || p.name || '—'}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>
|
||||
<span className="secondary">¥</span>{money(p.price)}
|
||||
{p.confidence === 'low' && <span className="pill pill-warn" style={{ marginLeft: 6 }}>待核对</span>}
|
||||
</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{p.moq ?? '—'}</td>
|
||||
<td className="secondary">{fmtDate(p.last_updated, false)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>合同导入记录</h2>
|
||||
<span className="secondary">{contracts.length} 条</span>
|
||||
</div>
|
||||
{contracts.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 12px' }}>
|
||||
<div className="empty-state-hint">还没有该供应商的合同导入记录。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container" style={{ maxHeight: 340 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>类型</th>
|
||||
<th style={{ textAlign: 'right' }}>新增</th>
|
||||
<th style={{ textAlign: 'right' }}>更新</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contracts.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="mono">
|
||||
{c.has_file
|
||||
? <a href={`${API}/contracts/${c.id}/file`} title="下载原件">{c.filename}</a>
|
||||
: c.filename}
|
||||
</td>
|
||||
<td className="secondary">{FILE_TYPE_LABEL[c.file_type] || c.file_type}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{c.rows_new}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{c.rows_matched}</td>
|
||||
<td><span className={STATUS_PILL[c.status] || 'pill pill-mute'}>{STATUS_LABEL[c.status] || c.status}</span></td>
|
||||
<td className="secondary">{fmtDate(c.processed_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------- 表单视图 ------------------------------- */
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{editingId ? '编辑供应商' : '新建供应商'}</h1>
|
||||
<p className="page-sub">
|
||||
{editingId ? editingName : '填写基础档案,之后可以随时修改'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn" onClick={() => (editingId ? openDetail(editingId) : setMode('list'))}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
|
||||
<div className="card" style={{ maxWidth: 860 }}>
|
||||
<div className="card-header"><h2>基础信息</h2></div>
|
||||
|
||||
<div className="form-row">
|
||||
{FIELDS.map(([key, label, required]) => (
|
||||
<div className="form-group" key={key}>
|
||||
<label>{label}{required ? ' *' : ''}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form[key] ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>主营产品类目</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用顿号或逗号分隔,例如:长嘴钳、老虎钳、斜口钳、卡簧钳"
|
||||
value={form.main_categories}
|
||||
onChange={(e) => setForm((f) => ({ ...f, main_categories: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => removeSupplier(editingId, editingName)}
|
||||
disabled={saving}
|
||||
>
|
||||
删除该供应商
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupplierManagement
|
||||
Reference in New Issue
Block a user