Add image upload
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -50,6 +50,11 @@ dashboard-server/meta-server/._package-lock.json
|
||||
dashboard-server/meta-server/._services
|
||||
*.tsbuildinfo
|
||||
|
||||
uploads/*
|
||||
uploads/**/*
|
||||
**/uploads/*
|
||||
**/uploads/**/*
|
||||
|
||||
# CSV data files
|
||||
*.csv
|
||||
csv/*
|
||||
|
||||
@@ -2,6 +2,61 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Client } = require('ssh2');
|
||||
const mysql = require('mysql2/promise');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
console.log(`Saving to: ${uploadsDir}`);
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
// Create unique filename with original extension
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
|
||||
// Make sure we preserve the original file extension
|
||||
let fileExt = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
// Ensure there is a proper extension based on mimetype if none exists
|
||||
if (!fileExt) {
|
||||
switch (file.mimetype) {
|
||||
case 'image/jpeg': fileExt = '.jpg'; break;
|
||||
case 'image/png': fileExt = '.png'; break;
|
||||
case 'image/gif': fileExt = '.gif'; break;
|
||||
case 'image/webp': fileExt = '.webp'; break;
|
||||
default: fileExt = '.jpg'; // Default to jpg
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = `${req.body.upc || 'product'}-${uniqueSuffix}${fileExt}`;
|
||||
console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`);
|
||||
cb(null, fileName);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
// Accept only image files
|
||||
const filetypes = /jpeg|jpg|png|gif|webp/;
|
||||
const mimetype = filetypes.test(file.mimetype);
|
||||
const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Only image files are allowed'));
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to setup SSH tunnel
|
||||
async function setupSshTunnel() {
|
||||
@@ -47,6 +102,89 @@ async function setupSshTunnel() {
|
||||
});
|
||||
}
|
||||
|
||||
// Image upload endpoint
|
||||
router.post('/upload-image', upload.single('image'), (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image file provided' });
|
||||
}
|
||||
|
||||
// Log file information
|
||||
console.log('File uploaded:', {
|
||||
filename: req.file.filename,
|
||||
originalname: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
path: req.file.path
|
||||
});
|
||||
|
||||
// Ensure the file exists
|
||||
const filePath = path.join(uploadsDir, req.file.filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(500).json({ error: 'File was not saved correctly' });
|
||||
}
|
||||
|
||||
// Log file access permissions
|
||||
fs.access(filePath, fs.constants.R_OK, (err) => {
|
||||
if (err) {
|
||||
console.error('File permission issue:', err);
|
||||
} else {
|
||||
console.log('File is readable');
|
||||
}
|
||||
});
|
||||
|
||||
// Create URL for the uploaded file - using an absolute URL with domain
|
||||
// This will generate a URL like: https://inventory.acot.site/uploads/products/filename.jpg
|
||||
const baseUrl = 'https://inventory.acot.site';
|
||||
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
||||
|
||||
// Return success response with image URL
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
imageUrl,
|
||||
fileName: req.file.filename,
|
||||
mimetype: req.file.mimetype,
|
||||
fullPath: filePath,
|
||||
message: 'Image uploaded successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload image' });
|
||||
}
|
||||
});
|
||||
|
||||
// Image deletion endpoint
|
||||
router.delete('/delete-image', (req, res) => {
|
||||
try {
|
||||
const { filename } = req.body;
|
||||
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
// Return success response
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting image:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete image' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all options for import fields
|
||||
router.get('/field-options', async (req, res) => {
|
||||
let ssh;
|
||||
@@ -267,4 +405,90 @@ router.get('/sublines/:lineId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Add a simple endpoint to check file existence and permissions
|
||||
router.get('/check-file/:filename', (req, res) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
// Prevent directory traversal
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
error: 'File not found',
|
||||
path: filePath,
|
||||
exists: false,
|
||||
readable: false
|
||||
});
|
||||
}
|
||||
|
||||
// Check if file is readable
|
||||
fs.accessSync(filePath, fs.constants.R_OK);
|
||||
|
||||
// Get file stats
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return res.json({
|
||||
filename,
|
||||
path: filePath,
|
||||
exists: true,
|
||||
readable: true,
|
||||
isFile: stats.isFile(),
|
||||
isDirectory: stats.isDirectory(),
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime,
|
||||
permissions: stats.mode.toString(8)
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
error: error.message,
|
||||
path: filePath,
|
||||
exists: fs.existsSync(filePath),
|
||||
readable: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// List all files in uploads directory
|
||||
router.get('/list-uploads', (req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
return res.status(404).json({ error: 'Uploads directory not found', path: uploadsDir });
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(uploadsDir);
|
||||
const fileDetails = files.map(file => {
|
||||
const filePath = path.join(uploadsDir, file);
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
filename: file,
|
||||
isFile: stats.isFile(),
|
||||
isDirectory: stats.isDirectory(),
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime,
|
||||
permissions: stats.mode.toString(8)
|
||||
};
|
||||
} catch (error) {
|
||||
return { filename: file, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
directory: uploadsDir,
|
||||
count: files.length,
|
||||
files: fileDetails
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message, path: uploadsDir });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
6
inventory/src/types/globals.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
// Global type definitions
|
||||
|
||||
// Add window properties
|
||||
interface Window {
|
||||
aiValidationTimer?: NodeJS.Timeout;
|
||||
}
|
||||
@@ -120,6 +120,12 @@ export default defineConfig(({ mode }) => {
|
||||
})
|
||||
},
|
||||
},
|
||||
"/uploads": {
|
||||
target: "https://inventory.kent.pw",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user