upadted flow
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ChevronDown, ChevronRight, Folder, FolderOpen, Search, Plus, X,
|
||||
Check, Loader2, FolderPlus
|
||||
} from 'lucide-react';
|
||||
import { categoryService } from '../../features/categories/services/category.service';
|
||||
import { useCategory } from '../../features/categories/hook/useCategory';
|
||||
import { notify } from '../../services/toast';
|
||||
|
||||
interface CategoryNode {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
parentId?: string;
|
||||
children: CategoryNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface CategoryTreeSelectProps {
|
||||
value?: string;
|
||||
onChange: (categoryId: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function buildCategoryTree(categories: any[]): CategoryNode[] {
|
||||
const map = new Map<string, CategoryNode>();
|
||||
categories.forEach((cat) => {
|
||||
map.set(cat.id, {
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
code: cat.code,
|
||||
parentId: cat.parentId || cat.parent_id,
|
||||
children: [],
|
||||
depth: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const roots: CategoryNode[] = [];
|
||||
categories.forEach((cat) => {
|
||||
const node = map.get(cat.id)!;
|
||||
const parentId = cat.parentId || cat.parent_id;
|
||||
if (parentId && map.has(parentId)) {
|
||||
map.get(parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
function assignDepth(node: CategoryNode, depth: number) {
|
||||
node.depth = depth;
|
||||
node.children.forEach((child) => assignDepth(child, depth + 1));
|
||||
}
|
||||
roots.forEach((root) => assignDepth(root, 0));
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export const CategoryTreeSelect: React.FC<CategoryTreeSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select Category...',
|
||||
error,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { categories, fetchCategories } = useCategory();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// Quick Create Drawer state
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
const [newCatParent, setNewCatParent] = useState('');
|
||||
const [newCatDesc, setNewCatDesc] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
// Handle outside click to close dropdown
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
const categoryTree = useMemo(() => buildCategoryTree(categories), [categories]);
|
||||
|
||||
const selectedCategory = useMemo(() => {
|
||||
return categories.find((c) => c.id === value);
|
||||
}, [categories, value]);
|
||||
|
||||
const toggleExpand = (nodeId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (categoryId: string) => {
|
||||
onChange(categoryId);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCatName.trim()) {
|
||||
notify.error('Category name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = newCatName.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/(^_+|_+$)/g, '');
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const created: any = await categoryService.create({
|
||||
name: newCatName.trim(),
|
||||
code: code || `cat_${Date.now()}`,
|
||||
parentId: newCatParent || undefined,
|
||||
description: newCatDesc,
|
||||
status: 'active',
|
||||
} as any);
|
||||
|
||||
const createdId = created?.id || created?.data?.id;
|
||||
notify.success(`Category "${newCatName}" created successfully!`);
|
||||
|
||||
await fetchCategories();
|
||||
if (createdId) {
|
||||
onChange(createdId);
|
||||
}
|
||||
|
||||
setNewCatName('');
|
||||
setNewCatParent('');
|
||||
setNewCatDesc('');
|
||||
setIsDrawerOpen(false);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Failed to create category';
|
||||
notify.error(msg);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper renderer for recursive tree node
|
||||
const renderTreeNode = (node: CategoryNode) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedNodes.has(node.id) || Boolean(search.trim());
|
||||
const isSelected = node.id === value;
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
const matches = node.name.toLowerCase().includes(q) || node.code.toLowerCase().includes(q);
|
||||
const childMatches = node.children.some((child) =>
|
||||
child.name.toLowerCase().includes(q) || child.code.toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
if (!matches && !childMatches) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.id} className="select-none">
|
||||
<div
|
||||
onClick={() => handleSelect(node.id)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary font-bold'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
style={{ paddingLeft: `${node.depth * 16 + 12}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleExpand(node.id, e)}
|
||||
className="w-4 h-4 rounded hover:bg-gray-200 flex items-center justify-center text-gray-500 shrink-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 h-4 shrink-0" />
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
)}
|
||||
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">({node.code})</span>
|
||||
</div>
|
||||
|
||||
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{node.children.map((child) => renderTreeNode(child))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
{/* Trigger Button */}
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`w-full border rounded-lg px-3 py-2.5 text-sm flex items-center justify-between bg-white cursor-pointer transition-all ${
|
||||
disabled ? 'bg-gray-50 opacity-60 cursor-not-allowed border-gray-200' :
|
||||
isOpen ? 'border-primary ring-2 ring-primary/20' :
|
||||
error ? 'border-red-300' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Folder className={`w-4 h-4 ${selectedCategory ? 'text-primary' : 'text-gray-400'}`} />
|
||||
<span className={selectedCategory ? 'text-gray-900 font-medium' : 'text-gray-400'}>
|
||||
{selectedCategory ? selectedCategory.name : placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-500 mt-1 font-medium">{error}</div>}
|
||||
|
||||
{/* Popover Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-1.5 w-full bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden flex flex-col max-h-80 animate-in fade-in zoom-in-95 duration-100">
|
||||
{/* Search Header */}
|
||||
<div className="p-2 border-b border-gray-100 flex items-center gap-2 bg-gray-50/50">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-2.5 top-2.5" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search category hierarchy..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-xs border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary bg-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDrawerOpen(true)}
|
||||
className="px-2.5 py-1.5 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg flex items-center gap-1 shrink-0 transition-colors shadow-2xs"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Quick Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Tree Body */}
|
||||
<div className="p-2 overflow-y-auto flex-1 space-y-1 max-h-56">
|
||||
{categoryTree.length > 0 ? (
|
||||
categoryTree.map((rootNode) => renderTreeNode(rootNode))
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-gray-400">
|
||||
No categories found. Click "Quick Create" to add one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Create Category Drawer Modal */}
|
||||
{isDrawerOpen && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/40 backdrop-blur-xs animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-md bg-white h-full shadow-2xl flex flex-col justify-between animate-in slide-in-from-right duration-200">
|
||||
{/* Drawer Header */}
|
||||
<div className="p-5 border-b border-gray-200 flex items-center justify-between bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderPlus className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-bold text-gray-900 text-base">Create Category</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
className="w-8 h-8 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-200 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Form */}
|
||||
<form onSubmit={handleCreateCategory} className="p-6 flex-1 overflow-y-auto space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 uppercase tracking-wider mb-1.5">
|
||||
Category Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
placeholder="e.g. Executive Desks, Electronics"
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 uppercase tracking-wider mb-1.5">
|
||||
Parent Category (Optional)
|
||||
</label>
|
||||
<select
|
||||
value={newCatParent}
|
||||
onChange={(e) => setNewCatParent(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-white"
|
||||
>
|
||||
<option value="">None (Top Level Root Category)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 uppercase tracking-wider mb-1.5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={newCatDesc}
|
||||
onChange={(e) => setNewCatDesc(e.target.value)}
|
||||
placeholder="Enter category description..."
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Drawer Footer */}
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDrawerOpen(false)}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreating}
|
||||
onClick={handleCreateCategory}
|
||||
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white text-sm font-semibold rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>Save & Select Category</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import type { Asset } from "../types/assets.types";
|
||||
import type { Product } from "../../product/types/product.types";
|
||||
import { notify } from "../../../services/toast";
|
||||
import apiClient from "../../../api/axiosInstance";
|
||||
import { getAssetUrl } from "../../../lib/utils";
|
||||
|
||||
export default function AssetList() {
|
||||
const navigate = useNavigate();
|
||||
@@ -508,7 +509,7 @@ export default function AssetList() {
|
||||
onClick={() => openPreviewDrawer(row)}
|
||||
>
|
||||
{row.mime_type?.startsWith("image/") && row.file_url ? (
|
||||
<img src={row.file_url} alt="preview" className="w-full h-full object-cover" />
|
||||
<img src={getAssetUrl(row.file_url)} alt="preview" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
getAssetIcon(row.mime_type)
|
||||
)}
|
||||
@@ -671,7 +672,7 @@ export default function AssetList() {
|
||||
{/* Card Thumbnail */}
|
||||
<div className="h-32 bg-gray-50 border-b border-gray-100 flex items-center justify-center overflow-hidden relative">
|
||||
{vMapping.asset?.mime_type?.startsWith("image/") && vMapping.asset.file_url ? (
|
||||
<img src={vMapping.asset.file_url} alt="asset" className="w-full h-full object-cover" />
|
||||
<img src={getAssetUrl(vMapping.asset.file_url)} alt="asset" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{getAssetIcon(vMapping.asset?.mime_type || '')}
|
||||
@@ -912,7 +913,7 @@ export default function AssetList() {
|
||||
onClick={() => openPreviewDrawer(asset)}
|
||||
>
|
||||
{asset.mime_type?.startsWith("image/") && asset.file_url ? (
|
||||
<img src={asset.file_url} alt="asset" className="w-full h-full object-cover group-hover:scale-105 transition-transform" />
|
||||
<img src={getAssetUrl(asset.file_url)} alt="asset" className="w-full h-full object-cover group-hover:scale-105 transition-transform" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{getAssetIcon(asset.mime_type)}
|
||||
@@ -981,7 +982,7 @@ export default function AssetList() {
|
||||
>
|
||||
<div className="w-8 h-8 rounded bg-gray-50 border border-gray-200 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
||||
{a.mime_type?.startsWith('image/') ? (
|
||||
<img src={a.file_url} className="w-full h-full object-cover" />
|
||||
<img src={getAssetUrl(a.file_url)} className="w-full h-full object-cover" />
|
||||
) : getAssetIcon(a.mime_type)}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
@@ -1277,7 +1278,7 @@ export default function AssetList() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-50 border border-gray-200 flex items-center justify-center overflow-hidden">
|
||||
{selectedAsset.mime_type?.startsWith('image/') ? (
|
||||
<img src={selectedAsset.file_url} className="w-full h-full object-cover" />
|
||||
<img src={getAssetUrl(selectedAsset.file_url)} className="w-full h-full object-cover" />
|
||||
) : getAssetIcon(selectedAsset.mime_type)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -1350,7 +1351,7 @@ export default function AssetList() {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-gray-400 block uppercase">STORAGE LINK</span>
|
||||
<a href={selectedAsset.file_url} target="_blank" rel="noreferrer" className="text-xs text-primary font-mono hover:underline break-all mt-1 block">
|
||||
<a href={getAssetUrl(selectedAsset.file_url)} target="_blank" rel="noreferrer" className="text-xs text-primary font-mono hover:underline break-all mt-1 block">
|
||||
{selectedAsset.file_url}
|
||||
</a>
|
||||
</div>
|
||||
@@ -1591,7 +1592,7 @@ export default function AssetList() {
|
||||
>
|
||||
<div className="h-24 bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{a.mime_type?.startsWith('image/') ? (
|
||||
<img src={a.file_url} className="w-full h-full object-cover" />
|
||||
<img src={getAssetUrl(a.file_url)} className="w-full h-full object-cover" />
|
||||
) : getAssetIcon(a.mime_type)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { AssetCreateRequest } from "../types/assets.types";
|
||||
import { Save, Upload, FileText, Video, Loader2, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
|
||||
import { notify } from '../../../services/toast';
|
||||
import { getAssetUrl, isImageFile } from '../../../lib/utils';
|
||||
|
||||
const inputClass = (error?: boolean) =>
|
||||
`w-full border ${error ? 'border-red-500 focus:ring-red-500' : 'border-gray-200 focus:ring-primary'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow bg-white`;
|
||||
@@ -140,7 +141,7 @@ export default function NewAsset() {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const isImage = formik.values.mime_type?.startsWith("image/");
|
||||
const isImage = isImageFile(formik.values.mime_type, formik.values.file_url);
|
||||
const isVideo = formik.values.mime_type?.startsWith("video/");
|
||||
|
||||
return (
|
||||
@@ -215,12 +216,9 @@ export default function NewAsset() {
|
||||
<div className="w-24 h-24 rounded-lg border border-gray-200 bg-white flex items-center justify-center shrink-0 overflow-hidden shadow-sm">
|
||||
{isImage ? (
|
||||
<img
|
||||
src={formik.values.file_url}
|
||||
src={getAssetUrl(formik.values.file_url)}
|
||||
alt="Uploaded File"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : isVideo ? (
|
||||
<Video className="w-8 h-8 text-primary" />
|
||||
@@ -258,7 +256,7 @@ export default function NewAsset() {
|
||||
|
||||
{isImage && (
|
||||
<div className="border border-gray-150 rounded-lg overflow-hidden bg-white">
|
||||
<img src={formik.values.file_url} alt="Full view" className="max-h-80 w-full object-contain mx-auto bg-gray-50" />
|
||||
<img src={getAssetUrl(formik.values.file_url)} alt="Full view" className="max-h-80 w-full object-contain mx-auto bg-gray-50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,10 @@ export const FamilyTable: React.FC<FamilyTableProps> = ({
|
||||
{
|
||||
key: "category",
|
||||
label: "Category",
|
||||
render: (val: string) => <span className="text-sm text-gray-600">{val || '—'}</span>,
|
||||
render: (val: any, row: Family) => {
|
||||
const catName = (typeof val === 'object' && val?.name) ? val.name : ((row as any).category?.name || (typeof val === 'string' ? val : '—'));
|
||||
return <span className="text-sm text-gray-600">{catName || '—'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "attributes",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, X, Tag } from 'lucide-react';
|
||||
|
||||
interface SuggestedValuesEditorProps {
|
||||
values: string[];
|
||||
onChange: (newValues: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const SuggestedValuesEditor: React.FC<SuggestedValuesEditorProps> = ({
|
||||
values = [],
|
||||
onChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) return;
|
||||
if (values.includes(trimmed)) {
|
||||
setInputValue('');
|
||||
return;
|
||||
}
|
||||
onChange([...values, trimmed]);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const updated = values.filter((_, idx) => idx !== index);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-gray-700">
|
||||
<Tag className="w-3.5 h-3.5 text-primary" />
|
||||
<span>Suggested Values (Optional)</span>
|
||||
<span className="text-[10px] text-gray-400 font-normal">(Suggestions for product creation)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 p-2 bg-gray-50/70 border border-gray-200 rounded-lg min-h-[42px]">
|
||||
{values.map((val, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white text-gray-800 text-xs font-medium border border-primary/20 rounded-md shadow-2xs"
|
||||
>
|
||||
{val}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(idx)}
|
||||
className="text-gray-400 hover:text-red-500 font-bold ml-1 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1 min-w-[140px]">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="+ Add suggested value"
|
||||
className="px-2 py-1 text-xs border border-transparent focus:border-primary/30 rounded focus:bg-white focus:outline-none flex-1 placeholder:text-gray-400"
|
||||
/>
|
||||
{inputValue.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="p-1 bg-primary text-white rounded hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, X, Search, Check } from 'lucide-react';
|
||||
|
||||
interface AvailableAttribute {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
is_variant_eligible?: boolean;
|
||||
}
|
||||
|
||||
interface VariantAxisFormProps {
|
||||
availableAttributes: AvailableAttribute[];
|
||||
selectedAttributeIds: string[];
|
||||
onAddAxis: (attribute: AvailableAttribute) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VariantAxisForm: React.FC<VariantAxisFormProps> = ({
|
||||
availableAttributes = [],
|
||||
selectedAttributeIds = [],
|
||||
onAddAxis,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const unselectedAttributes = availableAttributes.filter(
|
||||
attr => !selectedAttributeIds.includes(attr.id)
|
||||
);
|
||||
|
||||
const filteredAttributes = unselectedAttributes.filter(attr =>
|
||||
attr.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
attr.code.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || unselectedAttributes.length === 0}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 bg-primary hover:bg-primary-hover disabled:bg-gray-200 text-white rounded-lg text-xs font-semibold shadow-2xs transition-all disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add Axis</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-xl z-50 p-3 space-y-3 animate-in fade-in slide-in-from-top-2 duration-150">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2">
|
||||
<h4 className="font-semibold text-xs text-gray-900">Select Variant Axis Attribute</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-2.5 top-2.5" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search attributes..."
|
||||
className="w-full text-xs border border-gray-200 rounded-lg pl-8 pr-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary bg-gray-50/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
|
||||
{filteredAttributes.length > 0 ? (
|
||||
filteredAttributes.map(attr => (
|
||||
<button
|
||||
key={attr.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAddAxis(attr);
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
className="w-full text-left p-2.5 hover:bg-primary/5 rounded-lg flex items-center justify-between group transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-xs text-gray-900 group-hover:text-primary">{attr.name}</div>
|
||||
<div className="text-[10px] text-gray-400 font-mono mt-0.5">{attr.code} • {attr.type}</div>
|
||||
</div>
|
||||
<Check className="w-3.5 h-3.5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-6 text-xs text-gray-400">
|
||||
{unselectedAttributes.length === 0 ? 'All assigned attributes added' : 'No matching attributes found'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { SuggestedValuesEditor } from './SuggestedValuesEditor';
|
||||
import { Trash2, GripVertical, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
|
||||
export interface FamilyAxisConfig {
|
||||
attributeId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
active: boolean;
|
||||
suggestedValues: string[];
|
||||
}
|
||||
|
||||
interface VariantAxisTableProps {
|
||||
axes: FamilyAxisConfig[];
|
||||
onUpdateAxis: (attributeId: string, updates: Partial<FamilyAxisConfig>) => void;
|
||||
onRemoveAxis: (attributeId: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VariantAxisTable: React.FC<VariantAxisTableProps> = ({
|
||||
axes,
|
||||
onUpdateAxis,
|
||||
onRemoveAxis,
|
||||
disabled = false
|
||||
}) => {
|
||||
if (axes.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-gray-50/50 rounded-xl border border-dashed border-gray-300">
|
||||
<AlertCircle className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
<h4 className="text-sm font-semibold text-gray-700">No Variant Axes Configured</h4>
|
||||
<p className="text-xs text-gray-500 max-w-sm mx-auto mt-1">
|
||||
Click "+ Add Axis" above to select variant-eligible attributes for this product family blueprint.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{axes.map((axis) => (
|
||||
<div
|
||||
key={axis.attributeId || axis.code}
|
||||
className="bg-white border border-primary/10 rounded-xl p-5 shadow-2xs hover:border-primary/20 transition-all space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="w-4 h-4 text-gray-300 shrink-0 cursor-grab" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-gray-900">{axis.name}</span>
|
||||
<span className="px-2 py-0.5 rounded-full bg-primary/10 text-primary-dark text-[10px] font-mono font-bold">
|
||||
{axis.code}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-semibold">
|
||||
• {axis.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Required toggle */}
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={axis.required}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onUpdateAxis(axis.attributeId, { required: e.target.checked })}
|
||||
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light"
|
||||
/>
|
||||
<span>Required Axis</span>
|
||||
</label>
|
||||
|
||||
{/* Status Badge */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onUpdateAxis(axis.attributeId, { active: !axis.active })}
|
||||
className={`px-2.5 py-1 rounded-full text-[11px] font-semibold flex items-center gap-1 transition-colors ${
|
||||
axis.active
|
||||
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||||
: 'bg-gray-100 text-gray-500 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{axis.active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
|
||||
{/* Delete button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveAxis(axis.attributeId)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Remove Axis"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggested values inline editor */}
|
||||
<div className="pt-3 border-t border-gray-100">
|
||||
<SuggestedValuesEditor
|
||||
values={axis.suggestedValues || []}
|
||||
onChange={(newValues) => onUpdateAxis(axis.attributeId, { suggestedValues: newValues })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { VariantAxisTable, type FamilyAxisConfig } from './VariantAxisTable';
|
||||
import { VariantAxisForm } from './VariantAxisForm';
|
||||
import { Layers, Sparkles } from 'lucide-react';
|
||||
|
||||
interface VariantConfigurationStepProps {
|
||||
variantAxesConfigs: FamilyAxisConfig[];
|
||||
availableAttributes: any[];
|
||||
onUpdateAxisConfigs: (newConfigs: FamilyAxisConfig[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VariantConfigurationStep: React.FC<VariantConfigurationStepProps> = ({
|
||||
variantAxesConfigs = [],
|
||||
availableAttributes = [],
|
||||
onUpdateAxisConfigs,
|
||||
disabled = false
|
||||
}) => {
|
||||
const isEnabled = variantAxesConfigs.length > 0;
|
||||
|
||||
const handleToggleEnable = () => {
|
||||
if (isEnabled) {
|
||||
onUpdateAxisConfigs([]);
|
||||
} else {
|
||||
// Pick first variant eligible attribute or first assigned attribute as initial axis
|
||||
const initialAttr = availableAttributes.find(a => a.is_variant_eligible || a.type === 'select') || availableAttributes[0];
|
||||
if (initialAttr) {
|
||||
onUpdateAxisConfigs([{
|
||||
attributeId: initialAttr.id,
|
||||
code: initialAttr.code,
|
||||
name: initialAttr.name,
|
||||
type: initialAttr.type || 'select',
|
||||
required: true,
|
||||
active: true,
|
||||
suggestedValues: []
|
||||
}]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAxis = (attr: any) => {
|
||||
const newAxisConfig: FamilyAxisConfig = {
|
||||
attributeId: attr.id,
|
||||
code: attr.code,
|
||||
name: attr.name,
|
||||
type: attr.type || 'select',
|
||||
required: true,
|
||||
active: true,
|
||||
suggestedValues: (attr.optionsList || []).map((o: any) => o.label || o.code)
|
||||
};
|
||||
onUpdateAxisConfigs([...variantAxesConfigs, newAxisConfig]);
|
||||
};
|
||||
|
||||
const handleUpdateAxis = (attributeId: string, updates: Partial<FamilyAxisConfig>) => {
|
||||
const updated = variantAxesConfigs.map(config => {
|
||||
if (config.attributeId === attributeId) {
|
||||
return { ...config, ...updates };
|
||||
}
|
||||
return config;
|
||||
});
|
||||
onUpdateAxisConfigs(updated);
|
||||
};
|
||||
|
||||
const handleRemoveAxis = (attributeId: string) => {
|
||||
const updated = variantAxesConfigs.filter(config => config.attributeId !== attributeId);
|
||||
onUpdateAxisConfigs(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Capability card */}
|
||||
<div className="bg-white border border-primary/10 rounded-xl p-6 shadow-2xs space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 text-sm">Enable Product Variants</h4>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Defines whether products in this family support variant axes (e.g., Color, Size, Storage).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleToggleEnable}
|
||||
className={`px-4 py-2 text-xs font-bold rounded-lg border transition-all ${
|
||||
isEnabled
|
||||
? 'bg-primary text-white border-primary shadow-2xs'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{isEnabled ? 'Variants Enabled (YES)' : 'Variants Disabled (NO)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEnabled ? (
|
||||
<>
|
||||
<div className="bg-white border border-primary/10 rounded-xl p-6 shadow-2xs space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 text-sm">Configured Variant Axes & Suggested Values</h4>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Define variation dimensions and optional suggested values for products in this family.
|
||||
</p>
|
||||
</div>
|
||||
<VariantAxisForm
|
||||
availableAttributes={availableAttributes}
|
||||
selectedAttributeIds={variantAxesConfigs.map(c => c.attributeId)}
|
||||
onAddAxis={handleAddAxis}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VariantAxisTable
|
||||
axes={variantAxesConfigs}
|
||||
onUpdateAxis={handleUpdateAxis}
|
||||
onRemoveAxis={handleRemoveAxis}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blueprint Matrix Preview Simulation */}
|
||||
<div className="bg-white border border-primary/10 rounded-xl p-6 shadow-2xs space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<h4 className="text-xs font-bold text-gray-700 uppercase tracking-wider">
|
||||
Blueprint Matrix Simulation
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-primary/5 border border-primary/10 rounded-lg text-xs text-primary-dark">
|
||||
<span className="font-semibold block mb-1">Configured Axes:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variantAxesConfigs.map(c => (
|
||||
<span key={c.code} className="px-2 py-1 bg-white rounded border border-primary/20 font-mono text-[11px]">
|
||||
{c.name} ({c.suggestedValues.length > 0 ? `${c.suggestedValues.length} suggested values` : 'No suggested values'})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-8 text-center bg-white rounded-xl border border-gray-200 shadow-2xs">
|
||||
<Layers className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-gray-800 mb-1">Simple Products Blueprint</h3>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
Products created under this family will be standard **Simple Products** without variation axes. Click "Variants Enabled (YES)" above if this family requires variant axes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
CheckCircle2, AlertCircle, CheckSquare, Image as ImageIcon, Check,
|
||||
Box, Info
|
||||
} from 'lucide-react';
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { useFormik } from "formik";
|
||||
@@ -26,6 +25,8 @@ import { Select } from "../../../components/customs/Select";
|
||||
import { notify } from '../../../services/toast';
|
||||
import { familyService } from '../services/family.service';
|
||||
import { generateCodeFromName } from '../../../utils/validators';
|
||||
import { CategoryTreeSelect } from '../../../components/customs/CategoryTreeSelect';
|
||||
import { VariantConfigurationStep } from '../components/VariantConfigurationStep';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'basic', label: 'Basic Information', icon: FileText, step: 1 },
|
||||
@@ -112,9 +113,7 @@ export default function NewFamily() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = (isEdit || isView) && id
|
||||
? `/api/v1/catalogs/${id}/blueprint`
|
||||
: `/api/v1/attribute-sets/${setId}/structure`;
|
||||
const url = `/api/v1/attribute-sets/${setId}/structure`;
|
||||
|
||||
const json = await apiClient.get<any>(url);
|
||||
if (json.success && json.data) {
|
||||
@@ -130,7 +129,7 @@ export default function NewFamily() {
|
||||
} catch (err) {
|
||||
console.error('Blueprint preview load error:', err);
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
@@ -238,10 +237,29 @@ export default function NewFamily() {
|
||||
code: data.code || '',
|
||||
description: data.description || '',
|
||||
status: data.status || 'draft',
|
||||
category: data.category || '',
|
||||
category: data.category_id || data.categoryId || (data.category && typeof data.category === 'object' ? data.category.id : data.category) || '',
|
||||
attributeSetId: attrSetId,
|
||||
attributes: Array.isArray(data.attributes) ? data.attributes.map((a: any) => a.id || a) : [],
|
||||
variantAxes: Array.isArray(data.variantAxes) ? data.variantAxes.map((a: any) => a.id || a) : [],
|
||||
variantAxes: Array.isArray(data.variantAxes)
|
||||
? data.variantAxes.map((a: any) => {
|
||||
if (typeof a === 'string') {
|
||||
return a;
|
||||
}
|
||||
const through = a.FamilyVariantAxis || a.family_variant_axis || {};
|
||||
const suggestedVals = Array.isArray(a.suggestedValues)
|
||||
? a.suggestedValues.map((v: any) => (typeof v === 'string' ? v : v.value))
|
||||
: [];
|
||||
return {
|
||||
attributeId: a.id || a.attributeId,
|
||||
code: a.code || '',
|
||||
name: a.name || '',
|
||||
type: a.type || 'select',
|
||||
required: through.required !== undefined ? through.required : (a.required !== undefined ? a.required : true),
|
||||
active: through.active !== undefined ? through.active : (a.active !== undefined ? a.active : true),
|
||||
suggestedValues: suggestedVals
|
||||
};
|
||||
})
|
||||
: [],
|
||||
channels: Array.isArray(data.channels) ? data.channels.map((c: any) => c.channelCode || c.channel_code || c) : [],
|
||||
assetRequirements: Array.isArray(data.assetRequirements) ? data.assetRequirements.map((r: any) => r.id || r) : [],
|
||||
allowedBrands: Array.isArray(data.allowedBrands) ? data.allowedBrands.map((b: any) => b.id || b) : [],
|
||||
@@ -281,10 +299,32 @@ export default function NewFamily() {
|
||||
const isLoading = familyLoading || attributesLoading || channelsLoading || assetFamiliesLoading || workflowsLoading || setsLoading || brandsLoading || unitsLoading;
|
||||
|
||||
// Selected attributes list for Step 3 Axis Filtering
|
||||
const selectedAttributesList = attributes.filter(attr =>
|
||||
formik.values.attributes.includes(attr.id) &&
|
||||
((attr as any).isVariantEligible || (attr as any).is_variant_eligible || (attr as any).is_variant_axis || (attr as any).isVariantAxis)
|
||||
);
|
||||
const selectedAttributesList = useMemo(() => {
|
||||
const selectedIds = new Set(formik.values.attributes || []);
|
||||
const list: any[] = [];
|
||||
|
||||
attributes.forEach(attr => {
|
||||
if (selectedIds.has(attr.id)) {
|
||||
list.push(attr);
|
||||
}
|
||||
});
|
||||
|
||||
if (blueprintPreview && Array.isArray(blueprintPreview.groups)) {
|
||||
blueprintPreview.groups.forEach((g: any) => {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
g.attributes.forEach((attr: any) => {
|
||||
if (selectedIds.has(attr.id) && !list.some(existing => existing.id === attr.id)) {
|
||||
list.push(attr);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return list.filter(attr =>
|
||||
(attr.isVariantEligible || attr.is_variant_eligible || attr.is_variant_axis || attr.isVariantAxis)
|
||||
);
|
||||
}, [attributes, formik.values.attributes, blueprintPreview]);
|
||||
|
||||
const processedWorkflows = useMemo(() => {
|
||||
let list = (workflowsList || []).filter((wf: any) => wf.status === 'active' || wf.code === selectedWorkflow);
|
||||
@@ -308,58 +348,6 @@ export default function NewFamily() {
|
||||
return list;
|
||||
}, [workflowsList, wfSearch, wfSort, selectedWorkflow]);
|
||||
|
||||
// Step 3 Columns configuration
|
||||
const variantColumns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "AXIS NAME",
|
||||
render: (_val: any, row: any) => <span className="font-semibold text-gray-900">{row.name}</span>
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "CODE",
|
||||
render: (val: string) => <span className="font-mono text-xs text-primary bg-primary/5 px-2 py-0.5 rounded">{val}</span>
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
label: "DATA TYPE",
|
||||
render: (val: string) => (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 text-xs font-medium rounded border bg-purple-50 text-purple-700 border-purple-100">
|
||||
{val}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "is_axis",
|
||||
label: "IS VARIANT AXIS",
|
||||
align: "center" as const,
|
||||
render: (_val: any, row: any) => {
|
||||
const isAxis = formik.values.variantAxes.includes(row.id);
|
||||
const handleToggleAxis = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const current = [...formik.values.variantAxes];
|
||||
if (isAxis) {
|
||||
formik.setFieldValue('variantAxes', current.filter(id => id !== row.id));
|
||||
} else {
|
||||
formik.setFieldValue('variantAxes', [...current, row.id]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleAxis}
|
||||
className={`px-4 py-1 text-xs font-semibold rounded-full border transition-all ${isAxis
|
||||
? 'bg-primary text-white border-primary shadow-sm active:scale-95'
|
||||
: 'bg-white text-gray-400 border-gray-200 hover:border-primary/30 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{isAxis ? 'Axis Enabled' : 'Enable'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const handleSaveFamily = async () => {
|
||||
setSubmitError(null);
|
||||
const errors = await formik.validateForm();
|
||||
@@ -618,6 +606,15 @@ export default function NewFamily() {
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Primary Category</label>
|
||||
<CategoryTreeSelect
|
||||
value={formik.values.category}
|
||||
onChange={(catId) => formik.setFieldValue('category', catId)}
|
||||
placeholder="Select Primary Category (or Quick Create inline)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Description</label>
|
||||
@@ -774,33 +771,40 @@ export default function NewFamily() {
|
||||
|
||||
{/* ── Variant Strategy ── */}
|
||||
{activeTab === 'variants' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 flex items-start gap-3">
|
||||
<AlertCircle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-amber-900">Variant axes are inherited from the Product Family.</p>
|
||||
<p className="text-amber-600 mt-0.5 text-xs leading-relaxed">
|
||||
This family defines <strong>only the variant dimensions (axes)</strong> from the selected attributes in Step 2.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Variant Axes"
|
||||
subtitle="Select which of the assigned attributes serve as dimensions of variation"
|
||||
/>
|
||||
{selectedAttributesList.length > 0 ? (
|
||||
<DataTable
|
||||
columns={variantColumns}
|
||||
data={selectedAttributesList}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-8 text-center text-sm text-gray-400">
|
||||
No attributes selected in Step 2. Go back and assign at least one attribute to enable variant axes.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<VariantConfigurationStep
|
||||
variantAxesConfigs={
|
||||
Array.isArray(formik.values.variantAxes)
|
||||
? formik.values.variantAxes.map((item: any) => {
|
||||
const attrId = typeof item === 'string' ? item : (item.attributeId || item.id);
|
||||
const attr = attributes.find((a: any) => a.id === attrId);
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
attributeId: item,
|
||||
code: attr?.code || item,
|
||||
name: attr?.name || item,
|
||||
type: attr?.type || 'select',
|
||||
required: true,
|
||||
active: true,
|
||||
suggestedValues: []
|
||||
};
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
attributeId: attrId,
|
||||
name: item.name || attr?.name || attrId,
|
||||
code: item.code || attr?.code || '',
|
||||
type: item.type || attr?.type || 'select',
|
||||
required: item.required !== undefined ? item.required : true,
|
||||
active: item.active !== undefined ? item.active : true,
|
||||
suggestedValues: item.suggestedValues || []
|
||||
};
|
||||
})
|
||||
: []
|
||||
}
|
||||
availableAttributes={selectedAttributesList}
|
||||
onUpdateAxisConfigs={(newConfigs) => formik.setFieldValue('variantAxes', newConfigs)}
|
||||
disabled={isView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Allowed Channels ── */}
|
||||
|
||||
@@ -10,7 +10,15 @@ export const familySchema = Yup.object().shape({
|
||||
.max(100, 'Name cannot be more than 100 characters'),
|
||||
description: Yup.string(),
|
||||
attributes: Yup.array().of(Yup.string()),
|
||||
variantAxes: Yup.array().of(Yup.string()),
|
||||
variantAxes: Yup.array().of(
|
||||
Yup.mixed().test('is-string-or-axis-config', 'Invalid variant axis', (value) => {
|
||||
if (typeof value === 'string') return true;
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return typeof (value as any).attributeId === 'string' || typeof (value as any).id === 'string';
|
||||
}
|
||||
return false;
|
||||
})
|
||||
),
|
||||
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
|
||||
allowedBrands: Yup.array().of(Yup.string()).optional(),
|
||||
allowedUnits: Yup.array().of(Yup.string()).optional(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Loader } from '../../../components/customs/Loader';
|
||||
import { getAssetUrl, isImageFile } from '../../../lib/utils';
|
||||
|
||||
interface ProductAssetsTabProps {
|
||||
productId?: string;
|
||||
@@ -345,7 +346,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
const asset = mapping.asset;
|
||||
if (!asset) return null;
|
||||
|
||||
const isImage = asset.mime_type?.startsWith('image/');
|
||||
const isImage = isImageFile(asset.mime_type, asset.file_url);
|
||||
const isVideo = asset.mime_type?.startsWith('video/');
|
||||
const isPdf = asset.mime_type === 'application/pdf';
|
||||
|
||||
@@ -377,7 +378,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<td className="px-4 py-3">
|
||||
<div className="w-10 h-10 border border-gray-200 rounded-lg bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{isImage ? (
|
||||
<img src={asset.file_url} alt={asset.name} className="w-full h-full object-cover" />
|
||||
<img src={getAssetUrl(asset.file_url)} alt={asset.name} className="w-full h-full object-cover" />
|
||||
) : isVideo ? (
|
||||
<Video className="w-5 h-5 text-primary" />
|
||||
) : isPdf ? (
|
||||
@@ -490,7 +491,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
) : filteredLibrary.length > 0 ? (
|
||||
filteredLibrary.map(asset => {
|
||||
const isAssigned = assignedAssets.some(a => a.asset_id === asset.id);
|
||||
const isImage = asset.mime_type?.startsWith('image/');
|
||||
const isImage = isImageFile(asset.mime_type, asset.file_url);
|
||||
const isVideo = asset.mime_type?.startsWith('video/');
|
||||
|
||||
return (
|
||||
@@ -498,7 +499,7 @@ export const ProductAssetsTab: React.FC<ProductAssetsTabProps> = ({
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 border border-gray-200 rounded bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{isImage ? (
|
||||
<img src={asset.file_url} alt={asset.name} className="w-full h-full object-cover" />
|
||||
<img src={getAssetUrl(asset.file_url)} alt={asset.name} className="w-full h-full object-cover" />
|
||||
) : isVideo ? (
|
||||
<Video className="w-5 h-5 text-primary" />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Play, Loader2 } from 'lucide-react';
|
||||
|
||||
interface GenerateVariantsButtonProps {
|
||||
onGenerate: () => void;
|
||||
generating?: boolean;
|
||||
disabled?: boolean;
|
||||
totalCombinations: number;
|
||||
}
|
||||
|
||||
export const GenerateVariantsButton: React.FC<GenerateVariantsButtonProps> = ({
|
||||
onGenerate,
|
||||
generating = false,
|
||||
disabled = false,
|
||||
totalCombinations = 0
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerate}
|
||||
disabled={disabled || generating || totalCombinations === 0}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 bg-primary hover:bg-primary-hover disabled:bg-gray-200 text-white rounded-xl font-semibold text-xs transition-all shadow-md hover:shadow-lg disabled:text-gray-400 disabled:cursor-not-allowed disabled:shadow-none"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Generating {totalCombinations} Variant(s)...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 fill-current" />
|
||||
<span>Generate {totalCombinations} Variant{totalCombinations !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Layers, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface AxisSummary {
|
||||
name: string;
|
||||
code: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface VariantPreviewPanelProps {
|
||||
axes: AxisSummary[];
|
||||
totalCombinations: number;
|
||||
skuTemplate?: string;
|
||||
parentSku?: string;
|
||||
}
|
||||
|
||||
export const VariantPreviewPanel: React.FC<VariantPreviewPanelProps> = ({
|
||||
axes = [],
|
||||
totalCombinations = 0,
|
||||
skuTemplate = '{PARENT_SKU}-{COMBO}',
|
||||
parentSku = 'PRODUCT-SKU'
|
||||
}) => {
|
||||
const isTooLarge = totalCombinations > 500;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-primary/10 p-5 shadow-2xs space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-primary" />
|
||||
<h4 className="font-semibold text-sm text-gray-900">Live Variant Matrix Preview</h4>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${
|
||||
isTooLarge
|
||||
? 'bg-amber-100 text-amber-800 border border-amber-300'
|
||||
: totalCombinations > 0
|
||||
? 'bg-primary/10 text-primary-dark border border-primary/20'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{totalCombinations} Variant{totalCombinations !== 1 ? 's' : ''} Calculated
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Combination Formula display */}
|
||||
<div className="p-4 bg-gray-50/70 border border-gray-200/80 rounded-xl flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center flex-wrap gap-2 text-xs font-medium text-gray-700">
|
||||
{axes.map((axis, i) => (
|
||||
<React.Fragment key={axis.code}>
|
||||
<span className="px-2.5 py-1 bg-white border border-gray-200 rounded-md shadow-2xs font-mono">
|
||||
{axis.name} <strong className="text-primary">[{axis.count}]</strong>
|
||||
</span>
|
||||
{i < axes.length - 1 && <span className="text-gray-400 font-bold">×</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{axes.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400 font-bold">=</span>
|
||||
<span className="px-3 py-1 bg-primary text-white font-bold rounded-md shadow-2xs">
|
||||
{totalCombinations} Combinations
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-gray-400 font-mono">
|
||||
Template: {skuTemplate.replace('{PARENT_SKU}', parentSku)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning banner if > 500 */}
|
||||
{isTooLarge && (
|
||||
<div className="flex items-start gap-3 p-3.5 bg-amber-50 border border-amber-200 text-amber-900 rounded-xl text-xs">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-bold block">Large Variant Matrix Warning ({totalCombinations} Combinations)</span>
|
||||
<p className="text-amber-700 mt-0.5">
|
||||
Generating more than 500 variants for a single product may impact catalog management performance. Consider splitting values into separate product models or refining axes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Trash2, Edit2, Upload, FileText, Check, X, Tag } from 'lucide-react';
|
||||
|
||||
interface VariantValuesCardProps {
|
||||
axis: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
suggestedValues?: string[];
|
||||
};
|
||||
selectedValues: string[];
|
||||
onChangeValues: (newValues: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VariantValuesCard: React.FC<VariantValuesCardProps> = ({
|
||||
axis,
|
||||
selectedValues = [],
|
||||
onChangeValues,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [inlineInput, setInlineInput] = useState('');
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [editInputValue, setEditInputValue] = useState('');
|
||||
const [showBulkPaste, setShowBulkPaste] = useState(false);
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
|
||||
const handleAddInline = () => {
|
||||
const trimmed = inlineInput.trim();
|
||||
if (!trimmed) return;
|
||||
if (!selectedValues.includes(trimmed)) {
|
||||
onChangeValues([...selectedValues, trimmed]);
|
||||
}
|
||||
setInlineInput('');
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChangeValues(selectedValues.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleStartEdit = (index: number, val: string) => {
|
||||
setEditingIndex(index);
|
||||
setEditInputValue(val);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (index: number) => {
|
||||
const trimmed = editInputValue.trim();
|
||||
if (!trimmed) return;
|
||||
const updated = [...selectedValues];
|
||||
updated[index] = trimmed;
|
||||
onChangeValues(updated);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleBulkPasteSubmit = () => {
|
||||
const lines = bulkText
|
||||
.split(/[\n,]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
const combined = Array.from(new Set([...selectedValues, ...lines]));
|
||||
onChangeValues(combined);
|
||||
setBulkText('');
|
||||
setShowBulkPaste(false);
|
||||
};
|
||||
|
||||
const handleCsvImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
if (!content) return;
|
||||
const values = content
|
||||
.split(/[\r\n,]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
const combined = Array.from(new Set([...selectedValues, ...values]));
|
||||
onChangeValues(combined);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleSelectSuggested = (val: string) => {
|
||||
if (!selectedValues.includes(val)) {
|
||||
onChangeValues([...selectedValues, val]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-2xs space-y-4">
|
||||
{/* Card Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4 text-primary" />
|
||||
<h4 className="font-semibold text-sm text-gray-900">{axis.name}</h4>
|
||||
<span className="px-2 py-0.5 rounded-full bg-primary/10 text-primary-dark text-[10px] font-mono">
|
||||
{axis.code}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-gray-500">
|
||||
({selectedValues.length} value{selectedValues.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBulkPaste(!showBulkPaste)}
|
||||
className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-primary px-2.5 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Bulk Paste</span>
|
||||
</button>
|
||||
|
||||
<label className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-primary px-2.5 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Choose CSV</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
onChange={handleCsvImport}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggested values pills if any */}
|
||||
{axis.suggestedValues && axis.suggestedValues.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 bg-gray-50/70 p-2.5 rounded-lg text-xs">
|
||||
<span className="text-gray-500 font-medium mr-1">Suggested:</span>
|
||||
{axis.suggestedValues.map((sug, i) => {
|
||||
const isSelected = selectedValues.includes(sug);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={disabled || isSelected}
|
||||
onClick={() => handleSelectSuggested(sug)}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium border transition-colors ${
|
||||
isSelected
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200 opacity-60 cursor-default'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
+ {sug}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Paste Area */}
|
||||
{showBulkPaste && (
|
||||
<div className="p-3 bg-gray-50 border border-gray-200 rounded-lg space-y-2 animate-in fade-in duration-150">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-gray-700">
|
||||
<span>Paste Values (one per line or comma separated)</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBulkPaste(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
placeholder="Black Blue White Silver"
|
||||
rows={4}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 font-mono focus:outline-none focus:ring-1 focus:ring-primary bg-white"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkPasteSubmit}
|
||||
className="px-3 py-1.5 bg-primary text-white text-xs font-semibold rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
Add Pasted Values
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Values Chip List */}
|
||||
<div className="flex flex-wrap items-center gap-2 min-h-[44px] p-2 bg-gray-50/50 rounded-lg border border-gray-100">
|
||||
{selectedValues.map((val, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white text-xs font-medium text-gray-900 border border-gray-200 rounded-lg shadow-2xs group"
|
||||
>
|
||||
{editingIndex === idx ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editInputValue}
|
||||
onChange={(e) => setEditInputValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveEdit(idx)}
|
||||
className="w-24 text-xs border border-primary rounded px-1 py-0.5 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveEdit(idx)}
|
||||
className="text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span>{val}</span>
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleStartEdit(idx, val)}
|
||||
className="text-gray-400 hover:text-primary p-0.5"
|
||||
title="Edit value"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(idx)}
|
||||
className="text-gray-400 hover:text-red-500 p-0.5"
|
||||
title="Delete value"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!disabled && (
|
||||
<div className="flex items-center gap-1 min-w-[160px]">
|
||||
<input
|
||||
type="text"
|
||||
value={inlineInput}
|
||||
onChange={(e) => setInlineInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddInline())}
|
||||
placeholder={`+ Add ${axis.name}`}
|
||||
className="px-2.5 py-1 text-xs border border-gray-200 focus:border-primary rounded-lg focus:outline-none bg-white flex-1 placeholder:text-gray-400"
|
||||
/>
|
||||
{inlineInput.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddInline}
|
||||
className="p-1.5 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -33,9 +33,9 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
setAttributeSet(blueprint.attributeSet || null);
|
||||
|
||||
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
|
||||
let flatAttrs: any[] = [];
|
||||
if (Array.isArray(groupsData) && groupsData.length > 0) {
|
||||
setGroups(groupsData);
|
||||
const flatAttrs: any[] = [];
|
||||
groupsData.forEach((g: any) => {
|
||||
if (Array.isArray(g.attributes)) {
|
||||
g.attributes.forEach((a: any) => {
|
||||
@@ -43,7 +43,6 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
setAttributes(flatAttrs.length > 0 ? flatAttrs : (blueprint.attributes || []));
|
||||
} else if (Array.isArray(blueprint.attributes) && blueprint.attributes.length > 0) {
|
||||
const defaultGroup = [{
|
||||
id: 'general-group',
|
||||
@@ -52,15 +51,34 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
attributes: blueprint.attributes
|
||||
}];
|
||||
setGroups(defaultGroup);
|
||||
setAttributes(blueprint.attributes);
|
||||
flatAttrs = blueprint.attributes;
|
||||
} else {
|
||||
setGroups([]);
|
||||
setAttributes([]);
|
||||
flatAttrs = [];
|
||||
}
|
||||
setAttributes(flatAttrs);
|
||||
|
||||
// Hydrate variant axes to ensure full attribute objects with .code and .name exist
|
||||
const allAttrsMap = new Map<string, any>(flatAttrs.map(a => [a.id, a]));
|
||||
const resolvedVariantAxes = Array.isArray(blueprint.variantAxes)
|
||||
? blueprint.variantAxes.map((va: any) => typeof va === 'string' ? (allAttrsMap.get(va) || { id: va, code: va, name: va }) : va)
|
||||
: [];
|
||||
|
||||
const normalizedBlueprint = {
|
||||
...blueprint,
|
||||
variantAxes: resolvedVariantAxes,
|
||||
variantEnabled: resolvedVariantAxes.length > 0 || Boolean(blueprint.variantEnabled)
|
||||
};
|
||||
|
||||
setFamily(normalizedBlueprint);
|
||||
setAllowedBrands(blueprint.allowedBrands || []);
|
||||
setCategory(blueprint.category || null);
|
||||
setAttributeSet(blueprint.attributeSet || null);
|
||||
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
|
||||
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
|
||||
|
||||
return normalizedBlueprint;
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load product family configuration:', err);
|
||||
setError(err.message || 'Failed to load configuration');
|
||||
@@ -74,6 +92,7 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
setAttributes([]);
|
||||
setWorkflow(null);
|
||||
setAssetFamily(null);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -114,6 +133,7 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
|
||||
assetFamily,
|
||||
loading,
|
||||
error,
|
||||
retry
|
||||
retry,
|
||||
loadConfiguration
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { FamilyCard } from '../../../components/customs/FamilyCard';
|
||||
import { Radio, RadioGroup } from '../../../components/customs/Radio';
|
||||
import { Select } from '../../../components/customs/Select';
|
||||
import { Loader } from '../../../components/customs/Loader';
|
||||
import { CategoryTreeSelect } from '../../../components/customs/CategoryTreeSelect';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'general', label: 'General', icon: Box, step: 1 },
|
||||
@@ -83,6 +84,7 @@ export default function NewProduct() {
|
||||
groups: attributeGroups,
|
||||
attributes: attributesList,
|
||||
workflow: inheritedWorkflow,
|
||||
loadConfiguration,
|
||||
} = useProductFamilyConfiguration(selectedFamily || undefined);
|
||||
|
||||
const selectedFamilyObj = useMemo(() => {
|
||||
@@ -192,7 +194,7 @@ export default function NewProduct() {
|
||||
const targetId = id || productId;
|
||||
if (isEdit && targetId) {
|
||||
await productService.update(targetId, submissionValues as any);
|
||||
await loadProduct(targetId);
|
||||
await hydrateProductEditor(targetId);
|
||||
notify.success("Changes saved successfully!");
|
||||
} else {
|
||||
const createdRaw: any = await productService.create({ ...submissionValues, family_id: selectedFamily } as any);
|
||||
@@ -201,7 +203,7 @@ export default function NewProduct() {
|
||||
setProductId(created.id);
|
||||
setIsEditMode(true);
|
||||
navigate(`/products/${created.id}/edit`, { replace: true });
|
||||
await loadProduct(created.id);
|
||||
await hydrateProductEditor(created.id);
|
||||
setActiveTab('attributes');
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -270,70 +272,101 @@ export default function NewProduct() {
|
||||
|
||||
const loadedIdRef = useRef<string | null>(null);
|
||||
|
||||
const loadProduct = useCallback(async (targetId: string) => {
|
||||
const hydrateProductEditor = useCallback(async (targetId: string) => {
|
||||
if (!targetId || targetId === 'new') return;
|
||||
|
||||
// Prevent duplicate hydration calls
|
||||
loadedIdRef.current = targetId;
|
||||
setLoadingProduct(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
// 1. Fetch Product Entity Data
|
||||
const rawData: any = await productService.getById(targetId);
|
||||
const productData = rawData?.data?.id ? rawData.data : (rawData?.id ? rawData : rawData?.data || rawData);
|
||||
if (productData && productData.id) {
|
||||
setProduct(productData);
|
||||
const initialAttrs: Record<string, any> = {
|
||||
...(productData.metadata?.attributes || {}),
|
||||
...(productData.attributes || {})
|
||||
};
|
||||
if (Array.isArray(productData.attributeValues)) {
|
||||
productData.attributeValues.forEach((av: any) => {
|
||||
const code = av.attribute?.code || av.attribute_code;
|
||||
if (code) {
|
||||
initialAttrs[code] = av.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const familyId = productData.family_id || productData.familyId || (productData.family ? (typeof productData.family === 'object' ? productData.family.id : productData.family) : null);
|
||||
const brandId = productData.brand_id || productData.brandId || (productData.brand && typeof productData.brand === 'object' ? (productData.brand as any).id : productData.brand) || '';
|
||||
const categoryId = productData.category_id || productData.categoryId || (productData.category && typeof productData.category === 'object' ? (productData.category as any).id : productData.category) || '';
|
||||
const unitId = productData.unit_id || productData.unitId || (productData.unit && typeof productData.unit === 'object' ? (productData.unit as any).id : productData.unit) || '';
|
||||
|
||||
setValuesRef.current({
|
||||
name: productData.name || '',
|
||||
sku: productData.sku || productData.metadata?.sku || '',
|
||||
productId: productData.code || '',
|
||||
category: categoryId,
|
||||
subcategory: productData.subcategory || productData.metadata?.subcategory || '',
|
||||
status: productData.status || 'draft',
|
||||
price: productData.price !== undefined && productData.price !== null ? productData.price : (productData.metadata?.price || ''),
|
||||
stock: productData.stock !== undefined && productData.stock !== null ? productData.stock : (productData.metadata?.stock || 0),
|
||||
code: productData.code || '',
|
||||
barcode: productData.barcode || productData.metadata?.barcode || '',
|
||||
gtin: productData.gtin || productData.metadata?.gtin || '',
|
||||
upc: productData.upc || productData.metadata?.upc || '',
|
||||
ean: productData.ean || productData.metadata?.ean || '',
|
||||
brand: brandId,
|
||||
unit: unitId,
|
||||
country: productData.country || productData.metadata?.country || '',
|
||||
hsn: productData.hsn || productData.metadata?.hsn || '',
|
||||
type: productData.type || productData.metadata?.type || 'simple',
|
||||
shortDesc: productData.shortDesc || productData.metadata?.shortDesc || '',
|
||||
description: productData.description || productData.metadata?.description || '',
|
||||
categories: Array.isArray(productData.categories) ? productData.categories : (categoryId ? [categoryId] : []),
|
||||
metadata: productData.metadata || {
|
||||
workflowCode: '',
|
||||
workflowName: '',
|
||||
currentStage: 'draft'
|
||||
},
|
||||
attributes: initialAttrs
|
||||
});
|
||||
|
||||
if (familyId) {
|
||||
setSelectedFamily(familyId);
|
||||
}
|
||||
setStep(2);
|
||||
} else {
|
||||
if (!productData || !productData.id) {
|
||||
setLoadError('Product details not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProduct(productData);
|
||||
|
||||
// 2. Extract & Force Blueprint Hydration
|
||||
const familyId = productData.family_id || productData.familyId || (productData.family ? (typeof productData.family === 'object' ? productData.family.id : productData.family) : null);
|
||||
|
||||
let blueprint: any = null;
|
||||
if (familyId) {
|
||||
setSelectedFamily(familyId);
|
||||
blueprint = await loadConfiguration(familyId);
|
||||
}
|
||||
|
||||
// 3. Extract Saved Attribute Values
|
||||
const savedAttrs: Record<string, any> = {
|
||||
...(productData.metadata?.attributes || {}),
|
||||
...(productData.attributes || {})
|
||||
};
|
||||
if (Array.isArray(productData.attributeValues)) {
|
||||
productData.attributeValues.forEach((av: any) => {
|
||||
const code = av.attribute?.code || av.attribute_code;
|
||||
if (code) {
|
||||
savedAttrs[code] = av.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Blueprint * Product Attribute Value Merge
|
||||
const blueprintAttrs: Record<string, any> = {};
|
||||
if (blueprint && Array.isArray(blueprint.attributes)) {
|
||||
blueprint.attributes.forEach((attr: any) => {
|
||||
if (attr && attr.code) {
|
||||
blueprintAttrs[attr.code] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mergedAttributes = {
|
||||
...blueprintAttrs,
|
||||
...savedAttrs
|
||||
};
|
||||
|
||||
const brandId = productData.brand_id || productData.brandId || (productData.brand && typeof productData.brand === 'object' ? (productData.brand as any).id : productData.brand) || '';
|
||||
const categoryId = productData.category_id || productData.categoryId || (productData.category && typeof productData.category === 'object' ? (productData.category as any).id : productData.category) || '';
|
||||
const unitId = productData.unit_id || productData.unitId || (productData.unit && typeof productData.unit === 'object' ? (productData.unit as any).id : productData.unit) || '';
|
||||
|
||||
// 5. Initialize Formik Form State
|
||||
setValuesRef.current({
|
||||
name: productData.name || '',
|
||||
sku: productData.sku || productData.metadata?.sku || '',
|
||||
productId: productData.code || '',
|
||||
category: categoryId,
|
||||
subcategory: productData.subcategory || productData.metadata?.subcategory || '',
|
||||
status: productData.status || 'draft',
|
||||
price: productData.price !== undefined && productData.price !== null ? productData.price : (productData.metadata?.price || ''),
|
||||
stock: productData.stock !== undefined && productData.stock !== null ? productData.stock : (productData.metadata?.stock || 0),
|
||||
code: productData.code || '',
|
||||
barcode: productData.barcode || productData.metadata?.barcode || '',
|
||||
gtin: productData.gtin || productData.metadata?.gtin || '',
|
||||
upc: productData.upc || productData.metadata?.upc || '',
|
||||
ean: productData.ean || productData.metadata?.ean || '',
|
||||
brand: brandId,
|
||||
unit: unitId,
|
||||
country: productData.country || productData.metadata?.country || '',
|
||||
hsn: productData.hsn || productData.metadata?.hsn || '',
|
||||
type: productData.type || productData.metadata?.type || 'simple',
|
||||
shortDesc: productData.shortDesc || productData.metadata?.shortDesc || '',
|
||||
description: productData.description || productData.metadata?.description || '',
|
||||
categories: Array.isArray(productData.categories) ? productData.categories : (categoryId ? [categoryId] : []),
|
||||
metadata: productData.metadata || {
|
||||
workflowCode: '',
|
||||
workflowName: '',
|
||||
currentStage: 'draft'
|
||||
},
|
||||
attributes: mergedAttributes
|
||||
});
|
||||
|
||||
setIsEditMode(true);
|
||||
setStep(2);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || err?.message || 'Failed to load product details';
|
||||
setLoadError(msg);
|
||||
@@ -341,7 +374,7 @@ export default function NewProduct() {
|
||||
} finally {
|
||||
setLoadingProduct(false);
|
||||
}
|
||||
}, []);
|
||||
}, [loadConfiguration]);
|
||||
|
||||
// Calculate dynamic completeness score
|
||||
const productCompleteness = useMemo(() => {
|
||||
@@ -387,12 +420,9 @@ export default function NewProduct() {
|
||||
useEffect(() => {
|
||||
const targetId = id || productId;
|
||||
if (targetId && targetId !== 'new' && loadedIdRef.current !== targetId) {
|
||||
loadedIdRef.current = targetId;
|
||||
setIsEditMode(true);
|
||||
setStep(2);
|
||||
loadProduct(targetId);
|
||||
hydrateProductEditor(targetId);
|
||||
}
|
||||
}, [id, productId, loadProduct]);
|
||||
}, [id, productId, hydrateProductEditor]);
|
||||
|
||||
// Set default category when product family loads (Category Inheritance)
|
||||
useEffect(() => {
|
||||
@@ -959,22 +989,12 @@ export default function NewProduct() {
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Category *</label>
|
||||
<Select
|
||||
name="category"
|
||||
<CategoryTreeSelect
|
||||
value={formik.values.category}
|
||||
onChange={formik.handleChange}
|
||||
>
|
||||
<option value="">Select Category</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{'\u00A0'.repeat(((c as any).level || 0) * 4)}
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{formik.touched.category && formik.errors.category && (
|
||||
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.category}</div>
|
||||
)}
|
||||
onChange={(catId) => formik.setFieldValue('category', catId)}
|
||||
placeholder="Select Category (or Quick Create inline)..."
|
||||
error={formik.touched.category && typeof formik.errors.category === 'string' ? formik.errors.category : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Country of Origin</label>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Tag } from 'lucide-react';
|
||||
|
||||
interface VariantAxisCardProps {
|
||||
axisName: string;
|
||||
axisCode: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const VariantAxisCard: React.FC<VariantAxisCardProps> = ({
|
||||
axisName,
|
||||
axisCode,
|
||||
value
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-2xs flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
<Tag className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 block uppercase tracking-wider">{axisName} ({axisCode})</span>
|
||||
<span className="font-semibold text-sm text-gray-900 font-mono mt-0.5 block">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { DataTable } from '../../../components/customs/DataTable';
|
||||
import { StatusBadge } from '../../../components/customs/StatusBadge';
|
||||
|
||||
interface VariantGridProps {
|
||||
variants: any[];
|
||||
selectedIds?: Set<string>;
|
||||
onSelectionChange?: (ids: Set<string>) => void;
|
||||
onRowClick?: (variant: any) => void;
|
||||
onEdit?: (variant: any) => void;
|
||||
onDelete?: (variant: any) => void;
|
||||
}
|
||||
|
||||
export const VariantGrid: React.FC<VariantGridProps> = ({
|
||||
variants = [],
|
||||
selectedIds = new Set(),
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
onEdit,
|
||||
onDelete
|
||||
}) => {
|
||||
const columns = [
|
||||
{
|
||||
key: 'sku',
|
||||
label: 'SKU',
|
||||
sortable: true,
|
||||
render: (val: string) => <span className="font-mono text-xs font-bold text-gray-900">{val}</span>
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Variant Name',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
key: 'combination',
|
||||
label: 'Combination',
|
||||
render: (_val: any, row: any) => {
|
||||
const attrs = row.attributes || row.valuesMap || {};
|
||||
const entries = Object.entries(attrs);
|
||||
if (entries.length === 0) return <span className="text-gray-400 text-xs">-</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{entries.map(([k, v]) => (
|
||||
<span key={k} className="px-2 py-0.5 bg-gray-100 text-gray-800 text-[11px] font-semibold rounded font-mono">
|
||||
{String(v)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
label: 'Price',
|
||||
sortable: true,
|
||||
render: (val: any) => <span className="font-semibold text-xs">${Number(val || 0).toFixed(2)}</span>
|
||||
},
|
||||
{
|
||||
key: 'stock',
|
||||
label: 'Stock',
|
||||
sortable: true,
|
||||
render: (val: any) => <span className="font-medium text-xs">{val || 0}</span>
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<StatusBadge
|
||||
status={val === 'published' || val === 'active' ? 'active' : val === 'disabled' ? 'disabled' : 'draft'}
|
||||
label={val ? val.charAt(0).toUpperCase() + val.slice(1) : 'Draft'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={variants}
|
||||
selectable={!!onSelectionChange}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onRowClick={onRowClick}
|
||||
actionConfig={onEdit && onDelete ? {
|
||||
onEdit,
|
||||
onDelete
|
||||
} : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import { StatusBadge } from '../../../components/customs/StatusBadge';
|
||||
|
||||
interface VariantPreviewProps {
|
||||
variant: {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
status: string;
|
||||
parentProductName?: string;
|
||||
attributes?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export const VariantPreview: React.FC<VariantPreviewProps> = ({ variant }) => {
|
||||
return (
|
||||
<div className="bg-white border border-primary/10 rounded-2xl p-6 shadow-xs space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary font-bold">
|
||||
<Package className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-gray-900">{variant.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="font-mono text-xs font-semibold px-2 py-0.5 bg-gray-100 rounded text-gray-700">
|
||||
{variant.sku}
|
||||
</span>
|
||||
{variant.parentProductName && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Parent: <strong>{variant.parentProductName}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusBadge
|
||||
status={variant.status === 'published' || variant.status === 'active' ? 'active' : variant.status === 'disabled' ? 'disabled' : 'draft'}
|
||||
label={variant.status ? variant.status.toUpperCase() : 'DRAFT'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{variant.attributes && Object.keys(variant.attributes).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t border-gray-100">
|
||||
{Object.entries(variant.attributes).map(([key, val]) => (
|
||||
<span key={key} className="px-2.5 py-1 bg-primary/5 text-primary-dark border border-primary/15 rounded-lg text-xs font-semibold">
|
||||
{key}: <strong className="text-gray-900 font-mono">{val}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-gray-100">
|
||||
<div className="p-3 bg-gray-50/70 rounded-xl">
|
||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider block">Price</span>
|
||||
<span className="text-lg font-bold text-gray-900 mt-0.5 block">${Number(variant.price || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50/70 rounded-xl">
|
||||
<span className="text-[11px] font-semibold text-gray-500 uppercase tracking-wider block">Stock Level</span>
|
||||
<span className="text-lg font-bold text-gray-900 mt-0.5 block">{variant.stock || 0} units</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { Search, RefreshCw, Upload, Download } from 'lucide-react';
|
||||
|
||||
interface VariantToolbarProps {
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
selectedStatus: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
selectedCount?: number;
|
||||
onBulkUpdate?: () => void;
|
||||
onImport?: () => void;
|
||||
onExport?: () => void;
|
||||
}
|
||||
|
||||
export const VariantToolbar: React.FC<VariantToolbarProps> = ({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
selectedStatus,
|
||||
onStatusChange,
|
||||
selectedCount = 0,
|
||||
onBulkUpdate,
|
||||
onImport,
|
||||
onExport
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-2xs flex items-center justify-between flex-wrap gap-4">
|
||||
{/* Search Input */}
|
||||
<div className="relative min-w-[260px] flex-1">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search by SKU, variant name, or parent product..."
|
||||
className="w-full text-xs border border-gray-200 rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-gray-50/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Dropdown */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="text-xs border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary/20 bg-white"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
|
||||
{onImport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImport}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Import</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onExport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExport}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-lg text-xs font-semibold transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedCount > 0 && onBulkUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBulkUpdate}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 bg-primary text-white hover:bg-primary-hover rounded-lg text-xs font-semibold transition-colors shadow-2xs"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>Bulk Update ({selectedCount})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Edit2, Check, X } from 'lucide-react';
|
||||
|
||||
interface VariantValueEditorProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onSave: (newValue: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VariantValueEditor: React.FC<VariantValueEditorProps> = ({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [currentVal, setCurrentVal] = useState(value);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(currentVal);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50/70 border border-gray-200 rounded-lg text-xs">
|
||||
<span className="font-medium text-gray-600">{label}:</span>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={currentVal}
|
||||
onChange={(e) => setCurrentVal(e.target.value)}
|
||||
className="px-2 py-1 text-xs border border-primary rounded bg-white focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="p-1 text-emerald-600 hover:text-emerald-700 font-bold"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCurrentVal(value); setIsEditing(false); }}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-gray-900 font-mono">{value}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-gray-400 hover:text-primary transition-colors"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,19 @@ export const useVariant = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchVariantById = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await variantService.getById(id);
|
||||
return data;
|
||||
} catch (err) {
|
||||
notify.error(err);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createVariant = useCallback(async (req: VariantCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -63,5 +76,5 @@ export const useVariant = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { variants, loading, fetchVariants, createVariant, updateVariant, deleteVariant };
|
||||
return { variants, loading, fetchVariants, fetchVariantById, createVariant, updateVariant, deleteVariant };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useVariant } from '../hook/useVariant';
|
||||
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
|
||||
import { PageWrapper } from '../../../components/layouts/PageWrapper';
|
||||
import { VariantPreview } from '../components/VariantPreview';
|
||||
import type { VariantStatus } from '../types/variant.types';
|
||||
import {
|
||||
FileText, Package, DollarSign, Image, Globe, History, Save, ArrowLeft, Loader2
|
||||
} from 'lucide-react';
|
||||
import { notify } from '../../../services/toast';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Overview', icon: FileText },
|
||||
{ id: 'inventory', label: 'Inventory', icon: Package },
|
||||
{ id: 'pricing', label: 'Pricing', icon: DollarSign },
|
||||
{ id: 'assets', label: 'Assets', icon: Image },
|
||||
{ id: 'publishing', label: 'Publishing & Channels', icon: Globe },
|
||||
{ id: 'history', label: 'History', icon: History }
|
||||
];
|
||||
|
||||
export default function VariantDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { fetchVariantById, updateVariant } = useVariant();
|
||||
const [variant, setVariant] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
sku: '',
|
||||
price: 0,
|
||||
costPrice: 0,
|
||||
stock: 0,
|
||||
availableStock: 0,
|
||||
reservedStock: 0,
|
||||
safetyStock: 0,
|
||||
status: 'draft' as VariantStatus,
|
||||
barcode: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
fetchVariantById(id)
|
||||
.then((res: any) => {
|
||||
if (res) {
|
||||
setVariant(res);
|
||||
setFormData({
|
||||
name: res.name || '',
|
||||
sku: res.sku || '',
|
||||
price: Number(res.price || 0),
|
||||
costPrice: Number(res.costPrice || res.cost_price || 0),
|
||||
stock: Number(res.stock || 0),
|
||||
availableStock: Number(res.availableStock || res.available_stock || 0),
|
||||
reservedStock: Number(res.reservedStock || res.reserved_stock || 0),
|
||||
safetyStock: Number(res.safetyStock || res.safety_stock || 0),
|
||||
status: (res.status || 'draft') as VariantStatus,
|
||||
barcode: res.barcode || ''
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, fetchVariantById]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateVariant(id, {
|
||||
...formData,
|
||||
status: formData.status as VariantStatus
|
||||
});
|
||||
notify.success('Variant updated successfully');
|
||||
} catch (err: any) {
|
||||
notify.error(err?.message || 'Failed to update variant');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!variant) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
Variant not found.
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Home', href: '/dashboard' },
|
||||
{ label: 'Variant Management', href: '/variants' },
|
||||
{ label: variant.sku || 'Variant Detail' }
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/variants')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 border border-gray-200 hover:bg-gray-50 text-gray-700 rounded-lg text-xs font-semibold"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to Variants</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-xs font-semibold transition-colors shadow-2xs"
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
<span>Save Variant</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Left Card Preview */}
|
||||
<div className="lg:col-span-1">
|
||||
<VariantPreview variant={{ ...variant, ...formData }} />
|
||||
</div>
|
||||
|
||||
{/* Right Main Tabs & Content */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* Tab Bar */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-1.5 flex items-center gap-1 overflow-x-auto">
|
||||
{TABS.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||
isActive
|
||||
? 'bg-primary text-white shadow-2xs'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-2xs">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-sm text-gray-900 border-b border-gray-100 pb-3">General Attributes & Info</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Variant Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">SKU</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sku}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, sku: e.target.value }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 font-mono bg-gray-50 text-gray-700"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Barcode / EAN</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.barcode}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, barcode: e.target.value }))}
|
||||
placeholder="e.g. 8901234567890"
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value as VariantStatus }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'inventory' && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-sm text-gray-900 border-b border-gray-100 pb-3">Stock & Warehouse Levels</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Total Stock</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.stock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, stock: Number(e.target.value) }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Available Stock</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.availableStock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, availableStock: Number(e.target.value) }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Reserved Stock</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.reservedStock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, reservedStock: Number(e.target.value) }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Safety Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.safetyStock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, safetyStock: Number(e.target.value) }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'pricing' && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-sm text-gray-900 border-b border-gray-100 pb-3">Pricing & Margins</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Retail Selling Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, price: Number(e.target.value) }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Cost Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.costPrice}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, costPrice: Number(e.target.value) }))}
|
||||
className="w-full text-xs border border-gray-200 rounded-lg p-2.5 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'assets' && (
|
||||
<div className="text-center py-10 text-xs text-gray-400">
|
||||
Variant Asset mapping & DAM media assignments.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'publishing' && (
|
||||
<div className="text-center py-10 text-xs text-gray-400">
|
||||
Channel publishing statuses for this variant SKU.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div className="text-center py-10 text-xs text-gray-400">
|
||||
Audit log history for SKU {variant.sku}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Upload, Download, RefreshCw, Plus, Package, CheckCircle, Edit3, XCircle } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Upload, Download, Package, CheckCircle, Edit3, XCircle } from "lucide-react";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { useVariant } from "../hook/useVariant";
|
||||
import { DataTable } from "../../../components/customs/DataTable";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Can } from "../../../components/customs/Can";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
|
||||
import { StatsCard } from "../../../components/customs/StatsCard";
|
||||
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
|
||||
import { VariantToolbar } from "../components/VariantToolbar";
|
||||
|
||||
export default function VariantList() {
|
||||
const { variants, fetchVariants, deleteVariant } = useVariant();
|
||||
@@ -18,6 +18,8 @@ export default function VariantList() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({ isOpen: false, id: "", name: "" });
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchVariants();
|
||||
@@ -36,23 +38,38 @@ export default function VariantList() {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredVariants = useMemo(() => {
|
||||
return variants.filter(v => {
|
||||
const matchSearch = !searchTerm ||
|
||||
v.sku.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
v.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(v.parentProductName && v.parentProductName.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const matchStatus = !statusFilter || v.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
}, [variants, searchTerm, statusFilter]);
|
||||
|
||||
// Stats Calculations
|
||||
const total = variants.length;
|
||||
const draftCount = variants.filter(v => v.status === 'draft').length;
|
||||
const disabledCount = variants.filter(v => v.status === 'disabled').length;
|
||||
const publishedCount = variants.filter(v => v.status === 'published').length;
|
||||
const publishedCount = variants.filter(v => v.status === 'published' || v.status === 'active').length;
|
||||
|
||||
const columns = [
|
||||
{ key: "sku", label: "SKU", sortable: true },
|
||||
{
|
||||
key: "sku",
|
||||
label: "SKU",
|
||||
sortable: true,
|
||||
render: (val: string) => <span className="font-mono text-xs font-bold text-gray-900">{val}</span>
|
||||
},
|
||||
{ key: "name", label: "Variant Name", sortable: true },
|
||||
{ key: "parentProductName", label: "Product", sortable: true },
|
||||
{
|
||||
key: "family",
|
||||
label: "Family",
|
||||
key: "price",
|
||||
label: "Price",
|
||||
sortable: true,
|
||||
render: (_val: any, row: any) => row.parentProductFamily?.name || '-'
|
||||
render: (val: any) => `$${Number(val || 0).toFixed(2)}`
|
||||
},
|
||||
{ key: "price", label: "Price", sortable: true, render: (val: any) => `$${val?.toFixed(2) || '0.00'}` },
|
||||
{ key: "stock", label: "Stock", sortable: true },
|
||||
{
|
||||
key: "status",
|
||||
@@ -61,7 +78,7 @@ export default function VariantList() {
|
||||
render: (val: any) => (
|
||||
<StatusBadge
|
||||
status={val === "active" || val === "published" ? "active" : val === "disabled" ? "disabled" : "draft"}
|
||||
label={val ? val.charAt(0).toUpperCase() + val.slice(1) : "Unknown"}
|
||||
label={val ? val.charAt(0).toUpperCase() + val.slice(1) : "Draft"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -76,35 +93,23 @@ export default function VariantList() {
|
||||
return (
|
||||
<ProtectedRoute node="products.variants">
|
||||
<PageWrapper>
|
||||
{/* Breadcrumb & Actions */}
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb
|
||||
items={[{ label: "Home" }, { label: "Variant Management" }]}
|
||||
items={[{ label: "Home", href: "/dashboard" }, { label: "Variant Management" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="md" icon={<Upload className="w-4 h-4" />}>Import</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="md" icon={<Upload className="w-4 h-4" />}>Import CSV</Button>
|
||||
<Button variant="outline" size="md" icon={<Download className="w-4 h-4" />}>Export</Button>
|
||||
<Button variant="outline" size="md" icon={<RefreshCw className="w-4 h-4" />}>Bulk Update</Button>
|
||||
<Can node="products.variants" action="create">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate('new')}
|
||||
>
|
||||
Create Variant
|
||||
</Button>
|
||||
</Can>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6 mt-4">
|
||||
<StatsCard
|
||||
title="Total Variants"
|
||||
title="Total Generated Variants"
|
||||
value={total}
|
||||
subtitle="All variants"
|
||||
subtitle="SKUs created from Products"
|
||||
color="purple"
|
||||
icon={<Package className="w-5 h-5" />}
|
||||
/>
|
||||
@@ -112,7 +117,7 @@ export default function VariantList() {
|
||||
<StatsCard
|
||||
title="Published"
|
||||
value={publishedCount}
|
||||
subtitle="Live & active"
|
||||
subtitle="Active & Live SKUs"
|
||||
color="green"
|
||||
icon={<CheckCircle className="w-5 h-5" />}
|
||||
/>
|
||||
@@ -120,7 +125,7 @@ export default function VariantList() {
|
||||
<StatsCard
|
||||
title="Drafts"
|
||||
value={draftCount}
|
||||
subtitle="In progress"
|
||||
subtitle="Unpublished variants"
|
||||
color="blue"
|
||||
icon={<Edit3 className="w-5 h-5" />}
|
||||
/>
|
||||
@@ -128,25 +133,37 @@ export default function VariantList() {
|
||||
<StatsCard
|
||||
title="Disabled"
|
||||
value={disabledCount}
|
||||
subtitle="Not available"
|
||||
subtitle="Inactive variants"
|
||||
color="red"
|
||||
icon={<XCircle className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="mb-4">
|
||||
<VariantToolbar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
selectedStatus={statusFilter}
|
||||
onStatusChange={setStatusFilter}
|
||||
selectedCount={selectedIds.size}
|
||||
onBulkUpdate={() => alert(`Bulk updating ${selectedIds.size} variants`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="mb-8">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={variants}
|
||||
data={filteredVariants}
|
||||
selectable
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
onRowClick={(row) => navigate(`${row.id}/edit`)}
|
||||
searchPlaceholder="Search variants by SKU, name, or product..."
|
||||
onRowClick={(row) => navigate(`/variants/${row.id}`)}
|
||||
searchPlaceholder="Filter variants..."
|
||||
actionConfig={{
|
||||
onView: (row) => navigate(`${row.id}/view`),
|
||||
onEdit: (row) => navigate(`${row.id}/edit`),
|
||||
onView: (row) => navigate(`/variants/${row.id}`),
|
||||
onEdit: (row) => navigate(`/variants/${row.id}`),
|
||||
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import VariantList from '../pages/VariantList';
|
||||
import NewVariant from '../pages/NewVariant';
|
||||
import VariantDetail from '../pages/VariantDetail';
|
||||
|
||||
export const VariantRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<VariantList />} />
|
||||
<Route path="new" element={<NewVariant />} />
|
||||
<Route path=":id/edit" element={<NewVariant />} />
|
||||
<Route path=":id/view" element={<NewVariant />} />
|
||||
<Route path=":id" element={<VariantDetail />} />
|
||||
<Route path=":id/edit" element={<VariantDetail />} />
|
||||
<Route path=":id/view" element={<VariantDetail />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,3 +8,26 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to convert relative asset paths (/uploads/...) to full absolute URLs pointing to backend server.
|
||||
*/
|
||||
export function getAssetUrl(url?: string): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
|
||||
return url;
|
||||
}
|
||||
const cleanUrl = url.startsWith('/') ? url : `/${url}`;
|
||||
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:5000';
|
||||
return `${baseUrl}${cleanUrl}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to check if a file is an image based on mime_type or file extension.
|
||||
*/
|
||||
export function isImageFile(mimeType?: string, url?: string): boolean {
|
||||
if (mimeType?.startsWith('image/')) return true;
|
||||
if (url && /\.(jpg|jpeg|png|gif|webp|svg|avif|bmp)(\?.*)?$/i.test(url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/uploads': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user