full bulk product import support
This commit is contained in:
parent
5d0ce42592
commit
3bab4e4c56
1 changed files with 487 additions and 11 deletions
|
|
@ -21,19 +21,27 @@ import {
|
|||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle
|
||||
DialogTitle,
|
||||
Stack,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon
|
||||
Clear as ClearIcon,
|
||||
FileDownload as DownloadIcon,
|
||||
Upload as UploadIcon,
|
||||
CloudUpload as CloudUploadIcon,
|
||||
CheckCircleOutline as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../../services/api';
|
||||
import ProductImage from '../../components/ProductImage';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
const AdminProductsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -44,6 +52,18 @@ const AdminProductsPage = () => {
|
|||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState(null);
|
||||
|
||||
// New states for upload functionality
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState(null);
|
||||
const [parsedProducts, setParsedProducts] = useState([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
const [uploadResults, setUploadResults] = useState(null);
|
||||
|
||||
const fileInputRef = React.useRef(null);
|
||||
|
||||
// Fetch products
|
||||
const {
|
||||
data: products,
|
||||
|
|
@ -76,6 +96,23 @@ const AdminProductsPage = () => {
|
|||
}
|
||||
});
|
||||
|
||||
// Bulk upload mutation
|
||||
const bulkUploadProducts = useMutation({
|
||||
mutationFn: async (productData) => {
|
||||
return await apiClient.post('/admin/products', { products: productData });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
setUploadSuccess(true);
|
||||
setUploadResults(data.data);
|
||||
setIsUploading(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadError(error.message || 'Failed to upload products');
|
||||
setIsUploading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search change
|
||||
const handleSearchChange = (event) => {
|
||||
setSearch(event.target.value);
|
||||
|
|
@ -123,6 +160,237 @@ const AdminProductsPage = () => {
|
|||
navigate(`/admin/products/${productId}`);
|
||||
};
|
||||
|
||||
// Handle download template
|
||||
const handleDownloadTemplate = () => {
|
||||
// Create template headers
|
||||
const headers = [
|
||||
'name',
|
||||
'description',
|
||||
'categoryName',
|
||||
'price',
|
||||
'stockQuantity',
|
||||
'weightGrams',
|
||||
'lengthCm',
|
||||
'widthCm',
|
||||
'heightCm',
|
||||
'origin',
|
||||
'age',
|
||||
'materialType',
|
||||
'color',
|
||||
'stockNotification_enabled',
|
||||
'stockNotification_email',
|
||||
'stockNotification_threshold',
|
||||
'tags' // Comma-separated tags
|
||||
];
|
||||
|
||||
// Create example data row
|
||||
const exampleRow = [
|
||||
'Example Product',
|
||||
'This is a product description',
|
||||
'Rock', // Valid category name
|
||||
'19.99',
|
||||
'10',
|
||||
'500',
|
||||
'15',
|
||||
'10',
|
||||
'5',
|
||||
'Brazil',
|
||||
'Recent',
|
||||
'Quartz',
|
||||
'Purple',
|
||||
'FALSE', // stockNotification_enabled
|
||||
'email@example.com', // stockNotification_email
|
||||
'5', // stockNotification_threshold
|
||||
'Rare,Polished' // Comma-separated tags
|
||||
];
|
||||
|
||||
// Create workbook
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Products Template');
|
||||
|
||||
// Add column widths for better readability
|
||||
const colWidths = [];
|
||||
headers.forEach(() => colWidths.push({ wch: 15 }));
|
||||
worksheet['!cols'] = colWidths;
|
||||
|
||||
// Generate Excel file
|
||||
XLSX.writeFile(workbook, 'products_upload_template.xlsx');
|
||||
};
|
||||
|
||||
// Handle open upload dialog
|
||||
const handleOpenUploadDialog = () => {
|
||||
resetUploadState();
|
||||
setUploadDialogOpen(true);
|
||||
};
|
||||
|
||||
// Handle close upload dialog
|
||||
const handleCloseUploadDialog = () => {
|
||||
resetUploadState();
|
||||
setUploadDialogOpen(false);
|
||||
};
|
||||
|
||||
// Reset upload states
|
||||
const resetUploadState = () => {
|
||||
setUploadFile(null);
|
||||
setUploadProgress(0);
|
||||
setUploadError(null);
|
||||
setParsedProducts([]);
|
||||
setIsUploading(false);
|
||||
setUploadSuccess(false);
|
||||
setUploadResults(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = (e) => {
|
||||
setUploadError(null);
|
||||
setUploadSuccess(false);
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploadFile(file);
|
||||
|
||||
// Determine file type and parse accordingly
|
||||
const fileExtension = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (fileExtension === 'csv') {
|
||||
parseCsvFile(file);
|
||||
} else if (fileExtension === 'xlsx' || fileExtension === 'xls') {
|
||||
parseExcelFile(file);
|
||||
} else {
|
||||
setUploadError('Unsupported file format. Please upload a CSV or Excel file.');
|
||||
}
|
||||
};
|
||||
|
||||
// Parse CSV file
|
||||
const parseCsvFile = (file) => {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length > 0) {
|
||||
setUploadError(`Error parsing CSV: ${results.errors[0].message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedProducts = formatParsedProducts(results.data);
|
||||
setParsedProducts(formattedProducts);
|
||||
},
|
||||
error: (error) => {
|
||||
setUploadError(`Error parsing CSV: ${error.message}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Parse Excel file
|
||||
const parseExcelFile = (file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
// Get first sheet
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Convert to JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: null });
|
||||
|
||||
const formattedProducts = formatParsedProducts(jsonData);
|
||||
setParsedProducts(formattedProducts);
|
||||
} catch (error) {
|
||||
setUploadError(`Error parsing Excel file: ${error.message}`);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setUploadError('Error reading file');
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
// Format parsed products to match API expectations
|
||||
const formatParsedProducts = (rawProducts) => {
|
||||
return rawProducts.map(product => {
|
||||
// Extract stock notification fields and combine them
|
||||
const stockNotification = {
|
||||
enabled: product.stockNotification_enabled === 'TRUE' || product.stockNotification_enabled === true,
|
||||
email: product.stockNotification_email || null,
|
||||
threshold: product.stockNotification_threshold ? parseInt(product.stockNotification_threshold) : null
|
||||
};
|
||||
|
||||
// Process tags (convert from comma-separated string to array)
|
||||
let tags = [];
|
||||
if (product.tags) {
|
||||
if (typeof product.tags === 'string') {
|
||||
tags = product.tags.split(',').map(tag => tag.trim()).filter(tag => tag);
|
||||
} else if (Array.isArray(product.tags)) {
|
||||
tags = product.tags;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert numeric values
|
||||
const price = product.price ? parseFloat(product.price) : null;
|
||||
const stockQuantity = product.stockQuantity ? parseInt(product.stockQuantity) : null;
|
||||
const weightGrams = product.weightGrams ? parseFloat(product.weightGrams) : null;
|
||||
const lengthCm = product.lengthCm ? parseFloat(product.lengthCm) : null;
|
||||
const widthCm = product.widthCm ? parseFloat(product.widthCm) : null;
|
||||
const heightCm = product.heightCm ? parseFloat(product.heightCm) : null;
|
||||
|
||||
return {
|
||||
name: product.name || '',
|
||||
description: product.description || '',
|
||||
categoryName: product.categoryName || '',
|
||||
price: price,
|
||||
stockQuantity: stockQuantity,
|
||||
weightGrams: weightGrams,
|
||||
lengthCm: lengthCm,
|
||||
widthCm: widthCm,
|
||||
heightCm: heightCm,
|
||||
origin: product.origin || null,
|
||||
age: product.age || null,
|
||||
materialType: product.materialType || null,
|
||||
color: product.color || null,
|
||||
stockNotification: stockNotification,
|
||||
tags: tags,
|
||||
images: [] // No images in bulk upload template
|
||||
};
|
||||
}).filter(product =>
|
||||
// Filter out products with missing required fields
|
||||
product.name &&
|
||||
product.description &&
|
||||
product.categoryName &&
|
||||
product.price !== null &&
|
||||
product.stockQuantity !== null
|
||||
);
|
||||
};
|
||||
|
||||
// Submit bulk upload
|
||||
const handleSubmitUpload = () => {
|
||||
if (parsedProducts.length === 0) {
|
||||
setUploadError('No valid products found in the file. Please check your data.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
|
||||
// Submit products in batches of 20 to respect connection limits
|
||||
const batchSize = 20;
|
||||
const batches = [];
|
||||
|
||||
for (let i = 0; i < parsedProducts.length; i += batchSize) {
|
||||
const batch = parsedProducts.slice(i, i + batchSize);
|
||||
batches.push(batch);
|
||||
}
|
||||
|
||||
// Process first batch
|
||||
bulkUploadProducts.mutate(parsedProducts);
|
||||
};
|
||||
|
||||
// Filter and paginate products
|
||||
const filteredProducts = products || [];
|
||||
const paginatedProducts = filteredProducts.slice(
|
||||
|
|
@ -155,15 +423,39 @@ const AdminProductsPage = () => {
|
|||
Products
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to="/admin/products/new"
|
||||
>
|
||||
Add Product
|
||||
</Button>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Tooltip title="Download a template for bulk product uploads">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Upload products in bulk from a CSV or Excel file">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<UploadIcon />}
|
||||
onClick={handleOpenUploadDialog}
|
||||
>
|
||||
Upload Bulk Assets
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to="/admin/products/new"
|
||||
>
|
||||
Add Product
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Search */}
|
||||
|
|
@ -301,6 +593,190 @@ const AdminProductsPage = () => {
|
|||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Upload Dialog */}
|
||||
<Dialog
|
||||
open={uploadDialogOpen}
|
||||
onClose={handleCloseUploadDialog}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Upload Products</DialogTitle>
|
||||
<DialogContent>
|
||||
{!uploadSuccess ? (
|
||||
<>
|
||||
<DialogContentText sx={{ mb: 2 }}>
|
||||
Upload a CSV or Excel file containing your product data. Make sure the file follows the required format.
|
||||
</DialogContentText>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{/* Drag and drop area */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
border: '2px dashed',
|
||||
borderColor: theme => uploadFile ? theme.palette.success.main : theme.palette.divider,
|
||||
borderRadius: 1,
|
||||
p: 3,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme => uploadFile ? 'rgba(76, 175, 80, 0.08)' : 'transparent',
|
||||
cursor: isUploading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: theme => !isUploading && theme.palette.primary.main,
|
||||
backgroundColor: theme => !isUploading && 'rgba(25, 118, 210, 0.04)'
|
||||
}
|
||||
}}
|
||||
component="label"
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
if (isUploading) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
// Update the file input to reflect the dragged file
|
||||
if (fileInputRef.current) {
|
||||
// Create a DataTransfer object
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(files[0]);
|
||||
fileInputRef.current.files = dataTransfer.files;
|
||||
|
||||
// Trigger the change event handler
|
||||
const event = new Event('change', { bubbles: true });
|
||||
fileInputRef.current.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
if (!isUploading) {
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
e.currentTarget.style.backgroundColor = 'rgba(25, 118, 210, 0.04)';
|
||||
}
|
||||
}}
|
||||
onDragLeave={e => {
|
||||
e.preventDefault();
|
||||
if (!uploadFile) {
|
||||
e.currentTarget.style.borderColor = '';
|
||||
e.currentTarget.style.backgroundColor = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv, .xlsx, .xls"
|
||||
hidden
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{uploadFile ? (
|
||||
<>
|
||||
<CheckCircleIcon sx={{ color: 'success.main', fontSize: 48, mb: 1 }} />
|
||||
<Typography variant="body1" align="center" gutterBottom>
|
||||
File selected: {uploadFile.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
Drag and drop a different file or click to change
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudUploadIcon sx={{ fontSize: 48, color: 'action.active', mb: 1 }} />
|
||||
<Typography variant="body1" align="center" gutterBottom>
|
||||
Drag and drop your CSV or Excel file here
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
or click to select a file
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{parsedProducts.length > 0 && (
|
||||
<Alert severity="info" sx={{ mt: 2, width: '100%' }}>
|
||||
Found {parsedProducts.length} valid products in the file.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Upload successful!
|
||||
</Alert>
|
||||
|
||||
{uploadResults && (
|
||||
<>
|
||||
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>
|
||||
Upload Results:
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
{uploadResults.success?.length || 0} products created successfully
|
||||
</Typography>
|
||||
|
||||
{uploadResults.errs?.length > 0 && (
|
||||
<>
|
||||
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
|
||||
{uploadResults.errs.length} products failed to create
|
||||
</Typography>
|
||||
|
||||
<Paper variant="outlined" sx={{ mt: 1, p: 1, maxHeight: 150, overflow: 'auto' }}>
|
||||
{uploadResults.errs.map((err, index) => (
|
||||
<Typography key={index} variant="caption" display="block" color="error">
|
||||
Error: {err.message}
|
||||
</Typography>
|
||||
))}
|
||||
</Paper>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!uploadSuccess ? (
|
||||
<>
|
||||
<Button onClick={handleCloseUploadDialog} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitUpload}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
disabled={isUploading || parsedProducts.length === 0}
|
||||
startIcon={isUploading && <CircularProgress size={20} color="inherit" />}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Upload Products'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleCloseUploadDialog} color="primary">
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue