From 42af434bd7b8fadcdf29b0042f44bf1340ff00ea Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 26 Feb 2025 16:15:18 -0500 Subject: [PATCH] Add image upload --- .gitignore | 5 + inventory-server/src/routes/import.js | 224 +++++++++++ .../steps/ImageUploadStep/ImageUploadStep.tsx | 350 ++++++++++++++++++ .../src/steps/UploadFlow.tsx | 41 +- .../src/steps/UploadStep/UploadStep.tsx | 3 - .../steps/ValidationStep/ValidationStep.tsx | 62 ++-- .../src/translationsRSIProps.ts | 21 +- .../src/utils/steps.ts | 4 +- inventory/src/pages/Import.tsx | 2 +- inventory/src/types/globals.d.ts | 6 + inventory/vite.config.ts | 6 + 11 files changed, 680 insertions(+), 44 deletions(-) create mode 100644 inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx create mode 100644 inventory/src/types/globals.d.ts diff --git a/.gitignore b/.gitignore index 22495cf..a8f8540 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 09b9df6..cde2136 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -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; \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx new file mode 100644 index 0000000..63d6fcf --- /dev/null +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx @@ -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 = { + data: any[]; + file: File; + onBack?: () => void; + onSubmit: (data: any[], file: File) => void | Promise; +} + +type ProductImage = { + productIndex: number; + imageUrl: string; + loading: boolean; + fileName: string; +} + +export const ImageUploadStep = ({ + data, + file, + onBack, + onSubmit +}: Props) => { + const { translations } = useRsi(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [productImages, setProductImages] = useState([]); + 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 ( +
+ + {isDragActive ? ( +
Drop images here
+ ) : ( + <> + + Add Images + + )} +
+ ); + }; + + // 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 ( +
+
+

Add Product Images

+

+ Upload images for each product. The images will be added to the Image URL field. +

+
+ + + + +
+ {data.map((product: any, index: number) => ( + + +
+
+

{product.name || `Product #${index + 1}`}

+
+ UPC: {product.upc || 'N/A'} | + Supplier #: {product.supplier_no || 'N/A'} +
+
+ +
+ {/* Dropzone for image upload always on the left */} + + + {/* Images appear to the right of the dropzone in a scrollable container */} +
+ {getProductImages(index).map((image, imgIndex) => ( +
+ {image.loading ? ( +
+ + {image.fileName} +
+ ) : ( + <> + {`Product + + + )} +
+ ))} +
+ + {/* Hidden file input for backwards compatibility */} + fileInputRefs.current[index] = el} + type="file" + className="hidden" + accept="image/*" + multiple + onChange={(e) => e.target.files && handleImageUpload(e.target.files, index)} + /> +
+
+
+
+ ))} +
+
+ + + +
+ {onBack && ( + + )} + +
+
+ ); +}; \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx index 5e913f6..37e51b9 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx @@ -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(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 ( + { + if (onBack) { + onNext({ + type: StepType.validateData, + data: state.data, + globalSelections: state.globalSelections + }) + } + }} + onSubmit={onSubmit} + /> + ) default: return } diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/UploadStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/UploadStep.tsx index 23e0f2b..7968f10 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/UploadStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadStep/UploadStep.tsx @@ -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 diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx index 5f5214b..081ca2b 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx @@ -108,6 +108,7 @@ type Props = { initialData: RowData[] file: File onBack?: () => void + onNext?: (data: RowData[]) => void globalSelections?: GlobalSelections isFromScratch?: boolean } @@ -1121,6 +1122,7 @@ export const ValidationStep = ({ initialData, file, onBack, + onNext, globalSelections, isFromScratch }: Props) => { @@ -1615,28 +1617,37 @@ export const ValidationStep = ({ 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 = ({ {translations.alerts.submitIncomplete.cancelButtonTitle} - {allowInvalidSubmit && ( - - {translations.alerts.submitIncomplete.finishButtonTitle} - - )} + + {translations.alerts.submitIncomplete.finishButtonTitle} + @@ -2392,7 +2401,6 @@ export const ValidationStep = ({

Detailed Changes:

{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 = ({ ) } - // Add TypeScript declaration for our global timer variable declare global { interface Window { aiValidationTimer?: NodeJS.Timeout; } } + diff --git a/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts b/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts index e6cf9a2..c76c7bd 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/translationsRSIProps.ts @@ -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.", + }, }, } diff --git a/inventory/src/lib/react-spreadsheet-import/src/utils/steps.ts b/inventory/src/lib/react-spreadsheet-import/src/utils/steps.ts index a862b02..3e8e7e3 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/utils/steps.ts +++ b/inventory/src/lib/react-spreadsheet-import/src/utils/steps.ts @@ -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.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) => { diff --git a/inventory/src/pages/Import.tsx b/inventory/src/pages/Import.tsx index a33b711..b9b4bab 100644 --- a/inventory/src/pages/Import.tsx +++ b/inventory/src/pages/Import.tsx @@ -573,7 +573,7 @@ export function Import() { Preview Imported Data - + {JSON.stringify(importedData, null, 2)} diff --git a/inventory/src/types/globals.d.ts b/inventory/src/types/globals.d.ts new file mode 100644 index 0000000..b9b9f40 --- /dev/null +++ b/inventory/src/types/globals.d.ts @@ -0,0 +1,6 @@ +// Global type definitions + +// Add window properties +interface Window { + aiValidationTimer?: NodeJS.Timeout; +} \ No newline at end of file diff --git a/inventory/vite.config.ts b/inventory/vite.config.ts index 752a045..43525a4 100644 --- a/inventory/vite.config.ts +++ b/inventory/vite.config.ts @@ -120,6 +120,12 @@ export default defineConfig(({ mode }) => { }) }, }, + "/uploads": { + target: "https://inventory.kent.pw", + changeOrigin: true, + secure: false, + rewrite: (path) => path, + }, }, }, build: {