feat: 新增多模块功能与服务实现
新增广告计划、用户资产、B2B交易、合规规则等核心模型 实现爬虫工作器、贸易服务、现金流预测等业务服务 添加RBAC权限测试、压力测试等测试用例 完善扩展程序的消息处理与内容脚本功能 重构应用入口与文档生成器 更新项目规则与业务闭环分析文档
This commit is contained in:
456
dashboard/src/pages/Product/MaterialUpload.tsx
Normal file
456
dashboard/src/pages/Product/MaterialUpload.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Upload,
|
||||
Button,
|
||||
message,
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Image,
|
||||
Progress,
|
||||
Divider,
|
||||
Row,
|
||||
Col,
|
||||
Input,
|
||||
Select,
|
||||
Tooltip,
|
||||
Alert,
|
||||
Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
CopyOutlined,
|
||||
FolderOutlined,
|
||||
FileImageOutlined,
|
||||
FileOutlined,
|
||||
VideoCameraOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
interface MaterialFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'image' | 'video' | 'document';
|
||||
size: number;
|
||||
url: string;
|
||||
thumbnail?: string;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
status: 'READY' | 'PROCESSING' | 'FAILED';
|
||||
tags: string[];
|
||||
productId?: string;
|
||||
productName?: string;
|
||||
}
|
||||
|
||||
const MOCK_MATERIALS: MaterialFile[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'product_main_001.jpg',
|
||||
type: 'image',
|
||||
size: 2456789,
|
||||
url: 'https://via.placeholder.com/800x800?text=Product+1',
|
||||
thumbnail: 'https://via.placeholder.com/100x100?text=1',
|
||||
uploadedAt: '2026-03-18 10:30:00',
|
||||
uploadedBy: 'Admin',
|
||||
status: 'READY',
|
||||
tags: ['main', 'product'],
|
||||
productId: 'P-2026-001',
|
||||
productName: 'Industrial Sensor A',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'product_gallery_002.jpg',
|
||||
type: 'image',
|
||||
size: 1890234,
|
||||
url: 'https://via.placeholder.com/800x800?text=Product+2',
|
||||
thumbnail: 'https://via.placeholder.com/100x100?text=2',
|
||||
uploadedAt: '2026-03-18 10:35:00',
|
||||
uploadedBy: 'Admin',
|
||||
status: 'READY',
|
||||
tags: ['gallery', 'product'],
|
||||
productId: 'P-2026-001',
|
||||
productName: 'Industrial Sensor A',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'product_video_demo.mp4',
|
||||
type: 'video',
|
||||
size: 15678901,
|
||||
url: 'https://example.com/video.mp4',
|
||||
thumbnail: 'https://via.placeholder.com/100x100?text=Video',
|
||||
uploadedAt: '2026-03-17 14:00:00',
|
||||
uploadedBy: 'Operator',
|
||||
status: 'PROCESSING',
|
||||
tags: ['demo', 'video'],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'product_manual.pdf',
|
||||
type: 'document',
|
||||
size: 567890,
|
||||
url: 'https://example.com/manual.pdf',
|
||||
uploadedAt: '2026-03-16 09:00:00',
|
||||
uploadedBy: 'Admin',
|
||||
status: 'READY',
|
||||
tags: ['manual', 'document'],
|
||||
productId: 'P-2026-002',
|
||||
productName: 'Control Module B',
|
||||
},
|
||||
];
|
||||
|
||||
const TYPE_CONFIG: Record<string, { color: string; icon: React.ReactNode }> = {
|
||||
image: { color: 'blue', icon: <FileImageOutlined /> },
|
||||
video: { color: 'purple', icon: <VideoCameraOutlined /> },
|
||||
document: { color: 'green', icon: <FileOutlined /> },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
READY: { color: 'success', text: 'Ready' },
|
||||
PROCESSING: { color: 'processing', text: 'Processing' },
|
||||
FAILED: { color: 'error', text: 'Failed' },
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
export const MaterialUpload: React.FC = () => {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [materials, setMaterials] = useState<MaterialFile[]>(MOCK_MATERIALS);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<MaterialFile | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.warning('Please select files to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
for (let i = 0; i <= 100; i += 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
setUploadProgress(i);
|
||||
}
|
||||
|
||||
const newMaterials: MaterialFile[] = fileList.map((file, index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
name: file.name,
|
||||
type: file.type?.startsWith('image') ? 'image' :
|
||||
file.type?.startsWith('video') ? 'video' : 'document',
|
||||
size: file.size || 0,
|
||||
url: URL.createObjectURL(file as any),
|
||||
thumbnail: file.type?.startsWith('image') ? URL.createObjectURL(file as any) : undefined,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: 'Current User',
|
||||
status: 'READY',
|
||||
tags: [],
|
||||
}));
|
||||
|
||||
setMaterials([...newMaterials, ...materials]);
|
||||
setFileList([]);
|
||||
setUploading(false);
|
||||
message.success(`${fileList.length} file(s) uploaded successfully`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
Modal.confirm({
|
||||
title: 'Delete Material',
|
||||
content: 'Are you sure you want to delete this file?',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
setMaterials(materials.filter(m => m.id !== id));
|
||||
message.success('File deleted');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreview = (file: MaterialFile) => {
|
||||
setPreviewFile(file);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
const handleCopyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success('URL copied to clipboard');
|
||||
};
|
||||
|
||||
const getFilteredMaterials = () => {
|
||||
return materials.filter(m => {
|
||||
const matchesSearch = m.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
m.tags.some(t => t.toLowerCase().includes(searchText.toLowerCase()));
|
||||
const matchesType = filterType === 'all' || m.type === filterType;
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Preview',
|
||||
dataIndex: 'thumbnail',
|
||||
key: 'thumbnail',
|
||||
width: 80,
|
||||
render: (_: any, record: MaterialFile) => (
|
||||
record.type === 'image' ? (
|
||||
<Image
|
||||
src={record.thumbnail}
|
||||
alt={record.name}
|
||||
width={50}
|
||||
height={50}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 50, height: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f5f5f5', borderRadius: 4 }}>
|
||||
{TYPE_CONFIG[record.type].icon}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record: MaterialFile) => (
|
||||
<Space>
|
||||
<Tag color={TYPE_CONFIG[record.type].color} icon={TYPE_CONFIG[record.type].icon}>
|
||||
{record.type.toUpperCase()}
|
||||
</Tag>
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 100,
|
||||
render: (size: number) => formatFileSize(size),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => (
|
||||
<Tag color={STATUS_CONFIG[status].color}>{STATUS_CONFIG[status].text}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Product',
|
||||
dataIndex: 'productName',
|
||||
key: 'productName',
|
||||
width: 150,
|
||||
render: (name: string) => name || <Tag>Unassigned</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Uploaded',
|
||||
dataIndex: 'uploadedAt',
|
||||
key: 'uploadedAt',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
dataIndex: 'tags',
|
||||
key: 'tags',
|
||||
width: 150,
|
||||
render: (tags: string[]) => tags.map(t => <Tag key={t}>{t}</Tag>),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: any, record: MaterialFile) => (
|
||||
<Space>
|
||||
<Tooltip title="Preview">
|
||||
<Button type="text" icon={<EyeOutlined />} onClick={() => handlePreview(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Copy URL">
|
||||
<Button type="text" icon={<CopyOutlined />} onClick={() => handleCopyUrl(record.url)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const stats = {
|
||||
total: materials.length,
|
||||
images: materials.filter(m => m.type === 'image').length,
|
||||
videos: materials.filter(m => m.type === 'video').length,
|
||||
documents: materials.filter(m => m.type === 'document').length,
|
||||
totalSize: materials.reduce((acc, m) => acc + m.size, 0),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="material-upload-page">
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<FolderOutlined style={{ fontSize: 32, color: '#1890ff' }} />
|
||||
<div>
|
||||
<div style={{ color: '#666' }}>Total Files</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold' }}>{stats.total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<FileImageOutlined style={{ fontSize: 32, color: '#52c41a' }} />
|
||||
<div>
|
||||
<div style={{ color: '#666' }}>Images</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold' }}>{stats.images}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<VideoCameraOutlined style={{ fontSize: 32, color: '#722ed1' }} />
|
||||
<div>
|
||||
<div style={{ color: '#666' }}>Videos</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold' }}>{stats.videos}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<FileOutlined style={{ fontSize: 32, color: '#fa8c16' }} />
|
||||
<div>
|
||||
<div style={{ color: '#666' }}>Total Size</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold' }}>{formatFileSize(stats.totalSize)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="Upload Materials">
|
||||
<Tabs defaultActiveKey="upload">
|
||||
<TabPane tab="Upload" key="upload">
|
||||
<Alert
|
||||
message="Upload Guidelines"
|
||||
description="Supported formats: JPG, PNG, GIF, MP4, PDF. Maximum file size: 50MB per file. Recommended image resolution: 1000x1000 pixels or higher."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Upload
|
||||
multiple
|
||||
fileList={fileList}
|
||||
onChange={({ fileList }) => setFileList(fileList)}
|
||||
beforeUpload={() => false}
|
||||
listType="picture"
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>Select Files</Button>
|
||||
</Upload>
|
||||
|
||||
{uploading && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Progress percent={uploadProgress} status="active" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={fileList.length === 0}
|
||||
loading={uploading}
|
||||
>
|
||||
Start Upload
|
||||
</Button>
|
||||
<Button onClick={() => setFileList([])} disabled={fileList.length === 0}>
|
||||
Clear
|
||||
</Button>
|
||||
</Space>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Library" key="library">
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="Search by name or tag"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Select value={filterType} onChange={setFilterType} style={{ width: 120 }}>
|
||||
<Option value="all">All Types</Option>
|
||||
<Option value="image">Images</Option>
|
||||
<Option value="video">Videos</Option>
|
||||
<Option value="document">Documents</Option>
|
||||
</Select>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { setSearchText(''); setFilterType('all'); }}>
|
||||
Reset
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getFilteredMaterials()}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="File Preview"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
{previewFile && (
|
||||
<div>
|
||||
{previewFile.type === 'image' ? (
|
||||
<Image src={previewFile.url} style={{ width: '100%' }} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
{TYPE_CONFIG[previewFile.type].icon}
|
||||
<p style={{ marginTop: 16 }}>{previewFile.name}</p>
|
||||
<p style={{ color: '#666' }}>{formatFileSize(previewFile.size)}</p>
|
||||
<Button type="primary" href={previewFile.url} target="_blank">
|
||||
Open File
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialUpload;
|
||||
404
dashboard/src/pages/Product/ProductDetail.tsx
Normal file
404
dashboard/src/pages/Product/ProductDetail.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Descriptions,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Image,
|
||||
Divider,
|
||||
Row,
|
||||
Col,
|
||||
Tabs,
|
||||
Table,
|
||||
Modal,
|
||||
message,
|
||||
Spin,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import {
|
||||
ShoppingCartOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
HistoryOutlined,
|
||||
ShopOutlined,
|
||||
TagOutlined,
|
||||
DollarOutlined,
|
||||
BoxPlotOutlined,
|
||||
FileImageOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
interface ProductDetail {
|
||||
id: string;
|
||||
productId: string;
|
||||
platform: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
brand: string;
|
||||
status: 'DRAFT' | 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'SUSPENDED';
|
||||
price: number;
|
||||
costPrice: number;
|
||||
currency: string;
|
||||
stock: number;
|
||||
sku: string;
|
||||
images: string[];
|
||||
attributes: Record<string, string>;
|
||||
variants: ProductVariant[];
|
||||
complianceStatus: 'COMPLIANT' | 'NON_COMPLIANT' | 'PENDING_REVIEW';
|
||||
certificates: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PriceHistory {
|
||||
id: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
changedAt: string;
|
||||
changedBy: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface StockHistory {
|
||||
id: string;
|
||||
quantity: number;
|
||||
type: 'IN' | 'OUT';
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const MOCK_PRODUCT: ProductDetail = {
|
||||
id: '1',
|
||||
productId: 'P-2026-001',
|
||||
platform: 'Amazon',
|
||||
name: 'Industrial Temperature Sensor Pro',
|
||||
description: 'High-precision industrial temperature sensor with digital output. Suitable for manufacturing environments with temperature range -40°C to +125°C.',
|
||||
category: 'Industrial Automation > Sensors',
|
||||
brand: 'TechPro',
|
||||
status: 'ACTIVE',
|
||||
price: 89.99,
|
||||
costPrice: 45.00,
|
||||
currency: 'USD',
|
||||
stock: 256,
|
||||
sku: 'TP-TEMP-001',
|
||||
images: [
|
||||
'https://via.placeholder.com/400x400?text=Product+1',
|
||||
'https://via.placeholder.com/400x400?text=Product+2',
|
||||
'https://via.placeholder.com/400x400?text=Product+3',
|
||||
],
|
||||
attributes: {
|
||||
'Temperature Range': '-40°C to +125°C',
|
||||
'Accuracy': '±0.5°C',
|
||||
'Output Type': 'Digital (I2C)',
|
||||
'Power Supply': '3.3V - 5V DC',
|
||||
'Housing Material': 'Stainless Steel 316',
|
||||
'IP Rating': 'IP67',
|
||||
},
|
||||
variants: [
|
||||
{ id: 'v1', sku: 'TP-TEMP-001-A', name: 'Standard Cable 1m', price: 89.99, stock: 150, attributes: { 'Cable Length': '1m' } },
|
||||
{ id: 'v2', sku: 'TP-TEMP-001-B', name: 'Extended Cable 3m', price: 99.99, stock: 80, attributes: { 'Cable Length': '3m' } },
|
||||
{ id: 'v3', sku: 'TP-TEMP-001-C', name: 'Extended Cable 5m', price: 109.99, stock: 26, attributes: { 'Cable Length': '5m' } },
|
||||
],
|
||||
complianceStatus: 'COMPLIANT',
|
||||
certificates: ['CE', 'FCC', 'RoHS'],
|
||||
createdAt: '2025-12-15 10:00:00',
|
||||
updatedAt: '2026-03-18 14:30:00',
|
||||
publishedAt: '2025-12-20 09:00:00',
|
||||
};
|
||||
|
||||
const MOCK_PRICE_HISTORY: PriceHistory[] = [
|
||||
{ id: '1', price: 89.99, currency: 'USD', changedAt: '2026-03-18 14:30:00', changedBy: 'System', reason: 'Market price adjustment' },
|
||||
{ id: '2', price: 84.99, currency: 'USD', changedAt: '2026-02-01 10:00:00', changedBy: 'Admin', reason: 'Promotional pricing' },
|
||||
{ id: '3', price: 94.99, currency: 'USD', changedAt: '2025-12-20 09:00:00', changedBy: 'Admin', reason: 'Initial listing price' },
|
||||
];
|
||||
|
||||
const MOCK_STOCK_HISTORY: StockHistory[] = [
|
||||
{ id: '1', quantity: 50, type: 'IN', reason: 'Restock from supplier', createdAt: '2026-03-15 08:00:00' },
|
||||
{ id: '2', quantity: 25, type: 'OUT', reason: 'Order #ORD-2026-1234', createdAt: '2026-03-14 16:30:00' },
|
||||
{ id: '3', quantity: 100, type: 'IN', reason: 'Initial stock', createdAt: '2025-12-20 09:00:00' },
|
||||
];
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
DRAFT: { color: 'default', text: 'Draft' },
|
||||
PENDING: { color: 'processing', text: 'Pending Review' },
|
||||
ACTIVE: { color: 'success', text: 'Active' },
|
||||
INACTIVE: { color: 'warning', text: 'Inactive' },
|
||||
SUSPENDED: { color: 'error', text: 'Suspended' },
|
||||
};
|
||||
|
||||
const COMPLIANCE_CONFIG: Record<string, { color: string; icon: React.ReactNode }> = {
|
||||
COMPLIANT: { color: 'success', icon: <CheckCircleOutlined /> },
|
||||
NON_COMPLIANT: { color: 'error', icon: <CloseCircleOutlined /> },
|
||||
PENDING_REVIEW: { color: 'warning', icon: <SyncOutlined spin /> },
|
||||
};
|
||||
|
||||
export const ProductDetail: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||
const [priceHistory, setPriceHistory] = useState<PriceHistory[]>([]);
|
||||
const [stockHistory, setStockHistory] = useState<StockHistory[]>([]);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProductDetail();
|
||||
}, []);
|
||||
|
||||
const fetchProductDetail = async () => {
|
||||
setLoading(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setProduct(MOCK_PRODUCT);
|
||||
setPriceHistory(MOCK_PRICE_HISTORY);
|
||||
setStockHistory(MOCK_STOCK_HISTORY);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Modal.confirm({
|
||||
title: 'Delete Product',
|
||||
content: 'Are you sure you want to delete this product? This action cannot be undone.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
message.success('Product deleted successfully');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
message.success('Product duplicated. Redirecting to edit page...');
|
||||
};
|
||||
|
||||
const handlePublish = () => {
|
||||
Modal.confirm({
|
||||
title: 'Publish Product',
|
||||
content: 'Publish this product to all connected platforms?',
|
||||
onOk: () => {
|
||||
message.success('Product published successfully');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const priceColumns: ColumnsType<PriceHistory> = [
|
||||
{ title: 'Price', dataIndex: 'price', key: 'price', render: (v, r) => `${r.currency} ${v.toFixed(2)}` },
|
||||
{ title: 'Changed At', dataIndex: 'changedAt', key: 'changedAt' },
|
||||
{ title: 'Changed By', dataIndex: 'changedBy', key: 'changedBy' },
|
||||
{ title: 'Reason', dataIndex: 'reason', key: 'reason' },
|
||||
];
|
||||
|
||||
const stockColumns: ColumnsType<StockHistory> = [
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
render: (type: string) => (
|
||||
<Tag color={type === 'IN' ? 'green' : 'red'}>
|
||||
{type === 'IN' ? 'Stock In' : 'Stock Out'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{ title: 'Quantity', dataIndex: 'quantity', key: 'quantity' },
|
||||
{ title: 'Reason', dataIndex: 'reason', key: 'reason' },
|
||||
{ title: 'Date', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
];
|
||||
|
||||
const variantColumns: ColumnsType<ProductVariant> = [
|
||||
{ title: 'SKU', dataIndex: 'sku', key: 'sku' },
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'Price', dataIndex: 'price', key: 'price', render: (v) => `USD ${v.toFixed(2)}` },
|
||||
{
|
||||
title: 'Stock',
|
||||
dataIndex: 'stock',
|
||||
key: 'stock',
|
||||
render: (stock: number) => (
|
||||
<Badge
|
||||
count={stock}
|
||||
showZero
|
||||
style={{ backgroundColor: stock > 50 ? '#52c41a' : stock > 10 ? '#faad14' : '#ff4d4f' }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return <div>Product not found</div>;
|
||||
}
|
||||
|
||||
const profitMargin = ((product.price - product.costPrice) / product.price * 100).toFixed(1);
|
||||
const statusConfig = STATUS_CONFIG[product.status];
|
||||
const complianceConfig = COMPLIANCE_CONFIG[product.complianceStatus];
|
||||
|
||||
return (
|
||||
<div className="product-detail-page">
|
||||
<Card>
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Image.PreviewGroup>
|
||||
<Image
|
||||
src={product.images[0]}
|
||||
alt={product.name}
|
||||
style={{ width: '100%', borderRadius: 8 }}
|
||||
/>
|
||||
<div style={{ display: 'none' }}>
|
||||
{product.images.slice(1).map((img, idx) => (
|
||||
<Image key={idx} src={img} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
<Space style={{ marginTop: 16, width: '100%' }} wrap>
|
||||
{product.images.map((img, idx) => (
|
||||
<Image
|
||||
key={idx}
|
||||
src={img}
|
||||
width={60}
|
||||
height={60}
|
||||
style={{ borderRadius: 4, objectFit: 'cover', cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div>
|
||||
<Space>
|
||||
<Tag color="blue">{product.platform}</Tag>
|
||||
<Tag color={statusConfig.color}>{statusConfig.text}</Tag>
|
||||
<Tag color={complianceConfig.color} icon={complianceConfig.icon}>
|
||||
{product.complianceStatus.replace('_', ' ')}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Title level={3} style={{ marginTop: 8, marginBottom: 0 }}>{product.name}</Title>
|
||||
<Text type="secondary">SKU: {product.sku} | ID: {product.productId}</Text>
|
||||
</div>
|
||||
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Category">{product.category}</Descriptions.Item>
|
||||
<Descriptions.Item label="Brand">{product.brand}</Descriptions.Item>
|
||||
<Descriptions.Item label={<><DollarOutlined /> Price</>}>
|
||||
<Text strong style={{ fontSize: 18 }}>{product.currency} {product.price.toFixed(2)}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Cost Price">
|
||||
{product.currency} {product.costPrice.toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Profit Margin">
|
||||
<Tag color={parseFloat(profitMargin) >= 30 ? 'green' : parseFloat(profitMargin) >= 15 ? 'orange' : 'red'}>
|
||||
{profitMargin}%
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={<><BoxPlotOutlined /> Stock</>}>
|
||||
<Badge
|
||||
count={product.stock}
|
||||
showZero
|
||||
style={{ backgroundColor: product.stock > 50 ? '#52c41a' : product.stock > 10 ? '#faad14' : '#ff4d4f' }}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div>
|
||||
<Text strong>Certificates: </Text>
|
||||
{product.certificates.map(cert => (
|
||||
<Tag key={cert} icon={<CheckCircleOutlined />} color="green">{cert}</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Space>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>Edit</Button>
|
||||
<Button icon={<CopyOutlined />} onClick={handleDuplicate}>Duplicate</Button>
|
||||
<Button icon={<ShopOutlined />} onClick={handlePublish}>Publish</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleDelete}>Delete</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Tabs defaultActiveKey="description">
|
||||
<TabPane tab="Description" key="description">
|
||||
<Text style={{ whiteSpace: 'pre-wrap' }}>{product.description}</Text>
|
||||
</TabPane>
|
||||
<TabPane tab="Attributes" key="attributes">
|
||||
<Descriptions column={2} bordered size="small">
|
||||
{Object.entries(product.attributes).map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={key}>{value}</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</TabPane>
|
||||
<TabPane tab="Variants" key="variants">
|
||||
<Table
|
||||
columns={variantColumns}
|
||||
dataSource={product.variants}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane tab={<><HistoryOutlined /> Price History</>} key="priceHistory">
|
||||
<Table
|
||||
columns={priceColumns}
|
||||
dataSource={priceHistory}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 5 }}
|
||||
size="small"
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane tab={<><BoxPlotOutlined /> Stock History</>} key="stockHistory">
|
||||
<Table
|
||||
columns={stockColumns}
|
||||
dataSource={stockHistory}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 5 }}
|
||||
size="small"
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="Edit Product"
|
||||
open={editModalVisible}
|
||||
onCancel={() => setEditModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<p>Edit form will be implemented here...</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
465
dashboard/src/pages/Product/ProductPublishForm.tsx
Normal file
465
dashboard/src/pages/Product/ProductPublishForm.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
Button,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
Upload,
|
||||
message,
|
||||
Tabs,
|
||||
Table,
|
||||
Tag,
|
||||
Modal,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SaveOutlined,
|
||||
SendOutlined,
|
||||
InfoCircleOutlined,
|
||||
ShopOutlined,
|
||||
DollarOutlined,
|
||||
BoxPlotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TabPane } = Tabs;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
attributes: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PublishFormValues {
|
||||
name: string;
|
||||
platform: string[];
|
||||
category: string;
|
||||
brand: string;
|
||||
description: string;
|
||||
price: number;
|
||||
costPrice: number;
|
||||
currency: string;
|
||||
stock: number;
|
||||
sku: string;
|
||||
status: 'DRAFT' | 'PENDING' | 'ACTIVE';
|
||||
autoPublish: boolean;
|
||||
complianceCheck: boolean;
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
{ value: 'Amazon', label: 'Amazon' },
|
||||
{ value: 'eBay', label: 'eBay' },
|
||||
{ value: 'Shopify', label: 'Shopify' },
|
||||
{ value: 'Walmart', label: 'Walmart' },
|
||||
{ value: 'AliExpress', label: 'AliExpress' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'Industrial Automation > Sensors', label: 'Industrial Automation > Sensors' },
|
||||
{ value: 'Industrial Automation > Controllers', label: 'Industrial Automation > Controllers' },
|
||||
{ value: 'Industrial Automation > Motors', label: 'Industrial Automation > Motors' },
|
||||
{ value: 'Electronics > Components', label: 'Electronics > Components' },
|
||||
{ value: 'Tools > Power Tools', label: 'Tools > Power Tools' },
|
||||
];
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', label: 'USD - US Dollar' },
|
||||
{ value: 'EUR', label: 'EUR - Euro' },
|
||||
{ value: 'GBP', label: 'GBP - British Pound' },
|
||||
{ value: 'CNY', label: 'CNY - Chinese Yuan' },
|
||||
];
|
||||
|
||||
export const ProductPublishForm: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
|
||||
const handleAddVariant = () => {
|
||||
const newVariant: ProductVariant = {
|
||||
id: `v${Date.now()}`,
|
||||
sku: '',
|
||||
name: '',
|
||||
price: 0,
|
||||
stock: 0,
|
||||
attributes: {},
|
||||
};
|
||||
setVariants([...variants, newVariant]);
|
||||
};
|
||||
|
||||
const handleRemoveVariant = (id: string) => {
|
||||
setVariants(variants.filter(v => v.id !== id));
|
||||
};
|
||||
|
||||
const handleVariantChange = (id: string, field: string, value: any) => {
|
||||
setVariants(variants.map(v =>
|
||||
v.id === id ? { ...v, [field]: value } : v
|
||||
));
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
setLoading(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
message.success('Draft saved successfully');
|
||||
} catch (error) {
|
||||
message.error('Please fill in required fields');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
if (values.complianceCheck) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
message.info('Running compliance check...');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
Modal.success({
|
||||
title: 'Product Submitted',
|
||||
content: `Product "${values.name}" has been submitted for ${values.autoPublish ? 'auto-publish' : 'review'} on ${values.platform.join(', ')}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
message.error('Please fill in required fields');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const variantColumns = [
|
||||
{
|
||||
title: 'SKU',
|
||||
dataIndex: 'sku',
|
||||
key: 'sku',
|
||||
render: (value: string, record: ProductVariant) => (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleVariantChange(record.id, 'sku', e.target.value)}
|
||||
placeholder="SKU"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (value: string, record: ProductVariant) => (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleVariantChange(record.id, 'name', e.target.value)}
|
||||
placeholder="Variant Name"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
render: (value: number, record: ProductVariant) => (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(v) => handleVariantChange(record.id, 'price', v)}
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Stock',
|
||||
dataIndex: 'stock',
|
||||
key: 'stock',
|
||||
render: (value: number, record: ProductVariant) => (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(v) => handleVariantChange(record.id, 'stock', v)}
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
render: (_: any, record: ProductVariant) => (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveVariant(record.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="product-publish-form-page">
|
||||
<Card title="Product Publish Form">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
currency: 'USD',
|
||||
status: 'DRAFT',
|
||||
autoPublish: false,
|
||||
complianceCheck: true,
|
||||
}}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<TabPane tab="Basic Info" key="basic">
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Product Name"
|
||||
rules={[{ required: true, message: 'Please enter product name' }]}
|
||||
>
|
||||
<Input placeholder="Enter product name" maxLength={200} showCount />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="platform"
|
||||
label="Target Platforms"
|
||||
rules={[{ required: true, message: 'Please select at least one platform' }]}
|
||||
>
|
||||
<Select mode="multiple" placeholder="Select platforms">
|
||||
{PLATFORMS.map(p => (
|
||||
<Option key={p.value} value={p.value}>{p.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="Category"
|
||||
rules={[{ required: true, message: 'Please select category' }]}
|
||||
>
|
||||
<Select placeholder="Select category" showSearch>
|
||||
{CATEGORIES.map(c => (
|
||||
<Option key={c.value} value={c.value}>{c.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="brand" label="Brand">
|
||||
<Input placeholder="Enter brand name" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="Description"
|
||||
rules={[{ required: true, message: 'Please enter description' }]}
|
||||
>
|
||||
<TextArea rows={4} placeholder="Enter product description" maxLength={5000} showCount />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>Product Images</Divider>
|
||||
|
||||
<Form.Item label="Images">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={fileList}
|
||||
onChange={({ fileList }) => setFileList(fileList)}
|
||||
beforeUpload={() => false}
|
||||
multiple
|
||||
>
|
||||
{fileList.length >= 8 ? null : (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>Upload</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={<><DollarOutlined /> Pricing</>} key="pricing">
|
||||
<Alert
|
||||
message="Profit Margin Validation"
|
||||
description="B2B products must maintain at least 15% profit margin. B2C products require 20% minimum."
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="price"
|
||||
label="Selling Price"
|
||||
rules={[{ required: true, message: 'Please enter price' }]}
|
||||
>
|
||||
<InputNumber
|
||||
prefix="$"
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="costPrice"
|
||||
label="Cost Price"
|
||||
rules={[{ required: true, message: 'Please enter cost price' }]}
|
||||
>
|
||||
<InputNumber
|
||||
prefix="$"
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="currency" label="Currency">
|
||||
<Select>
|
||||
{CURRENCIES.map(c => (
|
||||
<Option key={c.value} value={c.value}>{c.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={<><BoxPlotOutlined /> Inventory</>} key="inventory">
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="sku"
|
||||
label="SKU"
|
||||
rules={[{ required: true, message: 'Please enter SKU' }]}
|
||||
>
|
||||
<Input placeholder="Enter SKU" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="stock"
|
||||
label="Initial Stock"
|
||||
rules={[{ required: true, message: 'Please enter stock quantity' }]}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider>Variants</Divider>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddVariant}>
|
||||
Add Variant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={variantColumns}
|
||||
dataSource={variants}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'No variants added' }}
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab="Publish Settings" key="settings">
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="Status"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="DRAFT">Draft</Option>
|
||||
<Option value="PENDING">Pending Review</Option>
|
||||
<Option value="ACTIVE">Active</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="complianceCheck"
|
||||
label="Run Compliance Check"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="autoPublish"
|
||||
label="Auto Publish After Compliance Check"
|
||||
valuePropName="checked"
|
||||
extra="Product will be automatically published if compliance check passes"
|
||||
>
|
||||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
|
||||
<Alert
|
||||
message="Publishing Rules"
|
||||
description={
|
||||
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||||
<li>All products must pass compliance check before publishing</li>
|
||||
<li>Products with profit margin below threshold will require manual approval</li>
|
||||
<li>Images must meet platform-specific requirements</li>
|
||||
</ul>
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => form.resetFields()}>Reset</Button>
|
||||
<Button icon={<SaveOutlined />} onClick={handleSaveDraft} loading={loading}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button type="primary" icon={<SendOutlined />} onClick={handleSubmit} loading={loading}>
|
||||
Submit for Review
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductPublishForm;
|
||||
3
dashboard/src/pages/Product/index.ts
Normal file
3
dashboard/src/pages/Product/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ProductDetail } from './ProductDetail';
|
||||
export { ProductPublishForm } from './ProductPublishForm';
|
||||
export { MaterialUpload } from './MaterialUpload';
|
||||
Reference in New Issue
Block a user