feat: 新增多模块功能与服务实现

新增广告计划、用户资产、B2B交易、合规规则等核心模型
实现爬虫工作器、贸易服务、现金流预测等业务服务
添加RBAC权限测试、压力测试等测试用例
完善扩展程序的消息处理与内容脚本功能
重构应用入口与文档生成器
更新项目规则与业务闭环分析文档
This commit is contained in:
2026-03-18 09:38:09 +08:00
parent 72cd7f6f45
commit 037e412aad
158 changed files with 50217 additions and 1313 deletions

View File

@@ -0,0 +1,692 @@
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';
const { Option } = Select;
const { TextArea } = Input;
interface BatchOrderItem {
key: string;
lineNumber: number;
productId: string;
sku: string;
productName: string;
quantity: number;
unitPrice: number;
totalPrice: number;
customerId: string;
customerName: string;
status: 'VALID' | 'INVALID' | 'PENDING';
error?: string;
}
interface BatchOrder {
id: string;
batchId: string;
customerId: string;
customerName: string;
totalItems: number;
totalAmount: number;
currency: string;
status: 'DRAFT' | 'PENDING_REVIEW' | 'CONFIRMED' | 'PROCESSING' | 'COMPLETED' | 'CANCELLED';
createdAt: string;
validItems: number;
invalidItems: number;
}
interface Customer {
id: string;
name: string;
company: string;
tier: 'BASIC' | 'PRO' | 'ENTERPRISE';
}
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 BatchOrder: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [batchOrders, setBatchOrders] = useState<BatchOrder[]>([]);
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<BatchOrder | 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 mockOrders: BatchOrder[] = [
{
id: '1',
batchId: 'BO-2026-001',
customerId: 'CUST_001',
customerName: 'ABC Trading Co.',
totalItems: 50,
totalAmount: 25000.00,
currency: 'USD',
status: 'CONFIRMED',
createdAt: '2026-03-18 10:00:00',
validItems: 48,
invalidItems: 2,
},
{
id: '2',
batchId: 'BO-2026-002',
customerId: 'CUST_002',
customerName: 'XYZ Electronics Ltd.',
totalItems: 100,
totalAmount: 45000.00,
currency: 'USD',
status: 'PENDING_REVIEW',
createdAt: '2026-03-17 14:30:00',
validItems: 95,
invalidItems: 5,
},
{
id: '3',
batchId: 'BO-2026-003',
customerId: 'CUST_003',
customerName: 'Global Import Inc.',
totalItems: 200,
totalAmount: 85000.00,
currency: 'USD',
status: 'COMPLETED',
createdAt: '2026-03-15 09:00:00',
validItems: 200,
invalidItems: 0,
},
];
setBatchOrders(mockOrders);
calculateStats(mockOrders);
} catch (error) {
message.error('Failed to load batch orders');
} finally {
setLoading(false);
}
};
const fetchCustomers = async () => {
const mockCustomers: Customer[] = [
{ id: 'CUST_001', name: 'ABC Trading Co.', company: 'ABC Trading', tier: 'ENTERPRISE' },
{ id: 'CUST_002', name: 'XYZ Electronics Ltd.', company: 'XYZ Electronics', tier: 'PRO' },
{ id: 'CUST_003', name: 'Global Import Inc.', company: 'Global Import', tier: 'ENTERPRISE' },
];
setCustomers(mockCustomers);
};
const calculateStats = (orders: BatchOrder[]) => {
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 = (file: any) => {
const mockItems: BatchOrderItem[] = [
{ key: '1', lineNumber: 1, productId: 'PROD_001', sku: 'SKU-001', productName: 'Wireless Headphones', quantity: 100, unitPrice: 22.00, totalPrice: 2200.00, customerId: selectedCustomer?.id || '', customerName: selectedCustomer?.name || '', status: 'VALID' },
{ key: '2', lineNumber: 2, productId: 'PROD_002', sku: 'SKU-002', productName: 'USB-C Cable', quantity: 500, unitPrice: 4.50, totalPrice: 2250.00, customerId: selectedCustomer?.id || '', customerName: selectedCustomer?.name || '', status: 'VALID' },
{ key: '3', lineNumber: 3, productId: 'PROD_003', sku: 'SKU-003', productName: 'Phone Case', quantity: 200, unitPrice: 7.00, totalPrice: 1400.00, customerId: selectedCustomer?.id || '', customerName: selectedCustomer?.name || '', status: 'VALID' },
{ key: '4', lineNumber: 4, productId: 'PROD_004', sku: 'SKU-004', productName: 'Invalid Product', quantity: 50, unitPrice: 0, totalPrice: 0, customerId: selectedCustomer?.id || '', customerName: selectedCustomer?.name || '', status: 'INVALID', error: 'Product not found' },
];
setOrderItems(mockItems);
setCurrentStep(1);
message.success('File parsed successfully');
};
const handleRemoveItem = (key: string) => {
setOrderItems(orderItems.filter((item) => item.key !== key));
};
const handleValidateItems = () => {
setLoading(true);
setTimeout(() => {
const validCount = orderItems.filter((i) => i.status === 'VALID').length;
const invalidCount = orderItems.filter((i) => i.status === 'INVALID').length;
message.success(`Validation complete: ${validCount} valid, ${invalidCount} invalid`);
setLoading(false);
}, 1000);
};
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);
const totalAmount = validItems.reduce((sum, item) => sum + item.totalPrice, 0);
const batchData = {
customerId: values.customerId,
items: validItems,
totalAmount,
note: values.note,
};
console.log('Submitting batch order:', batchData);
await new Promise((resolve) => setTimeout(resolve, 1000));
message.success('Batch order submitted successfully');
setCurrentStep(2);
} catch (error) {
message.error('Failed to submit batch order');
} finally {
setLoading(false);
}
};
const handleViewDetail = (batch: BatchOrder) => {
setSelectedBatch(batch);
setDetailModalVisible(true);
};
const handleApproveBatch = async (batchId: string) => {
setLoading(true);
try {
console.log('Approving batch order:', batchId);
await new Promise((resolve) => setTimeout(resolve, 500));
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<BatchOrder> = [
{
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 BatchOrder;

View File

@@ -0,0 +1,730 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Form,
Input,
Select,
Button,
Table,
Modal,
Descriptions,
Divider,
message,
Space,
Tag,
Row,
Col,
DatePicker,
Upload,
Tooltip,
Badge,
Statistic,
Timeline,
Tabs,
Popconfirm,
} from 'antd';
import {
FileTextOutlined,
PlusOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
UploadOutlined,
DownloadOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
HistoryOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
const { Option } = Select;
const { TabPane } = Tabs;
const { RangePicker } = DatePicker;
const { TextArea } = Input;
interface Contract {
id: string;
contractId: string;
contractNumber: string;
customerId: string;
customerName: string;
title: string;
type: 'SALES' | 'PURCHASE' | 'SERVICE' | 'NDA' | 'OTHER';
status: 'DRAFT' | 'PENDING_SIGN' | 'ACTIVE' | 'EXPIRED' | 'TERMINATED';
startDate: string;
endDate: string;
totalValue: number;
currency: string;
signedDate?: string;
signedBy?: string;
attachments: string[];
createdAt: string;
updatedAt: string;
}
interface ContractHistory {
id: string;
contractId: string;
action: string;
operator: string;
timestamp: string;
remark?: string;
}
interface Customer {
id: string;
name: string;
company: string;
}
const CONTRACT_STATUS_MAP: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'default', text: 'Draft' },
PENDING_SIGN: { color: 'processing', text: 'Pending Signature' },
ACTIVE: { color: 'success', text: 'Active' },
EXPIRED: { color: 'warning', text: 'Expired' },
TERMINATED: { color: 'error', text: 'Terminated' },
};
const CONTRACT_TYPE_MAP: Record<string, { color: string; text: string }> = {
SALES: { color: 'blue', text: 'Sales Contract' },
PURCHASE: { color: 'green', text: 'Purchase Contract' },
SERVICE: { color: 'orange', text: 'Service Agreement' },
NDA: { color: 'purple', text: 'NDA' },
OTHER: { color: 'default', text: 'Other' },
};
export const ContractManage: React.FC = () => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [contracts, setContracts] = useState<Contract[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [selectedContract, setSelectedContract] = useState<Contract | null>(null);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [contractHistory, setContractHistory] = useState<ContractHistory[]>([]);
const [fileList, setFileList] = useState<any[]>([]);
const [stats, setStats] = useState({
total: 0,
active: 0,
expiringSoon: 0,
totalValue: 0,
});
useEffect(() => {
fetchContracts();
fetchCustomers();
}, []);
const fetchContracts = async () => {
setLoading(true);
try {
const mockContracts: Contract[] = [
{
id: '1',
contractId: 'CTR-2026-001',
contractNumber: 'SALES-2026-001',
customerId: 'CUST_001',
customerName: 'ABC Trading Co.',
title: 'Annual Sales Agreement',
type: 'SALES',
status: 'ACTIVE',
startDate: '2026-01-01',
endDate: '2026-12-31',
totalValue: 500000.00,
currency: 'USD',
signedDate: '2025-12-15',
signedBy: 'John Smith',
attachments: ['contract.pdf'],
createdAt: '2025-12-01 10:00:00',
updatedAt: '2025-12-15 14:00:00',
},
{
id: '2',
contractId: 'CTR-2026-002',
contractNumber: 'NDA-2026-001',
customerId: 'CUST_002',
customerName: 'XYZ Electronics Ltd.',
title: 'Non-Disclosure Agreement',
type: 'NDA',
status: 'ACTIVE',
startDate: '2026-02-01',
endDate: '2028-01-31',
totalValue: 0,
currency: 'USD',
signedDate: '2026-01-20',
signedBy: 'Emma Wilson',
attachments: ['nda.pdf'],
createdAt: '2026-01-10 09:00:00',
updatedAt: '2026-01-20 16:00:00',
},
{
id: '3',
contractId: 'CTR-2026-003',
contractNumber: 'PURCHASE-2026-001',
customerId: 'CUST_003',
customerName: 'Global Import Inc.',
title: 'Purchase Agreement Q2',
type: 'PURCHASE',
status: 'PENDING_SIGN',
startDate: '2026-04-01',
endDate: '2026-06-30',
totalValue: 150000.00,
currency: 'USD',
attachments: ['purchase_agreement.pdf'],
createdAt: '2026-03-15 11:00:00',
updatedAt: '2026-03-18 10:00:00',
},
{
id: '4',
contractId: 'CTR-2025-015',
contractNumber: 'SALES-2025-015',
customerId: 'CUST_001',
customerName: 'ABC Trading Co.',
title: 'Expired Sales Contract',
type: 'SALES',
status: 'EXPIRED',
startDate: '2025-01-01',
endDate: '2025-12-31',
totalValue: 350000.00,
currency: 'USD',
signedDate: '2025-01-05',
signedBy: 'John Smith',
attachments: ['old_contract.pdf'],
createdAt: '2025-01-01 10:00:00',
updatedAt: '2026-01-01 00:00:00',
},
];
setContracts(mockContracts);
calculateStats(mockContracts);
} catch (error) {
message.error('Failed to load contracts');
} finally {
setLoading(false);
}
};
const fetchCustomers = async () => {
const mockCustomers: Customer[] = [
{ id: 'CUST_001', name: 'ABC Trading Co.', company: 'ABC Trading' },
{ id: 'CUST_002', name: 'XYZ Electronics Ltd.', company: 'XYZ Electronics' },
{ id: 'CUST_003', name: 'Global Import Inc.', company: 'Global Import' },
];
setCustomers(mockCustomers);
};
const calculateStats = (contractList: Contract[]) => {
const today = new Date();
const thirtyDaysLater = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
setStats({
total: contractList.length,
active: contractList.filter((c) => c.status === 'ACTIVE').length,
expiringSoon: contractList.filter((c) => {
if (c.status !== 'ACTIVE') return false;
const endDate = new Date(c.endDate);
return endDate <= thirtyDaysLater && endDate >= today;
}).length,
totalValue: contractList.filter((c) => c.status === 'ACTIVE').reduce((sum, c) => sum + c.totalValue, 0),
});
};
const handleViewDetail = (contract: Contract) => {
setSelectedContract(contract);
setDetailModalVisible(true);
};
const handleEdit = (contract: Contract) => {
setSelectedContract(contract);
form.setFieldsValue({
customerId: contract.customerId,
title: contract.title,
type: contract.type,
startDate: contract.startDate,
endDate: contract.endDate,
totalValue: contract.totalValue,
});
setEditModalVisible(true);
};
const handleViewHistory = (contract: Contract) => {
setSelectedContract(contract);
const mockHistory: ContractHistory[] = [
{ id: '1', contractId: contract.contractId, action: 'Created', operator: 'System', timestamp: contract.createdAt },
{ id: '2', contractId: contract.contractId, action: 'Status changed to Pending Signature', operator: 'Admin', timestamp: '2026-03-16 10:00:00' },
{ id: '3', contractId: contract.contractId, action: 'Document uploaded', operator: 'Admin', timestamp: '2026-03-17 14:00:00' },
];
setContractHistory(mockHistory);
setHistoryModalVisible(true);
};
const handleCreateContract = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const contractData = {
...values,
attachments: fileList.map((f) => f.name),
};
console.log('Creating contract:', contractData);
await new Promise((resolve) => setTimeout(resolve, 1000));
message.success('Contract created successfully');
setCreateModalVisible(false);
form.resetFields();
setFileList([]);
fetchContracts();
} catch (error) {
message.error('Failed to create contract');
} finally {
setLoading(false);
}
};
const handleUpdateContract = async () => {
try {
const values = await form.validateFields();
setLoading(true);
console.log('Updating contract:', { contractId: selectedContract?.contractId, ...values });
await new Promise((resolve) => setTimeout(resolve, 1000));
message.success('Contract updated successfully');
setEditModalVisible(false);
fetchContracts();
} catch (error) {
message.error('Failed to update contract');
} finally {
setLoading(false);
}
};
const handleDeleteContract = async (contractId: string) => {
setLoading(true);
try {
console.log('Deleting contract:', contractId);
await new Promise((resolve) => setTimeout(resolve, 500));
message.success('Contract deleted successfully');
fetchContracts();
} catch (error) {
message.error('Failed to delete contract');
} finally {
setLoading(false);
}
};
const handleStatusChange = async (contractId: string, newStatus: string) => {
setLoading(true);
try {
console.log('Changing status:', { contractId, newStatus });
await new Promise((resolve) => setTimeout(resolve, 500));
message.success('Status updated successfully');
fetchContracts();
} catch (error) {
message.error('Failed to update status');
} finally {
setLoading(false);
}
};
const handleDownload = (filename: string) => {
message.info(`Downloading ${filename}...`);
};
const columns: ColumnsType<Contract> = [
{
title: 'Contract ID',
dataIndex: 'contractId',
key: 'contractId',
width: 130,
render: (text: string, record) => (
<a onClick={() => handleViewDetail(record)}>{text}</a>
),
},
{
title: 'Contract Number',
dataIndex: 'contractNumber',
key: 'contractNumber',
width: 150,
},
{
title: 'Title',
dataIndex: 'title',
key: 'title',
width: 200,
ellipsis: true,
},
{
title: 'Customer',
dataIndex: 'customerName',
key: 'customerName',
width: 150,
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 130,
render: (type: string) => {
const config = CONTRACT_TYPE_MAP[type];
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 130,
render: (status: string) => {
const config = CONTRACT_STATUS_MAP[status];
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: 'Value',
dataIndex: 'totalValue',
key: 'totalValue',
width: 120,
render: (value: number) => value > 0 ? `$${value.toLocaleString()}` : '-',
},
{
title: 'End Date',
dataIndex: 'endDate',
key: 'endDate',
width: 110,
},
{
title: 'Actions',
key: 'action',
width: 180,
fixed: 'right',
render: (_, record) => (
<Space>
<Tooltip title="View Details">
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)} />
</Tooltip>
<Tooltip title="Edit">
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
</Tooltip>
<Tooltip title="History">
<Button type="link" icon={<HistoryOutlined />} onClick={() => handleViewHistory(record)} />
</Tooltip>
{record.status === 'DRAFT' && (
<Popconfirm
title="Delete this contract?"
onConfirm={() => handleDeleteContract(record.contractId)}
>
<Button type="link" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
return (
<div className="contract-manage-page">
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="Total Contracts" value={stats.total} prefix={<FileTextOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Active"
value={stats.active}
valueStyle={{ color: '#52c41a' }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Expiring Soon"
value={stats.expiringSoon}
valueStyle={{ color: '#faad14' }}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Total Value"
value={stats.totalValue}
precision={0}
prefix="$"
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
</Row>
<Card
title="Contract Management"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
New Contract
</Button>
}
>
<Tabs defaultActiveKey="all">
<TabPane tab="All Contracts" key="all" />
<TabPane tab="Active" key="active" />
<TabPane tab="Pending Signature" key="pending" />
<TabPane tab="Expired" key="expired" />
</Tabs>
<Table
columns={columns}
dataSource={contracts}
rowKey="id"
loading={loading}
scroll={{ x: 1400 }}
pagination={{
showSizeChanger: true,
showTotal: (total) => `Total ${total} contracts`,
}}
/>
</Card>
<Modal
title="Create New Contract"
open={createModalVisible}
onCancel={() => setCreateModalVisible(false)}
onOk={handleCreateContract}
confirmLoading={loading}
width={700}
>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="customerId" label="Customer" rules={[{ required: true }]}>
<Select placeholder="Select customer" showSearch optionFilterProp="children">
{customers.map((c) => (
<Option key={c.id} value={c.id}>
{c.name} ({c.company})
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="type" label="Contract Type" rules={[{ required: true }]}>
<Select placeholder="Select type">
{Object.entries(CONTRACT_TYPE_MAP).map(([key, value]) => (
<Option key={key} value={key}>{value.text}</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="title" label="Contract Title" rules={[{ required: true }]}>
<Input placeholder="Enter contract title" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="startDate" label="Start Date" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="endDate" label="End Date" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="totalValue" label="Contract Value">
<InputNumber
prefix="$"
min={0}
precision={2}
style={{ width: '100%' }}
placeholder="Enter contract value"
/>
</Form.Item>
<Form.Item label="Attachments">
<Upload
fileList={fileList}
onChange={({ fileList }) => setFileList(fileList)}
beforeUpload={() => false}
>
<Button icon={<UploadOutlined />}>Upload Files</Button>
</Upload>
</Form.Item>
<Form.Item name="note" label="Notes">
<TextArea rows={3} placeholder="Add notes (optional)" />
</Form.Item>
</Form>
</Modal>
<Modal
title="Edit Contract"
open={editModalVisible}
onCancel={() => setEditModalVisible(false)}
onOk={handleUpdateContract}
confirmLoading={loading}
width={700}
>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="customerId" label="Customer" rules={[{ required: true }]}>
<Select placeholder="Select customer" disabled>
{customers.map((c) => (
<Option key={c.id} value={c.id}>
{c.name} ({c.company})
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="type" label="Contract Type" rules={[{ required: true }]}>
<Select placeholder="Select type">
{Object.entries(CONTRACT_TYPE_MAP).map(([key, value]) => (
<Option key={key} value={key}>{value.text}</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="title" label="Contract Title" rules={[{ required: true }]}>
<Input placeholder="Enter contract title" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="startDate" label="Start Date" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="endDate" label="End Date" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="totalValue" label="Contract Value">
<InputNumber
prefix="$"
min={0}
precision={2}
style={{ width: '100%' }}
placeholder="Enter contract value"
/>
</Form.Item>
</Form>
</Modal>
<Modal
title="Contract Details"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>
Close
</Button>,
selectedContract?.status === 'PENDING_SIGN' && (
<Button key="activate" type="primary" onClick={() => {
handleStatusChange(selectedContract.contractId, 'ACTIVE');
setDetailModalVisible(false);
}}>
Mark as Active
</Button>
),
]}
width={800}
>
{selectedContract && (
<>
<Descriptions bordered column={2}>
<Descriptions.Item label="Contract ID">{selectedContract.contractId}</Descriptions.Item>
<Descriptions.Item label="Contract Number">{selectedContract.contractNumber}</Descriptions.Item>
<Descriptions.Item label="Title" span={2}>{selectedContract.title}</Descriptions.Item>
<Descriptions.Item label="Customer">{selectedContract.customerName}</Descriptions.Item>
<Descriptions.Item label="Type">
<Tag color={CONTRACT_TYPE_MAP[selectedContract.type].color}>
{CONTRACT_TYPE_MAP[selectedContract.type].text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={CONTRACT_STATUS_MAP[selectedContract.status].color}>
{CONTRACT_STATUS_MAP[selectedContract.status].text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Value">
{selectedContract.totalValue > 0 ? `$${selectedContract.totalValue.toLocaleString()}` : '-'}
</Descriptions.Item>
<Descriptions.Item label="Start Date">{selectedContract.startDate}</Descriptions.Item>
<Descriptions.Item label="End Date">{selectedContract.endDate}</Descriptions.Item>
{selectedContract.signedDate && (
<Descriptions.Item label="Signed Date">{selectedContract.signedDate}</Descriptions.Item>
)}
{selectedContract.signedBy && (
<Descriptions.Item label="Signed By">{selectedContract.signedBy}</Descriptions.Item>
)}
<Descriptions.Item label="Created">{selectedContract.createdAt}</Descriptions.Item>
<Descriptions.Item label="Updated">{selectedContract.updatedAt}</Descriptions.Item>
</Descriptions>
{selectedContract.attachments.length > 0 && (
<>
<Divider>Attachments</Divider>
<Space>
{selectedContract.attachments.map((file, index) => (
<Button
key={index}
icon={<DownloadOutlined />}
onClick={() => handleDownload(file)}
>
{file}
</Button>
))}
</Space>
</>
)}
</>
)}
</Modal>
<Modal
title="Contract History"
open={historyModalVisible}
onCancel={() => setHistoryModalVisible(false)}
footer={[
<Button key="close" onClick={() => setHistoryModalVisible(false)}>
Close
</Button>,
]}
width={600}
>
<Timeline
items={contractHistory.map((h) => ({
color: 'blue',
children: (
<div>
<p><strong>{h.action}</strong></p>
<p style={{ color: '#666', margin: 0 }}>
{h.operator} - {h.timestamp}
</p>
{h.remark && <p style={{ color: '#999' }}>{h.remark}</p>}
</div>
),
}))}
/>
</Modal>
</div>
);
};
export default ContractManage;

View File

@@ -0,0 +1,634 @@
import React, { useState, useEffect } from 'react';
import {
Card,
Form,
Input,
Select,
Button,
Table,
Modal,
Descriptions,
Divider,
message,
Space,
Tag,
Row,
Col,
InputNumber,
DatePicker,
Tooltip,
Badge,
Statistic,
} from 'antd';
import {
FileTextOutlined,
PlusOutlined,
CalculatorOutlined,
SendOutlined,
HistoryOutlined,
UserOutlined,
ShoppingOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
const { Option } = Select;
const { TextArea } = Input;
const { RangePicker } = DatePicker;
interface Customer {
id: string;
name: string;
email: string;
company: string;
tier: 'BASIC' | 'PRO' | 'ENTERPRISE';
}
interface Product {
id: string;
sku: string;
name: string;
costPrice: number;
suggestedPrice: number;
moq: number;
stock: number;
}
interface QuoteItem {
key: string;
productId: string;
sku: string;
productName: string;
quantity: number;
unitPrice: number;
discount: number;
totalPrice: number;
note?: string;
}
interface Quotation {
id: string;
quoteId: string;
customerId: string;
customerName: string;
status: 'DRAFT' | 'PENDING' | 'SENT' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED';
validUntil: string;
totalAmount: number;
currency: string;
createdAt: string;
items: QuoteItem[];
}
const QUOTE_STATUS_MAP: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'default', text: 'Draft' },
PENDING: { color: 'processing', text: 'Pending' },
SENT: { color: 'blue', text: 'Sent' },
ACCEPTED: { color: 'success', text: 'Accepted' },
REJECTED: { color: 'error', text: 'Rejected' },
EXPIRED: { color: 'warning', text: 'Expired' },
};
const CUSTOMER_TIERS = [
{ value: 'BASIC', label: 'Basic', discount: 0 },
{ value: 'PRO', label: 'Pro', discount: 5 },
{ value: 'ENTERPRISE', label: 'Enterprise', discount: 10 },
];
const MIN_PROFIT_RATE = 15;
export const EnterpriseQuote: React.FC = () => {
const [form] = Form.useForm();
const [itemForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [quotations, setQuotations] = useState<Quotation[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
const [quoteItems, setQuoteItems] = useState<QuoteItem[]>([]);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedQuote, setSelectedQuote] = useState<Quotation | null>(null);
const [addProductModalVisible, setAddProductModalVisible] = useState(false);
const [stats, setStats] = useState({
total: 0,
pending: 0,
accepted: 0,
totalAmount: 0,
});
useEffect(() => {
fetchQuotations();
fetchCustomers();
fetchProducts();
}, []);
const fetchQuotations = async () => {
setLoading(true);
try {
const mockQuotations: Quotation[] = [
{
id: '1',
quoteId: 'QT-2026-001',
customerId: 'CUST_001',
customerName: 'ABC Trading Co.',
status: 'SENT',
validUntil: '2026-04-18',
totalAmount: 15000.00,
currency: 'USD',
createdAt: '2026-03-18 10:00:00',
items: [],
},
{
id: '2',
quoteId: 'QT-2026-002',
customerId: 'CUST_002',
customerName: 'XYZ Electronics Ltd.',
status: 'PENDING',
validUntil: '2026-04-15',
totalAmount: 28500.00,
currency: 'USD',
createdAt: '2026-03-17 14:30:00',
items: [],
},
{
id: '3',
quoteId: 'QT-2026-003',
customerId: 'CUST_003',
customerName: 'Global Import Inc.',
status: 'ACCEPTED',
validUntil: '2026-04-10',
totalAmount: 42000.00,
currency: 'USD',
createdAt: '2026-03-15 09:00:00',
items: [],
},
];
setQuotations(mockQuotations);
calculateStats(mockQuotations);
} catch (error) {
message.error('Failed to load quotations');
} finally {
setLoading(false);
}
};
const fetchCustomers = async () => {
const mockCustomers: Customer[] = [
{ id: 'CUST_001', name: 'ABC Trading Co.', email: 'contact@abc.com', company: 'ABC Trading', tier: 'ENTERPRISE' },
{ id: 'CUST_002', name: 'XYZ Electronics Ltd.', email: 'sales@xyz.com', company: 'XYZ Electronics', tier: 'PRO' },
{ id: 'CUST_003', name: 'Global Import Inc.', email: 'info@global.com', company: 'Global Import', tier: 'ENTERPRISE' },
];
setCustomers(mockCustomers);
};
const fetchProducts = async () => {
const mockProducts: Product[] = [
{ id: 'PROD_001', sku: 'SKU-001', name: 'Wireless Headphones', costPrice: 15.00, suggestedPrice: 25.00, moq: 100, stock: 5000 },
{ id: 'PROD_002', sku: 'SKU-002', name: 'USB-C Cable', costPrice: 2.50, suggestedPrice: 5.00, moq: 500, stock: 10000 },
{ id: 'PROD_003', sku: 'SKU-003', name: 'Phone Case', costPrice: 3.00, suggestedPrice: 8.00, moq: 200, stock: 8000 },
];
setProducts(mockProducts);
};
const calculateStats = (quotes: Quotation[]) => {
setStats({
total: quotes.length,
pending: quotes.filter((q) => q.status === 'PENDING' || q.status === 'SENT').length,
accepted: quotes.filter((q) => q.status === 'ACCEPTED').length,
totalAmount: quotes.filter((q) => q.status === 'ACCEPTED').reduce((sum, q) => sum + q.totalAmount, 0),
});
};
const handleCustomerChange = (customerId: string) => {
const customer = customers.find((c) => c.id === customerId);
setSelectedCustomer(customer || null);
};
const handleAddProduct = async () => {
try {
const values = await itemForm.validateFields();
const product = products.find((p) => p.id === values.productId);
if (!product) return;
const tierDiscount = selectedCustomer
? CUSTOMER_TIERS.find((t) => t.value === selectedCustomer.tier)?.discount || 0
: 0;
const unitPrice = values.unitPrice || product.suggestedPrice;
const discount = values.discount || tierDiscount;
const totalPrice = unitPrice * values.quantity * (1 - discount / 100);
const profitRate = ((unitPrice - product.costPrice) / product.costPrice) * 100;
if (profitRate < MIN_PROFIT_RATE) {
message.warning(`Warning: Profit rate (${profitRate.toFixed(1)}%) is below minimum (${MIN_PROFIT_RATE}%)`);
}
const newItem: QuoteItem = {
key: Date.now().toString(),
productId: product.id,
sku: product.sku,
productName: product.name,
quantity: values.quantity,
unitPrice,
discount,
totalPrice,
note: values.note,
};
setQuoteItems([...quoteItems, newItem]);
setAddProductModalVisible(false);
itemForm.resetFields();
message.success('Product added to quotation');
} catch (error) {
message.error('Failed to add product');
}
};
const handleRemoveItem = (key: string) => {
setQuoteItems(quoteItems.filter((item) => item.key !== key));
};
const handleSubmitQuotation = async () => {
try {
const values = await form.validateFields();
if (quoteItems.length === 0) {
message.error('Please add at least one product');
return;
}
setLoading(true);
const totalAmount = quoteItems.reduce((sum, item) => sum + item.totalPrice, 0);
const quotationData = {
customerId: values.customerId,
validUntil: values.validUntil,
items: quoteItems,
totalAmount,
currency: 'USD',
note: values.note,
};
console.log('Submitting quotation:', quotationData);
await new Promise((resolve) => setTimeout(resolve, 1000));
message.success('Quotation created successfully');
setCreateModalVisible(false);
form.resetFields();
setQuoteItems([]);
setSelectedCustomer(null);
fetchQuotations();
} catch (error) {
message.error('Failed to create quotation');
} finally {
setLoading(false);
}
};
const handleViewDetail = (quote: Quotation) => {
setSelectedQuote(quote);
setDetailModalVisible(true);
};
const handleSendQuote = async (quoteId: string) => {
setLoading(true);
try {
console.log('Sending quotation:', quoteId);
await new Promise((resolve) => setTimeout(resolve, 500));
message.success('Quotation sent to customer');
fetchQuotations();
} catch (error) {
message.error('Failed to send quotation');
} finally {
setLoading(false);
}
};
const itemColumns: ColumnsType<QuoteItem> = [
{ 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: 'Discount',
dataIndex: 'discount',
key: 'discount',
width: 80,
render: (discount: number) => `${discount}%`,
},
{
title: 'Total',
dataIndex: 'totalPrice',
key: 'totalPrice',
width: 100,
render: (price: number) => `$${price.toFixed(2)}`,
},
{
title: 'Action',
key: 'action',
width: 80,
render: (_, record) => (
<Button type="link" danger onClick={() => handleRemoveItem(record.key)}>
Remove
</Button>
),
},
];
const quoteColumns: ColumnsType<Quotation> = [
{
title: 'Quote ID',
dataIndex: 'quoteId',
key: 'quoteId',
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: 100,
render: (status: string) => {
const config = QUOTE_STATUS_MAP[status];
return <Tag color={config.color}>{config.text}</Tag>;
},
},
{
title: 'Amount',
dataIndex: 'totalAmount',
key: 'totalAmount',
width: 120,
render: (amount: number) => (
<span style={{ fontWeight: 'bold' }}>${amount.toFixed(2)}</span>
),
},
{
title: 'Valid Until',
dataIndex: 'validUntil',
key: 'validUntil',
width: 120,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
},
{
title: 'Actions',
key: 'action',
width: 180,
fixed: 'right',
render: (_, record) => (
<Space>
<Tooltip title="View Details">
<Button type="link" icon={<FileTextOutlined />} onClick={() => handleViewDetail(record)} />
</Tooltip>
{(record.status === 'DRAFT' || record.status === 'PENDING') && (
<Tooltip title="Send to Customer">
<Button type="link" icon={<SendOutlined />} onClick={() => handleSendQuote(record.quoteId)} />
</Tooltip>
)}
</Space>
),
},
];
const totalAmount = quoteItems.reduce((sum, item) => sum + item.totalPrice, 0);
return (
<div className="enterprise-quote-page">
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic title="Total Quotations" value={stats.total} prefix={<FileTextOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Pending"
value={stats.pending}
valueStyle={{ color: '#1890ff' }}
prefix={<HistoryOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Accepted"
value={stats.accepted}
valueStyle={{ color: '#52c41a' }}
prefix={<FileTextOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="Total Amount"
value={stats.totalAmount}
precision={2}
prefix="$"
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
</Row>
<Card
title="Enterprise Quotations"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
Create Quotation
</Button>
}
>
<Table
columns={quoteColumns}
dataSource={quotations}
rowKey="id"
loading={loading}
scroll={{ x: 1000 }}
pagination={{
showSizeChanger: true,
showTotal: (total) => `Total ${total} quotations`,
}}
/>
</Card>
<Modal
title="Create New Quotation"
open={createModalVisible}
onCancel={() => {
setCreateModalVisible(false);
setQuoteItems([]);
setSelectedCustomer(null);
}}
onOk={handleSubmitQuotation}
confirmLoading={loading}
width={900}
>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<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>
</Col>
<Col span={12}>
<Form.Item name="validUntil" label="Valid Until" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
{selectedCustomer && (
<Card size="small" style={{ marginBottom: 16, background: '#f5f5f5' }}>
<Descriptions column={3} size="small">
<Descriptions.Item label="Tier">
<Tag color={selectedCustomer.tier === 'ENTERPRISE' ? 'gold' : 'blue'}>
{selectedCustomer.tier}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Default Discount">
{CUSTOMER_TIERS.find((t) => t.value === selectedCustomer.tier)?.discount}%
</Descriptions.Item>
<Descriptions.Item label="Email">{selectedCustomer.email}</Descriptions.Item>
</Descriptions>
</Card>
)}
<Divider>Products</Divider>
<div style={{ marginBottom: 16 }}>
<Button icon={<PlusOutlined />} onClick={() => setAddProductModalVisible(true)}>
Add Product
</Button>
</div>
<Table
columns={itemColumns}
dataSource={quoteItems}
rowKey="key"
pagination={false}
size="small"
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={5} align="right">
<strong>Total:</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={1}>
<strong>${totalAmount.toFixed(2)}</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} />
</Table.Summary.Row>
)}
/>
<Form.Item name="note" label="Note" style={{ marginTop: 16 }}>
<TextArea rows={2} placeholder="Add notes (optional)" />
</Form.Item>
</Form>
</Modal>
<Modal
title="Add Product"
open={addProductModalVisible}
onCancel={() => setAddProductModalVisible(false)}
onOk={handleAddProduct}
width={600}
>
<Form form={itemForm} layout="vertical">
<Form.Item name="productId" label="Product" rules={[{ required: true }]}>
<Select placeholder="Select product" showSearch optionFilterProp="children">
{products.map((p) => (
<Option key={p.id} value={p.id}>
{p.sku} - {p.name} (Cost: ${p.costPrice}, Suggested: ${p.suggestedPrice}, MOQ: {p.moq})
</Option>
))}
</Select>
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="quantity" label="Quantity" rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="unitPrice" label="Unit Price ($)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="discount" label="Discount (%)">
<InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="note" label="Note">
<Input placeholder="Item note (optional)" />
</Form.Item>
</Form>
</Modal>
<Modal
title="Quotation Details"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>
Close
</Button>,
]}
width={700}
>
{selectedQuote && (
<Descriptions bordered column={2}>
<Descriptions.Item label="Quote ID">{selectedQuote.quoteId}</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={QUOTE_STATUS_MAP[selectedQuote.status].color}>
{QUOTE_STATUS_MAP[selectedQuote.status].text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Customer">{selectedQuote.customerName}</Descriptions.Item>
<Descriptions.Item label="Currency">{selectedQuote.currency}</Descriptions.Item>
<Descriptions.Item label="Total Amount" span={2}>
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
${selectedQuote.totalAmount.toFixed(2)}
</span>
</Descriptions.Item>
<Descriptions.Item label="Valid Until">{selectedQuote.validUntil}</Descriptions.Item>
<Descriptions.Item label="Created">{selectedQuote.createdAt}</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
);
};
export default EnterpriseQuote;

View File

@@ -0,0 +1,6 @@
export { EnterpriseQuote } from './EnterpriseQuote';
export { BatchOrder } from './BatchOrder';
export { ContractManage } from './ContractManage';
export { default as EnterpriseQuotePage } from './EnterpriseQuote';
export { default as BatchOrderPage } from './BatchOrder';
export { default as ContractManagePage } from './ContractManage';