[SAAS-019] feat: Add Portfolio module frontend
- API services: categories.api.ts, products.api.ts - React Query hooks: usePortfolio.ts - Full CRUD operations for products, categories, variants, prices Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
36ee5213c5
commit
a4253a8ce9
@ -13,3 +13,4 @@ export * from './useExport';
|
||||
export * from './useAnalytics';
|
||||
export * from './useMfa';
|
||||
export * from './useWhatsApp';
|
||||
export * from './usePortfolio';
|
||||
|
||||
253
src/hooks/usePortfolio.ts
Normal file
253
src/hooks/usePortfolio.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
categoriesApi,
|
||||
productsApi,
|
||||
type CategoryFilters,
|
||||
type CreateCategoryDto,
|
||||
type UpdateCategoryDto,
|
||||
type ProductFilters,
|
||||
type CreateProductDto,
|
||||
type UpdateProductDto,
|
||||
type UpdateProductStatusDto,
|
||||
type CreateVariantDto,
|
||||
type UpdateVariantDto,
|
||||
type CreatePriceDto,
|
||||
type UpdatePriceDto,
|
||||
} from '@/services/portfolio';
|
||||
|
||||
// ============================================
|
||||
// Categories Hooks
|
||||
// ============================================
|
||||
|
||||
export function useCategories(filters?: CategoryFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['portfolio', 'categories', filters],
|
||||
queryFn: () => categoriesApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCategory(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['portfolio', 'categories', id],
|
||||
queryFn: () => categoriesApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCategoryTree() {
|
||||
return useQuery({
|
||||
queryKey: ['portfolio', 'categories', 'tree'],
|
||||
queryFn: () => categoriesApi.getTree(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCategoryDto) => categoriesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'categories'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateCategoryDto }) =>
|
||||
categoriesApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'categories', id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => categoriesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'categories'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Products Hooks
|
||||
// ============================================
|
||||
|
||||
export function useProducts(filters?: ProductFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['portfolio', 'products', filters],
|
||||
queryFn: () => productsApi.list(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProduct(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['portfolio', 'products', id],
|
||||
queryFn: () => productsApi.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateProductDto) => productsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateProductDto }) =>
|
||||
productsApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProductStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateProductStatusDto }) =>
|
||||
productsApi.updateStatus(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => productsApi.duplicate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => productsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Variants Hooks
|
||||
// ============================================
|
||||
|
||||
export function useProductVariants(productId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['portfolio', 'products', productId, 'variants'],
|
||||
queryFn: () => productsApi.getVariants(productId),
|
||||
enabled: !!productId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVariant() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ productId, data }: { productId: string; data: CreateVariantDto }) =>
|
||||
productsApi.createVariant(productId, data),
|
||||
onSuccess: (_, { productId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId, 'variants'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVariant() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
productId,
|
||||
variantId,
|
||||
data,
|
||||
}: {
|
||||
productId: string;
|
||||
variantId: string;
|
||||
data: UpdateVariantDto;
|
||||
}) => productsApi.updateVariant(productId, variantId, data),
|
||||
onSuccess: (_, { productId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId, 'variants'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVariant() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ productId, variantId }: { productId: string; variantId: string }) =>
|
||||
productsApi.deleteVariant(productId, variantId),
|
||||
onSuccess: (_, { productId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId, 'variants'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Prices Hooks
|
||||
// ============================================
|
||||
|
||||
export function useProductPrices(productId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['portfolio', 'products', productId, 'prices'],
|
||||
queryFn: () => productsApi.getPrices(productId),
|
||||
enabled: !!productId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePrice() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ productId, data }: { productId: string; data: CreatePriceDto }) =>
|
||||
productsApi.createPrice(productId, data),
|
||||
onSuccess: (_, { productId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId, 'prices'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePrice() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
productId,
|
||||
priceId,
|
||||
data,
|
||||
}: {
|
||||
productId: string;
|
||||
priceId: string;
|
||||
data: UpdatePriceDto;
|
||||
}) => productsApi.updatePrice(productId, priceId, data),
|
||||
onSuccess: (_, { productId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId, 'prices'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePrice() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ productId, priceId }: { productId: string; priceId: string }) =>
|
||||
productsApi.deletePrice(productId, priceId),
|
||||
onSuccess: (_, { productId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', 'products', productId, 'prices'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
93
src/services/portfolio/categories.api.ts
Normal file
93
src/services/portfolio/categories.api.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export interface Category {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
parent_id: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
position: number;
|
||||
image_url: string | null;
|
||||
color: string;
|
||||
icon: string | null;
|
||||
is_active: boolean;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
custom_fields: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
children?: Category[];
|
||||
product_count?: number;
|
||||
}
|
||||
|
||||
export interface CategoryTreeNode extends Category {
|
||||
children: CategoryTreeNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface CreateCategoryDto {
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id?: string;
|
||||
description?: string;
|
||||
position?: number;
|
||||
image_url?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
is_active?: boolean;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
custom_fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateCategoryDto = Partial<CreateCategoryDto>;
|
||||
|
||||
export interface CategoryFilters {
|
||||
parent_id?: string | null;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedCategories {
|
||||
items: Category[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export const categoriesApi = {
|
||||
list: async (params?: CategoryFilters): Promise<PaginatedCategories> => {
|
||||
const response = await api.get<PaginatedCategories>('/portfolio/categories', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Category> => {
|
||||
const response = await api.get<Category>(`/portfolio/categories/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTree: async (): Promise<CategoryTreeNode[]> => {
|
||||
const response = await api.get<CategoryTreeNode[]>('/portfolio/categories/tree');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateCategoryDto): Promise<Category> => {
|
||||
const response = await api.post<Category>('/portfolio/categories', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateCategoryDto): Promise<Category> => {
|
||||
const response = await api.put<Category>(`/portfolio/categories/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/portfolio/categories/${id}`);
|
||||
},
|
||||
};
|
||||
2
src/services/portfolio/index.ts
Normal file
2
src/services/portfolio/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './categories.api';
|
||||
export * from './products.api';
|
||||
272
src/services/portfolio/products.api.ts
Normal file
272
src/services/portfolio/products.api.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export type ProductType = 'physical' | 'digital' | 'service' | 'subscription' | 'bundle';
|
||||
export type ProductStatus = 'draft' | 'active' | 'inactive' | 'discontinued' | 'out_of_stock';
|
||||
export type PriceType = 'one_time' | 'recurring' | 'usage_based' | 'tiered';
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
category_id: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
sku: string | null;
|
||||
barcode: string | null;
|
||||
description: string | null;
|
||||
short_description: string | null;
|
||||
product_type: ProductType;
|
||||
status: ProductStatus;
|
||||
base_price: number;
|
||||
cost_price: number | null;
|
||||
compare_at_price: number | null;
|
||||
currency: string;
|
||||
track_inventory: boolean;
|
||||
stock_quantity: number;
|
||||
low_stock_threshold: number;
|
||||
allow_backorder: boolean;
|
||||
weight: number | null;
|
||||
weight_unit: string;
|
||||
length: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
dimension_unit: string;
|
||||
images: string[];
|
||||
featured_image_url: string | null;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
tags: string[];
|
||||
is_visible: boolean;
|
||||
is_featured: boolean;
|
||||
has_variants: boolean;
|
||||
variant_attributes: string[];
|
||||
custom_fields: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
published_at: string | null;
|
||||
category?: { id: string; name: string; slug: string } | null;
|
||||
variant_count?: number;
|
||||
}
|
||||
|
||||
export interface Variant {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
product_id: string;
|
||||
sku: string | null;
|
||||
barcode: string | null;
|
||||
name: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
price: number | null;
|
||||
cost_price: number | null;
|
||||
compare_at_price: number | null;
|
||||
stock_quantity: number;
|
||||
low_stock_threshold: number | null;
|
||||
weight: number | null;
|
||||
image_url: string | null;
|
||||
is_active: boolean;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
product_id: string | null;
|
||||
variant_id: string | null;
|
||||
price_type: PriceType;
|
||||
currency: string;
|
||||
amount: number;
|
||||
compare_at_amount: number | null;
|
||||
billing_period: string | null;
|
||||
billing_interval: number | null;
|
||||
min_quantity: number;
|
||||
max_quantity: number | null;
|
||||
valid_from: string | null;
|
||||
valid_until: string | null;
|
||||
priority: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateProductDto {
|
||||
name: string;
|
||||
slug: string;
|
||||
category_id?: string;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
description?: string;
|
||||
short_description?: string;
|
||||
product_type?: ProductType;
|
||||
status?: ProductStatus;
|
||||
base_price?: number;
|
||||
cost_price?: number;
|
||||
compare_at_price?: number;
|
||||
currency?: string;
|
||||
track_inventory?: boolean;
|
||||
stock_quantity?: number;
|
||||
low_stock_threshold?: number;
|
||||
allow_backorder?: boolean;
|
||||
weight?: number;
|
||||
weight_unit?: string;
|
||||
length?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dimension_unit?: string;
|
||||
images?: string[];
|
||||
featured_image_url?: string;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
tags?: string[];
|
||||
is_visible?: boolean;
|
||||
is_featured?: boolean;
|
||||
has_variants?: boolean;
|
||||
variant_attributes?: string[];
|
||||
custom_fields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateProductDto = Partial<CreateProductDto>;
|
||||
|
||||
export interface UpdateProductStatusDto {
|
||||
status: ProductStatus;
|
||||
}
|
||||
|
||||
export interface CreateVariantDto {
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
name?: string;
|
||||
attributes: Record<string, unknown>;
|
||||
price?: number;
|
||||
cost_price?: number;
|
||||
compare_at_price?: number;
|
||||
stock_quantity?: number;
|
||||
low_stock_threshold?: number;
|
||||
weight?: number;
|
||||
image_url?: string;
|
||||
is_active?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export type UpdateVariantDto = Partial<CreateVariantDto>;
|
||||
|
||||
export interface CreatePriceDto {
|
||||
product_id?: string;
|
||||
variant_id?: string;
|
||||
price_type?: PriceType;
|
||||
currency: string;
|
||||
amount: number;
|
||||
compare_at_amount?: number;
|
||||
billing_period?: string;
|
||||
billing_interval?: number;
|
||||
min_quantity?: number;
|
||||
max_quantity?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string;
|
||||
priority?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export type UpdatePriceDto = Partial<CreatePriceDto>;
|
||||
|
||||
export interface ProductFilters {
|
||||
category_id?: string;
|
||||
product_type?: ProductType;
|
||||
status?: ProductStatus;
|
||||
is_visible?: boolean;
|
||||
is_featured?: boolean;
|
||||
min_price?: number;
|
||||
max_price?: number;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
sort_by?: string;
|
||||
sort_order?: 'ASC' | 'DESC';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedProducts {
|
||||
items: Product[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export const productsApi = {
|
||||
// Products
|
||||
list: async (params?: ProductFilters): Promise<PaginatedProducts> => {
|
||||
const response = await api.get<PaginatedProducts>('/portfolio/products', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Product> => {
|
||||
const response = await api.get<Product>(`/portfolio/products/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: CreateProductDto): Promise<Product> => {
|
||||
const response = await api.post<Product>('/portfolio/products', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: UpdateProductDto): Promise<Product> => {
|
||||
const response = await api.put<Product>(`/portfolio/products/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateStatus: async (id: string, data: UpdateProductStatusDto): Promise<Product> => {
|
||||
const response = await api.patch<Product>(`/portfolio/products/${id}/status`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
duplicate: async (id: string): Promise<Product> => {
|
||||
const response = await api.post<Product>(`/portfolio/products/${id}/duplicate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await api.delete(`/portfolio/products/${id}`);
|
||||
},
|
||||
|
||||
// Variants
|
||||
getVariants: async (productId: string): Promise<Variant[]> => {
|
||||
const response = await api.get<Variant[]>(`/portfolio/products/${productId}/variants`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createVariant: async (productId: string, data: CreateVariantDto): Promise<Variant> => {
|
||||
const response = await api.post<Variant>(`/portfolio/products/${productId}/variants`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateVariant: async (productId: string, variantId: string, data: UpdateVariantDto): Promise<Variant> => {
|
||||
const response = await api.patch<Variant>(`/portfolio/products/${productId}/variants/${variantId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteVariant: async (productId: string, variantId: string): Promise<void> => {
|
||||
await api.delete(`/portfolio/products/${productId}/variants/${variantId}`);
|
||||
},
|
||||
|
||||
// Prices
|
||||
getPrices: async (productId: string): Promise<Price[]> => {
|
||||
const response = await api.get<Price[]>(`/portfolio/products/${productId}/prices`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createPrice: async (productId: string, data: CreatePriceDto): Promise<Price> => {
|
||||
const response = await api.post<Price>(`/portfolio/products/${productId}/prices`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePrice: async (productId: string, priceId: string, data: UpdatePriceDto): Promise<Price> => {
|
||||
const response = await api.patch<Price>(`/portfolio/products/${productId}/prices/${priceId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deletePrice: async (productId: string, priceId: string): Promise<void> => {
|
||||
await api.delete(`/portfolio/products/${productId}/prices/${priceId}`);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user