Fix product and product family configuration
This commit is contained in:
@@ -197,20 +197,6 @@ export default function NewAttributeGroup() {
|
||||
<CardHeader title="Basic Information" subtitle="Define the group's identity and metadata" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., general_info"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Group Name <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
@@ -230,6 +216,20 @@ export default function NewAttributeGroup() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Group Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., general_info"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -228,20 +228,6 @@ export default function NewAttributeSet() {
|
||||
<CardHeader title="Basic Information" subtitle="Define the set's identity and metadata" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., electronic_accessories"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Set Name <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
@@ -259,6 +245,20 @@ export default function NewAttributeSet() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
|
||||
<input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g., electronic_accessories"
|
||||
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-background disabled:text-muted-foreground`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unique identifier (snake_case/kebab-case)</p>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -176,6 +176,14 @@ export default function NewAttribute() {
|
||||
apiVisible: (match as any).apiVisible ?? true,
|
||||
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
|
||||
});
|
||||
|
||||
if ((match as any).optionsList && Array.isArray((match as any).optionsList)) {
|
||||
setOptionsList((match as any).optionsList.map((o: any) => ({ code: o.code, label: o.label })));
|
||||
} else if ((match as any).options && Array.isArray((match as any).options)) {
|
||||
setOptionsList((match as any).options.map((o: any) => typeof o === 'string' ? { code: o.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, ''), label: o } : o));
|
||||
} else {
|
||||
setOptionsList([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -283,19 +291,6 @@ export default function NewAttribute() {
|
||||
<CardHeader title="General Information" subtitle="Basic details about the attribute" />
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit || isView}
|
||||
placeholder="e.g., product_weight"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Name <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
@@ -309,6 +304,19 @@ export default function NewAttribute() {
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Code <span className="text-red-400">*</span></label>
|
||||
<Input
|
||||
name="code"
|
||||
value={formik.values.code}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={isEdit || isView}
|
||||
placeholder="e.g., product_weight"
|
||||
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
|
||||
/>
|
||||
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import apiClient from '../../../api/axiosInstance';
|
||||
import { useFamily } from '../hook/useFamily';
|
||||
@@ -6,14 +6,16 @@ import { useAttribute } from '../../attributes/hook/useAttribute';
|
||||
import { useChannel } from '../../channels/hook/useChannel';
|
||||
import { useWorkflow } from '../../workflow/hook/useWorkflow';
|
||||
import { StageTimeline } from '../../workflow/components/StageTimeline';
|
||||
import { Search, X, Layers, Activity } from 'lucide-react';
|
||||
import { Search, X, Layers, Activity, Plus, Loader2, ChevronDown } from 'lucide-react';
|
||||
import { useUnit } from '../../units/hook/useUnit';
|
||||
import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet';
|
||||
import { useAttributeGroup } from '../../attribute-groups/hook/useAttributeGroup';
|
||||
import { attributeGroupsService } from '../../attribute-groups/services/attribute-groups.service';
|
||||
import { useAssetFamily } from '../../asset-families/hook/useAssetFamily';
|
||||
import { useBrand } from '../../brands/hook/useBrand';
|
||||
import {
|
||||
FileText, LayoutGrid, Tags, Globe, Eye, Settings2, Save,
|
||||
CheckCircle2, AlertCircle, CheckSquare, Image as ImageIcon, Check,
|
||||
CheckCircle2, AlertCircle, Image as ImageIcon, Check,
|
||||
Box, Info
|
||||
} from 'lucide-react';
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
@@ -35,8 +37,7 @@ const TABS = [
|
||||
{ id: 'channels', label: 'Allowed Channels', icon: Globe, step: 4 },
|
||||
{ id: 'assets', label: 'Asset Families', icon: ImageIcon, step: 5 },
|
||||
{ id: 'workflow', label: 'Workflow Assignment', icon: Settings2, step: 6 },
|
||||
{ id: 'rules', label: 'Completeness Rules', icon: CheckSquare, step: 7 },
|
||||
{ id: 'summary', label: 'Inheritance Summary', icon: Eye, step: 8 },
|
||||
{ id: 'summary', label: 'Inheritance Summary', icon: Eye, step: 7 },
|
||||
];
|
||||
|
||||
const inputClass = "w-full border border-primary/10 focus:ring-primary-light rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface placeholder-muted-foreground";
|
||||
@@ -75,7 +76,8 @@ export default function NewFamily() {
|
||||
const isEdit = Boolean(id) && !isView;
|
||||
|
||||
const { createFamily, updateFamily } = useFamily();
|
||||
const { attributes, fetchAttributes, loading: attributesLoading } = useAttribute();
|
||||
const { attributes, fetchAttributes, createAttribute, loading: attributesLoading } = useAttribute();
|
||||
const { items: attributeGroupsList, fetchItems: fetchAttributeGroups } = useAttributeGroup();
|
||||
const { items: channelsList, fetchItems: fetchChannels, loading: channelsLoading } = useChannel();
|
||||
const { items: assetFamiliesList, fetchItems: fetchAssetFamilies, loading: assetFamiliesLoading } = useAssetFamily();
|
||||
const { items: workflowsList, fetchItems: fetchWorkflows, loading: workflowsLoading } = useWorkflow();
|
||||
@@ -95,6 +97,54 @@ export default function NewFamily() {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Step 2 Filtering & Inline Quick Create Attribute state
|
||||
const [selectedGroupFilter, setSelectedGroupFilter] = useState('');
|
||||
const [attributeSearchQuery, setAttributeSearchQuery] = useState('');
|
||||
|
||||
// Searchable Attribute Set Dropdown state & ref
|
||||
const [isAttributeSetDropdownOpen, setIsAttributeSetDropdownOpen] = useState(false);
|
||||
const [attributeSetSearchQuery, setAttributeSetSearchQuery] = useState('');
|
||||
const attributeSetDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Step 5 Asset Families Search & Dropdown state & ref
|
||||
const [assetFamilySearchQuery, setAssetFamilySearchQuery] = useState('');
|
||||
const [isAssetFamilyDropdownOpen, setIsAssetFamilyDropdownOpen] = useState(false);
|
||||
const assetFamilyDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (attributeSetDropdownRef.current && !attributeSetDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsAttributeSetDropdownOpen(false);
|
||||
}
|
||||
if (assetFamilyDropdownRef.current && !assetFamilyDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsAssetFamilyDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredAttributeSets = useMemo(() => {
|
||||
if (!attributeSetSearchQuery.trim()) return attributeSetsList;
|
||||
const q = attributeSetSearchQuery.toLowerCase().trim();
|
||||
return attributeSetsList.filter((s: any) =>
|
||||
(s.name && s.name.toLowerCase().includes(q)) ||
|
||||
(s.code && s.code.toLowerCase().includes(q))
|
||||
);
|
||||
}, [attributeSetsList, attributeSetSearchQuery]);
|
||||
|
||||
|
||||
|
||||
const [showAttributeModal, setShowAttributeModal] = useState(false);
|
||||
const [inlineAttributeName, setInlineAttributeName] = useState('');
|
||||
const [inlineAttributeType, setInlineAttributeType] = useState('text');
|
||||
const [inlineAttributeRequired, setInlineAttributeRequired] = useState(false);
|
||||
const [inlineAttributeOptions, setInlineAttributeOptions] = useState('');
|
||||
const [inlineAttributeGroupId, setInlineAttributeGroupId] = useState('');
|
||||
const [inlineAttributeSubmitting, setInlineAttributeSubmitting] = useState(false);
|
||||
|
||||
// Maps form fields to which tab they live on for auto-navigation
|
||||
const FIELD_TAB_MAP: Record<string, string> = {
|
||||
name: 'basic', code: 'basic', status: 'basic',
|
||||
@@ -103,7 +153,6 @@ export default function NewFamily() {
|
||||
variantAxes: 'variants',
|
||||
channels: 'channels',
|
||||
assetRequirements: 'assets',
|
||||
completenessRules: 'rules',
|
||||
};
|
||||
|
||||
|
||||
@@ -157,6 +206,16 @@ export default function NewFamily() {
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
setSubmitError(null);
|
||||
|
||||
// Validate completeness rules sum to 100%
|
||||
const rulesSum = Object.values(values.completenessRules || {}).reduce((sum, v) => sum + (Number(v) || 0), 0);
|
||||
if (rulesSum !== 100) {
|
||||
const msg = `Completeness rules weights must equal 100%. Current sum: ${rulesSum}%`;
|
||||
notify.error(msg);
|
||||
setSubmitError(msg);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the full form first and navigate to first tab with an error
|
||||
const errors = await formik.validateForm();
|
||||
const errorFields = Object.keys(errors);
|
||||
@@ -215,13 +274,14 @@ export default function NewFamily() {
|
||||
// Fetch dynamic lookup lists on mount
|
||||
useEffect(() => {
|
||||
fetchAttributes();
|
||||
fetchAttributeGroups();
|
||||
fetchChannels();
|
||||
fetchAssetFamilies();
|
||||
fetchWorkflows();
|
||||
fetchAttributeSets();
|
||||
fetchBrands();
|
||||
fetchUnits();
|
||||
}, [fetchAttributes, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
|
||||
}, [fetchAttributes, fetchAttributeGroups, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
|
||||
|
||||
// Load family details in Edit mode
|
||||
useEffect(() => {
|
||||
@@ -298,6 +358,34 @@ export default function NewFamily() {
|
||||
const activeIndex = TABS.findIndex(t => t.id === activeTab);
|
||||
const isLoading = familyLoading || attributesLoading || channelsLoading || assetFamiliesLoading || workflowsLoading || setsLoading || brandsLoading || unitsLoading;
|
||||
|
||||
const familyCompleteness = useMemo(() => {
|
||||
let score = 0;
|
||||
if (formik.values.name && formik.values.code) score += 20;
|
||||
if (formik.values.category) score += 20;
|
||||
if (formik.values.attributeSetId) score += 20;
|
||||
if (formik.values.channels && formik.values.channels.length > 0) score += 20;
|
||||
if (formik.values.assetRequirements && formik.values.assetRequirements.length > 0) score += 20;
|
||||
return score;
|
||||
}, [formik.values.name, formik.values.code, formik.values.category, formik.values.attributeSetId, formik.values.channels, formik.values.assetRequirements]);
|
||||
|
||||
const assignedAssetFamiliesList = useMemo(() => {
|
||||
const assignedIds = new Set(formik.values.assetRequirements || []);
|
||||
return assetFamiliesList.filter((af: any) => assignedIds.has(af.id));
|
||||
}, [assetFamiliesList, formik.values.assetRequirements]);
|
||||
|
||||
const filteredUnassignedAssetFamilies = useMemo(() => {
|
||||
const assignedIds = new Set(formik.values.assetRequirements || []);
|
||||
let unassigned = assetFamiliesList.filter((af: any) => !assignedIds.has(af.id));
|
||||
if (assetFamilySearchQuery.trim()) {
|
||||
const q = assetFamilySearchQuery.toLowerCase().trim();
|
||||
unassigned = unassigned.filter((af: any) =>
|
||||
(af.name && af.name.toLowerCase().includes(q)) ||
|
||||
(af.code && af.code.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return unassigned;
|
||||
}, [assetFamiliesList, formik.values.assetRequirements, assetFamilySearchQuery]);
|
||||
|
||||
// Selected attributes list for Step 3 Axis Filtering
|
||||
const selectedAttributesList = useMemo(() => {
|
||||
const selectedIds = new Set(formik.values.attributes || []);
|
||||
@@ -402,6 +490,16 @@ export default function NewFamily() {
|
||||
|
||||
const handleSaveFamily = async () => {
|
||||
setSubmitError(null);
|
||||
|
||||
// Validate completeness rules sum to 100%
|
||||
const rulesSum = Object.values(formik.values.completenessRules || {}).reduce((sum, v) => sum + (Number(v) || 0), 0);
|
||||
if (rulesSum !== 100) {
|
||||
const msg = `Completeness rules weights must equal 100%. Current sum: ${rulesSum}%`;
|
||||
notify.error(msg);
|
||||
setSubmitError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = await formik.validateForm();
|
||||
const errorFields = Object.keys(errors);
|
||||
|
||||
@@ -561,6 +659,47 @@ export default function NewFamily() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Completeness Score */}
|
||||
<div className="mx-3 mb-3 border border-primary/10 rounded-lg p-3 bg-primary/5">
|
||||
<p className="text-[10px] font-semibold text-primary uppercase tracking-widest mb-2">Completeness</p>
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="relative w-16 h-16">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="45" fill="none" stroke="var(--color-border)" strokeWidth="10" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="var(--color-primary)"
|
||||
strokeWidth="10"
|
||||
strokeDasharray="283"
|
||||
strokeDashoffset={283 - (283 * familyCompleteness) / 100}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
{familyCompleteness}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-[11px]">
|
||||
{[
|
||||
{ label: 'Variants', value: String(formik.values.variantAxes?.length || 0) },
|
||||
{ label: 'Assets', value: String(formik.values.assetRequirements?.length || 0) },
|
||||
{ label: 'Category', value: formik.values.category ? '1' : '0' },
|
||||
{ label: 'Channels', value: String(formik.values.channels?.length || 0) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-bold text-foreground">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
@@ -686,55 +825,215 @@ export default function NewFamily() {
|
||||
{/* ── Attribute Assignment ── */}
|
||||
{activeTab === 'attributes' && (
|
||||
<Card>
|
||||
<CardHeader title="Attribute Set Blueprint Groups" subtitle="Attributes are defined by the Attribute Set. Expand groups to enable/disable optional items." />
|
||||
<CardHeader title="Attribute Set Blueprint Groups" subtitle="Attributes are defined by the Attribute Set. Filter, search, or quick-create attributes inline." />
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Row: Attribute Set Select */}
|
||||
<div className="border-b border-border pb-5">
|
||||
<label className={labelClass}>Attribute Set <span className="text-red-400">*</span></label>
|
||||
<Select
|
||||
name="attributeSetId"
|
||||
value={formik.values.attributeSetId}
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
const setId = e.target.value;
|
||||
const selectedSet = attributeSetsList.find(s => s.id === setId);
|
||||
if (selectedSet && selectedSet.groups) {
|
||||
const inherited: string[] = [];
|
||||
for (const g of selectedSet.groups) {
|
||||
if (g.attributes) {
|
||||
for (const a of g.attributes) {
|
||||
inherited.push(a.id || a);
|
||||
|
||||
{/* Top Control Panel */}
|
||||
<div className="bg-surface-muted/40 border border-border rounded-xl p-4 space-y-4 font-sans">
|
||||
{/* Row 1: Attribute Set & Attribute Group Selectors */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Set <span className="text-red-400">*</span></label>
|
||||
<div ref={attributeSetDropdownRef} className="relative">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!isView) {
|
||||
setIsAttributeSetDropdownOpen(!isAttributeSetDropdownOpen);
|
||||
}
|
||||
}
|
||||
}
|
||||
formik.setFieldValue('attributes', [...new Set(inherited)]);
|
||||
} else {
|
||||
formik.setFieldValue('attributes', []);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Select an Attribute Set...</option>
|
||||
{attributeSetsList.map((set) => (
|
||||
<option key={set.id} value={set.id}>{set.name} ({set.code})</option>
|
||||
))}
|
||||
</Select>
|
||||
{formik.touched.attributeSetId && formik.errors.attributeSetId && (
|
||||
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.attributeSetId}</div>
|
||||
)}
|
||||
}}
|
||||
className={`w-full border rounded-lg px-3 py-2 text-sm flex items-center justify-between bg-surface ${
|
||||
formik.touched.attributeSetId && formik.errors.attributeSetId ? 'border-red-400' : 'border-border'
|
||||
} ${isView ? 'cursor-not-allowed opacity-75' : 'cursor-pointer hover:border-primary/30'}`}
|
||||
>
|
||||
{(() => {
|
||||
const selectedObj = attributeSetsList.find((s: any) => String(s.id) === String(formik.values.attributeSetId));
|
||||
return (
|
||||
<span className={selectedObj?.name ? 'text-foreground font-medium' : 'text-muted-foreground'}>
|
||||
{selectedObj?.name
|
||||
? `${selectedObj.name} (${selectedObj.code})`
|
||||
: 'Select Attribute Set...'}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{formik.values.attributeSetId && !isView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
formik.setFieldValue('attributeSetId', '');
|
||||
formik.setFieldValue('attributes', []);
|
||||
setSelectedGroupFilter('');
|
||||
setAttributeSearchQuery('');
|
||||
setBlueprintPreview(null);
|
||||
}}
|
||||
className="p-0.5 hover:bg-background rounded-full text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAttributeSetDropdownOpen && (
|
||||
<div className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden flex flex-col max-h-60 animate-in fade-in zoom-in-95 duration-100 font-sans">
|
||||
<div className="p-2 border-b border-border bg-surface-muted flex items-center gap-2">
|
||||
<Search className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={attributeSetSearchQuery}
|
||||
onChange={(e) => setAttributeSetSearchQuery(e.target.value)}
|
||||
placeholder="Search by name or code..."
|
||||
autoFocus
|
||||
className="w-full px-2 py-1 text-xs border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary bg-background text-foreground font-sans"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 overflow-y-auto flex-1 space-y-1">
|
||||
{filteredAttributeSets.length > 0 ? (
|
||||
filteredAttributeSets.map((s: any) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
formik.setFieldValue('attributeSetId', s.id);
|
||||
setSelectedGroupFilter('');
|
||||
setAttributeSearchQuery('');
|
||||
if (s.groups) {
|
||||
const inherited: string[] = [];
|
||||
for (const g of s.groups) {
|
||||
if (g.attributes) {
|
||||
for (const a of g.attributes) {
|
||||
inherited.push(a.id || a);
|
||||
}
|
||||
}
|
||||
}
|
||||
formik.setFieldValue('attributes', [...new Set(inherited)]);
|
||||
} else {
|
||||
formik.setFieldValue('attributes', []);
|
||||
}
|
||||
loadBlueprintPreview(s.id);
|
||||
setIsAttributeSetDropdownOpen(false);
|
||||
setAttributeSetSearchQuery('');
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
|
||||
formik.values.attributeSetId === s.id
|
||||
? 'bg-primary/10 text-primary font-bold'
|
||||
: 'text-foreground hover:bg-background/50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">{s.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono mt-0.5">Code: {s.code}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-muted-foreground">No Attribute Sets found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{formik.touched.attributeSetId && formik.errors.attributeSetId && (
|
||||
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.attributeSetId}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Attribute Group</label>
|
||||
<Select
|
||||
value={selectedGroupFilter}
|
||||
disabled={!formik.values.attributeSetId || isView}
|
||||
onChange={(e) => setSelectedGroupFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All Attribute Groups</option>
|
||||
{(() => {
|
||||
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
|
||||
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
|
||||
return availableGroups.map((g: any) => (
|
||||
<option key={g.id || g.code} value={g.id || g.code}>{g.name} ({g.code})</option>
|
||||
));
|
||||
})()}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Search Input + Quick Create Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex items-center flex-1">
|
||||
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={attributeSearchQuery}
|
||||
onChange={(e) => setAttributeSearchQuery(e.target.value)}
|
||||
placeholder="Search attributes..."
|
||||
disabled={!formik.values.attributeSetId || isView}
|
||||
className="w-full border border-border focus:ring-primary rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground"
|
||||
/>
|
||||
{attributeSearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttributeSearchQuery('')}
|
||||
className="absolute right-3 p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
|
||||
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
|
||||
setInlineAttributeGroupId(availableGroups[0]?.id || '');
|
||||
setShowAttributeModal(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors shrink-0 h-[38px] cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Create Attribute
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attribute Groups & Attributes List */}
|
||||
{formik.values.attributeSetId ? (
|
||||
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-2">
|
||||
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-2 font-sans">
|
||||
{(() => {
|
||||
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
|
||||
const groupsToRender = blueprintPreview?.groups || selectedSet?.groups || [];
|
||||
let groupsToRender = blueprintPreview?.groups || selectedSet?.groups || [];
|
||||
|
||||
if (groupsToRender.length === 0) {
|
||||
return <div className="text-center py-8 text-muted-foreground text-sm">No groups found in this Attribute Set.</div>;
|
||||
// Filter by Attribute Group if selected
|
||||
if (selectedGroupFilter) {
|
||||
groupsToRender = groupsToRender.filter((g: any) =>
|
||||
String(g.id) === String(selectedGroupFilter) || String(g.code) === String(selectedGroupFilter)
|
||||
);
|
||||
}
|
||||
|
||||
return groupsToRender.map((group: any) => {
|
||||
if (groupsToRender.length === 0) {
|
||||
return <div className="text-center py-8 text-muted-foreground text-sm">No attribute groups match the selected filter.</div>;
|
||||
}
|
||||
|
||||
let totalMatchingAttributes = 0;
|
||||
|
||||
const renderedGroups = groupsToRender.map((group: any) => {
|
||||
const isExpanded = expandedGroups[group.id] !== false; // default true
|
||||
const groupAttributes = group.attributes || [];
|
||||
let groupAttributes = group.attributes || [];
|
||||
|
||||
// Filter attributes by search query
|
||||
if (attributeSearchQuery.trim()) {
|
||||
const q = attributeSearchQuery.toLowerCase().trim();
|
||||
groupAttributes = groupAttributes.filter((attr: any) =>
|
||||
(attr.name && attr.name.toLowerCase().includes(q)) ||
|
||||
(attr.code && attr.code.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
totalMatchingAttributes += groupAttributes.length;
|
||||
|
||||
if (attributeSearchQuery.trim() && groupAttributes.length === 0) {
|
||||
return null; // Skip rendering empty group during active search
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.id} className="border border-border rounded-lg overflow-hidden bg-surface shadow-xs">
|
||||
@@ -760,13 +1059,12 @@ export default function NewFamily() {
|
||||
<div className="p-4 grid grid-cols-2 gap-3 bg-surface">
|
||||
{groupAttributes.map((attr: any) => {
|
||||
const isChecked = formik.values.attributes.includes(attr.id);
|
||||
const isRequired = attr.is_required || attr.isRequired;
|
||||
const isVariant = attr.is_variant_eligible || attr.is_variant_axis || attr.isVariantEligible;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isView) return;
|
||||
const current = [...formik.values.attributes];
|
||||
if (isChecked) {
|
||||
if (isRequired) return; // Prevent disabling required fields
|
||||
formik.setFieldValue('attributes', current.filter(id => id !== attr.id));
|
||||
// Remove from variant axes if deselected
|
||||
formik.setFieldValue('variantAxes', formik.values.variantAxes.filter(id => id !== attr.id));
|
||||
@@ -793,7 +1091,6 @@ export default function NewFamily() {
|
||||
<div className="font-semibold text-sm text-foreground">{attr.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{attr.code} • {attr.type?.toUpperCase()}
|
||||
{isRequired && <span className="ml-2 text-red-500 font-bold">* Required</span>}
|
||||
{isVariant && <span className="ml-2 text-primary font-medium">(Variant Axis)</span>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -810,11 +1107,17 @@ export default function NewFamily() {
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (attributeSearchQuery.trim() && totalMatchingAttributes === 0) {
|
||||
return <div className="text-center py-8 text-muted-foreground text-sm">No attributes match search "{attributeSearchQuery}".</div>;
|
||||
}
|
||||
|
||||
return renderedGroups;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
⚠️ Please select an Attribute Set in Step 1 (Basic Details) to view and assign inherited attributes.
|
||||
⚠️ Please select an Attribute Set to view, filter, and assign attributes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -888,39 +1191,157 @@ export default function NewFamily() {
|
||||
{/* ── Asset Families ── */}
|
||||
{activeTab === 'assets' && (
|
||||
<Card>
|
||||
<CardHeader title="Asset Families" subtitle="Select which asset families are assigned to this product family" />
|
||||
<div className="p-6 space-y-2">
|
||||
{assetFamiliesList.map((assetFamily) => {
|
||||
const isChecked = formik.values.assetRequirements.includes(assetFamily.id);
|
||||
const handleToggle = () => {
|
||||
const current = [...formik.values.assetRequirements];
|
||||
if (isChecked) {
|
||||
formik.setFieldValue('assetRequirements', current.filter(id => id !== assetFamily.id));
|
||||
} else {
|
||||
formik.setFieldValue('assetRequirements', [...current, assetFamily.id]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<label key={assetFamily.id} onClick={handleToggle} className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${isChecked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-border hover:border-primary/10 hover:bg-primary/5/20'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<input type="checkbox" checked={isChecked} readOnly className="w-4 h-4 text-primary rounded border-border focus:ring-primary-light" />
|
||||
<div>
|
||||
<div className="font-medium text-sm text-foreground">{assetFamily.name}</div>
|
||||
{assetFamily.assetTypes && assetFamily.assetTypes.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Expected Types: {assetFamily.assetTypes.map((t: any) => t.name).join(', ')}
|
||||
<CardHeader
|
||||
title="Asset Families Configuration"
|
||||
subtitle="Search and assign asset families to configure asset requirements for products in this family."
|
||||
/>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Searchable Asset Family Selector */}
|
||||
<div className="bg-surface-muted/40 border border-border rounded-xl p-4 font-sans space-y-3">
|
||||
<label className={labelClass}>Assign Asset Family</label>
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center">
|
||||
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={assetFamilySearchQuery}
|
||||
onChange={(e) => {
|
||||
setAssetFamilySearchQuery(e.target.value);
|
||||
setIsAssetFamilyDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsAssetFamilyDropdownOpen(true)}
|
||||
placeholder="Search asset family by name or code to assign..."
|
||||
disabled={isView}
|
||||
className="w-full border border-border focus:ring-primary rounded-lg pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-surface text-foreground"
|
||||
/>
|
||||
{assetFamilySearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAssetFamilySearchQuery('')}
|
||||
className="absolute right-3 p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown list for search & select */}
|
||||
{isAssetFamilyDropdownOpen && (
|
||||
<div
|
||||
ref={assetFamilyDropdownRef}
|
||||
className="absolute z-50 mt-1.5 w-full bg-surface border border-border rounded-xl shadow-xl overflow-hidden max-h-60 overflow-y-auto animate-in fade-in zoom-in-95 duration-100"
|
||||
>
|
||||
{filteredUnassignedAssetFamilies.length > 0 ? (
|
||||
filteredUnassignedAssetFamilies.map((family: any) => (
|
||||
<div
|
||||
key={family.id}
|
||||
onClick={() => {
|
||||
if (!formik.values.assetRequirements.includes(family.id)) {
|
||||
formik.setFieldValue('assetRequirements', [...formik.values.assetRequirements, family.id]);
|
||||
}
|
||||
setAssetFamilySearchQuery('');
|
||||
setIsAssetFamilyDropdownOpen(false);
|
||||
}}
|
||||
className="px-4 py-3 border-b border-border/50 last:border-b-0 hover:bg-background/80 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="font-semibold text-sm text-foreground">{family.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono mt-0.5">Code: {family.code}</div>
|
||||
{family.assetTypes && family.assetTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{family.assetTypes.map((t: any) => (
|
||||
<span key={t.id || t.name} className="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-[10px] font-medium">
|
||||
{t.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-muted-foreground">
|
||||
{assetFamiliesList.length === 0
|
||||
? 'No asset families available.'
|
||||
: 'All matching asset families have been assigned.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isChecked && <CheckCircle2 className="w-4 h-4 text-primary" />}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{assetFamiliesList.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">No asset families available.</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Asset Families List */}
|
||||
<div className="space-y-3 font-sans">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">
|
||||
Assigned Asset Families ({assignedAssetFamiliesList.length})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{assignedAssetFamiliesList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{assignedAssetFamiliesList.map((family: any) => {
|
||||
const assetTypes = family.assetTypes || [];
|
||||
return (
|
||||
<div
|
||||
key={family.id}
|
||||
className="flex items-start justify-between p-4 rounded-xl border border-primary/20 bg-primary/5/30 shadow-xs transition-all"
|
||||
>
|
||||
<div className="space-y-2 flex-1 mr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="font-semibold text-sm text-foreground">{family.name}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">({family.code})</span>
|
||||
</div>
|
||||
|
||||
{/* Asset Type Classifications associated with this Asset Family */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-muted-foreground mb-1">
|
||||
Asset Type Classifications ({assetTypes.length}):
|
||||
</div>
|
||||
{assetTypes.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assetTypes.map((type: any) => (
|
||||
<span
|
||||
key={type.id || type.name}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-surface border border-border rounded-md text-xs font-medium text-foreground shadow-2xs"
|
||||
>
|
||||
<ImageIcon className="w-3 h-3 text-muted-foreground" />
|
||||
{type.name}
|
||||
{type.code && <code className="text-[10px] text-muted-foreground">({type.code})</code>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">No classifications defined for this asset family.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isView && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
formik.setFieldValue(
|
||||
'assetRequirements',
|
||||
formik.values.assetRequirements.filter((id: string) => id !== family.id)
|
||||
);
|
||||
}}
|
||||
className="p-1.5 hover:bg-red-50 text-muted-foreground hover:text-red-500 rounded-lg transition-colors shrink-0 cursor-pointer"
|
||||
title="Unassign Asset Family"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 border border-dashed border-border rounded-xl text-center text-sm text-muted-foreground bg-surface-muted/20">
|
||||
No Asset Families assigned to this Product Family yet. Search and select an Asset Family above to assign.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -1053,57 +1474,6 @@ export default function NewFamily() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Completeness Rules ── */}
|
||||
{activeTab === 'rules' && (
|
||||
<Card>
|
||||
<CardHeader title="Completeness Rules" subtitle="Define weights that sum up to 100%" />
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-primary/5 border border-primary/10 rounded-lg px-4 py-3 flex items-start gap-3">
|
||||
<AlertCircle className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-primary-dark">
|
||||
Configure weights for completeness rules. The sum of all weights must equal exactly 100%. Current sum: {' '}
|
||||
<strong className="underline">
|
||||
{Object.values(formik.values.completenessRules).reduce((sum, v) => sum + (Number(v) || 0), 0)}%
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'required_attributes', name: 'All required attributes filled' },
|
||||
{ key: 'at_least_one_image', name: 'At least one product image' },
|
||||
{ key: 'marketing_content', name: 'Marketing content complete' },
|
||||
{ key: 'tech_specs', name: 'Technical specifications complete' },
|
||||
{ key: 'skus_assigned', name: 'All variants have SKUs' }
|
||||
].map((rule) => {
|
||||
const value = formik.values.completenessRules[rule.key] ?? 0;
|
||||
const handleWeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = Math.max(0, parseInt(e.target.value) || 0);
|
||||
formik.setFieldValue(`completenessRules.${rule.key}`, val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={rule.key} className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface shadow-xs">
|
||||
<div className="font-medium text-sm text-foreground">{rule.name}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={value}
|
||||
onChange={handleWeightChange}
|
||||
className="w-20 border border-border rounded-lg px-3 py-1.5 text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
/>
|
||||
<span className="text-sm font-semibold text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Inheritance Summary ── */}
|
||||
{activeTab === 'summary' && (
|
||||
<Card>
|
||||
@@ -1206,6 +1576,176 @@ export default function NewFamily() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Create Attribute Modal */}
|
||||
{showAttributeModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs animate-in fade-in duration-150">
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-2xl p-6 w-full max-w-md animate-in zoom-in-95 duration-200 overflow-y-auto max-h-[90vh] font-sans">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-border pb-3">
|
||||
<h3 className="font-bold text-foreground text-base">Quick Create Attribute</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAttributeModal(false)}
|
||||
className="p-1 hover:bg-background rounded text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inlineAttributeName}
|
||||
onChange={(e) => setInlineAttributeName(e.target.value)}
|
||||
placeholder="e.g. Pack Size"
|
||||
className="w-full border border-border focus:ring-primary rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Type *</label>
|
||||
<Select
|
||||
value={inlineAttributeType}
|
||||
onChange={(e) => setInlineAttributeType(e.target.value)}
|
||||
>
|
||||
<option value="text">Text</option>
|
||||
<option value="textarea">Textarea</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="select">Select (Dropdown)</option>
|
||||
<option value="multiselect">Multi-select</option>
|
||||
<option value="date">Date</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(inlineAttributeType === 'select' || inlineAttributeType === 'multiselect') && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground block mb-1">Options (Comma separated) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inlineAttributeOptions}
|
||||
onChange={(e) => setInlineAttributeOptions(e.target.value)}
|
||||
placeholder="e.g. Small, Medium, Large"
|
||||
className="w-full border border-border focus:ring-primary rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-background text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground block mb-1">Attribute Group</label>
|
||||
<Select
|
||||
value={inlineAttributeGroupId}
|
||||
onChange={(e) => setInlineAttributeGroupId(e.target.value)}
|
||||
>
|
||||
{(() => {
|
||||
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
|
||||
const availableGroups = blueprintPreview?.groups || selectedSet?.groups || attributeGroupsList || [];
|
||||
return availableGroups.map((g: any) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
));
|
||||
})()}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-xs font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inlineAttributeRequired}
|
||||
onChange={(e) => setInlineAttributeRequired(e.target.checked)}
|
||||
className="w-4 h-4 text-primary rounded border-border focus:ring-primary"
|
||||
/>
|
||||
Mark as Required
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 border-t border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAttributeModal(false)}
|
||||
className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-foreground hover:bg-background bg-surface transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
inlineAttributeSubmitting ||
|
||||
!inlineAttributeName.trim() ||
|
||||
!inlineAttributeGroupId ||
|
||||
((inlineAttributeType === 'select' || inlineAttributeType === 'multiselect') && !inlineAttributeOptions.trim())
|
||||
}
|
||||
onClick={async () => {
|
||||
setInlineAttributeSubmitting(true);
|
||||
let createdAttr: any;
|
||||
try {
|
||||
const opts = (inlineAttributeType === 'select' || inlineAttributeType === 'multiselect')
|
||||
? inlineAttributeOptions.split(',').map((o: string) => o.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const attrPayload: any = {
|
||||
name: inlineAttributeName.trim(),
|
||||
type: inlineAttributeType,
|
||||
isRequired: inlineAttributeRequired,
|
||||
options: opts,
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
createdAttr = await createAttribute(attrPayload as any);
|
||||
} catch (err) {
|
||||
notify.error('Failed to create attribute');
|
||||
setInlineAttributeSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newId = createdAttr?.id || (createdAttr as any)?.data?.id;
|
||||
|
||||
if (newId && inlineAttributeGroupId) {
|
||||
const groupDetails = await attributeGroupsService.getById(inlineAttributeGroupId);
|
||||
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);
|
||||
|
||||
await attributeGroupsService.update(inlineAttributeGroupId, {
|
||||
attributes: [...existingAttributeIds, newId]
|
||||
} as any);
|
||||
|
||||
// Add new attribute to form attributes list
|
||||
if (!formik.values.attributes.includes(newId)) {
|
||||
formik.setFieldValue('attributes', [...formik.values.attributes, newId]);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAttributes();
|
||||
fetchAttributeGroups();
|
||||
if (formik.values.attributeSetId) {
|
||||
await loadBlueprintPreview(formik.values.attributeSetId);
|
||||
}
|
||||
notify.success('Attribute created and added to group successfully');
|
||||
} catch (assocErr) {
|
||||
console.error("Failed to associate attribute with group:", assocErr);
|
||||
} finally {
|
||||
setInlineAttributeName('');
|
||||
setInlineAttributeType('text');
|
||||
setInlineAttributeRequired(false);
|
||||
setInlineAttributeOptions('');
|
||||
setShowAttributeModal(false);
|
||||
setInlineAttributeSubmitting(false);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{inlineAttributeSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
|
||||
@@ -18,8 +18,8 @@ interface VariantMatrixViewProps {
|
||||
|
||||
export const VariantMatrixView: React.FC<VariantMatrixViewProps> = ({
|
||||
variants,
|
||||
axesKeys,
|
||||
axesNames,
|
||||
axesKeys: _axesKeys,
|
||||
axesNames: _axesNames,
|
||||
selectedIds,
|
||||
onSelectChange,
|
||||
onSelectAllChange,
|
||||
|
||||
@@ -1378,10 +1378,12 @@ export default function NewProduct() {
|
||||
<div className="space-y-1.5 text-[11px]">
|
||||
{[
|
||||
{ label: 'Variants', value: String(product?.variants?.length || 0) },
|
||||
{ label: 'Assets', value: String(
|
||||
(product?.productAssets?.length || 0) +
|
||||
(product?.variants || []).reduce((s: number, v: any) => s + (v.images?.length ?? 0), 0)
|
||||
)},
|
||||
{
|
||||
label: 'Assets', value: String(
|
||||
(product?.productAssets?.length || 0) +
|
||||
(product?.variants || []).reduce((s: number, v: any) => s + (v.images?.length ?? 0), 0)
|
||||
)
|
||||
},
|
||||
{ label: 'Category', value: formik.values.category ? '1' : '0' },
|
||||
{ label: 'Channels', value: String((formik.values.metadata?.channels?.length || 0) + (family?.channels?.length || 0)) },
|
||||
].map(({ label, value }) => (
|
||||
|
||||
Reference in New Issue
Block a user