upadted flow

This commit is contained in:
m7338ohd-debug
2026-07-25 15:55:59 +05:30
parent 577ff94780
commit 85c439dc42
27 changed files with 2242 additions and 235 deletions
@@ -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>
);
};
+8 -7
View File
@@ -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">
+4 -6
View File
@@ -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>
);
};
+94 -90
View File
@@ -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&#10;Blue&#10;White&#10;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
};
};
+98 -78
View File
@@ -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>
);
};
+14 -1
View File
@@ -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>
);
}
+55 -38
View File
@@ -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>
);
};
+23
View File
@@ -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;
}
+8
View File
@@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/uploads': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
})