feat(products): implement Asset Family filtering and automatic role mapping in Assets step
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user