solve merge conficltes
This commit is contained in:
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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: () => [],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user