solve merge conficltes

This commit is contained in:
MohamedHasan07
2026-07-23 12:12:07 +05:30
54 changed files with 2177 additions and 1321 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ const API_BASE_URL = 'http://localhost:5000';
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
+6 -1
View File
@@ -192,7 +192,7 @@ export function DataTable<T extends Record<string, any> = any>({
className="w-full pl-10 pr-4 h-9 text-sm bg-surface border border-border text-foreground placeholder:text-muted-foreground rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
{search && (
<button onClick={() => handleSearch("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<button type="button" onClick={() => handleSearch("")} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X className="w-4 h-4" />
</button>
)}
@@ -205,6 +205,7 @@ export function DataTable<T extends Record<string, any> = any>({
{/* Status Filter */}
<div className="relative" ref={statusRef}>
<button
type="button"
onClick={() => setShowStatusMenu(!showStatusMenu)}
className="flex items-center gap-2 px-4 py-2 text-sm border border-border text-foreground rounded-lg hover:bg-surface-muted hover:border-primary/20 transition-colors"
>
@@ -250,6 +251,7 @@ export function DataTable<T extends Record<string, any> = any>({
{/* Columns Menu */}
<div className="relative" ref={columnRef}>
<button
type="button"
onClick={() => setShowColumnMenu(!showColumnMenu)}
className="flex items-center gap-2 px-4 py-2 text-sm border border-border text-foreground rounded-lg hover:bg-surface-muted hover:border-primary/20 transition-colors"
>
@@ -424,6 +426,7 @@ export function DataTable<T extends Record<string, any> = any>({
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="w-9 h-9 flex items-center justify-center rounded-lg border border-border text-foreground hover:bg-surface-muted disabled:opacity-40"
@@ -432,6 +435,7 @@ export function DataTable<T extends Record<string, any> = any>({
</button>
{Array.from({ length: Math.min(7, totalPages) }, (_, i) => i + 1).map(p => (
<button
type="button"
key={p}
onClick={() => setPage(p)}
className={`w-9 h-9 rounded-lg border font-medium ${p === page ? "bg-primary text-white border-primary" : "border-border text-foreground hover:bg-primary/5"}`}
@@ -440,6 +444,7 @@ export function DataTable<T extends Record<string, any> = any>({
</button>
))}
<button
type="button"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="w-9 h-9 flex items-center justify-center rounded-lg border border-border text-foreground hover:bg-surface-muted disabled:opacity-40"
@@ -1,2 +1,39 @@
import { assetFamiliesService } from '../services/asset-families.service';
export const assetFamiliesApi = assetFamiliesService;
import apiClient from '../../../api/axiosInstance';
import type { AssetFamily, AssetFamilyCreateRequest, AssetFamilyUpdateRequest } from '../types/asset-families.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/asset-families';
export const assetFamiliesApi = {
getAll: async (): Promise<AssetFamily[]> => {
const res = await apiClient.get<ApiResponse<AssetFamily[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<AssetFamily | undefined> => {
const res = await apiClient.get<ApiResponse<AssetFamily>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: AssetFamilyCreateRequest): Promise<AssetFamily> => {
const res = await apiClient.post<ApiResponse<AssetFamily>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: AssetFamilyUpdateRequest): Promise<AssetFamily> => {
const res = await apiClient.put<ApiResponse<AssetFamily>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default assetFamiliesApi;
@@ -1,36 +1,10 @@
import type { AssetFamily, AssetFamilyCreateRequest, AssetFamilyUpdateRequest } from '../types/asset-families.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { assetFamiliesApi } from '../api/asset-families.api';
import type { AssetFamilyCreateRequest, AssetFamilyUpdateRequest } from '../types/asset-families.types';
export const assetFamiliesService = {
getAll: async (): Promise<AssetFamily[]> => {
const res = await apiClient.get<ApiResponse<AssetFamily[]>>('/api/v1/asset-families');
return res.data || [];
},
getById: async (id: string): Promise<AssetFamily | undefined> => {
const res = await apiClient.get<ApiResponse<AssetFamily>>(`/api/v1/asset-families/${id}`);
return res.data;
},
create: async (req: AssetFamilyCreateRequest): Promise<AssetFamily> => {
const res = await apiClient.post<ApiResponse<AssetFamily>>('/api/v1/asset-families', req);
return res.data;
},
update: async (id: string, req: AssetFamilyUpdateRequest): Promise<AssetFamily> => {
const res = await apiClient.put<ApiResponse<AssetFamily>>(`/api/v1/asset-families/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/asset-families/${id}`);
return res.success;
},
getAll: () => assetFamiliesApi.getAll(),
getById: (id: string) => assetFamiliesApi.getById(id),
create: (req: AssetFamilyCreateRequest) => assetFamiliesApi.create(req),
update: (id: string, req: AssetFamilyUpdateRequest) => assetFamiliesApi.update(id, req),
delete: (id: string) => assetFamiliesApi.remove(id),
};
@@ -1,2 +1,39 @@
import { assetTypesService } from '../services/asset-types.service';
export const assetTypesApi = assetTypesService;
import apiClient from '../../../api/axiosInstance';
import type { AssetType, AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/asset-types';
export const assetTypesApi = {
getAll: async (): Promise<AssetType[]> => {
const res = await apiClient.get<ApiResponse<AssetType[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<AssetType | undefined> => {
const res = await apiClient.get<ApiResponse<AssetType>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: AssetTypeCreateRequest): Promise<AssetType> => {
const res = await apiClient.post<ApiResponse<AssetType>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: AssetTypeUpdateRequest): Promise<AssetType> => {
const res = await apiClient.put<ApiResponse<AssetType>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default assetTypesApi;
@@ -1,36 +1,10 @@
import type { AssetType, AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { assetTypesApi } from '../api/asset-types.api';
import type { AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
export const assetTypesService = {
getAll: async (): Promise<AssetType[]> => {
const res = await apiClient.get<ApiResponse<AssetType[]>>('/api/v1/asset-types');
return res.data || [];
},
getById: async (id: string): Promise<AssetType | undefined> => {
const res = await apiClient.get<ApiResponse<AssetType>>(`/api/v1/asset-types/${id}`);
return res.data;
},
create: async (req: AssetTypeCreateRequest): Promise<AssetType> => {
const res = await apiClient.post<ApiResponse<AssetType>>('/api/v1/asset-types', req);
return res.data;
},
update: async (id: string, req: AssetTypeUpdateRequest): Promise<AssetType> => {
const res = await apiClient.put<ApiResponse<AssetType>>(`/api/v1/asset-types/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/asset-types/${id}`);
return res.success;
},
getAll: () => assetTypesApi.getAll(),
getById: (id: string) => assetTypesApi.getById(id),
create: (req: AssetTypeCreateRequest) => assetTypesApi.create(req),
update: (id: string, req: AssetTypeUpdateRequest) => assetTypesApi.update(id, req),
delete: (id: string) => assetTypesApi.remove(id),
};
+135 -2
View File
@@ -1,2 +1,135 @@
import { assetsService } from '../services/assets.service';
export const assetsApi = assetsService;
import apiClient from '../../../api/axiosInstance';
import type { Asset, AssetCreateRequest, AssetUpdateRequest } from '../types/assets.types';
import type { AssetRelationInfo, AssetAnalytics, AssetMapping } from '../services/assets.service';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/assets';
export const assetsApi = {
getAll: async (params?: Record<string, string>): Promise<Asset[]> => {
const res = await apiClient.get<ApiResponse<Asset[]>>(BASE_URL, { params });
return res.data || [];
},
getById: async (id: string): Promise<Asset | undefined> => {
const res = await apiClient.get<ApiResponse<Asset>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: AssetCreateRequest): Promise<Asset> => {
const res = await apiClient.post<ApiResponse<Asset>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: AssetUpdateRequest): Promise<Asset> => {
const res = await apiClient.put<ApiResponse<Asset>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${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 }>>(
`${BASE_URL}/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>>(`${BASE_URL}/${id}/replace`, formData);
return res.data;
},
archive: async (id: string): Promise<Asset> => {
const res = await apiClient.post<ApiResponse<Asset>>(`${BASE_URL}/${id}/archive`);
return res.data;
},
restore: async (id: string): Promise<Asset> => {
const res = await apiClient.post<ApiResponse<Asset>>(`${BASE_URL}/${id}/restore`);
return res.data;
},
getRelations: async (id: string): Promise<AssetRelationInfo> => {
const res = await apiClient.get<ApiResponse<AssetRelationInfo>>(`${BASE_URL}/${id}/relations`);
return res.data;
},
getAnalytics: async (): Promise<AssetAnalytics> => {
const res = await apiClient.get<ApiResponse<AssetAnalytics>>(`${BASE_URL}/analytics`);
return res.data;
},
getFolders: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/folders`);
return res.data || [];
},
getTags: async (): Promise<any[]> => {
const res = await apiClient.get<ApiResponse<any[]>>(`${BASE_URL}/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 || [];
}
};
export default assetsApi;
+23 -126
View File
@@ -1,11 +1,5 @@
import { assetsApi } from '../api/assets.api';
import type { Asset, AssetCreateRequest, AssetUpdateRequest } from '../types/assets.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface AssetRelationInfo {
products: { id: string; name: string; code: string; role: string }[];
@@ -41,123 +35,26 @@ export interface AssetMapping {
}
export const assetsService = {
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/assets/${id}`);
return res.data;
},
create: async (req: AssetCreateRequest): Promise<Asset> => {
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/assets/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
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 || [];
}
getAll: (params?: Record<string, string>) => assetsApi.getAll(params),
getById: (id: string) => assetsApi.getById(id),
create: (req: AssetCreateRequest) => assetsApi.create(req),
update: (id: string, req: AssetUpdateRequest) => assetsApi.update(id, req),
delete: (id: string) => assetsApi.remove(id),
upload: (file: File) => assetsApi.upload(file),
replace: (id: string, file: File) => assetsApi.replace(id, file),
archive: (id: string) => assetsApi.archive(id),
restore: (id: string) => assetsApi.restore(id),
getRelations: (id: string) => assetsApi.getRelations(id),
getAnalytics: () => assetsApi.getAnalytics(),
getFolders: () => assetsApi.getFolders(),
getTags: () => assetsApi.getTags(),
getProductAssets: (productId: string) => assetsApi.getProductAssets(productId),
assignProductAsset: (productId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignProductAsset(productId, body),
updateProductAsset: (productId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateProductAsset(productId, assetId, body),
unassignProductAsset: (productId: string, assetId: string) => assetsApi.unassignProductAsset(productId, assetId),
getVariantAssets: (variantId: string) => assetsApi.getVariantAssets(variantId),
assignVariantAsset: (variantId: string, body: { asset_id: string; role: string; display_order?: number; is_primary?: boolean }) => assetsApi.assignVariantAsset(variantId, body),
updateVariantAsset: (variantId: string, assetId: string, body: { role?: string; display_order?: number; is_primary?: boolean }) => assetsApi.updateVariantAsset(variantId, assetId, body),
unassignVariantAsset: (variantId: string, assetId: string) => assetsApi.unassignVariantAsset(variantId, assetId),
getProductVariants: (productId: string) => assetsApi.getProductVariants(productId),
};
@@ -1,2 +1,39 @@
import { attributeGroupsService } from '../services/attribute-groups.service';
export const attributeGroupsApi = attributeGroupsService;
import apiClient from '../../../api/axiosInstance';
import type { AttributeGroup, AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/attribute-groups';
export const attributeGroupsApi = {
getAll: async (params?: Record<string, any>): Promise<AttributeGroup[]> => {
const res = await apiClient.get<ApiResponse<AttributeGroup[]>>(BASE_URL, { params });
return res.data || [];
},
getById: async (id: string): Promise<AttributeGroup | undefined> => {
const res = await apiClient.get<ApiResponse<AttributeGroup>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: AttributeGroupCreateRequest): Promise<AttributeGroup> => {
const res = await apiClient.post<ApiResponse<AttributeGroup>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: AttributeGroupUpdateRequest): Promise<AttributeGroup> => {
const res = await apiClient.put<ApiResponse<AttributeGroup>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default attributeGroupsApi;
+4 -1
View File
@@ -1,4 +1,7 @@
export { default as AttributeGroupList } from './pages/AttributeGroupList';
export { default as NewAttributeGroup } from './pages/NewAttributeGroup';
export { AttributeGroupRoutes } from './routes/attribute-groups.routes';
export * from './types/attribute-groups.types';
export * from './services/attribute-groups.service';
export * from './api/attribute-groups.api';
export * from './hook/useAttributeGroup';
export * from './routes/attribute-groups.routes';
@@ -1,35 +1,10 @@
import type { AttributeGroup, AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { attributeGroupsApi } from '../api/attribute-groups.api';
import type { AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
export const attributeGroupsService = {
getAll: async (params?: Record<string, any>): Promise<AttributeGroup[]> => {
const res = await apiClient.get<ApiResponse<AttributeGroup[]>>('/api/v1/attribute-groups', { params });
return res.data || [];
},
getById: async (id: string): Promise<AttributeGroup | undefined> => {
const res = await apiClient.get<ApiResponse<AttributeGroup>>(`/api/v1/attribute-groups/${id}`);
return res.data;
},
create: async (req: AttributeGroupCreateRequest): Promise<AttributeGroup> => {
const res = await apiClient.post<ApiResponse<AttributeGroup>>('/api/v1/attribute-groups', req);
return res.data;
},
update: async (id: string, req: AttributeGroupUpdateRequest): Promise<AttributeGroup> => {
const res = await apiClient.put<ApiResponse<AttributeGroup>>(`/api/v1/attribute-groups/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/attribute-groups/${id}`);
return res.success;
},
getAll: (params?: Record<string, any>) => attributeGroupsApi.getAll(params),
getById: (id: string) => attributeGroupsApi.getById(id),
create: (req: AttributeGroupCreateRequest) => attributeGroupsApi.create(req),
update: (id: string, req: AttributeGroupUpdateRequest) => attributeGroupsApi.update(id, req),
delete: (id: string) => attributeGroupsApi.remove(id),
};
@@ -0,0 +1,39 @@
import apiClient from '../../../api/axiosInstance';
import type { AttributeSet, AttributeSetCreateRequest, AttributeSetUpdateRequest } from '../types/attribute-sets.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/attribute-sets';
export const attributeSetsApi = {
getAll: async (): Promise<AttributeSet[]> => {
const res = await apiClient.get<ApiResponse<AttributeSet[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<AttributeSet | undefined> => {
const res = await apiClient.get<ApiResponse<AttributeSet>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: AttributeSetCreateRequest): Promise<AttributeSet> => {
const res = await apiClient.post<ApiResponse<AttributeSet>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: AttributeSetUpdateRequest): Promise<AttributeSet> => {
const res = await apiClient.put<ApiResponse<AttributeSet>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default attributeSetsApi;
+5
View File
@@ -0,0 +1,5 @@
export * from './types/attribute-sets.types';
export * from './services/attribute-sets.service';
export * from './api/attribute-sets.api';
export * from './hook/useAttributeSet';
export * from './routes/attribute-sets.routes';
@@ -44,11 +44,40 @@ export default function NewAttributeSet() {
const isView = pathname.includes('/view');
const isEdit = Boolean(id) && !isView;
const { createItem, updateItem, items, fetchItems } = useAttributeSet();
const { items: attributeGroups, fetchItems: fetchGroups } = useAttributeGroup();
const { items: attributeGroups, fetchItems: fetchGroups, createItem: createGroupItem } = useAttributeGroup();
const [activeStep, setActiveStep] = useState("basic");
const [selectedGroups, setSelectedGroups] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [newGroupCode, setNewGroupCode] = useState("");
const [newGroupDesc, setNewGroupDesc] = useState("");
const [modalSaving, setModalSaving] = useState(false);
const handleCreateGroup = async () => {
if (!newGroupName.trim() || !newGroupCode.trim()) return;
setModalSaving(true);
try {
const created = await createGroupItem({
name: newGroupName.trim(),
code: newGroupCode.trim(),
description: newGroupDesc.trim(),
status: "active"
});
if (created) {
setSelectedGroups((prev) => [...prev, created]);
setShowCreateModal(false);
setNewGroupName("");
setNewGroupCode("");
setNewGroupDesc("");
}
} catch {
// Handled inside hook
} finally {
setModalSaving(false);
}
};
const formik = useFormik({
initialValues: { code: "", name: "", description: "", status: "draft" },
@@ -269,40 +298,81 @@ export default function NewAttributeSet() {
<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 className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Available Groups */}
<div className="lg:col-span-2 space-y-4">
<div className="flex gap-3">
<input
type="text"
placeholder="Search attribute groups..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={`${inputClass()} flex-1`}
/>
<Button
type="button"
variant="outline"
icon={<Plus className="w-4 h-4" />}
onClick={() => setShowCreateModal(true)}
>
Create Group
</Button>
</div>
<div className="border border-primary/10 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
{filteredGroups.length > 0 ? (
filteredGroups.map((g) => (
<div key={g.id} className="flex items-center justify-between px-4 py-3 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)}
>
{selectedGroups.some((group) => group.id === g.id) ? "Added" : "Add"}
</Button>
</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 className="p-12 text-center text-gray-500 text-sm">No attribute groups found</div>
)}
</div>
</div>
{/* Right: Selected Groups */}
<div className="border-l border-gray-100 pl-6 space-y-4">
<h4 className="font-semibold text-gray-900 text-sm">Selected Groups ({selectedGroups.length})</h4>
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
{selectedGroups.length > 0 ? (
selectedGroups.map((g) => (
<div key={g.id} className="flex items-center justify-between bg-primary/5 border border-primary/10 rounded-lg px-3 py-2">
<div className="min-w-0">
<div className="font-medium text-xs text-gray-900 truncate">{g.name}</div>
<div className="text-[10px] text-gray-400 font-mono truncate">{g.code}</div>
</div>
<button
type="button"
onClick={() => removeGroup(g.id)}
className="text-xs text-red-500 hover:text-red-700 font-medium shrink-0 ml-2"
>
Remove
</button>
</div>
))
) : (
<p className="text-xs text-gray-400 italic py-4">No groups selected yet.</p>
)}
</div>
</div>
</div>
</div>
</div>
@@ -388,6 +458,68 @@ export default function NewAttributeSet() {
</div>
</form>
{/* Create Group Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-xs flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 overflow-hidden border border-gray-100 animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50">
<h3 className="font-bold text-gray-900 text-sm">Create Attribute Group</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Group Name *</label>
<input
type="text"
placeholder="e.g. Technical Specifications"
value={newGroupName}
onChange={(e) => {
setNewGroupName(e.target.value);
setNewGroupCode(generateCodeFromName(e.target.value));
}}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent bg-white"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Group Code *</label>
<input
type="text"
placeholder="e.g. technical_specs"
value={newGroupCode}
onChange={(e) => setNewGroupCode(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent bg-white font-mono"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-1">Description</label>
<textarea
placeholder="Enter group description..."
value={newGroupDesc}
onChange={(e) => setNewGroupDesc(e.target.value)}
rows={3}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent bg-white resize-none"
/>
</div>
</div>
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 border border-gray-200 rounded-lg text-xs font-semibold text-gray-600 hover:bg-gray-100 transition-colors"
>
Cancel
</button>
<button
type="button"
disabled={!newGroupName.trim() || !newGroupCode.trim() || modalSaving}
onClick={handleCreateGroup}
className="px-4 py-2 bg-primary hover:bg-primary-hover disabled:opacity-50 text-white text-xs font-semibold rounded-lg transition-colors flex items-center gap-1.5"
>
{modalSaving ? "Saving..." : "Create Group"}
</button>
</div>
</div>
</div>
)}
</PageWrapper>
</ProtectedRoute>
);
@@ -1,35 +1,10 @@
import type { AttributeSet, AttributeSetCreateRequest, AttributeSetUpdateRequest } from '../types/attribute-sets.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { attributeSetsApi } from '../api/attribute-sets.api';
import type { AttributeSetCreateRequest, AttributeSetUpdateRequest } from '../types/attribute-sets.types';
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;
},
getAll: () => attributeSetsApi.getAll(),
getById: (id: string) => attributeSetsApi.getById(id),
create: (req: AttributeSetCreateRequest) => attributeSetsApi.create(req),
update: (id: string, req: AttributeSetUpdateRequest) => attributeSetsApi.update(id, req),
delete: (id: string) => attributeSetsApi.remove(id),
};
+38 -2
View File
@@ -1,3 +1,39 @@
import { attributeService } from '../services/attribute.service';
import apiClient from '../../../api/axiosInstance';
import type { Attribute, AttributeCreateRequest, AttributeUpdateRequest } from '../types/attribute.types';
export const attributeApi = attributeService;
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/attributes';
export const attributeApi = {
getAll: async (): Promise<Attribute[]> => {
const res = await apiClient.get<ApiResponse<Attribute[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<Attribute | undefined> => {
const res = await apiClient.get<ApiResponse<Attribute>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: AttributeCreateRequest): Promise<Attribute> => {
const res = await apiClient.post<ApiResponse<Attribute>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: AttributeUpdateRequest): Promise<Attribute> => {
const res = await apiClient.put<ApiResponse<Attribute>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default attributeApi;
@@ -1,36 +1,10 @@
import type { Attribute, AttributeCreateRequest, AttributeUpdateRequest } from '../types/attribute.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { attributeApi } from '../api/attribute.api';
import type { AttributeCreateRequest, AttributeUpdateRequest } from '../types/attribute.types';
export const attributeService = {
getAll: async (): Promise<Attribute[]> => {
const res = await apiClient.get<ApiResponse<Attribute[]>>('/api/v1/attributes');
return res.data || [];
},
getById: async (id: string): Promise<Attribute | undefined> => {
const res = await apiClient.get<ApiResponse<Attribute>>(`/api/v1/attributes/${id}`);
return res.data;
},
create: async (req: AttributeCreateRequest): Promise<Attribute> => {
const res = await apiClient.post<ApiResponse<Attribute>>('/api/v1/attributes', req);
return res.data;
},
update: async (id: string, req: AttributeUpdateRequest): Promise<Attribute> => {
const res = await apiClient.put<ApiResponse<Attribute>>(`/api/v1/attributes/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/attributes/${id}`);
return res.success;
},
getAll: () => attributeApi.getAll(),
getById: (id: string) => attributeApi.getById(id),
create: (req: AttributeCreateRequest) => attributeApi.create(req),
update: (id: string, req: AttributeUpdateRequest) => attributeApi.update(id, req),
delete: (id: string) => attributeApi.remove(id),
};
+38 -2
View File
@@ -1,3 +1,39 @@
import { brandService } from '../services/brand.service';
import apiClient from '../../../api/axiosInstance';
import type { Brand, BrandCreateRequest, BrandUpdateRequest } from '../types/brand.types';
export const brandApi = brandService;
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/brands';
export const brandApi = {
getAll: async (): Promise<Brand[]> => {
const res = await apiClient.get<ApiResponse<Brand[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<Brand | undefined> => {
const res = await apiClient.get<ApiResponse<Brand>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: BrandCreateRequest): Promise<Brand> => {
const res = await apiClient.post<ApiResponse<Brand>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: BrandUpdateRequest): Promise<Brand> => {
const res = await apiClient.put<ApiResponse<Brand>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default brandApi;
@@ -0,0 +1,59 @@
import React from 'react';
import { DataTable } from '../../../components/customs/DataTable';
import { StatusBadge } from '../../../components/customs/StatusBadge';
import { formatDate } from '../../../utils/formatters';
import type { Brand } from '../types/brand.types';
interface BrandTableProps {
brands: Brand[];
onView: (row: Brand) => void;
onEdit: (row: Brand) => void;
onDelete: (row: Brand) => void;
onRowClick?: (row: Brand) => void;
}
export const BrandTable: React.FC<BrandTableProps> = ({
brands,
onView,
onEdit,
onDelete,
onRowClick,
}) => {
const columns = [
{ key: "code", label: "CODE", sortable: true },
{ key: "name", label: "BRAND NAME", sortable: true },
{ key: "description", label: "DESCRIPTION", sortable: true },
{
key: "status",
label: "STATUS",
sortable: true,
render: (val: string) => (
<StatusBadge
status={val === "active" ? "active" : "disabled"}
label={val === "active" ? "Active" : "Inactive"}
/>
),
},
{
key: "lastUpdated",
label: "LAST UPDATED",
render: (val: string) => formatDate(val),
},
];
return (
<DataTable<Brand>
columns={columns}
data={brands}
onRowClick={onRowClick}
searchPlaceholder="Search brands by code, name, or description..."
actionConfig={{
onView,
onEdit,
onDelete,
}}
/>
);
};
export default BrandTable;
+1
View File
@@ -5,3 +5,4 @@ export * from './types/brand.types';
export * from './hook/useBrand';
export * from './services/brand.service';
export * from './api/brand.api';
export * from './components/BrandTable';
+7 -37
View File
@@ -1,17 +1,13 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../../../components/customs/DataTable";
import BrandTable from "../components/BrandTable";
import { useBrand } from "../hook/useBrand";
import type { Brand } from "../types/brand.types";
import { formatDate } from "../../../utils/formatters";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
export default function BrandList() {
const navigate = useNavigate();
@@ -36,28 +32,6 @@ export default function BrandList() {
}
};
const columns = [
{ key: "code", label: "CODE", sortable: true },
{ key: "name", label: "BRAND NAME", sortable: true },
{ key: "description", label: "DESCRIPTION", sortable: true },
{
key: "status",
label: "STATUS",
sortable: true,
render: (val: string) => (
<StatusBadge
status={val === "active" ? "active" : "disabled"}
label={val === "active" ? "Active" : "Inactive"}
/>
),
},
{
key: "lastUpdated",
label: "LAST UPDATED",
render: (val: string) => formatDate(val),
},
];
return (
<ProtectedRoute node="masters.brands">
<PageWrapper>
@@ -71,16 +45,12 @@ export default function BrandList() {
/>
<div className="mb-8">
<DataTable<Brand>
columns={columns}
data={brands}
<BrandTable
brands={brands}
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
searchPlaceholder="Search brands by code, name, or description..."
actionConfig={{
onView: (row) => navigate(`/brands/${row.id}/view`),
onEdit: (row) => navigate(`/brands/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
onView={(row) => navigate(`/brands/${row.id}/view`)}
onEdit={(row) => navigate(`/brands/${row.id}/edit`)}
onDelete={(row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
/>
</div>
+7 -33
View File
@@ -1,36 +1,10 @@
import type { Brand, BrandCreateRequest, BrandUpdateRequest } from '../types/brand.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { brandApi } from '../api/brand.api';
import type { BrandCreateRequest, BrandUpdateRequest } from '../types/brand.types';
export const brandService = {
getAll: async (): Promise<Brand[]> => {
const res = await apiClient.get<ApiResponse<Brand[]>>('/api/v1/brands');
return res.data || [];
},
getById: async (id: string): Promise<Brand | undefined> => {
const res = await apiClient.get<ApiResponse<Brand>>(`/api/v1/brands/${id}`);
return res.data;
},
create: async (req: BrandCreateRequest): Promise<Brand> => {
const res = await apiClient.post<ApiResponse<Brand>>('/api/v1/brands', req);
return res.data;
},
update: async (id: string, req: BrandUpdateRequest): Promise<Brand> => {
const res = await apiClient.put<ApiResponse<Brand>>(`/api/v1/brands/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/brands/${id}`);
return res.success;
},
getAll: () => brandApi.getAll(),
getById: (id: string) => brandApi.getById(id),
create: (req: BrandCreateRequest) => brandApi.create(req),
update: (id: string, req: BrandUpdateRequest) => brandApi.update(id, req),
delete: (id: string) => brandApi.remove(id),
};
+38 -2
View File
@@ -1,3 +1,39 @@
import { categoryService } from '../services/category.service';
import apiClient from '../../../api/axiosInstance';
import type { Category, CategoryCreateRequest, CategoryUpdateRequest } from '../types/category.types';
export const categoryApi = categoryService;
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/categories';
export const categoryApi = {
getAll: async (): Promise<Category[]> => {
const res = await apiClient.get<ApiResponse<Category[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<Category | undefined> => {
const res = await apiClient.get<ApiResponse<Category>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: CategoryCreateRequest): Promise<Category> => {
const res = await apiClient.post<ApiResponse<Category>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: CategoryUpdateRequest): Promise<Category> => {
const res = await apiClient.put<ApiResponse<Category>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default categoryApi;
@@ -0,0 +1,82 @@
import React from 'react';
import { DataTable } from '../../../components/customs/DataTable';
import { StatusBadge } from '../../../components/customs/StatusBadge';
import type { Category } from '../types/category.types';
interface CategoryTableProps {
categories: Category[];
onView: (row: Category) => void;
onEdit: (row: Category) => void;
onDelete: (row: Category) => void;
onRowClick?: (row: Category) => void;
}
export const CategoryTable: React.FC<CategoryTableProps> = ({
categories,
onView,
onEdit,
onDelete,
onRowClick,
}) => {
const columns = [
{
key: "name",
label: "CATEGORY NAME",
sortable: true,
render: (val: string, row: Category) => (
<div className="flex flex-col">
<span className="font-medium text-gray-900">{val}</span>
<span className="text-xs text-gray-500 mt-0.5">{row.description || "—"}</span>
</div>
),
},
{
key: "code",
label: "CODE",
sortable: true,
render: (val: string) => <span className="font-mono text-xs text-gray-700">{val}</span>,
},
{
key: "parentName",
label: "PARENT",
sortable: true,
render: (val: string) => <span className="text-sm text-gray-700">{val || "—"}</span>,
},
{
key: "level",
label: "LEVEL",
render: (_: unknown, row: Category) => (
<span className="text-sm font-medium text-gray-900">
{row.parentId ? "Child" : "Root"}
</span>
),
},
{
key: "status",
label: "STATUS",
sortable: true,
render: (val: string) => (
<StatusBadge
status={val === "active" ? "active" : "disabled"}
label={val === "active" ? "Active" : "Inactive"}
/>
),
},
];
return (
<DataTable<Category>
columns={columns}
data={categories}
onRowClick={onRowClick}
searchPlaceholder="Search categories by code, name, parent category, or description..."
actionConfig={{
onView,
onEdit,
onDelete,
}}
/>
);
};
export default CategoryTable;
+1
View File
@@ -8,3 +8,4 @@ export * from './services/category.service';
export * from './api/category.api';
export { default as CategoryTree } from './components/CategoryTree';
export { default as categoryRoutes } from './routes/category.routes';
export * from './components/CategoryTable';
+6 -58
View File
@@ -4,10 +4,8 @@ import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../../../components/customs/DataTable";
import CategoryTable from "../components/CategoryTable";
import { useCategory } from "../hook/useCategory";
import type { Category } from "../types/category.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
@@ -52,52 +50,6 @@ export default function CategoryList() {
}
};
const columns = [
{
key: "name",
label: "CATEGORY NAME",
sortable: true,
render: (val: string, row: Category) => (
<div className="flex flex-col">
<span className="font-medium text-gray-900">{val}</span>
<span className="text-xs text-gray-500 mt-0.5">{row.description || "—"}</span>
</div>
),
},
{
key: "code",
label: "CODE",
sortable: true,
render: (val: string) => <span className="font-mono text-xs text-gray-700">{val}</span>,
},
{
key: "parentName",
label: "PARENT",
sortable: true,
render: (val: string) => <span className="text-sm text-gray-700">{val || "—"}</span>,
},
{
key: "level",
label: "LEVEL",
render: (_: unknown, row: Category) => (
<span className="text-sm font-medium text-gray-900">
{row.parentId ? "Child" : "Root"}
</span>
),
},
{
key: "status",
label: "STATUS",
sortable: true,
render: (val: string) => (
<StatusBadge
status={val === "active" ? "active" : "disabled"}
label={val === "active" ? "Active" : "Inactive"}
/>
),
},
];
return (
<ProtectedRoute node="products.categories">
<PageWrapper>
@@ -169,16 +121,12 @@ export default function CategoryList() {
{/* Content */}
<div className="mb-8">
{viewMode === "table" ? (
<DataTable<Category>
columns={columns}
data={categories}
<CategoryTable
categories={categories}
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`),
onEdit: (row) => navigate(`/categories/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name }),
}}
onView={(row) => navigate(`/categories/${row.id}/view`)}
onEdit={(row) => navigate(`/categories/${row.id}/edit`)}
onDelete={(row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
/>
) : (
<div className="space-y-4">
@@ -1,36 +1,10 @@
import type { Category, CategoryCreateRequest, CategoryUpdateRequest } from '../types/category.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { categoryApi } from '../api/category.api';
import type { CategoryCreateRequest, CategoryUpdateRequest } from '../types/category.types';
export const categoryService = {
getAll: async (): Promise<Category[]> => {
const res = await apiClient.get<ApiResponse<Category[]>>('/api/v1/categories');
return res.data || [];
},
getById: async (id: string): Promise<Category | undefined> => {
const res = await apiClient.get<ApiResponse<Category>>(`/api/v1/categories/${id}`);
return res.data;
},
create: async (req: CategoryCreateRequest): Promise<Category> => {
const res = await apiClient.post<ApiResponse<Category>>('/api/v1/categories', req);
return res.data;
},
update: async (id: string, req: CategoryUpdateRequest): Promise<Category> => {
const res = await apiClient.put<ApiResponse<Category>>(`/api/v1/categories/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/categories/${id}`);
return res.success;
},
getAll: () => categoryApi.getAll(),
getById: (id: string) => categoryApi.getById(id),
create: (req: CategoryCreateRequest) => categoryApi.create(req),
update: (id: string, req: CategoryUpdateRequest) => categoryApi.update(id, req),
delete: (id: string) => categoryApi.remove(id),
};
+39 -2
View File
@@ -1,2 +1,39 @@
import { channelsService } from '../services/channels.service';
export const channelsApi = channelsService;
import apiClient from '../../../api/axiosInstance';
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/channels';
export const channelsApi = {
getAll: async (): Promise<Channel[]> => {
const res = await apiClient.get<ApiResponse<Channel[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<Channel | undefined> => {
const res = await apiClient.get<ApiResponse<Channel>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: ChannelCreateRequest): Promise<Channel> => {
const res = await apiClient.post<ApiResponse<Channel>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: ChannelUpdateRequest): Promise<Channel> => {
const res = await apiClient.put<ApiResponse<Channel>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default channelsApi;
+4 -1
View File
@@ -1,4 +1,7 @@
export { default as ChannelList } from './pages/ChannelList';
export { default as NewChannel } from './pages/NewChannel';
export { ChannelRoutes } from './routes/channels.routes';
export * from './types/channels.types';
export * from './services/channels.service';
export * from './api/channels.api';
export * from './hook/useChannel';
export * from './routes/channels.routes';
@@ -1,31 +1,10 @@
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { channelsApi } from '../api/channels.api';
import type { ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
export const channelsService = {
getAll: async (): Promise<Channel[]> => {
const res = await apiClient.get<ApiResponse<Channel[]>>('/api/v1/channels');
return res.data || [];
},
getById: async (id: string): Promise<Channel | undefined> => {
const res = await apiClient.get<ApiResponse<Channel>>(`/api/v1/channels/${id}`);
return res.data;
},
create: async (req: ChannelCreateRequest): Promise<Channel> => {
const res = await apiClient.post<ApiResponse<Channel>>('/api/v1/channels', req);
return res.data;
},
update: async (id: string, req: ChannelUpdateRequest): Promise<Channel> => {
const res = await apiClient.put<ApiResponse<Channel>>(`/api/v1/channels/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/channels/${id}`);
return res.success;
},
getAll: () => channelsApi.getAll(),
getById: (id: string) => channelsApi.getById(id),
create: (req: ChannelCreateRequest) => channelsApi.create(req),
update: (id: string, req: ChannelUpdateRequest) => channelsApi.update(id, req),
delete: (id: string) => channelsApi.remove(id),
};
+44 -2
View File
@@ -1,3 +1,45 @@
import { familyService } from '../services/family.service';
import apiClient from '../../../api/axiosInstance';
import type { Family, FamilyCreateRequest, FamilyUpdateRequest } from '../types/family.types';
export const familyApi = familyService;
// Helper interface since backend likely wraps the payload in { success, data, message }
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/families';
export const familyApi = {
getAll: async (): Promise<Family[]> => {
const res = await apiClient.get<ApiResponse<Family[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<Family | undefined> => {
const res = await apiClient.get<ApiResponse<Family>>(`${BASE_URL}/${id}`);
return res.data;
},
getBlueprint: async (id: string): Promise<any> => {
const res = await apiClient.get<ApiResponse<any>>(`${BASE_URL}/${id}/blueprint`);
return (res as any)?.data || res;
},
create: async (req: FamilyCreateRequest): Promise<Family> => {
const res = await apiClient.post<ApiResponse<Family>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: FamilyUpdateRequest): Promise<Family> => {
const res = await apiClient.put<ApiResponse<Family>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default familyApi;
@@ -0,0 +1,122 @@
import React from 'react';
import { DataTable } from '../../../components/customs/DataTable';
import { StatusBadge } from '../../../components/customs/StatusBadge';
import type { Family } from '../types/family.types';
interface FamilyTableProps {
families: Family[];
onView: (row: Family) => void;
onEdit: (row: Family) => void;
onDelete: (row: Family) => void;
onRowClick?: (row: Family) => void;
}
export const FamilyTable: React.FC<FamilyTableProps> = ({
families,
onView,
onEdit,
onDelete,
onRowClick,
}) => {
const columns = [
{
key: "name",
label: "Family Name",
sortable: true,
render: (_val: string, row: Family) => (
<div>
<div className="font-semibold text-gray-900">{row.name}</div>
{row.description && (
<div className="text-xs text-gray-400 mt-0.5 max-w-[180px] line-clamp-2">{row.description}</div>
)}
</div>
),
},
{
key: "code",
label: "Code",
sortable: true,
render: (val: string) => <span className="font-mono text-sm text-gray-600">{val}</span>,
},
{
key: "category",
label: "Category",
render: (val: string) => <span className="text-sm text-gray-600">{val || '—'}</span>,
},
{
key: "attributes",
label: "Attributes",
render: (val: string[], row: Family) => (
<div className="text-center">
<div className="font-semibold text-gray-900">{val.length}</div>
<div className="text-xs text-gray-400">{row.attributeGroups ?? 0} groups</div>
</div>
),
},
{
key: "variantAxes",
label: "Variants",
render: (val: string[]) => (
<div className="text-center">
<div className="font-semibold text-gray-900">{val.length}</div>
<div className="text-xs text-gray-400">
{val.length} {val.length === 1 ? 'axis' : 'axes'}
</div>
</div>
),
},
{
key: "productCount",
label: "Products",
sortable: true,
render: (val: number) => (
<span className="font-semibold text-gray-900">{(val ?? 0).toLocaleString()}</span>
),
},
{
key: "completeness",
label: "Completeness",
sortable: true,
render: (val: number) => {
const pct = val ?? 0;
const color = pct >= 90 ? 'bg-green-500' : pct >= 70 ? 'bg-amber-400' : 'bg-red-400';
return (
<div className="flex items-center gap-2 min-w-[120px]">
<span className={`text-sm font-semibold ${pct >= 90 ? 'text-green-600' : pct >= 70 ? 'text-amber-600' : 'text-red-500'}`}>
{pct}%
</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
},
},
{
key: "status",
label: "Status",
sortable: true,
render: (val: string) => {
const badgeStatus: "active" | "disabled" = val === "active" ? "active" : "disabled";
const label = val === "active" ? "Active" : "Inactive";
return <StatusBadge status={badgeStatus} label={label} />;
},
},
];
return (
<DataTable<Family>
columns={columns}
data={families}
onRowClick={onRowClick}
searchPlaceholder="Search families by code, name, or description..."
actionConfig={{
onView,
onEdit,
onDelete,
}}
/>
);
};
export default FamilyTable;
+1
View File
@@ -6,3 +6,4 @@ export * from './hook/useFamily';
export * from './validation/family.schema';
export * from './services/family.service';
export * from './api/family.api';
export * from './components/FamilyTable';
+9 -100
View File
@@ -1,17 +1,14 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { Plus, LayoutGrid, BookCheck, Box, TrendingUp } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { Button } from "../../../components/customs/Button";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import { DataTable } from "../../../components/customs/DataTable";
import FamilyTable from "../components/FamilyTable";
import { useFamily } from "../hook/useFamily";
import type { Family } from "../types/family.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useState } from "react";
export default function FamilyList() {
const navigate = useNavigate();
@@ -46,92 +43,6 @@ export default function FamilyList() {
}
};
const columns = [
{
key: "name",
label: "Family Name",
sortable: true,
render: (_val: string, row: Family) => (
<div>
<div className="font-semibold text-gray-900">{row.name}</div>
{row.description && (
<div className="text-xs text-gray-400 mt-0.5 max-w-[180px] line-clamp-2">{row.description}</div>
)}
</div>
),
},
{
key: "code",
label: "Code",
sortable: true,
render: (val: string) => <span className="font-mono text-sm text-gray-600">{val}</span>,
},
{
key: "category",
label: "Category",
render: (val: string) => <span className="text-sm text-gray-600">{val || '—'}</span>,
},
{
key: "attributes",
label: "Attributes",
render: (val: string[], row: Family) => (
<div className="text-center">
<div className="font-semibold text-gray-900">{val.length}</div>
<div className="text-xs text-gray-400">{row.attributeGroups ?? 0} groups</div>
</div>
),
},
{
key: "variantAxes",
label: "Variants",
render: (val: string[]) => (
<div className="text-center">
<div className="font-semibold text-gray-900">{val.length}</div>
<div className="text-xs text-gray-400">
{val.length} {val.length === 1 ? 'axis' : 'axes'}
</div>
</div>
),
},
{
key: "productCount",
label: "Products",
sortable: true,
render: (val: number) => (
<span className="font-semibold text-gray-900">{(val ?? 0).toLocaleString()}</span>
),
},
{
key: "completeness",
label: "Completeness",
sortable: true,
render: (val: number) => {
const pct = val ?? 0;
const color = pct >= 90 ? 'bg-green-500' : pct >= 70 ? 'bg-amber-400' : 'bg-red-400';
return (
<div className="flex items-center gap-2 min-w-[120px]">
<span className={`text-sm font-semibold ${pct >= 90 ? 'text-green-600' : pct >= 70 ? 'text-amber-600' : 'text-red-500'}`}>
{pct}%
</span>
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
},
},
{
key: "status",
label: "Status",
sortable: true,
render: (val: string) => {
const badgeStatus: "active" | "disabled" = val === "active" ? "active" : "disabled";
const label = val === "active" ? "Active" : "Inactive";
return <StatusBadge status={badgeStatus} label={label} />;
},
},
];
return (
<ProtectedRoute node="products.families">
<PageWrapper>
@@ -176,17 +87,15 @@ export default function FamilyList() {
/>
</div>
{/* DataTable */}
<DataTable<Family>
columns={columns}
data={families}
{/* Family Table Component */}
<FamilyTable
families={families}
onRowClick={(row) => navigate(`/families/${row.id}/edit`)}
actionConfig={{
onView: (row) => navigate(`/families/${row.id}/view`),
onEdit: (row) => navigate(`/families/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
onView={(row) => navigate(`/families/${row.id}/view`)}
onEdit={(row) => navigate(`/families/${row.id}/edit`)}
onDelete={(row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
/>
<ConfirmationModal
isOpen={deleteModal.isOpen}
title="Delete Family"
+147 -220
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import apiClient from '../../../api/axiosInstance';
import { useFamily } from '../hook/useFamily';
import { useCategory } from '../../categories/hook/useCategory';
import { useAttribute } from '../../attributes/hook/useAttribute';
import { useChannel } from '../../channels/hook/useChannel';
import { useWorkflow } from '../../workflow/hook/useWorkflow';
@@ -14,7 +14,7 @@ import { useBrand } from '../../brands/hook/useBrand';
import {
FileText, LayoutGrid, Tags, Globe, Eye, Settings2, Save,
CheckCircle2, AlertCircle, CheckSquare, Image as ImageIcon, Check,
FolderTree, Box, Info
Box, Info
} from 'lucide-react';
import { DataTable } from "../../../components/customs/DataTable";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
@@ -64,161 +64,7 @@ function Card({ children, className = '' }: { children: React.ReactNode; classNa
);
}
function SearchableMultiSelect<T>({
title,
items,
selectedIds,
onChange,
searchPlaceholder = 'Search...',
getDisplayInfo,
error
}: {
title: string;
items: T[];
selectedIds: string[];
onChange: (ids: string[]) => void;
searchPlaceholder?: string;
getDisplayInfo: (item: T) => { id: string; name: string; code: string; status?: string; badgeText?: string };
error?: string;
}) {
const [search, setSearch] = useState('');
const filteredItems = useMemo(() => {
if (!search.trim()) return items;
const q = search.toLowerCase();
return items.filter(item => {
const info = getDisplayInfo(item);
return (
info.name.toLowerCase().includes(q) ||
info.code.toLowerCase().includes(q) ||
(info.badgeText && info.badgeText.toLowerCase().includes(q))
);
});
}, [items, search, getDisplayInfo]);
const handleToggle = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter(x => x !== id));
} else {
onChange([...selectedIds, id]);
}
};
const handleSelectAll = () => {
const filteredIds = filteredItems.map(item => getDisplayInfo(item).id);
const newSelected = [...new Set([...selectedIds, ...filteredIds])];
onChange(newSelected);
};
const handleClearAll = () => {
const filteredIds = new Set(filteredItems.map(item => getDisplayInfo(item).id));
onChange(selectedIds.filter(id => !filteredIds.has(id)));
};
return (
<div className="border border-primary/10 rounded-xl bg-white shadow-sm overflow-hidden flex flex-col transition-all focus-within:border-primary-light">
<div className="px-4 py-3 border-b border-primary/5 bg-gradient-to-r from-primary/5/50 to-white flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-primary-dark text-sm">{title}</span>
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-primary/10 text-primary-dark">
Selected: {selectedIds.length}
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleSelectAll}
className="text-xs text-primary hover:text-primary-dark font-medium transition-colors cursor-pointer"
>
Select All
</button>
<span className="text-gray-300 text-xs">|</span>
<button
type="button"
onClick={handleClearAll}
className="text-xs text-gray-500 hover:text-gray-700 font-medium transition-colors cursor-pointer"
>
Clear All
</button>
</div>
</div>
<div className="p-3 border-b border-gray-50 relative flex items-center">
<Search className="w-4 h-4 text-gray-400 absolute left-3" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className="w-full pl-9 pr-8 py-1.5 text-xs border border-gray-200 focus:ring-1 focus:ring-primary focus:border-transparent rounded-lg focus:outline-none placeholder-gray-400 bg-gray-50/50"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-6 text-gray-400 hover:text-gray-600 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="max-h-[180px] overflow-y-auto divide-y divide-gray-50">
{filteredItems.map(item => {
const info = getDisplayInfo(item);
const isChecked = selectedIds.includes(info.id);
return (
<label
key={info.id}
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer hover:bg-primary/5/10 transition-all select-none ${isChecked ? 'bg-primary/5/20' : ''
}`}
>
<div className="flex items-center gap-3 min-w-0">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggle(info.id)}
className="w-4 h-4 text-primary rounded border-gray-300 focus:ring-primary-light cursor-pointer"
/>
<div className="min-w-0">
<div className="font-medium text-xs text-gray-900 truncate">{info.name}</div>
<div className="text-[10px] text-gray-400 font-mono truncate">{info.code}</div>
</div>
</div>
<div className="flex items-center gap-2">
{info.badgeText && (
<span className="px-1.5 py-0.5 text-[9px] rounded font-medium bg-gray-100 text-gray-600">
{info.badgeText}
</span>
)}
{info.status && (
<span
className={`px-1.5 py-0.5 text-[9px] font-bold rounded-full uppercase tracking-wider ${info.status === 'active'
? 'bg-green-50 text-green-600 border border-green-100'
: 'bg-gray-100 text-gray-500'
}`}
>
{info.status}
</span>
)}
</div>
</label>
);
})}
{filteredItems.length === 0 && (
<div className="p-8 text-center text-xs text-gray-400">
No items found matching "{search}"
</div>
)}
</div>
{error && (
<div className="px-4 py-1.5 bg-red-50 text-[11px] text-red-500 font-medium border-t border-red-100">
{error}
</div>
)}
</div>
);
}
export default function NewFamily() {
const navigate = useNavigate();
@@ -228,7 +74,6 @@ export default function NewFamily() {
const isEdit = Boolean(id) && !isView;
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: assetFamiliesList, fetchItems: fetchAssetFamilies, loading: assetFamiliesLoading } = useAssetFamily();
@@ -236,8 +81,8 @@ export default function NewFamily() {
const { items: attributeSetsList, fetchItems: fetchAttributeSets, loading: setsLoading } = useAttributeSet();
const { brands, fetchBrands, loading: brandsLoading } = useBrand();
const { units, fetchUnits, loading: unitsLoading } = useUnit();
const { fetchBrands, loading: brandsLoading } = useBrand();
const { fetchUnits, loading: unitsLoading } = useUnit();
const [familyLoading, setFamilyLoading] = useState(false);
const [activeTab, setActiveTab] = useState('basic');
@@ -247,6 +92,19 @@ export default function NewFamily() {
const [wfSort, setWfSort] = useState<'name' | 'stages'>('name');
const [previewWf, setPreviewWf] = useState<any | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [submitError, setSubmitError] = useState<string | null>(null);
// Maps form fields to which tab they live on for auto-navigation
const FIELD_TAB_MAP: Record<string, string> = {
name: 'basic', code: 'basic', status: 'basic',
allowedBrands: 'basic', allowedUnits: 'basic',
attributeSetId: 'attributes', attributes: 'attributes',
variantAxes: 'variants',
channels: 'channels',
assetRequirements: 'assets',
completenessRules: 'rules',
};
const loadBlueprintPreview = useCallback(async (setId: string) => {
if (!setId) {
@@ -258,13 +116,7 @@ export default function NewFamily() {
? `/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();
const json = await apiClient.get<any>(url);
if (json.success && json.data) {
setBlueprintPreview(json.data);
// Expand all groups by default
@@ -285,7 +137,7 @@ export default function NewFamily() {
name: '',
code: '',
description: '',
status: 'draft',
status: 'active',
category: '',
attributeSetId: '',
attributes: [] as string[],
@@ -304,6 +156,34 @@ export default function NewFamily() {
},
validationSchema: familySchema,
onSubmit: async (values, { setSubmitting }) => {
setSubmitError(null);
// Validate the full form first and navigate to first tab with an error
const errors = await formik.validateForm();
const errorFields = Object.keys(errors);
if (errorFields.length > 0) {
// Show validation errors to the user in a toast
const errorMessages = Object.entries(errors)
.map(([field, err]) => `${field}: ${err}`)
.join(', ');
notify.error(`Validation failed: ${errorMessages}`);
setSubmitError(`Validation failed: ${errorMessages}`);
// Find first tab that has an error
const firstErrorTab = TABS.find(tab =>
errorFields.some(field => FIELD_TAB_MAP[field] === tab.id)
);
if (firstErrorTab) {
setActiveTab(firstErrorTab.id);
}
// Mark all error fields as touched so errors display
formik.setTouched(
errorFields.reduce((acc, f) => ({ ...acc, [f]: true }), {})
);
setSubmitting(false);
return;
}
try {
const payload = {
...values,
@@ -317,7 +197,14 @@ export default function NewFamily() {
}
navigate('/families');
} catch (err: any) {
const msg = err.response?.data?.message || err.message || 'Verification failed';
const backendErrors = err?.response?.data?.errors;
let msg = err?.response?.data?.message;
if (Array.isArray(backendErrors) && backendErrors.length > 0) {
msg = backendErrors.map((e: any) => `${e.field || e.path || 'Error'}: ${e.message || e.msg}`).join(', ');
} else if (!msg) {
msg = err?.message || 'Failed to save family. Please try again.';
}
setSubmitError(msg);
notify.error(msg);
} finally {
setSubmitting(false);
@@ -325,9 +212,9 @@ export default function NewFamily() {
}
});
// Fetch dynamic lookup lists on mount
useEffect(() => {
fetchCategories();
fetchAttributes();
fetchChannels();
fetchAssetFamilies();
@@ -335,7 +222,7 @@ export default function NewFamily() {
fetchAttributeSets();
fetchBrands();
fetchUnits();
}, [fetchCategories, fetchAttributes, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
}, [fetchAttributes, fetchChannels, fetchAssetFamilies, fetchWorkflows, fetchAttributeSets, fetchBrands, fetchUnits]);
// Load family details in Edit mode
useEffect(() => {
@@ -351,7 +238,7 @@ export default function NewFamily() {
code: data.code || '',
description: data.description || '',
status: data.status || 'draft',
category: data.categoryId || data.category || '',
category: data.category || '',
attributeSetId: attrSetId,
attributes: Array.isArray(data.attributes) ? data.attributes.map((a: any) => a.id || a) : [],
variantAxes: Array.isArray(data.variantAxes) ? data.variantAxes.map((a: any) => a.id || a) : [],
@@ -391,7 +278,7 @@ export default function NewFamily() {
}, [formik.values.attributeSetId, loadBlueprintPreview]);
const activeIndex = TABS.findIndex(t => t.id === activeTab);
const isLoading = familyLoading || categoriesLoading || attributesLoading || channelsLoading || assetFamiliesLoading || workflowsLoading || setsLoading || brandsLoading || unitsLoading;
const isLoading = familyLoading || attributesLoading || channelsLoading || assetFamiliesLoading || workflowsLoading || setsLoading || brandsLoading || unitsLoading;
// Selected attributes list for Step 3 Axis Filtering
const selectedAttributesList = attributes.filter(attr =>
@@ -473,6 +360,57 @@ export default function NewFamily() {
}
];
const handleSaveFamily = async () => {
setSubmitError(null);
const errors = await formik.validateForm();
const errorFields = Object.keys(errors);
if (errorFields.length > 0) {
formik.setTouched(
errorFields.reduce((acc, f) => ({ ...acc, [f]: true }), {})
);
const errorMessages = Object.entries(errors)
.map(([field, err]) => `${field}: ${err}`)
.join(', ');
notify.error(`Validation error: ${errorMessages}`);
setSubmitError(`Validation failed: ${errorMessages}`);
const firstErrorTab = TABS.find(tab =>
errorFields.some(field => FIELD_TAB_MAP[field] === tab.id)
);
if (firstErrorTab) {
setActiveTab(firstErrorTab.id);
}
return;
}
try {
const payload = {
...formik.values,
workflowCode: selectedWorkflow
};
if (isEdit && id) {
await updateFamily(id, payload as any);
notify.success('Family updated successfully!');
} else {
await createFamily(payload as any);
notify.success('Family created successfully!');
}
navigate('/families');
} catch (err: any) {
const backendErrors = err?.response?.data?.errors;
let msg = err?.response?.data?.message;
if (Array.isArray(backendErrors) && backendErrors.length > 0) {
msg = backendErrors.map((e: any) => `${e.field || e.path || 'Error'}: ${e.message || e.msg}`).join(', ');
} else if (!msg) {
msg = err?.message || 'Failed to save family. Please try again.';
}
setSubmitError(msg);
notify.error(msg);
}
};
return (
<ProtectedRoute node="products.families">
<div className="h-screen flex flex-col overflow-hidden bg-gray-50/50">
@@ -493,8 +431,8 @@ export default function NewFamily() {
</button>
{!isView && (
<button
type="submit"
form="family-form"
type="button"
onClick={handleSaveFamily}
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"
>
@@ -594,7 +532,25 @@ export default function NewFamily() {
>
<fieldset disabled={isView} className="contents">
{/* ── Basic Information ── */}
{/* ── Submit Error Banner ── */}
{submitError && (
<div className="mb-4 flex items-start gap-3 bg-red-50 border border-red-200 text-red-800 rounded-lg px-4 py-3">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold">Could not save family</p>
<p className="text-xs mt-0.5">{submitError}</p>
</div>
<button
type="button"
onClick={() => setSubmitError(null)}
className="ml-auto text-red-400 hover:text-red-600 shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{activeTab === 'basic' && (
<Card>
<CardHeader title="Family Details" subtitle="Define the core identity of this product family" />
@@ -623,7 +579,13 @@ export default function NewFamily() {
<input
name="code"
value={formik.values.code}
onChange={formik.handleChange}
onChange={(e) => {
const val = e.target.value
.toLowerCase()
.replace(/[^a-z0-9_]/g, '')
.substring(0, 50);
formik.setFieldValue("code", val);
}}
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'}`}
@@ -636,19 +598,6 @@ export default function NewFamily() {
)}
</div>
{/* Row 2: Category | Status */}
<div>
<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>
<RadioGroup className="mt-1">
@@ -669,33 +618,16 @@ export default function NewFamily() {
</RadioGroup>
</div>
{/* Allowed Brands */}
{/* Description */}
<div className="col-span-2">
<label className={labelClass}>Allowed Brands <span className="text-red-400">*</span></label>
<SearchableMultiSelect
title="Allowed Brands"
items={brands}
selectedIds={formik.values.allowedBrands}
onChange={(ids) => formik.setFieldValue('allowedBrands', ids)}
searchPlaceholder="Search brands by name or code..."
getDisplayInfo={(b) => ({ id: b.id, name: b.name, code: b.code, status: b.status })}
error={formik.touched.allowedBrands && formik.errors.allowedBrands ? String(formik.errors.allowedBrands) : undefined}
/>
</div>
{/* Row 3 — Description last */}
<div className="col-span-2">
<label className={labelClass}>Allowed Units <span className="text-red-400">*</span></label>
<SearchableMultiSelect
title="Allowed Units"
items={units}
selectedIds={formik.values.allowedUnits}
onChange={(ids) => formik.setFieldValue('allowedUnits', ids)}
searchPlaceholder="Search units by name, code or symbol..."
getDisplayInfo={(u: any) => ({ id: u.id, name: u.name, code: u.code, badgeText: u.symbol, status: u.status })}
error={formik.touched.allowedUnits && formik.errors.allowedUnits ? String(formik.errors.allowedUnits) : undefined}
<label className={labelClass}>Description</label>
<textarea
name="description"
value={formik.values.description}
onChange={formik.handleChange}
placeholder="Enter a description for this product family..."
rows={3}
className={`${inputClass} resize-none`}
/>
</div>
</div>
@@ -1133,10 +1065,6 @@ export default function NewFamily() {
<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>
<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>
@@ -1176,7 +1104,6 @@ export default function NewFamily() {
<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' },
+8 -35
View File
@@ -1,38 +1,11 @@
import type { Family, FamilyCreateRequest, FamilyUpdateRequest } from '../types/family.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { familyApi } from '../api/family.api';
import type { FamilyCreateRequest, FamilyUpdateRequest } from '../types/family.types';
export const familyService = {
getAll: async (): Promise<Family[]> => {
// Assuming backend maps this to /api/v1/catalogs/families or /api/v1/families
// For now, using /api/v1/families as standard REST pattern
const res = await apiClient.get<ApiResponse<Family[]>>('/api/v1/families');
return res.data || [];
},
getById: async (id: string): Promise<Family | undefined> => {
const res = await apiClient.get<ApiResponse<Family>>(`/api/v1/families/${id}`);
return res.data;
},
create: async (req: FamilyCreateRequest): Promise<Family> => {
const res = await apiClient.post<ApiResponse<Family>>('/api/v1/families', req);
return res.data;
},
update: async (id: string, req: FamilyUpdateRequest): Promise<Family> => {
const res = await apiClient.put<ApiResponse<Family>>(`/api/v1/families/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/families/${id}`);
return res.success;
},
getAll: () => familyApi.getAll(),
getById: (id: string) => familyApi.getById(id),
getBlueprint: (id: string) => familyApi.getBlueprint(id),
create: (req: FamilyCreateRequest) => familyApi.create(req),
update: (id: string, req: FamilyUpdateRequest) => familyApi.update(id, req),
delete: (id: string) => familyApi.remove(id),
};
@@ -3,12 +3,15 @@ import * as Yup from 'yup';
export const familySchema = Yup.object().shape({
code: Yup.string()
.required('Family code is required')
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores'),
name: Yup.string().required('Family name is required'),
.matches(/^[a-z0-9_]+$/, 'Code can only contain lowercase letters, numbers, and underscores')
.max(50, 'Code cannot be more than 50 characters'),
name: Yup.string()
.required('Family name is required')
.max(100, 'Name cannot be more than 100 characters'),
description: Yup.string(),
attributes: Yup.array().of(Yup.string()),
variantAxes: Yup.array().of(Yup.string()),
status: Yup.string().oneOf(['active', 'inactive', 'draft']),
allowedBrands: Yup.array().of(Yup.string()),
allowedUnits: Yup.array().of(Yup.string()),
allowedBrands: Yup.array().of(Yup.string()).optional(),
allowedUnits: Yup.array().of(Yup.string()).optional(),
});
+39 -2
View File
@@ -1,2 +1,39 @@
import { productService } from '../services/product.service';
export const productApi = productService;
import apiClient from '../../../api/axiosInstance';
import type { Product, ProductCreateRequest, ProductUpdateRequest } from '../types/product.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/products';
export const productApi = {
getAll: async (): Promise<Product[]> => {
const res = await apiClient.get<ApiResponse<Product[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<Product | undefined> => {
const res = await apiClient.get<ApiResponse<Product>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: ProductCreateRequest): Promise<Product> => {
const res = await apiClient.post<ApiResponse<Product>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: ProductUpdateRequest): Promise<Product> => {
const res = await apiClient.put<ApiResponse<Product>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default productApi;
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { ProductAttributeGroup } from './ProductAttributeGroup';
import { LayoutGrid, AlertCircle } from 'lucide-react';
@@ -52,19 +52,34 @@ export const DynamicAttributesSection: React.FC<DynamicAttributesSectionProps> =
);
}
if (groups.length === 0) {
const EXCLUDED_CODES = useMemo(() => new Set([
'brand', 'category', 'unit', 'name', 'product_name', 'brand_id', 'category_id', 'unit_id', 'title', 'sku', 'code'
]), []);
const filteredGroups = useMemo(() => {
return groups.map(group => {
const attributes = (group.attributes || []).filter(attr => {
const codeLower = (attr.code || '').toLowerCase().trim();
const nameLower = (attr.name || '').toLowerCase().trim();
return !EXCLUDED_CODES.has(codeLower) && !EXCLUDED_CODES.has(nameLower);
});
return { ...group, attributes };
}).filter(group => (group.attributes || []).length > 0);
}, [groups, EXCLUDED_CODES]);
if (filteredGroups.length === 0) {
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-xs p-8 text-center flex flex-col items-center justify-center">
<LayoutGrid className="w-10 h-10 text-gray-400 mb-3" />
<h3 className="font-semibold text-gray-800 mb-1">No Attribute Groups found.</h3>
<p className="text-xs text-gray-500">The assigned Attribute Set does not contain any attribute groups.</p>
<h3 className="font-semibold text-gray-800 mb-1">No Custom Dynamic Attributes found.</h3>
<p className="text-xs text-gray-500">General Information fields are managed in the General tab.</p>
</div>
);
}
return (
<div className="space-y-6">
{groups.map((group) => (
{filteredGroups.map((group) => (
<ProductAttributeGroup
key={group.id}
group={group}
@@ -62,15 +62,21 @@ export const ProductGeneralSection: React.FC<ProductGeneralSectionProps> = ({
<div className="text-xs text-red-500 mt-1 font-medium">{nameError}</div>
)}
</div>
<div>
<label className={labelClass}>Product Code</label>
<input
type="text"
value={codeValue}
readOnly
className={`${inputClass} bg-gray-50 text-gray-500 cursor-not-allowed`}
/>
</div>
{codeValue ? (
<div>
<label className={labelClass}>Product Code</label>
<div className="px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-lg text-sm font-mono text-gray-700 font-semibold">
{codeValue}
</div>
</div>
) : (
<div>
<label className={labelClass}>Product Code</label>
<div className="px-3 py-2.5 bg-gray-50/50 border border-dashed border-gray-200 rounded-lg text-xs text-gray-400 font-medium">
Auto-generated on Save Draft (e.g. EXEC-OFFICE-CHAIR)
</div>
</div>
)}
<div>
<label className={labelClass}>Status</label>
<RadioGroup className="mt-1">
@@ -111,14 +117,16 @@ export const ProductGeneralSection: React.FC<ProductGeneralSectionProps> = ({
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className={labelClass}>SKU <span className="text-red-500">*</span></label>
<input
type="text"
value={skuValue}
onChange={(e) => onFieldChange('sku', e.target.value)}
placeholder="Stock Keeping Unit"
className={inputClass}
/>
<label className={labelClass}>Master SKU</label>
{skuValue ? (
<div className="px-3 py-2.5 bg-primary/5 border border-primary/20 rounded-lg text-sm font-mono text-primary-dark font-bold">
{skuValue}
</div>
) : (
<div className="px-3 py-2.5 bg-gray-50/50 border border-dashed border-gray-200 rounded-lg text-xs text-gray-400 font-medium">
Auto-generated on Save Draft (e.g. FURN-EXEC-OFFICE-CHAIR-00001)
</div>
)}
{skuTouched && skuError && (
<div className="text-xs text-red-500 mt-1 font-medium">{skuError}</div>
)}
@@ -60,6 +60,19 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
return map;
}, [variantAxes]);
// If the family does not support variants
if (variantAxes.length === 0) {
return (
<div className="p-8 text-center bg-white rounded-xl border border-gray-200 shadow-sm">
<Layers className="w-10 h-10 text-gray-400 mx-auto mb-3" />
<h3 className="font-semibold text-gray-900 mb-1">This Product Family does not support variants.</h3>
<p className="text-sm text-gray-500 max-w-md mx-auto">
The assigned Product Family ({family?.name || 'Selected Family'}) has no variant axes configured.
</p>
</div>
);
}
// If the product is not Configurable (type !== 'variant')
if (productType !== 'variant') {
return (
@@ -80,7 +93,7 @@ export const VariantsTab: React.FC<VariantsTabProps> = ({
<Info className="w-10 h-10 text-primary mx-auto mb-3 animate-bounce" />
<h3 className="font-semibold text-gray-900 mb-1">Save Product to Configure Variants</h3>
<p className="text-sm text-gray-500 max-w-md mx-auto">
You must create and save the basic product information first before you can configure and generate variants. Please fill in the required fields in the General step and click **Save** on the header bar.
You must create and save the basic product information first before you can configure and generate variants. Please fill in the required fields in the General step and click **Create Draft** on the header bar.
</p>
</div>
);
@@ -1,8 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { familyService } from '../../family/services/family.service';
import { categoryService } from '../../categories/services/category.service';
import { attributeSetsService } from '../../attribute-sets/services/attribute-sets.service';
import { workflowService } from '../../workflow/services/workflow.service';
export const useProductFamilyConfiguration = (familyId?: string) => {
const [family, setFamily] = useState<any | null>(null);
@@ -21,32 +18,23 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
setLoading(true);
setError(null);
try {
// 1. Fetch Product Family details first to obtain category/set/workflow references
const familyData: any = await familyService.getById(id);
if (!familyData) {
throw new Error('Product Family not found');
// 1. Fetch Consolidated Product Family Blueprint
let blueprint: any = await familyService.getBlueprint(id).catch(() => null);
if (!blueprint || (!blueprint.familyId && !blueprint.id)) {
blueprint = await familyService.getById(id);
}
if (!blueprint) {
throw new Error('Product Family blueprint not found');
}
setFamily(familyData);
setAllowedBrands(familyData.allowedBrands || []);
const categoryId = familyData.category_id || familyData.categoryId || familyData.category;
const attrSetId = familyData.attribute_set_id || familyData.attributeSetId || familyData.attributeSet?.id;
const wfCode = familyData.workflow_code || familyData.workflowCode || 'standard';
setFamily(blueprint);
setAllowedBrands(blueprint.allowedBrands || []);
setCategory(blueprint.category || null);
setAttributeSet(blueprint.attributeSet || null);
// 2. Fetch Category, Attribute Set, and Workflow concurrently via Promise.all
const [categoryData, setStructure, allWorkflows] = await Promise.all([
categoryId ? categoryService.getById(categoryId).catch(() => null) : Promise.resolve(null),
attrSetId ? attributeSetsService.getById(attrSetId).catch(() => null) : Promise.resolve(null),
workflowService.getAll().catch(() => [] as any[])
]);
setCategory(categoryData);
setAttributeSet(setStructure);
if (setStructure) {
const groupsData = (setStructure as any).groups || [];
const groupsData = blueprint.groups || blueprint.attributeGroups || [];
if (Array.isArray(groupsData) && groupsData.length > 0) {
setGroups(groupsData);
const flatAttrs: any[] = [];
groupsData.forEach((g: any) => {
if (Array.isArray(g.attributes)) {
@@ -55,21 +43,23 @@ export const useProductFamilyConfiguration = (familyId?: string) => {
});
}
});
setAttributes(flatAttrs);
setAttributes(flatAttrs.length > 0 ? flatAttrs : (blueprint.attributes || []));
} else if (Array.isArray(blueprint.attributes) && blueprint.attributes.length > 0) {
const defaultGroup = [{
id: 'general-group',
name: 'General Attributes',
code: 'general_attributes',
attributes: blueprint.attributes
}];
setGroups(defaultGroup);
setAttributes(blueprint.attributes);
} else {
setGroups([]);
setAttributes([]);
}
if (allWorkflows && Array.isArray(allWorkflows)) {
const matchingWf = allWorkflows.find((w: any) => w.code === wfCode);
setWorkflow(matchingWf || null);
} else {
setWorkflow(null);
}
// Asset Family stub for Phase 1
setAssetFamily(null);
setWorkflow(blueprint.workflow || (blueprint.workflowCode ? { code: blueprint.workflowCode } : null));
setAssetFamily(blueprint.assetRequirements || blueprint.assetFamily || null);
} catch (err: any) {
console.error('Failed to load product family configuration:', err);
+4 -1
View File
@@ -1,4 +1,7 @@
export { default as ProductList } from './pages/ProductList';
export { default as NewProduct } from './pages/NewProduct';
export { default as ProductRoutes } from './routes/product.routes';
export * from './types/product.types';
export * from './services/product.service';
export * from './api/product.api';
export * from './hook/useProduct';
export * from './routes/product.routes';
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -144,7 +144,7 @@ export default function ProductList() {
{canExport && <ExportButton />}
{canImport && <ImportButton />}
<Can node="products.items" action="create">
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("/products/new")}>
Create Product
</Button>
</Can>
@@ -178,13 +178,13 @@ export default function ProductList() {
selectable
selectedIds={selected}
onSelectionChange={setSelected}
onRowClick={row => navigate(`${row.id}/edit`)}
onRowClick={row => navigate(`/products/${row.id}/edit`)}
rowIdKey="id"
resultLabel="products"
pageSizeOptions={[5, 10, 25, 50]}
actionConfig={{
onView: (row) => navigate(`${row.id}/edit`),
onEdit: (row) => navigate(`${row.id}/edit`),
onView: (row) => navigate(`/products/${row.id}/edit`),
onEdit: (row) => navigate(`/products/${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
/>
@@ -9,6 +9,7 @@ export const ProductRoutes = () => {
<Route index element={<ProductList />} /> {/* /products */}
<Route path="new" element={<NewProduct />} /> {/* /products/new */}
<Route path=":id/edit" element={<NewProduct />} /> {/* /products/123/edit */}
<Route path=":id" element={<NewProduct />} /> {/* /products/123 */}
</Routes>
);
};
@@ -1,42 +1,11 @@
import type { Product, ProductCreateRequest, ProductUpdateRequest } from '../types/product.types';
import apiClient from '../../../api/axiosInstance';
// Helper interface since backend likely wraps the payload in { success, data, message }
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { productApi } from '../api/product.api';
import type { ProductCreateRequest, ProductUpdateRequest } from '../types/product.types';
export const productService = {
getAll: async (): Promise<Product[]> => {
const res = await apiClient.get<ApiResponse<Product[]>>('/api/v1/products');
return res.data || [];
},
getById: async (id: string): Promise<Product | undefined> => {
const res = await apiClient.get<ApiResponse<Product>>(`/api/v1/products/${id}`);
return res.data;
},
create: async (req: ProductCreateRequest): Promise<Product> => {
const res = await apiClient.post<ApiResponse<Product>>('/api/v1/products', req);
return res.data;
},
update: async (id: string, req: ProductUpdateRequest): Promise<Product> => {
const res = await apiClient.put<ApiResponse<Product>>(`/api/v1/products/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/products/${id}`);
return res.success;
},
// Stub for now if components need it
resetData: () => {
return [];
},
getAll: () => productApi.getAll(),
getById: (id: string) => productApi.getById(id),
create: (req: ProductCreateRequest) => productApi.create(req),
update: (id: string, req: ProductUpdateRequest) => productApi.update(id, req),
delete: (id: string) => productApi.remove(id),
resetData: () => [],
};
+39 -2
View File
@@ -1,2 +1,39 @@
import { unitService } from '../services/unit.service';
export const unitApi = unitService;
import apiClient from '../../../api/axiosInstance';
import type { Unit, UnitCreateRequest, UnitUpdateRequest } from '../types/unit.types';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
const BASE_URL = '/api/v1/units';
export const unitApi = {
getAll: async (): Promise<Unit[]> => {
const res = await apiClient.get<ApiResponse<Unit[]>>(BASE_URL);
return res.data || [];
},
getById: async (id: string): Promise<Unit | undefined> => {
const res = await apiClient.get<ApiResponse<Unit>>(`${BASE_URL}/${id}`);
return res.data;
},
create: async (req: UnitCreateRequest): Promise<Unit> => {
const res = await apiClient.post<ApiResponse<Unit>>(BASE_URL, req);
return res.data;
},
update: async (id: string, req: UnitUpdateRequest): Promise<Unit> => {
const res = await apiClient.put<ApiResponse<Unit>>(`${BASE_URL}/${id}`, req);
return res.data;
},
remove: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`${BASE_URL}/${id}`);
return res.success;
},
};
export default unitApi;
@@ -0,0 +1,68 @@
import React from 'react';
import { DataTable } from '../../../components/customs/DataTable';
import { StatusBadge } from '../../../components/customs/StatusBadge';
import type { Unit } from '../types/unit.types';
interface UnitTableProps {
units: Unit[];
onView: (row: Unit) => void;
onEdit: (row: Unit) => void;
onDelete: (row: Unit) => void;
onRowClick?: (row: Unit) => void;
}
export const UnitTable: React.FC<UnitTableProps> = ({
units,
onView,
onEdit,
onDelete,
onRowClick,
}) => {
const columns = [
{ key: "name", label: "UNIT NAME", sortable: true },
{ key: "code", label: "CODE", sortable: true },
{ key: "symbol", label: "SYMBOL", sortable: true },
{ key: "unitType", label: "TYPE", sortable: true },
{
key: "conversionFactor",
label: "CONVERSION",
render: (value: any, row: Unit) =>
value !== undefined ? `${value} ${row.baseUnit || ''}` : "—"
},
{
key: "status",
label: "STATUS",
sortable: true,
render: (value: string) => (
<StatusBadge
status={value === "active" ? "active" : "disabled"}
label={value === "active" ? "Active" : "Inactive"}
/>
)
},
{
key: "lastUpdated",
label: "LAST UPDATED",
render: (val: string) => val ? new Date(val).toLocaleDateString() : "—"
},
];
return (
<DataTable<Unit>
columns={columns}
data={units}
onRowClick={onRowClick}
rowIdKey="id"
resultLabel="units"
pageSizeOptions={[5, 10, 25, 50]}
statusKey="status"
actionConfig={{
onView,
onEdit,
onDelete,
}}
/>
);
};
export default UnitTable;
+5 -1
View File
@@ -1,4 +1,8 @@
export { default as UnitList } from './pages/UnitList';
export { default as NewUnit } from './pages/NewUnit';
export { UnitRoutes } from './routes/unit.routes';
export * from './types/unit.types';
export * from './services/unit.service';
export * from './api/unit.api';
export * from './hook/useUnit';
export * from './routes/unit.routes';
export * from './components/UnitTable';
+6 -44
View File
@@ -6,10 +6,8 @@ import { PageWrapper } from "../../../components/layouts/PageWrapper";
import { usePermissions } from "../../../hooks/usePermission";
import { Can } from "../../../components/customs/Can";
import { Button, ExportButton, ImportButton } from "../../../components/customs/Button";
import { DataTable } from "../../../components/customs/DataTable";
import { StatusBadge } from "../../../components/customs/StatusBadge";
import UnitTable from "../components/UnitTable";
import { useUnit } from "../hook/useUnit";
import type { Unit } from "../types/unit.types";
import { Breadcrumb } from "../../../components/layouts/Breadcrumb";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
@@ -42,35 +40,6 @@ export default function UnitList() {
}
};
const columns = [
{ key: "name", label: "UNIT NAME", sortable: true },
{ key: "code", label: "CODE", sortable: true },
{ key: "symbol", label: "SYMBOL", sortable: true },
{ key: "unitType", label: "TYPE", sortable: true },
{
key: "conversionFactor",
label: "CONVERSION",
render: (value: any, row: Unit) =>
value !== undefined ? `${value} ${row.baseUnit || ''}` : "—"
},
{
key: "status",
label: "STATUS",
sortable: true,
render: (value: string) => (
<StatusBadge
status={value === "active" ? "active" : "disabled"}
label={value === "active" ? "Active" : "Inactive"}
/>
)
},
{
key: "lastUpdated",
label: "LAST UPDATED",
render: (val: string) => val ? new Date(val).toLocaleDateString() : "—"
},
];
return (
<ProtectedRoute node="masters.units">
<PageWrapper>
@@ -89,19 +58,12 @@ export default function UnitList() {
}
/>
<DataTable
columns={columns}
data={units}
<UnitTable
units={units}
onRowClick={(unit) => navigate(`${unit.id}/edit`)}
rowIdKey="id" // ← This was causing the error
resultLabel="units"
pageSizeOptions={[5, 10, 25, 50]}
statusKey="status"
actionConfig={{
onView: (row) => navigate(`${row.id}/view`),
onEdit: (row) => navigate(`${row.id}/edit`),
onDelete: (row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })
}}
onView={(row) => navigate(`${row.id}/view`)}
onEdit={(row) => navigate(`${row.id}/edit`)}
onDelete={(row) => setDeleteModal({ isOpen: true, id: row.id, name: row.name })}
/>
<ConfirmationModal
+7 -32
View File
@@ -1,35 +1,10 @@
import type { Unit, UnitCreateRequest, UnitUpdateRequest } from '../types/unit.types';
import apiClient from '../../../api/axiosInstance';
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
import { unitApi } from '../api/unit.api';
import type { UnitCreateRequest, UnitUpdateRequest } from '../types/unit.types';
export const unitService = {
getAll: async (): Promise<Unit[]> => {
const res = await apiClient.get<ApiResponse<Unit[]>>('/api/v1/units');
return res.data || [];
},
getById: async (id: string): Promise<Unit | undefined> => {
const res = await apiClient.get<ApiResponse<Unit>>(`/api/v1/units/${id}`);
return res.data;
},
create: async (req: UnitCreateRequest): Promise<Unit> => {
const res = await apiClient.post<ApiResponse<Unit>>('/api/v1/units', req);
return res.data;
},
update: async (id: string, req: UnitUpdateRequest): Promise<Unit> => {
const res = await apiClient.put<ApiResponse<Unit>>(`/api/v1/units/${id}`, req);
return res.data;
},
delete: async (id: string): Promise<boolean> => {
const res = await apiClient.delete<ApiResponse<any>>(`/api/v1/units/${id}`);
return res.success;
},
getAll: () => unitApi.getAll(),
getById: (id: string) => unitApi.getById(id),
create: (req: UnitCreateRequest) => unitApi.create(req),
update: (id: string, req: UnitUpdateRequest) => unitApi.update(id, req),
delete: (id: string) => unitApi.remove(id),
};
+166 -1
View File
@@ -10,21 +10,69 @@ import { StatsCard } from "../../../components/customs/StatsCard";
import { ConfirmationModal } from "../../../components/modals/ConfirmationModal";
import { useWorkflow } from "../hook/useWorkflow";
import { StageTimeline } from "../components/StageTimeline";
import { useCallback } from "react";
import { productService } from "../../product/services/product.service";
import { notify } from "../../../services/toast";
import type { Workflow } from "../types/workflow.types";
export default function WorkflowList() {
const navigate = useNavigate();
const { items: workflows, loading, fetchItems, archiveItem, restoreItem, deleteItem } = useWorkflow();
const [activeTab, setActiveTab] = useState<'workflows' | 'approvals'>('workflows');
const [pendingProducts, setPendingProducts] = useState<any[]>([]);
const [loadingPending, setLoadingPending] = useState(false);
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [archiveModal, setArchiveModal] = useState({ isOpen: false, id: "", name: "" });
const [restoreModal, setRestoreModal] = useState({ isOpen: false, id: "", name: "" });
const [deleteModal, setDeleteModal] = useState({ isOpen: false, id: "", name: "" });
const fetchPendingProducts = useCallback(async () => {
setLoadingPending(true);
try {
const res: any = await productService.getAll();
const list = Array.isArray(res) ? res : (res?.data || []);
const filtered = list.filter((p: any) => p.status === 'pending' || p.status === 'pending_review' || p.metadata?.currentStage === 'pending_review');
setPendingProducts(filtered);
} catch {
setPendingProducts([]);
} finally {
setLoadingPending(false);
}
}, []);
useEffect(() => {
fetchItems();
}, [fetchItems]);
fetchPendingProducts();
}, [fetchItems, fetchPendingProducts]);
const handleApproveProduct = async (prodId: string) => {
try {
await productService.update(prodId, {
status: 'active',
metadata: { currentStage: 'approved' }
} as any);
notify.success("Product approved and activated!");
fetchPendingProducts();
} catch {
notify.error("Failed to approve product");
}
};
const handleRejectProduct = async (prodId: string) => {
try {
await productService.update(prodId, {
status: 'draft',
metadata: { currentStage: 'draft' }
} as any);
notify.success("Product returned to Draft mode!");
fetchPendingProducts();
} catch {
notify.error("Failed to reject product");
}
};
// If items reload, update selected workflow inside the drawer to keep data fresh
useEffect(() => {
@@ -218,6 +266,122 @@ export default function WorkflowList() {
/>
</div>
{/* Navigation Tabs */}
<div className="flex items-center gap-2 border-b border-gray-200 mb-6 pb-2">
<button
type="button"
onClick={() => setActiveTab('workflows')}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors cursor-pointer ${
activeTab === 'workflows'
? 'bg-primary text-white shadow-xs'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Workflows Engine ({workflows.length})
</button>
<button
type="button"
onClick={() => setActiveTab('approvals')}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors flex items-center gap-2 cursor-pointer ${
activeTab === 'approvals'
? 'bg-primary text-white shadow-xs'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<Clock className="w-4 h-4" />
Pending Approvals Queue
{pendingProducts.length > 0 && (
<span className="px-2 py-0.5 text-xs font-bold bg-amber-500 text-white rounded-full">
{pendingProducts.length}
</span>
)}
</button>
</div>
{activeTab === 'approvals' ? (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
<div>
<h3 className="font-bold text-gray-900 text-sm">Products Awaiting Review & Approval</h3>
<p className="text-xs text-gray-500">Products submitted by team members requiring approval before publishing.</p>
</div>
<button
type="button"
onClick={fetchPendingProducts}
className="text-xs text-primary hover:underline flex items-center gap-1 cursor-pointer font-medium"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh Queue
</button>
</div>
{loadingPending ? (
<div className="p-12 text-center text-gray-500 text-sm">Loading pending approval queue...</div>
) : pendingProducts.length === 0 ? (
<div className="p-12 text-center">
<CheckCircle className="w-10 h-10 text-emerald-500 mx-auto mb-2 opacity-80" />
<h4 className="font-semibold text-gray-800 text-sm mb-1">Queue Empty</h4>
<p className="text-xs text-gray-500">All submitted products have been reviewed and approved.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-gray-600 font-semibold border-b border-gray-200 text-xs uppercase tracking-wider">
<tr>
<th className="py-3 px-4">Product</th>
<th className="py-3 px-4">Code / SKU</th>
<th className="py-3 px-4">Status</th>
<th className="py-3 px-4">Stage</th>
<th className="py-3 px-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{pendingProducts.map((prod) => (
<tr key={prod.id} className="hover:bg-gray-50/80 transition-colors">
<td className="py-3 px-4 font-medium text-gray-900">
{prod.name}
</td>
<td className="py-3 px-4 text-xs font-mono text-gray-600">
{prod.code || prod.sku || prod.id}
</td>
<td className="py-3 px-4">
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200">
{prod.status}
</span>
</td>
<td className="py-3 px-4 text-xs text-gray-600 capitalize">
{prod.metadata?.currentStage || 'Pending Review'}
</td>
<td className="py-3 px-4 text-right space-x-2">
<button
type="button"
onClick={() => navigate(`/products/${prod.id}/edit`)}
className="px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-200 rounded hover:bg-gray-100 cursor-pointer"
>
Review
</button>
<button
type="button"
onClick={() => handleRejectProduct(prod.id)}
className="px-2.5 py-1 text-xs font-medium text-red-600 border border-red-200 rounded hover:bg-red-50 cursor-pointer"
>
Reject
</button>
<button
type="button"
onClick={() => handleApproveProduct(prod.id)}
className="px-2.5 py-1 text-xs font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded cursor-pointer shadow-2xs"
>
Approve
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
<DataTable<Workflow>
columns={columns}
@@ -253,6 +417,7 @@ export default function WorkflowList() {
searchPlaceholder="Search workflows..."
/>
</div>
)}
{/* Slide-out Preview Drawer */}
{drawerOpen && selectedWorkflow && (
+3 -1
View File
@@ -9,5 +9,7 @@ export const generateCodeFromName = (name: string): string => {
.trim()
.replace(/[^a-z0-9\s_-]/g, "") // strip special characters except space, underscore, hyphen
.replace(/[\s-]+/g, "_") // replace spaces and hyphens with underscores
.replace(/_+/g, "_"); // merge consecutive underscores
.replace(/_+/g, "_") // merge consecutive underscores
.substring(0, 50) // limit to 50 characters to prevent DB overflow
.replace(/_+$/, ""); // strip trailing underscores after truncation
};