Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ffa4f978 | ||
|
|
3da12a9e49 | ||
|
|
d7b5093693 | ||
|
|
1d9f7840cd |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleApply} className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
{actionType === 'price' && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
@@ -138,13 +138,18 @@ export const VariantBulkActions: React.FC<VariantBulkActionsProps> = ({
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleApply(e);
|
||||
}}
|
||||
className="px-2.5 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import type { Variant, VariantStatus } from '../../types/variant.types';
|
||||
import { assetsService } from '../../../assets/services/assets.service';
|
||||
import { integrationsService } from '../../../integrations/services/integrations.service';
|
||||
import type { Integration } from '../../../integrations/types/integrations.types';
|
||||
import type { Asset } from '../../../assets/types/assets.types';
|
||||
import {
|
||||
X, Save, Archive, Trash2, Image as ImageIcon,
|
||||
Package, Tag, DollarSign, CheckCircle, AlertCircle, Loader2,
|
||||
ShoppingBag, Hash
|
||||
ShoppingBag, Hash, Upload, ExternalLink, Globe, Layers, Plus, Star
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface VariantDetailModalProps {
|
||||
variant: Variant | null;
|
||||
@@ -23,65 +28,186 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
onArchive,
|
||||
readOnly = false
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'media' | 'channels'>('general');
|
||||
|
||||
// Form state
|
||||
const [sku, setSku] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [costPrice, setCostPrice] = useState('');
|
||||
const [stock, setStock] = useState('');
|
||||
const [status, setStatus] = useState<VariantStatus>('draft');
|
||||
const [activeImageIdx, setActiveImageIdx] = useState(0);
|
||||
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
// Media Assets state
|
||||
const [variantAssets, setVariantAssets] = useState<any[]>([]);
|
||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||
const [uploadingMedia, setUploadingMedia] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Asset Library Picker modal state
|
||||
const [showAssetPicker, setShowAssetPicker] = useState(false);
|
||||
const [libraryAssets, setLibraryAssets] = useState<Asset[]>([]);
|
||||
const [loadingLibrary, setLoadingLibrary] = useState(false);
|
||||
|
||||
// Channels state
|
||||
const [integrations, setIntegrations] = useState<Integration[]>([]);
|
||||
const [loadingIntegrations, setLoadingIntegrations] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant) {
|
||||
setSku(variant.sku || '');
|
||||
setName(variant.name || '');
|
||||
setPrice(String(variant.price ?? ''));
|
||||
setCostPrice(String(variant.costPrice ?? ''));
|
||||
setStock(String(variant.stock ?? ''));
|
||||
setStatus(variant.status || 'draft');
|
||||
setActiveImageIdx(0);
|
||||
setSaveState('idle');
|
||||
|
||||
// Load variant assets
|
||||
loadVariantAssets(variant.id);
|
||||
// Load integrations / channel status
|
||||
loadIntegrations();
|
||||
}
|
||||
}, [variant]);
|
||||
|
||||
if (!variant) return null;
|
||||
const loadVariantAssets = async (variantId: string) => {
|
||||
setLoadingAssets(true);
|
||||
try {
|
||||
const data = await assetsService.getVariantAssets(variantId);
|
||||
setVariantAssets(data || []);
|
||||
} catch {
|
||||
// fallback to variant.images if API call fails
|
||||
if (variant?.images) {
|
||||
setVariantAssets(variant.images);
|
||||
}
|
||||
} finally {
|
||||
setLoadingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
const images = variant.images || [];
|
||||
const primaryImage = images.find(i => i.isPrimary) || images[0];
|
||||
const activeImage = images[activeImageIdx] || primaryImage;
|
||||
const loadIntegrations = async () => {
|
||||
setLoadingIntegrations(true);
|
||||
try {
|
||||
const list = await integrationsService.getAll();
|
||||
setIntegrations(list || []);
|
||||
} catch {
|
||||
setIntegrations([]);
|
||||
} finally {
|
||||
setLoadingIntegrations(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!variant) return null;
|
||||
|
||||
const axisEntries = Object.entries(variant.attributes || {});
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
const pNum = parseFloat(price);
|
||||
const cpNum = parseFloat(costPrice);
|
||||
const sNum = parseInt(stock, 10);
|
||||
|
||||
const hasChanges =
|
||||
sku !== variant.sku ||
|
||||
pNum !== variant.price ||
|
||||
cpNum !== variant.costPrice ||
|
||||
sNum !== variant.stock ||
|
||||
status !== variant.status;
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
setSaveState('saving');
|
||||
try {
|
||||
await onUpdate(variant.id, {
|
||||
sku,
|
||||
name,
|
||||
price: isNaN(pNum) ? 0 : pNum,
|
||||
costPrice: isNaN(cpNum) ? 0 : cpNum,
|
||||
stock: isNaN(sNum) ? 0 : sNum,
|
||||
status
|
||||
});
|
||||
setSaveState('saved');
|
||||
toast.success('Variant updated successfully');
|
||||
setTimeout(() => setSaveState('idle'), 2000);
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
setSaveState('error');
|
||||
toast.error(err?.message || 'Failed to save variant changes');
|
||||
setTimeout(() => setSaveState('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image file directly for this variant
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setUploadingMedia(true);
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const uploadedAsset: any = await assetsService.upload(file);
|
||||
if (uploadedAsset && uploadedAsset.id) {
|
||||
await assetsService.assignVariantAsset(variant.id, {
|
||||
asset_id: uploadedAsset.id,
|
||||
role: 'gallery_image',
|
||||
is_primary: variantAssets.length === 0
|
||||
});
|
||||
}
|
||||
}
|
||||
toast.success('Image(s) uploaded and assigned to variant');
|
||||
await loadVariantAssets(variant.id);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to upload image asset');
|
||||
} finally {
|
||||
setUploadingMedia(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open asset library picker
|
||||
const openAssetPicker = async () => {
|
||||
setShowAssetPicker(true);
|
||||
setLoadingLibrary(true);
|
||||
try {
|
||||
const res: any = await assetsService.getAll();
|
||||
const list = Array.isArray(res) ? res : res?.data || [];
|
||||
setLibraryAssets(list);
|
||||
} catch {
|
||||
toast.error('Failed to load asset library');
|
||||
} finally {
|
||||
setLoadingLibrary(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Assign existing asset from library to variant
|
||||
const handleAssignLibraryAsset = async (assetId: string) => {
|
||||
try {
|
||||
await assetsService.assignVariantAsset(variant.id, {
|
||||
asset_id: assetId,
|
||||
role: 'gallery_image',
|
||||
is_primary: variantAssets.length === 0
|
||||
});
|
||||
toast.success('Asset assigned to variant');
|
||||
await loadVariantAssets(variant.id);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to assign asset');
|
||||
}
|
||||
};
|
||||
|
||||
// Set primary asset for variant
|
||||
const handleSetPrimaryAsset = async (assetId: string) => {
|
||||
try {
|
||||
await assetsService.updateVariantAsset(variant.id, assetId, { is_primary: true });
|
||||
toast.success('Primary image updated');
|
||||
await loadVariantAssets(variant.id);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to update primary image');
|
||||
}
|
||||
};
|
||||
|
||||
// Unassign asset from variant
|
||||
const handleUnassignAsset = async (assetId: string) => {
|
||||
try {
|
||||
await assetsService.unassignVariantAsset(variant.id, assetId);
|
||||
toast.info('Asset removed from variant');
|
||||
await loadVariantAssets(variant.id);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Failed to remove asset');
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor: Record<VariantStatus, string> = {
|
||||
active: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
draft: 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
@@ -91,22 +217,30 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-4xl max-h-[92vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0 bg-background/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Package className="w-4 h-4 text-primary" />
|
||||
<Package className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-foreground text-sm leading-tight">
|
||||
<h2 className="font-bold text-foreground text-base leading-tight">
|
||||
{variant.name || 'Variant Details'}
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted-foreground font-mono mt-0.5">{variant.sku}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground font-mono">{variant.sku}</span>
|
||||
{axisEntries.length > 0 && (
|
||||
<span className="text-[10px] bg-primary/10 text-primary font-semibold px-2 py-0.5 rounded-full">
|
||||
{axisEntries.map(([k, v]) => `${k}: ${v}`).join(' | ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold uppercase border ${statusColor[variant.status] || statusColor.draft}`}>
|
||||
{variant.status}
|
||||
</span>
|
||||
@@ -115,107 +249,101 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-background rounded-lg text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-5 gap-0 h-full">
|
||||
{/* ── Navigation Tabs ── */}
|
||||
<div className="flex items-center gap-2 px-6 border-b border-border bg-surface flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-xs font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'general'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
General & Pricing
|
||||
</button>
|
||||
|
||||
{/* Left: Image Gallery */}
|
||||
<div className="col-span-2 border-r border-border p-5 flex flex-col gap-4 bg-background/50">
|
||||
{/* Main image */}
|
||||
<div className="aspect-square rounded-xl border border-border overflow-hidden bg-surface flex items-center justify-center">
|
||||
{activeImage?.url || activeImage?.thumbnailUrl ? (
|
||||
<img
|
||||
src={activeImage.thumbnailUrl || activeImage.url!}
|
||||
alt={activeImage.name || variant.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ImageIcon className="w-10 h-10 opacity-30" />
|
||||
<span className="text-[11px] font-medium">No image</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('media')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-xs font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'media'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Media Assets ({variantAssets.length})
|
||||
</button>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{images.map((img, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActiveImageIdx(idx)}
|
||||
className={`flex-shrink-0 w-12 h-12 rounded-lg border-2 overflow-hidden transition-all ${idx === activeImageIdx ? 'border-primary' : 'border-border hover:border-primary/40'}`}
|
||||
>
|
||||
{img.url || img.thumbnailUrl ? (
|
||||
<img src={img.thumbnailUrl || img.url!} alt={img.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface-muted flex items-center justify-center">
|
||||
<ImageIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('channels')}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-xs font-semibold border-b-2 transition-all ${
|
||||
activeTab === 'channels'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
Channels & Syndication ({integrations.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tab Contents ── */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* ──── Tab 1: General & Pricing ──── */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* SKU Code */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<Hash className="inline w-3.5 h-3.5 mr-1" />SKU Code
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={e => setSku(e.target.value)}
|
||||
placeholder="e.g. PROD-RED-M"
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Axis pills */}
|
||||
{axisEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Variant Axes</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{axisEntries.map(([key, val]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/8 border border-primary/20 rounded-full text-[11px] font-semibold text-primary"
|
||||
>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
<span className="text-muted-foreground capitalize">{key}:</span>
|
||||
<span>{val}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Variant Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Variant Title / Name
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-medium text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{name || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Red / Medium"
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
{images.length} asset{images.length !== 1 ? 's' : ''} uploaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Edit Fields */}
|
||||
<div className="col-span-3 p-6 space-y-5">
|
||||
|
||||
{/* SKU */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<Hash className="inline w-3 h-3 mr-1" />SKU Code
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="font-mono text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{sku || '—'}</p>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={sku}
|
||||
onChange={e => setSku(e.target.value)}
|
||||
placeholder="e.g. PROD-RED-M"
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price & Cost */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3 h-3 mr-1" />Sale Price
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3.5 h-3.5 mr-1" />Sale Price
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${price}</p>
|
||||
@@ -228,14 +356,15 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
className="w-full border border-border rounded-lg pl-7 pr-3 py-2 text-sm font-bold focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3 h-3 mr-1" />Cost Price
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<DollarSign className="inline w-3.5 h-3.5 mr-1" />Cost Price
|
||||
</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">${costPrice}</p>
|
||||
@@ -253,30 +382,11 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
<ShoppingBag className="inline w-3 h-3 mr-1" />Stock
|
||||
</label>
|
||||
<label className="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Lifecycle Status</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground bg-surface-muted border border-border rounded-lg px-3 py-2">{stock}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={stock}
|
||||
onChange={e => setStock(e.target.value)}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 bg-surface text-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-1.5">Status</label>
|
||||
{readOnly ? (
|
||||
<p className={`inline-flex items-center px-2.5 py-1.5 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
|
||||
<p className={`inline-flex items-center px-3 py-2 rounded-lg text-xs font-bold uppercase border ${statusColor[status]}`}>{status}</p>
|
||||
) : (
|
||||
<select
|
||||
value={status}
|
||||
@@ -292,28 +402,243 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Available Stock', value: variant.availableStock ?? 0 },
|
||||
{ label: 'Reserved', value: variant.reservedStock ?? 0 },
|
||||
{ label: 'Safety Stock', value: variant.safetyStock ?? 0 },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-surface-muted border border-border rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-foreground">{value}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-medium mt-0.5">{label}</div>
|
||||
{/* Inventory Stock */}
|
||||
<div className="border border-border rounded-xl p-4 bg-background/50 space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<ShoppingBag className="w-4 h-4 text-primary" /> Inventory & Stock Control
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-muted-foreground uppercase mb-1">Total Stock</label>
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-bold text-foreground">{stock}</p>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={stock}
|
||||
onChange={e => setStock(e.target.value)}
|
||||
className="w-full border border-border rounded-lg px-2.5 py-1.5 text-sm font-bold bg-surface text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="bg-surface p-2.5 rounded-lg border border-border text-center">
|
||||
<div className="text-sm font-bold text-emerald-600">{variant.availableStock ?? stock}</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Available</div>
|
||||
</div>
|
||||
<div className="bg-surface p-2.5 rounded-lg border border-border text-center">
|
||||
<div className="text-sm font-bold text-amber-600">{variant.reservedStock ?? 0}</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Reserved</div>
|
||||
</div>
|
||||
<div className="bg-surface p-2.5 rounded-lg border border-border text-center">
|
||||
<div className="text-sm font-bold text-slate-600">{variant.safetyStock ?? 0}</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-semibold">Safety Stock</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last updated */}
|
||||
{variant.lastUpdated && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Last updated: {new Date(variant.lastUpdated).toLocaleString()}
|
||||
</p>
|
||||
{/* Variant Attribute Axes Pills */}
|
||||
{axisEntries.length > 0 && (
|
||||
<div className="border border-border rounded-xl p-4 bg-surface space-y-2">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Tag className="w-4 h-4 text-primary" /> Configured Variant Attributes
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{axisEntries.map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-lg text-xs font-semibold text-primary"
|
||||
>
|
||||
<span className="text-muted-foreground font-normal capitalize">{key}:</span>
|
||||
<span className="font-bold">{val}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ──── Tab 2: Media Assets ──── */}
|
||||
{activeTab === 'media' && (
|
||||
<div className="space-y-6">
|
||||
{/* Controls bar */}
|
||||
{!readOnly && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 bg-surface border border-border rounded-xl p-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-foreground uppercase tracking-wider">Variant Image Gallery</h4>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">Upload images or select existing assets from PIM library.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={e => handleFileUpload(e.target.files)}
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingMedia}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-all shadow-xs disabled:opacity-60"
|
||||
>
|
||||
{uploadingMedia ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
{uploadingMedia ? 'Uploading...' : 'Upload Image'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAssetPicker}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold transition-all"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Pick from PIM Library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
{loadingAssets ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : variantAssets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 bg-surface border border-dashed border-border rounded-xl text-center">
|
||||
<ImageIcon className="w-12 h-12 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm font-semibold text-foreground">No images assigned to this variant</p>
|
||||
<p className="text-xs text-muted-foreground max-w-sm mt-1">Upload product photos directly or select existing assets to showcase this variant.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{variantAssets.map((item, idx) => {
|
||||
const rawAsset = item.asset || item;
|
||||
const url = rawAsset.file_url || rawAsset.url || rawAsset.thumbnailUrl;
|
||||
const isPrimary = item.is_primary || item.isPrimary || idx === 0;
|
||||
const assetId = rawAsset.id || item.asset_id || item.assetId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`group relative flex flex-col bg-surface border rounded-xl overflow-hidden shadow-xs transition-all ${
|
||||
isPrimary ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{/* Primary Badge */}
|
||||
{isPrimary && (
|
||||
<span className="absolute top-2 left-2 z-10 inline-flex items-center gap-1 bg-primary text-white text-[9px] font-bold px-2 py-0.5 rounded-md shadow-xs">
|
||||
<Star className="w-2.5 h-2.5 fill-current" /> Primary
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="aspect-square bg-background overflow-hidden relative">
|
||||
{url ? (
|
||||
<img src={url} alt={rawAsset.name || `Image ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<ImageIcon className="w-8 h-8 opacity-30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="p-2 bg-surface border-t border-border flex items-center justify-between gap-1">
|
||||
{!isPrimary ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetPrimaryAsset(assetId)}
|
||||
className="text-[10px] text-primary hover:underline font-semibold"
|
||||
>
|
||||
Set Primary
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[10px] text-emerald-600 font-bold">Main Image</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnassignAsset(assetId)}
|
||||
className="p-1 text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="Remove image"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ──── Tab 3: Channels & Syndication ──── */}
|
||||
{activeTab === 'channels' && (
|
||||
<div className="space-y-6">
|
||||
<div className="border border-border rounded-xl p-4 bg-surface space-y-1">
|
||||
<h4 className="text-xs font-bold text-foreground uppercase tracking-wider">Channel Integration & Syndication</h4>
|
||||
<p className="text-[11px] text-muted-foreground">Status of this variant across your connected sales channels and marketplaces.</p>
|
||||
</div>
|
||||
|
||||
{loadingIntegrations ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : integrations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 bg-surface border border-dashed border-border rounded-xl text-center">
|
||||
<Globe className="w-12 h-12 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm font-semibold text-foreground">No channels connected yet</p>
|
||||
<p className="text-xs text-muted-foreground max-w-sm mt-1">Connect Shopify, Amazon, or Google Merchant in the Integration Hub to publish variants.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{integrations.map(integ => {
|
||||
const provider = integ.integration_type || integ.type || integ.name || 'Channel';
|
||||
return (
|
||||
<div key={integ.id} className="flex items-center justify-between p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold uppercase text-xs">
|
||||
{String(provider).slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h5 className="font-bold text-foreground text-sm">{integ.name}</h5>
|
||||
<span className="text-[10px] bg-emerald-100 text-emerald-700 font-bold px-2 py-0.5 rounded-full border border-emerald-200 uppercase">
|
||||
{integ.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Channel Type: <span className="capitalize font-medium text-foreground">{provider}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-emerald-600 font-semibold bg-emerald-50 border border-emerald-200 px-3 py-1.5 rounded-lg">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Published & Live
|
||||
</span>
|
||||
|
||||
{String(provider).toLowerCase().includes('shopify') && (
|
||||
<a
|
||||
href="https://maskcomerce.myshopify.com/admin/products"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 border border-border hover:bg-background text-foreground rounded-lg text-xs font-semibold transition-all"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground" /> View in Shopify
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
@@ -327,17 +652,17 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
Archive
|
||||
Archive Variant
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Delete this variant?')) { onDelete(variant.id); onClose(); } }}
|
||||
onClick={() => { if (window.confirm('Are you sure you want to delete this variant permanently?')) { onDelete(variant.id); onClose(); } }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-red-200 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete
|
||||
Delete Variant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -347,7 +672,7 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-sm font-medium transition-colors"
|
||||
className="px-4 py-2 border border-border text-muted-foreground hover:bg-background rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
{readOnly ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
@@ -356,7 +681,7 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saveState === 'saving'}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-60"
|
||||
className="flex items-center gap-2 px-5 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors disabled:opacity-60 shadow-xs"
|
||||
>
|
||||
{saveState === 'saving' && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{saveState === 'saved' && <CheckCircle className="w-3.5 h-3.5 text-white" />}
|
||||
@@ -368,6 +693,49 @@ export const VariantDetailModal: React.FC<VariantDetailModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PIM Asset Library Picker Sub-Modal */}
|
||||
{showAssetPicker && (
|
||||
<div className="fixed inset-0 z-60 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
|
||||
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-bold text-foreground text-sm">Select Asset from PIM Library</h3>
|
||||
<button type="button" onClick={() => setShowAssetPicker(false)} className="p-1 hover:bg-background rounded">
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loadingLibrary ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : libraryAssets.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">No assets found in PIM library.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{libraryAssets.map(ast => (
|
||||
<div
|
||||
key={ast.id}
|
||||
onClick={() => { handleAssignLibraryAsset(ast.id); setShowAssetPicker(false); }}
|
||||
className="group cursor-pointer border border-border hover:border-primary rounded-xl overflow-hidden bg-background p-1.5 transition-all text-center"
|
||||
>
|
||||
<div className="aspect-square bg-surface rounded-lg overflow-hidden mb-1 flex items-center justify-center">
|
||||
{ast.file_url ? (
|
||||
<img src={ast.file_url} alt={ast.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<ImageIcon className="w-6 h-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span className="block text-[10px] font-semibold text-foreground truncate" title={ast.name}>{ast.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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