Add in image library feature
This commit is contained in:
@@ -253,6 +253,7 @@ export const ImageUploadStep = ({
|
||||
}
|
||||
getProductContainerClasses={() => getProductContainerClasses(index)}
|
||||
findContainer={findContainer}
|
||||
handleAddImageFromUrl={handleAddImageFromUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -31,5 +31,6 @@ export interface Product {
|
||||
supplier_no?: string;
|
||||
sku?: string;
|
||||
model?: string;
|
||||
company?: string;
|
||||
product_images?: string | string[];
|
||||
}
|
||||
773
inventory/src/components/settings/ReusableImageManagement.tsx
Normal file
773
inventory/src/components/settings/ReusableImageManagement.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user