diff --git a/src/features/attribute-groups/pages/NewAttributeGroup.tsx b/src/features/attribute-groups/pages/NewAttributeGroup.tsx index fff1193..c405a87 100644 --- a/src/features/attribute-groups/pages/NewAttributeGroup.tsx +++ b/src/features/attribute-groups/pages/NewAttributeGroup.tsx @@ -197,20 +197,6 @@ export default function NewAttributeGroup() {
-
- - -

Unique identifier (snake_case)

- {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} -
{formik.touched.name && formik.errors.name &&

{formik.errors.name}

}
+
+ + +

Unique identifier (snake_case)

+ {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} +
diff --git a/src/features/attribute-sets/pages/NewAttributeSet.tsx b/src/features/attribute-sets/pages/NewAttributeSet.tsx index a76b8ab..aaf0e3f 100644 --- a/src/features/attribute-sets/pages/NewAttributeSet.tsx +++ b/src/features/attribute-sets/pages/NewAttributeSet.tsx @@ -228,20 +228,6 @@ export default function NewAttributeSet() {
-
- - -

Unique identifier (snake_case/kebab-case)

- {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} -
{formik.touched.name && formik.errors.name &&

{formik.errors.name}

}
+
+ + +

Unique identifier (snake_case/kebab-case)

+ {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} +
diff --git a/src/features/attributes/pages/NewAttribute.tsx b/src/features/attributes/pages/NewAttribute.tsx index 0dceb81..bcec6cb 100644 --- a/src/features/attributes/pages/NewAttribute.tsx +++ b/src/features/attributes/pages/NewAttribute.tsx @@ -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() {
-
- - - {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} -
{formik.touched.name && formik.errors.name &&

{formik.errors.name}

}
+
+ + + {formik.touched.code && formik.errors.code &&

{formik.errors.code}

} +
diff --git a/src/features/family/pages/NewFamily.tsx b/src/features/family/pages/NewFamily.tsx index c81422a..ffede8b 100644 --- a/src/features/family/pages/NewFamily.tsx +++ b/src/features/family/pages/NewFamily.tsx @@ -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>({}); const [submitError, setSubmitError] = useState(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(null); + + // Step 5 Asset Families Search & Dropdown state & ref + const [assetFamilySearchQuery, setAssetFamilySearchQuery] = useState(''); + const [isAssetFamilyDropdownOpen, setIsAssetFamilyDropdownOpen] = useState(false); + const assetFamilyDropdownRef = useRef(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 = { 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() { ); })} + + {/* Completeness Score */} +
+

Completeness

+
+
+ + + + +
+ + {familyCompleteness}% + +
+
+
+
+ {[ + { 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 }) => ( +
+ {label} + {value} +
+ ))} +
+
{/* Content */} @@ -686,55 +825,215 @@ export default function NewFamily() { {/* ── Attribute Assignment ── */} {activeTab === 'attributes' && ( - +
- {/* Row: Attribute Set Select */} -
- - - {formik.touched.attributeSetId && formik.errors.attributeSetId && ( -
{formik.errors.attributeSetId}
- )} + }} + 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 ( + + {selectedObj?.name + ? `${selectedObj.name} (${selectedObj.code})` + : 'Select Attribute Set...'} + + ); + })()} +
+ {formik.values.attributeSetId && !isView && ( + + )} + +
+
+ + {isAttributeSetDropdownOpen && ( +
+
+ + 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" + /> +
+
+ {filteredAttributeSets.length > 0 ? ( + filteredAttributeSets.map((s: any) => ( +
{ + 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' + }`} + > +
{s.name}
+
Code: {s.code}
+
+ )) + ) : ( +
No Attribute Sets found.
+ )} +
+
+ )} +
+ {formik.touched.attributeSetId && formik.errors.attributeSetId && ( +
{formik.errors.attributeSetId}
+ )} +
+ +
+ + +
+
+ + {/* Row 2: Search Input + Quick Create Button */} +
+
+ + 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 && ( + + )} +
+ + {!isView && ( + + )} +
+ + {/* Attribute Groups & Attributes List */} {formik.values.attributeSetId ? ( -
+
{(() => { 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
No groups found in this Attribute Set.
; + // 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
No attribute groups match the selected filter.
; + } + + 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 (
@@ -760,13 +1059,12 @@ export default function NewFamily() {
{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() {
{attr.name}
{attr.code} • {attr.type?.toUpperCase()} - {isRequired && * Required} {isVariant && (Variant Axis)}
@@ -810,11 +1107,17 @@ export default function NewFamily() {
); }); + + if (attributeSearchQuery.trim() && totalMatchingAttributes === 0) { + return
No attributes match search "{attributeSearchQuery}".
; + } + + return renderedGroups; })()}
) : (
- ⚠️ 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.
)}
@@ -888,39 +1191,157 @@ export default function NewFamily() { {/* ── Asset Families ── */} {activeTab === 'assets' && ( - -
- {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 ( -
+ + {/* Quick Create Attribute Modal */} + {showAttributeModal && ( +
+
+
+

Quick Create Attribute

+ +
+
+
+ + 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" + /> +
+
+ + +
+ + {(inlineAttributeType === 'select' || inlineAttributeType === 'multiselect') && ( +
+ + 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" + /> +
+ )} + +
+ + +
+ +
+ +
+
+ +
+ + +
+
+
+ )}
); diff --git a/src/features/product/pages/NewProduct.tsx b/src/features/product/pages/NewProduct.tsx index 005b355..e81f896 100644 --- a/src/features/product/pages/NewProduct.tsx +++ b/src/features/product/pages/NewProduct.tsx @@ -1378,10 +1378,12 @@ export default function NewProduct() {
{[ { 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 }) => (