From eff0f70b7f93687b18d45d579613fa786c3119c9 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 06:48:55 -0600 Subject: [PATCH] [SAAS-021] feat: Implement MLM module frontend - 4 API services: structures, ranks, nodes, commissions - 30+ React Query hooks in useMlm.ts - My network/dashboard hooks for user experience - Tree visualization support hooks Co-Authored-By: Claude Opus 4.5 --- src/hooks/index.ts | 1 + src/hooks/useMlm.ts | 302 ++++++++++++++++++++++++++++ src/services/mlm/commissions.api.ts | 138 +++++++++++++ src/services/mlm/index.ts | 4 + src/services/mlm/nodes.api.ts | 208 +++++++++++++++++++ src/services/mlm/ranks.api.ts | 94 +++++++++ src/services/mlm/structures.api.ts | 82 ++++++++ 7 files changed, 829 insertions(+) create mode 100644 src/hooks/useMlm.ts create mode 100644 src/services/mlm/commissions.api.ts create mode 100644 src/services/mlm/index.ts create mode 100644 src/services/mlm/nodes.api.ts create mode 100644 src/services/mlm/ranks.api.ts create mode 100644 src/services/mlm/structures.api.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 25cf94f..e279389 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -15,3 +15,4 @@ export * from './useMfa'; export * from './useWhatsApp'; export * from './usePortfolio'; export * from './useGoals'; +export * from './useMlm'; diff --git a/src/hooks/useMlm.ts b/src/hooks/useMlm.ts new file mode 100644 index 0000000..179cf22 --- /dev/null +++ b/src/hooks/useMlm.ts @@ -0,0 +1,302 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + structuresApi, + ranksApi, + nodesApi, + commissionsApi, + type StructureFilters, + type CreateStructureDto, + type UpdateStructureDto, + type RankFilters, + type CreateRankDto, + type UpdateRankDto, + type NodeFilters, + type CreateNodeDto, + type UpdateNodeDto, + type NodeStatus, + type CommissionFilters, + type CalculateCommissionsDto, + type CommissionStatus, +} from '@/services/mlm'; + +// ============================================ +// Structures Hooks +// ============================================ + +export function useStructures(filters?: StructureFilters) { + return useQuery({ + queryKey: ['mlm', 'structures', filters], + queryFn: () => structuresApi.list(filters), + }); +} + +export function useStructure(id: string) { + return useQuery({ + queryKey: ['mlm', 'structures', id], + queryFn: () => structuresApi.get(id), + enabled: !!id, + }); +} + +export function useCreateStructure() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateStructureDto) => structuresApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'structures'] }); + }, + }); +} + +export function useUpdateStructure() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateStructureDto }) => + structuresApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'structures'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'structures', id] }); + }, + }); +} + +export function useDeleteStructure() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => structuresApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'structures'] }); + }, + }); +} + +// ============================================ +// Ranks Hooks +// ============================================ + +export function useRanks(filters?: RankFilters) { + return useQuery({ + queryKey: ['mlm', 'ranks', filters], + queryFn: () => ranksApi.list(filters), + }); +} + +export function useRank(id: string) { + return useQuery({ + queryKey: ['mlm', 'ranks', id], + queryFn: () => ranksApi.get(id), + enabled: !!id, + }); +} + +export function useCreateRank() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateRankDto) => ranksApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'ranks'] }); + }, + }); +} + +export function useUpdateRank() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateRankDto }) => + ranksApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'ranks'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'ranks', id] }); + }, + }); +} + +export function useDeleteRank() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => ranksApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'ranks'] }); + }, + }); +} + +export function useEvaluateRanks() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (structureId: string) => ranksApi.evaluate(structureId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'nodes'] }); + }, + }); +} + +// ============================================ +// Nodes Hooks +// ============================================ + +export function useNodes(filters?: NodeFilters) { + return useQuery({ + queryKey: ['mlm', 'nodes', filters], + queryFn: () => nodesApi.list(filters), + }); +} + +export function useNode(id: string) { + return useQuery({ + queryKey: ['mlm', 'nodes', id], + queryFn: () => nodesApi.get(id), + enabled: !!id, + }); +} + +export function useCreateNode() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateNodeDto) => nodesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'nodes'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'my'] }); + }, + }); +} + +export function useUpdateNode() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateNodeDto }) => + nodesApi.update(id, data), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'nodes'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'nodes', id] }); + }, + }); +} + +export function useUpdateNodeStatus() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, status }: { id: string; status: NodeStatus }) => + nodesApi.updateStatus(id, { status }), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'nodes'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'nodes', id] }); + }, + }); +} + +export function useNodeDownline(nodeId: string, maxDepth?: number) { + return useQuery({ + queryKey: ['mlm', 'nodes', nodeId, 'downline', maxDepth], + queryFn: () => nodesApi.getDownline(nodeId, maxDepth), + enabled: !!nodeId, + }); +} + +export function useNodeUpline(nodeId: string) { + return useQuery({ + queryKey: ['mlm', 'nodes', nodeId, 'upline'], + queryFn: () => nodesApi.getUpline(nodeId), + enabled: !!nodeId, + }); +} + +export function useNodeTree(nodeId: string, maxDepth?: number) { + return useQuery({ + queryKey: ['mlm', 'nodes', nodeId, 'tree', maxDepth], + queryFn: () => nodesApi.getTree(nodeId, maxDepth), + enabled: !!nodeId, + }); +} + +// ============================================ +// My Network Hooks +// ============================================ + +export function useMyDashboard() { + return useQuery({ + queryKey: ['mlm', 'my', 'dashboard'], + queryFn: () => nodesApi.getMyDashboard(), + }); +} + +export function useMyNetwork(maxDepth?: number) { + return useQuery({ + queryKey: ['mlm', 'my', 'network', maxDepth], + queryFn: () => nodesApi.getMyNetwork(maxDepth), + }); +} + +export function useMyEarnings() { + return useQuery({ + queryKey: ['mlm', 'my', 'earnings'], + queryFn: () => nodesApi.getMyEarnings(), + }); +} + +export function useMyRank() { + return useQuery({ + queryKey: ['mlm', 'my', 'rank'], + queryFn: () => nodesApi.getMyRank(), + }); +} + +export function useGenerateInviteLink() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => nodesApi.generateInviteLink(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'my'] }); + }, + }); +} + +// ============================================ +// Commissions Hooks +// ============================================ + +export function useMLMCommissions(filters?: CommissionFilters) { + return useQuery({ + queryKey: ['mlm', 'commissions', filters], + queryFn: () => commissionsApi.list(filters), + }); +} + +export function useMLMCommission(id: string) { + return useQuery({ + queryKey: ['mlm', 'commissions', id], + queryFn: () => commissionsApi.get(id), + enabled: !!id, + }); +} + +export function useCalculateCommissions() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CalculateCommissionsDto) => commissionsApi.calculate(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'commissions'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'nodes'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'my'] }); + }, + }); +} + +export function useUpdateCommissionStatus() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, status }: { id: string; status: CommissionStatus }) => + commissionsApi.updateStatus(id, { status }), + onSuccess: (_, { id }) => { + queryClient.invalidateQueries({ queryKey: ['mlm', 'commissions'] }); + queryClient.invalidateQueries({ queryKey: ['mlm', 'commissions', id] }); + }, + }); +} + +export function useCommissionsByLevel(nodeId?: string) { + return useQuery({ + queryKey: ['mlm', 'commissions', 'by-level', nodeId], + queryFn: () => commissionsApi.getByLevel(nodeId), + }); +} diff --git a/src/services/mlm/commissions.api.ts b/src/services/mlm/commissions.api.ts new file mode 100644 index 0000000..86d660d --- /dev/null +++ b/src/services/mlm/commissions.api.ts @@ -0,0 +1,138 @@ +import api from '../api'; + +// ───────────────────────────────────────────── +// Types +// ───────────────────────────────────────────── + +export type CommissionType = 'level' | 'matching' | 'infinity' | 'leadership' | 'pool'; +export type CommissionStatus = 'pending' | 'approved' | 'paid' | 'cancelled'; +export type BonusType = 'rank_achievement' | 'rank_maintenance' | 'fast_start' | 'pool_share'; + +export interface Commission { + id: string; + tenantId: string; + nodeId: string; + sourceNodeId: string; + type: CommissionType; + level: number; + sourceAmount: number; + rateApplied: number; + commissionAmount: number; + currency: string; + periodId: string | null; + sourceReference: string | null; + status: CommissionStatus; + paidAt: string | null; + notes: string | null; + createdAt: string; + node?: { + id: string; + userId: string; + user?: { + email: string; + firstName: string | null; + lastName: string | null; + }; + }; + sourceNode?: { + id: string; + userId: string; + user?: { + email: string; + firstName: string | null; + lastName: string | null; + }; + }; +} + +export interface CalculateCommissionsDto { + sourceNodeId: string; + amount: number; + currency?: string; + sourceReference?: string; + periodId?: string; +} + +export interface UpdateCommissionStatusDto { + status: CommissionStatus; +} + +export interface CommissionFilters { + nodeId?: string; + sourceNodeId?: string; + type?: CommissionType; + level?: number; + status?: CommissionStatus; + periodId?: string; + page?: number; + limit?: number; +} + +export interface PaginatedCommissions { + items: Commission[]; + total: number; +} + +export interface CommissionsByLevel { + level: number; + count: number; + totalAmount: number; +} + +export interface EarningsSummary { + totalCommissions: number; + totalBonuses: number; + totalEarnings: number; + pendingAmount: number; + paidAmount: number; + byLevel: CommissionsByLevel[]; +} + +export interface Bonus { + id: string; + tenantId: string; + nodeId: string; + rankId: string | null; + type: BonusType; + amount: number; + currency: string; + periodId: string | null; + status: CommissionStatus; + paidAt: string | null; + achievedAt: string; + notes: string | null; + createdAt: string; +} + +// ───────────────────────────────────────────── +// API Service +// ───────────────────────────────────────────── + +export const commissionsApi = { + list: async (params?: CommissionFilters): Promise => { + const response = await api.get('/mlm/commissions', { params }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/mlm/commissions/${id}`); + return response.data; + }, + + calculate: async (data: CalculateCommissionsDto): Promise => { + const response = await api.post('/mlm/commissions/calculate', data); + return response.data; + }, + + updateStatus: async (id: string, data: UpdateCommissionStatusDto): Promise => { + const response = await api.patch(`/mlm/commissions/${id}/status`, data); + return response.data; + }, + + getByLevel: async (nodeId?: string): Promise => { + const response = await api.get('/mlm/commissions/by-level', { + params: { nodeId }, + }); + return response.data; + }, +}; diff --git a/src/services/mlm/index.ts b/src/services/mlm/index.ts new file mode 100644 index 0000000..4708742 --- /dev/null +++ b/src/services/mlm/index.ts @@ -0,0 +1,4 @@ +export * from './structures.api'; +export * from './ranks.api'; +export * from './nodes.api'; +export * from './commissions.api'; diff --git a/src/services/mlm/nodes.api.ts b/src/services/mlm/nodes.api.ts new file mode 100644 index 0000000..1440db4 --- /dev/null +++ b/src/services/mlm/nodes.api.ts @@ -0,0 +1,208 @@ +import api from '../api'; + +// ───────────────────────────────────────────── +// Types +// ───────────────────────────────────────────── + +export type NodeStatus = 'pending' | 'active' | 'inactive' | 'suspended'; + +export interface Node { + id: string; + tenantId: string; + structureId: string; + userId: string; + parentId: string | null; + sponsorId: string | null; + position: number | null; + path: string | null; + depth: number; + rankId: string | null; + highestRankId: string | null; + personalVolume: number; + groupVolume: number; + directReferrals: number; + totalDownline: number; + totalEarnings: number; + status: NodeStatus; + joinedAt: string; + inviteCode: string | null; + createdAt: string; + updatedAt: string; + user?: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + }; + rank?: { + id: string; + name: string; + level: number; + color: string | null; + }; +} + +export interface CreateNodeDto { + structureId: string; + userId: string; + parentId?: string; + sponsorId?: string; + position?: number; +} + +export interface UpdateNodeDto { + parentId?: string; + position?: number; +} + +export interface UpdateNodeStatusDto { + status: NodeStatus; +} + +export interface NodeFilters { + structureId?: string; + parentId?: string; + sponsorId?: string; + status?: NodeStatus; + minDepth?: number; + maxDepth?: number; + search?: string; + page?: number; + limit?: number; +} + +export interface PaginatedNodes { + items: Node[]; + total: number; +} + +export interface TreeNode { + id: string; + userId: string; + depth: number; + position: number | null; + personalVolume: number; + groupVolume: number; + directReferrals: number; + status: NodeStatus; + user?: { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + }; + rank?: { + id: string; + name: string; + level: number; + color: string | null; + badgeUrl: string | null; + }; + children: TreeNode[]; +} + +export interface InviteLink { + inviteCode: string; + inviteUrl: string; +} + +export interface MyNetworkSummary { + totalDownline: number; + directReferrals: number; + activeDownline: number; + personalVolume: number; + groupVolume: number; + totalEarnings: number; + currentRank: { + id: string; + name: string; + level: number; + } | null; + nextRank: { + id: string; + name: string; + level: number; + requirements: Record; + progress: Record; + } | null; +} + +// ───────────────────────────────────────────── +// API Service +// ───────────────────────────────────────────── + +export const nodesApi = { + // Nodes CRUD + list: async (params?: NodeFilters): Promise => { + const response = await api.get('/mlm/nodes', { params }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/mlm/nodes/${id}`); + return response.data; + }, + + create: async (data: CreateNodeDto): Promise => { + const response = await api.post('/mlm/nodes', data); + return response.data; + }, + + update: async (id: string, data: UpdateNodeDto): Promise => { + const response = await api.patch(`/mlm/nodes/${id}`, data); + return response.data; + }, + + updateStatus: async (id: string, data: UpdateNodeStatusDto): Promise => { + const response = await api.patch(`/mlm/nodes/${id}/status`, data); + return response.data; + }, + + // Network navigation + getDownline: async (nodeId: string, maxDepth?: number): Promise => { + const response = await api.get(`/mlm/nodes/${nodeId}/downline`, { + params: { maxDepth }, + }); + return response.data; + }, + + getUpline: async (nodeId: string): Promise => { + const response = await api.get(`/mlm/nodes/${nodeId}/upline`); + return response.data; + }, + + getTree: async (nodeId: string, maxDepth?: number): Promise => { + const response = await api.get(`/mlm/nodes/${nodeId}/tree`, { + params: { maxDepth }, + }); + return response.data; + }, + + // My Network + getMyDashboard: async (): Promise => { + const response = await api.get('/mlm/my/dashboard'); + return response.data; + }, + + getMyNetwork: async (maxDepth?: number): Promise => { + const response = await api.get('/mlm/my/network', { + params: { maxDepth }, + }); + return response.data; + }, + + getMyEarnings: async () => { + const response = await api.get('/mlm/my/earnings'); + return response.data; + }, + + getMyRank: async () => { + const response = await api.get('/mlm/my/rank'); + return response.data; + }, + + generateInviteLink: async (): Promise => { + const response = await api.post('/mlm/my/invite'); + return response.data; + }, +}; diff --git a/src/services/mlm/ranks.api.ts b/src/services/mlm/ranks.api.ts new file mode 100644 index 0000000..2bb8f9c --- /dev/null +++ b/src/services/mlm/ranks.api.ts @@ -0,0 +1,94 @@ +import api from '../api'; + +// ───────────────────────────────────────────── +// Types +// ───────────────────────────────────────────── + +export interface RankRequirements { + personalVolume?: number; + groupVolume?: number; + directReferrals?: number; + activeLegs?: number; + rankInLegs?: { + rankLevel: number; + count: number; + }; +} + +export interface RankBenefits { + discount?: number; + access?: string[]; + features?: string[]; +} + +export interface Rank { + id: string; + tenantId: string; + structureId: string; + name: string; + level: number; + badgeUrl: string | null; + color: string | null; + requirements: RankRequirements; + bonusRate: number | null; + benefits: RankBenefits; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateRankDto { + structureId: string; + name: string; + level: number; + badgeUrl?: string; + color?: string; + requirements?: RankRequirements; + bonusRate?: number; + benefits?: RankBenefits; + isActive?: boolean; +} + +export type UpdateRankDto = Partial; + +export interface RankFilters { + structureId?: string; + isActive?: boolean; +} + +// ───────────────────────────────────────────── +// API Service +// ───────────────────────────────────────────── + +export const ranksApi = { + list: async (params?: RankFilters): Promise => { + const response = await api.get('/mlm/ranks', { params }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/mlm/ranks/${id}`); + return response.data; + }, + + create: async (data: CreateRankDto): Promise => { + const response = await api.post('/mlm/ranks', data); + return response.data; + }, + + update: async (id: string, data: UpdateRankDto): Promise => { + const response = await api.patch(`/mlm/ranks/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/mlm/ranks/${id}`); + }, + + evaluate: async (structureId: string): Promise => { + const response = await api.post('/mlm/ranks/evaluate', null, { + params: { structureId }, + }); + return response.data; + }, +}; diff --git a/src/services/mlm/structures.api.ts b/src/services/mlm/structures.api.ts new file mode 100644 index 0000000..867b61d --- /dev/null +++ b/src/services/mlm/structures.api.ts @@ -0,0 +1,82 @@ +import api from '../api'; + +// ───────────────────────────────────────────── +// Types +// ───────────────────────────────────────────── + +export type StructureType = 'unilevel' | 'binary' | 'matrix' | 'hybrid'; + +export interface LevelRate { + level: number; + rate: number; +} + +export interface StructureConfig { + maxWidth?: number | null; + maxDepth?: number; + spillover?: 'left_first' | 'weak_leg' | 'balanced'; + width?: number; + depth?: number; +} + +export interface Structure { + id: string; + tenantId: string; + name: string; + description: string | null; + type: StructureType; + config: StructureConfig; + levelRates: LevelRate[]; + matchingRates: LevelRate[]; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateStructureDto { + name: string; + description?: string; + type: StructureType; + config?: StructureConfig; + levelRates?: LevelRate[]; + matchingRates?: LevelRate[]; + isActive?: boolean; +} + +export type UpdateStructureDto = Partial; + +export interface StructureFilters { + type?: StructureType; + isActive?: boolean; + search?: string; +} + +// ───────────────────────────────────────────── +// API Service +// ───────────────────────────────────────────── + +export const structuresApi = { + list: async (params?: StructureFilters): Promise => { + const response = await api.get('/mlm/structures', { params }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/mlm/structures/${id}`); + return response.data; + }, + + create: async (data: CreateStructureDto): Promise => { + const response = await api.post('/mlm/structures', data); + return response.data; + }, + + update: async (id: string, data: UpdateStructureDto): Promise => { + const response = await api.patch(`/mlm/structures/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/mlm/structures/${id}`); + }, +};