+
+ {activeImage && (
+
})
-
)}
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/DroppableContainer.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/DroppableContainer.tsx
new file mode 100644
index 0000000..e2a5225
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/DroppableContainer.tsx
@@ -0,0 +1,30 @@
+import { useDroppable } from '@dnd-kit/core';
+
+interface DroppableContainerProps {
+ id: string;
+ children: React.ReactNode;
+ isEmpty: boolean;
+}
+
+export const DroppableContainer = ({ id, children, isEmpty }: DroppableContainerProps) => {
+ const { setNodeRef } = useDroppable({
+ id,
+ data: {
+ type: 'container',
+ isEmpty
+ }
+ });
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/GenericDropzone.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/GenericDropzone.tsx
new file mode 100644
index 0000000..a9c70e1
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/GenericDropzone.tsx
@@ -0,0 +1,73 @@
+import { Button } from "@/components/ui/button";
+import { Loader2, Upload } from "lucide-react";
+import { useDropzone } from "react-dropzone";
+import { cn } from "@/lib/utils";
+
+interface GenericDropzoneProps {
+ processingBulk: boolean;
+ unassignedImages: { previewUrl: string; file: File }[];
+ showUnassigned: boolean;
+ onDrop: (files: File[]) => void;
+ onShowUnassigned: () => void;
+}
+
+export const GenericDropzone = ({
+ processingBulk,
+ unassignedImages,
+ showUnassigned,
+ onDrop,
+ onShowUnassigned
+}: GenericDropzoneProps) => {
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ accept: {
+ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
+ },
+ onDrop,
+ multiple: true
+ });
+
+ return (
+
+
+
+ {processingBulk ? (
+ <>
+
+
Processing images...
+ >
+ ) : isDragActive ? (
+ <>
+
+
Drop images here
+
+ >
+ ) : (
+ <>
+
+
Drop images here or click to select
+
Images dropped here will be automatically assigned to products based on filename
+ {unassignedImages.length > 0 && !showUnassigned && (
+
+ )}
+ >
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/CopyButton.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/CopyButton.tsx
new file mode 100644
index 0000000..208ae4d
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/CopyButton.tsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+import { Copy, Check } from "lucide-react";
+import { toast } from "sonner";
+
+interface CopyButtonProps {
+ text: string;
+ itemKey: string;
+}
+
+export const CopyButton = ({ text }: CopyButtonProps) => {
+ const [isCopied, setIsCopied] = useState(false);
+ const canCopy = text && text !== 'N/A';
+
+ const copyToClipboard = () => {
+ if (!canCopy) return;
+
+ navigator.clipboard.writeText(text)
+ .then(() => {
+ // Show success state
+ setIsCopied(true);
+
+ // Show toast notification
+ toast.success(`Copied: ${text}`);
+
+ // Reset after 2 seconds
+ setTimeout(() => {
+ setIsCopied(false);
+ }, 2000);
+ })
+ .catch(err => {
+ console.error('Failed to copy:', err);
+ toast.error('Failed to copy to clipboard');
+ });
+ };
+
+ return (
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx
new file mode 100644
index 0000000..647a340
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx
@@ -0,0 +1,39 @@
+import { Upload } from "lucide-react";
+import { useDropzone } from "react-dropzone";
+import { cn } from "@/lib/utils";
+
+interface ImageDropzoneProps {
+ productIndex: number;
+ onDrop: (files: File[]) => void;
+}
+
+export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => {
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ accept: {
+ 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
+ },
+ onDrop: (acceptedFiles) => {
+ onDrop(acceptedFiles);
+ },
+ });
+
+ return (
+
+
+ {isDragActive ? (
+
Drop images here
+ ) : (
+ <>
+
+
Add Images
+ >
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx
new file mode 100644
index 0000000..a39e3f7
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx
@@ -0,0 +1,163 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent } from "@/components/ui/card";
+import { Loader2, Link as LinkIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { ImageDropzone } from "./ImageDropzone";
+import { SortableImage } from "./SortableImage";
+import { CopyButton } from "./CopyButton";
+import { ProductImageSortable, Product } from "../../types";
+import { DroppableContainer } from "../DroppableContainer";
+import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
+
+interface ProductCardProps {
+ product: Product;
+ index: number;
+ urlInput: string;
+ processingUrl: boolean;
+ activeDroppableId: string | null;
+ activeId: string | null;
+ productImages: ProductImageSortable[];
+ fileInputRef: React.RefObject
;
+ onUrlInputChange: (value: string) => void;
+ onUrlSubmit: (e: React.FormEvent) => void;
+ onImageUpload: (files: FileList | File[]) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onRemoveImage: (id: string) => void;
+ getProductContainerClasses: () => string;
+ findContainer: (id: string) => string | null;
+}
+
+export const ProductCard = ({
+ product,
+ index,
+ urlInput,
+ processingUrl,
+ activeDroppableId,
+ activeId,
+ productImages,
+ fileInputRef,
+ onUrlInputChange,
+ onUrlSubmit,
+ onImageUpload,
+ onDragOver,
+ onRemoveImage,
+ getProductContainerClasses,
+ findContainer
+}: ProductCardProps) => {
+ // Function to get images for this product
+ const getProductImages = () => {
+ return productImages.filter(img => img.productIndex === index);
+ };
+
+ // Convert string container to number for internal comparison
+ const getContainerAsNumber = (id: string): number | null => {
+ const result = findContainer(id);
+ return result !== null ? parseInt(result) : null;
+ };
+
+ return (
+
+
+
+
+
+
{product.name || `Product #${index + 1}`}
+
+ UPC: {product.upc || 'N/A'}
+
+ {' | '}
+ Supplier #: {product.supplier_no || 'N/A'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {getProductImages().length > 0 ? (
+ img.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {getProductImages().map((image, imgIndex) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
+
e.target.files && onImageUpload(e.target.files)}
+ />
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx
new file mode 100644
index 0000000..c5f0813
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx
@@ -0,0 +1,172 @@
+import { useState, useRef } from "react";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { Loader2, Trash2, Maximize2, GripVertical, X } from "lucide-react";
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import config from "@/config";
+import { ProductImageSortable } from "../types";
+
+// Function to ensure URLs are properly formatted with absolute paths
+const getFullImageUrl = (url: string): string => {
+ // If the URL is already absolute (starts with http:// or https://) return it as is
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ return url;
+ }
+
+ // Otherwise, it's a relative URL, prepend the domain
+ const baseUrl = 'https://inventory.acot.site';
+ // Make sure url starts with / for path
+ const path = url.startsWith('/') ? url : `/${url}`;
+ return `${baseUrl}${path}`;
+};
+
+export const SortableImage = ({
+ image,
+ productIndex,
+ imgIndex,
+ productName,
+ removeImage
+}: SortableImageProps) => {
+ 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);
+
+ const displayName = productName || `Product #${productIndex + 1}`;
+
+ return (
+ {
+ // This ensures the native drag doesn't interfere
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ {image.loading ? (
+
+
+ {image.fileName}
+
+ ) : (
+ <>
+
})
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/UnassignedImagesSection.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/UnassignedImagesSection.tsx
new file mode 100644
index 0000000..ac2804c
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/UnassignedImagesSection.tsx
@@ -0,0 +1,60 @@
+import { AlertCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { UnassignedImage, Product } from "../types";
+import { UnassignedImageItem } from "./UnassignedImagesSection/UnassignedImageItem";
+
+interface UnassignedImagesSectionProps {
+ showUnassigned: boolean;
+ unassignedImages: UnassignedImage[];
+ data: Product[];
+ onHide: () => void;
+ onAssign: (imageIndex: number, productIndex: number) => void;
+ onRemove: (index: number) => void;
+}
+
+export const UnassignedImagesSection = ({
+ showUnassigned,
+ unassignedImages,
+ data,
+ onHide,
+ onAssign,
+ onRemove
+}: UnassignedImagesSectionProps) => {
+ if (!showUnassigned || unassignedImages.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+ Unassigned Images ({unassignedImages.length})
+
+
+
+
+
+
+ {unassignedImages.map((image, index) => (
+
+ ))}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/UnassignedImagesSection/UnassignedImageItem.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/UnassignedImagesSection/UnassignedImageItem.tsx
new file mode 100644
index 0000000..2ba1c00
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/components/UnassignedImagesSection/UnassignedImageItem.tsx
@@ -0,0 +1,112 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Trash2, Maximize2, X } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { UnassignedImage, Product } from "../../types";
+
+interface UnassignedImageItemProps {
+ image: UnassignedImage;
+ index: number;
+ data: Product[];
+ onAssign: (imageIndex: number, productIndex: number) => void;
+ onRemove: (index: number) => void;
+}
+
+export const UnassignedImageItem = ({
+ image,
+ index,
+ data,
+ onAssign,
+ onRemove
+}: UnassignedImageItemProps) => {
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ return (
+
+

+
+
{image.file.name}
+
+
+
+
+
+ {/* Zoom button for unassigned images */}
+
+
+ );
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useBulkImageUpload.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useBulkImageUpload.ts
new file mode 100644
index 0000000..8cd7dd2
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useBulkImageUpload.ts
@@ -0,0 +1,183 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { UnassignedImage, Product, ProductImageSortable } from "../types";
+
+type HandleImageUploadFn = (files: FileList | File[], productIndex: number) => Promise;
+
+interface UseBulkImageUploadProps {
+ data: Product[];
+ handleImageUpload: HandleImageUploadFn;
+}
+
+export const useBulkImageUpload = ({ data, handleImageUpload }: UseBulkImageUploadProps) => {
+ const [unassignedImages, setUnassignedImages] = useState([]);
+ const [processingBulk, setProcessingBulk] = useState(false);
+ const [showUnassigned, setShowUnassigned] = useState(false);
+
+ // 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[]) => {
+ 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.warning(`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
+ const cleanupPreviewUrls = () => {
+ unassignedImages.forEach(image => {
+ URL.revokeObjectURL(image.previewUrl);
+ });
+ };
+
+ return {
+ unassignedImages,
+ setUnassignedImages,
+ processingBulk,
+ showUnassigned,
+ setShowUnassigned,
+ handleBulkUpload,
+ assignImageToProduct,
+ removeUnassignedImage,
+ cleanupPreviewUrls
+ };
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useDragAndDrop.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useDragAndDrop.ts
new file mode 100644
index 0000000..8f3d076
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useDragAndDrop.ts
@@ -0,0 +1,340 @@
+import { useState, useEffect } from "react";
+import {
+ DragEndEvent,
+ DragStartEvent,
+ DragMoveEvent,
+ CollisionDetection,
+ pointerWithin,
+ rectIntersection
+} from '@dnd-kit/core';
+import { arrayMove } from '@dnd-kit/sortable';
+import { toast } from "sonner";
+import { ProductImageSortable } from "../types";
+
+type UseDragAndDropProps = {
+ productImages: ProductImageSortable[];
+ setProductImages: React.Dispatch>;
+ data: any[];
+};
+
+type UseDragAndDropReturn = {
+ activeId: string | null;
+ activeImage: ProductImageSortable | null;
+ activeDroppableId: string | null;
+ customCollisionDetection: CollisionDetection;
+ findContainer: (id: string) => string | null;
+ getProductImages: (productIndex: number) => ProductImageSortable[];
+ getProductContainerClasses: (index: number) => string;
+ handleDragStart: (event: DragStartEvent) => void;
+ handleDragOver: (event: DragMoveEvent) => void;
+ handleDragEnd: (event: DragEndEvent) => void;
+};
+
+export const useDragAndDrop = ({
+ productImages,
+ setProductImages,
+ data
+}: UseDragAndDropProps): UseDragAndDropReturn => {
+ const [activeId, setActiveId] = useState(null);
+ const [activeImage, setActiveImage] = useState(null);
+ const [activeDroppableId, setActiveDroppableId] = useState(null);
+
+ // 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);
+ };
+
+ // 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);
+ };
+
+ // 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);
+ }
+ // 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);
+ }
+ }
+ };
+
+ // 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];
+ }
+ // Otherwise check if it's an image, so find its container
+ else {
+ overContainer = findContainer(overId.toString());
+ }
+
+ // If we couldn't determine active container, do nothing
+ if (!activeContainer) {
+ setActiveId(null);
+ setActiveImage(null);
+ return;
+ }
+
+ // If we couldn't determine the over container, do nothing
+ if (!overContainer) {
+ 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) {
+ // 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);
+
+ // 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) {
+ // 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-')) {
+ // 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);
+
+ 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);
+
+ return newItems;
+ });
+ }
+ }
+
+ setActiveId(null);
+ setActiveImage(null);
+ };
+
+ // Monitor drag events to prevent browser behaviors
+ 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]);
+
+ // Add product IDs to the valid droppable elements
+ useEffect(() => {
+ // Add data-droppable attributes to make product containers easier to identify
+ data.forEach((_, index) => {
+ 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';
+ }
+ }
+ });
+ }, [data, productImages]); // Add productImages as a dependency to re-run when images change
+
+ // Effect to register browser-level drag events on product containers
+ useEffect(() => {
+ // For each product container
+ data.forEach((_, index) => {
+ const container = document.getElementById(`product-${index}`);
+
+ if (container) {
+ // Define handlers for native browser drag events
+ const handleNativeDragOver = (e: DragEvent) => {
+ e.preventDefault();
+ setActiveDroppableId(`product-${index}`);
+ };
+
+ const handleNativeDragLeave = () => {
+ 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 add more visual indication when dragging
+ const getProductContainerClasses = (index: number) => {
+ const isValidDropTarget = activeId && findContainer(activeId) !== index.toString();
+ const isActiveDropTarget = activeDroppableId === `product-${index}`;
+
+ return [
+ "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"
+ : ""
+ ].filter(Boolean).join(" ");
+ };
+
+ return {
+ activeId,
+ activeImage,
+ activeDroppableId,
+ customCollisionDetection,
+ findContainer,
+ getProductImages,
+ getProductContainerClasses,
+ handleDragStart,
+ handleDragOver,
+ handleDragEnd
+ };
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useProductImageOperations.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useProductImageOperations.ts
new file mode 100644
index 0000000..f47775c
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useProductImageOperations.ts
@@ -0,0 +1,195 @@
+import { toast } from "sonner";
+import config from "@/config";
+import { Product, ProductImageSortable } from "../types";
+
+interface UseProductImageOperationsProps {
+ data: Product[];
+ productImages: ProductImageSortable[];
+ setProductImages: React.Dispatch>;
+}
+
+export const useProductImageOperations = ({
+ data,
+ productImages,
+ setProductImages,
+}: UseProductImageOperationsProps) => {
+ // 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];
+
+ // We need to update product_images array directly instead of the image_url field
+ if (!product.product_images) {
+ product.product_images = [];
+ } else if (typeof product.product_images === 'string') {
+ // Handle case where it might be a comma-separated string
+ product.product_images = product.product_images.split(',').filter(Boolean);
+ }
+
+ // Filter out the image URL we're removing
+ if (Array.isArray(product.product_images)) {
+ product.product_images = product.product_images.filter((url: string) => url && url !== imageUrl);
+ }
+
+ return newData;
+ };
+
+ // Function to add an image URL to a product
+ const addImageToProduct = (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 = [];
+ } else if (typeof product.product_images === 'string') {
+ // Handle case where it might be a comma-separated string
+ product.product_images = product.product_images.split(',').filter(Boolean);
+ }
+
+ // Ensure it's an array
+ if (!Array.isArray(product.product_images)) {
+ product.product_images = [product.product_images].filter(Boolean);
+ }
+
+ // Only add if the URL doesn't already exist
+ if (!product.product_images.includes(imageUrl)) {
+ product.product_images.push(imageUrl);
+ }
+
+ return newData;
+ };
+
+ // Function to handle image upload - update product data
+ const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
+ if (!files || files.length === 0) return;
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+
+ // Add placeholder for this image
+ const newImage: ProductImageSortable = {
+ id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
+ productIndex,
+ imageUrl: '',
+ loading: true,
+ fileName: file.name,
+ // Add required schema fields for ProductImageSortable
+ pid: data[productIndex].id || 0,
+ iid: 0,
+ type: 0,
+ order: 0,
+ width: 0,
+ height: 0,
+ hidden: 0
+ };
+
+ setProductImages(prev => [...prev, newImage]);
+
+ // Create form data for upload
+ const formData = new FormData();
+ formData.append('image', file);
+ formData.append('productIndex', productIndex.toString());
+ formData.append('upc', data[productIndex].upc || '');
+ formData.append('supplier_no', data[productIndex].supplier_no || '');
+
+ try {
+ // Upload the image
+ const response = await fetch(`${config.apiUrl}/import/upload-image`, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to upload image');
+ }
+
+ const result = await response.json();
+
+ // Update the image URL in our state
+ setProductImages(prev =>
+ prev.map(img =>
+ (img.loading && img.productIndex === productIndex && img.fileName === file.name)
+ ? { ...img, imageUrl: result.imageUrl, loading: false }
+ : img
+ )
+ );
+
+ // Update the product data with the new image URL
+ addImageToProduct(productIndex, result.imageUrl);
+
+ toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
+ } catch (error) {
+ console.error('Upload error:', error);
+
+ // Remove the failed image from our state
+ setProductImages(prev =>
+ prev.filter(img =>
+ !(img.loading && img.productIndex === productIndex && img.fileName === file.name)
+ )
+ );
+
+ toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+ };
+
+ // 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'}`);
+ }
+ };
+
+ return {
+ removeImageFromProduct,
+ addImageToProduct,
+ handleImageUpload,
+ removeImage,
+ };
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useProductImagesInit.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useProductImagesInit.ts
new file mode 100644
index 0000000..5a3280b
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useProductImagesInit.ts
@@ -0,0 +1,88 @@
+import { useState } from "react";
+import { ProductImageSortable, Product } from "../types";
+
+export const useProductImagesInit = (data: Product[]) => {
+ // 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 images: any[] = [];
+
+ // Handle different formats of product_images
+ 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: string) => ({
+ 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];
+ }
+
+ // Create ProductImageSortable objects for each image
+ images.forEach((img, i) => {
+ // Handle both URL strings and structured image objects
+ const imageUrl = typeof img === 'string' ? img : img.imageUrl;
+
+ if (imageUrl && imageUrl.trim()) {
+ initialImages.push({
+ id: `image-${productIndex}-initial-${i}`,
+ productIndex,
+ imageUrl: imageUrl.trim(),
+ loading: false,
+ fileName: `Image ${i + 1}`,
+ // Add schema fields
+ pid: product.id || 0,
+ iid: typeof img === 'object' && img.iid ? img.iid : i,
+ type: typeof img === 'object' && img.type !== undefined ? img.type : 0,
+ order: typeof img === 'object' && img.order !== undefined ? img.order : i,
+ width: typeof img === 'object' && img.width ? img.width : 0,
+ height: typeof img === 'object' && img.height ? img.height : 0,
+ hidden: typeof img === 'object' && img.hidden !== undefined ? img.hidden : 0
+ });
+ }
+ });
+ }
+ });
+
+ return initialImages;
+ });
+
+ // Function to ensure URLs are properly formatted with absolute paths
+ const getFullImageUrl = (url: string): string => {
+ // If the URL is already absolute (starts with http:// or https://) return it as is
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ return url;
+ }
+
+ // Otherwise, it's a relative URL, prepend the domain
+ const baseUrl = 'https://inventory.acot.site';
+ // Make sure url starts with / for path
+ const path = url.startsWith('/') ? url : `/${url}`;
+ return `${baseUrl}${path}`;
+ };
+
+ return {
+ productImages,
+ setProductImages,
+ getFullImageUrl
+ };
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useUrlImageUpload.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useUrlImageUpload.ts
new file mode 100644
index 0000000..db1a287
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/hooks/useUrlImageUpload.ts
@@ -0,0 +1,98 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { Product, ProductImageSortable } from "../types";
+
+type AddImageToProductFn = (productIndex: number, imageUrl: string) => Product[];
+
+interface UseUrlImageUploadProps {
+ data: Product[];
+ setProductImages: React.Dispatch>;
+ addImageToProduct: AddImageToProductFn;
+}
+
+export const useUrlImageUpload = ({
+ data,
+ setProductImages,
+ addImageToProduct
+}: UseUrlImageUploadProps) => {
+ const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({});
+ const [processingUrls, setProcessingUrls] = useState<{ [key: number]: boolean }>({});
+
+ // Handle adding an image from a URL - simplified to skip server
+ const handleAddImageFromUrl = async (productIndex: number, url: string) => {
+ if (!url || !url.trim()) {
+ toast.error("Please enter a valid URL");
+ return;
+ }
+
+ try {
+ // Set processing state
+ setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
+
+ // Validate URL format
+ let validatedUrl = url.trim();
+
+ // Add protocol if missing
+ if (!validatedUrl.startsWith('http://') && !validatedUrl.startsWith('https://')) {
+ validatedUrl = `https://${validatedUrl}`;
+ }
+
+ // Basic URL validation
+ try {
+ new URL(validatedUrl);
+ } catch (e) {
+ toast.error("Invalid URL format. Please enter a valid URL");
+ setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
+ return;
+ }
+
+ // Create a unique ID for this image
+ const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
+
+ // Create the new image object with the URL
+ const newImage: ProductImageSortable = {
+ id: imageId,
+ productIndex,
+ imageUrl: validatedUrl,
+ loading: false, // We're not loading from server, so it's ready immediately
+ fileName: "From URL",
+ // Add required schema fields
+ pid: data[productIndex].id || 0,
+ iid: 0,
+ type: 0,
+ order: 0,
+ width: 0,
+ height: 0,
+ hidden: 0
+ };
+
+ // Add the image directly to the product images list
+ setProductImages(prev => [...prev, newImage]);
+
+ // Update the product data with the new image URL
+ addImageToProduct(productIndex, validatedUrl);
+
+ // Clear the URL input field on success
+ setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
+
+ toast.success(`Image URL added to ${data[productIndex].name || `Product #${productIndex + 1}`}`);
+ } catch (error) {
+ console.error('Add image from URL error:', error);
+ toast.error(`Failed to add image URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ } finally {
+ setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
+ }
+ };
+
+ // Update the URL input value
+ const updateUrlInput = (productIndex: number, value: string) => {
+ setUrlInputs(prev => ({ ...prev, [productIndex]: value }));
+ };
+
+ return {
+ urlInputs,
+ processingUrls,
+ handleAddImageFromUrl,
+ updateUrlInput
+ };
+};
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/types.ts b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/types.ts
new file mode 100644
index 0000000..17f9b23
--- /dev/null
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/types.ts
@@ -0,0 +1,35 @@
+export type ProductImage = {
+ productIndex: number;
+ imageUrl: string;
+ loading: boolean;
+ fileName: string;
+ // Schema fields
+ pid: number;
+ iid: number;
+ type: number;
+ order: number;
+ width: number;
+ height: number;
+ hidden: number;
+}
+
+export type UnassignedImage = {
+ file: File;
+ previewUrl: string;
+}
+
+// Product ID type to handle the sortable state
+export type ProductImageSortable = ProductImage & {
+ id: string;
+};
+
+// Shared Product interface
+export interface Product {
+ id?: number;
+ name?: string;
+ upc?: string;
+ supplier_no?: string;
+ sku?: string;
+ model?: string;
+ product_images?: string | string[];
+}
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/SelectHeaderTable.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/SelectHeaderTable.tsx
index 0104576..3983525 100644
--- a/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/SelectHeaderTable.tsx
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/components/SelectHeaderTable.tsx
@@ -43,23 +43,7 @@ export const SelectHeaderTable = ({ data, selectedRows, setSelectedRows }: Props
-
-
-
-
-
- {columns.map((column) => (
-
-
- {column.name}
-
-
- ))}
-
-
+
= ({
aria-expanded={open}
className={cn("w-full justify-between overflow-hidden", triggerClassName)}
>
- {getDisplayText()}
+ {getDisplayText()}
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx
index 0edb323..f4d907d 100644
--- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationCell.tsx
@@ -1,6 +1,6 @@
import React from 'react'
import { Field, ErrorType } from '../../../types'
-import { Loader2, AlertCircle, ArrowDown, X } from 'lucide-react'
+import { AlertCircle, ArrowDown, X } from 'lucide-react'
import {
Tooltip,
TooltipContent,
@@ -11,6 +11,7 @@ import InputCell from './cells/InputCell'
import SelectCell from './cells/SelectCell'
import MultiSelectCell from './cells/MultiSelectCell'
import { TableCell } from '@/components/ui/table'
+import { Skeleton } from '@/components/ui/skeleton'
// Context for copy down selection mode
export const CopyDownContext = React.createContext<{
@@ -351,12 +352,8 @@ const ValidationCell = React.memo(({
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box' as const,
- cursor: isInTargetRow ? 'pointer' : undefined,
- ...(isSourceCell ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, boxShadow: '0 0 0 2px #3b82f6' } :
- isSelectedTarget ? { backgroundColor: '#bfdbfe', borderRadius: '0.375rem', padding: 0 } :
- isInTargetRow && isTargetRowHovered ? { backgroundColor: '#dbeafe', borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } :
- isInTargetRow ? { borderRadius: '0.375rem', padding: 0, cursor: 'pointer' } : {})
- }), [width, isInTargetRow, isSourceCell, isSelectedTarget, isTargetRowHovered]);
+ cursor: isInTargetRow ? 'pointer' : undefined
+ }), [width, isInTargetRow]);
// Memoize the cell class name to prevent re-calculating on every render
const cellClassName = React.useMemo(() => {
@@ -431,12 +428,21 @@ const ValidationCell = React.memo(({
)}
{isLoading ? (
-
-
-
Loading...
+
+
) : (
-
+
({
validationErrors,
rowSelection,
setRowSelection,
- updateRow,
templates,
selectedTemplateId,
applyTemplate,
@@ -144,7 +141,6 @@ const ValidationContainer = ({
}, []);
// Add a ref to track the last validation time
- const lastValidationTime = useRef(0);
// Trigger revalidation only for specifically marked fields
useEffect(() => {
@@ -301,82 +297,8 @@ const ValidationContainer = ({
}, [prepareRowDataForTemplateForm, fetchFieldOptions]);
// Create a function to validate uniqueness if validateUniqueItemNumbers is not available
- const validateUniqueValues = useCallback(() => {
- // Check if validateUniqueItemNumbers exists on validationState using safer method
- if ('validateUniqueItemNumbers' in validationState &&
- typeof (validationState as any).validateUniqueItemNumbers === 'function') {
- (validationState as any).validateUniqueItemNumbers();
- } else {
- // Otherwise fall back to revalidating all rows
- validationState.revalidateRows(Array.from(Array(data.length).keys()));
- }
- }, [validationState, data.length]);
// Apply item numbers to data and trigger revalidation for uniqueness
- const applyItemNumbersAndValidate = useCallback(() => {
- // Clear uniqueness validation caches to ensure fresh validation
- clearAllUniquenessCaches();
-
- upcValidation.applyItemNumbersToData((updatedRowIds) => {
- console.log(`Revalidating item numbers for ${updatedRowIds.length} rows`);
-
- // Force clearing all uniqueness errors for item_number and upc fields first
- const newValidationErrors = new Map(validationErrors);
-
- // Clear uniqueness errors for all rows that had their item numbers updated
- updatedRowIds.forEach(rowIndex => {
- const rowErrors = newValidationErrors.get(rowIndex);
- if (rowErrors) {
- // Create a copy of row errors without uniqueness errors for item_number/upc
- const filteredErrors: Record = { ...rowErrors };
- let hasChanges = false;
-
- // Clear item_number errors if they exist and are uniqueness errors
- if (filteredErrors.item_number &&
- filteredErrors.item_number.some(e => e.type === ErrorType.Unique)) {
- delete filteredErrors.item_number;
- hasChanges = true;
- }
-
- // Also clear upc/barcode errors if they exist and are uniqueness errors
- if (filteredErrors.upc &&
- filteredErrors.upc.some(e => e.type === ErrorType.Unique)) {
- delete filteredErrors.upc;
- hasChanges = true;
- }
-
- if (filteredErrors.barcode &&
- filteredErrors.barcode.some(e => e.type === ErrorType.Unique)) {
- delete filteredErrors.barcode;
- hasChanges = true;
- }
-
- // Update the map or remove the row entry if no errors remain
- if (hasChanges) {
- if (Object.keys(filteredErrors).length > 0) {
- newValidationErrors.set(rowIndex, filteredErrors);
- } else {
- newValidationErrors.delete(rowIndex);
- }
- }
- }
- });
-
- // Call the revalidateRows function directly with affected rows
- validationState.revalidateRows(updatedRowIds);
-
- // Immediately run full uniqueness validation across all rows if available
- // This is crucial to properly identify new uniqueness issues
- setTimeout(() => {
- validateUniqueValues();
- }, 0);
-
- // Mark all updated rows for revalidation
- updatedRowIds.forEach(rowIndex => {
- markRowForRevalidation(rowIndex, 'item_number');
- });
- });
- }, [upcValidation.applyItemNumbersToData, markRowForRevalidation, clearAllUniquenessCaches, validationErrors, validationState.revalidateRows, validateUniqueValues]);
// Handle next button click - memoized
const handleNext = useCallback(() => {
@@ -479,21 +401,12 @@ const ValidationContainer = ({
// Add scroll container ref at the container level
const scrollContainerRef = useRef(null);
const lastScrollPosition = useRef({ left: 0, top: 0 });
- const isScrolling = useRef(false);
// Track if we're currently validating a UPC
- const isValidatingUpcRef = useRef(false);
// Track last UPC update to prevent conflicting changes
- const lastUpcUpdate = useRef({
- rowIndex: -1,
- supplier: "",
- upc: ""
- });
// Add these ref declarations here, at component level
- const lastCompanyFetchTime = useRef>({});
- const lastLineFetchTime = useRef>({});
// Memoize scroll handlers - simplified to avoid performance issues
const handleScroll = useCallback((event: React.UIEvent | Event) => {
@@ -1150,9 +1063,9 @@ const ValidationContainer = ({
{/* Selection Action Bar - only shown when items are selected */}
{Object.keys(rowSelection).length > 0 && (
-
+
-
+
{Object.keys(rowSelection).length} selected
@@ -1167,11 +1080,10 @@ const ValidationContainer =
({
-
+
{isLoadingTemplates ? (
-
),
cell: ({ row }) => (
-
+
handleRowSelect(!!value, row)}
@@ -590,7 +590,7 @@ const ValidationTable = ({
key={row.id}
className={cn(
"hover:bg-muted/50",
- row.getIsSelected() ? "bg-muted/50" : "",
+ row.getIsSelected() ? "!bg-blue-50/50" : "",
hasErrors ? "bg-red-50/40" : "",
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
)}
diff --git a/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts b/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts
index 7c97c6c..a0753d0 100644
--- a/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts
+++ b/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts
@@ -44,7 +44,7 @@ export const translations = {
backButtonTitle: "Back",
noRowsMessage: "No data found",
noRowsMessageWhenFiltered: "No data containing errors",
- discardButtonTitle: "Discard selected rows",
+ discardButtonTitle: "Delete selected rows",
filterSwitchTitle: "Show only rows with errors",
},
imageUploadStep: {
diff --git a/inventory/src/pages/Import.tsx b/inventory/src/pages/Import.tsx
index 5e4d1ad..b099354 100644
--- a/inventory/src/pages/Import.tsx
+++ b/inventory/src/pages/Import.tsx
@@ -9,7 +9,7 @@ import { motion } from "framer-motion";
import { useQuery } from "@tanstack/react-query";
import config from "@/config";
import { StepType } from "@/lib/react-spreadsheet-import/src/steps/UploadFlow";
-
+import { Loader2 } from "lucide-react";
// Define base fields without dynamic options
const BASE_IMPORT_FIELDS = [
{
@@ -525,7 +525,10 @@ export function Import() {
if (isLoadingOptions) {
return (
-
Loading import options...
+
+
+
Preparing import options...
+
);
}