Files
yunhaifinance/frontend/src/pages/LogisticsCompaniesPage.tsx
T

680 lines
25 KiB
TypeScript

/**
* 物流管理页面
* 遵循设计方案:采购-付款-物流-退库一体化流程设计方案.md
* 章节:四、物流管理
*
* 统一合作伙伴界面规范:
* - 基本信息:公司名称、地址、联系方式、报价描述
* - 联系人:支持多个联系人,标记主联系人
* - 收款信息:支持多个银行账户,标记默认账户
* - 业务台账:订单列表、运费总额、已付/未付金额
*/
import React, { useState, useEffect, useCallback } from 'react'
import {
Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card,
Row, Col, Popconfirm, Tabs, Descriptions, Upload, Image, Checkbox
} from 'antd'
import {
PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined,
PhoneOutlined, BankOutlined, FileTextOutlined, CarOutlined, DollarOutlined
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import BusinessLedgerTab from '../components/BusinessLedgerTab'
import useFormDraft from '../hooks/useFormDraft'
interface LogisticsCompany {
id: number
name: string
address: string
phone: string
quotation_description: string
remark: string
created_at: string
contacts: Contact[]
payment_infos: PaymentInfo[]
orders: OrderRecord[]
total_primary_freight: number
total_secondary_freight: number
paid_primary_freight: number
paid_secondary_freight: number
ledger?: {
summary: {
item_count: number
total_primary_freight: number
total_secondary_freight: number
total_freight: number
paid_primary_freight: number
paid_secondary_freight: number
paid_amount: number
unpaid_amount: number
}
items: any[]
}
}
interface Contact {
id: number
name: string
phone: string
position: string
is_primary: number
}
interface PaymentInfo {
id: number
account_name: string
account_number: string
bank_name: string
qr_code: string
is_default: number
}
interface OrderRecord {
id: number
code: string
order_code: string
ship_date: string
status: string
primary_freight: number
primary_freight_currency: string
primary_freight_status: string
secondary_freight: number
secondary_freight_currency: string
secondary_freight_status: string
}
const LogisticsCompaniesPage: React.FC = () => {
const [companies, setCompanies] = useState<LogisticsCompany[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [detailModalVisible, setDetailModalVisible] = useState(false)
const [editingCompany, setEditingCompany] = useState<LogisticsCompany | null>(null)
const [currentCompany, setCurrentCompany] = useState<LogisticsCompany | null>(null)
const [activeDetailTab, setActiveDetailTab] = useState('basic')
const [contactModalVisible, setContactModalVisible] = useState(false)
const [editingContact, setEditingContact] = useState<Contact | null>(null)
const [contactForm] = Form.useForm()
const [paymentModalVisible, setPaymentModalVisible] = useState(false)
const [editingPayment, setEditingPayment] = useState<PaymentInfo | null>(null)
const [paymentForm] = Form.useForm()
const [form] = Form.useForm()
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'logistics_company_create',
})
const handleFormChange = useCallback(() => {
saveDraft()
}, [saveDraft])
const fetchCompanies = async () => {
setLoading(true)
try {
const response = await fetch('/api/logistics-companies')
const data = await response.json()
if (data.success) {
setCompanies(data.data)
} else {
message.error('获取物流公司列表失败')
}
} catch (error) {
console.error('获取物流公司列表失败:', error)
message.error('获取物流公司列表失败')
} finally {
setLoading(false)
}
}
const fetchCompanyDetail = async (id: number) => {
try {
const response = await fetch(`/api/logistics-companies/${id}`)
const data = await response.json()
if (data.success) {
setCurrentCompany(data.data)
setDetailModalVisible(true)
setActiveDetailTab('basic')
} else {
message.error('获取物流公司详情失败')
}
} catch (error) {
console.error('获取物流公司详情失败:', error)
message.error('获取物流公司详情失败')
}
}
useEffect(() => {
fetchCompanies()
}, [])
const handleCreate = () => {
setEditingCompany(null)
form.resetFields()
setModalVisible(true)
// 检查是否有草稿,提示用户是否恢复
setTimeout(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未提交的物流公司信息,是否恢复?',
okText: '恢复草稿',
cancelText: '重新填写',
onOk: () => {
restoreDraft()
},
onCancel: () => {
clearDraft()
form.resetFields()
},
})
}
}, 0)
}
const handleEdit = (company: LogisticsCompany) => {
setEditingCompany(company)
form.setFieldsValue(company)
setModalVisible(true)
}
const handleDelete = async (id: number) => {
try {
const response = await fetch(`/api/logistics-companies/${id}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
message.success('删除成功')
fetchCompanies()
} else {
message.error(data.message || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败')
}
}
const handleSave = async () => {
try {
const values = await form.validateFields()
const url = editingCompany
? `/api/logistics-companies/${editingCompany.id}`
: '/api/logistics-companies'
const method = editingCompany ? 'PUT' : 'POST'
const { contacts, ...companyData } = values
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(companyData)
})
const data = await response.json()
if (data.success) {
if (!editingCompany && contacts && contacts.length > 0) {
const companyId = data.data.id
for (const contact of contacts) {
if (contact.name) {
await fetch(`/api/logistics-companies/${companyId}/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contact)
})
}
}
}
message.success(editingCompany ? '更新成功' : '创建成功')
clearDraft()
setModalVisible(false)
fetchCompanies()
} else {
message.error('保存失败')
}
} catch (error) {
console.error('保存失败:', error)
}
}
const handleAddContact = () => {
setEditingContact(null)
contactForm.resetFields()
setContactModalVisible(true)
}
const handleEditContact = (contact: Contact) => {
setEditingContact(contact)
contactForm.setFieldsValue(contact)
setContactModalVisible(true)
}
const handleSaveContact = async () => {
try {
const values = await contactForm.validateFields()
const url = editingContact
? `/api/logistics-companies/${currentCompany?.id}/contacts/${editingContact.id}`
: `/api/logistics-companies/${currentCompany?.id}/contacts`
const method = editingContact ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
})
const data = await response.json()
if (data.success) {
message.success(editingContact ? '联系人更新成功' : '联系人添加成功')
setContactModalVisible(false)
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('操作失败')
}
} catch (error) {
console.error('保存联系人失败:', error)
}
}
const handleDeleteContact = async (contactId: number) => {
try {
const response = await fetch(`/api/logistics-companies/${currentCompany?.id}/contacts/${contactId}`, {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
message.success('联系人删除成功')
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('删除失败')
}
} catch (error) {
console.error('删除联系人失败:', error)
}
}
const handleAddPayment = () => {
setEditingPayment(null)
paymentForm.resetFields()
setPaymentModalVisible(true)
}
const handleEditPayment = (payment: PaymentInfo) => {
setEditingPayment(payment)
paymentForm.setFieldsValue(payment)
setPaymentModalVisible(true)
}
const handleSavePayment = async () => {
try {
const values = await paymentForm.validateFields()
const url = editingPayment
? `/api/logistics-companies/${currentCompany?.id}/payment-infos/${editingPayment.id}`
: `/api/logistics-companies/${currentCompany?.id}/payment-infos`
const method = editingPayment ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
})
const data = await response.json()
if (data.success) {
message.success(editingPayment ? '收款信息更新成功' : '收款信息添加成功')
setPaymentModalVisible(false)
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('操作失败')
}
} catch (error) {
console.error('保存收款信息失败:', error)
}
}
const handleDeletePayment = async (paymentId: number) => {
try {
const response = await fetch(`/api/logistics-companies/${currentCompany?.id}/payment-infos/${paymentId}`, {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
message.success('收款信息删除成功')
fetchCompanyDetail(currentCompany!.id)
} else {
message.error('删除失败')
}
} catch (error) {
console.error('删除收款信息失败:', error)
}
}
const getFreightStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '待付款' },
requested: { color: 'blue', text: '已申请' },
paid: { color: 'green', text: '已支付' }
}
const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag>
}
const columns: ColumnsType<LogisticsCompany> = [
{
title: '公司名称',
dataIndex: 'name',
key: 'name',
width: 180,
render: (v: string, r: LogisticsCompany) => (
<a onClick={() => fetchCompanyDetail(r.id)} style={{ fontWeight: 500 }}>{v}</a>
)
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 120
},
{
title: '报价描述',
dataIndex: 'quotation_description',
key: 'quotation_description',
width: 200,
ellipsis: true,
render: (v: string) => v || '-'
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 100,
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, record) => (
<Space size={4}>
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchCompanyDetail(record.id)} />
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
<Popconfirm title="确定要删除吗?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]
const contactColumns: ColumnsType<Contact> = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '职位', dataIndex: 'position', key: 'position', width: 80 },
{ title: '电话', dataIndex: 'phone', key: 'phone', width: 120 },
{ title: '主联系人', dataIndex: 'is_primary', key: 'is_primary', width: 80, render: (v: boolean | number) => v ? <Tag color="blue">主联系人</Tag> : null },
{
title: '操作',
key: 'actions',
width: 100,
render: (_, record) => (
<Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditContact(record)} />
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteContact(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]
const paymentColumns: ColumnsType<PaymentInfo> = [
{ title: '收款户名', dataIndex: 'account_name', key: 'account_name', width: 120 },
{ title: '银行账号', dataIndex: 'account_number', key: 'account_number', width: 150 },
{ title: '开户银行', dataIndex: 'bank_name', key: 'bank_name', width: 120 },
{ title: '默认', dataIndex: 'is_default', key: 'is_default', width: 60, render: (v: boolean | number) => v ? <Tag color="green">默认</Tag> : null },
{
title: '操作',
key: 'actions',
width: 100,
render: (_, record) => (
<Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditPayment(record)} />
<Popconfirm title="确定删除?" onConfirm={() => handleDeletePayment(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]
const orderColumns: ColumnsType<OrderRecord> = [
{ title: '物流单号', dataIndex: 'code', key: 'code', width: 120 },
{ title: '采购订单', dataIndex: 'order_code', key: 'order_code', width: 120 },
{ title: '发货日期', dataIndex: 'ship_date', key: 'ship_date', width: 100 },
{ title: '一次运费', dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.primary_freight_currency || 'CNY'} ${v?.toFixed(2) || '0.00'}` },
{ title: '一次运费状态', dataIndex: 'primary_freight_status', key: 'primary_freight_status', width: 100, render: getFreightStatusTag },
{ title: '二次运费', dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.secondary_freight_currency || 'LAK'} ${v?.toFixed(2) || '0.00'}` },
{ title: '二次运费状态', dataIndex: 'secondary_freight_status', key: 'secondary_freight_status', width: 100, render: getFreightStatusTag },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag>{s}</Tag> }
]
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}>物流管理</h2>
<p style={{ color: '#888', marginBottom: 0 }}>管理物流合作伙伴(统一合作伙伴界面规范)</p>
</div>
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建物流公司</Button>}>
<Table columns={columns} dataSource={companies} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="small" scroll={{ x: 1100 }} />
</Card>
{/* 编辑/新建弹窗 */}
<Modal
title={editingCompany ? '编辑物流公司' : '新建物流公司'}
open={modalVisible}
onOk={handleSave}
onCancel={() => {
if (form.isFieldsTouched()) {
Modal.confirm({
title: '确认关闭',
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
okText: '关闭',
cancelText: '继续编辑',
onOk: () => {
saveDraft()
setModalVisible(false)
},
})
} else {
setModalVisible(false)
}
}}
width={700}
maskClosable={false}
>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Form.Item name="name" label="公司名称" rules={[{ required: true }]}>
<Input placeholder="请输入公司名称" />
</Form.Item>
<Form.Item name="address" label="地址">
<Input placeholder="请输入地址" />
</Form.Item>
<Form.Item name="quotation_description" label="报价描述">
<Input.TextArea rows={3} placeholder="请输入报价描述(如:中国-老挝陆运报价、时效等)" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="请输入备注" />
</Form.Item>
{!editingCompany && (
<Form.List name="contacts">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}>联系人</span>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => add()}>添加联系人</Button>
</div>
{fields.map(({ key, name, ...restField }) => (
<Row key={key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={6}>
<Form.Item {...restField} name={[name, 'name']} rules={[{ required: true, message: '必填' }]}>
<Input placeholder="姓名" size="small" />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item {...restField} name={[name, 'phone']}>
<Input placeholder="电话" size="small" />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item {...restField} name={[name, 'position']}>
<Input placeholder="职位" size="small" />
</Form.Item>
</Col>
<Col span={5}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked">
<Checkbox>主联系人</Checkbox>
</Form.Item>
</Col>
<Col span={3}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} onClick={() => remove(name)} />
</Col>
</Row>
))}
</>
)}
</Form.List>
)}
</Form>
</Modal>
{/* 详情弹窗 - 多TAB */}
<Modal
title={`物流公司详情 - ${currentCompany?.name || ''}`}
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={null}
width={1000}
>
{currentCompany && (
<Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}>
{/* TAB1: 基本信息 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> 基本信息</span>} key="basic">
<Descriptions bordered column={2}>
<Descriptions.Item label="公司名称">{currentCompany.name}</Descriptions.Item>
<Descriptions.Item label="联系电话">{currentCompany.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{currentCompany.email || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{currentCompany.created_at}</Descriptions.Item>
<Descriptions.Item label="地址" span={2}>{currentCompany.address || '-'}</Descriptions.Item>
<Descriptions.Item label="报价描述" span={2}>{currentCompany.quotation_description || '-'}</Descriptions.Item>
{currentCompany.remark && <Descriptions.Item label="备注" span={2}>{currentCompany.remark}</Descriptions.Item>}
</Descriptions>
</Tabs.TabPane>
{/* TAB2: 联系人 */}
<Tabs.TabPane tab={<span><PhoneOutlined /> 联系人</span>} key="contacts">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddContact} style={{ marginBottom: 16 }}>添加联系人</Button>
<Table columns={contactColumns} dataSource={currentCompany.contacts || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane>
{/* TAB3: 收款信息 */}
<Tabs.TabPane tab={<span><BankOutlined /> 收款信息</span>} key="payment">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}>添加收款信息</Button>
<Table columns={paymentColumns} dataSource={currentCompany.payment_infos || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane>
{/* TAB4: 业务台账 */}
<Tabs.TabPane tab={<span><DollarOutlined /> 业务台账</span>} key="orders">
<BusinessLedgerTab
partnerType="logistics"
summary={currentCompany.ledger?.summary || { item_count: 0, total_primary_freight: 0, total_secondary_freight: 0, total_freight: 0, paid_primary_freight: 0, paid_secondary_freight: 0, paid_amount: 0, unpaid_amount: 0 }}
items={currentCompany.ledger?.items || []}
/>
</Tabs.TabPane>
</Tabs>
)}
</Modal>
{/* 联系人编辑弹窗 */}
<Modal
title={editingContact ? '编辑联系人' : '添加联系人'}
open={contactModalVisible}
onOk={handleSaveContact}
onCancel={() => setContactModalVisible(false)}
width={500}
>
<Form form={contactForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="position" label="职位">
<Input placeholder="请输入职位" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="phone" label="电话">
<Input placeholder="请输入电话" />
</Form.Item>
</Col>
</Row>
<Form.Item name="is_primary" label="主联系人">
<Select placeholder="是否为主联系人">
<Select.Option value={true}></Select.Option>
<Select.Option value={false}></Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* 收款信息编辑弹窗 */}
<Modal
title={editingPayment ? '编辑收款信息' : '添加收款信息'}
open={paymentModalVisible}
onOk={handleSavePayment}
onCancel={() => setPaymentModalVisible(false)}
width={500}
>
<Form form={paymentForm} layout="vertical">
<Form.Item name="account_name" label="收款户名" rules={[{ required: true }]}>
<Input placeholder="请输入收款户名" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="account_number" label="银行账号" rules={[{ required: true }]}>
<Input placeholder="请输入银行账号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true }]}>
<Input placeholder="请输入开户银行" />
</Form.Item>
</Col>
</Row>
<Form.Item name="qr_code" label="收款码">
<Input placeholder="请输入收款码图片URL" />
</Form.Item>
<Form.Item name="is_default" label="默认账户">
<Select placeholder="是否为默认账户">
<Select.Option value={true}></Select.Option>
<Select.Option value={false}></Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default LogisticsCompaniesPage