From 8141fafb3428c9b7539c27fa021ac08bb9506650 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 26 Feb 2025 16:25:56 -0500 Subject: [PATCH] Add bulk image upload with auto assign --- .../steps/ImageUploadStep/ImageUploadStep.tsx | 299 +++++++++++++++++- 1 file changed, 295 insertions(+), 4 deletions(-) diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx index 63d6fcf..b38b060 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx @@ -1,7 +1,7 @@ -import { useCallback, useState, useRef } from "react"; +import { useCallback, useState, useRef, useEffect } from "react"; import { useRsi } from "../../hooks/useRsi"; import { Button } from "@/components/ui/button"; -import { Loader2, Upload, Trash2 } from "lucide-react"; +import { Loader2, Upload, Trash2, AlertCircle } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input"; import config from "@/config"; import { useDropzone } from "react-dropzone"; import { cn } from "@/lib/utils"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; type Props = { data: any[]; @@ -25,6 +26,11 @@ type ProductImage = { fileName: string; } +type UnassignedImage = { + file: File; + previewUrl: string; +} + export const ImageUploadStep = ({ data, file, @@ -35,6 +41,9 @@ export const ImageUploadStep = ({ const [isSubmitting, setIsSubmitting] = useState(false); const [productImages, setProductImages] = useState([]); const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}); + const [unassignedImages, setUnassignedImages] = useState([]); + const [processingBulk, setProcessingBulk] = useState(false); + const [showUnassigned, setShowUnassigned] = useState(false); // Function to handle image upload const handleImageUpload = async (files: FileList | File[], productIndex: number) => { @@ -101,6 +110,217 @@ export const ImageUploadStep = ({ } }; + // Function to extract identifiers from a filename + const extractIdentifiers = (filename: string): string[] => { + // Remove file extension and convert to lowercase + const nameWithoutExt = filename.split('.').slice(0, -1).join('.').toLowerCase(); + + // Split by common separators + const parts = nameWithoutExt.split(/[-_\s.]+/); + + // Add the full name without extension as a possible identifier + const identifiers = [nameWithoutExt]; + + // Add parts with at least 3 characters + identifiers.push(...parts.filter(part => part.length >= 3)); + + // Look for potential UPC or product codes (digits only) + const digitOnlyParts = parts.filter(part => /^\d+$/.test(part) && part.length >= 5); + identifiers.push(...digitOnlyParts); + + // Look for product codes (mix of letters and digits) + const productCodes = parts.filter(part => + /^[a-z0-9]+$/.test(part) && + /\d/.test(part) && + /[a-z]/.test(part) && + part.length >= 4 + ); + identifiers.push(...productCodes); + + return [...new Set(identifiers)]; // Remove duplicates + }; + + // Function to find product index by identifier + const findProductByIdentifier = (identifier: string): number => { + // Try to match against supplier_no, upc, SKU, or name + return data.findIndex(product => { + // Skip if product is missing all identifiers + if (!product.supplier_no && !product.upc && !product.sku && !product.name) { + return false; + } + + const supplierNo = String(product.supplier_no || '').toLowerCase(); + const upc = String(product.upc || '').toLowerCase(); + const sku = String(product.sku || '').toLowerCase(); + const name = String(product.name || '').toLowerCase(); + const model = String(product.model || '').toLowerCase(); + + // For exact matches, prioritize certain fields + if ( + (supplierNo && identifier === supplierNo) || + (upc && identifier === upc) || + (sku && identifier === sku) + ) { + return true; + } + + // For partial matches, check if the identifier is contained within the field + // or if the field is contained within the identifier + return ( + (supplierNo && (supplierNo.includes(identifier) || identifier.includes(supplierNo))) || + (upc && (upc.includes(identifier) || identifier.includes(upc))) || + (sku && (sku.includes(identifier) || identifier.includes(sku))) || + (model && (model.includes(identifier) || identifier.includes(model))) || + (name && name.includes(identifier)) + ); + }); + }; + + // Function to create preview URLs for files + const createPreviewUrl = (file: File): string => { + return URL.createObjectURL(file); + }; + + // Function to handle bulk image upload + const handleBulkUpload = async (files: File[]) => { + if (!files.length) return; + + setProcessingBulk(true); + const unassigned: UnassignedImage[] = []; + + for (const file of files) { + // Extract identifiers from filename + const identifiers = extractIdentifiers(file.name); + let assigned = false; + + // Try to match each identifier + for (const identifier of identifiers) { + const productIndex = findProductByIdentifier(identifier); + + if (productIndex !== -1) { + // Found a match, upload to this product + await handleImageUpload([file], productIndex); + assigned = true; + break; + } + } + + // If no match was found, add to unassigned + if (!assigned) { + unassigned.push({ + file, + previewUrl: createPreviewUrl(file) + }); + } + } + + // Update unassigned images + setUnassignedImages(prev => [...prev, ...unassigned]); + setProcessingBulk(false); + + // Show summary toast + const assignedCount = files.length - unassigned.length; + if (assignedCount > 0) { + toast.success(`Auto-assigned ${assignedCount} ${assignedCount === 1 ? 'image' : 'images'} to products`); + } + if (unassigned.length > 0) { + toast.error(`Could not auto-assign ${unassigned.length} ${unassigned.length === 1 ? 'image' : 'images'}`); + setShowUnassigned(true); + } + }; + + // Function to manually assign an unassigned image + const assignImageToProduct = async (imageIndex: number, productIndex: number) => { + const image = unassignedImages[imageIndex]; + if (!image) return; + + // Upload the image to the selected product + await handleImageUpload([image.file], productIndex); + + // Remove from unassigned list + setUnassignedImages(prev => prev.filter((_, idx) => idx !== imageIndex)); + + // Revoke the preview URL to free memory + URL.revokeObjectURL(image.previewUrl); + + toast.success(`Image assigned to ${data[productIndex].name || `Product #${productIndex + 1}`}`); + }; + + // Function to remove an unassigned image + const removeUnassignedImage = (index: number) => { + const image = unassignedImages[index]; + if (!image) return; + + // Revoke the preview URL to free memory + URL.revokeObjectURL(image.previewUrl); + + // Remove from state + setUnassignedImages(prev => prev.filter((_, idx) => idx !== index)); + }; + + // Cleanup function for preview URLs + useEffect(() => { + return () => { + // Cleanup preview URLs when component unmounts + unassignedImages.forEach(image => { + URL.revokeObjectURL(image.previewUrl); + }); + }; + }, []); + + // Generic dropzone component + const GenericDropzone = () => { + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { + 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] + }, + onDrop: handleBulkUpload, + multiple: true + }); + + return ( +
+ + {processingBulk ? ( +
+ +

Processing images...

+
+ ) : isDragActive ? ( +
+

Drop your images here

+

We'll automatically assign them based on filename

+
+ ) : ( + <> + +

Drop images here or click to select

+

We'll automatically match images to products based on filename

+ {unassignedImages.length > 0 && !showUnassigned && ( + + )} + + )} +
+ ); + }; + // Component for image dropzone const ImageDropzone = ({ productIndex }: { productIndex: number }) => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ @@ -253,6 +473,71 @@ export const ImageUploadStep = ({ return `${baseUrl}${path}`; }; + // Component for displaying unassigned images + const UnassignedImagesSection = () => { + if (!showUnassigned || unassignedImages.length === 0) return null; + + return ( +
+
+
+
+ +

+ Unassigned Images ({unassignedImages.length}) +

+
+ +
+ +
+ {unassignedImages.map((image, index) => ( +
+ {`Unassigned +
+

{image.file.name}

+
+ + +
+
+
+ ))} +
+
+
+ ); + }; + return (
@@ -262,6 +547,12 @@ export const ImageUploadStep = ({

+
+ +
+ + + @@ -340,9 +631,9 @@ export const ImageUploadStep = ({ Back )} -