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, DialogActions,
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle DialogTitle,
Stack,
Tooltip
} from '@mui/material'; } from '@mui/material';
import { import {
Edit as EditIcon, Edit as EditIcon,
Delete as DeleteIcon, Delete as DeleteIcon,
Add as AddIcon, Add as AddIcon,
Search as SearchIcon, 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'; } from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../services/api'; import apiClient from '../../services/api';
import ProductImage from '../../components/ProductImage'; import ProductImage from '../../components/ProductImage';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
const AdminProductsPage = () => { const AdminProductsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -44,6 +52,18 @@ const AdminProductsPage = () => {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [productToDelete, setProductToDelete] = useState(null); 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 // Fetch products
const { const {
data: products, 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 // Handle search change
const handleSearchChange = (event) => { const handleSearchChange = (event) => {
setSearch(event.target.value); setSearch(event.target.value);
@ -123,6 +160,237 @@ const AdminProductsPage = () => {
navigate(`/admin/products/${productId}`); 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 // Filter and paginate products
const filteredProducts = products || []; const filteredProducts = products || [];
const paginatedProducts = filteredProducts.slice( const paginatedProducts = filteredProducts.slice(
@ -155,6 +423,29 @@ const AdminProductsPage = () => {
Products Products
</Typography> </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 <Button
variant="contained" variant="contained"
color="primary" color="primary"
@ -164,6 +455,7 @@ const AdminProductsPage = () => {
> >
Add Product Add Product
</Button> </Button>
</Stack>
</Box> </Box>
{/* Search */} {/* Search */}
@ -301,6 +593,190 @@ const AdminProductsPage = () => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </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> </Box>
); );
}; };