diff --git a/inventory-server/db/setup-schema.sql b/inventory-server/db/setup-schema.sql index c344de4..6c57b1a 100644 --- a/inventory-server/db/setup-schema.sql +++ b/inventory-server/db/setup-schema.sql @@ -49,6 +49,30 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_system_prompt ON ai_prompts (prompt_type) WHERE prompt_type = 'system'; +-- Reusable Images table for storing persistent images +CREATE TABLE IF NOT EXISTS reusable_images ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + image_url TEXT NOT NULL, + is_global BOOLEAN NOT NULL DEFAULT false, + company TEXT, + mime_type TEXT, + file_size INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT company_required_for_non_global CHECK ( + (is_global = true AND company IS NULL) OR + (is_global = false AND company IS NOT NULL) + ) +); + +-- Create index on company for efficient querying +CREATE INDEX IF NOT EXISTS idx_reusable_images_company ON reusable_images(company); +-- Create index on is_global for efficient querying +CREATE INDEX IF NOT EXISTS idx_reusable_images_is_global ON reusable_images(is_global); + -- AI Validation Performance Tracking CREATE TABLE IF NOT EXISTS ai_validation_performance ( id SERIAL PRIMARY KEY, @@ -82,4 +106,10 @@ CREATE TRIGGER update_templates_updated_at CREATE TRIGGER update_ai_prompts_updated_at BEFORE UPDATE ON ai_prompts FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger to automatically update the updated_at column for reusable_images +CREATE TRIGGER update_reusable_images_updated_at + BEFORE UPDATE ON reusable_images + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/inventory-server/scripts/import/products.js b/inventory-server/scripts/import/products.js index f91619d..60762ef 100644 --- a/inventory-server/scripts/import/products.js +++ b/inventory-server/scripts/import/products.js @@ -146,8 +146,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid WHEN p.reorder < 0 THEN 0 WHEN ( (COALESCE(pls.date_sold, '0000-00-00') = '0000-00-00' OR pls.date_sold <= DATE_SUB(CURRENT_DATE, INTERVAL 5 YEAR)) - OR (p.datein = '0000-00-00 00:00:00' OR p.datein <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) - OR (p.date_refill = '0000-00-00 00:00:00' OR p.date_refill <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + AND (p.datein = '0000-00-00 00:00:00' OR p.datein <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + AND (p.date_refill = '0000-00-00 00:00:00' OR p.date_refill <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) ) THEN 0 ELSE 1 END AS replenishable, @@ -159,7 +159,11 @@ async function importMissingProducts(prodConnection, localConnection, missingPid COALESCE(p.sellingprice, 0) AS regular_price, CASE WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) - THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0) + THEN ( + SELECT ROUND(SUM(costeach * count) / SUM(count), 5) + FROM product_inventory + WHERE pid = p.pid AND count > 0 + ) ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) END AS cost_price, NULL as landing_cost_price, @@ -187,7 +191,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid p.country_of_origin, (SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets, (SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies, - p.totalsold AS total_sold, + (SELECT COALESCE(SUM(oi.qty_ordered), 0) FROM order_items oi WHERE oi.prod_pid = p.pid) AS total_sold, pls.date_sold as date_last_sold, GROUP_CONCAT(DISTINCT CASE WHEN pc.cat_id IS NOT NULL @@ -237,7 +241,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid row.pid, row.title, row.description, - row.itemnumber || '', + row.sku || '', row.stock_quantity > 5000 ? 0 : Math.max(0, row.stock_quantity), row.preorder_count, row.notions_inv_count, @@ -341,8 +345,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen WHEN p.reorder < 0 THEN 0 WHEN ( (COALESCE(pls.date_sold, '0000-00-00') = '0000-00-00' OR pls.date_sold <= DATE_SUB(CURRENT_DATE, INTERVAL 5 YEAR)) - OR (p.datein = '0000-00-00 00:00:00' OR p.datein <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) - OR (p.date_refill = '0000-00-00 00:00:00' OR p.date_refill <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + AND (p.datein = '0000-00-00 00:00:00' OR p.datein <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) + AND (p.date_refill = '0000-00-00 00:00:00' OR p.date_refill <= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 YEAR)) ) THEN 0 ELSE 1 END AS replenishable, @@ -354,7 +358,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen COALESCE(p.sellingprice, 0) AS regular_price, CASE WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) - THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0) + THEN ( + SELECT ROUND(SUM(costeach * count) / SUM(count), 5) + FROM product_inventory + WHERE pid = p.pid AND count > 0 + ) ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) END AS cost_price, NULL as landing_cost_price, @@ -382,7 +390,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen p.country_of_origin, (SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets, (SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies, - p.totalsold AS total_sold, + (SELECT COALESCE(SUM(oi.qty_ordered), 0) FROM order_items oi WHERE oi.prod_pid = p.pid) AS total_sold, pls.date_sold as date_last_sold, GROUP_CONCAT(DISTINCT CASE WHEN pc.cat_id IS NOT NULL @@ -436,7 +444,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen row.pid, row.title, row.description, - row.itemnumber || '', + row.sku || '', row.stock_quantity > 5000 ? 0 : Math.max(0, row.stock_quantity), row.preorder_count, row.notions_inv_count, diff --git a/inventory-server/src/routes/reusable-images.js b/inventory-server/src/routes/reusable-images.js new file mode 100644 index 0000000..e812ebb --- /dev/null +++ b/inventory-server/src/routes/reusable-images.js @@ -0,0 +1,396 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +// Create reusable uploads directory if it doesn't exist +const uploadsDir = path.join('/var/www/html/inventory/uploads/reusable'); +fs.mkdirSync(uploadsDir, { recursive: true }); + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + console.log(`Saving reusable image 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 = `reusable-${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')); + } +}); + +// Get all reusable images +router.get('/', async (req, res) => { + try { + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM reusable_images + ORDER BY created_at DESC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching reusable images:', error); + res.status(500).json({ + error: 'Failed to fetch reusable images', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get images by company or global images +router.get('/by-company/:companyId', async (req, res) => { + try { + const { companyId } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Get images that are either global or belong to this company + const result = await pool.query(` + SELECT * FROM reusable_images + WHERE is_global = true OR company = $1 + ORDER BY created_at DESC + `, [companyId]); + + res.json(result.rows); + } catch (error) { + console.error('Error fetching reusable images by company:', error); + res.status(500).json({ + error: 'Failed to fetch reusable images by company', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get global images only +router.get('/global', async (req, res) => { + try { + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM reusable_images + WHERE is_global = true + ORDER BY created_at DESC + `); + + res.json(result.rows); + } catch (error) { + console.error('Error fetching global reusable images:', error); + res.status(500).json({ + error: 'Failed to fetch global reusable images', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get a single image by ID +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + SELECT * FROM reusable_images + WHERE id = $1 + `, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Reusable image not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching reusable image:', error); + res.status(500).json({ + error: 'Failed to fetch reusable image', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Upload a new reusable image +router.post('/upload', upload.single('image'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No image file provided' }); + } + + const { name, is_global, company } = req.body; + + // Validate required fields + if (!name) { + return res.status(400).json({ error: 'Image name is required' }); + } + + // Convert is_global from string to boolean + const isGlobal = is_global === 'true' || is_global === true; + + // Validate company is provided for non-global images + if (!isGlobal && !company) { + return res.status(400).json({ error: 'Company is required for non-global images' }); + } + + // Log file information + console.log('Reusable image 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' }); + } + + // Create URL for the uploaded file + const baseUrl = 'https://inventory.acot.site'; + const imageUrl = `${baseUrl}/uploads/reusable/${req.file.filename}`; + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Insert record into database + const result = await pool.query(` + INSERT INTO reusable_images ( + name, + filename, + file_path, + image_url, + is_global, + company, + mime_type, + file_size + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `, [ + name, + req.file.filename, + filePath, + imageUrl, + isGlobal, + isGlobal ? null : company, + req.file.mimetype, + req.file.size + ]); + + // Return success response with image data + res.status(201).json({ + success: true, + image: result.rows[0], + message: 'Image uploaded successfully' + }); + + } catch (error) { + console.error('Error uploading reusable image:', error); + res.status(500).json({ error: error.message || 'Failed to upload image' }); + } +}); + +// Update image details (name, is_global, company) +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const { name, is_global, company } = req.body; + + // Validate required fields + if (!name) { + return res.status(400).json({ error: 'Image name is required' }); + } + + // Convert is_global from string to boolean if necessary + const isGlobal = typeof is_global === 'string' ? is_global === 'true' : !!is_global; + + // Validate company is provided for non-global images + if (!isGlobal && !company) { + return res.status(400).json({ error: 'Company is required for non-global images' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Check if the image exists + const checkResult = await pool.query('SELECT * FROM reusable_images WHERE id = $1', [id]); + if (checkResult.rows.length === 0) { + return res.status(404).json({ error: 'Reusable image not found' }); + } + + const result = await pool.query(` + UPDATE reusable_images + SET + name = $1, + is_global = $2, + company = $3 + WHERE id = $4 + RETURNING * + `, [ + name, + isGlobal, + isGlobal ? null : company, + id + ]); + + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating reusable image:', error); + res.status(500).json({ + error: 'Failed to update reusable image', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Delete a reusable image +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + // Get the image data first to get the filename + const imageResult = await pool.query('SELECT * FROM reusable_images WHERE id = $1', [id]); + + if (imageResult.rows.length === 0) { + return res.status(404).json({ error: 'Reusable image not found' }); + } + + const image = imageResult.rows[0]; + + // Delete from database + await pool.query('DELETE FROM reusable_images WHERE id = $1', [id]); + + // Delete the file from filesystem + const filePath = path.join(uploadsDir, image.filename); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + res.json({ + message: 'Reusable image deleted successfully', + image + }); + } catch (error) { + console.error('Error deleting reusable image:', error); + res.status(500).json({ + error: 'Failed to delete reusable image', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Check if file exists 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 + }); + } +}); + +// Error handling middleware +router.use((err, req, res, next) => { + console.error('Reusable images route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index d09dbc6..e9ad7cb 100755 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -19,6 +19,7 @@ const importRouter = require('./routes/import'); const aiValidationRouter = require('./routes/ai-validation'); const templatesRouter = require('./routes/templates'); const aiPromptsRouter = require('./routes/ai-prompts'); +const reusableImagesRouter = require('./routes/reusable-images'); // Get the absolute path to the .env file const envPath = '/var/www/html/inventory/.env'; @@ -105,6 +106,7 @@ async function startServer() { app.use('/api/ai-validation', aiValidationRouter); app.use('/api/templates', templatesRouter); app.use('/api/ai-prompts', aiPromptsRouter); + app.use('/api/reusable-images', reusableImagesRouter); // Basic health check route app.get('/health', (req, res) => { diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx index 9a6594d..d8c650b 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx @@ -253,6 +253,7 @@ export const ImageUploadStep = ({ } getProductContainerClasses={() => getProductContainerClasses(index)} findContainer={findContainer} + handleAddImageFromUrl={handleAddImageFromUrl} /> ))} diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx index a39e3f7..6e00b15 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ProductCard.tsx @@ -1,7 +1,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Link as LinkIcon } from "lucide-react"; +import { Loader2, Link as LinkIcon, Image as ImageIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { ImageDropzone } from "./ImageDropzone"; import { SortableImage } from "./SortableImage"; @@ -9,6 +9,25 @@ import { CopyButton } from "./CopyButton"; import { ProductImageSortable, Product } from "../../types"; import { DroppableContainer } from "../DroppableContainer"; import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'; +import { useQuery } from "@tanstack/react-query"; +import config from "@/config"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useState, useMemo } from "react"; + +interface ReusableImage { + id: number; + name: string; + image_url: string; + is_global: boolean; + company: string | null; +} interface ProductCardProps { product: Product; @@ -26,6 +45,7 @@ interface ProductCardProps { onRemoveImage: (id: string) => void; getProductContainerClasses: () => string; findContainer: (id: string) => string | null; + handleAddImageFromUrl: (productIndex: number, url: string) => void; } export const ProductCard = ({ @@ -43,8 +63,11 @@ export const ProductCard = ({ onDragOver, onRemoveImage, getProductContainerClasses, - findContainer + findContainer, + handleAddImageFromUrl }: ProductCardProps) => { + const [isReusableDialogOpen, setIsReusableDialogOpen] = useState(false); + // Function to get images for this product const getProductImages = () => { return productImages.filter(img => img.productIndex === index); @@ -56,6 +79,32 @@ export const ProductCard = ({ return result !== null ? parseInt(result) : null; }; + // Fetch reusable images + const { data: reusableImages, isLoading: isLoadingReusable } = useQuery({ + queryKey: ["reusable-images"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/reusable-images`); + if (!response.ok) { + throw new Error("Failed to fetch reusable images"); + } + return response.json(); + }, + }); + + // Filter reusable images based on product's company + const availableReusableImages = useMemo(() => { + if (!reusableImages) return []; + return reusableImages.filter(img => + img.is_global || img.company === product.company + ); + }, [reusableImages, product.company]); + + // Handle adding a reusable image + const handleAddReusableImage = (imageUrl: string) => { + handleAddImageFromUrl(index, imageUrl); + setIsReusableDialogOpen(false); + }; + return ( + {getProductImages().length === 0 && ( + + )}
-
+
+ + {/* Reusable Images Dialog */} + + + + Select from Image Library + + Choose a global or company-specific image to add to this product. + + + + {isLoadingReusable ? ( +
+ +
+ ) : availableReusableImages.length === 0 ? ( +
+ +

No reusable images available

+
+ ) : ( +
+ {availableReusableImages.map((image) => ( +
handleAddReusableImage(image.image_url)} + > + {image.name} +
+
+

{image.name}

+
+
+ ))} +
+ )} + + +
); }; \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/types.ts b/inventory/src/components/product-import/steps/ImageUploadStep/types.ts index 17f9b23..eee0f3c 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/types.ts +++ b/inventory/src/components/product-import/steps/ImageUploadStep/types.ts @@ -31,5 +31,6 @@ export interface Product { supplier_no?: string; sku?: string; model?: string; + company?: string; product_images?: string | string[]; } \ No newline at end of file diff --git a/inventory/src/components/settings/ReusableImageManagement.tsx b/inventory/src/components/settings/ReusableImageManagement.tsx new file mode 100644 index 0000000..c9708a1 --- /dev/null +++ b/inventory/src/components/settings/ReusableImageManagement.tsx @@ -0,0 +1,773 @@ +import { useState, useMemo, useCallback, useRef, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ArrowUpDown, Pencil, Trash2, PlusCircle, Image, Eye } from "lucide-react"; +import config from "@/config"; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + SortingState, + flexRender, + type ColumnDef, +} from "@tanstack/react-table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogClose +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; +import { useDropzone } from "react-dropzone"; +import { cn } from "@/lib/utils"; + +interface FieldOption { + label: string; + value: string; +} + +interface ImageFormData { + id?: number; + name: string; + is_global: boolean; + company: string | null; + file?: File; +} + +interface ReusableImage { + id: number; + name: string; + filename: string; + file_path: string; + image_url: string; + is_global: boolean; + company: string | null; + mime_type: string; + file_size: number; + created_at: string; + updated_at: string; +} + +interface FieldOptions { + companies: FieldOption[]; +} + +const ImageForm = ({ + editingImage, + formData, + setFormData, + onSubmit, + onCancel, + fieldOptions, + getRootProps, + getInputProps, + isDragActive +}: { + editingImage: ReusableImage | null; + formData: ImageFormData; + setFormData: (data: ImageFormData) => void; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + fieldOptions: FieldOptions | undefined; + getRootProps: any; + getInputProps: any; + isDragActive: boolean; +}) => { + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setFormData(prev => ({ ...prev, name: e.target.value })); + }, [setFormData]); + + const handleGlobalChange = useCallback((checked: boolean) => { + setFormData(prev => ({ + ...prev, + is_global: checked, + company: checked ? null : prev.company + })); + }, [setFormData]); + + const handleCompanyChange = useCallback((value: string) => { + setFormData(prev => ({ ...prev, company: value })); + }, [setFormData]); + + return ( +
+
+
+ + +
+ + {!editingImage && ( +
+ +
+ +
+ {formData.file ? ( + <> +
+ +
+
+ + {formData.file.name} +
+

Click or drag to replace

+ + ) : isDragActive ? ( + <> + +

Drop image here

+ + ) : ( + <> + +

Click or drag to upload

+ + )} +
+
+
+ )} + +
+ + +
+ + {!formData.is_global && ( +
+ + +
+ )} +
+ + + + + +
+ ); +}; + +export function ReusableImageManagement() { + const [isFormOpen, setIsFormOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [imageToDelete, setImageToDelete] = useState(null); + const [previewImage, setPreviewImage] = useState(null); + const [editingImage, setEditingImage] = useState(null); + const [sorting, setSorting] = useState([ + { id: "created_at", desc: true } + ]); + const [searchQuery, setSearchQuery] = useState(""); + const [formData, setFormData] = useState({ + name: "", + is_global: false, + company: null, + file: undefined + }); + + const queryClient = useQueryClient(); + + const { data: images, isLoading } = useQuery({ + queryKey: ["reusable-images"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/reusable-images`); + if (!response.ok) { + throw new Error("Failed to fetch reusable images"); + } + return response.json(); + }, + }); + + const { data: fieldOptions } = useQuery({ + queryKey: ["fieldOptions"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/import/field-options`); + if (!response.ok) { + throw new Error("Failed to fetch field options"); + } + return response.json(); + }, + }); + + const createMutation = useMutation({ + mutationFn: async (data: ImageFormData) => { + // Create FormData for file upload + const formData = new FormData(); + formData.append('name', data.name); + formData.append('is_global', String(data.is_global)); + + if (!data.is_global && data.company) { + formData.append('company', data.company); + } + + if (data.file) { + formData.append('image', data.file); + } else { + throw new Error("Image file is required"); + } + + const response = await fetch(`${config.apiUrl}/reusable-images/upload`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || error.error || "Failed to upload image"); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["reusable-images"] }); + toast.success("Image uploaded successfully"); + resetForm(); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to upload image"); + }, + }); + + const updateMutation = useMutation({ + mutationFn: async (data: ImageFormData) => { + if (!data.id) throw new Error("Image ID is required for update"); + + const response = await fetch(`${config.apiUrl}/reusable-images/${data.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: data.name, + is_global: data.is_global, + company: data.is_global ? null : data.company + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || error.error || "Failed to update image"); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["reusable-images"] }); + toast.success("Image updated successfully"); + resetForm(); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to update image"); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + const response = await fetch(`${config.apiUrl}/reusable-images/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("Failed to delete image"); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["reusable-images"] }); + toast.success("Image deleted successfully"); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to delete image"); + }, + }); + + const handleEdit = (image: ReusableImage) => { + setEditingImage(image); + setFormData({ + id: image.id, + name: image.name, + is_global: image.is_global, + company: image.company, + }); + setIsFormOpen(true); + }; + + const handleDeleteClick = (image: ReusableImage) => { + setImageToDelete(image); + setIsDeleteOpen(true); + }; + + const handlePreview = (image: ReusableImage) => { + setPreviewImage(image); + setIsPreviewOpen(true); + }; + + const handleDeleteConfirm = () => { + if (imageToDelete) { + deleteMutation.mutate(imageToDelete.id); + setIsDeleteOpen(false); + setImageToDelete(null); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + // If is_global is true, ensure company is null + const submitData = { + ...formData, + company: formData.is_global ? null : formData.company, + }; + + if (editingImage) { + updateMutation.mutate(submitData); + } else { + if (!submitData.file) { + toast.error("Please select an image file"); + return; + } + createMutation.mutate(submitData); + } + }; + + const resetForm = () => { + setFormData({ + name: "", + is_global: false, + company: null, + file: undefined + }); + setEditingImage(null); + setIsFormOpen(false); + }; + + const handleCreateClick = () => { + resetForm(); + setIsFormOpen(true); + }; + + // Configure dropzone for image uploads + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; // Take only the first file + setFormData(prev => ({ + ...prev, + file + })); + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { + 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] + }, + onDrop, + multiple: false // Only accept single files + }); + + const columns = useMemo[]>(() => [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "is_global", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isGlobal = row.getValue("is_global") as boolean; + return isGlobal ? "Global" : "Company Specific"; + }, + }, + { + accessorKey: "company", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isGlobal = row.getValue("is_global") as boolean; + if (isGlobal) return 'N/A'; + + const companyId = row.getValue("company"); + if (!companyId) return 'None'; + return fieldOptions?.companies.find(c => c.value === companyId)?.label || companyId; + }, + }, + { + accessorKey: "file_size", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const size = row.getValue("file_size") as number; + return `${(size / 1024).toFixed(1)} KB`; + }, + }, + { + accessorKey: "created_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => new Date(row.getValue("created_at")).toLocaleDateString(), + }, + { + accessorKey: "image_url", + header: "Thumbnail", + cell: ({ row }) => ( +
+ {row.getValue("name") +
+ ), + }, + { + id: "actions", + cell: ({ row }) => ( +
+ + + +
+ ), + }, + ], [fieldOptions]); + + const filteredData = useMemo(() => { + if (!images) return []; + return images.filter((image) => { + const searchString = searchQuery.toLowerCase(); + return ( + image.name.toLowerCase().includes(searchString) || + (image.is_global ? "global" : "company").includes(searchString) || + (image.company && image.company.toLowerCase().includes(searchString)) + ); + }); + }, [images, searchQuery]); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + }, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+
+

Reusable Images

+ +
+ +
+ setSearchQuery(e.target.value)} + className="max-w-sm" + /> +
+ + {isLoading ? ( +
Loading images...
+ ) : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No images found + + + )} + +
+
+ )} + + {/* Image Form Dialog */} + + + + {editingImage ? "Edit Image" : "Upload New Image"} + + {editingImage + ? "Update this reusable image's details." + : "Upload a new reusable image that can be used across products."} + + + + { + resetForm(); + setIsFormOpen(false); + }} + fieldOptions={fieldOptions} + getRootProps={getRootProps} + getInputProps={getInputProps} + isDragActive={isDragActive} + /> + + + + {/* Delete Confirmation Dialog */} + + + + Delete Image + + Are you sure you want to delete this image? This action cannot be undone. + + + + { + setIsDeleteOpen(false); + setImageToDelete(null); + }}> + Cancel + + + Delete + + + + + + {/* Preview Dialog */} + + + + {previewImage?.name} + + {previewImage?.is_global + ? "Global image" + : `Company specific image for ${fieldOptions?.companies.find(c => c.value === previewImage?.company)?.label}`} + + + +
+ {previewImage && ( +
+ {previewImage.name} +
+ )} +
+ +
+
+ Filename: {previewImage?.filename} +
+
+ Size: {previewImage && `${(previewImage.file_size / 1024).toFixed(1)} KB`} +
+
+ Type: {previewImage?.mime_type} +
+
+ Uploaded: {previewImage && new Date(previewImage.created_at).toLocaleString()} +
+
+ + + + + + +
+
+ + +
+ ); +} + +const ImagePreview = ({ file }: { file: File }) => { + const [previewUrl, setPreviewUrl] = useState(''); + + useEffect(() => { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [file]); + + return ( + Preview + ); +}; \ No newline at end of file diff --git a/inventory/src/pages/Settings.tsx b/inventory/src/pages/Settings.tsx index 1a414a6..cfb5cf1 100644 --- a/inventory/src/pages/Settings.tsx +++ b/inventory/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { CalculationSettings } from "@/components/settings/CalculationSettings"; import { TemplateManagement } from "@/components/settings/TemplateManagement"; import { UserManagement } from "@/components/settings/UserManagement"; import { PromptManagement } from "@/components/settings/PromptManagement"; +import { ReusableImageManagement } from "@/components/settings/ReusableImageManagement"; import { motion } from 'framer-motion'; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Protected } from "@/components/auth/Protected"; @@ -42,7 +43,8 @@ const SETTINGS_GROUPS: SettingsGroup[] = [ label: "Content Management", tabs: [ { id: "templates", permission: "settings:templates", label: "Template Management" }, - { id: "ai-prompts", permission: "settings:templates", label: "AI Prompts" }, + { id: "ai-prompts", permission: "settings:prompt_management", label: "AI Prompts" }, + { id: "reusable-images", permission: "settings:library_management", label: "Reusable Images" }, ] }, { @@ -220,7 +222,7 @@ export function Settings() { @@ -233,6 +235,21 @@ export function Settings() { + + + + You don't have permission to access Reusable Images. + + + } + > + + + +