From a4253a8ce990ac767b3602391075ad4e30405d44 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 05:42:13 -0600 Subject: [PATCH] [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 --- src/hooks/index.ts | 1 + src/hooks/usePortfolio.ts | 253 +++++++++++++++++++++ src/services/portfolio/categories.api.ts | 93 ++++++++ src/services/portfolio/index.ts | 2 + src/services/portfolio/products.api.ts | 272 +++++++++++++++++++++++ 5 files changed, 621 insertions(+) create mode 100644 src/hooks/usePortfolio.ts create mode 100644 src/services/portfolio/categories.api.ts create mode 100644 src/services/portfolio/index.ts create mode 100644 src/services/portfolio/products.api.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f2d5518..fb19021 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -13,3 +13,4 @@ export * from './useExport'; export * from './useAnalytics'; export * from './useMfa'; export * from './useWhatsApp'; +export * from './usePortfolio'; diff --git a/src/hooks/usePortfolio.ts b/src/hooks/usePortfolio.ts new file mode 100644 index 0000000..cc60d9e --- /dev/null +++ b/src/hooks/usePortfolio.ts @@ -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'] }); + }, + }); +} diff --git a/src/services/portfolio/categories.api.ts b/src/services/portfolio/categories.api.ts new file mode 100644 index 0000000..4a1deff --- /dev/null +++ b/src/services/portfolio/categories.api.ts @@ -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; + 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; +} + +export type UpdateCategoryDto = Partial; + +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 => { + const response = await api.get('/portfolio/categories', { params }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/portfolio/categories/${id}`); + return response.data; + }, + + getTree: async (): Promise => { + const response = await api.get('/portfolio/categories/tree'); + return response.data; + }, + + create: async (data: CreateCategoryDto): Promise => { + const response = await api.post('/portfolio/categories', data); + return response.data; + }, + + update: async (id: string, data: UpdateCategoryDto): Promise => { + const response = await api.put(`/portfolio/categories/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/portfolio/categories/${id}`); + }, +}; diff --git a/src/services/portfolio/index.ts b/src/services/portfolio/index.ts new file mode 100644 index 0000000..31a9625 --- /dev/null +++ b/src/services/portfolio/index.ts @@ -0,0 +1,2 @@ +export * from './categories.api'; +export * from './products.api'; diff --git a/src/services/portfolio/products.api.ts b/src/services/portfolio/products.api.ts new file mode 100644 index 0000000..1c5d2ff --- /dev/null +++ b/src/services/portfolio/products.api.ts @@ -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; + 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; + 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; +} + +export type UpdateProductDto = Partial; + +export interface UpdateProductStatusDto { + status: ProductStatus; +} + +export interface CreateVariantDto { + sku?: string; + barcode?: string; + name?: string; + attributes: Record; + 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; + +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; + +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 => { + const response = await api.get('/portfolio/products', { params }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/portfolio/products/${id}`); + return response.data; + }, + + create: async (data: CreateProductDto): Promise => { + const response = await api.post('/portfolio/products', data); + return response.data; + }, + + update: async (id: string, data: UpdateProductDto): Promise => { + const response = await api.put(`/portfolio/products/${id}`, data); + return response.data; + }, + + updateStatus: async (id: string, data: UpdateProductStatusDto): Promise => { + const response = await api.patch(`/portfolio/products/${id}/status`, data); + return response.data; + }, + + duplicate: async (id: string): Promise => { + const response = await api.post(`/portfolio/products/${id}/duplicate`); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/portfolio/products/${id}`); + }, + + // Variants + getVariants: async (productId: string): Promise => { + const response = await api.get(`/portfolio/products/${productId}/variants`); + return response.data; + }, + + createVariant: async (productId: string, data: CreateVariantDto): Promise => { + const response = await api.post(`/portfolio/products/${productId}/variants`, data); + return response.data; + }, + + updateVariant: async (productId: string, variantId: string, data: UpdateVariantDto): Promise => { + const response = await api.patch(`/portfolio/products/${productId}/variants/${variantId}`, data); + return response.data; + }, + + deleteVariant: async (productId: string, variantId: string): Promise => { + await api.delete(`/portfolio/products/${productId}/variants/${variantId}`); + }, + + // Prices + getPrices: async (productId: string): Promise => { + const response = await api.get(`/portfolio/products/${productId}/prices`); + return response.data; + }, + + createPrice: async (productId: string, data: CreatePriceDto): Promise => { + const response = await api.post(`/portfolio/products/${productId}/prices`, data); + return response.data; + }, + + updatePrice: async (productId: string, priceId: string, data: UpdatePriceDto): Promise => { + const response = await api.patch(`/portfolio/products/${productId}/prices/${priceId}`, data); + return response.data; + }, + + deletePrice: async (productId: string, priceId: string): Promise => { + await api.delete(`/portfolio/products/${productId}/prices/${priceId}`); + }, +};