Merge remote-tracking branch 'origin/dev' into feature/inam-platform-core-setup

This commit is contained in:
Inamul-hasan-tec
2026-08-19 12:45:30 +05:30
8 changed files with 1047 additions and 338 deletions
+18 -13
View File
@@ -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'}`} />
+2 -1
View File
@@ -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