diff --git a/inventory/src/components/templates/SearchProductTemplateDialog.tsx b/inventory/src/components/templates/SearchProductTemplateDialog.tsx index dba6e32..c17c51f 100644 --- a/inventory/src/components/templates/SearchProductTemplateDialog.tsx +++ b/inventory/src/components/templates/SearchProductTemplateDialog.tsx @@ -1,17 +1,13 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Loader2, Search, ChevronsUpDown, Check, X, ChevronUp, ChevronDown } from 'lucide-react'; +import { Loader2, Search, ChevronsUpDown, X, ChevronUp, ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; -import { cn } from '@/lib/utils'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Pagination, @@ -89,25 +85,6 @@ interface FieldOptions { shippingRestrictions: FieldOption[]; } -interface TemplateFormData { - company: string; - product_type: string; - supplier?: string; - msrp?: number; - cost_each?: number; - qty_per_unit?: number; - case_qty?: number; - hts_code?: string; - description?: string; - weight?: number; - length?: number; - width?: number; - height?: number; - tax_cat?: string; - size_cat?: string; - categories?: string[]; - ship_restrictions?: string; -} // Add sorting types type SortDirection = 'asc' | 'desc' | null; @@ -203,9 +180,7 @@ const FilterSelects = React.memo(({ setSelectedCompany, selectedDateFilter, setSelectedDateFilter, - companies, - onFilterChange -}: { + companies}: { selectedCompany: string; setSelectedCompany: (value: string) => void; selectedDateFilter: string; @@ -267,56 +242,6 @@ const FilterSelects = React.memo(({ }); // Create a memoized results table component -const ResultsTable = React.memo(({ - results, - selectedCompany, - onSelect -}: { - results: any[]; - selectedCompany: string; - onSelect: (product: any) => void; -}) => ( - - - - Name - {selectedCompany === 'all' && Company} - Line - Price - Total Sold - Date In - Last Sold - - - - {results.map((product) => ( - onSelect(product)} - > - {product.title} - {selectedCompany === 'all' && {product.brand || '-'}} - {product.line || '-'} - - {product.price != null ? `$${Number(product.price).toFixed(2)}` : '-'} - - {product.total_sold || 0} - - {product.first_received - ? new Date(product.first_received).toLocaleDateString() - : '-'} - - - {product.date_last_sold - ? new Date(product.date_last_sold).toLocaleDateString() - : '-'} - - - ))} - -
-)); export function SearchProductTemplateDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) { // Basic component state @@ -507,12 +432,10 @@ export function SearchProductTemplateDialog({ isOpen, onClose, onTemplateCreated setSelectedProduct(product); // Try to find a matching company ID - let companyId = ''; if (product.brand_id && fieldOptions?.companies) { // Attempt to match by brand ID const companyMatch = fieldOptions.companies.find(c => c.value === product.brand_id); if (companyMatch) { - companyId = companyMatch.value; } } 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 8b9f261..9a6594d 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,83 +1,35 @@ -import { useCallback, useState, useRef, useEffect } from "react"; +import { useCallback, useState, useRef, useEffect, createRef } from "react"; import { useRsi } from "../../hooks/useRsi"; import { Button } from "@/components/ui/button"; -import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X, Link2, Copy, Check } from "lucide-react"; -import { Card, CardContent } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; import { toast } from "sonner"; -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"; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, - DragEndEvent, - DragOverlay, - DragStartEvent, - pointerWithin, - rectIntersection, - DragMoveEvent, - CollisionDetection, - useDroppable + DragOverlay } from '@dnd-kit/core'; import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - horizontalListSortingStrategy -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { - Dialog, - DialogContent, - DialogTrigger, -} from "@/components/ui/dialog"; + sortableKeyboardCoordinates} from '@dnd-kit/sortable'; +import { Product } from "./types"; +import { GenericDropzone } from "./components/GenericDropzone"; +import { UnassignedImagesSection } from "./components/UnassignedImagesSection"; +import { ProductCard } from "./components/ProductCard/ProductCard"; +import { useDragAndDrop } from "./hooks/useDragAndDrop"; +import { useProductImagesInit } from "./hooks/useProductImagesInit"; +import { useProductImageOperations } from "./hooks/useProductImageOperations"; +import { useBulkImageUpload } from "./hooks/useBulkImageUpload"; +import { useUrlImageUpload } from "./hooks/useUrlImageUpload"; -type Product = { - id?: string | number; - name?: string; - supplier_no?: string; - upc?: string; - sku?: string; - model?: string; - product_images?: string | string[]; -}; - -type Props = { +interface Props { data: Product[]; file: File; onBack?: () => void; onSubmit: (data: Product[], file: File) => void | Promise; } -type ProductImage = { - productIndex: number; - imageUrl: string; - loading: boolean; - fileName: string; - pid?: string | number; - iid?: string; - width?: number; - height?: number; - type?: number; - order?: number; -} - -type UnassignedImage = { - file: File; - previewUrl: string; -} - -// Add a product ID type to handle the sortable state -type ProductImageSortable = ProductImage & { - id: string; -}; - export const ImageUploadStep = ({ data, file, @@ -86,63 +38,49 @@ export const ImageUploadStep = ({ }: Props) => { useRsi(); const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}); - const [unassignedImages, setUnassignedImages] = useState([]); - const [processingBulk, setProcessingBulk] = useState(false); - const [showUnassigned, setShowUnassigned] = useState(false); - const [activeId, setActiveId] = useState(null); - const [activeImage, setActiveImage] = useState(null); + const fileInputRefs = useRef<{ [key: number]: React.RefObject }>({}); - // Add state for URL input - const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({}); - const [processingUrls, setProcessingUrls] = useState<{ [key: number]: boolean }>({}); - - // Add state for copy button feedback - const [copyState, setCopyState] = useState<{[key: string]: boolean}>({}); - - // Initialize product images from data - const [productImages, setProductImages] = useState(() => { - // Convert existing product_images to ProductImageSortable objects - const initialImages: ProductImageSortable[] = []; - - data.forEach((product: Product, productIndex: number) => { - if (product.product_images) { - let imageUrls: string[] = []; - - // Handle different formats of product_images - if (typeof product.product_images === 'string') { - // Split by comma if it's a string - imageUrls = product.product_images.split(',').filter(Boolean); - } else if (Array.isArray(product.product_images)) { - // Use the array directly - imageUrls = product.product_images.filter(Boolean); - } else if (product.product_images) { - // Handle case where it might be a single value - imageUrls = [String(product.product_images)]; - } - - // Create ProductImageSortable objects for each URL - imageUrls.forEach((url, i) => { - if (url && url.trim()) { - initialImages.push({ - id: `image-${productIndex}-initial-${i}`, - productIndex, - imageUrl: url.trim(), - loading: false, - fileName: `Image ${i + 1}`, - pid: product.id || '', - iid: '', - width: 0, - height: 0 - }); - } - }); - } - }); - - return initialImages; + // Use our hook for product images initialization + const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data); + + // Use our hook for product image operations + const { + addImageToProduct, + handleImageUpload, + removeImage + } = useProductImageOperations({ + data, + productImages, + setProductImages }); + // Use our hook for URL image uploads + const { + urlInputs, + processingUrls, + handleAddImageFromUrl, + updateUrlInput + } = useUrlImageUpload({ + data, + setProductImages, + addImageToProduct + }); + + // Use our hook for bulk image uploads + const { + unassignedImages, + processingBulk, + showUnassigned, + setShowUnassigned, + handleBulkUpload, + assignImageToProduct, + removeUnassignedImage, + cleanupPreviewUrls + } = useBulkImageUpload({ + data, + handleImageUpload + }); + // Set up sensors for drag and drop with enhanced configuration const sensors = useSensors( useSensor(PointerSensor, { @@ -158,662 +96,33 @@ export const ImageUploadStep = ({ }) ); - // Track which product container is being hovered over - const [activeDroppableId, setActiveDroppableId] = useState(null); + // Use the drag and drop hook + const { + activeId, + activeImage, + activeDroppableId, + customCollisionDetection, + findContainer, + getProductContainerClasses, + handleDragStart, + handleDragOver, + handleDragEnd + } = useDragAndDrop({ + productImages, + setProductImages, + data + }); - // Custom collision detection algorithm that prioritizes product containers - const customCollisionDetection: CollisionDetection = (args) => { - // Use the built-in pointerWithin algorithm first for better performance - const pointerCollisions = pointerWithin(args); - - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - // Fall back to rectIntersection if no pointer collisions - return rectIntersection(args); - }; - - // Handle drag start to set active image and prevent default behavior - const handleDragStart = (event: DragStartEvent) => { - const { active } = event; - - const activeImageItem = productImages.find(img => img.id === active.id); - setActiveId(active.id.toString()); - if (activeImageItem) { - setActiveImage(activeImageItem); - } - }; - - // Handle drag over to track which product container is being hovered - const handleDragOver = (event: DragMoveEvent) => { - const { over } = event; - - if (!over) { - setActiveDroppableId(null); - return; - } - - let overContainer = null; - - // Check if we're over a product container directly - if (typeof over.id === 'string' && over.id.toString().startsWith('product-')) { - overContainer = over.id.toString(); - setActiveDroppableId(overContainer); - - // Log the hover state for debugging - console.log('Hovering over product container:', overContainer); - } - // Otherwise check if we're over another image - else { - const overImage = productImages.find(img => img.id === over.id); - if (overImage) { - overContainer = `product-${overImage.productIndex}`; - setActiveDroppableId(overContainer); - } else { - setActiveDroppableId(null); - } - } - }; - - // Monitor drag events to prevent browser behaviors + // Initialize refs for each product useEffect(() => { - // Add a global event listener to prevent browser's native drag behavior - const preventDefaultDragImage = (event: DragEvent) => { - if (activeId) { - event.preventDefault(); - } - }; - - document.addEventListener('dragstart', preventDefaultDragImage); - - return () => { - document.removeEventListener('dragstart', preventDefaultDragImage); - }; - }, [activeId]); - - // Function to find container (productIndex) an image belongs to - const findContainer = (id: string) => { - const image = productImages.find(img => img.id === id); - return image ? image.productIndex.toString() : null; - }; - - // Function to get images for a specific product - const getProductImages = (productIndex: number) => { - return productImages.filter(img => img.productIndex === productIndex); - }; - - // Add product IDs to the valid droppable elements - useEffect(() => { - // Add data-droppable attributes to make product containers easier to identify + // Create refs for each product's file input data.forEach((_: Product, index: number) => { - const container = document.getElementById(`product-${index}`); - if (container) { - container.setAttribute('data-droppable', 'true'); - container.setAttribute('aria-dropeffect', 'move'); - - // Check if the container has images - const hasImages = getProductImages(index).length > 0; - - // Set data-empty attribute for tracking purposes - container.setAttribute('data-empty', hasImages ? 'false' : 'true'); - - // Ensure the container has sufficient size to be a drop target - if (container.offsetHeight < 100) { - container.style.minHeight = '100px'; - } + if (!fileInputRefs.current[index]) { + fileInputRefs.current[index] = createRef(); } }); - }, [data, productImages]); // Add productImages as a dependency to re-run when images change + }, [data]); - // Effect to register browser-level drag events on product containers - useEffect(() => { - // For each product container - data.forEach((_: Product, index: number) => { - const container = document.getElementById(`product-${index}`); - - if (container) { - // Define handlers for native browser drag events - const handleNativeDragOver = (e: DragEvent) => { - e.preventDefault(); - // Highlight all containers during dragover - container.classList.add('border-primary', 'bg-primary/5'); - setActiveDroppableId(`product-${index}`); - }; - - const handleNativeDragLeave = () => { - // Remove highlight when drag leaves - container.classList.remove('border-primary', 'bg-primary/5'); - if (activeDroppableId === `product-${index}`) { - setActiveDroppableId(null); - } - }; - - // Add these handlers - container.addEventListener('dragover', handleNativeDragOver); - container.addEventListener('dragleave', handleNativeDragLeave); - - // Return cleanup function - return () => { - container.removeEventListener('dragover', handleNativeDragOver); - container.removeEventListener('dragleave', handleNativeDragLeave); - }; - } - }); - }, [data, productImages, activeDroppableId]); // Re-run when data or productImages change - - // Function to remove an image URL from a product - const removeImageFromProduct = (productIndex: number, imageUrl: string) => { - // Create a copy of the data - const newData = [...data]; - - // Get the current product - const product = newData[productIndex]; - - // Initialize product_images array if it doesn't exist - if (!product.product_images) { - product.product_images = []; - return newData; - } - - // Handle different formats of product_images - let images: any[] = []; - - if (typeof product.product_images === 'string') { - try { - // Try to parse as JSON - images = JSON.parse(product.product_images); - } catch (e) { - // If not JSON, split by comma if it's a string - images = product.product_images.split(',').filter(Boolean).map(url => ({ - imageUrl: url.trim(), - pid: product.id || 0, - iid: 0, - type: 0, - order: 255, - width: 0, - height: 0, - hidden: 0 - })); - } - } else if (Array.isArray(product.product_images)) { - // Use the array directly - images = product.product_images; - } else if (product.product_images) { - // Handle case where it might be a single value - images = [product.product_images]; - } - - // Filter out the image URL we're removing - const filteredImages = images.filter(img => { - const imgUrl = typeof img === 'string' ? img : img.imageUrl; - return imgUrl !== imageUrl; - }); - - // Update the product_images field - product.product_images = filteredImages; - - return newData; - }; - - // Function to add an image URL to a product - - // Update handleDragEnd to work with the updated product data structure - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - // Reset active droppable - setActiveDroppableId(null); - - if (!over) { - setActiveId(null); - setActiveImage(null); - return; - } - - const activeId = active.id; - const overId = over.id; - - // Find the containers (product indices) for the active element - const activeContainer = findContainer(activeId.toString()); - let overContainer = null; - - // Check if overId is a product container directly - if (typeof overId === 'string' && overId.toString().startsWith('product-')) { - overContainer = overId.toString().split('-')[1]; - console.log('Dropping directly onto product container:', overContainer); - } - // Otherwise check if it's an image, so find its container - else { - overContainer = findContainer(overId.toString()); - console.log('Dropping onto another image in container:', overContainer); - } - - // Log what was detected for debugging - console.log('Drag end detected:', { - activeId, - overId, - overContainer, - activeContainer, - isOverProduct: typeof overId === 'string' && overId.toString().startsWith('product-') - }); - - // If we couldn't determine active container, do nothing - if (!activeContainer) { - console.log('Could not determine source container', { activeContainer, activeId }); - setActiveId(null); - setActiveImage(null); - return; - } - - // If we couldn't determine the over container, do nothing - if (!overContainer) { - console.log('Could not determine target container', { overContainer, overId }); - setActiveId(null); - setActiveImage(null); - return; - } - - // Convert containers to numbers - const sourceProductIndex = parseInt(activeContainer); - const targetProductIndex = parseInt(overContainer); - - // Find the active image - const activeImage = productImages.find(img => img.id === activeId); - if (!activeImage) { - setActiveId(null); - setActiveImage(null); - return; - } - - // IMPORTANT: If source and target are different products, ALWAYS prioritize moving over reordering - if (sourceProductIndex !== targetProductIndex) { - console.log('Moving image between products', { sourceProductIndex, targetProductIndex }); - - // Create a copy of the image with the new product index - const newImage: ProductImageSortable = { - ...activeImage, - productIndex: targetProductIndex, - // Generate a new ID for the image in its new location - id: `image-${targetProductIndex}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` - }; - - // Remove the image from the source product and add to target product - setProductImages(items => { - // Remove the image from its current product - const filteredItems = items.filter(item => item.id !== activeId); - - // Add the image to the target product - filteredItems.push(newImage); - - // Check if the operation was successful - console.log('Image movement operation', { - originalCount: items.length, - newCount: filteredItems.length, - sourceImages: items.filter(item => item.productIndex === sourceProductIndex).length, - targetImagesBefore: items.filter(item => item.productIndex === targetProductIndex).length, - targetImagesAfter: filteredItems.filter(item => item.productIndex === targetProductIndex).length - }); - - // Update both products' image data fields - - - - // Show notification - toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`); - - return filteredItems; - }); - } - // Source and target are the same product - this is a reordering operation - else { - // Only attempt reordering if we have at least 2 images in this container - const productImages = getProductImages(sourceProductIndex); - - if (productImages.length >= 2) { - console.log('Reordering images within product', { sourceProductIndex, imagesCount: productImages.length }); - - // Handle reordering regardless of whether we're over a container or another image - setProductImages(items => { - // Filter to get only the images for this product - const productFilteredItems = items.filter(item => item.productIndex === sourceProductIndex); - - // If dropping onto the container itself, put at the end - if (overId.toString().startsWith('product-')) { - console.log('Dropping onto container - moving to end'); - // Find active index - const activeIndex = productFilteredItems.findIndex(item => item.id === activeId); - - if (activeIndex === -1) { - return items; // No change needed - } - - // Move active item to end (remove and push to end) - const newFilteredItems = [...productFilteredItems]; - const [movedItem] = newFilteredItems.splice(activeIndex, 1); - newFilteredItems.push(movedItem); - - // Create a new full list replacing the items for this product with the reordered ones - const newItems = items.filter(item => item.productIndex !== sourceProductIndex); - newItems.push(...newFilteredItems); - - // Update the product data with the new image order - // Since we're just reordering, the URLs don't change, but their order might matter - return newItems; - } - - // Find indices within the filtered list - const activeIndex = productFilteredItems.findIndex(item => item.id === activeId); - const overIndex = productFilteredItems.findIndex(item => item.id === overId); - - // If one of the indices is not found or they're the same, do nothing - if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) { - return items; - } - - // Reorder the filtered items - const newFilteredItems = arrayMove(productFilteredItems, activeIndex, overIndex); - - // Create a new full list replacing the items for this product with the reordered ones - const newItems = items.filter(item => item.productIndex !== sourceProductIndex); - newItems.push(...newFilteredItems); - - // Update the product data with the new image order - // The order might matter for display purposes - return newItems; - }); - } - } - - setActiveId(null); - setActiveImage(null); - }; - - // Function to handle image upload - const handleImageUpload = async (files: FileList | File[], productIndex: number): Promise => { - if (!files || files.length === 0) return; - - const fileArray = Array.from(files); - - for (let i = 0; i < fileArray.length; i++) { - const file = fileArray[i]; - - // Create initial image data - let imageData: ProductImageSortable = { - id: `image-${productIndex}-${Date.now()}-${i}`, - productIndex, - imageUrl: '', - loading: true, - fileName: file.name, - pid: String(data[productIndex].id || ''), - iid: '', - type: 0, - order: productImages.filter(img => img.productIndex === productIndex).length + i - }; - - // Add to state - setProductImages(prev => [...prev, imageData]); - - try { - // Create form data for upload - const formData = new FormData(); - formData.append('file', file); - formData.append('productIndex', String(productIndex)); - - // Upload the file - const response = await fetch(`${config.apiUrl}/upload-image`, { - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error('Failed to upload image'); - } - - const result = await response.json(); - - // Update the image data with server response - if (result) { - imageData = { - ...imageData, - imageUrl: result.imageUrl, - loading: false, - iid: result.iid || imageData.iid, - width: result.width || imageData.width, - height: result.height || imageData.height - }; - - // Update the product images state - setProductImages(prev => - prev.map(img => - img.id === imageData.id ? imageData : img - ) - ); - } - } catch (error) { - console.error('Error uploading image:', error); - toast.error('Failed to upload image'); - - // Remove the placeholder image on error - setProductImages(prev => - prev.filter(img => img.id !== imageData.id) - ); - } - } - }; - - // 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: 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[]): Promise => { - if (!files.length) return; - - setProcessingBulk(true); - const unassigned: UnassignedImage[] = []; - - for (const file of files) { - // Extract identifiers from filename - const identifiers = extractIdentifiers(file.name); - - // Try to find matching product - let productIndex = -1; - for (const identifier of identifiers) { - productIndex = findProductByIdentifier(identifier); - if (productIndex !== -1) break; - } - - if (productIndex === -1) { - // If no match found, add to unassigned images - const previewUrl = createPreviewUrl(file); - setUnassignedImages(prev => [...prev, { file, previewUrl }]); - } else { - // If match found, add to product images - let imageData: ProductImageSortable = { - id: `image-${productIndex}-${Date.now()}`, - productIndex, - imageUrl: '', - loading: true, - fileName: file.name, - pid: String(data[productIndex].id || ''), - iid: '', - type: 0, - order: productImages.filter(img => img.productIndex === productIndex).length - }; - - // Add to state - setProductImages(prev => [...prev, imageData]); - - try { - // Create form data for upload - const formData = new FormData(); - formData.append('file', file); - formData.append('productIndex', String(productIndex)); - - // Upload the file - const response = await fetch(`${config.apiUrl}/upload-image`, { - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error('Failed to upload image'); - } - - const result = await response.json(); - - // Update the image data with server response - if (result) { - imageData = { - ...imageData, - imageUrl: result.imageUrl, - loading: false, - iid: result.iid || imageData.iid, - width: result.width || imageData.width, - height: result.height || imageData.height - }; - - // Update the product images state - setProductImages(prev => - prev.map(img => - img.id === imageData.id ? imageData : img - ) - ); - } - } catch (error) { - console.error('Error uploading image:', error); - toast.error('Failed to upload image'); - - // Remove the placeholder image on error - setProductImages(prev => - prev.filter(img => img.id !== imageData.id) - ); - } - } - } - - // 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); - }); - }; - }, []); - // Add this CSS for preventing browser drag behavior useEffect(() => { // Add a custom style element to the document head @@ -832,55 +141,11 @@ export const ImageUploadStep = ({ return () => { // Clean up on unmount document.head.removeChild(styleEl); + // Clean up preview URLs + cleanupPreviewUrls(); }; }, []); - // Function to remove an image - update to work with product_images - const removeImage = async (imageIndex: number) => { - const image = productImages[imageIndex]; - if (!image) return; - - try { - // Check if this is an external URL-based image or an uploaded image - const isExternalUrl = image.imageUrl.startsWith('http') && - !image.imageUrl.includes(config.apiUrl.replace(/^https?:\/\//, '')); - - // Only call the API to delete the file if it's an uploaded image - if (!isExternalUrl) { - // Extract the filename from the URL - const urlParts = image.imageUrl.split('/'); - const filename = urlParts[urlParts.length - 1]; - - // Call API to delete the image - const response = await fetch(`${config.apiUrl}/import/delete-image`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - imageUrl: image.imageUrl, - filename - }), - }); - - if (!response.ok) { - throw new Error('Failed to delete image'); - } - } - - // Remove the image from our state - setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex)); - - // Remove the image URL from the product data - removeImageFromProduct(image.productIndex, image.imageUrl); - - toast.success('Image removed successfully'); - } catch (error) { - console.error('Delete error:', error); - toast.error(`Failed to remove image: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - }; - // Handle calling onSubmit with the current data const handleSubmit = useCallback(async () => { setIsSubmitting(true); @@ -909,527 +174,6 @@ export const ImageUploadStep = ({ setIsSubmitting(false); } }, [data, file, onSubmit, productImages]); - - // Function to get full image URL - const getFullImageUrl = (urlInput: string | undefined | null): string => { - if (!urlInput) return ''; - if (urlInput.startsWith('http://') || urlInput.startsWith('https://')) { - return urlInput; - } - return `${config.apiUrl}/images/${urlInput}`; - }; - - // 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

-

Images dropped here will be automatically assigned to products based on filename

- {unassignedImages.length > 0 && !showUnassigned && ( - - )} - - )} -
- ); - }; - - // Component for image dropzone - const ImageDropzone = ({ productIndex }: { productIndex: number }) => { - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept: { - 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] - }, - onDrop: (acceptedFiles) => { - // Automatically start upload when files are dropped - handleImageUpload(acceptedFiles, productIndex); - }, - }); - - return ( -
- - {isDragActive ? ( -
Drop images here
- ) : ( - <> - - Add Images - - )} -
- ); - }; - - // Component for individual unassigned image item - const UnassignedImageItem = ({ image, index }: { image: UnassignedImage; index: number }) => { - const [dialogOpen, setDialogOpen] = useState(false); - - return ( -
- {`Unassigned -
-

{image.file.name}

-
- - -
-
- {/* Zoom button for unassigned images */} - - - - - -
- -
- {`Unassigned -
-
- {`Unassigned image: ${image.file.name}`} -
-
-
-
-
- ); - }; - - // Component for displaying unassigned images - const UnassignedImagesSection = () => { - if (!showUnassigned || unassignedImages.length === 0) return null; - - return ( -
-
-
-
- -

- Unassigned Images ({unassignedImages.length}) -

-
- -
- -
- {unassignedImages.map((image, index) => ( - - ))} -
-
-
- ); - }; - - // Add the ZoomedImage component to show a larger version of the image - - // Sortable Image component with enhanced drag prevention - const SortableImage = ({ image, productIndex, imgIndex }: { image: ProductImageSortable, productIndex: number, imgIndex: number }) => { - const [dialogOpen, setDialogOpen] = useState(false); - - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging - } = useSortable({ - id: image.id, - data: { - productIndex, - image, - type: 'image' - } - }); - - // Create a new style object with fixed dimensions to prevent distortion - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - zIndex: isDragging ? 999 : 1, // Higher z-index when dragging - touchAction: 'none', // Prevent touch scrolling during drag - userSelect: 'none', // Prevent text selection during drag - cursor: isDragging ? 'grabbing' : 'grab', - width: '96px', - height: '96px', - flexShrink: 0, - flexGrow: 0, - position: 'relative', - }; - - // Create a ref for the buttons to exclude them from drag listeners - const deleteButtonRef = useRef(null); - const zoomButtonRef = useRef(null); - - return ( -
{ - // This ensures the native drag doesn't interfere - e.preventDefault(); - e.stopPropagation(); - }} - > - {image.loading ? ( -
- - {image.fileName} -
- ) : ( - <> - {`${data[productIndex].name -
-
- -
- - - {/* Fix zoom button with proper state management */} - - - - - -
- -
- {`${data[productIndex].name -
-
- {`${data[productIndex].name || `Product #${productIndex + 1}`} - Image ${imgIndex + 1}`} -
-
-
-
- - )} -
- ); - }; - - // Function to add more visual indication when dragging - const getProductContainerClasses = (index: number) => { - const isValidDropTarget = activeId && findContainer(activeId) !== index.toString(); - const isActiveDropTarget = activeDroppableId === `product-${index}`; - - return cn( - "flex-1 min-h-[6rem] rounded-md p-2 transition-all", - // Only show borders during active drag operations - isValidDropTarget && isActiveDropTarget - ? "border-2 border-dashed border-primary bg-primary/10" - : isValidDropTarget - ? "border border-dashed border-muted-foreground/30" - : "" - ); - }; - - // Add a DroppableContainer component for product containers - const DroppableContainer = ({ id, children, isEmpty }: { id: string; children: React.ReactNode; isEmpty: boolean }) => { - const { setNodeRef } = useDroppable({ - id, - data: { - type: 'container', - isEmpty - } - }); - - return ( -
- {children} -
- ); - }; - - // Add a URL input component that doesn't expand/collapse - - // Function to handle adding image from URL - const handleAddImageFromUrl = async (productIndex: number, urlInput: string): Promise => { - if (!urlInput) return; - - // Set processing state - setProcessingUrls(prev => ({ ...prev, [productIndex]: true })); - - // Create initial image data - let newImage: ProductImageSortable = { - id: `image-${productIndex}-${Date.now()}`, - productIndex, - imageUrl: urlInput, - loading: true, - fileName: urlInput.split('/').pop() || 'url-image', - pid: String(data[productIndex].id || ''), - iid: '', - type: 0, - order: productImages.filter(img => img.productIndex === productIndex).length - }; - - // Add to state - setProductImages(prev => [...prev, newImage]); - - try { - // Validate URL format - const validUrl = getFullImageUrl(urlInput); - if (!validUrl) { - throw new Error('Invalid URL format'); - } - - // Update image data with validated URL - newImage = { - ...newImage, - imageUrl: validUrl, - loading: false - }; - - // Update product images state - setProductImages(prev => - prev.map(img => - img.id === newImage.id ? newImage : img - ) - ); - - // Clear URL input - updateUrlInput(productIndex, ''); - - toast.success(`Image added from URL for ${data[productIndex].name || `Product #${productIndex + 1}`}`); - } catch (error) { - console.error('Error adding image from URL:', error); - toast.error('Failed to add image from URL'); - - // Remove the placeholder image on error - setProductImages(prev => - prev.filter(img => img.id !== newImage.id) - ); - } finally { - // Clear processing state - setProcessingUrls(prev => ({ ...prev, [productIndex]: false })); - } - }; - - // Function to update URL input - const updateUrlInput = (productIndex: number, value: string): void => { - setUrlInputs((prev: { [key: number]: string }) => ({ ...prev, [productIndex]: value })); - }; - - // Function to copy text to clipboard - const copyToClipboard = (text: string, key: string): void => { - if (!text || text === 'N/A') return; - - navigator.clipboard.writeText(text) - .then(() => { - // Show success state - setCopyState(prev => ({ ...prev, [key]: true })); - - // Show toast notification - toast.success(`Copied: ${text}`); - - // Reset after 2 seconds - setTimeout(() => { - setCopyState(prev => ({ ...prev, [key]: false })); - }, 2000); - }) - .catch(err => { - console.error('Failed to copy:', err); - toast.error('Failed to copy to clipboard'); - }); - }; - - // Small reusable copy button component - const CopyButton = ({ text, itemKey }: { text: string, itemKey: string }) => { - const isSuccess = copyState[itemKey]; - const canCopy = text && text !== 'N/A'; - - return ( - - ); - }; return (
@@ -1445,11 +189,24 @@ export const ImageUploadStep = ({
- + setShowUnassigned(true)} + />
- + setShowUnassigned(false)} + onAssign={assignImageToProduct} + onRemove={removeUnassignedImage} + />
{/* Scrollable product cards */} @@ -1467,149 +224,47 @@ export const ImageUploadStep = ({ } }} > - {activeId && ( -