solve megre conflicts

This commit is contained in:
MohamedHasan07
2026-07-14 12:58:59 +05:30
44 changed files with 4182 additions and 1169 deletions
+3 -1
View File
@@ -4,7 +4,9 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --mode development",
"test": "vite --mode test",
"local": "vite --mode localdev",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
+8 -2
View File
@@ -40,11 +40,17 @@ const apiClient = {
return response.data;
},
post: async <T>(url: string, data?: unknown): Promise<T> => {
const response = await axiosInstance.post<T>(url, data);
const config = data instanceof FormData
? { headers: { 'Content-Type': 'multipart/form-data' } }
: undefined;
const response = await axiosInstance.post<T>(url, data, config);
return response.data;
},
put: async <T>(url: string, data?: unknown): Promise<T> => {
const response = await axiosInstance.put<T>(url, data);
const config = data instanceof FormData
? { headers: { 'Content-Type': 'multipart/form-data' } }
: undefined;
const response = await axiosInstance.put<T>(url, data, config);
return response.data;
},
patch: async <T>(url: string, data?: unknown): Promise<T> => {
+5
View File
@@ -16,6 +16,7 @@ interface SelectProps {
error?: boolean;
placeholder?: string;
children?: React.ReactNode;
disabled?: boolean;
}
export function Select({
@@ -27,6 +28,7 @@ export function Select({
error,
placeholder = "Select...",
children,
disabled,
}: SelectProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [rect, setRect] = React.useState<DOMRect | null>(null);
@@ -79,6 +81,7 @@ export function Select({
}, [isOpen]);
const handleToggle = () => {
if (disabled) return;
if (!isOpen && buttonRef.current) {
setRect(buttonRef.current.getBoundingClientRect());
}
@@ -95,10 +98,12 @@ export function Select({
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={handleToggle}
onBlur={onBlur}
className={cn(
"w-full h-9 flex items-center justify-between rounded-md border px-3 py-1 text-sm bg-surface text-foreground transition-[color,box-shadow] outline-none cursor-pointer text-left",
disabled && "opacity-60 cursor-not-allowed bg-gray-50/50",
error
? "border-danger"
: "border-border hover:border-primary/50 focus:border-primary focus:ring-2 focus:ring-primary/20",
+1 -1
View File
@@ -63,5 +63,5 @@ export const useAsset = () => {
}
}, []);
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
return { items, loading, fetchItems, createItem, updateItem, deleteItem, replaceAsset, archiveAsset, restoreAsset };
};
File diff suppressed because it is too large Load Diff
+268 -41
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -8,8 +8,9 @@ import { useAsset } from "../hook/useAsset";
import { assetSchema } from "../validation/assets.schema";
import { assetsService } from "../services/assets.service";
import type { AssetCreateRequest } from "../types/assets.types";
import { Save } from 'lucide-react';
import { Save, Upload, FileText, Video, Loader2, Trash2, AlertCircle } from 'lucide-react';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { toast } from 'react-toastify';
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`;
@@ -21,23 +22,43 @@ export default function NewAsset() {
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem } = useAsset();
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const formik = useFormik({
initialValues: {
name: "",
status: "active",
file_url: "",
file_size: 0,
mime_type: "",
status: "active" as "active" | "inactive" | "draft",
},
validationSchema: assetSchema,
onSubmit: async (values, { setSubmitting }) => {
try {
if (!values.file_url) {
toast.error("Please upload a file first");
return;
}
const payload: AssetCreateRequest = {
name: values.name,
file_url: values.file_url,
file_size: values.file_size,
mime_type: values.mime_type,
status: values.status,
};
if (isEdit && id) {
await updateItem(id, values as any);
await updateItem(id, payload);
} else {
await createItem(values as AssetCreateRequest);
await createItem(payload);
}
navigate("..");
} catch {
// toast handled
// toast handled in hook
} finally {
setSubmitting(false);
}
@@ -47,12 +68,81 @@ export default function NewAsset() {
useEffect(() => {
if (isEdit && id) {
assetsService.getById(id).then(item => {
if (item) formik.setValues({ name: item.name, status: item.status });
if (item) {
formik.setValues({
name: item.name,
file_url: item.file_url || "",
file_size: item.file_size || 0,
mime_type: item.mime_type || "",
status: item.status || "active",
});
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id]);
const handleFileUpload = async (file: File) => {
setUploading(true);
try {
const data = await assetsService.upload(file);
formik.setFieldValue("file_url", data.file_url);
formik.setFieldValue("file_size", data.file_size);
formik.setFieldValue("mime_type", data.mime_type);
// Auto fill asset name with file name if not already set
if (!formik.values.name) {
formik.setFieldValue("name", file.name.replace(/\.[^/.]+$/, ""));
}
toast.success("File uploaded successfully!");
} catch (err: any) {
toast.error(err.response?.data?.message || err.message || "Failed to upload file");
} finally {
setUploading(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
handleFileUpload(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = () => {
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileUpload(e.dataTransfer.files[0]);
}
};
const removeFile = () => {
formik.setFieldValue("file_url", "");
formik.setFieldValue("file_size", 0);
formik.setFieldValue("mime_type", "");
};
const formatBytes = (bytes: number, decimals = 2) => {
if (!bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const isImage = formik.values.mime_type?.startsWith("image/");
const isVideo = formik.values.mime_type?.startsWith("video/");
return (
<PageWrapper>
<Breadcrumb
@@ -65,45 +155,182 @@ export default function NewAsset() {
</>
}
/>
<form id="asset-form" onSubmit={formik.handleSubmit} className="max-w-4xl space-y-10">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-6 border-b border-gray-200 pb-2">Basic Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className={labelClass}>Asset Name *</label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Hero Image"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
<form id="asset-form" onSubmit={formik.handleSubmit} className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: File Upload */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-xl border border-primary/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">Media File</h3>
<p className="text-xs text-primary-light mt-0.5">Upload product images, videos or documentation</p>
</div>
</div>
<div className="md:col-span-2">
<label className={labelClass}>Status</label>
<RadioGroup className="mt-2">
<Radio
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
label="Active"
/>
<Radio
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
label="Inactive"
/>
</RadioGroup>
<div className="p-6">
{!formik.values.file_url ? (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all flex flex-col items-center justify-center ${
dragOver ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-primary/20 hover:bg-gray-50/50 bg-white'
}`}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,video/*,application/pdf,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
/>
{uploading ? (
<div className="space-y-3">
<Loader2 className="w-10 h-10 text-primary animate-spin mx-auto" />
<p className="font-medium text-sm text-gray-900">Uploading file to PIM server...</p>
<p className="text-xs text-gray-500">Please wait while the media is processed.</p>
</div>
) : (
<div className="space-y-4">
<div className="w-12 h-12 rounded-full bg-primary/5 flex items-center justify-center mx-auto border border-primary/10">
<Upload className="w-6 h-6 text-primary" />
</div>
<div>
<h4 className="text-gray-900 font-semibold text-sm">Drag & Drop Files Here</h4>
<p className="text-xs text-gray-400 mt-1">or click to browse from your computer</p>
</div>
<p className="text-[11px] text-gray-400">
PNG, JPG, WEBP, MP4, PDF, XLSX, CSV (Max size: 10MB)
</p>
</div>
)}
</div>
) : (
<div className="border border-primary/10 rounded-xl p-6 bg-gray-50/50 space-y-6">
{/* File preview */}
<div className="flex items-start gap-4">
<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}
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" />
) : formik.values.mime_type === "application/pdf" ? (
<FileText className="w-8 h-8 text-emerald-600" />
) : (
<FileText className="w-8 h-8 text-blue-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-gray-900 truncate">{formik.values.name}</div>
<div className="text-xs text-gray-400 font-mono mt-1 select-all break-all">{formik.values.file_url}</div>
<div className="flex flex-wrap gap-4 mt-3 text-xs text-gray-500">
<div>
<span className="font-medium text-gray-700">Size: </span>
{formatBytes(formik.values.file_size)}
</div>
<div>
<span className="font-medium text-gray-700">Format: </span>
{formik.values.mime_type}
</div>
</div>
</div>
<button
type="button"
onClick={removeFile}
className="p-2 border border-red-100 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 hover:text-red-700 transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{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" />
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Right Column: Specifications */}
<div className="space-y-6">
<div className="bg-white rounded-xl border border-primary/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">Asset Properties</h3>
<p className="text-xs text-primary-light mt-0.5">Define name, classification and visibility</p>
</div>
</div>
<div className="p-6 space-y-6">
<div>
<label className={labelClass}>Asset Name <span className="text-red-400">*</span></label>
<input
name="name"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Front Detail Close-up"
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-2 space-y-2">
<Radio
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
label="Active"
/>
<Radio
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
label="Inactive"
/>
<Radio
name="status"
value="draft"
checked={formik.values.status === "draft"}
onChange={formik.handleChange}
label="Draft"
/>
</RadioGroup>
</div>
</div>
</div>
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 flex gap-3">
<AlertCircle className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-blue-900 text-xs">PIM Asset Requirements</h4>
<p className="text-[11px] text-blue-700 mt-1 leading-normal">
Files are stored directly on the PIM server. Once saved, these digital assets can be mapped to categories, variant SKUs, or product catalogs.
</p>
</div>
</div>
</div>
</form>
</PageWrapper>
);
+134 -8
View File
@@ -1,5 +1,4 @@
import type { Asset, AssetCreateRequest, AssetUpdateRequest } from '../types/assets.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
@@ -8,30 +7,157 @@ interface ApiResponse<T> {
message?: string;
}
export interface AssetRelationInfo {
products: { id: string; name: string; code: string; role: string }[];
variants: { id: string; name: string; code: string; role: string }[];
families: { id: string; name: string; code: string; role: string }[];
categories: { id: string; name: string; code: string; role: string }[];
channels: { id: string; name: string; code: string; role: string }[];
}
export interface AssetAnalytics {
stats: {
total: number;
images: number;
videos: number;
pdfs: number;
documents: number;
storageUsed: number;
unused: number;
archived: number;
};
largest: { id: string; name: string; file_size: number; file_url: string; mime_type: string }[];
recentlyUploaded: { id: string; name: string; created_at: string; file_url: string }[];
recentlyModified: { id: string; name: string; updated_at: string; file_url: string }[];
}
export interface AssetMapping {
id: string;
asset_id: string;
role: string;
display_order: number;
is_primary: boolean;
asset?: Asset & { assetType?: { code: string; name: string } };
}
export const assetsService = {
getAll: async (): Promise<Asset[]> => {
// Assuming backend maps this to /api/v1/media/assets
const res = await apiClient.get<ApiResponse<Asset[]>>('/api/v1/media/assets');
getAll: async (params?: Record<string, string>): Promise<Asset[]> => {
const res = await apiClient.get<ApiResponse<Asset[]>>('/api/v1/assets', { params });
return res.data || [];
},
getById: async (id: string): Promise<Asset | undefined> => {
const res = await apiClient.get<ApiResponse<Asset>>(`/api/v1/media/assets/${id}`);
const res = await apiClient.get<ApiResponse<Asset>>(`/api/v1/assets/${id}`);
return res.data;
},
create: async (req: AssetCreateRequest): Promise<Asset> => {
const res = await apiClient.post<ApiResponse<Asset>>('/api/v1/media/assets', req);
const res = await apiClient.post<ApiResponse<Asset>>('/api/v1/assets', req);
return res.data;
},
update: async (id: string, req: AssetUpdateRequest): Promise<Asset> => {
const res = await apiClient.put<ApiResponse<Asset>>(`/api/v1/media/assets/${id}`, req);
const res = await apiClient.put<ApiResponse<Asset>>(`/api/v1/assets/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/media/assets/${id}`);
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/assets/${id}`);
return res.success;
},
upload: async (file: File): Promise<{ name: string; file_url: string; file_size: number; mime_type: string }> => {
const formData = new FormData();
formData.append('file', file);
const res = await apiClient.post<ApiResponse<{ name: string; file_url: string; file_size: number; mime_type: string }>>(
'/api/v1/assets/upload',
formData
);
return res.data;
},
replace: async (id: string, file: File): Promise<Asset> => {
const formData = new FormData();
formData.append('file', file);
const res = await apiClient.post<ApiResponse<Asset>>(`/api/v1/assets/${id}/replace`, formData);
return res.data;
},
archive: async (id: string): Promise<Asset> => {
const res = await apiClient.post<ApiResponse<Asset>>(`/api/v1/assets/${id}/archive`);
return res.data;
},
restore: async (id: string): Promise<Asset> => {
const res = await apiClient.post<ApiResponse<Asset>>(`/api/v1/assets/${id}/restore`);
return res.data;
},
getRelations: async (id: string): Promise<AssetRelationInfo> => {
const res = await apiClient.get<ApiResponse<AssetRelationInfo>>(`/api/v1/assets/${id}/relations`);
return res.data;
},
getAnalytics: async (): Promise<AssetAnalytics> => {
const res = await apiClient.get<ApiResponse<AssetAnalytics>>('/api/v1/assets/analytics');
return res.data;
},
getFolders: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/assets/folders');
return res.data || [];
},
getTags: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/assets/tags');
return res.data || [];
},
// Product Assets Assignment
getProductAssets: async (productId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/products/${productId}/assets`);
return res.data || [];
},
assignProductAsset: async (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets`, body);
return res.data;
},
updateProductAsset: async (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/products/${productId}/assets/${assetId}`, body);
return res.data;
},
unassignProductAsset: async (productId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/products/${productId}/assets/${assetId}`);
return res.success;
},
// Variant Assets Assignment
getVariantAssets: async (variantId: string): Promise<AssetMapping[]> => {
const res = await apiClient.get<ApiResponse<AssetMapping[]>>(`/api/v1/variants/${variantId}/assets`);
return res.data || [];
},
assignVariantAsset: async (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.post<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets`, body);
return res.data;
},
updateVariantAsset: async (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }): Promise<AssetMapping> => {
const res = await apiClient.put<ApiResponse<AssetMapping>>(`/api/v1/variants/${variantId}/assets/${assetId}`, body);
return res.data;
},
unassignVariantAsset: async (variantId: string, assetId: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/variants/${variantId}/assets/${assetId}`);
return res.success;
},
// Product variants list for the variant select dropdown
getProductVariants: async (productId: string): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>('/api/v1/variants', { params: { parentProductId: productId } });
return res.data || [];
}
};
+19 -1
View File
@@ -1,8 +1,26 @@
export interface Asset {
id: string;
name: string;
status: 'active' | 'inactive';
file_url?: string;
file_size?: number;
mime_type?: string;
status: 'active' | 'inactive' | 'draft';
createdAt: string;
code?: string;
description?: string;
asset_type_id?: string;
file_name?: string;
extension?: string;
checksum?: string;
width?: number;
height?: number;
duration?: number;
page_count?: number;
folder_id?: string;
version?: number;
versions?: any[];
tags?: any;
}
export type AssetCreateRequest = Omit<Asset, 'id' | 'createdAt'>;
export type AssetUpdateRequest = Partial<AssetCreateRequest>;
@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { Plus, Upload, Download } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
@@ -8,35 +8,37 @@ import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../../../components/customs/DataTable";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
interface AttributeGroup {
id: string;
name: string;
code: string;
description: string;
attributesCount: number;
status: 'active' | 'draft' | 'disabled';
updatedAt: string;
}
const mockGroups: AttributeGroup[] = [
{ id: "1", name: "Basic Information", code: "basic-info", description: "Core product identification attributes", attributesCount: 8, status: "active", updatedAt: "2024-03-15" },
{ id: "2", name: "Pricing & Inventory", code: "pricing-inventory", description: "Commercial and stock management attributes", attributesCount: 12, status: "active", updatedAt: "2024-03-14" },
{ id: "3", name: "Marketing Content", code: "marketing-content", description: "Customer-facing content and SEO attributes", attributesCount: 10, status: "active", updatedAt: "2024-03-12" },
{ id: "4", name: "Technical Specifications", code: "tech-specs", description: "Product technical details and specifications", attributesCount: 16, status: "active", updatedAt: "2024-03-10" },
{ id: "5", name: "Logistics & Shipping", code: "logistics-shipping", description: "Warehouse and fulfillment attributes", attributesCount: 9, status: "draft", updatedAt: "2024-03-08" },
];
import { useAttributeGroup } from "../hook/useAttributeGroup";
export default function AttributeGroupList() {
const navigate = useNavigate();
const { items, loading, fetchItems, deleteItem } = useAttributeGroup();
const [statusFilter, setStatusFilter] = useState("All Status");
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const filteredGroups = useMemo(() => {
return mockGroups.filter((group) =>
return items.filter((group: any) =>
statusFilter === "All Status" || group.status.toLowerCase() === statusFilter.toLowerCase()
);
}, [statusFilter]);
}, [items, statusFilter]);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteItem(deleteModal.id);
setDeleteModal({ isOpen: false, id: "", name: "" });
} catch (err) {
// Handled by hook toast
} finally {
setIsDeleting(false);
}
};
const columns = [
{
@@ -54,14 +56,14 @@ export default function AttributeGroupList() {
{
key: "description",
label: "DESCRIPTION",
render: (val: string) => <span className="text-sm text-gray-600">{val}</span>
render: (val: string) => <span className="text-sm text-gray-600">{val || "—"}</span>
},
{
key: "attributesCount",
key: "attributes",
label: "ATTRIBUTES",
render: (val: number) => (
render: (val: any) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-primary/5 text-primary-dark">
{val}
{Array.isArray(val) ? val.length : 0}
</span>
),
},
@@ -72,13 +74,13 @@ export default function AttributeGroupList() {
render: (val: string) => {
let displayStatus: any = "pending";
if (val === "active") displayStatus = "active";
else if (val === "disabled") displayStatus = "disabled";
else if (val === "disabled" || val === "inactive") displayStatus = "disabled";
else if (val === "draft") displayStatus = "warning";
return (
<StatusBadge
status={displayStatus}
label={val.charAt(0).toUpperCase() + val.slice(1)}
label={val ? val.charAt(0).toUpperCase() + val.slice(1) : "Draft"}
/>
);
},
@@ -87,7 +89,7 @@ export default function AttributeGroupList() {
key: "updatedAt",
label: "UPDATED",
sortable: true,
render: (val: string) => <span className="text-sm text-gray-500">{val}</span>,
render: (val: string) => <span className="text-sm text-gray-500">{val ? new Date(val).toISOString().split('T')[0] : "—"}</span>,
},
];
@@ -113,28 +115,32 @@ export default function AttributeGroupList() {
/>
<div className="mb-8">
<DataTable<AttributeGroup>
columns={columns}
data={filteredGroups}
searchPlaceholder="Search groups by name, code, or description..."
toolbarRight={
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option>All Status</option>
<option>Active</option>
<option>Draft</option>
<option>Disabled</option>
</select>
}
actionConfig={{
onView: (row) => navigate(`/attribute-groups/${row.id}/view`),
onEdit: (row) => navigate(`/attribute-groups/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
{loading && items.length === 0 ? (
<div className="flex items-center justify-center p-12 text-gray-500">Loading attribute groups...</div>
) : (
<DataTable<any>
columns={columns}
data={filteredGroups}
searchPlaceholder="Search groups by name, code, or description..."
toolbarRight={
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option>All Status</option>
<option>Active</option>
<option>Draft</option>
<option>Disabled</option>
</select>
}
actionConfig={{
onView: (row) => navigate(`/attribute-groups/${row.id}/view`),
onEdit: (row) => navigate(`/attribute-groups/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
)}
</div>
<ConfirmationModal
@@ -142,7 +148,8 @@ export default function AttributeGroupList() {
title="Delete Attribute Group"
description="Are you sure you want to delete this attribute group? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: "", name: "" })}
/>
</PageWrapper>
@@ -8,6 +8,7 @@ import { Radio, RadioGroup } from "../../../components/customs/Radio";
import { useAttributeGroup } from "../hook/useAttributeGroup";
import { attributeGroupSchema } from "../validation/attribute-groups.schema";
import type { AttributeGroupCreateRequest } from "../types/attribute-groups.types";
import { useAttribute } from "../../attributes/hook/useAttribute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const STEPS = [
@@ -39,19 +40,12 @@ export default function NewAttributeGroup() {
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem, items, fetchItems } = useAttributeGroup();
const { attributes, fetchAttributes } = useAttribute();
const [activeStep, setActiveStep] = useState("basic");
const [selectedAttributes, setSelectedAttributes] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const availableAttributes = [
{ id: "1", name: "Dimensions", code: "product_dimensions", type: "Text", required: true },
{ id: "2", name: "Material", code: "product_material", type: "Select", required: false },
{ id: "3", name: "Warranty Period", code: "product_warranty", type: "Number", required: false },
{ id: "4", name: "Brand", code: "product_brand", type: "Reference", required: true },
{ id: "5", name: "Product Weight", code: "product_weight", type: "Decimal", required: false },
];
const formik = useFormik({
initialValues: { code: "", name: "", description: "", status: "draft" },
validationSchema: attributeGroupSchema,
@@ -74,7 +68,10 @@ export default function NewAttributeGroup() {
},
});
useEffect(() => { fetchItems(); }, [fetchItems]);
useEffect(() => {
fetchItems();
fetchAttributes();
}, [fetchItems, fetchAttributes]);
useEffect(() => {
if (isEdit && id && items.length > 0) {
@@ -86,12 +83,15 @@ export default function NewAttributeGroup() {
description: (match as any).description || "",
status: match.status || "draft",
});
if (match.attributes) {
setSelectedAttributes(match.attributes);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
const filteredAttributes = availableAttributes.filter((a) =>
const filteredAttributes = attributes.filter((a) =>
a.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.code.toLowerCase().includes(searchTerm.toLowerCase())
);
@@ -271,7 +271,7 @@ export default function NewAttributeGroup() {
<span className="font-mono">{attr.code}</span>
<span className="text-gray-300"></span>
<span>{attr.type}</span>
{attr.required && (
{attr.isRequired && (
<span className="px-2 py-0.5 text-[10px] font-medium bg-red-50 text-red-600 border border-red-100 rounded-full">Required</span>
)}
</div>
@@ -1,55 +1,35 @@
import type { AttributeGroup, AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
import apiClient from '../../../api/axiosInstance';
const STORAGE_KEY = 'pim_attribute_groups';
const getStored = (): AttributeGroup[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
};
const setStored = (items: AttributeGroup[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
};
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export const attributeGroupsService = {
getAll: async (): Promise<AttributeGroup[]> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
const res = await apiClient.get<ApiResponse<AttributeGroup[]>>('/api/v1/attribute-groups');
return res.data || [];
},
getById: async (id: string): Promise<AttributeGroup | undefined> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
const res = await apiClient.get<ApiResponse<AttributeGroup>>(`/api/v1/attribute-groups/${id}`);
return res.data;
},
create: async (req: AttributeGroupCreateRequest): Promise<AttributeGroup> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newItem: AttributeGroup = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
list.push(newItem);
setStored(list);
resolve(newItem);
}, 300);
});
const res = await apiClient.post<ApiResponse<AttributeGroup>>('/api/v1/attribute-groups', req);
return res.data;
},
update: async (id: string, req: AttributeGroupUpdateRequest): Promise<AttributeGroup> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(p => p.id === id);
if (index === -1) { reject(new Error('Not found')); return; }
const updated = { ...list[index], ...req };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
});
const res = await apiClient.put<ApiResponse<AttributeGroup>>(`/api/v1/attribute-groups/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(p => p.id !== id);
setStored(list);
resolve(true);
}, 300);
});
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/attribute-groups/${id}`);
return res.success;
},
};
@@ -1,8 +1,12 @@
export interface AttributeGroup {
id: string;
code: string;
name: string;
status: 'active' | 'inactive';
description?: string;
status: 'active' | 'inactive' | 'draft';
attributes?: any[];
createdAt: string;
}
export type AttributeGroupCreateRequest = Omit<AttributeGroup, 'id' | 'createdAt'>;
export type AttributeGroupUpdateRequest = Partial<AttributeGroupCreateRequest>;
@@ -1,6 +1,9 @@
import * as Yup from 'yup';
export const attributeGroupSchema = Yup.object().shape({
code: Yup.string()
.required('Group code is required')
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores'),
name: Yup.string().required('Attribute Group name is required'),
status: Yup.string().oneOf(['active', 'inactive']),
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
});
@@ -0,0 +1,68 @@
import { useState, useCallback } from 'react';
import { attributeSetsService } from '../services/attribute-sets.service';
import type { AttributeSet, AttributeSetCreateRequest, AttributeSetUpdateRequest } from '../types/attribute-sets.types';
import { toast } from 'react-toastify';
export const useAttributeSet = () => {
const [items, setItems] = useState<AttributeSet[]>([]);
const [loading, setLoading] = useState(false);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const data = await attributeSetsService.getAll();
setItems(data);
} catch (err: any) {
toast.error(err.message || 'Failed to fetch attribute sets');
} finally {
setLoading(false);
}
}, []);
const createItem = useCallback(async (req: AttributeSetCreateRequest) => {
setLoading(true);
try {
const created = await attributeSetsService.create(req);
setItems((prev) => [...prev, created]);
toast.success('Attribute Set created successfully!');
return created;
} catch (err: any) {
toast.error(err.message || 'Failed to create attribute set');
throw err;
} finally {
setLoading(false);
}
}, []);
const updateItem = useCallback(async (id: string, req: AttributeSetUpdateRequest) => {
setLoading(true);
try {
const updated = await attributeSetsService.update(id, req);
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
toast.success('Attribute Set updated successfully!');
return updated;
} catch (err: any) {
toast.error(err.message || 'Failed to update attribute set');
throw err;
} finally {
setLoading(false);
}
}, []);
const deleteItem = useCallback(async (id: string) => {
setLoading(true);
try {
await attributeSetsService.delete(id);
setItems((prev) => prev.filter((p) => p.id !== id));
toast.success('Attribute Set deleted successfully!');
return true;
} catch (err: any) {
toast.error(err.message || 'Failed to delete attribute set');
throw err;
} finally {
setLoading(false);
}
}, []);
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
};
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { Plus, Upload, Download } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageWrapper } from '../../../components/layouts/PageWrapper';
@@ -7,37 +7,37 @@ import { StatusBadge } from '../../../components/customs/StatusBadge';
import { DataTable } from '../../../components/customs/DataTable';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
import { ConfirmationModal } from '../../../components/modals/ConfirmationModal';
interface AttributeSet {
id: string;
name: string;
code: string;
description: string;
groupsCount: number;
attributesCount: number;
status: 'active' | 'draft' | 'disabled';
updatedAt: string;
}
const mockSets: AttributeSet[] = [
{ id: '1', name: 'Electronics Base Set', code: 'electronics-base', description: 'Core attributes for all electronic products', groupsCount: 4, attributesCount: 36, status: 'active', updatedAt: '2024-03-15' },
{ id: '2', name: 'Fashion & Apparel Set', code: 'fashion-apparel', description: 'Attributes for clothing and accessories', groupsCount: 3, attributesCount: 24, status: 'active', updatedAt: '2024-03-14' },
{ id: '3', name: 'Food & Beverage Set', code: 'food-beverage', description: 'Nutritional and packaging attributes for food', groupsCount: 3, attributesCount: 20, status: 'active', updatedAt: '2024-03-12' },
{ id: '4', name: 'Industrial Parts Set', code: 'industrial-parts', description: 'Technical specs for industrial components', groupsCount: 5, attributesCount: 45, status: 'draft', updatedAt: '2024-03-10' },
{ id: '5', name: 'Digital Products Set', code: 'digital-products', description: 'Attributes for software and digital goods', groupsCount: 2, attributesCount: 14, status: 'active', updatedAt: '2024-03-08' },
{ id: '6', name: 'Home & Garden Set', code: 'home-garden', description: 'Attributes for home improvement and garden items', groupsCount: 3, attributesCount: 22, status: 'disabled', updatedAt: '2024-03-05' },
];
import { useAttributeSet } from '../hook/useAttributeSet';
export default function AttributeSetList() {
const navigate = useNavigate();
const { items, loading, fetchItems, deleteItem } = useAttributeSet();
const [statusFilter, setStatusFilter] = useState('All Status');
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: '', name: '' });
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const filtered = useMemo(() =>
mockSets.filter(s => statusFilter === 'All Status' || s.status.toLowerCase() === statusFilter.toLowerCase()),
[statusFilter]
items.filter(s => statusFilter === 'All Status' || s.status.toLowerCase() === statusFilter.toLowerCase()),
[items, statusFilter]
);
const handleDeleteConfirm = async () => {
if (!deleteModal.id) return;
setIsDeleting(true);
try {
await deleteItem(deleteModal.id);
setDeleteModal({ isOpen: false, id: '', name: '' });
} catch {
// Handled by hook
} finally {
setIsDeleting(false);
}
};
const columns = [
{
key: 'name', label: 'SET NAME', sortable: true,
@@ -49,30 +49,44 @@ export default function AttributeSetList() {
},
{
key: 'description', label: 'DESCRIPTION',
render: (val: string) => <span className="text-sm text-gray-600">{val}</span>,
render: (val: string) => <span className="text-sm text-gray-600">{val || '—'}</span>,
},
{
key: 'groupsCount', label: 'GROUPS',
render: (val: number) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-blue-50 text-blue-700">{val} groups</span>
key: 'groups', label: 'GROUPS',
render: (val: any) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-blue-50 text-blue-700">
{Array.isArray(val) ? val.length : 0} groups
</span>
),
},
{
key: 'attributesCount', label: 'ATTRIBUTES',
render: (val: number) => (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-primary/5 text-primary-dark">{val}</span>
),
key: 'groups', label: 'ATTRIBUTES',
render: (groups: any) => {
let count = 0;
if (Array.isArray(groups)) {
groups.forEach((g: any) => {
if (g.attributes && Array.isArray(g.attributes)) {
count += g.attributes.length;
}
});
}
return (
<span className="px-2.5 py-1 text-xs font-medium rounded bg-primary/5 text-primary-dark">
{count}
</span>
);
},
},
{
key: 'status', label: 'STATUS', sortable: true,
render: (val: string) => {
const map: Record<string, any> = { active: 'active', disabled: 'disabled', draft: 'warning' };
const map: Record<string, any> = { active: 'active', disabled: 'disabled', inactive: 'disabled', draft: 'warning' };
return <StatusBadge status={map[val] ?? 'pending'} label={val.charAt(0).toUpperCase() + val.slice(1)} />;
},
},
{
key: 'updatedAt', label: 'UPDATED', sortable: true,
render: (val: string) => <span className="text-sm text-gray-500">{val}</span>,
render: (val: string) => <span className="text-sm text-gray-500">{val ? new Date(val).toISOString().split('T')[0] : '—'}</span>,
},
];
@@ -92,28 +106,32 @@ export default function AttributeSetList() {
/>
<div className="mb-8">
<DataTable<AttributeSet>
columns={columns}
data={filtered}
searchPlaceholder="Search sets by name, code, or description..."
toolbarRight={
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="h-9 px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option>All Status</option>
<option>Active</option>
<option>Draft</option>
<option>Disabled</option>
</select>
}
actionConfig={{
onView: row => navigate(`/attribute-sets/${row.id}/view`),
onEdit: row => navigate(`/attribute-sets/${row.id}/edit`),
onDelete: row => setDeleteModal({ isOpen: true, id: row.id, name: row.name }),
}}
/>
{loading && items.length === 0 ? (
<div className="flex items-center justify-center p-12 text-gray-500">Loading attribute sets...</div>
) : (
<DataTable<any>
columns={columns}
data={filtered}
searchPlaceholder="Search sets by name, code, or description..."
toolbarRight={
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="h-9 px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option>All Status</option>
<option>Active</option>
<option>Draft</option>
<option>Disabled</option>
</select>
}
actionConfig={{
onView: row => navigate(`/attribute-sets/${row.id}/view`),
onEdit: row => navigate(`/attribute-sets/${row.id}/edit`),
onDelete: row => setDeleteModal({ isOpen: true, id: row.id, name: row.name }),
}}
/>
)}
</div>
<ConfirmationModal
@@ -121,7 +139,8 @@ export default function AttributeSetList() {
title="Delete Attribute Set"
description="Are you sure you want to delete this attribute set? This action cannot be undone."
itemName={deleteModal.name}
onConfirm={() => setDeleteModal({ isOpen: false, id: '', name: '' })}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteModal({ isOpen: false, id: '', name: '' })}
/>
</PageWrapper>
@@ -0,0 +1,377 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useFormik } from "formik";
import { Plus, Save, Info, Check } from "lucide-react";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { Radio, RadioGroup } from "../../../components/customs/Radio";
import { useAttributeSet } from "../hook/useAttributeSet";
import { useAttributeGroup } from "../../attribute-groups/hook/useAttributeGroup";
import { attributeSetSchema } from "../validation/attribute-sets.schema";
import type { AttributeSetCreateRequest } from "../types/attribute-sets.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
const STEPS = [
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "select", label: "Group Selection", step: 2 },
{ id: "ordering", label: "Group Ordering", step: 3 },
{ id: "preview", label: "Preview", step: 4 },
];
const inputClass = (error?: boolean) =>
`w-full border ${error ? "border-red-400 focus:ring-red-400" : "border-primary/10 focus:ring-primary-light"} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white placeholder-gray-400`;
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">{title}</h3>
<p className="text-xs text-primary-light mt-0.5">{subtitle}</p>
</div>
</div>
);
}
export default function NewAttributeSet() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { createItem, updateItem, items, fetchItems } = useAttributeSet();
const { items: attributeGroups, fetchItems: fetchGroups } = useAttributeGroup();
const [activeStep, setActiveStep] = useState("basic");
const [selectedGroups, setSelectedGroups] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const formik = useFormik({
initialValues: { code: "", name: "", description: "", status: "draft" },
validationSchema: attributeSetSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload: any = { code: values.code, name: values.name, status: values.status };
if (values.description?.trim()) payload.description = values.description.trim();
payload.groups = selectedGroups.map((g) => g.id);
try {
if (isEdit && id) {
await updateItem(id, payload);
} else {
await createItem(payload as AttributeSetCreateRequest);
}
navigate("..");
} catch {
// Handled by hook
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
fetchItems();
fetchGroups();
}, [fetchItems, fetchGroups]);
useEffect(() => {
if (isEdit && id && items.length > 0) {
const match = items.find((item) => item.id === id);
if (match) {
formik.setValues({
code: match.code || "",
name: match.name,
description: match.description || "",
status: match.status || "draft",
});
if (match.groups) {
setSelectedGroups(match.groups);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEdit, id, items]);
const filteredGroups = attributeGroups.filter((g) =>
g.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
g.code.toLowerCase().includes(searchTerm.toLowerCase())
);
const addGroup = (group: any) => {
if (!selectedGroups.find((g) => g.id === group.id))
setSelectedGroups([...selectedGroups, group]);
};
const removeGroup = (groupId: string) =>
setSelectedGroups(selectedGroups.filter((g) => g.id !== groupId));
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
return (
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Attribute Sets", href: "/attribute-sets" }, { label: isEdit ? "Edit Attribute Set" : "Create Attribute Set" }]}
backTo="/attribute-sets"
actions={
<>
<Button variant="outline" size="md" onClick={() => navigate("..")}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="attribute-set-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>Save Set</Button>
</>
}
/>
<form id="attribute-set-form" onSubmit={formik.handleSubmit} className="flex gap-5">
{/* Timeline Sidebar */}
<aside className="w-52 shrink-0 self-start bg-white border border-primary/10 rounded-lg shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-primary/5 bg-primary/5/40">
<p className="text-[11px] font-semibold text-primary uppercase tracking-widest">Configuration</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-gray-400">Step {activeIndex + 1} of {STEPS.length}</p>
<span className="text-[10px] font-medium text-primary bg-primary-light px-2 py-0.5 rounded-full">
{Math.round(((activeIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="mt-2 h-1 bg-primary-light rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${((activeIndex + 1) / STEPS.length) * 100}%` }}
/>
</div>
</div>
<nav className="px-4 py-3">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = idx < activeIndex;
const isLast = idx === STEPS.length - 1;
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-white border-2 border-gray-200 hover:border-primary/30"
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
: <span className={`text-[9px] font-bold ${isActive ? "text-white" : "text-gray-400"}`}>{s.step}</span>
}
</button>
{!isLast && (
<div className={`w-px flex-1 my-0.5 ${isDone ? "bg-emerald-300" : "bg-gray-200"}`} style={{ minHeight: 14 }} />
)}
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" : isDone ? "text-gray-600" : "text-gray-400 hover:text-gray-600"
}`}>{s.label}</span>
</button>
</div>
);
})}
</nav>
</aside>
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
{/* Step 1 — Basic Information */}
{activeStep === "basic" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Basic Information" subtitle="Define the set's identity and metadata" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Set Code <span className="text-red-400">*</span></label>
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g., electronic_accessories"
className={`${inputClass(formik.touched.code && Boolean(formik.errors.code))} disabled:bg-gray-50 disabled:text-gray-500`}
/>
<p className="text-xs text-gray-400 mt-1">Unique identifier (snake_case/kebab-case)</p>
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Set Name <span className="text-red-400">*</span></label>
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., Electronic Accessories Set"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
</div>
<div>
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Describe the purpose of this attribute set..."
rows={4}
className={`${inputClass()} resize-y`}
style={{ minHeight: "100px" }}
/>
</div>
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-1">
{["draft", "active", "inactive"].map((s) => (
<Radio
key={s}
name="status"
value={s}
checked={formik.values.status === s}
onChange={formik.handleChange}
label={s}
/>
))}
</RadioGroup>
</div>
</div>
</div>
)}
{/* Step 2 — Group Selection */}
{activeStep === "select" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Group Selection" subtitle="Add attribute groups to this set" />
<div className="p-6">
<input
type="text"
placeholder="Search attribute groups..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={`${inputClass()} mb-5`}
/>
<div className="border border-primary/10 rounded-lg overflow-hidden">
{filteredGroups.length > 0 ? (
filteredGroups.map((g) => (
<div key={g.id} className="flex items-center justify-between px-4 py-3.5 border-b border-gray-100 last:border-b-0 hover:bg-primary/5/20 transition-all">
<div>
<div className="font-medium text-sm text-gray-900">{g.name}</div>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span className="font-mono">{g.code}</span>
<span className="text-gray-300"></span>
<span>{g.attributes?.length || 0} attributes</span>
</div>
</div>
<Button
type="button"
variant="primary"
size="sm"
icon={<Plus className="w-4 h-4" />}
onClick={() => addGroup(g)}
disabled={selectedGroups.some((group) => group.id === g.id)}
>
Add
</Button>
</div>
))
) : (
<div className="p-12 text-center text-gray-500 text-sm">No attribute groups found</div>
)}
</div>
</div>
</div>
)}
{/* Step 3 — Group Ordering */}
{activeStep === "ordering" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Group Ordering" subtitle="Remove or manage the groups inside this set" />
<div className="p-6">
{selectedGroups.length === 0 ? (
<div className="border-2 border-dashed border-primary/10 rounded-xl py-16 text-center">
<Info className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="font-medium text-gray-500">No groups added yet</p>
<p className="text-sm text-gray-400 mt-1">Add groups from the selection step</p>
</div>
) : (
<div className="space-y-2">
{selectedGroups.map((g, index) => (
<div key={g.id} className="flex items-center gap-4 bg-primary/5 border border-primary/10 rounded-lg px-4 py-3">
<div className="text-xs font-bold text-primary-light w-5">{index + 1}</div>
<div className="flex-1">
<div className="font-medium text-sm text-gray-900">{g.name}</div>
<div className="text-xs text-gray-400 font-mono">{g.code}</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => removeGroup(g.id)}>Remove</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Step 4 — Preview */}
{activeStep === "preview" && (
<div className="space-y-4">
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Set Preview" subtitle="How this set structure and its groups are organized" />
<div className="p-6 space-y-6">
{selectedGroups.length > 0 ? (
selectedGroups.map((g) => (
<div key={g.id} className="border border-primary/10 rounded-lg overflow-hidden bg-gray-50/50">
<div className="px-4 py-2 border-b border-primary/5 bg-primary/5 font-semibold text-xs text-primary">
{g.name} ({g.code})
</div>
<div className="p-4 space-y-2">
{g.attributes && g.attributes.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{g.attributes.map((a: any) => (
<div key={a.id} className="bg-white px-3 py-2 rounded border border-gray-100 text-xs text-gray-600">
<span className="font-medium text-gray-800">{a.name}</span>
<span className="text-[10px] text-gray-400 font-mono block mt-0.5">{a.code}</span>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-400 italic">No attributes in this group</p>
)}
</div>
</div>
))
) : (
<p className="text-gray-400 italic text-center py-8 text-sm">No groups to preview</p>
)}
</div>
</div>
</div>
)}
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors">Back</button>
)}
{activeIndex < STEPS.length - 1 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)} className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors">Next</button>
)}
</div>
</div>
</form>
</PageWrapper>
);
}
@@ -1,8 +1,11 @@
import { Routes, Route } from 'react-router-dom';
import AttributeSetList from '../pages/AttributeSetList';
import NewAttributeSet from '../pages/NewAttributeSet';
export const AttributeSetRoutes = () => (
<Routes>
<Route index element={<AttributeSetList />} />
<Route path="new" element={<NewAttributeSet />} />
<Route path=":id/edit" element={<NewAttributeSet />} />
</Routes>
);
@@ -0,0 +1,35 @@
import type { AttributeSet, AttributeSetCreateRequest, AttributeSetUpdateRequest } from '../types/attribute-sets.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export const attributeSetsService = {
getAll: async (): Promise<AttributeSet[]> => {
const res = await apiClient.get<ApiResponse<AttributeSet[]>>('/api/v1/attribute-sets');
return res.data || [];
},
getById: async (id: string): Promise<AttributeSet | undefined> => {
const res = await apiClient.get<ApiResponse<AttributeSet>>(`/api/v1/attribute-sets/${id}`);
return res.data;
},
create: async (req: AttributeSetCreateRequest): Promise<AttributeSet> => {
const res = await apiClient.post<ApiResponse<AttributeSet>>('/api/v1/attribute-sets', req);
return res.data;
},
update: async (id: string, req: AttributeSetUpdateRequest): Promise<AttributeSet> => {
const res = await apiClient.put<ApiResponse<AttributeSet>>(`/api/v1/attribute-sets/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/attribute-sets/${id}`);
return res.success;
},
};
@@ -0,0 +1,17 @@
import type { AttributeGroup } from '../../attribute-groups/types/attribute-groups.types';
export interface AttributeSet {
id: string;
code: string;
name: string;
description?: string;
status: 'active' | 'inactive' | 'draft';
groups?: AttributeGroup[];
createdAt: string;
}
export type AttributeSetCreateRequest = Omit<AttributeSet, 'id' | 'createdAt' | 'groups'> & {
groups?: string[]; // Array of group IDs linked to the set
};
export type AttributeSetUpdateRequest = Partial<AttributeSetCreateRequest>;
@@ -0,0 +1,9 @@
import * as Yup from 'yup';
export const attributeSetSchema = Yup.object().shape({
code: Yup.string()
.required('Attribute Set code is required')
.matches(/^[a-z0-9_-]+$/, 'Code can only contain lowercase letters, numbers, hyphens, and underscores'),
name: Yup.string().required('Attribute Set name is required'),
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
});
@@ -138,16 +138,16 @@ export default function AttributeList() {
key: "usageCount",
label: "USAGE COUNT",
render: (_: any, row: any) => {
let count = "0", cats = "0", fams = "0", prods = "0";
if (row.code === "ATTR_WEIGHT") { count = "2,915"; cats = "45"; fams = "23"; prods = "2847"; }
else if (row.code === "ATTR_COLOR") { count = "5,718"; cats = "12"; fams = "34"; prods = "5672"; }
else if (row.code === "ATTR_STORAGE") { count = "1,266"; cats = "8"; fams = "15"; prods = "1243"; }
const groupsCount = Array.isArray(row.groups) ? row.groups.length : 0;
const familiesCount = Array.isArray(row.families) ? row.families.length : 0;
const productsCount = Array.isArray(row.products) ? row.products.length : 0;
const totalUsages = groupsCount + familiesCount + productsCount;
return count !== "0" ? (
return totalUsages > 0 ? (
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{count}</span>
<span className="font-semibold text-gray-900">{totalUsages}</span>
<span className="text-[10px] text-gray-500 leading-tight mt-0.5">
{cats} cats, {fams} fams, {prods} prods
{groupsCount} groups, {familiesCount} families, {productsCount} products
</span>
</div>
) : <span className="text-gray-300"></span>;
+108 -10
View File
@@ -17,6 +17,7 @@ const STEPS = [
{ id: "general", label: "General Information", step: 1 },
{ id: "validation", label: "Validation Rules", step: 2 },
{ id: "behavior", label: "Behavior Settings", step: 3 },
{ id: "review", label: "Review Configuration",step: 4 },
];
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
@@ -60,11 +61,18 @@ export default function NewAttribute() {
isFilterable: false,
isLocalizable: false,
isChannelSpecific: false,
helpText: "",
placeholder: "",
sortable: false,
visibleInGrid: true,
visibleInProduct: true,
apiVisible: true,
isRequiredForCompleteness: false,
},
validationSchema: attributeSchema,
onSubmit: async (values, { setSubmitting }) => {
const payload: any = {
code: values.code,
code: values.code || undefined,
name: values.name,
type: values.dataType,
status: values.status,
@@ -79,6 +87,13 @@ export default function NewAttribute() {
isFilterable: values.isFilterable,
isLocalizable: values.isLocalizable,
isChannelSpecific: values.isChannelSpecific,
helpText: values.helpText || null,
placeholder: values.placeholder || null,
sortable: values.sortable,
visibleInGrid: values.visibleInGrid,
visibleInProduct: values.visibleInProduct,
apiVisible: values.apiVisible,
isRequiredForCompleteness: values.isRequiredForCompleteness,
};
if (values.description?.trim()) payload.description = values.description.trim();
try {
@@ -96,6 +111,17 @@ export default function NewAttribute() {
},
});
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit) {
const generatedCode = e.target.value
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '');
formik.setFieldValue("code", generatedCode);
}
};
useEffect(() => { fetchAttributes(); }, [fetchAttributes]);
useEffect(() => {
@@ -119,6 +145,13 @@ export default function NewAttribute() {
isFilterable: (match as any).isFilterable ?? false,
isLocalizable: match.isLocalizable ?? false,
isChannelSpecific: (match as any).isChannelSpecific ?? false,
helpText: (match as any).helpText || "",
placeholder: (match as any).placeholder || "",
sortable: (match as any).sortable ?? false,
visibleInGrid: (match as any).visibleInGrid ?? true,
visibleInProduct: (match as any).visibleInProduct ?? true,
apiVisible: (match as any).apiVisible ?? true,
isRequiredForCompleteness: (match as any).isRequiredForCompleteness ?? false,
});
}
}
@@ -238,7 +271,7 @@ export default function NewAttribute() {
<Input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onChange={handleNameChange}
onBlur={formik.handleBlur}
placeholder="e.g., Product Weight"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
@@ -295,10 +328,31 @@ export default function NewAttribute() {
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Describe how this attribute should be used..."
rows={4}
style={{ minHeight: "100px" }}
rows={3}
style={{ minHeight: "80px" }}
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Help Text</label>
<Input
name="helpText"
value={formik.values.helpText}
onChange={formik.handleChange}
placeholder="Help context for content managers..."
/>
</div>
<div>
<label className={labelClass}>Placeholder</label>
<Input
name="placeholder"
value={formik.values.placeholder}
onChange={formik.handleChange}
placeholder="Input placeholder text..."
/>
</div>
</div>
</div>
</div>
)}
@@ -363,12 +417,17 @@ export default function NewAttribute() {
<div className="p-6">
<div className="grid grid-cols-2 gap-3">
{[
{ name: "isRequired", label: "Required", desc: "Must be filled for all products" },
{ name: "isVariantEligible", label: "Variant Eligible", desc: "Can be used in variant axes" },
{ name: "isSearchable", label: "Searchable", desc: "Indexed for search queries" },
{ name: "isFilterable", label: "Filterable", desc: "Available as product filter" },
{ name: "isLocalizable", label: "Localizable", desc: "Different value per locale" },
{ name: "isChannelSpecific", label: "Channel Specific", desc: "Different value per channel" },
{ name: "isRequired", label: "Required", desc: "Must be filled for all products" },
{ name: "isVariantEligible", label: "Variant Eligible", desc: "Can be used in variant axes" },
{ name: "isSearchable", label: "Searchable", desc: "Indexed for search queries" },
{ name: "isFilterable", label: "Filterable", desc: "Available as product filter" },
{ name: "isLocalizable", label: "Localizable", desc: "Different value per locale" },
{ name: "isChannelSpecific", label: "Channel Specific", desc: "Different value per channel" },
{ name: "sortable", label: "Sortable", desc: "Enables listing sort actions" },
{ name: "visibleInGrid", label: "Visible in Grid", desc: "Shown in products data table" },
{ name: "visibleInProduct", label: "Visible in Product Edit", desc: "Rendered in specs editing form" },
{ name: "apiVisible", label: "API Visible", desc: "Exposed on public API responses" },
{ name: "isRequiredForCompleteness", label: "Required for Completeness", desc: "Mandatory check for 100% completeness" }
].map(({ name, label, desc }) => (
<label key={name} className="flex items-start gap-3 px-4 py-3.5 border border-gray-100 rounded-lg hover:border-primary/10 hover:bg-primary/5/20 transition-all cursor-pointer">
<input
@@ -389,6 +448,45 @@ export default function NewAttribute() {
</div>
)}
{/* Review Step */}
{activeStep === "review" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Review Specifications" subtitle="Check configuration details before saving the attribute" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm text-gray-700">
<div className="border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-500 block text-xs uppercase tracking-wider">Attribute Name</span>
<span className="text-gray-900 font-medium">{formik.values.name || 'N/A'}</span>
</div>
<div className="border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-500 block text-xs uppercase tracking-wider">Attribute Code</span>
<span className="text-gray-900 font-mono">{formik.values.code || 'N/A'}</span>
</div>
<div className="border-b border-gray-100 pb-2 col-span-2">
<span className="font-semibold text-gray-500 block text-xs uppercase tracking-wider">Description</span>
<span>{formik.values.description || 'No description provided.'}</span>
</div>
<div className="border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-500 block text-xs uppercase tracking-wider">Data Type</span>
<span className="capitalize font-semibold text-primary">{formik.values.dataType}</span>
</div>
<div className="border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-500 block text-xs uppercase tracking-wider">Status</span>
<span className="capitalize">{formik.values.status}</span>
</div>
<div className="border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-500 block text-xs uppercase tracking-wider">Required / Unique</span>
<span>{formik.values.isRequired ? 'Yes' : 'No'} / {formik.values.isUnique ? 'Yes' : 'No'}</span>
</div>
<div className="border-b border-gray-100 pb-2">
<span className="font-semibold text-gray-500 block text-xs uppercase tracking-wider">Localizable / Channel Specific</span>
<span>{formik.values.isLocalizable ? 'Yes' : 'No'} / {formik.values.isChannelSpecific ? 'Yes' : 'No'}</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Bottom navigation */}
@@ -2,18 +2,13 @@ import * as Yup from 'yup';
export const attributeSchema = Yup.object().shape({
code: Yup.string()
.required('Attribute code is required')
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores'),
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores')
.nullable(),
name: Yup.string().required('Attribute name is required'),
group: Yup.string().required('Attribute group is required'),
type: Yup.string().required('Attribute type is required'),
optionsText: Yup.string().when('type', {
is: (val: string) => val === 'select' || val === 'multiselect',
then: (schema) => schema.required('At least one option is required for selectable types'),
otherwise: (schema) => schema.notRequired(),
}),
group: Yup.string().notRequired(),
dataType: Yup.string().required('Attribute type is required'),
isRequired: Yup.boolean(),
isUnique: Yup.boolean(),
isLocalizable: Yup.boolean(),
status: Yup.string().oneOf(['active', 'inactive']),
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
});
+12 -1
View File
@@ -69,6 +69,17 @@ export default function NewBrand() {
},
});
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit) {
const generatedCode = e.target.value
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '');
formik.setFieldValue("code", generatedCode);
}
};
useEffect(() => {
if (isEdit && id) {
brandService.getById(id).then((brand) => {
@@ -111,7 +122,7 @@ export default function NewBrand() {
<input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onChange={handleNameChange}
onBlur={formik.handleBlur}
placeholder="e.g., Apple"
className={inputClass(formik.touched.name && Boolean(formik.errors.name))}
@@ -2,8 +2,8 @@ import * as Yup from 'yup';
export const brandSchema = Yup.object().shape({
code: Yup.string()
.required("Brand code is required")
.matches(/^[A-Za-z0-9_]+$/, "Code can only contain letters, numbers, and underscores"),
.matches(/^[A-Za-z0-9_]+$/, "Code can only contain letters, numbers, and underscores")
.nullable(),
name: Yup.string().required("Brand name is required"),
description: Yup.string(),
website: Yup.string().url("Must be a valid URL").optional(),
@@ -1,20 +1,7 @@
/**
* CategoryTaxonomyTree.tsx
*
* Builds a fully interactive, collapsible taxonomy tree from the flat
* Category[] array returned by the API.
*
* - Resolves parent→child relationships from parentId
* - Root nodes = categories with no parentId
* - Each node is collapsible/expandable on click
* - Status dot: green = active, amber = inactive
* - Actions: Edit, Delete per node
*/
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import {
FolderOpen, Folder, Crown, ChevronRight,
Pencil, Trash2, Plus,
Pencil, Trash2, Plus, Eye,
} from "lucide-react";
import type { Category } from "../types/category.types";
@@ -29,7 +16,11 @@ interface CategoryTaxonomyTreeProps {
categories: Category[];
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
onView: (category: Category) => void;
onAdd: () => void;
onCreateChild: (category: Category) => void;
expandAllSignal: number;
collapseAllSignal: number;
}
// ─── Build tree from flat list ────────────────────────────────────────────────
@@ -78,14 +69,34 @@ function TreeNodeRow({
prefix,
onEdit,
onDelete,
onView,
onCreateChild,
expandAllSignal,
collapseAllSignal,
}: {
node: TreeNode;
isLast: boolean;
prefix: string;
onEdit: (c: Category) => void;
onDelete: (c: Category) => void;
onView: (c: Category) => void;
onCreateChild: (c: Category) => void;
expandAllSignal: number;
collapseAllSignal: number;
}) {
const [open, setOpen] = useState(node.depth < 2); // auto-expand first 2 levels
const [open, setOpen] = useState(node.depth < 2);
useEffect(() => {
if (expandAllSignal > 0) {
setOpen(true);
}
}, [expandAllSignal]);
useEffect(() => {
if (collapseAllSignal > 0) {
setOpen(false);
}
}, [collapseAllSignal]);
const hasChildren = node.children.length > 0;
const isRoot = node.depth === 0;
@@ -155,14 +166,14 @@ function TreeNodeRow({
</span>
{/* Code */}
<span className="text-[11px] font-mono text-gray-400 shrink-0 hidden sm:block">
<span className="text-[11px] font-mono text-gray-400 shrink-0 hidden sm:block mr-2">
{node.category.code}
</span>
{/* Status dot */}
<span
className={[
"shrink-0 flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
"shrink-0 flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full mr-2",
isActive
? "bg-emerald-100 text-emerald-700"
: "bg-amber-100 text-amber-700",
@@ -179,20 +190,36 @@ function TreeNodeRow({
{/* Child count — shown when collapsed */}
{hasChildren && !open && (
<span className="shrink-0 text-[10px] font-semibold bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded-full">
<span className="shrink-0 text-[10px] font-semibold bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded-full mr-2">
{descendants} {descendants === 1 ? "child" : "children"}
</span>
)}
{/* Root badge */}
{isRoot && (
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full">
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full mr-2">
Root
</span>
)}
{/* Actions — visible on hover */}
<span className="shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onView(node.category)}
className="p-1 rounded hover:bg-primary/10 text-gray-400 hover:text-primary transition-colors"
title="View Details"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => onCreateChild(node.category)}
className="p-1 rounded hover:bg-primary/10 text-gray-400 hover:text-primary transition-colors"
title="Create Child"
>
<Plus className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => onEdit(node.category)}
@@ -221,6 +248,10 @@ function TreeNodeRow({
prefix={childPrefix}
onEdit={onEdit}
onDelete={onDelete}
onView={onView}
onCreateChild={onCreateChild}
expandAllSignal={expandAllSignal}
collapseAllSignal={collapseAllSignal}
/>
))}
</div>
@@ -232,7 +263,11 @@ export function CategoryTaxonomyTree({
categories,
onEdit,
onDelete,
onView,
onAdd,
onCreateChild,
expandAllSignal,
collapseAllSignal,
}: CategoryTaxonomyTreeProps) {
const roots = useMemo(() => buildTree(categories), [categories]);
@@ -257,7 +292,7 @@ export function CategoryTaxonomyTree({
}
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{/* Panel header */}
<div className="flex items-center gap-3 px-5 py-3 bg-white border-b border-gray-100">
<div className="flex gap-1.5">
@@ -295,6 +330,10 @@ export function CategoryTaxonomyTree({
prefix=""
onEdit={onEdit}
onDelete={onDelete}
onView={onView}
onCreateChild={onCreateChild}
expandAllSignal={expandAllSignal}
collapseAllSignal={collapseAllSignal}
/>
))}
</div>
@@ -303,7 +342,7 @@ export function CategoryTaxonomyTree({
<div className="px-5 py-2.5 border-t border-gray-100 bg-gray-50 flex items-center gap-2">
<ChevronRight className="w-3 h-3 text-gray-400" />
<span className="text-[11px] text-gray-400">
Click any folder to expand · Hover a row for actions
Click any folder to expand · Hover a row for contextual PIM tree options
</span>
</div>
</div>
+4 -5
View File
@@ -14,11 +14,10 @@ export const useCategory = () => {
setError(null);
try {
const data = await categoryService.getAll();
// Fall back to mock data when API returns empty (backend not running)
setCategories(data && data.length > 0 ? data : MOCK_CATEGORIES);
} catch {
// API unavailable — use mock data so the UI is always demonstrable
setCategories(MOCK_CATEGORIES);
setCategories(data || []);
} catch (err: any) {
setError(err.message || 'Failed to fetch categories');
setCategories([]);
} finally {
setLoading(false);
}
+55 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Plus, Grid3x3, Layers, Package, LayoutGrid, LayoutList, Network } from "lucide-react";
import { Plus, Grid3x3, Layers, Package, LayoutGrid, LayoutList, Network, RefreshCw } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
@@ -18,13 +18,19 @@ type ViewMode = "table" | "tree";
export default function CategoryList() {
const navigate = useNavigate();
const { categories, fetchCategories, deleteCategory } = useCategory();
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [viewMode, setViewMode] = useState<ViewMode>("tree"); // Tree view defaults as primary
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; id: string; name: string }>({
isOpen: false, id: "", name: "",
});
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
// States to control expand/collapse all nodes programmatically
const [expandAllSignal, setExpandAllSignal] = useState(0);
const [collapseAllSignal, setCollapseAllSignal] = useState(0);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const stats = {
total: categories.length || 0,
@@ -103,7 +109,7 @@ export default function CategoryList() {
icon={<Plus className="w-4 h-4" />}
onClick={() => navigate("/categories/new")}
>
Create Category
Add Root Category
</Button>
}
/>
@@ -166,7 +172,7 @@ export default function CategoryList() {
<DataTable<Category>
columns={columns}
data={categories}
onRowClick={(row) => navigate(`/categories/${row.id}/edit`)}
onRowClick={(row) => navigate(`/categories/${row.id}/view`)}
searchPlaceholder="Search categories by code, name, parent category, or description..."
actionConfig={{
onView: (row) => navigate(`/categories/${row.id}/view`),
@@ -175,12 +181,50 @@ export default function CategoryList() {
}}
/>
) : (
<CategoryTaxonomyTree
categories={categories}
onEdit={(cat) => navigate(`/categories/${cat.id}/edit`)}
onDelete={(cat) => setDeleteModal({ isOpen: true, id: cat.id, name: cat.name })}
onAdd={() => navigate("/categories/new")}
/>
<div className="space-y-4">
{/* Tree Controls Toolbar */}
<div className="flex items-center gap-2 mb-3 bg-white p-2.5 rounded-lg border border-gray-200 shadow-sm">
<Button
variant="outline"
size="sm"
onClick={() => {
setExpandAllSignal(Date.now());
setCollapseAllSignal(0);
}}
>
Expand All
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setCollapseAllSignal(Date.now());
setExpandAllSignal(0);
}}
>
Collapse All
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fetchCategories()}
icon={<RefreshCw className="w-3 h-3" />}
>
Refresh
</Button>
</div>
<CategoryTaxonomyTree
categories={categories}
onEdit={(cat) => navigate(`/categories/${cat.id}/edit`)}
onDelete={(cat) => setDeleteModal({ isOpen: true, id: cat.id, name: cat.name })}
onView={(cat) => navigate(`/categories/${cat.id}/view`)}
onAdd={() => navigate("/categories/new")}
onCreateChild={(cat) => navigate(`/categories/new?parentId=${cat.id}`)}
expandAllSignal={expandAllSignal}
collapseAllSignal={collapseAllSignal}
/>
</div>
)}
</div>
@@ -0,0 +1,161 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ChevronLeft, Pencil, Folder, Calendar, User } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Button } from "../../../components/customs/Button";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { useCategory } from "../hook/useCategory";
import { categoryService } from "../services/category.service";
import { StatusBadge } from "../../../components/customs/StatusBadge";
export default function CategoryView() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { categories } = useCategory();
const [category, setCategory] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (id) {
setLoading(true);
categoryService.getById(id)
.then((data) => {
setCategory(data);
})
.catch(() => {
// fallback to local list if backend query fails
const localMatch = categories.find((c) => c.id === id);
if (localMatch) setCategory(localMatch);
})
.finally(() => {
setLoading(false);
});
}
}, [id, categories]);
if (loading) {
return (
<ProtectedRoute node="products.categories">
<PageWrapper>
<div className="flex items-center justify-center h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</PageWrapper>
</ProtectedRoute>
);
}
if (!category) {
return (
<ProtectedRoute node="products.categories">
<PageWrapper>
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900">Category not found</h3>
<Button variant="outline" className="mt-4" onClick={() => navigate("/categories")}>
Back to Categories
</Button>
</div>
</PageWrapper>
</ProtectedRoute>
);
}
const parentName = category.parentName || (category.parent ? category.parent.name : "None (Root Level)");
const childrenCount = category.children ? category.children.length : categories.filter(c => c.parentId === category.id).length;
return (
<ProtectedRoute node="products.categories">
<PageWrapper>
<Breadcrumb
items={[{ label: "Home" }, { label: "Categories", href: "/categories" }, { label: "View Category" }]}
backTo="/categories"
actions={
<>
<Button variant="outline" onClick={() => navigate("/categories")} icon={<ChevronLeft className="w-4 h-4" />}>
Back
</Button>
<Button variant="primary" onClick={() => navigate(`/categories/${category.id}/edit`)} icon={<Pencil className="w-4 h-4" />}>
Edit Category
</Button>
</>
}
/>
<div className="max-w-4xl space-y-6">
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
{/* Header info */}
<div className="px-6 py-5 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<Folder className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">{category.name}</h2>
<p className="text-xs font-mono text-gray-500 mt-0.5">Code: {category.code}</p>
</div>
</div>
<StatusBadge
status={category.status === "active" ? "active" : "disabled"}
label={category.status === "active" ? "Active" : "Inactive"}
/>
</div>
{/* Content Details */}
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Main Classification details */}
<div className="space-y-4">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-widest border-b pb-1.5">Hierarchy & Taxonomy</h3>
<div className="grid grid-cols-3 gap-2 py-1">
<span className="text-xs font-medium text-gray-500 col-span-1">Parent Category:</span>
<span className="text-xs text-gray-900 col-span-2 font-medium">{parentName}</span>
</div>
<div className="grid grid-cols-3 gap-2 py-1">
<span className="text-xs font-medium text-gray-500 col-span-1">Hierarchy Level:</span>
<span className="text-xs text-gray-900 col-span-2 font-mono">Level {category.level ?? 0} (Root = 0)</span>
</div>
<div className="grid grid-cols-3 gap-2 py-1">
<span className="text-xs font-medium text-gray-500 col-span-1">Materialized Path:</span>
<span className="text-xs text-gray-900 col-span-2 font-mono text-primary bg-primary/5 px-2 py-0.5 rounded break-all">
{category.path || `/${category.code}`}
</span>
</div>
<div className="grid grid-cols-3 gap-2 py-1">
<span className="text-xs font-medium text-gray-500 col-span-1">Subcategories Count:</span>
<span className="text-xs text-gray-900 col-span-2 font-semibold">{childrenCount} children</span>
</div>
</div>
{/* Description & metadata */}
<div className="space-y-4">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-widest border-b pb-1.5">Metadata & Details</h3>
<div className="py-1">
<span className="text-xs font-medium text-gray-500 block mb-1">Description:</span>
<p className="text-xs text-gray-700 bg-gray-50 p-2.5 rounded border border-gray-100 min-h-[60px] leading-relaxed">
{category.description || "No description provided."}
</p>
</div>
<div className="flex gap-6 pt-2 border-t border-gray-50">
<div className="flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[10px] text-gray-500">Created by: <strong className="text-gray-700">Sarah Chen</strong></span>
</div>
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 text-gray-400" />
<span className="text-[10px] text-gray-500">Updated: <strong className="text-gray-700">{category.createdAt ? new Date(category.createdAt).toISOString().split('T')[0] : "2026-07-09"}</strong></span>
</div>
</div>
</div>
</div>
</div>
</div>
</PageWrapper>
</ProtectedRoute>
);
}
+182 -238
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useMemo } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useFormik } from "formik";
import * as Yup from "yup";
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
import { Save } from "lucide-react";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { Button } from "../../../components/customs/Button";
@@ -11,15 +11,14 @@ import { TextArea } from "../../../components/customs/TextArea";
import { useCategory } from "../hook/useCategory";
import { Radio, RadioGroup } from "../../../components/customs/Radio";
import { Select } from "../../../components/customs/Select";
import type { CategoryCreateRequest, CategoryStatus } from "../types/category.types";
import { TaxonomyPreview } from "../components/TaxonomyPreview";
import type { CategoryCreateRequest, CategoryUpdateRequest, CategoryStatus } from "../types/category.types";
const categorySchema = Yup.object().shape({
code: Yup.string()
.required("Category code is required")
.min(3, "Code must be at least 3 characters")
.max(50, "Code must be less than 50 characters")
.matches(/^[a-z0-9_]+$/, "Code can only contain lowercase letters, numbers and underscores"),
.matches(/^[a-z0-9_]+$/, "Code can only contain lowercase letters, numbers and underscores")
.nullable(),
name: Yup.string()
.required("Category name is required")
.min(2, "Name must be at least 2 characters")
@@ -27,305 +26,250 @@ const categorySchema = Yup.object().shape({
description: Yup.string().max(500, "Description must be less than 500 characters"),
parentId: Yup.string().nullable(),
status: Yup.string().oneOf(["active", "inactive"] as const).required("Status is required"),
imageUrl: Yup.string()
.transform((value, originalValue) => originalValue === "" ? null : value)
.url("Must be a valid URL")
.nullable()
});
const STEPS = [
{ id: "basic", label: "Basic Information", step: 1 },
{ id: "parent", label: "Parent Category", step: 2 },
{ id: "attributes", label: "Attribute Groups", step: 3 },
{ id: "hierarchy", label: "Hierarchy Preview", step: 4 },
];
const inputClass = "w-full border border-primary/10 focus:ring-primary-light rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white placeholder-gray-400";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
const errorClass = "text-xs text-red-500 mt-1";
export default function NewCategory() {
const navigate = useNavigate();
const { createCategory, categories, fetchCategories } = useCategory();
const [activeStep, setActiveStep] = useState("basic");
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const parentIdParam = searchParams.get("parentId");
const isEdit = Boolean(id);
useEffect(() => { fetchCategories(); }, [fetchCategories]);
const { createCategory, updateCategory, categories, fetchCategories } = useCategory();
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
const formik = useFormik({
initialValues: {
code: "",
name: "",
description: "",
parentId: "",
parentId: parentIdParam || "",
status: "active" as CategoryStatus,
imageUrl: ""
},
validationSchema: categorySchema,
onSubmit: async (values, { setSubmitting }) => {
try {
const payload: CategoryCreateRequest = { ...values, status: values.status as CategoryStatus };
await createCategory(payload);
const payload = {
code: values.code || undefined,
name: values.name,
description: values.description || "",
parentId: values.parentId || null,
status: values.status as CategoryStatus,
imageUrl: values.imageUrl || null
};
if (isEdit && id) {
await updateCategory(id, payload as CategoryUpdateRequest);
} else {
await createCategory(payload as CategoryCreateRequest);
}
navigate("/categories");
} catch (error) {
console.error("Failed to create category:", error);
console.error("Failed to save category:", error);
} finally {
setSubmitting(false);
}
},
});
const attributeGroups = [
{ id: 1, name: "Basic Information", count: 8, code: "basic_info" },
{ id: 2, name: "Pricing & Inventory", count: 12, code: "pricing_inventory" },
{ id: 3, name: "Marketing Content", count: 10, code: "marketing_content" },
{ id: 4, name: "Technical Specifications",count: 16, code: "tech_specs" },
{ id: 5, name: "Logistics & Shipping", count: 9, code: "logistics_shipping" },
];
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e);
if (!isEdit) {
const generatedCode = e.target.value
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '');
formik.setFieldValue("code", generatedCode);
}
};
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
useEffect(() => {
if (isEdit && id && categories.length > 0) {
const match = categories.find((item) => item.id === id);
if (match) {
formik.setValues({
code: match.code,
name: match.name,
description: match.description || "",
parentId: match.parentId || "",
status: match.status || "active",
imageUrl: (match as any).imageUrl || ""
});
}
} else if (!isEdit && parentIdParam) {
formik.setFieldValue("parentId", parentIdParam);
}
}, [isEdit, id, categories, parentIdParam]);
const allowedParentOptions = useMemo(() => {
if (!isEdit) return categories;
return categories.filter((cat) => cat.id !== id && !cat.path?.startsWith(categories.find(c => c.id === id)?.path + "/"));
}, [categories, isEdit, id]);
return (
<ProtectedRoute node="products.categories">
<div className="h-screen flex flex-col overflow-hidden bg-gray-50/50">
{/* Top Bar */}
<div className="shrink-0 px-6 pt-3">
<Breadcrumb
items={[{ label: "Home" }, { label: "Categories", href: "/categories" }, { label: "Create Category" }]}
items={[
{ label: "Home" },
{ label: "Categories", href: "/categories" },
{ label: isEdit ? "Edit Category" : "Create Category" }
]}
backTo="/categories"
actions={
<>
<Button variant="outline" onClick={() => navigate("/categories")}>Cancel</Button>
<Button variant="primary" type="submit" form="category-form" loading={formik.isSubmitting}>Save Category</Button>
<Button variant="outline" type="button" onClick={() => navigate("/categories")}>
Cancel
</Button>
<Button
variant="primary"
type="submit"
form="category-form"
icon={<Save className="w-4 h-4" />}
loading={formik.isSubmitting}
>
{isEdit ? "Save Changes" : "Save Category"}
</Button>
</>
}
/>
</div>
{/* Body */}
<div className="flex flex-1 min-h-0 gap-0 px-6 pb-6">
{/* Timeline Sidebar */}
<aside className="w-52 shrink-0 self-start bg-white border border-primary/10 rounded-lg shadow-sm overflow-hidden mr-5">
<div className="px-4 py-3 border-b border-primary/5 bg-primary/5/40">
<p className="text-[11px] font-semibold text-primary uppercase tracking-widest">Configuration</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-gray-400">Step {activeIndex + 1} of {STEPS.length}</p>
<span className="text-[10px] font-medium text-primary bg-primary-light px-2 py-0.5 rounded-full">
{Math.round(((activeIndex + 1) / STEPS.length) * 100)}%
</span>
</div>
<div className="mt-2 h-1 bg-primary-light rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${((activeIndex + 1) / STEPS.length) * 100}%` }}
/>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<form id="category-form" onSubmit={formik.handleSubmit} className="max-w-4xl bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden mt-4">
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">
{isEdit ? "Category Details" : "New Category Classification"}
</h3>
<p className="text-xs text-primary-light mt-0.5">
Define the taxonomy settings for this product category
</p>
</div>
</div>
<nav className="px-4 py-3">
{STEPS.map((s, idx) => {
const isActive = activeStep === s.id;
const isDone = s.step < activeIndex + 1;
const isLast = idx === STEPS.length - 1;
return (
<div key={s.id} className="flex gap-3">
<div className="flex flex-col items-center" style={{ width: 24 }}>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10 transition-all ${
isActive ? "bg-primary ring-2 ring-primary/20" :
isDone ? "bg-emerald-500" :
"bg-white border-2 border-gray-200 hover:border-primary/30"
}`}
>
{isDone
? <Check className="w-3 h-3 text-white" />
: <span className={`text-[9px] font-bold ${isActive ? "text-white" : "text-gray-400"}`}>{s.step}</span>
}
</button>
{!isLast && (
<div className={`w-px flex-1 my-0.5 ${isDone ? "bg-emerald-300" : "bg-gray-200"}`} style={{ minHeight: 14 }} />
)}
</div>
<button
type="button"
onClick={() => setActiveStep(s.id)}
className={`flex-1 min-w-0 pb-3 text-left ${isLast ? "pb-0" : ""}`}
>
<span className={`text-xs font-medium leading-tight block ${
isActive ? "text-primary-dark" : isDone ? "text-gray-600" : "text-gray-400 hover:text-gray-600"
}`}>{s.label}</span>
</button>
</div>
);
})}
</nav>
</aside>
{/* Content */}
<form id="category-form" onSubmit={formik.handleSubmit} className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
{/* Basic Information */}
{activeStep === "basic" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">Basic Information</h3>
<p className="text-xs text-primary-light mt-0.5">Define the core identity of this category</p>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Category Name */}
<div>
<label className={labelClass}>Category Name *</label>
<Input
name="name"
value={formik.values.name}
onChange={handleNameChange}
onBlur={formik.handleBlur}
placeholder="e.g., Computers"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
/>
{formik.touched.name && formik.errors.name && <p className={errorClass}>{formik.errors.name}</p>}
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Category Code <span className="text-red-400">*</span></label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., electronics"
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
{formik.touched.code && formik.errors.code && <p className="text-xs text-red-500 mt-1">{formik.errors.code}</p>}
</div>
<div>
<label className={labelClass}>Category Name <span className="text-red-400">*</span></label>
<Input
name="name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., Electronics"
aria-invalid={formik.touched.name && Boolean(formik.errors.name)}
/>
{formik.touched.name && formik.errors.name && <p className="text-xs text-red-500 mt-1">{formik.errors.name}</p>}
</div>
</div>
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-1">
<Radio
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
label="Active"
/>
<Radio
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
label="Inactive"
/>
</RadioGroup>
</div>
<div>
<label className={labelClass}>Description</label>
<TextArea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Describe this category and what products it contains..."
rows={4}
style={{ minHeight: "120px" }}
/>
</div>
{/* Category Code */}
<div>
<label className={labelClass}>Category Code *</label>
<Input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g., computers"
disabled={isEdit}
aria-invalid={formik.touched.code && Boolean(formik.errors.code)}
/>
{!isEdit && <p className="text-[10px] text-gray-400 mt-1">Lowercase letters, numbers, and underscores only</p>}
{formik.touched.code && formik.errors.code && <p className={errorClass}>{formik.errors.code}</p>}
</div>
</div>
)}
{/* Parent Category */}
{activeStep === "parent" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">Parent Category Selection</h3>
<p className="text-xs text-primary-light mt-0.5">Choose where this category sits in the hierarchy</p>
</div>
</div>
<div className="p-6">
{/* Parent Category */}
<div>
<label className={labelClass}>Parent Category</label>
<Select
name="parentId"
value={formik.values.parentId}
onChange={formik.handleChange}
disabled={!isEdit} // Disabled (Read-only) during creation, enabled during Edit
>
<option value="">None (Root Level)</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
{allowedParentOptions.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name} ({cat.code})
</option>
))}
</Select>
{!isEdit && (
<p className="text-[10px] text-gray-400 mt-1">
Locked to parent context. Click inline tree actions to create subcategories.
</p>
)}
</div>
</div>
)}
{/* Attribute Groups */}
{activeStep === "attributes" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary-light rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">Assigned Attribute Groups</h3>
<p className="text-xs text-primary-light mt-0.5">Select attribute groups for products in this category</p>
</div>
{/* Status */}
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-2.5">
<Radio
name="status"
value="active"
checked={formik.values.status === "active"}
onChange={formik.handleChange}
label="Active"
/>
<Radio
name="status"
value="inactive"
checked={formik.values.status === "inactive"}
onChange={formik.handleChange}
label="Inactive"
/>
</RadioGroup>
</div>
<div className="p-6">
<input
type="text"
placeholder="Search attribute groups..."
className={`${inputClass} mb-5`}
{/* Image URL */}
<div className="md:col-span-2">
<label className={labelClass}>Category Image URL (Optional)</label>
<Input
name="imageUrl"
value={formik.values.imageUrl}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="https://example.com/images/category.png"
aria-invalid={formik.touched.imageUrl && Boolean(formik.errors.imageUrl)}
/>
<div className="space-y-2">
{attributeGroups.map((group) => (
<div key={group.id} className="flex justify-between items-center px-4 py-3.5 border border-gray-100 rounded-lg hover:border-primary/10 hover:bg-primary/5/20 transition-all">
<div>
<div className="font-medium text-sm text-gray-900">{group.name}</div>
<div className="text-xs text-gray-400 mt-0.5">{group.count} attributes · {group.code}</div>
</div>
<button type="button" className="px-4 py-1.5 text-sm font-medium text-primary border border-primary/20 hover:bg-primary/5 rounded-lg transition-colors">
Add
</button>
</div>
))}
</div>
{formik.touched.imageUrl && formik.errors.imageUrl && <p className={errorClass}>{formik.errors.imageUrl}</p>}
</div>
</div>
)}
{/* Hierarchy Preview */}
{activeStep === "hierarchy" && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-primary/5 bg-gradient-to-r from-primary/5/70 to-white flex items-center gap-3">
<div className="w-1 h-5 bg-primary rounded-full shrink-0" />
<div>
<h3 className="font-semibold text-primary-dark text-sm">Hierarchy Preview</h3>
<p className="text-xs text-gray-400 mt-0.5">Live taxonomy tree updates instantly as you fill the form</p>
</div>
</div>
<div className="p-6">
<TaxonomyPreview
categoryName={formik.values.name}
parentId={formik.values.parentId}
status={formik.values.status}
allCategories={categories}
{/* Description */}
<div className="md:col-span-2">
<label className={labelClass}>Description</label>
<TextArea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="Describe this category classification..."
rows={4}
style={{ minHeight: "100px" }}
/>
</div>
</div>
)}
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
{activeIndex > 0 && (
<Button variant="outline" type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} icon={<ChevronLeft className="w-4 h-4" />}>Back</Button>
)}
{activeIndex < STEPS.length - 1 && (
<Button variant="primary" type="button" onClick={() => setActiveStep(STEPS[activeIndex + 1].id)}>Next <ChevronRight className="w-4 h-4" /></Button>
)}
</div>
</form>
</div>
@@ -1,15 +1,15 @@
import { Routes, Route } from 'react-router-dom';
import CategoryList from '../pages/CategoryList';
import NewCategory from '../pages/NewCategory';
import CategoryView from '../pages/CategoryView';
export const CategoryRoutes = () => {
return (
<Routes>
<Route index element={<CategoryList />} />
<Route path="new" element={<NewCategory />} />
<Route path=":id/edit" element={<NewCategory />} />
</Routes>
);
};
export const CategoryRoutes = () => (
<Routes>
<Route index element={<CategoryList />} />
<Route path="new" element={<NewCategory />} />
<Route path=":id/edit" element={<NewCategory />} />
<Route path=":id/view" element={<CategoryView />} />
</Routes>
);
export default CategoryRoutes;
@@ -10,6 +10,7 @@ export interface Category {
status: CategoryStatus;
lastUpdated: string;
createdBy: string;
path?: string;
}
export type CategoryCreateRequest = Omit<Category, 'id' | 'lastUpdated' | 'createdBy' | 'parentName'>;
+563 -193
View File
@@ -1,17 +1,25 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useFamily } from '../hook/useFamily';
import { useCategory } from '../../categories/hook/useCategory';
import { useAttribute } from '../../attributes/hook/useAttribute';
import { useChannel } from '../../channels/hook/useChannel';
import { useAssetType } from '../../asset-types/hook/useAssetType';
import { useWorkflow } from '../../workflow/hook/useWorkflow';
import { useAttributeSet } from '../../attribute-sets/hook/useAttributeSet';
import {
FileText, LayoutGrid, Tags, Globe, Eye, Settings2, Save,
CheckCircle2, AlertCircle, Plus, CheckSquare, Image as ImageIcon, Check
CheckCircle2, AlertCircle, CheckSquare, Image as ImageIcon, Check,
FolderTree, Box, Info
} from 'lucide-react';
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../../../components/customs/DataTable";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { useFormik } from "formik";
import { familySchema } from "../validation/family.schema";
import { Radio, RadioGroup } from "../../../components/customs/Radio";
import { Select } from "../../../components/customs/Select";
import { toast } from 'react-toastify';
import { familyService } from '../services/family.service';
const TABS = [
{ id: 'basic', label: 'Basic Information', icon: FileText, step: 1 },
@@ -24,13 +32,6 @@ const TABS = [
{ id: 'summary', label: 'Inheritance Summary', icon: Eye, step: 8 },
];
const WORKFLOWS = [
{ value: 'standard', label: 'Standard Product Approval', desc: 'Default approval process' },
{ value: 'none', label: 'No workflow', desc: 'No approval required' },
{ value: 'express', label: 'Express Approval', desc: 'Fast-track approval' },
{ value: 'compliance', label: 'Multi-Stage Compliance Review', desc: 'Strict regulatory workflow' },
];
const inputClass = "w-full border border-primary/10 focus:ring-primary-light rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white placeholder-gray-400";
const labelClass = "block text-sm font-medium text-gray-700 mb-1.5";
@@ -59,26 +60,217 @@ function Card({ children, className = '' }: { children: React.ReactNode; classNa
export default function NewFamily() {
const navigate = useNavigate();
const { createFamily } = useFamily();
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const { createFamily, updateFamily } = useFamily();
const { categories, fetchCategories, loading: categoriesLoading } = useCategory();
const { attributes, fetchAttributes, loading: attributesLoading } = useAttribute();
const { items: channelsList, fetchItems: fetchChannels, loading: channelsLoading } = useChannel();
const { items: assetTypesList, fetchItems: fetchAssetTypes, loading: assetTypesLoading } = useAssetType();
const { items: workflowsList, fetchItems: fetchWorkflows, loading: workflowsLoading } = useWorkflow();
const { items: attributeSetsList, fetchItems: fetchAttributeSets, loading: setsLoading } = useAttributeSet();
const [familyLoading, setFamilyLoading] = useState(false);
const [activeTab, setActiveTab] = useState('basic');
const [selectedWorkflow, setSelectedWorkflow] = useState('standard');
const [blueprintPreview, setBlueprintPreview] = useState<any>(null);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const loadBlueprintPreview = useCallback(async (setId: string) => {
if (!setId) {
setBlueprintPreview(null);
return;
}
try {
const url = isEdit && id
? `/api/v1/catalogs/${id}/blueprint`
: `/api/v1/attribute-sets/${setId}/structure`;
const token = localStorage.getItem('token') || sessionStorage.getItem('token');
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const json = await res.json();
if (json.success && json.data) {
setBlueprintPreview(json.data);
// Expand all groups by default
const groups = json.data.groups || [];
const expanded: Record<string, boolean> = {};
groups.forEach((g: any) => {
expanded[g.id] = true;
});
setExpandedGroups(expanded);
}
} catch (err) {
console.error('Blueprint preview load error:', err);
}
}, [isEdit, id]);
const formik = useFormik({
initialValues: { name: '', code: '', description: '', status: 'draft', category: '', attributes: [], variantAxes: [] },
initialValues: {
name: '',
code: '',
description: '',
status: 'draft',
category: '',
attributeSetId: '',
attributes: [] as string[],
variantAxes: [] as string[],
channels: [] as string[],
assetRequirements: [] as string[],
completenessRules: {
required_attributes: 40,
at_least_one_image: 20,
marketing_content: 15,
tech_specs: 15,
skus_assigned: 10
} as Record<string, number>
},
validationSchema: familySchema,
onSubmit: async (values, { setSubmitting }) => {
try {
await createFamily(values as any);
const payload = {
...values,
workflowCode: selectedWorkflow
};
if (isEdit && id) {
await updateFamily(id, payload as any);
} else {
await createFamily(payload as any);
}
navigate('/families');
} catch {
// handled by hook
} catch (err: any) {
const msg = err.response?.data?.message || err.message || 'Verification failed';
toast.error(msg);
} finally {
setSubmitting(false);
}
}
});
// Fetch dynamic lookup lists on mount
useEffect(() => {
fetchCategories();
fetchAttributes();
fetchChannels();
fetchAssetTypes();
fetchWorkflows();
fetchAttributeSets();
}, [fetchCategories, fetchAttributes, fetchChannels, fetchAssetTypes, fetchWorkflows, fetchAttributeSets]);
// Load family details in Edit mode
useEffect(() => {
if (id) {
const loadFamily = async () => {
setFamilyLoading(true);
try {
const data = await familyService.getById(id);
if (data) {
formik.setValues({
name: data.name || '',
code: data.code || '',
description: data.description || '',
status: data.status || 'draft',
category: data.categoryId || data.category || '', // ID
attributeSetId: data.attributeSetId || data.attribute_set_id || '',
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) : [],
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) : [],
completenessRules: data.completenessRules || {
required_attributes: 40,
at_least_one_image: 20,
marketing_content: 15,
tech_specs: 15,
skus_assigned: 10
}
});
setSelectedWorkflow(data.workflowCode || 'standard');
}
} catch (err: any) {
toast.error(err.message || 'Failed to load product family details');
} finally {
setFamilyLoading(false);
}
};
loadFamily();
}
}, [id]);
// Trigger blueprint preview updates on change
useEffect(() => {
const setId = formik.values.attributeSetId;
if (setId) {
loadBlueprintPreview(setId);
}
}, [formik.values.attributeSetId, loadBlueprintPreview]);
const activeIndex = TABS.findIndex(t => t.id === activeTab);
const isLoading = familyLoading || categoriesLoading || attributesLoading || channelsLoading || assetTypesLoading || workflowsLoading || setsLoading;
// 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)
);
// 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>
);
}
}
];
return (
<div className="h-screen flex flex-col overflow-hidden bg-gray-50/50">
@@ -86,7 +278,7 @@ export default function NewFamily() {
{/* ── Top Bar ── */}
<div className="shrink-0 px-6 pt-3">
<Breadcrumb
items={[{ label: 'Home' }, { label: 'Families', href: '/families' }, { label: 'Create Product Family' }]}
items={[{ label: 'Home' }, { label: 'Families', href: '/families' }, { label: isEdit ? 'Edit Product Family' : 'Create Product Family' }]}
backTo="/families"
actions={
<>
@@ -100,9 +292,10 @@ export default function NewFamily() {
<button
type="submit"
form="family-form"
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm shadow-purple-200"
disabled={formik.isSubmitting || isLoading}
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2 shadow-sm shadow-purple-200 disabled:opacity-50"
>
<Save className="w-4 h-4" /> Create Family
<Save className="w-4 h-4" /> {isEdit ? 'Save Changes' : 'Create Family'}
</button>
</>
}
@@ -110,7 +303,17 @@ export default function NewFamily() {
</div>
{/* ── Body: Sidebar + Content ── */}
<div className="flex flex-1 min-h-0 gap-0 px-6 pb-6">
<div className="flex flex-1 min-h-0 gap-0 px-6 pb-6 relative">
{/* Loading overlay spinner */}
{isLoading && (
<div className="absolute inset-0 bg-white/70 backdrop-blur-xs flex items-center justify-center z-50">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm font-medium text-primary">Loading data...</p>
</div>
</div>
)}
{/* Timeline Sidebar */}
<aside className="w-52 shrink-0 self-start bg-white border border-primary/10 rounded-lg shadow-sm overflow-hidden mr-5">
@@ -197,10 +400,61 @@ export default function NewFamily() {
<div>
<label className={labelClass}>Family Name <span className="text-red-400">*</span></label>
<input name="name" value={formik.values.name} onChange={formik.handleChange} placeholder="e.g., Laptop Family" className={inputClass} />
{formik.touched.name && formik.errors.name && (
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.name}</div>
)}
</div>
<div>
<label className={labelClass}>Family Code <span className="text-red-400">*</span></label>
<input name="code" value={formik.values.code} onChange={formik.handleChange} placeholder="e.g., laptop_family" className={`${inputClass} bg-primary/5/40 text-gray-500`} />
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
placeholder="e.g., laptop_family"
disabled={isEdit}
className={`${inputClass} ${isEdit ? 'bg-gray-100 text-gray-500 cursor-not-allowed border-gray-200' : 'bg-primary/5/40 text-gray-800'}`}
/>
{formik.touched.code && formik.errors.code && (
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.code}</div>
)}
{isEdit && (
<div className="text-[10px] text-gray-400 mt-1">Codes cannot be edited once saved.</div>
)}
</div>
{/* Row: Attribute Set Select */}
<div className="col-span-2">
<label className={labelClass}>Attribute Set <span className="text-red-400">*</span></label>
<Select
name="attributeSetId"
value={formik.values.attributeSetId}
onChange={(e) => {
formik.handleChange(e);
const setId = e.target.value;
const selectedSet = attributeSetsList.find(s => s.id === setId);
if (selectedSet && selectedSet.groups) {
const inherited: string[] = [];
for (const g of selectedSet.groups) {
if (g.attributes) {
for (const a of g.attributes) {
inherited.push(a.id || a);
}
}
}
formik.setFieldValue('attributes', [...new Set(inherited)]);
} else {
formik.setFieldValue('attributes', []);
}
}}
>
<option value="">Select an Attribute Set...</option>
{attributeSetsList.map((set) => (
<option key={set.id} value={set.id}>{set.name} ({set.code})</option>
))}
</Select>
{formik.touched.attributeSetId && formik.errors.attributeSetId && (
<div className="text-xs text-red-500 mt-1 font-medium">{formik.errors.attributeSetId}</div>
)}
</div>
{/* Row 2: Category | Status */}
@@ -208,7 +462,13 @@ export default function NewFamily() {
<label className={labelClass}>Category Assignment</label>
<Select name="category" value={formik.values.category} onChange={formik.handleChange}>
<option value="">Select a category...</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{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>
)}
</div>
<div>
<label className={labelClass}>Status</label>
@@ -239,38 +499,107 @@ export default function NewFamily() {
</Card>
)}
{/* ── Attribute Groups ── */}
{/* ── Attribute Assignment ── */}
{activeTab === 'attributes' && (
<Card>
<CardHeader title="Attribute Group Assignment" subtitle="Choose which groups products in this family inherit" />
<CardHeader title="Attribute Set Blueprint Groups" subtitle="Attributes are defined by the Attribute Set. Expand groups to enable/disable optional items." />
<div className="p-6">
<div className="bg-primary/5 border border-primary/10 rounded-lg px-4 py-3 mb-5">
<p className="text-sm text-primary-dark">
Select which attribute groups products in this family should inherit. Platform and category-level groups are always included automatically.
</p>
</div>
<div className="space-y-2">
{[
{ name: 'Technical Specifications', desc: '16 attributes', checked: true },
{ name: 'Marketing Content', desc: '10 attributes', checked: true },
{ name: 'Physical Attributes', desc: '8 attributes', checked: false },
{ name: 'Compliance', desc: '6 attributes', checked: false },
{ name: 'Warranty', desc: '4 attributes', checked: false },
].map((group, i) => (
<label key={i} className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${
group.checked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={group.checked} readOnly className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light" />
<div>
<div className="font-medium text-sm text-gray-900">{group.name}</div>
<div className="text-xs text-gray-400 mt-0.5">{group.desc}</div>
</div>
</div>
{group.checked && <CheckCircle2 className="w-4 h-4 text-primary-light" />}
</label>
))}
</div>
{formik.values.attributeSetId ? (
<div className="space-y-4 max-h-[420px] overflow-y-auto pr-2">
{(() => {
const selectedSet = attributeSetsList.find(s => s.id === formik.values.attributeSetId);
const groupsToRender = blueprintPreview?.groups || selectedSet?.groups || [];
if (groupsToRender.length === 0) {
return <div className="text-center py-8 text-gray-400 text-sm">No groups found in this Attribute Set.</div>;
}
return groupsToRender.map((group: any) => {
const isExpanded = expandedGroups[group.id] !== false; // default true
const groupAttributes = group.attributes || [];
return (
<div key={group.id} className="border border-gray-200 rounded-lg overflow-hidden bg-white shadow-xs">
{/* Group Accordion Header */}
<button
type="button"
onClick={() => setExpandedGroups(prev => ({ ...prev, [group.id]: !isExpanded }))}
className="w-full flex items-center justify-between px-4 py-3.5 bg-gray-50 hover:bg-gray-100/70 border-b border-gray-200 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-800 uppercase tracking-wider">{group.name}</span>
<span className="px-2 py-0.5 rounded-full bg-gray-200/60 text-[10px] text-gray-500 font-bold">
{groupAttributes.length} Attributes
</span>
</div>
<span className="text-xs font-semibold text-primary">
{isExpanded ? 'Collapse' : 'Expand'}
</span>
</button>
{/* Group Attributes list */}
{isExpanded && (
<div className="p-4 grid grid-cols-2 gap-3 bg-white">
{groupAttributes.map((attr: any) => {
const isChecked = formik.values.attributes.includes(attr.id);
const isRequired = attr.is_required || attr.isRequired;
const isVariant = attr.is_variant_eligible || attr.is_variant_axis || attr.isVariantEligible;
const handleToggle = () => {
const current = [...formik.values.attributes];
if (isChecked) {
if (isRequired) return; // Prevent disabling required fields
formik.setFieldValue('attributes', current.filter(id => id !== attr.id));
// Remove from variant axes if deselected
formik.setFieldValue('variantAxes', formik.values.variantAxes.filter(id => id !== attr.id));
} else {
formik.setFieldValue('attributes', [...current, attr.id]);
}
};
return (
<label
key={attr.id}
onClick={handleToggle}
className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${
isChecked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20 opacity-60'
}`}
>
<div className="flex items-center gap-4">
<input
type="checkbox"
checked={isChecked}
readOnly
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light"
/>
<div>
<div className="font-semibold text-sm text-gray-900">{attr.name}</div>
<div className="text-[11px] text-gray-400 mt-0.5">
{attr.code} {attr.type?.toUpperCase()}
{isRequired && <span className="ml-2 text-red-500 font-bold">* Required</span>}
{isVariant && <span className="ml-2 text-primary font-medium">(Variant Axis)</span>}
</div>
</div>
</div>
{isChecked && <CheckCircle2 className="w-4 h-4 text-primary" />}
</label>
);
})}
{groupAttributes.length === 0 && (
<div className="col-span-2 text-center py-4 text-gray-400 text-sm">No attributes in this group.</div>
)}
</div>
)}
</div>
);
});
})()}
</div>
) : (
<div className="text-center py-12 text-gray-400 text-sm">
Please select an Attribute Set in Step 1 (Basic Details) to view and assign inherited attributes.
</div>
)}
</div>
</Card>
)}
@@ -283,48 +612,25 @@ export default function NewFamily() {
<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>. Actual values (e.g., Black, Blue, White) are added at the individual product level.
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="Define the dimensions that will vary across products in this family"
action={
<button type="button" className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-all shadow-sm shadow-purple-200 active:scale-95">
<Plus className="w-4 h-4" /> Add Variant Axis
</button>
}
/>
<DataTable
columns={[
{ key: "name", label: "AXIS NAME" },
{ 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: "desc", label: "DESCRIPTION", render: (val: string) => <span className="text-gray-500 text-sm">{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 ${
val === 'color swatch' ? 'bg-primary/5 text-primary-dark border-primary/10' : 'bg-amber-50 text-amber-700 border-amber-100'
}`}>{val}</span>
)
},
{
key: "required", label: "REQUIRED", align: "center", render: (val: boolean) => (
<span className={`inline-block px-3 py-0.5 text-xs font-medium rounded-full ${
val ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' : 'bg-gray-100 text-gray-400'
}`}>{val ? 'Yes' : 'No'}</span>
)
},
{ key: "status", label: "STATUS", render: (val: string) => <StatusBadge status={val.toLowerCase() as any} label={val} /> },
]}
data={[
{ id: '1', name: "Color", code: "color", desc: "Product color variations", type: "color swatch", required: true, status: "Active" },
{ id: '2', name: "Storage", code: "storage", desc: "Storage capacity variations", type: "dropdown", required: true, status: "Active" },
{ id: '3', name: "RAM", code: "ram", desc: "Memory capacity variations", type: "dropdown", required: false, status: "Active" },
]}
actionConfig={{ onEdit: () => {}, onDelete: () => {} }}
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>
)}
@@ -334,23 +640,31 @@ export default function NewFamily() {
<Card>
<CardHeader title="Allowed Channels" subtitle="Select which channels products in this family can be published to" />
<div className="p-6 grid grid-cols-3 gap-3">
{[
{ name: 'Amazon', desc: 'Amazon Marketplace', checked: true },
{ name: 'Shopify', desc: 'E-commerce storefront', checked: true },
{ name: 'POS', desc: 'Point of Sale systems', checked: true },
{ name: 'B2B Portal', desc: 'Business customers', checked: false },
{ name: 'Mobile App', desc: 'Mobile application', checked: false },
].map((channel, i) => (
<label key={i} className={`flex items-start justify-between p-4 rounded-lg border cursor-pointer transition-all ${
channel.checked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div>
<div className="font-semibold text-sm text-gray-900 mb-0.5">{channel.name}</div>
<div className="text-xs text-gray-400">{channel.desc}</div>
</div>
<input type="checkbox" checked={channel.checked} readOnly className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light mt-0.5" />
</label>
))}
{channelsList.map((channel) => {
const isChecked = formik.values.channels.includes(channel.code || '');
const handleToggle = () => {
const current = [...formik.values.channels];
if (isChecked) {
formik.setFieldValue('channels', current.filter(c => c !== channel.code));
} else {
formik.setFieldValue('channels', [...current, channel.code]);
}
};
return (
<label key={channel.id} onClick={handleToggle} className={`flex items-start justify-between p-4 rounded-lg border cursor-pointer transition-all ${
isChecked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div>
<div className="font-semibold text-sm text-gray-900 mb-0.5">{channel.name}</div>
<div className="text-xs text-gray-400">{channel.description || 'Channel Code: ' + channel.code}</div>
</div>
<input type="checkbox" checked={isChecked} readOnly className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light mt-0.5" />
</label>
);
})}
{channelsList.length === 0 && (
<div className="col-span-3 text-center py-8 text-gray-400 text-sm">No channels available.</div>
)}
</div>
</Card>
)}
@@ -360,25 +674,34 @@ export default function NewFamily() {
<Card>
<CardHeader title="Default Asset Types" subtitle="Define which asset types are expected for products in this family" />
<div className="p-6 space-y-2">
{[
{ name: 'Primary Image', req: true, checked: true },
{ name: 'Gallery Images', req: false, checked: true },
{ name: 'Product Video', req: false, checked: false },
{ name: 'Technical Documentation', req: false, checked: false },
].map((asset, i) => (
<label key={i} className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${
asset.checked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={asset.checked} readOnly className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light" />
<div>
<div className="font-medium text-sm text-gray-900">{asset.name}</div>
{asset.req && <div className="text-xs text-red-400 mt-0.5">Required</div>}
{assetTypesList.map((asset) => {
const isChecked = formik.values.assetRequirements.includes(asset.id);
const handleToggle = () => {
const current = [...formik.values.assetRequirements];
if (isChecked) {
formik.setFieldValue('assetRequirements', current.filter(id => id !== asset.id));
} else {
formik.setFieldValue('assetRequirements', [...current, asset.id]);
}
};
return (
<label key={asset.id} onClick={handleToggle} className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${
isChecked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={isChecked} readOnly className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light" />
<div>
<div className="font-medium text-sm text-gray-900">{asset.name}</div>
{asset.isRequired && <div className="text-xs text-red-400 mt-0.5">Required Asset</div>}
</div>
</div>
</div>
{asset.checked && <CheckCircle2 className="w-4 h-4 text-primary-light" />}
</label>
))}
{isChecked && <CheckCircle2 className="w-4 h-4 text-primary" />}
</label>
);
})}
{assetTypesList.length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm">No asset types available.</div>
)}
</div>
</Card>
)}
@@ -394,32 +717,29 @@ export default function NewFamily() {
value={selectedWorkflow}
onChange={(e) => setSelectedWorkflow(e.target.value)}
>
{WORKFLOWS.map((wf) => (
<option key={wf.value} value={wf.value}>{wf.label}</option>
{workflowsList.map((wf) => (
<option key={wf.id} value={wf.code}>{wf.name}</option>
))}
</Select>
<p className="mt-2.5 text-sm text-primary-light">
{WORKFLOWS.find(w => w.value === selectedWorkflow)?.desc}
</p>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
{WORKFLOWS.map((wf) => (
{workflowsList.map((wf) => (
<button
key={wf.value}
key={wf.id}
type="button"
onClick={() => setSelectedWorkflow(wf.value)}
onClick={() => setSelectedWorkflow(wf.code)}
className={`text-left p-4 rounded-lg border transition-all ${
selectedWorkflow === wf.value
selectedWorkflow === wf.code
? 'border-primary/20 bg-primary/5/50 shadow-sm'
: 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm text-gray-900">{wf.label}</span>
{selectedWorkflow === wf.value && <Check className="w-4 h-4 text-primary" />}
<span className="font-medium text-sm text-gray-900">{wf.name}</span>
{selectedWorkflow === wf.code && <Check className="w-4 h-4 text-primary" />}
</div>
<span className="text-xs text-gray-400">{wf.desc}</span>
<span className="text-xs text-gray-400">{wf.code}</span>
</button>
))}
</div>
@@ -430,27 +750,50 @@ export default function NewFamily() {
{/* ── Completeness Rules ── */}
{activeTab === 'rules' && (
<Card>
<CardHeader title="Completeness Rules" subtitle="Define what counts toward a product's completeness score" />
<div className="p-6 space-y-2">
{[
{ name: 'All required attributes filled', weight: '40%', checked: true },
{ name: 'At least one product image', weight: '20%', checked: true },
{ name: 'Marketing content complete', weight: '15%', checked: true },
{ name: 'Technical specifications complete', weight: '15%', checked: false },
{ name: 'All variants have SKUs', weight: '10%', checked: true },
].map((rule, i) => (
<label key={i} className={`flex items-center justify-between px-4 py-3.5 rounded-lg border cursor-pointer transition-all ${
rule.checked ? 'border-primary/20 bg-primary/5/40 shadow-sm' : 'border-gray-100 hover:border-primary/10 hover:bg-primary/5/20'
}`}>
<div className="flex items-center gap-4">
<input type="checkbox" checked={rule.checked} readOnly className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light" />
<div className="font-medium text-sm text-gray-900">{rule.name}</div>
</div>
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
rule.checked ? 'bg-primary-light text-primary-dark' : 'bg-gray-100 text-gray-400'
}`}>{rule.weight}</span>
</label>
))}
<CardHeader title="Completeness Rules" subtitle="Define weights that sum up to 100%" />
<div className="p-6 space-y-4">
<div className="bg-primary/5 border border-primary/10 rounded-lg px-4 py-3 flex items-start gap-3">
<AlertCircle className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
<div className="text-sm text-primary-dark">
Configure weights for completeness rules. The sum of all weights must equal exactly 100%. Current sum: {' '}
<strong className="underline">
{Object.values(formik.values.completenessRules).reduce((sum, v) => sum + (Number(v) || 0), 0)}%
</strong>
</div>
</div>
<div className="space-y-3">
{[
{ key: 'required_attributes', name: 'All required attributes filled' },
{ key: 'at_least_one_image', name: 'At least one product image' },
{ key: 'marketing_content', name: 'Marketing content complete' },
{ key: 'tech_specs', name: 'Technical specifications complete' },
{ key: 'skus_assigned', name: 'All variants have SKUs' }
].map((rule) => {
const value = formik.values.completenessRules[rule.key] ?? 0;
const handleWeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Math.max(0, parseInt(e.target.value) || 0);
formik.setFieldValue(`completenessRules.${rule.key}`, val);
};
return (
<div key={rule.key} className="flex items-center justify-between p-4 rounded-lg border border-gray-100 bg-white shadow-xs">
<div className="font-medium text-sm text-gray-900">{rule.name}</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="100"
value={value}
onChange={handleWeightChange}
className="w-20 border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
/>
<span className="text-sm font-semibold text-gray-500">%</span>
</div>
</div>
);
})}
</div>
</div>
</Card>
)}
@@ -458,53 +801,80 @@ export default function NewFamily() {
{/* ── Inheritance Summary ── */}
{activeTab === 'summary' && (
<Card>
<CardHeader title="Inheritance Summary" subtitle="Everything products in this family will automatically inherit" />
<div className="p-6 space-y-3">
{[
{ icon: LayoutGrid, color: 'purple', title: 'Attribute Groups', desc: '2 family-level attribute groups (plus platform + category attributes)', note: null },
{ icon: Tags, color: 'amber', title: 'Variant Axes', desc: '3 variant axes defined: Color, Storage, RAM', note: 'Products will define their own values for each axis' },
{ icon: Settings2, color: 'blue', title: 'Workflow', desc: 'Standard Product Approval', note: null },
].map(({ icon: Icon, color, title, desc, note }, i) => (
<div key={i} className={`flex gap-4 p-4 rounded-lg border border-${color}-100 bg-${color}-50/30`}>
<div className={`w-9 h-9 bg-${color}-100 rounded-lg flex items-center justify-center shrink-0`}>
<Icon className={`w-4 h-4 text-${color}-600`} />
<CardHeader title="Inheritance Summary" subtitle="Verify setup and inheritance metrics for this family" />
<div className="p-6 space-y-6">
{/* Visual Flowchart */}
<div>
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Inheritance Blueprint Flowchart</h4>
<div className="flex items-center flex-wrap gap-2 text-xs font-semibold text-gray-700 bg-gray-50/50 p-4 rounded-xl border border-gray-150 justify-center">
<div className="px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-lg shadow-xs">
📁 {categories.find(c => c.id === formik.values.category)?.name || 'Category'}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-900 text-sm flex items-center gap-2">
{title} <Check className="w-4 h-4 text-emerald-500" />
</div>
<div className="text-sm text-gray-500 mt-0.5">{desc}</div>
{note && <div className="text-xs text-primary mt-1">{note}</div>}
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-purple-50 border border-purple-200 rounded-lg shadow-xs">
{attributeSetsList.find(s => s.id === formik.values.attributeSetId)?.name || 'Attribute Set'}
</div>
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-indigo-50 border border-indigo-200 rounded-lg shadow-xs">
📦 {blueprintPreview?.groups?.length || attributeSetsList.find(s => s.id === formik.values.attributeSetId)?.groups?.length || 0} Groups
</div>
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-pink-50 border border-pink-200 rounded-lg shadow-xs">
🔤 {formik.values.attributes.length} Attributes
</div>
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-lg shadow-xs">
📐 {selectedAttributesList.length} Variant Axes
</div>
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-emerald-50 border border-emerald-200 rounded-lg shadow-xs">
🌐 {formik.values.channels.length} Channels
</div>
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-rose-50 border border-rose-200 rounded-lg shadow-xs">
🖼 {formik.values.assetRequirements.length} Asset Types
</div>
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-teal-50 border border-teal-200 rounded-lg shadow-xs">
🔄 Workflow ({selectedWorkflow})
</div>
<span className="text-gray-400 font-bold"></span>
<div className="px-3 py-1.5 bg-orange-50 border border-orange-200 rounded-lg shadow-xs">
Completeness Rules
</div>
</div>
))}
<div className="grid grid-cols-2 gap-3">
{[
{ icon: Globe, color: 'emerald', title: 'Allowed Channels', desc: '3 channels enabled' },
{ icon: ImageIcon, color: 'rose', title: 'Asset Types', desc: '2 default asset types' },
].map(({ icon: Icon, color, title, desc }, i) => (
<div key={i} className={`flex gap-4 p-4 rounded-lg border border-${color}-100 bg-${color}-50/30`}>
<div className={`w-9 h-9 bg-${color}-100 rounded-lg flex items-center justify-center shrink-0`}>
<Icon className={`w-4 h-4 text-${color}-600`} />
</div>
<div>
<div className="font-semibold text-gray-900 text-sm">{title}</div>
<div className="text-sm text-gray-500 mt-0.5">{desc}</div>
</div>
</div>
))}
</div>
<div className="bg-primary/5 border border-primary/10 rounded-lg p-4 flex gap-3">
<AlertCircle className="w-4 h-4 text-primary shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-primary-dark">Important Note</p>
<p className="text-sm text-primary mt-1 leading-relaxed">
Products created from this family will inherit the variant axes structure, but will define their own variant values. This allows each product to have different value sets while maintaining a consistent variant strategy.
</p>
{/* Grid stats */}
<div>
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Inherited Metrics & Stats</h4>
<div className="grid grid-cols-2 gap-4">
{[
{ icon: FolderTree, title: 'Category Tree', value: categories.find(c => c.id === formik.values.category)?.name || 'Not assigned', desc: 'Classification scope' },
{ icon: LayoutGrid, title: 'Attribute Set', value: attributeSetsList.find(s => s.id === formik.values.attributeSetId)?.name || 'None', desc: 'Blueprint specification set' },
{ icon: Box, title: 'Attribute Groups', value: `${blueprintPreview?.groups?.length || attributeSetsList.find(s => s.id === formik.values.attributeSetId)?.groups?.length || 0} Groups`, desc: 'Collapsible structural sections' },
{ icon: FileText, title: 'Total Attributes', value: `${formik.values.attributes.length} Fields`, desc: 'Inherited EAV schema fields' },
{ icon: Tags, title: 'Variant Strategy', value: `${selectedAttributesList.length} Axes`, desc: selectedAttributesList.map(a => a.name).join(', ') || 'No variants' },
{ icon: Globe, title: 'Allowed Channels', value: `${formik.values.channels.length} Mappings`, desc: formik.values.channels.join(', ') || 'No channels selected' },
{ icon: ImageIcon, title: 'Media Requirements', value: `${formik.values.assetRequirements.length} Asset Types`, desc: 'Enforced for completeness' },
{ icon: Settings2, title: 'Lifecycle Workflow', value: workflowsList.find(w => w.code === selectedWorkflow)?.name || selectedWorkflow, desc: 'Products lifecycle flow' },
{ icon: Info, title: 'Completeness Rules', value: `${Object.keys(formik.values.completenessRules).length} Rules`, desc: 'Auto-calculated criteria' }
].map(({ icon: Icon, title, value, desc }, i) => (
<div key={i} className="flex gap-3 p-4 rounded-xl border border-gray-150 bg-white shadow-xs">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center border border-gray-150 shrink-0">
<Icon className="w-5 h-5 text-gray-500" />
</div>
<div>
<div className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">{title}</div>
<div className="font-semibold text-gray-900 text-sm mt-0.5">{value}</div>
<div className="text-xs text-gray-500 mt-0.5">{desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
</Card>
)}
@@ -6,6 +6,7 @@ export interface Family {
name: string;
description?: string;
category?: string;
categoryId?: string | null;
attributes: string[]; // List of attribute codes
attributeGroups?: number;
variantAxes: string[]; // List of attribute codes used as variant axes
@@ -14,6 +15,12 @@ export interface Family {
status: FamilyStatus;
lastUpdated: string;
createdBy: string;
channels?: string[];
assetRequirements?: string[];
completenessRules?: Record<string, number>;
workflowCode?: string;
attributeSetId?: string;
attribute_set_id?: string;
}
export type FamilyCreateRequest = Omit<Family, 'id' | 'lastUpdated' | 'createdBy'>;
@@ -8,5 +8,5 @@ export const familySchema = Yup.object().shape({
description: Yup.string(),
attributes: Yup.array().of(Yup.string()),
variantAxes: Yup.array().of(Yup.string()),
status: Yup.string().oneOf(['active', 'inactive']),
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
});
+9 -1
View File
@@ -1,4 +1,6 @@
export type ProductStatus = 'published' | 'pending' | 'draft' | 'incomplete';
import type { Attribute } from '../../attributes/types/attribute.types';
export type ProductStatus = 'active' | 'published' | 'pending' | 'draft' | 'disabled' | 'incomplete';
export interface Product {
id: string;
@@ -14,6 +16,12 @@ export interface Product {
channels: { active: number; total: number };
updatedAt: string;
updatedBy: string;
family?: {
id: string;
code: string;
name: string;
variantAxes?: Attribute[];
};
}
export type ProductCreateRequest = Omit<Product, 'id' | 'updatedAt' | 'updatedBy' | 'completeness' | 'channels'>;
+2 -2
View File
@@ -7,10 +7,10 @@ export const useVariant = () => {
const [variants, setVariants] = useState<Variant[]>([]);
const [loading, setLoading] = useState(false);
const fetchVariants = useCallback(async () => {
const fetchVariants = useCallback(async (params?: Record<string, any>) => {
setLoading(true);
try {
const data = await variantService.getAll();
const data = await variantService.getAll(params);
setVariants(data);
} catch (err) {
notify.error(err);
+297 -68
View File
@@ -6,17 +6,13 @@ import { Button } from '../../../components/customs/Button';
import { Radio, RadioGroup } from '../../../components/customs/Radio';
import { Select } from '../../../components/customs/Select';
import { variantService } from '../services/variant.service';
import { useProduct } from '../../product/hook/useProduct';
import { productService } from '../../product/services/product.service';
import { variantSchema } from '../validation/variant.schema';
import type { VariantStatus } from '../types/variant.types';
import { Save, Check } from 'lucide-react';
import { Save, Check, Loader2, AlertCircle } from 'lucide-react';
import { Breadcrumb } from '../../../components/layouts/Breadcrumb';
const PRODUCT_OPTIONS = [
{ id: '1', name: 'Wireless Headphones Pro' },
{ id: '2', name: 'Organic Cotton T-Shirt' },
{ id: '3', name: 'Stainless Steel Water Bottle' },
{ id: '4', name: 'Smart LED Desk Lamp' },
];
import { toast } from 'react-toastify';
const STEPS = [
{ id: 'basic', label: 'Basic Information', step: 1 },
@@ -26,7 +22,7 @@ const STEPS = [
const inputClass = (error?: boolean) =>
`w-full border ${error ? 'border-red-400 focus:ring-red-400' : 'border-primary/10 focus:ring-primary-light'} rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:border-transparent bg-white placeholder-gray-400`;
const labelClass = 'block text-sm font-medium text-gray-700 mb-1.5';
const labelClass = 'block text-sm font-semibold text-gray-700 mb-1.5';
const errorClass = 'text-xs text-red-500 mt-1';
function CardHeader({ title, subtitle }: { title: string; subtitle: string }) {
@@ -45,6 +41,11 @@ export default function NewVariant() {
const navigate = useNavigate();
const { id } = useParams<{ id?: string }>();
const isEdit = Boolean(id);
const { products, fetchProducts, loading: productsLoading } = useProduct();
const [parentProductDetail, setParentProductDetail] = useState<any>(null);
const [parentLoading, setParentLoading] = useState(false);
const [variantLoading, setVariantLoading] = useState(false);
const [activeStep, setActiveStep] = useState('basic');
const formik = useFormik({
@@ -55,9 +56,13 @@ export default function NewVariant() {
parentProductName: '',
status: 'draft' as VariantStatus,
price: '',
costPrice: '',
currency: 'USD',
stock: '',
colorAttr: '',
sizeAttr: '',
availableStock: '',
reservedStock: '',
safetyStock: '',
attributes: {} as Record<string, any>
},
validationSchema: variantSchema,
onSubmit: async (values, { setSubmitting }) => {
@@ -68,54 +73,110 @@ export default function NewVariant() {
parentProductName: values.parentProductName,
status: values.status,
price: parseFloat(values.price) || 0,
costPrice: parseFloat(values.costPrice) || 0,
currency: values.currency || 'USD',
stock: parseInt(values.stock) || 0,
attributes: {
...(values.colorAttr ? { color: values.colorAttr } : {}),
...(values.sizeAttr ? { size: values.sizeAttr } : {}),
},
availableStock: parseInt(values.availableStock) || (parseInt(values.stock) || 0),
reservedStock: parseInt(values.reservedStock) || 0,
safetyStock: parseInt(values.safetyStock) || 0,
attributes: values.attributes
};
try {
if (isEdit && id) {
await variantService.update(id, payload);
toast.success('Variant updated successfully!');
} else {
await variantService.create(payload);
toast.success('Variant created successfully!');
}
navigate('/variants');
} catch {
// handled inside service
} catch (err: any) {
const msg = err.response?.data?.message || err.message || 'Validation error';
toast.error(msg);
} finally {
setSubmitting(false);
}
},
});
// Fetch active products list on mount
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
// Load variant details in Edit mode
useEffect(() => {
if (!id) return;
variantService.getById(id).then((v) => {
if (v) {
formik.setValues({
sku: v.sku,
name: v.name,
parentProductId: v.parentProductId,
parentProductName: v.parentProductName,
status: v.status,
price: String(v.price),
stock: String(v.stock),
colorAttr: v.attributes.color || '',
sizeAttr: v.attributes.size || '',
});
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
setVariantLoading(true);
variantService.getById(id)
.then((v) => {
if (v) {
formik.setValues({
sku: v.sku,
name: v.name,
parentProductId: v.parentProductId || '',
parentProductName: v.parentProductName || '',
status: v.status,
price: String(v.price || ''),
costPrice: String(v.costPrice || ''),
currency: v.currency || 'USD',
stock: String(v.stock || ''),
availableStock: String(v.availableStock || ''),
reservedStock: String(v.reservedStock || ''),
safetyStock: String(v.safetyStock || ''),
attributes: v.attributes || {}
});
}
})
.catch((err) => {
toast.error(err.message || 'Failed to fetch variant details');
})
.finally(() => {
setVariantLoading(false);
});
}, [id]);
// Fetch parent product details when select changes to resolve family
useEffect(() => {
const parentId = formik.values.parentProductId;
if (parentId) {
setParentLoading(true);
productService.getById(parentId)
.then((prod) => {
setParentProductDetail(prod);
// Pre-populate values.attributes keys for wizard step 3
if (prod?.family?.variantAxes) {
const newAttrs = { ...formik.values.attributes };
for (const axis of prod.family.variantAxes) {
if (newAttrs[axis.code] === undefined) {
newAttrs[axis.code] = '';
}
}
formik.setFieldValue('attributes', newAttrs);
}
})
.catch((err) => {
console.error("Failed to load parent product info", err);
})
.finally(() => {
setParentLoading(false);
});
} else {
setParentProductDetail(null);
}
}, [formik.values.parentProductId]);
const handleProductChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const product = PRODUCT_OPTIONS.find((p) => p.id === e.target.value);
formik.setFieldValue('parentProductId', e.target.value);
const val = e.target.value;
const product = products.find((p) => p.id === val);
formik.setFieldValue('parentProductId', val);
formik.setFieldValue('parentProductName', product?.name || '');
};
const activeIndex = STEPS.findIndex(s => s.id === activeStep);
const activeProducts = products.filter(p => p.status === 'active');
const isLoading = productsLoading || variantLoading || parentLoading;
return (
<PageWrapper>
@@ -124,15 +185,25 @@ export default function NewVariant() {
backTo="/variants"
actions={
<>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/variants')} disabled={formik.isSubmitting}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="variant-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting}>
<Button variant="outline" size="md" type="button" onClick={() => navigate('/variants')} disabled={formik.isSubmitting || isLoading}>Cancel</Button>
<Button variant="primary" size="md" type="submit" form="variant-form" icon={<Save className="w-4 h-4" />} loading={formik.isSubmitting} disabled={isLoading}>
{isEdit ? 'Update Variant' : 'Create Variant'}
</Button>
</>
}
/>
<form id="variant-form" onSubmit={formik.handleSubmit} className="flex gap-5">
<form id="variant-form" onSubmit={formik.handleSubmit} className="flex gap-5 relative">
{/* Loading overlay spinner */}
{isLoading && (
<div className="absolute inset-0 bg-white/70 backdrop-blur-xs flex items-center justify-center z-50 rounded-lg">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
<p className="text-sm font-medium text-primary">Loading data...</p>
</div>
</div>
)}
{/* Timeline Sidebar */}
<aside className="w-52 shrink-0 self-start bg-white border border-primary/10 rounded-lg shadow-sm overflow-hidden">
@@ -194,7 +265,7 @@ export default function NewVariant() {
{/* Main Content + Buttons */}
<div className="flex-1 min-w-0 flex flex-col">
<div className="overflow-y-auto">
<div className="overflow-y-auto min-h-[420px]">
{/* Step 1 — Basic Information */}
{activeStep === 'basic' && (
@@ -209,8 +280,9 @@ export default function NewVariant() {
value={formik.values.sku}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
disabled={isEdit}
placeholder="e.g. WH-PRO-BLK-M"
className={inputClass(formik.touched.sku && Boolean(formik.errors.sku))}
className={`${inputClass(formik.touched.sku && Boolean(formik.errors.sku))} ${isEdit ? 'bg-gray-100 text-gray-500 cursor-not-allowed border-gray-200' : ''}`}
/>
{formik.touched.sku && formik.errors.sku && <p className={errorClass}>{formik.errors.sku}</p>}
</div>
@@ -236,15 +308,43 @@ export default function NewVariant() {
onChange={handleProductChange}
onBlur={formik.handleBlur}
error={formik.touched.parentProductId && Boolean(formik.errors.parentProductId)}
disabled={isEdit}
>
<option value="">Select product</option>
{PRODUCT_OPTIONS.map((p) => (
{activeProducts.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</Select>
{formik.touched.parentProductId && formik.errors.parentProductId && (
<p className={errorClass}>{formik.errors.parentProductId}</p>
)}
{/* Eagerly loaded parent product details */}
{parentProductDetail && (
<div className="mt-4 p-4 rounded-lg bg-primary/5/30 border border-primary/10 grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-gray-400 block font-semibold uppercase tracking-wider">Family</span>
<span className="font-semibold text-gray-900">{parentProductDetail.family?.name || 'Not assigned'}</span>
</div>
<div>
<span className="text-gray-400 block font-semibold uppercase tracking-wider">Category</span>
<span className="font-semibold text-gray-900">{parentProductDetail.family?.category?.name || 'Not assigned'}</span>
</div>
<div>
<span className="text-gray-400 block font-semibold uppercase tracking-wider">Workflow</span>
<span className="font-semibold text-gray-900">{parentProductDetail.family?.workflow_code || 'Standard'}</span>
</div>
<div>
<span className="text-gray-400 block font-semibold uppercase tracking-wider">Variant Strategy Axes</span>
<span className="font-semibold text-primary">
{parentProductDetail.family?.variantAxes && parentProductDetail.family.variantAxes.length > 0
? parentProductDetail.family.variantAxes.map((axis: any) => axis.name).join(', ')
: 'None'
}
</span>
</div>
</div>
)}
</div>
<div>
@@ -257,7 +357,7 @@ export default function NewVariant() {
value={s}
checked={formik.values.status === s}
onChange={formik.handleChange}
label={s}
label={s.charAt(0).toUpperCase() + s.slice(1)}
/>
))}
</RadioGroup>
@@ -288,7 +388,7 @@ export default function NewVariant() {
{formik.touched.price && formik.errors.price && <p className={errorClass}>{formik.errors.price}</p>}
</div>
<div>
<label className={labelClass}>Stock Quantity</label>
<label className={labelClass}>Stock Quantity <span className="text-red-400">*</span></label>
<input
name="stock"
type="number"
@@ -301,40 +401,169 @@ export default function NewVariant() {
/>
{formik.touched.stock && formik.errors.stock && <p className={errorClass}>{formik.errors.stock}</p>}
</div>
<div>
<label className={labelClass}>Cost Price ($)</label>
<input
name="costPrice"
type="number"
min="0"
step="0.01"
value={formik.values.costPrice}
onChange={formik.handleChange}
placeholder="0.00"
className={inputClass()}
/>
</div>
<div>
<label className={labelClass}>Currency</label>
<Select
name="currency"
value={formik.values.currency}
onChange={formik.handleChange}
>
<option value="USD">USD ($)</option>
<option value="EUR">EUR ()</option>
<option value="GBP">GBP (£)</option>
<option value="JPY">JPY (¥)</option>
</Select>
</div>
<div>
<label className={labelClass}>Available Stock</label>
<input
name="availableStock"
type="number"
min="0"
value={formik.values.availableStock}
onChange={formik.handleChange}
placeholder="0"
className={inputClass()}
/>
</div>
<div>
<label className={labelClass}>Reserved Stock</label>
<input
name="reservedStock"
type="number"
min="0"
value={formik.values.reservedStock}
onChange={formik.handleChange}
placeholder="0"
className={inputClass()}
/>
</div>
<div className="col-span-2">
<label className={labelClass}>Safety Stock</label>
<input
name="safetyStock"
type="number"
min="0"
value={formik.values.safetyStock}
onChange={formik.handleChange}
placeholder="0"
className={inputClass()}
/>
</div>
</div>
</div>
</div>
)}
{/* Step 3 — Attributes */}
{/* Step 3 — Attributes (Dynamic Inputs) */}
{activeStep === 'attrs' && (
<div className="bg-white rounded-lg border border-primary/10 shadow-sm overflow-hidden">
<CardHeader title="Attributes" subtitle="Define the variant-specific attribute values" />
<CardHeader title="Variant Attributes" subtitle="Enter variant-specific attribute values mapped from the product family" />
<div className="p-6 space-y-6">
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>Color</label>
<input
name="colorAttr"
value={formik.values.colorAttr}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. Black"
className={inputClass()}
/>
{parentProductDetail?.family?.variantAxes && parentProductDetail.family.variantAxes.length > 0 ? (
<div className="grid grid-cols-2 gap-6">
{parentProductDetail.family.variantAxes.map((axis: any) => {
const val = formik.values.attributes[axis.code] ?? '';
const onChange = (v: any) => {
formik.setFieldValue(`attributes.${axis.code}`, v);
};
const type = (axis.type || 'text').toLowerCase();
return (
<div key={axis.id} className="space-y-1.5 col-span-1">
<label className={labelClass}>
{axis.name || axis.code} {axis.is_required && <span className="text-red-400">*</span>}
</label>
{type === 'boolean' ? (
<label className="flex items-center gap-3 cursor-pointer p-3 border rounded-lg hover:bg-gray-50 border-gray-100">
<input
type="checkbox"
checked={Boolean(val)}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light"
/>
<span className="text-sm font-semibold text-gray-700">Enable {axis.name}</span>
</label>
) : type === 'select' || type === 'dropdown' ? (
<Select
value={val}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Select option</option>
{axis.options && Array.isArray(axis.options) && axis.options.map((opt: any) => {
const keyVal = typeof opt === 'string' ? opt : (opt.value || opt.code || '');
const labelVal = typeof opt === 'string' ? opt : (opt.label || opt.value || opt.code || '');
return <option key={keyVal} value={keyVal}>{labelVal}</option>;
})}
</Select>
) : type === 'number' ? (
<input
type="number"
value={val}
onChange={(e) => onChange(e.target.value)}
placeholder="0"
className={inputClass()}
/>
) : type === 'date' ? (
<input
type="date"
value={val}
onChange={(e) => onChange(e.target.value)}
className={inputClass()}
/>
) : type === 'color' ? (
<div className="flex items-center gap-3">
<input
type="color"
value={val || '#ffffff'}
onChange={(e) => onChange(e.target.value)}
className="w-10 h-10 border border-gray-200 rounded cursor-pointer shrink-0"
/>
<input
type="text"
value={val}
onChange={(e) => onChange(e.target.value)}
placeholder="#FFFFFF"
className={inputClass()}
/>
</div>
) : (
// Text fallback
<input
type="text"
value={val}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter ${axis.name}`}
className={inputClass()}
/>
)}
</div>
);
})}
</div>
<div>
<label className={labelClass}>Size</label>
<input
name="sizeAttr"
value={formik.values.sizeAttr}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
placeholder="e.g. M or 500ml"
className={inputClass()}
/>
) : (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center border border-dashed border-gray-200 rounded-lg bg-gray-50/50">
<AlertCircle className="w-8 h-8 text-amber-500 mb-2" />
<p className="text-sm font-semibold text-gray-700">No active variant axes found.</p>
<p className="text-xs text-gray-400 mt-1 max-w-sm">
Please go back to Step 1 and select a parent product whose Product Family has active Variant Axes configured.
</p>
</div>
</div>
)}
</div>
</div>
)}
@@ -342,7 +571,7 @@ export default function NewVariant() {
</div>
{/* Bottom navigation */}
<div className="shrink-0 pt-2 flex justify-end gap-2">
<div className="shrink-0 pt-4 flex justify-end gap-2 border-t border-primary/5 mt-4">
{activeIndex > 0 && (
<button type="button" onClick={() => setActiveStep(STEPS[activeIndex - 1].id)} className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors">Back</button>
)}
@@ -46,6 +46,12 @@ export default function VariantList() {
{ key: "sku", label: "SKU", sortable: true },
{ key: "name", label: "Variant Name", sortable: true },
{ key: "parentProductName", label: "Product", sortable: true },
{
key: "family",
label: "Family",
sortable: true,
render: (_val: any, row: any) => row.parentProductFamily?.name || '-'
},
{ key: "price", label: "Price", sortable: true, render: (val: any) => `$${val?.toFixed(2) || '0.00'}` },
{ key: "stock", label: "Stock", sortable: true },
{
@@ -1,73 +1,47 @@
import type { Variant, VariantCreateRequest, VariantUpdateRequest } from '../types/variant.types';
import apiClient from '../../../api/axiosInstance';
const STORAGE_KEY = 'pim_variants';
const DEFAULT_VARIANTS: Variant[] = [
{ id: '1', sku: 'WH-PRO-BLK-M', parentProductId: '1', parentProductName: 'Wireless Headphones Pro', name: 'Black / Medium', attributes: { color: 'Black', size: 'M' }, status: 'active', stock: 320, price: 89.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
{ id: '2', sku: 'WH-PRO-WHT-M', parentProductId: '1', parentProductName: 'Wireless Headphones Pro', name: 'White / Medium', attributes: { color: 'White', size: 'M' }, status: 'active', stock: 280, price: 89.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
{ id: '3', sku: 'TS-ORG-BLU-S', parentProductId: '2', parentProductName: 'Organic Cotton T-Shirt', name: 'Blue / Small', attributes: { color: 'Blue', size: 'S' }, status: 'active', stock: 150, price: 24.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
{ id: '4', sku: 'TS-ORG-BLU-M', parentProductId: '2', parentProductName: 'Organic Cotton T-Shirt', name: 'Blue / Medium', attributes: { color: 'Blue', size: 'M' }, status: 'active', stock: 200, price: 24.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
{ id: '5', sku: 'TS-ORG-RED-L', parentProductId: '2', parentProductName: 'Organic Cotton T-Shirt', name: 'Red / Large', attributes: { color: 'Red', size: 'L' }, status: 'draft', stock: 0, price: 24.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
{ id: '6', sku: 'WB-SS-500ML', parentProductId: '3', parentProductName: 'Stainless Steel Water Bottle', name: '500ml', attributes: { size: '500ml' }, status: 'active', stock: 1600, price: 19.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
{ id: '7', sku: 'WB-SS-1L', parentProductId: '3', parentProductName: 'Stainless Steel Water Bottle', name: '1 Liter', attributes: { size: '1L' }, status: 'active', stock: 1600, price: 24.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
{ id: '8', sku: 'LAMP-SM-WHT', parentProductId: '4', parentProductName: 'Smart LED Desk Lamp', name: 'White', attributes: { color: 'White' }, status: 'disabled', stock: 0, price: 49.99, lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
];
const getStored = (): Variant[] => {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_VARIANTS));
return DEFAULT_VARIANTS;
}
return JSON.parse(stored);
};
const setStored = (variants: Variant[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(variants));
};
interface ApiResponse<T> {
success: boolean;
message?: string;
data: T;
pagination?: any;
timestamp?: string;
}
export const variantService = {
getAll: async (): Promise<Variant[]> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
getAll: async (params?: Record<string, any>): Promise<Variant[]> => {
const res = await apiClient.get<ApiResponse<Variant[]>>('/api/v1/variants', { params });
return (res as any).data || [];
},
getById: async (id: string): Promise<Variant | undefined> => {
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(v => v.id === id)), 200));
const res = await apiClient.get<ApiResponse<Variant>>(`/api/v1/variants/${id}`);
return (res as any).data;
},
create: async (req: VariantCreateRequest): Promise<Variant> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored();
const newVariant: Variant = { ...req, id: String(Date.now()), lastUpdated: new Date().toISOString(), createdBy: 'Admin' };
list.push(newVariant);
setStored(list);
resolve(newVariant);
}, 300);
});
const res = await apiClient.post<ApiResponse<Variant>>('/api/v1/variants', req);
return (res as any).data;
},
update: async (id: string, req: VariantUpdateRequest): Promise<Variant> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const list = getStored();
const index = list.findIndex(v => v.id === id);
if (index === -1) { reject(new Error('Variant not found')); return; }
const updated: Variant = { ...list[index], ...req, lastUpdated: new Date().toISOString() };
list[index] = updated;
setStored(list);
resolve(updated);
}, 300);
});
const res = await apiClient.put<ApiResponse<Variant>>(`/api/v1/variants/${id}`, req);
return (res as any).data;
},
delete: async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
setTimeout(() => {
const list = getStored().filter(v => v.id !== id);
setStored(list);
resolve(true);
}, 300);
});
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/variants/${id}`);
return (res as any).success;
},
archive: async (id: string): Promise<boolean> => {
const res = await apiClient.post<ApiResponse<any>>(`/api/v1/variants/${id}/archive`);
return (res as any).success;
},
restore: async (id: string): Promise<Variant> => {
const res = await apiClient.post<ApiResponse<Variant>>(`/api/v1/variants/${id}/restore`);
return (res as any).data;
}
};
@@ -10,6 +10,11 @@ export interface Variant {
status: VariantStatus;
stock: number;
price: number;
costPrice?: number;
currency?: string;
availableStock?: number;
reservedStock?: number;
safetyStock?: number;
lastUpdated: string;
createdBy: string;
}
@@ -10,7 +10,9 @@ export const variantSchema = Yup.object().shape({
.min(0, 'Price must be a non-negative number'),
stock: Yup.number()
.transform((value, originalValue) => originalValue === '' ? undefined : value)
.nullable()
.optional()
.required('Stock is required')
.min(0, 'Stock cannot be negative'),
status: Yup.string()
.oneOf(['draft', 'active', 'disabled', 'published'])
.required('Status is required')
});
+7 -2
View File
@@ -17,6 +17,8 @@ import { NotificationsTab } from "../components/NotificationsTab";
import { AuditUsageTab } from "../components/AuditUsageTab";
import { WorkflowOverview } from "../components/WorkflowOverview";
import { toast } from "react-toastify";
export default function NewWorkflow() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
@@ -36,12 +38,15 @@ export default function NewWorkflow() {
try {
if (isEdit && id) {
await workflowService.update(id, values as any);
toast.success("Workflow updated successfully!");
} else {
await workflowService.create(values as any);
toast.success("Workflow created successfully!");
}
navigate('/workflows');
} catch {
// handled by service
} catch (err: any) {
const msg = err.response?.data?.message || err.message || "Failed to save workflow";
toast.error(msg);
} finally {
setSubmitting(false);
}
@@ -1,7 +1,8 @@
export interface Workflow {
id: string;
code: string;
name: string;
status: 'active' | 'inactive';
status: 'draft' | 'active' | 'inactive';
createdAt: string;
}
export type WorkflowCreateRequest = Omit<Workflow, 'id' | 'createdAt'>;