Add image upload

This commit is contained in:
2025-02-26 16:15:18 -05:00
parent fbb200c4ee
commit 42af434bd7
11 changed files with 680 additions and 44 deletions

View File

@@ -0,0 +1,350 @@
import { useCallback, useState, useRef } from "react";
import { useRsi } from "../../hooks/useRsi";
import { Button } from "@/components/ui/button";
import { Loader2, Upload, Trash2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import config from "@/config";
import { useDropzone } from "react-dropzone";
import { cn } from "@/lib/utils";
type Props<T extends string> = {
data: any[];
file: File;
onBack?: () => void;
onSubmit: (data: any[], file: File) => void | Promise<any>;
}
type ProductImage = {
productIndex: number;
imageUrl: string;
loading: boolean;
fileName: string;
}
export const ImageUploadStep = <T extends string>({
data,
file,
onBack,
onSubmit
}: Props<T>) => {
const { translations } = useRsi<T>();
const [isSubmitting, setIsSubmitting] = useState(false);
const [productImages, setProductImages] = useState<ProductImage[]>([]);
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
// Function to handle image upload
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: ProductImage = {
productIndex,
imageUrl: '',
loading: true,
fileName: file.name
};
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
updateProductWithImageUrl(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'}`);
}
}
};
// Component for image dropzone
const ImageDropzone = ({ productIndex }: { productIndex: number }) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
},
onDrop: (acceptedFiles) => {
// Automatically start upload when files are dropped
handleImageUpload(acceptedFiles, productIndex);
},
});
return (
<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 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>
);
};
// Function to trigger file input click
// Function to update product data with image URL
const updateProductWithImageUrl = (productIndex: number, imageUrl: string) => {
// Create a copy of the data
const newData = [...data];
// Get the current product
const product = newData[productIndex];
// Get the current image URLs or initialize as empty array
let currentUrls = product.image_url ?
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
: [];
// If it's not an array, convert to array
if (!Array.isArray(currentUrls)) {
currentUrls = [currentUrls];
}
// Filter out empty values
currentUrls = currentUrls.filter((url: string) => url);
// Add the new URL
currentUrls.push(imageUrl);
// Update the product
product.image_url = currentUrls.join(',');
// Update the data
newData[productIndex] = product;
// Return the updated data
return newData;
};
// Function to remove an image
const removeImage = async (imageIndex: number) => {
const image = productImages[imageIndex];
if (!image) return;
try {
// 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
const newData = [...data];
const product = newData[image.productIndex];
// Get current image URLs
let currentUrls = product.image_url ?
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
: [];
// Filter out empty values and the URL we're removing
currentUrls = currentUrls.filter((url: string) => url && url !== image.imageUrl);
// Update the product
product.image_url = currentUrls.join(',');
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'}`);
}
};
// Function to handle submit
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
await onSubmit(data, file);
} catch (error) {
console.error('Submit error:', error);
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSubmitting(false);
}
}, [data, file, onSubmit]);
// Get images for a specific product
const getProductImages = (productIndex: number) => {
return productImages.filter(img => img.productIndex === productIndex);
};
// 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 (
<div className="flex flex-col h-full">
<div className="p-4">
<h2 className="text-2xl font-semibold mb-2">Add Product Images</h2>
<p className="text-sm text-muted-foreground mb-2">
Upload images for each product. The images will be added to the Image URL field.
</p>
</div>
<Separator />
<ScrollArea className="flex-1 p-4">
<div className="space-y-2">
{data.map((product: any, index: number) => (
<Card key={index} className="p-3">
<CardContent className="p-0">
<div className="flex flex-col 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'} |
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
</div>
</div>
<div className="flex items-start gap-2">
{/* Dropzone for image upload always on the left */}
<ImageDropzone productIndex={index} />
{/* Images appear to the right of the dropzone in a scrollable container */}
<div className="flex flex-wrap gap-2 overflow-x-auto flex-1">
{getProductImages(index).map((image, imgIndex) => (
<div
key={`${index}-${imgIndex}`}
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0"
>
{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={`Product ${index + 1} - Image ${imgIndex + 1}`}
className="h-full w-full object-cover"
/>
<button
className="absolute top-1 right-1 bg-black/60 rounded-full p-0.5 text-white"
onClick={() => removeImage(productImages.findIndex(img =>
img.productIndex === index && img.imageUrl === image.imageUrl
))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
))}
</div>
{/* Hidden file input for backwards compatibility */}
<Input
ref={el => fileInputRefs.current[index] = el}
type="file"
className="hidden"
accept="image/*"
multiple
onChange={(e) => e.target.files && handleImageUpload(e.target.files, index)}
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
<Separator />
<div className="p-4 flex justify-between">
{onBack && (
<Button variant="outline" onClick={onBack}>
Back
</Button>
)}
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit
</Button>
</div>
</div>
);
};

View File

@@ -5,6 +5,7 @@ import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStep } from "./ValidationStep/ValidationStep"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
@@ -19,6 +20,7 @@ export enum StepType {
selectHeader = "selectHeader",
matchColumns = "matchColumns",
validateData = "validateData",
imageUpload = "imageUpload",
}
export type StepState =
@@ -45,6 +47,12 @@ export type StepState =
globalSelections?: GlobalSelections
isFromScratch?: boolean
}
| {
type: StepType.imageUpload
data: any[]
file: File
globalSelections?: GlobalSelections
}
interface Props {
state: StepState
@@ -62,7 +70,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
fields,
rowHook,
tableHook,
} = useRsi()
onSubmit } = useRsi()
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const { toast } = useToast()
const errorToast = useCallback(
@@ -83,11 +91,6 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
: undefined
)
const handleStartFromScratch = useCallback(() => {
if (onNext) {
onNext({ type: StepType.validateData, data: [{}], isFromScratch: true })
}
}, [onNext])
switch (state.type) {
case StepType.upload:
@@ -194,10 +197,36 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
onBack()
}
}}
onNext={(validatedData) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,
data: validatedData,
file: uploadedFile!,
globalSelections: state.globalSelections
});
}}
globalSelections={state.globalSelections}
isFromScratch={state.isFromScratch}
/>
)
case StepType.imageUpload:
return (
<ImageUploadStep
data={state.data}
file={state.file}
onBack={() => {
if (onBack) {
onNext({
type: StepType.validateData,
data: state.data,
globalSelections: state.globalSelections
})
}
}}
onSubmit={onSubmit}
/>
)
default:
return <Progress value={33} className="w-full" />
}

View File

@@ -2,11 +2,8 @@ import type XLSX from "xlsx"
import { useCallback, useState } from "react"
import { useRsi } from "../../hooks/useRsi"
import { DropZone } from "./components/DropZone"
import { ExampleTable } from "./components/ExampleTable"
import { FadingOverlay } from "./components/FadingOverlay"
import { StepType } from "../UploadFlow"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
type UploadProps = {
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>

View File

@@ -108,6 +108,7 @@ type Props<T extends string> = {
initialData: RowData<T>[]
file: File
onBack?: () => void
onNext?: (data: RowData<T>[]) => void
globalSelections?: GlobalSelections
isFromScratch?: boolean
}
@@ -1121,6 +1122,7 @@ export const ValidationStep = <T extends string>({
initialData,
file,
onBack,
onNext,
globalSelections,
isFromScratch
}: Props<T>) => {
@@ -1615,28 +1617,37 @@ export const ValidationStep = <T extends string>({
setShowSubmitAlert(false);
setSubmitting(true);
const response = onSubmit(result, file);
if (response?.then) {
response
.then(() => {
onClose();
})
.catch((err: Error) => {
const defaultMessage = translations.alerts.submitError.defaultMessage;
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred';
toast({
variant: "destructive",
title: translations.alerts.submitError.title,
description: String(err?.message || errorMessage),
});
})
.finally(() => {
setSubmitting(false);
});
// Check if onNext is provided (we're in the multi-step flow)
if (onNext) {
// Just pass the data to the next step
onNext(data);
setSubmitting(false);
} else {
onClose();
// Use the original submission flow
const response = onSubmit(result, file);
if (response?.then) {
response
.then(() => {
onClose();
})
.catch((err: Error) => {
const defaultMessage = translations.alerts.submitError.defaultMessage;
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred';
toast({
variant: "destructive",
title: translations.alerts.submitError.title,
description: String(err?.message || errorMessage),
});
})
.finally(() => {
setSubmitting(false);
});
} else {
onClose();
}
}
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations, globalSelections]);
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations, globalSelections, onNext]);
const onContinue = useCallback(() => {
const invalidData = data.find((value) => {
@@ -2302,11 +2313,9 @@ export const ValidationStep = <T extends string>({
<AlertDialogCancel>
{translations.alerts.submitIncomplete.cancelButtonTitle}
</AlertDialogCancel>
{allowInvalidSubmit && (
<AlertDialogAction onClick={submitData}>
{translations.alerts.submitIncomplete.finishButtonTitle}
</AlertDialogAction>
)}
<AlertDialogAction onClick={submitData}>
{translations.alerts.submitIncomplete.finishButtonTitle}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
@@ -2392,7 +2401,6 @@ export const ValidationStep = <T extends string>({
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
{aiValidationDetails.changeDetails.map((product, i) => {
// Find the title change if it exists
const titleChange = product.changes.find(change => change.field === 'title');
// Get the best title to display:
// 1. Use corrected title if available
@@ -2798,10 +2806,10 @@ export const ValidationStep = <T extends string>({
</div>
)
}
// Add TypeScript declaration for our global timer variable
declare global {
interface Window {
aiValidationTimer?: NodeJS.Timeout;
}
}

View File

@@ -40,13 +40,18 @@ export const translations = {
},
validationStep: {
title: "Validate data",
nextButtonTitle: "Confirm",
nextButtonTitle: "Next",
backButtonTitle: "Back",
noRowsMessage: "No data found",
noRowsMessageWhenFiltered: "No data containing errors",
discardButtonTitle: "Discard selected rows",
filterSwitchTitle: "Show only rows with errors",
},
imageUploadStep: {
title: "Add Product Images",
nextButtonTitle: "Submit",
backButtonTitle: "Back",
},
alerts: {
confirmClose: {
headerTitle: "Exit import flow",
@@ -55,11 +60,11 @@ export const translations = {
exitButtonTitle: "Exit flow",
},
submitIncomplete: {
headerTitle: "Errors detected",
bodyText: "There are still some rows that contain errors. Rows with errors will be ignored when submitting.",
bodyTextSubmitForbidden: "There are still some rows containing errors.",
cancelButtonTitle: "Cancel",
finishButtonTitle: "Submit",
headerTitle: "Validation Issues",
bodyText: "There are still some rows that contain errors. You can continue anyway, but rows with errors will be ignored when submitting.",
bodyTextSubmitForbidden: "There are still some rows containing errors. Would you like to fix them or continue anyway?",
cancelButtonTitle: "Go back",
finishButtonTitle: "Continue anyway",
},
submitError: {
title: "Error",
@@ -75,6 +80,10 @@ export const translations = {
toast: {
error: "Error",
},
validation: {
title: "Validation Issues",
description: "There are some validation issues that need to be addressed.",
},
},
}

View File

@@ -1,18 +1,20 @@
import { StepType } from "../steps/UploadFlow"
export const steps = ["uploadStep", "selectHeaderStep", "matchColumnsStep", "validationStep"] as const
export const steps = ["uploadStep", "selectHeaderStep", "matchColumnsStep", "validationStep", "imageUploadStep"] as const
const StepTypeToStepRecord: Record<StepType, (typeof steps)[number]> = {
[StepType.upload]: "uploadStep",
[StepType.selectSheet]: "uploadStep",
[StepType.selectHeader]: "selectHeaderStep",
[StepType.matchColumns]: "matchColumnsStep",
[StepType.validateData]: "validationStep",
[StepType.imageUpload]: "imageUploadStep",
}
const StepToStepTypeRecord: Record<(typeof steps)[number], StepType> = {
uploadStep: StepType.upload,
selectHeaderStep: StepType.selectHeader,
matchColumnsStep: StepType.matchColumns,
validationStep: StepType.validateData,
imageUploadStep: StepType.imageUpload,
}
export const stepIndexToStepType = (stepIndex: number) => {

View File

@@ -573,7 +573,7 @@ export function Import() {
<CardTitle>Preview Imported Data</CardTitle>
</CardHeader>
<CardContent>
<Code className="p-4 w-full rounded-md border">
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
{JSON.stringify(importedData, null, 2)}
</Code>
</CardContent>

6
inventory/src/types/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
// Global type definitions
// Add window properties
interface Window {
aiValidationTimer?: NodeJS.Timeout;
}

View File

@@ -120,6 +120,12 @@ export default defineConfig(({ mode }) => {
})
},
},
"/uploads": {
target: "https://inventory.kent.pw",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
},
},
build: {