Style tweaks, fix image uploads, refactor image upload step into smaller files
This commit is contained in:
@@ -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;
|
||||
}) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
{selectedCompany === 'all' && <TableHead>Company</TableHead>}
|
||||
<TableHead>Line</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Total Sold</TableHead>
|
||||
<TableHead>Date In</TableHead>
|
||||
<TableHead>Last Sold</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.map((product) => (
|
||||
<TableRow
|
||||
key={product.pid}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onSelect(product)}
|
||||
>
|
||||
<TableCell>{product.title}</TableCell>
|
||||
{selectedCompany === 'all' && <TableCell>{product.brand || '-'}</TableCell>}
|
||||
<TableCell>{product.line || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{product.price != null ? `$${Number(product.price).toFixed(2)}` : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{product.total_sold || 0}</TableCell>
|
||||
<TableCell>
|
||||
{product.first_received
|
||||
? new Date(product.first_received).toLocaleDateString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{product.date_last_sold
|
||||
? new Date(product.date_last_sold).toLocaleDateString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
id={id}
|
||||
data-droppable="true"
|
||||
data-empty={isEmpty ? "true" : "false"}
|
||||
className="w-full h-full flex flex-row flex-wrap gap-2"
|
||||
style={{ minHeight: '100px' }} // Ensure minimum height for empty containers
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"border-2 border-dashed border-secondary-foreground/30 bg-muted/90 rounded-md w-full py-6 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/70 transition-colors",
|
||||
isDragActive && "border-primary bg-muted"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center justify-center h-32 py-2">
|
||||
{processingBulk ? (
|
||||
<>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
|
||||
<p className="text-base text-muted-foreground">Processing images...</p>
|
||||
</>
|
||||
) : isDragActive ? (
|
||||
<>
|
||||
<Upload className="h-8 w-8 mb-2 text-primary" />
|
||||
<p className="text-base text-muted-foreground mb-2">Drop images here</p>
|
||||
<p className="text-sm text-muted-foreground"> </p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 mb-2 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground mb-2">Drop images here or click to select</p>
|
||||
<p className="text-sm text-muted-foreground">Images dropped here will be automatically assigned to products based on filename</p>
|
||||
{unassignedImages.length > 0 && !showUnassigned && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowUnassigned();
|
||||
}}
|
||||
className="mt-2 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
Show {unassignedImages.length} unassigned {unassignedImages.length === 1 ? 'image' : 'images'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
copyToClipboard();
|
||||
}}
|
||||
className={`ml-1 inline-flex items-center justify-center rounded-full p-1 transition-colors ${
|
||||
canCopy
|
||||
? isCopied
|
||||
? "bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "text-muted-foreground hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
: "opacity-50 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!canCopy}
|
||||
title={canCopy ? "Copy to clipboard" : "Nothing to copy"}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"border-2 border-dashed border-secondary-foreground/30 bg-muted/90 rounded-md h-24 w-24 flex flex-col items-center justify-center self-center cursor-pointer hover:bg-muted/70 transition-colors shrink-0",
|
||||
isDragActive && "border-primary bg-muted"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<div className="text-xs text-center text-muted-foreground p-1">Drop images here</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-5 w-5 mb-1 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Add Images</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLInputElement>;
|
||||
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 (
|
||||
<Card
|
||||
className={cn(
|
||||
"p-3 transition-colors",
|
||||
activeDroppableId === `product-${index}` && activeId &&
|
||||
getContainerAsNumber(activeId) !== index &&
|
||||
"ring-2 ring-primary bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
|
||||
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
|
||||
<div className="text-xs lg:text-sm text-muted-foreground">
|
||||
<span className="font-medium">UPC:</span> {product.upc || 'N/A'}
|
||||
<CopyButton text={product.upc || ''} itemKey={`upc-${index}`} />
|
||||
{' | '}
|
||||
<span className="font-medium">Supplier #:</span> {product.supplier_no || 'N/A'}
|
||||
<CopyButton text={product.supplier_no || ''} itemKey={`supplier-${index}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={onUrlSubmit}
|
||||
>
|
||||
<Input
|
||||
placeholder="Add image from URL"
|
||||
value={urlInput}
|
||||
onChange={(e) => onUrlInputChange(e.target.value)}
|
||||
className="!text-xs h-8 w-[180px]"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 whitespace-nowrap flex gap-1 items-center text-xs"
|
||||
disabled={processingUrl || !urlInput}
|
||||
>
|
||||
{processingUrl ?
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> :
|
||||
<LinkIcon className="h-3.5 w-3.5" />}
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex flex-row gap-2 items-start">
|
||||
<ImageDropzone
|
||||
productIndex={index}
|
||||
onDrop={onImageUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={getProductContainerClasses()}
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'none',
|
||||
minHeight: '100px',
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
>
|
||||
<DroppableContainer
|
||||
id={`product-${index}`}
|
||||
isEmpty={getProductImages().length === 0}
|
||||
>
|
||||
{getProductImages().length > 0 ? (
|
||||
<SortableContext
|
||||
items={getProductImages().map(img => img.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{getProductImages().map((image, imgIndex) => (
|
||||
<SortableImage
|
||||
key={image.id}
|
||||
image={image}
|
||||
productIndex={index}
|
||||
imgIndex={imgIndex}
|
||||
productName={product.name}
|
||||
removeImage={onRemoveImage}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<div className="w-full h-full" data-empty-placeholder="true"></div>
|
||||
)}
|
||||
</DroppableContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => e.target.files && onImageUpload(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const zoomButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const displayName = productName || `Product #${productIndex + 1}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="relative border rounded-md overflow-hidden flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing select-none no-native-drag group hover:ring-2 hover:ring-primary/30 transition-all"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onDragStart={(e) => {
|
||||
// This ensures the native drag doesn't interfere
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{image.loading ? (
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin mb-1" />
|
||||
<span className="text-xs text-center truncate w-full">{image.fileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={getFullImageUrl(image.imageUrl)}
|
||||
alt={`${displayName} - Image ${imgIndex + 1}`}
|
||||
className="h-full w-full object-cover select-none no-native-drag"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
|
||||
<div className="absolute right-0 top-0 p-1 opacity-0 group-hover:opacity-90 transition-opacity">
|
||||
<GripVertical className="h-3 w-3 text-white drop-shadow-md" />
|
||||
</div>
|
||||
<button
|
||||
ref={deleteButtonRef}
|
||||
className="absolute opacity-0 group-hover:opacity-100 transition-opacity duration-200 top-1 right-1 bg-black/40 hover:bg-black/70 hover:scale-110 rounded-full p-1 text-white z-10"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent triggering drag listeners
|
||||
removeImage(image.id);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation(); // Prevent drag from starting
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation(); // Prevent drag from starting
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation(); // Prevent drag from starting on touch
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
ref={zoomButtonRef}
|
||||
className="absolute opacity-0 group-hover:opacity-100 transition-opacity duration-200 bottom-1 left-1 bg-black/40 hover:bg-black/70 hover:scale-110 rounded-full p-1 text-white z-10"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent triggering drag listeners
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation(); // Prevent drag from starting
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation(); // Prevent drag from starting
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation(); // Prevent drag from starting on touch
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[650px] max-h-[90vh] p-6 bg-background/95 backdrop-blur-sm border border-border rounded-lg shadow-2xl">
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute -top-2 -right-2 bg-black/60 text-white hover:bg-black/80 rounded-full z-10 h-8 w-8"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
|
||||
<img
|
||||
src={getFullImageUrl(image.imageUrl)}
|
||||
alt={`${displayName} - Image ${imgIndex + 1}`}
|
||||
className="max-h-[70vh] max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground text-center">
|
||||
{`${displayName} - Image ${imgIndex + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="mb-4 px-4">
|
||||
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-md p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
Unassigned Images ({unassignedImages.length})
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onHide}
|
||||
className="h-8 text-muted-foreground"
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{unassignedImages.map((image, index) => (
|
||||
<UnassignedImageItem
|
||||
key={index}
|
||||
image={image}
|
||||
index={index}
|
||||
data={data}
|
||||
onAssign={onAssign}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="relative border rounded-md overflow-hidden">
|
||||
<img
|
||||
src={image.previewUrl}
|
||||
alt={`Unassigned image ${index + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2">
|
||||
<p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Select onValueChange={(value) => onAssign(index, parseInt(value))}>
|
||||
<SelectTrigger className="h-7 text-xs bg-white dark:bg-gray-800 border-0 text-black dark:text-white">
|
||||
<SelectValue placeholder="Assign to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data.map((product: Product, productIndex: number) => (
|
||||
<SelectItem key={productIndex} value={productIndex.toString()}>
|
||||
{product.name || `Product #${productIndex + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 bg-white dark:bg-gray-800 text-black dark:text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Zoom button for unassigned images */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className="absolute top-1 left-1 bg-black/60 rounded-full p-1 text-white z-10 hover:bg-black/80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[650px] max-h-[90vh] p-6 bg-background/95 backdrop-blur-sm border border-border rounded-lg shadow-2xl">
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute -top-2 -right-2 bg-black/60 text-white hover:bg-black/80 rounded-full z-10 h-8 w-8"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
|
||||
<img
|
||||
src={image.previewUrl}
|
||||
alt={`Unassigned image: ${image.file.name}`}
|
||||
className="max-h-[70vh] max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground text-center">
|
||||
{`Unassigned image: ${image.file.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<void>;
|
||||
|
||||
interface UseBulkImageUploadProps {
|
||||
data: Product[];
|
||||
handleImageUpload: HandleImageUploadFn;
|
||||
}
|
||||
|
||||
export const useBulkImageUpload = ({ data, handleImageUpload }: UseBulkImageUploadProps) => {
|
||||
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -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<React.SetStateAction<ProductImageSortable[]>>;
|
||||
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<string | null>(null);
|
||||
const [activeImage, setActiveImage] = useState<ProductImageSortable | null>(null);
|
||||
const [activeDroppableId, setActiveDroppableId] = useState<string | null>(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
|
||||
};
|
||||
};
|
||||
@@ -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<React.SetStateAction<ProductImageSortable[]>>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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<ProductImageSortable[]>(() => {
|
||||
// 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
|
||||
};
|
||||
};
|
||||
@@ -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<React.SetStateAction<ProductImageSortable[]>>;
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -43,23 +43,7 @@ export const SelectHeaderTable = ({ data, selectedRows, setSelectedRows }: Props
|
||||
|
||||
<div className="h-[calc(100vh-23rem)] overflow-auto">
|
||||
<Table className="relative w-full" style={{ tableLayout: 'fixed' }}>
|
||||
<TableHeader>
|
||||
<TableRow className="grid" style={{ gridTemplateColumns }}>
|
||||
<TableHead className="sticky top-0 z-20 bg-background overflow-hidden">
|
||||
|
||||
</TableHead>
|
||||
{columns.map((column) => (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className="sticky top-0 z-20 bg-background overflow-hidden"
|
||||
>
|
||||
<div className="truncate">
|
||||
{column.name}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<RadioGroup
|
||||
value={selectedRowIndex?.toString()}
|
||||
|
||||
@@ -232,7 +232,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between overflow-hidden", triggerClassName)}
|
||||
>
|
||||
<span className="truncate overflow-hidden mr-2">{getDisplayText()}</span>
|
||||
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -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(({
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-sm px-2 py-1.5`}>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
<span>Loading...</span>
|
||||
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md px-2 py-2`}>
|
||||
<Skeleton className="w-full h-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}>
|
||||
<div
|
||||
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isSourceCell ? '#dbeafe' :
|
||||
isSelectedTarget ? '#bfdbfe' :
|
||||
isInTargetRow && isTargetRowHovered ? '#dbeafe' :
|
||||
undefined,
|
||||
borderRadius: (isSourceCell || isSelectedTarget || isInTargetRow) ? '0.375rem' : undefined,
|
||||
boxShadow: isSourceCell ? '0 0 0 2px #3b82f6' : undefined
|
||||
}}
|
||||
>
|
||||
<BaseCellContent
|
||||
field={field}
|
||||
value={displayValue}
|
||||
|
||||
@@ -9,7 +9,6 @@ import SearchableTemplateSelect from './SearchableTemplateSelect'
|
||||
import { useAiValidation } from '../hooks/useAiValidation'
|
||||
import { AiValidationDialogs } from './AiValidationDialogs'
|
||||
import { Fields } from '../../../types'
|
||||
import { ErrorType, ValidationError, ErrorSources } from '../../../types'
|
||||
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
|
||||
import { TemplateForm } from '@/components/templates/TemplateForm'
|
||||
import axios from 'axios'
|
||||
@@ -17,8 +16,7 @@ import { RowSelectionState } from '@tanstack/react-table'
|
||||
import { useUpcValidation } from '../hooks/useUpcValidation'
|
||||
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
|
||||
import UpcValidationTableAdapter from './UpcValidationTableAdapter'
|
||||
import { clearAllUniquenessCaches } from '../hooks/useValidation'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
/**
|
||||
* ValidationContainer component - the main wrapper for the validation step
|
||||
*
|
||||
@@ -49,7 +47,6 @@ const ValidationContainer = <T extends string>({
|
||||
validationErrors,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
updateRow,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
applyTemplate,
|
||||
@@ -144,7 +141,6 @@ const ValidationContainer = <T extends string>({
|
||||
}, []);
|
||||
|
||||
// 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 = <T extends string>({
|
||||
}, [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<string, ValidationError[]> = { ...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 = <T extends string>({
|
||||
// Add scroll container ref at the container level
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(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<Record<string, number>>({});
|
||||
const lastLineFetchTime = useRef<Record<string, number>>({});
|
||||
|
||||
// Memoize scroll handlers - simplified to avoid performance issues
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement> | Event) => {
|
||||
@@ -1150,9 +1063,9 @@ const ValidationContainer = <T extends string>({
|
||||
{/* Selection Action Bar - only shown when items are selected */}
|
||||
{Object.keys(rowSelection).length > 0 && (
|
||||
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="bg-card shadow-xl rounded-lg border border-muted px-4 py-3 flex items-center gap-3">
|
||||
<div className="bg-card shadow-xl rounded-2xl border border-gray-200 px-4 py-3 flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mr-2 bg-muted items-center flex text-primary pl-2 pr-7 h-[32px] flex-shrink-0 rounded-md text-xs font-medium border border-primary">
|
||||
<div className="mr-3 bg-muted shadow-xs items-center flex text-primary pl-2 pr-7 h-8 flex-shrink-0 rounded-md text-xs font-medium border border-muted">
|
||||
{Object.keys(rowSelection).length} selected
|
||||
</div>
|
||||
|
||||
@@ -1167,11 +1080,10 @@ const ValidationContainer = <T extends string>({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center ml-2 mr-1 shadow-xs">
|
||||
{isLoadingTemplates ? (
|
||||
<Button variant="outline" className="w-[220px] justify-between" disabled>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading templates...
|
||||
<Button variant="outline" className="w-[250px] justify-between h-8" disabled>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</Button>
|
||||
) : templates && templates.length > 0 ? (
|
||||
<SearchableTemplateSelect
|
||||
@@ -1183,11 +1095,11 @@ const ValidationContainer = <T extends string>({
|
||||
}
|
||||
}}
|
||||
getTemplateDisplayText={getTemplateDisplayText}
|
||||
placeholder="Apply template to selected"
|
||||
triggerClassName="w-[220px]"
|
||||
placeholder="Apply template to selected rows"
|
||||
triggerClassName="w-[250px] text-xs h-8"
|
||||
/>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full justify-between" disabled>
|
||||
<Button variant="outline" className="w-full justify-between text-xs" disabled>
|
||||
No templates available
|
||||
</Button>
|
||||
)}
|
||||
@@ -1198,14 +1110,16 @@ const ValidationContainer = <T extends string>({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openTemplateForm}
|
||||
className="h-8 mr-1 shadow-xs"
|
||||
>
|
||||
Save as Template
|
||||
Save as template
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={isFromScratch ? "destructive" : "outline"}
|
||||
variant={"destructive"}
|
||||
size="sm"
|
||||
className="h-8 shadow-xs"
|
||||
onClick={() => {
|
||||
console.log('Delete/Discard button clicked');
|
||||
console.log('Row selection state:', rowSelection);
|
||||
|
||||
@@ -254,7 +254,7 @@ const ValidationTable = <T extends string>({
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex h-[40px] items-center justify-center">
|
||||
<div className="flex items-center justify-center py-9">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => handleRowSelect(!!value, row)}
|
||||
@@ -590,7 +590,7 @@ const ValidationTable = <T extends string>({
|
||||
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" : ""
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
<div className="container mx-auto py-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Loading import options...</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">Preparing import options...</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user