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

5
.gitignore vendored
View File

@@ -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/*

View File

@@ -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;

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: {