Merge remote-tracking branch 'origin/dev' into feature/inam-platform-core-setup
This commit is contained in:
@@ -19,6 +19,7 @@ interface CategoryNode {
|
||||
interface CategoryTreeSelectProps {
|
||||
value?: string;
|
||||
onChange: (categoryId: string) => void;
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
@@ -60,6 +61,7 @@ function buildCategoryTree(categories: any[]): CategoryNode[] {
|
||||
export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
placeholder = 'Select Category...',
|
||||
error,
|
||||
disabled = false,
|
||||
@@ -68,7 +70,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
|
||||
|
||||
// Quick Create Drawer state
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
@@ -86,12 +88,17 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setIsOpen((prev) => {
|
||||
if (prev) {
|
||||
onBlur?.();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
}, [onBlur]);
|
||||
|
||||
const categoryTree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||
|
||||
@@ -138,7 +145,7 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
|
||||
const createdId = created?.id || created?.data?.id;
|
||||
notify.success(`Category "${newCatName}" created successfully!`);
|
||||
|
||||
|
||||
await fetchCategories();
|
||||
if (createdId) {
|
||||
onChange(createdId);
|
||||
@@ -178,11 +185,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
<div key={node.id} className="select-none">
|
||||
<div
|
||||
onClick={() => handleSelect(node.id)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary font-bold'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${isSelected
|
||||
? 'bg-primary/10 text-primary font-bold'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
style={{ paddingLeft: `${node.depth * 16 + 12}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -233,11 +239,10 @@ export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
{/* Trigger Button */}
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${
|
||||
disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
|
||||
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
|
||||
isOpen ? 'border-primary ring-2 ring-primary/20' :
|
||||
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Folder className={`w-4 h-4 ${selectedCategory ? 'text-primary' : 'text-gray-400'}`} />
|
||||
|
||||
@@ -77,10 +77,11 @@ export function Select({
|
||||
dropdownRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setIsOpen(false);
|
||||
onBlur?.({ target: { name } } as any);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
}, [isOpen, name, onBlur]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled) return;
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface AssetTypeValidation {
|
||||
maxFileSize: number;
|
||||
minUploadCount: number;
|
||||
maxUploadCount: number;
|
||||
allowed_extensions?: string[];
|
||||
max_file_size?: number;
|
||||
}
|
||||
|
||||
export interface AssetType {
|
||||
|
||||
@@ -23,6 +23,7 @@ interface DynamicAttributeRendererProps {
|
||||
attribute: Attribute;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
onBlur?: () => void;
|
||||
error?: string;
|
||||
touched?: boolean;
|
||||
readOnly?: boolean;
|
||||
@@ -32,6 +33,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
attribute,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
error,
|
||||
touched,
|
||||
readOnly,
|
||||
@@ -47,6 +49,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
<textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={`Enter ${attribute.name}`}
|
||||
rows={3}
|
||||
className={`${inputClass} resize-none`}
|
||||
@@ -64,6 +67,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
const val = e.target.value;
|
||||
onChange(val === '' ? undefined : Number(val));
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
placeholder={`Enter ${attribute.name}`}
|
||||
className={inputClass}
|
||||
disabled={readOnly}
|
||||
@@ -75,6 +79,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
type="date"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className={inputClass}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
@@ -87,6 +92,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
const val = e.target.value;
|
||||
onChange(val === 'true' ? true : val === 'false' ? false : undefined);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">Select option</option>
|
||||
@@ -101,6 +107,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
<Select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<option value="">Select option</option>
|
||||
@@ -119,9 +126,10 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
updated = selectedValues.filter((v) => v !== optCode);
|
||||
}
|
||||
onChange(updated.join(','));
|
||||
onBlur?.(); // trigger validation immediately
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30">
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-background/30" onBlur={onBlur}>
|
||||
{attribute.optionsList?.map((opt) => {
|
||||
const isChecked = selectedValues.includes(opt.code);
|
||||
return (
|
||||
@@ -149,6 +157,7 @@ export const DynamicAttributeRenderer: React.FC<DynamicAttributeRendererProps> =
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={`Enter ${attribute.name}`}
|
||||
className={inputClass}
|
||||
disabled={readOnly}
|
||||
|
||||
@@ -32,6 +32,7 @@ interface DynamicAttributesSectionProps {
|
||||
errors?: Record<string, any>;
|
||||
touched?: Record<string, any>;
|
||||
onAttributeChange: (code: string, value: any) => void;
|
||||
onAttributeBlur?: (code: string) => void;
|
||||
onAddAttributeClick?: (group: any) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
errors = {},
|
||||
touched = {},
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
readOnly,
|
||||
}) => {
|
||||
@@ -91,6 +93,7 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
onAttributeChange={onAttributeChange}
|
||||
onAttributeBlur={onAttributeBlur}
|
||||
onAddAttributeClick={onAddAttributeClick}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { assetsService, type AssetMapping } from '../../assets/services/assets.service';
|
||||
import type { Asset } from '../../assets/types/assets.types';
|
||||
import { useAssetType } from '../../asset-types/hook/useAssetType';
|
||||
import { assetFamiliesService } from '../../asset-families/services/asset-families.service';
|
||||
import {
|
||||
Upload, Image as ImageIcon, Video, FileText, Trash2,
|
||||
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown, Plus
|
||||
Check, Loader2, Search, Info, Shield, ArrowUp, ArrowDown, Plus, Box
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loader } from '../../../components/customs/Loader';
|
||||
import { getAssetUrl, isImageFile } from '../../../lib/utils';
|
||||
import { Select } from '../../../components/customs/Select';
|
||||
|
||||
interface ProductAssetsTabProps {
|
||||
productId?: string;
|
||||
family: any;
|
||||
readOnly?: boolean;
|
||||
refreshProductData?: () => void;
|
||||
}
|
||||
|
||||
export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
productId,
|
||||
family,
|
||||
readOnly
|
||||
readOnly,
|
||||
refreshProductData
|
||||
}) => {
|
||||
const [assignedAssets, setAssignedAssets] = useState<AssetMapping[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -34,6 +39,85 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Asset type and family states
|
||||
const { items: allAssetTypes, fetchItems: fetchAssetTypes } = useAssetType();
|
||||
const [selectedAssetTypeId, setSelectedAssetTypeId] = useState<string>('');
|
||||
const [allAssetFamilies, setAllAssetFamilies] = useState<any[]>([]);
|
||||
const [selectedAssetFamilyId, setSelectedAssetFamilyId] = useState<string>('');
|
||||
|
||||
// Fetch asset types and families
|
||||
useEffect(() => {
|
||||
fetchAssetTypes();
|
||||
const fetchFamilies = async () => {
|
||||
try {
|
||||
const data = await assetFamiliesService.getAll();
|
||||
setAllAssetFamilies(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load asset families:", err);
|
||||
}
|
||||
};
|
||||
fetchFamilies();
|
||||
}, [fetchAssetTypes]);
|
||||
|
||||
// Filter asset types based on selected asset family
|
||||
const filteredAssetTypes = useMemo(() => {
|
||||
if (!selectedAssetFamilyId) {
|
||||
return allAssetTypes;
|
||||
}
|
||||
const matchedFamily = allAssetFamilies.find(af => af.id === selectedAssetFamilyId);
|
||||
if (!matchedFamily) {
|
||||
return [];
|
||||
}
|
||||
const allowedTypeIds =
|
||||
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
|
||||
? matchedFamily.assetTypeIds
|
||||
: (matchedFamily.assetTypes || [])
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
|
||||
return allAssetTypes.filter(at => allowedTypeIds.includes(at.id));
|
||||
}, [allAssetTypes, allAssetFamilies, selectedAssetFamilyId]);
|
||||
|
||||
// Reset selected asset type if it is no longer allowed by the newly selected family
|
||||
useEffect(() => {
|
||||
if (selectedAssetTypeId && selectedAssetFamilyId) {
|
||||
const isStillAllowed = filteredAssetTypes.some(at => at.id === selectedAssetTypeId);
|
||||
if (!isStillAllowed) {
|
||||
setSelectedAssetTypeId('');
|
||||
}
|
||||
}
|
||||
}, [selectedAssetFamilyId, filteredAssetTypes, selectedAssetTypeId]);
|
||||
|
||||
// Resolve selected asset type details
|
||||
const selectedAssetType = useMemo(() => {
|
||||
return allAssetTypes.find(at => at.id === selectedAssetTypeId);
|
||||
}, [allAssetTypes, selectedAssetTypeId]);
|
||||
|
||||
// Resolve required asset type ids from family requirements
|
||||
const requiredAssetTypeIds = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
const familyReqs = family?.assetRequirements || [];
|
||||
familyReqs.forEach((req: any) => {
|
||||
const matchedFamily = allAssetFamilies.find(af => af.id === req.id);
|
||||
if (matchedFamily) {
|
||||
const allowedTypeIds =
|
||||
Array.isArray(matchedFamily.assetTypeIds) && matchedFamily.assetTypeIds.length > 0
|
||||
? matchedFamily.assetTypeIds
|
||||
: (matchedFamily.assetTypes || [])
|
||||
.map((at: any) => at.id || at.assetTypeId)
|
||||
.filter(Boolean);
|
||||
ids.push(...allowedTypeIds);
|
||||
}
|
||||
});
|
||||
return [...new Set(ids)];
|
||||
}, [family, allAssetFamilies]);
|
||||
|
||||
const isAssetTypeRequiredByFamily = (code: string) => {
|
||||
const matchingType = allAssetTypes.find(at => at.code === code);
|
||||
if (!matchingType) return false;
|
||||
return requiredAssetTypeIds.includes(matchingType.id);
|
||||
};
|
||||
|
||||
// Load product assets
|
||||
const loadProductAssets = async () => {
|
||||
if (!productId) return;
|
||||
@@ -85,80 +169,94 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Handle new file upload
|
||||
const handleFileUpload = async (file: File) => {
|
||||
// 1. Validate size (Max 10MB)
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
if (file.size > MAX_SIZE) {
|
||||
toast.error('File size exceeds the 10MB limit.');
|
||||
// Handle multiple files upload sequentially
|
||||
const handleMultipleFilesUpload = async (files: FileList | File[]) => {
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Validate type
|
||||
const allowedExtensions = [
|
||||
'jpg', 'jpeg', 'png', 'webp', 'gif', 'svg', 'avif', 'bmp',
|
||||
'mp4', 'webm', 'mov', 'pdf', 'csv', 'xlsx', 'xls', 'doc', 'docx'
|
||||
];
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
const isAllowedType =
|
||||
file.type.startsWith('image/') ||
|
||||
file.type.startsWith('video/') ||
|
||||
[
|
||||
'application/pdf', 'text/csv',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
].includes(file.type) ||
|
||||
allowedExtensions.includes(fileExtension || '');
|
||||
|
||||
if (!isAllowedType) {
|
||||
toast.error('Unsupported file format. Please upload images, videos, PDFs, CSVs, or Word/Excel documents.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
// 1. Upload to PIM media server
|
||||
const uploadedData = await assetsService.upload(file);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// 2. Create the asset registry record
|
||||
const newAsset = await assetsService.create({
|
||||
name: file.name.replace(/\.[^/.]+$/, ""),
|
||||
file_url: uploadedData.file_url,
|
||||
file_size: uploadedData.file_size,
|
||||
mime_type: uploadedData.mime_type,
|
||||
status: 'active'
|
||||
});
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Determine default role based on mime-type
|
||||
let role = 'gallery_image';
|
||||
if (uploadedData.mime_type.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (uploadedData.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
// 1. Validate format/extension
|
||||
const allowedFileTypes =
|
||||
selectedAssetType.validation?.allowedFileTypes ??
|
||||
selectedAssetType.validation?.allowed_extensions ??
|
||||
[];
|
||||
const allowedExts = allowedFileTypes.map((e: string) => e.toLowerCase().replace('.', ''));
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (allowedExts.length > 0 && !allowedExts.includes(fileExtension)) {
|
||||
toast.error(`File "${file.name}" rejected: Unsupported format.`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Map this asset to current product
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0, // Set primary if it's the first asset
|
||||
display_order: assignedAssets.length
|
||||
});
|
||||
// 2. Validate size
|
||||
const maxFileSize =
|
||||
selectedAssetType.validation?.maxFileSize ??
|
||||
selectedAssetType.validation?.max_file_size;
|
||||
if (maxFileSize && file.size > maxFileSize) {
|
||||
const sizeMb = Math.round(maxFileSize / (1024 * 1024));
|
||||
toast.error(`File "${file.name}" rejected: Exceeds the ${sizeMb}MB limit.`);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
toast.success('Asset uploaded and assigned successfully');
|
||||
loadProductAssets();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to upload asset');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
try {
|
||||
// Upload to PIM media server
|
||||
const uploadedData = await assetsService.upload(file);
|
||||
|
||||
// Create the asset registry record
|
||||
const newAsset = await assetsService.create({
|
||||
name: file.name.replace(/\.[^/.]+$/, ""),
|
||||
file_url: uploadedData.file_url,
|
||||
file_size: uploadedData.file_size,
|
||||
mime_type: uploadedData.mime_type,
|
||||
asset_type_id: selectedAssetType.id,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// Determine role based on selected Asset Type code
|
||||
let role = selectedAssetType?.code || 'gallery_image';
|
||||
if (!selectedAssetType) {
|
||||
if (uploadedData.mime_type.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (uploadedData.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
}
|
||||
}
|
||||
|
||||
// Map this asset to current product
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
asset_id: newAsset.id,
|
||||
role,
|
||||
is_primary: assignedAssets.length === 0 && successCount === 0,
|
||||
display_order: assignedAssets.length + successCount
|
||||
});
|
||||
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
toast.error(`Failed to upload "${file.name}": ${err?.message || 'Unknown error'}`);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Successfully uploaded and assigned ${successCount} assets.`);
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleMultipleFilesUpload(e.target.files);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,8 +273,12 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFileUpload(e.dataTransfer.files[0]);
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
handleMultipleFilesUpload(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -190,11 +292,20 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine role from the actual library Asset's Asset Type
|
||||
let role = 'gallery_image';
|
||||
if (asset.mime_type?.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (asset.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
if (asset.asset_type_id) {
|
||||
const matchingType = allAssetTypes.find(at => at.id === asset.asset_type_id);
|
||||
if (matchingType && matchingType.code) {
|
||||
role = matchingType.code;
|
||||
}
|
||||
}
|
||||
if (!role || role === 'gallery_image') {
|
||||
if (asset.mime_type?.startsWith('video/')) {
|
||||
role = 'video';
|
||||
} else if (asset.mime_type === 'application/pdf') {
|
||||
role = 'document';
|
||||
}
|
||||
}
|
||||
|
||||
await assetsService.assignProductAsset(productId, {
|
||||
@@ -206,6 +317,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
|
||||
toast.success('Asset assigned from library');
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to assign asset');
|
||||
}
|
||||
@@ -218,21 +330,13 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
await assetsService.unassignProductAsset(productId, assetId);
|
||||
toast.success('Asset unassigned');
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to unassign asset');
|
||||
}
|
||||
};
|
||||
|
||||
// Update asset role mapping
|
||||
const handleRoleChange = async (assetId: string, role: string) => {
|
||||
try {
|
||||
await assetsService.updateProductAsset(productId, assetId, { role });
|
||||
setAssignedAssets(prev => prev.map(a => a.asset_id === assetId ? { ...a, role } : a));
|
||||
toast.success('Asset role updated');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to update asset role');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Set selected asset as the primary display image
|
||||
const handleSetPrimary = async (assetId: string) => {
|
||||
@@ -240,6 +344,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
await assetsService.updateProductAsset(productId, assetId, { is_primary: true });
|
||||
toast.success('Primary image updated');
|
||||
loadProductAssets();
|
||||
refreshProductData?.();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to set primary image');
|
||||
}
|
||||
@@ -268,6 +373,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const filteredLibrary = libraryAssets.filter(asset =>
|
||||
asset.name.toLowerCase().includes(pickerSearch.toLowerCase())
|
||||
);
|
||||
@@ -302,6 +409,89 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset Family & Asset Type Selectors */}
|
||||
{!readOnly && (
|
||||
<div className="bg-surface border border-border rounded-xl p-5 shadow-2xs space-y-4">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{/* Asset Family Selector */}
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<label className="text-xs font-bold text-foreground block mb-2">Asset Family</label>
|
||||
<div className="relative">
|
||||
<Select
|
||||
id="asset-family-select"
|
||||
value={selectedAssetFamilyId}
|
||||
onChange={(e) => setSelectedAssetFamilyId(e.target.value)}
|
||||
placeholder="Select Asset Family..."
|
||||
className="w-full text-xs font-semibold"
|
||||
>
|
||||
{allAssetFamilies.filter(af => af && af.status === 'active').map((af) => (
|
||||
<option key={af.id} value={af.id}>
|
||||
{af.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset Type Selector */}
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<label className="text-xs font-bold text-foreground block mb-2">Asset Type Classification</label>
|
||||
<div className="relative">
|
||||
<Select
|
||||
id="asset-type-select"
|
||||
value={selectedAssetTypeId}
|
||||
onChange={(e) => setSelectedAssetTypeId(e.target.value)}
|
||||
placeholder={selectedAssetFamilyId && filteredAssetTypes.length === 0 ? "No asset types available for this family" : "Select Asset Type classification..."}
|
||||
className="w-full text-xs font-semibold"
|
||||
disabled={!!(selectedAssetFamilyId && filteredAssetTypes.length === 0)}
|
||||
>
|
||||
{filteredAssetTypes.filter(at => at && at.status === 'active').map((at) => {
|
||||
const isRequired = isAssetTypeRequiredByFamily(at.code);
|
||||
return (
|
||||
<option key={at.id} value={at.id}>
|
||||
{at.name} ({at.category || 'Other'}) {isRequired ? '★ Required' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAssetType && (() => {
|
||||
const allowedFileTypes =
|
||||
selectedAssetType.validation?.allowedFileTypes ??
|
||||
selectedAssetType.validation?.allowed_extensions ??
|
||||
[];
|
||||
const maxFileSize =
|
||||
selectedAssetType.validation?.maxFileSize ??
|
||||
selectedAssetType.validation?.max_file_size;
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-border rounded-lg p-3.5 flex items-start gap-3">
|
||||
<Info className="w-4 h-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="text-xs space-y-1">
|
||||
<div>
|
||||
<span className="font-bold text-foreground">Allowed formats: </span>
|
||||
<span className="font-mono text-primary-dark font-semibold">
|
||||
{allowedFileTypes.length > 0 ? allowedFileTypes.map((t: string) => t.toUpperCase()).join(', ') : 'Any'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-foreground">Max File Size: </span>
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
{maxFileSize
|
||||
? `${Math.round(maxFileSize / (1024 * 1024))} MB`
|
||||
: '10 MB'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Zone & Picker Trigger */}
|
||||
{!readOnly && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
@@ -309,7 +499,13 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => {
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className={`md:col-span-2 border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/20 hover:bg-background/50 bg-surface'
|
||||
}`}
|
||||
>
|
||||
@@ -318,19 +514,36 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,video/*,application/pdf"
|
||||
multiple
|
||||
accept={(() => {
|
||||
const allowedFileTypes =
|
||||
selectedAssetType?.validation?.allowedFileTypes ??
|
||||
selectedAssetType?.validation?.allowed_extensions ??
|
||||
[];
|
||||
return allowedFileTypes.length > 0
|
||||
? allowedFileTypes.map((t: string) => '.' + t.replace('.', '')).join(',')
|
||||
: '*';
|
||||
})()}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="space-y-2">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin mx-auto" />
|
||||
<p className="font-semibold text-xs text-foreground">Uploading new media file...</p>
|
||||
<p className="font-semibold text-xs text-foreground">Uploading files...</p>
|
||||
</div>
|
||||
) : !selectedAssetType ? (
|
||||
<div className="space-y-2 text-muted-foreground">
|
||||
<Info className="w-8 h-8 mx-auto animate-pulse" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-xs text-foreground">Select Asset Type to Upload</h4>
|
||||
<p className="text-[10px] mt-1">Classification is required before mapping files to product registry</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<h4 className="text-foreground font-semibold text-xs">Drag & Drop Files Here</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">or click to browse media files</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">or click to browse {selectedAssetType.name} files</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -346,8 +559,15 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs cursor-pointer"
|
||||
onClick={() => {
|
||||
if (!selectedAssetType) {
|
||||
toast.error('Please select an Asset Type first.');
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold shadow-2xs cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!selectedAssetType}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
+ Add Asset
|
||||
@@ -378,7 +598,6 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
{!readOnly && <th className="px-4 py-3 w-16 text-center">Order</th>}
|
||||
<th className="px-4 py-3 w-20">Preview</th>
|
||||
<th className="px-4 py-3">Asset Details</th>
|
||||
<th className="px-4 py-3 w-40">Role Classification</th>
|
||||
<th className="px-4 py-3 w-32 text-center">Primary</th>
|
||||
{!readOnly && <th className="px-4 py-3 w-20 text-right">Actions</th>}
|
||||
</tr>
|
||||
@@ -391,6 +610,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const isImage = isImageFile(asset.mime_type, asset.file_url);
|
||||
const isVideo = asset.mime_type?.startsWith('video/');
|
||||
const isPdf = asset.mime_type === 'application/pdf';
|
||||
const is3DModel = asset.mime_type?.startsWith('model/') || ['glb', 'gltf', 'usdz'].includes(asset.extension || '');
|
||||
|
||||
return (
|
||||
<tr key={mapping.id} className="hover:bg-background/50 transition-colors">
|
||||
@@ -427,6 +647,8 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<Video className="w-5 h-5 text-primary" />
|
||||
) : isPdf ? (
|
||||
<FileText className="w-5 h-5 text-emerald-500" />
|
||||
) : is3DModel ? (
|
||||
<Box className="w-5 h-5 text-purple-500" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 text-blue-500" />
|
||||
)}
|
||||
@@ -441,30 +663,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Role dropdown selection or read-only label */}
|
||||
<td className="px-4 py-3">
|
||||
{readOnly ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-surface-muted border border-border text-muted-foreground">
|
||||
{mapping.role === 'primary_image' ? 'Primary Image' :
|
||||
mapping.role === 'gallery_image' ? 'Gallery Image' :
|
||||
mapping.role === 'thumbnail' ? 'Thumbnail' :
|
||||
mapping.role === 'video' ? 'Video' :
|
||||
mapping.role === 'document' ? 'Documentation PDF' : mapping.role}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
value={mapping.role}
|
||||
onChange={(e) => handleRoleChange(asset.id, e.target.value)}
|
||||
className="text-xs border border-border focus:ring-primary rounded-lg px-2.5 py-1 focus:outline-none bg-surface font-medium text-foreground"
|
||||
>
|
||||
<option value="primary_image">Primary Image</option>
|
||||
<option value="gallery_image">Gallery Image</option>
|
||||
<option value="thumbnail">Thumbnail</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="document">Documentation PDF</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
|
||||
|
||||
{/* Primary Badge toggle button */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
|
||||
@@ -33,6 +33,7 @@ interface ProductAttributeGroupProps {
|
||||
errors?: Record<string, any>;
|
||||
touched?: Record<string, any>;
|
||||
onAttributeChange: (code: string, value: any) => void;
|
||||
onAttributeBlur?: (code: string) => void;
|
||||
onAddAttributeClick?: (group: AttributeGroup) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
errors = {},
|
||||
touched = {},
|
||||
onAttributeChange,
|
||||
onAttributeBlur,
|
||||
onAddAttributeClick,
|
||||
readOnly,
|
||||
}) => {
|
||||
@@ -106,6 +108,7 @@ export const ProductAttributeGroup: React.FC<ProductAttributeGroupProps> = ({
|
||||
attribute={attr}
|
||||
value={values[attr.code]}
|
||||
onChange={(val) => onAttributeChange(attr.code, val)}
|
||||
onBlur={() => onAttributeBlur?.(attr.code)}
|
||||
error={errors[attr.code]}
|
||||
touched={touched[attr.code]}
|
||||
readOnly={readOnly}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user