diff --git a/frontend/src/pages/Admin/ProductsPage.jsx b/frontend/src/pages/Admin/ProductsPage.jsx index 39498f5..a397d57 100644 --- a/frontend/src/pages/Admin/ProductsPage.jsx +++ b/frontend/src/pages/Admin/ProductsPage.jsx @@ -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(); @@ -43,6 +51,18 @@ const AdminProductsPage = () => { const [search, setSearch] = useState(''); 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 { @@ -75,6 +95,23 @@ const AdminProductsPage = () => { setProductToDelete(null); } }); + + // 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) => { @@ -122,6 +159,237 @@ const AdminProductsPage = () => { const handleEditClick = (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 const filteredProducts = products || []; @@ -155,15 +423,39 @@ const AdminProductsPage = () => { Products - + + + + + + + + + + + {/* Search */} @@ -301,6 +593,190 @@ const AdminProductsPage = () => { + + {/* Upload Dialog */} + + Upload Products + + {!uploadSuccess ? ( + <> + + Upload a CSV or Excel file containing your product data. Make sure the file follows the required format. + + + + {/* Drag and drop area */} + 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 = ''; + } + }} + > + + + {uploadFile ? ( + <> + + + File selected: {uploadFile.name} + + + Drag and drop a different file or click to change + + + ) : ( + <> + + + Drag and drop your CSV or Excel file here + + + or click to select a file + + + )} + + + {parsedProducts.length > 0 && ( + + Found {parsedProducts.length} valid products in the file. + + )} + + {uploadError && ( + + {uploadError} + + )} + + + ) : ( + + + Upload successful! + + + {uploadResults && ( + <> + + Upload Results: + + + + {uploadResults.success?.length || 0} products created successfully + + + {uploadResults.errs?.length > 0 && ( + <> + + {uploadResults.errs.length} products failed to create + + + + {uploadResults.errs.map((err, index) => ( + + Error: {err.message} + + ))} + + + )} + + )} + + )} + + + {!uploadSuccess ? ( + <> + + + + ) : ( + + )} + + ); };