[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:
Adrian Flores Cortes 2026-01-25 05:42:13 -06:00
parent 36ee5213c5
commit a4253a8ce9
5 changed files with 621 additions and 0 deletions

View File

@ -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
View 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'] });
},
});
}

View 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}`);
},
};

View File

@ -0,0 +1,2 @@
export * from './categories.api';
export * from './products.api';

View 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}`);
},
};