Files
makemd/dashboard/src/pages/B2BTrade/BatchOrder.tsx
wurenzhi 15ee1758f5 refactor: 重构项目结构并优化类型定义
- 移除extension模块,将功能迁移至node-agent
- 修复类型导出问题,使用export type明确类型导出
- 统一数据库连接方式,从直接导入改为使用config/database
- 更新文档中的项目结构描述
- 添加多个服务的实用方法,如getForecast、getBalances等
- 修复类型错误和TS1205警告
- 优化RedisService调用方式
- 添加新的实体类型定义
- 更新审计日志格式,统一字段命名
2026-03-21 15:04:06 +08:00

613 lines
19 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
Card,
Form,
Input,
Select,
Button,
Table,
Modal,
Descriptions,
Divider,
message,
Space,
Tag,
Row,
Col,
InputNumber,
Steps,
Upload,
Tooltip,
Badge,
Statistic,
Progress,
Alert,
} from 'antd';
import {
ShoppingCartOutlined,
UploadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
DownloadOutlined,
FileExcelOutlined,
UserOutlined,
DeleteOutlined,
EyeOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { b2bTradeDataSource, BatchOrder as BatchOrderType, BatchOrderItem, Customer } from '@/services/b2bTradeDataSource';
const { Option } = Select;
const { TextArea } = Input;
const BATCH_STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
DRAFT: { color: 'default', text: 'Draft', icon: <FileExcelOutlined /> },
PENDING_REVIEW: { color: 'processing', text: 'Pending Review', icon: <SyncOutlined spin /> },
CONFIRMED: { color: 'blue', text: 'Confirmed', icon: <CheckCircleOutlined /> },
PROCESSING: { color: 'blue', text: 'Processing', icon: <SyncOutlined spin /> },
COMPLETED: { color: 'success', text: 'Completed', icon: <CheckCircleOutlined /> },
CANCELLED: { color: 'error', text: 'Cancelled', icon: <CloseCircleOutlined /> },
};
const ITEM_STATUS_MAP: Record<string, { color: string; text: string }> = {
VALID: { color: 'success', text: 'Valid' },
INVALID: { color: 'error', text: 'Invalid' },
PENDING: { color: 'warning', text: 'Pending' },
};
export const B2BTradeBatchOrder: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [batchOrders, setBatchOrders] = useState<BatchOrderType[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [orderItems, setOrderItems] = useState<BatchOrderItem[]>([]);
const [currentStep, setCurrentStep] = useState(0);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedBatch, setSelectedBatch] = useState<BatchOrderType | null>(null);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
const [fileList, setFileList] = useState<any[]>([]);
const [stats, setStats] = useState({
total: 0,
pending: 0,
processing: 0,
completed: 0,
totalAmount: 0,
});
useEffect(() => {
fetchBatchOrders();
fetchCustomers();
}, []);
const fetchBatchOrders = async () => {
setLoading(true);
try {
const orders = await b2bTradeDataSource.fetchBatchOrders();
setBatchOrders(orders);
calculateStats(orders);
} catch (error) {
message.error('Failed to load batch orders');
} finally {
setLoading(false);
}
};
const fetchCustomers = async () => {
try {
const data = await b2bTradeDataSource.fetchCustomers();
setCustomers(data);
} catch (error) {
message.error('Failed to load customers');
}
};
const calculateStats = (orders: BatchOrderType[]) => {
setStats({
total: orders.length,
pending: orders.filter((o) => o.status === 'PENDING_REVIEW').length,
processing: orders.filter((o) => o.status === 'PROCESSING').length,
completed: orders.filter((o) => o.status === 'COMPLETED').length,
totalAmount: orders.filter((o) => o.status === 'COMPLETED').reduce((sum, o) => sum + o.totalAmount, 0),
});
};
const handleCustomerChange = (customerId: string) => {
const customer = customers.find((c) => c.id === customerId);
setSelectedCustomer(customer || null);
};
const handleFileUpload = (info: any) => {
setFileList(info.fileList.slice(-1));
if (info.file.status === 'done' || info.file) {
parseUploadedFile(info.file);
}
};
const parseUploadedFile = async (file: any) => {
try {
const items = await b2bTradeDataSource.parseUploadFile(file, selectedCustomer?.id || '');
setOrderItems(items);
setCurrentStep(1);
message.success('File parsed successfully');
} catch (error) {
message.error('Failed to parse file');
}
};
const handleRemoveItem = (key: string) => {
setOrderItems(orderItems.filter((item) => item.key !== key));
};
const handleValidateItems = async () => {
setLoading(true);
try {
const result = await b2bTradeDataSource.validateItems(orderItems);
setOrderItems(result);
const validCount = result.filter(i => i.status === 'VALID').length;
const invalidCount = result.filter(i => i.status === 'INVALID').length;
message.success(`Validation complete: ${validCount} valid, ${invalidCount} invalid`);
} catch (error) {
message.error('Validation failed');
} finally {
setLoading(false);
}
};
const handleSubmitBatch = async () => {
try {
const values = await form.validateFields();
const validItems = orderItems.filter((i) => i.status === 'VALID');
if (validItems.length === 0) {
message.error('No valid items to submit');
return;
}
setLoading(true);
await b2bTradeDataSource.createBatchOrder({
customerId: values.customerId,
items: validItems,
});
message.success('Batch order submitted successfully');
setCurrentStep(2);
} catch (error) {
message.error('Failed to submit batch order');
} finally {
setLoading(false);
}
};
const handleViewDetail = (batch: BatchOrderType) => {
setSelectedBatch(batch);
setDetailModalVisible(true);
};
const handleApproveBatch = async (batchId: string) => {
setLoading(true);
try {
await b2bTradeDataSource.approveBatchOrder(batchId);
message.success('Batch order approved');
fetchBatchOrders();
} catch (error) {
message.error('Failed to approve batch order');
} finally {
setLoading(false);
}
};
const handleDownloadTemplate = () => {
message.info('Downloading template...');
};
const itemColumns: ColumnsType<BatchOrderItem> = [
{ title: 'Line', dataIndex: 'lineNumber', key: 'lineNumber', width: 60 },
{ title: 'SKU', dataIndex: 'sku', key: 'sku', width: 100 },
{ title: 'Product', dataIndex: 'productName', key: 'productName' },
{ title: 'Qty', dataIndex: 'quantity', key: 'quantity', width: 80 },
{
title: 'Unit Price',
dataIndex: 'unitPrice',
key: 'unitPrice',
width: 100,
render: (price: number) => `$${price.toFixed(2)}`,
},
{
title: 'Total',
dataIndex: 'totalPrice',
key: 'totalPrice',
width: 100,
render: (price: number) => `$${price.toFixed(2)}`,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status: string) => {
const config = ITEM_STATUS_MAP[status];
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: 'Error',
dataIndex: 'error',
key: 'error',
width: 150,
render: (error?: string) => error ? <span style={{ color: '#f5222d' }}>{error}</span> : '-',
},
{
title: 'Action',
key: 'action',
width: 80,
render: (_, record) => (
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleRemoveItem(record.key)} />
),
},
];
const batchColumns: ColumnsType<BatchOrderType> = [
{
title: 'Batch ID',
dataIndex: 'batchId',
key: 'batchId',
width: 130,
render: (text: string, record) => (
<a onClick={() => handleViewDetail(record)}>{text}</a>
),
},
{
title: 'Customer',
dataIndex: 'customerName',
key: 'customerName',
width: 180,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 130,
render: (status: string) => {
const config = BATCH_STATUS_MAP[status];
return <Tag icon={config.icon} color={config.color}>{config.text}</Tag>;
},
},
{
title: 'Items',
key: 'items',
width: 120,
render: (_, record) => (
<Space>
<Badge count={record.validItems} style={{ backgroundColor: '#52c41a' }} />
<Badge count={record.invalidItems} style={{ backgroundColor: '#f5222d' }} />
</Space>
),
},
{
title: 'Amount',
dataIndex: 'totalAmount',
key: 'totalAmount',
width: 120,
render: (amount: number) => (
<span style={{ fontWeight: 'bold' }}>${amount.toFixed(2)}</span>
),
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
},
{
title: 'Actions',
key: 'action',
width: 150,
fixed: 'right',
render: (_, record) => (
<Space>
<Tooltip title="View Details">
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)} />
</Tooltip>
{record.status === 'PENDING_REVIEW' && (
<Tooltip title="Approve">
<Button type="link" icon={<CheckCircleOutlined />} onClick={() => handleApproveBatch(record.batchId)} />
</Tooltip>
)}
</Space>
),
},
];
const validCount = orderItems.filter((i) => i.status === 'VALID').length;
const invalidCount = orderItems.filter((i) => i.status === 'INVALID').length;
const totalAmount = orderItems.filter((i) => i.status === 'VALID').reduce((sum, item) => sum + item.totalPrice, 0);
const steps = [
{ title: 'Upload', description: 'Select customer & upload file' },
{ title: 'Review', description: 'Validate items' },
{ title: 'Submit', description: 'Confirm & submit' },
];
return (
<div className="batch-order-page">
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={4}>
<Card>
<Statistic title="Total Batches" value={stats.total} prefix={<ShoppingCartOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="Pending Review"
value={stats.pending}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="Processing"
value={stats.processing}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={4}>
<Card>
<Statistic
title="Completed"
value={stats.completed}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="Total Amount"
value={stats.totalAmount}
precision={2}
prefix="$"
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
</Row>
<Card
title="Batch Orders"
extra={
<Button type="primary" icon={<ShoppingCartOutlined />} onClick={() => {
setCreateModalVisible(true);
setCurrentStep(0);
setOrderItems([]);
setFileList([]);
}}>
Create Batch Order
</Button>
}
>
<Table
columns={batchColumns}
dataSource={batchOrders}
rowKey="id"
loading={loading}
scroll={{ x: 1100 }}
pagination={{
showSizeChanger: true,
showTotal: (total) => `Total ${total} batches`,
}}
/>
</Card>
<Modal
title="Create Batch Order"
open={createModalVisible}
onCancel={() => setCreateModalVisible(false)}
footer={null}
width={1000}
>
<Steps current={currentStep} items={steps} style={{ marginBottom: 24 }} />
{currentStep === 0 && (
<Form form={form} layout="vertical">
<Form.Item name="customerId" label="Customer" rules={[{ required: true }]}>
<Select
placeholder="Select customer"
showSearch
optionFilterProp="children"
onChange={handleCustomerChange}
>
{customers.map((c) => (
<Option key={c.id} value={c.id}>
{c.name} ({c.company})
</Option>
))}
</Select>
</Form.Item>
{selectedCustomer && (
<Alert
message={`Selected Customer: ${selectedCustomer.name} (${selectedCustomer.tier})`}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Divider>Upload Order File</Divider>
<div style={{ marginBottom: 16 }}>
<Button icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
Download Template
</Button>
</div>
<Upload
accept=".xlsx,.xls,.csv"
fileList={fileList}
onChange={handleFileUpload}
beforeUpload={() => false}
maxCount={1}
>
<Button icon={<UploadOutlined />} disabled={!selectedCustomer}>
Select File
</Button>
</Upload>
<div style={{ marginTop: 16, color: '#666' }}>
Supported formats: Excel (.xlsx, .xls), CSV (.csv)
</div>
</Form>
)}
{currentStep === 1 && (
<>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card size="small">
<Statistic
title="Valid Items"
value={validCount}
valueStyle={{ color: '#52c41a' }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic
title="Invalid Items"
value={invalidCount}
valueStyle={{ color: '#f5222d' }}
prefix={<CloseCircleOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic
title="Total Amount"
value={totalAmount}
precision={2}
prefix="$"
/>
</Card>
</Col>
</Row>
<div style={{ marginBottom: 16 }}>
<Space>
<Button onClick={handleValidateItems} loading={loading}>
Re-validate
</Button>
<Button onClick={() => setCurrentStep(0)}>Back</Button>
</Space>
</div>
<Table
columns={itemColumns}
dataSource={orderItems}
rowKey="key"
pagination={false}
scroll={{ y: 300 }}
size="small"
/>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="note" label="Note">
<TextArea rows={2} placeholder="Add notes (optional)" />
</Form.Item>
</Form>
<div style={{ textAlign: 'right', marginTop: 16 }}>
<Space>
<Button onClick={() => setCreateModalVisible(false)}>Cancel</Button>
<Button
type="primary"
onClick={handleSubmitBatch}
loading={loading}
disabled={validCount === 0}
>
Submit Batch Order
</Button>
</Space>
</div>
</>
)}
{currentStep === 2 && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<CheckCircleOutlined style={{ fontSize: 64, color: '#52c41a' }} />
<h2 style={{ marginTop: 24 }}>Batch Order Submitted</h2>
<p style={{ color: '#666' }}>
Your batch order has been submitted for review.
<br />
You will be notified once it is approved.
</p>
<Descriptions bordered column={1} style={{ maxWidth: 400, margin: '24px auto' }}>
<Descriptions.Item label="Batch ID">BO-2026-NEW</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color="processing">Pending Review</Tag>
</Descriptions.Item>
<Descriptions.Item label="Valid Items">{validCount}</Descriptions.Item>
<Descriptions.Item label="Total Amount">${totalAmount.toFixed(2)}</Descriptions.Item>
</Descriptions>
<Button type="primary" onClick={() => setCreateModalVisible(false)}>
Done
</Button>
</div>
)}
</Modal>
<Modal
title="Batch Order Details"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>
Close
</Button>,
]}
width={700}
>
{selectedBatch && (
<>
<Descriptions bordered column={2}>
<Descriptions.Item label="Batch ID">{selectedBatch.batchId}</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={BATCH_STATUS_MAP[selectedBatch.status].color}>
{BATCH_STATUS_MAP[selectedBatch.status].text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Customer">{selectedBatch.customerName}</Descriptions.Item>
<Descriptions.Item label="Currency">{selectedBatch.currency}</Descriptions.Item>
<Descriptions.Item label="Valid Items">{selectedBatch.validItems}</Descriptions.Item>
<Descriptions.Item label="Invalid Items">{selectedBatch.invalidItems}</Descriptions.Item>
<Descriptions.Item label="Total Amount" span={2}>
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
${selectedBatch.totalAmount.toFixed(2)}
</span>
</Descriptions.Item>
<Descriptions.Item label="Created">{selectedBatch.createdAt}</Descriptions.Item>
</Descriptions>
<Divider>Validation Progress</Divider>
<Progress
percent={Math.round((selectedBatch.validItems / selectedBatch.totalItems) * 100)}
status={selectedBatch.invalidItems > 0 ? 'exception' : 'success'}
format={() => `${selectedBatch.validItems}/${selectedBatch.totalItems} valid`}
/>
</>
)}
</Modal>
</div>
);
};
export default B2BTradeBatchOrder;