feat(products): implement Asset Family filtering and automatic role mapping in Assets step

This commit is contained in:
Mahir-Mohamed
2026-08-13 14:55:18 +05:30
parent 7cd9ab5e0c
commit abb135b5e0
3 changed files with 409 additions and 226 deletions
@@ -3,6 +3,8 @@ export interface AssetTypeValidation {
maxFileSize: number;
minUploadCount: number;
maxUploadCount: number;
allowed_extensions?: string[];
max_file_size?: number;
}
export interface AssetType {
@@ -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">
+96 -114
View File
@@ -11,7 +11,6 @@ import { useProductFamilyConfiguration } from '../hook/useProductFamilyConfigura
import { VariantsTab } from '../components/variants/VariantsTab';
import { DynamicAttributesSection } from '../components/DynamicAttributesSection';
import { ProductAssetsTab } from '../components/ProductAssetsTab';
import { StageTimeline } from '../../workflow/components/StageTimeline';
import { notify } from '../../../services/toast';
import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet';
import { useAttribute } from '../../attributes/hook/useAttribute';
@@ -183,7 +182,7 @@ export default function NewProduct() {
attributeSet,
groups: attributeGroups,
attributes: attributesList,
workflow: inheritedWorkflow,
workflow: _inheritedWorkflow,
loadConfiguration,
} = useProductFamilyConfiguration(selectedFamily || undefined);
@@ -223,9 +222,6 @@ export default function NewProduct() {
}
};
const workflowStages = useMemo(() => {
return inheritedWorkflow?.stages || [];
}, [inheritedWorkflow]);
// Resolve list of brands based on allowed list
const activeAllowedBrandsList = useMemo(() => {
@@ -254,29 +250,32 @@ export default function NewProduct() {
const EXCLUDED_ATTRIBUTE_CODES = ['brand', 'brand_id', 'product_name', 'name', 'short_description', 'description', 'long_description', 'category', 'category_id', 'unit', 'unit_id', 'code', 'sku'];
const activeAttributeGroups = useMemo(() => {
if (selectedAttributeSetObj) {
return selectedAttributeSetObj.groups || [];
}
if (selectedFamily) {
return attributeGroups || [];
}
return selectedAttributeSetObj?.groups || [];
return [];
}, [selectedFamily, attributeGroups, selectedAttributeSetObj]);
const activeAttributesList = useMemo(() => {
const list: any[] = [];
if (selectedFamily) {
list.push(...(attributesList || []));
} else if (selectedAttributeSetObj?.groups) {
if (selectedAttributeSetObj?.groups) {
selectedAttributeSetObj.groups.forEach((g: any) => {
if (g.attributes) {
list.push(...g.attributes);
}
});
} else if (selectedFamily) {
list.push(...(attributesList || []));
}
// Append custom added attributes to activeAttributesList
customAddedAttributes.forEach((attr) => {
if (!list.some((a) =>
(a.id && a.id === attr.id) ||
(a._id && a._id === attr.id) ||
(a.id && String(a.id) === String(attr.id)) ||
(a._id && String(a._id) === String(attr.id)) ||
(a.code || '').toLowerCase() === (attr.code || '').toLowerCase()
)) {
list.push(attr);
@@ -300,8 +299,8 @@ export default function NewProduct() {
};
let groupObj = groupsCopy.find((g: any) =>
(g.id && g.id === targetGroupInfo.id) ||
(g._id && g._id === targetGroupInfo.id) ||
(g.id && String(g.id) === String(targetGroupInfo.id)) ||
(g._id && String(g._id) === String(targetGroupInfo.id)) ||
(g.code && g.code === targetGroupInfo.code)
);
if (!groupObj) {
@@ -315,8 +314,8 @@ export default function NewProduct() {
}
const isAlreadyInGroup = groupObj.attributes.some((a: any) =>
(a.id && a.id === attr.id) ||
(a._id && a._id === attr.id) ||
(a.id && String(a.id) === String(attr.id)) ||
(a._id && String(a._id) === String(attr.id)) ||
(a.code || '').toLowerCase() === (attr.code || '').toLowerCase()
);
if (!isAlreadyInGroup) {
@@ -598,16 +597,17 @@ export default function NewProduct() {
const currentTabs = useMemo(() => {
const isVariant = formik.values.type === 'variant';
const assetsCount = product?.productAssets?.length ?? 0;
const tabsList = [
{ id: 'general', label: 'General', icon: Box },
{ id: 'attributes', label: 'Attributes', icon: LayoutGrid },
...(isVariant ? [{ id: 'variants', label: 'Variants', icon: Tags }] : []),
{ id: 'assets', label: 'Assets', icon: ImageIcon },
{ id: 'assets', label: `Assets (${assetsCount})`, icon: ImageIcon },
{ id: 'channels', label: 'Channels', icon: Globe },
{ id: 'review', label: 'Review', icon: Eye },
];
return tabsList.map((t, idx) => ({ ...t, step: idx + 1 }));
}, [formik.values.type]);
}, [formik.values.type, product?.productAssets]);
// Redirect if current activeTab is not in available tabs list (e.g. Variants removed)
useEffect(() => {
@@ -638,9 +638,18 @@ export default function NewProduct() {
}
}
// 3.5. If on 'assets' step, block if there are missing required assets in completeness entries
if (activeTab === 'assets') {
const defaultEntry = (product?.completenessEntries || []).find((c: any) => c.channel === 'default');
const missingAssets = defaultEntry?.missing_assets || [];
if (missingAssets.length > 0) {
return true;
}
}
// 4. Fallback: check global Formik validation status
return !formik.isValid;
}, [activeTab, formik.values, formik.errors, formik.isValid, filteredAttributesList, areRequiredAttributesComplete]);
}, [activeTab, formik.values, formik.errors, formik.isValid, filteredAttributesList, areRequiredAttributesComplete, product?.completenessEntries]);
const handleSubmitWithValidation = async (targetStatus?: string) => {
const statusToUse = targetStatus || formik.values.status || 'draft';
@@ -711,11 +720,16 @@ export default function NewProduct() {
if (familyId) {
setSelectedFamily(familyId);
blueprint = await loadConfiguration(familyId);
} else if (productData.metadata?.attributeSetId) {
}
if (productData.metadata?.attributeSetId) {
const setId = productData.metadata.attributeSetId;
setSelectedAttributeSetId(setId);
const setDetails = await attributeSetsService.getById(setId);
setSelectedAttributeSetObj(setDetails);
try {
const setDetails = await attributeSetsService.getById(setId);
setSelectedAttributeSetObj(setDetails);
} catch (err) {
console.error("Failed to load attribute set details during hydration:", err);
}
}
// 3. Extract Saved Attribute Values
@@ -792,6 +806,20 @@ export default function NewProduct() {
}
}, [loadConfiguration]);
const refreshProductData = useCallback(async () => {
const targetId = id || productId;
if (!targetId) return;
try {
const rawData: any = await productService.getById(targetId);
const productData = rawData?.data?.id ? rawData.data : (rawData?.id ? rawData : rawData?.data || rawData);
if (productData && productData.id) {
setProduct(productData);
}
} catch (err) {
console.error("Failed to refresh product completeness data:", err);
}
}, [id, productId]);
// Calculate dynamic completeness score
const productCompleteness = useMemo(() => {
let score = 0;
@@ -829,7 +857,7 @@ export default function NewProduct() {
useEffect(() => {
if (showAttributeModal && modalAvailableGroups && modalAvailableGroups.length > 0) {
const isAlreadyValid = modalAvailableGroups.some((g: any) => g.id === inlineAttributeGroupId);
const isAlreadyValid = modalAvailableGroups.some((g: any) => String(g.id || g._id) === String(inlineAttributeGroupId));
if (!isAlreadyValid) {
setInlineAttributeGroupId(modalAvailableGroups[0].id);
}
@@ -1093,8 +1121,12 @@ export default function NewProduct() {
<h2 className="text-base font-bold text-foreground leading-tight">{formik.values.name || 'Untitled Product'}</h2>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-0.5 font-mono">
<span>Code: <strong className="text-foreground">{formik.values.code}</strong></span>
<span></span>
<span>SKU: <strong className="text-primary-dark">{formik.values.sku}</strong></span>
{formik.values.sku && formik.values.status?.toLowerCase() !== 'draft' && (
<>
<span></span>
<span>SKU: <strong className="text-primary-dark">{formik.values.sku}</strong></span>
</>
)}
<span></span>
<span>Family: <strong className="text-foreground">{family?.name || 'Furniture'}</strong></span>
</div>
@@ -1244,72 +1276,7 @@ export default function NewProduct() {
{/* Main Content Area */}
<div className="flex-1 min-w-0 flex flex-col">
{isEdit && inheritedWorkflow && (
<div className="mb-4 bg-surface border border-border rounded-xl p-5 shadow-sm mr-1">
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border">
<div>
<span className="text-[9px] font-bold text-muted-foreground block uppercase font-mono">Lifecycle Workflow</span>
<h4 className="font-semibold text-foreground text-sm">{inheritedWorkflow.name} ({inheritedWorkflow.category})</h4>
</div>
<div className="flex items-center gap-4 text-right">
<div className="text-xs">
<span className="text-muted-foreground">Current Stage: </span>
<span className="font-bold text-primary-dark uppercase">
{workflowStages.find((s: any) => s.code === formik.values.metadata?.currentStage)?.name || formik.values.metadata?.currentStage || 'Draft'}
</span>
</div>
{(() => {
const currentStageCode = formik.values.metadata?.currentStage || 'draft';
const sorted = [...workflowStages].sort((a, b) => Number(a.order || 0) - Number(b.order || 0));
const currentIdx = sorted.findIndex((s: any) => s.code === currentStageCode);
if (currentIdx !== -1 && currentIdx < sorted.length - 1) {
const nextStage = sorted[currentIdx + 1];
return (
<button
type="button"
onClick={async () => {
try {
const updatedMetadata = {
...formik.values.metadata,
currentStage: nextStage.code
};
const targetId = id || productId;
if (targetId) {
await productService.update(targetId, {
...formik.values,
metadata: updatedMetadata
} as any);
formik.setFieldValue('metadata', updatedMetadata);
notify.success(`Product advanced to stage: ${nextStage.name}`);
}
} catch (err: any) {
notify.error(err);
}
}}
className="px-3 py-1.5 bg-primary hover:bg-primary-hover text-white text-[11px] font-bold rounded-lg transition-all shadow-xs"
>
Promote to {nextStage.name}
</button>
);
}
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider bg-emerald-50 text-emerald-700 border border-emerald-150">
Final Stage Completed
</span>
);
})()}
</div>
</div>
<div className="py-2 overflow-x-auto">
<StageTimeline
stages={workflowStages}
currentStageCode={formik.values.metadata?.currentStage || 'draft'}
variant="horizontal"
/>
</div>
</div>
)}
<div className="overflow-y-auto pr-1 flex-1">
{activeTab === 'general' && (
@@ -1722,11 +1689,12 @@ export default function NewProduct() {
<div ref={attributeSetDropdownRef} className="relative">
<div
onClick={() => {
if (!isReadOnlyView && !selectedFamily) {
const hasFamilyAttributeSet = family && family.attributeSet;
if (!isReadOnlyView && !hasFamilyAttributeSet) {
setIsAttributeSetDropdownOpen(!isAttributeSetDropdownOpen);
}
}}
className={`w-full border border-border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-surface ${isReadOnlyView || selectedFamily ? 'cursor-not-allowed opacity-75' : 'cursor-pointer hover:border-primary/30'
className={`w-full border border-border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-surface ${isReadOnlyView || (family && family.attributeSet) ? 'cursor-not-allowed opacity-75' : 'cursor-pointer hover:border-primary/30'
}`}
>
<span className={selectedAttributeSetObj?.name ? 'text-foreground font-medium' : 'text-muted-foreground'}>
@@ -1735,7 +1703,7 @@ export default function NewProduct() {
: 'Select Attribute Set...'}
</span>
<div className="flex items-center gap-1.5">
{selectedAttributeSetId && !isReadOnlyView && !selectedFamily && (
{selectedAttributeSetId && !isReadOnlyView && !(family && family.attributeSet) && (
<button
type="button"
onClick={(e) => {
@@ -1788,9 +1756,7 @@ export default function NewProduct() {
)}
</div>
{selectedFamily && (
<p className="text-[10px] text-primary mt-1 font-medium absolute">Inherited from Product Family: {selectedFamilyObj?.name || family?.name}</p>
)}
</div>
{/* Search attributes... input */}
@@ -1933,6 +1899,7 @@ export default function NewProduct() {
productId={id || productId}
family={family}
readOnly={isReadOnlyView}
refreshProductData={refreshProductData}
/>
)
)}
@@ -2648,6 +2615,7 @@ export default function NewProduct() {
}
onClick={async () => {
setInlineAttributeSubmitting(true);
let createdAttr: any;
try {
// 1. Create Attribute
const opts = (inlineAttributeType === 'select' || inlineAttributeType === 'multiselect')
@@ -2662,13 +2630,31 @@ export default function NewProduct() {
status: 'active'
};
const createdAttr = await createAttribute(attrPayload as any);
createdAttr = await createAttribute(attrPayload as any);
} catch (err) {
// API failed, keep modal open
setInlineAttributeSubmitting(false);
return;
}
// Success! Clear inputs and close modal immediately
setInlineAttributeName('');
setInlineAttributeType('text');
setInlineAttributeRequired(false);
setInlineAttributeOptions('');
setShowAttributeModal(false);
setInlineAttributeSubmitting(false);
// Post-creation steps: group link, state sync, and config refreshes (wrapped separately to avoid blocking modal close)
try {
const newId = createdAttr?.id || (createdAttr as any)?.data?.id;
const rawAttr = createdAttr?.data || createdAttr;
if (newId && inlineAttributeGroupId) {
// 2. Associate with Attribute Group
const groupDetails = await attributeGroupsService.getById(inlineAttributeGroupId);
const existingAttributeIds = (groupDetails?.attributes || []).map((a: any) => {
const groupData = (groupDetails as any)?.data || groupDetails;
const existingAttributeIds = (groupData?.attributes || []).map((a: any) => {
if (typeof a === 'string') return a;
return a?.id || a?._id;
}).filter(Boolean);
@@ -2677,15 +2663,19 @@ export default function NewProduct() {
attributes: [...existingAttributeIds, newId]
} as any);
const matchingGroupObj = modalAvailableGroups?.find((g: any) => g.id === inlineAttributeGroupId);
const groupName = groupDetails?.name || matchingGroupObj?.name || 'Group';
const groupCode = groupDetails?.code || matchingGroupObj?.code || 'group';
const matchingGroupObj = modalAvailableGroups?.find((g: any) => String(g.id || g._id) === String(inlineAttributeGroupId));
const groupName = groupData?.name || matchingGroupObj?.name || 'Group';
const groupCode = groupData?.code || matchingGroupObj?.code || 'group';
const opts = (inlineAttributeType === 'select' || inlineAttributeType === 'multiselect')
? inlineAttributeOptions.split(',').map((o: string) => o.trim()).filter(Boolean)
: undefined;
// Push the newly created attribute to customAddedAttributes so it renders immediately in the correct group
const attrForCustomList = {
...createdAttr,
...rawAttr,
id: newId,
code: createdAttr?.code || inlineAttributeName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_'),
code: rawAttr?.code || inlineAttributeName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_'),
is_required: inlineAttributeRequired,
isRequired: inlineAttributeRequired,
optionsList: opts ? opts.map((opt: string, index: number) => ({
@@ -2709,19 +2699,11 @@ export default function NewProduct() {
await loadConfiguration(selectedFamily);
} else if (selectedAttributeSetId) {
const setDetails = await attributeSetsService.getById(selectedAttributeSetId);
setSelectedAttributeSetObj(setDetails);
const setData = (setDetails as any)?.data || setDetails;
setSelectedAttributeSetObj(setData);
}
// Reset quick create state
setInlineAttributeName('');
setInlineAttributeType('text');
setInlineAttributeRequired(false);
setInlineAttributeOptions('');
setShowAttributeModal(false);
} catch (err) {
// error toast is shown by hooks
} finally {
setInlineAttributeSubmitting(false);
} catch (assocErr) {
console.error("Failed to associate attribute with group or reload configuration:", assocErr);
}
}}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center gap-2"