full bulk product import support

This commit is contained in:
2ManyProjects 2025-05-05 15:57:59 -05:00
parent 5d0ce42592
commit 3bab4e4c56

View file

@ -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,6 +423,29 @@ const AdminProductsPage = () => {
Products
</Typography>
<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"
@ -164,6 +455,7 @@ const AdminProductsPage = () => {
>
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>
);
};