Add in image library feature

This commit is contained in:
2025-03-25 12:14:36 -04:00
parent 00a02aa788
commit 517bbe72f4
9 changed files with 1348 additions and 15 deletions

View File

@@ -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,
@@ -83,3 +107,9 @@ 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();

View File

@@ -142,8 +142,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,
@@ -155,7 +155,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,
@@ -183,7 +187,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
@@ -233,7 +237,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,
@@ -337,8 +341,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,
@@ -350,7 +354,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,
@@ -378,7 +386,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
@@ -432,7 +440,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,

View File

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

View File

@@ -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) => {

View File

@@ -253,6 +253,7 @@ export const ImageUploadStep = ({
}
getProductContainerClasses={() => getProductContainerClasses(index)}
findContainer={findContainer}
handleAddImageFromUrl={handleAddImageFromUrl}
/>
))}
</div>

View File

@@ -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<ReusableImage[]>({
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 (
<Card
className={cn(
@@ -83,6 +132,18 @@ export const ProductCard = ({
className="flex items-center gap-2"
onSubmit={onUrlSubmit}
>
{getProductImages().length === 0 && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 whitespace-nowrap flex gap-1 items-center text-xs"
onClick={() => setIsReusableDialogOpen(true)}
>
<ImageIcon className="h-3.5 w-3.5" />
Select from Library
</Button>
)}
<Input
placeholder="Add image from URL"
value={urlInput}
@@ -105,7 +166,7 @@ export const ProductCard = ({
</div>
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex flex-row gap-2 items-start">
<div className="flex flex-row gap-2 items-center gap-4">
<ImageDropzone
productIndex={index}
onDrop={onImageUpload}
@@ -158,6 +219,50 @@ export const ProductCard = ({
/>
</div>
</CardContent>
{/* Reusable Images Dialog */}
<Dialog open={isReusableDialogOpen} onOpenChange={setIsReusableDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Select from Image Library</DialogTitle>
<DialogDescription>
Choose a global or company-specific image to add to this product.
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[400px] pr-4">
{isLoadingReusable ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : availableReusableImages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<ImageIcon className="h-8 w-8 mb-2" />
<p>No reusable images available</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{availableReusableImages.map((image) => (
<div
key={image.id}
className="group relative aspect-square border rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary"
onClick={() => handleAddReusableImage(image.image_url)}
>
<img
src={image.image_url}
alt={image.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<p className="text-xs text-white truncate">{image.name}</p>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</Card>
);
};

View File

@@ -31,5 +31,6 @@ export interface Product {
supplier_no?: string;
sku?: string;
model?: string;
company?: string;
product_images?: string | string[];
}

View File

@@ -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<HTMLInputElement>) => {
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 (
<form onSubmit={onSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="image_name">Image Name</Label>
<Input
id="image_name"
name="image_name"
value={formData.name}
onChange={handleNameChange}
placeholder="Enter image name"
required
/>
</div>
{!editingImage && (
<div className="grid gap-2">
<Label htmlFor="image">Upload Image</Label>
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed border-secondary-foreground/30 bg-muted/90 rounded-md w-full py-6 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/70 transition-colors",
isDragActive && "border-primary bg-muted"
)}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center justify-center py-2">
{formData.file ? (
<>
<div className="mb-4">
<ImagePreview file={formData.file} />
</div>
<div className="flex items-center gap-2 mb-2">
<Image className="h-4 w-4 text-primary" />
<span className="text-sm">{formData.file.name}</span>
</div>
<p className="text-xs text-muted-foreground">Click or drag to replace</p>
</>
) : isDragActive ? (
<>
<Image className="h-8 w-8 mb-2 text-primary" />
<p className="text-base text-muted-foreground">Drop image here</p>
</>
) : (
<>
<Image className="h-8 w-8 mb-2 text-muted-foreground" />
<p className="text-base text-muted-foreground">Click or drag to upload</p>
</>
)}
</div>
</div>
</div>
)}
<div className="flex items-center space-x-2">
<Checkbox
id="is_global"
checked={formData.is_global}
onCheckedChange={handleGlobalChange}
/>
<Label htmlFor="is_global">Available for all companies</Label>
</div>
{!formData.is_global && (
<div className="grid gap-2">
<Label htmlFor="company">Company</Label>
<Select
value={formData.company || ''}
onValueChange={handleCompanyChange}
required={!formData.is_global}
>
<SelectTrigger>
<SelectValue placeholder="Select company" />
</SelectTrigger>
<SelectContent>
{fieldOptions?.companies.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">
{editingImage ? "Update" : "Upload"} Image
</Button>
</DialogFooter>
</form>
);
};
export function ReusableImageManagement() {
const [isFormOpen, setIsFormOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [imageToDelete, setImageToDelete] = useState<ReusableImage | null>(null);
const [previewImage, setPreviewImage] = useState<ReusableImage | null>(null);
const [editingImage, setEditingImage] = useState<ReusableImage | null>(null);
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true }
]);
const [searchQuery, setSearchQuery] = useState("");
const [formData, setFormData] = useState<ImageFormData>({
name: "",
is_global: false,
company: null,
file: undefined
});
const queryClient = useQueryClient();
const { data: images, isLoading } = useQuery<ReusableImage[]>({
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<FieldOptions>({
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<ColumnDef<ReusableImage>[]>(() => [
{
accessorKey: "name",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "is_global",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const isGlobal = row.getValue("is_global") as boolean;
return isGlobal ? "Global" : "Company Specific";
},
},
{
accessorKey: "company",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Company
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
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 }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Size
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const size = row.getValue("file_size") as number;
return `${(size / 1024).toFixed(1)} KB`;
},
},
{
accessorKey: "created_at",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => new Date(row.getValue("created_at")).toLocaleDateString(),
},
{
accessorKey: "image_url",
header: "Thumbnail",
cell: ({ row }) => (
<div className="flex items-center justify-center">
<img
src={row.getValue("image_url") as string}
alt={row.getValue("name") as string}
className="w-10 h-10 object-contain border rounded"
/>
</div>
),
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex gap-2 justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => handlePreview(row.original)}
title="Preview Image"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(row.original)}
title="Edit Image"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(row.original)}
title="Delete Image"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
),
},
], [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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Reusable Images</h2>
<Button onClick={handleCreateClick}>
<PlusCircle className="mr-2 h-4 w-4" />
Upload New Image
</Button>
</div>
<div className="flex items-center gap-4">
<Input
placeholder="Search images..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm"
/>
</div>
{isLoading ? (
<div>Loading images...</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader className="bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-100">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="pl-6">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
No images found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{/* Image Form Dialog */}
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingImage ? "Edit Image" : "Upload New Image"}</DialogTitle>
<DialogDescription>
{editingImage
? "Update this reusable image's details."
: "Upload a new reusable image that can be used across products."}
</DialogDescription>
</DialogHeader>
<ImageForm
editingImage={editingImage}
formData={formData}
setFormData={setFormData}
onSubmit={handleSubmit}
onCancel={() => {
resetForm();
setIsFormOpen(false);
}}
fieldOptions={fieldOptions}
getRootProps={getRootProps}
getInputProps={getInputProps}
isDragActive={isDragActive}
/>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Image</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this image? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteOpen(false);
setImageToDelete(null);
}}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Preview Dialog */}
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{previewImage?.name}</DialogTitle>
<DialogDescription>
{previewImage?.is_global
? "Global image"
: `Company specific image for ${fieldOptions?.companies.find(c => c.value === previewImage?.company)?.label}`}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center p-4">
{previewImage && (
<div className="bg-checkerboard rounded-md overflow-hidden">
<img
src={previewImage.image_url}
alt={previewImage.name}
className="max-h-[500px] max-w-full object-contain"
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Filename:</span> {previewImage?.filename}
</div>
<div>
<span className="font-medium">Size:</span> {previewImage && `${(previewImage.file_size / 1024).toFixed(1)} KB`}
</div>
<div>
<span className="font-medium">Type:</span> {previewImage?.mime_type}
</div>
<div>
<span className="font-medium">Uploaded:</span> {previewImage && new Date(previewImage.created_at).toLocaleString()}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button>Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<style jsx global>{`
.bg-checkerboard {
background-image: linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
`}</style>
</div>
);
}
const ImagePreview = ({ file }: { file: File }) => {
const [previewUrl, setPreviewUrl] = useState<string>('');
useEffect(() => {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [file]);
return (
<img
src={previewUrl}
alt="Preview"
className="max-h-32 max-w-full object-contain rounded-md"
/>
);
};

View File

@@ -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() {
<TabsContent value="ai-prompts" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:templates"
permission="settings:prompt_management"
fallback={
<Alert>
<AlertDescription>
@@ -233,6 +235,21 @@ export function Settings() {
</Protected>
</TabsContent>
<TabsContent value="reusable-images" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:library_management"
fallback={
<Alert>
<AlertDescription>
You don't have permission to access Reusable Images.
</AlertDescription>
</Alert>
}
>
<ReusableImageManagement />
</Protected>
</TabsContent>
<TabsContent value="user-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:user_management"