solve megre conflicts
This commit is contained in:
+3
-1
@@ -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"
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,5 +63,5 @@ export const useAsset = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem, replaceAsset, archiveAsset, restoreAsset };
|
||||
};
|
||||
|
||||
+1523
-315
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 || [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
Reference in New Issue
Block a user